import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";

// pure.js est un script classique chargé par <script>, pas un module ES.
// On l'évalue exactement comme le fait le navigateur — ça teste au passage que
// le fichier livré expose bien la globale BMPure attendue par app.js.
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../site");
const source = fs.readFileSync(path.join(SITE, "pure.js"), "utf8");
const sandbox = { module: undefined };
vm.createContext(sandbox);
vm.runInContext(source, sandbox, { filename: "pure.js" });

const P = sandbox.BMPure;

describe("chargement du script", () => {
  it("expose la globale BMPure attendue par app.js", () => {
    expect(P).toBeDefined();
  });

  it("expose toutes les fonctions auxquelles app.js délègue", () => {
    const appJs = fs.readFileSync(path.join(SITE, "app.js"), "utf8");
    const used = [...appJs.matchAll(/BMPure\.(\w+)/g)].map((m) => m[1]);
    expect(used.length).toBeGreaterThan(0);
    for (const fn of new Set(used)) {
      expect(typeof P[fn], `BMPure.${fn} utilisé dans app.js`).toBe("function");
    }
  });
});

const band = (over = {}) => ({
  ma_id: 1, name: "Mayhem", genre: "Black Metal", status: "Active",
  country: "NO", location_text: "Oslo", themes: [], formed_year: 1984, ...over,
});

describe("escapeHtml", () => {
  it("neutralise les caractères d'injection HTML", () => {
    expect(P.escapeHtml('<script>alert("x")</script>'))
      .toBe("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
  });

  it("échappe l'apostrophe (attribut délimité par des quotes simples)", () => {
    expect(P.escapeHtml("l'enfer")).toBe("l&#039;enfer");
  });

  it("échappe l'esperluette en premier (pas de double échappement inversé)", () => {
    expect(P.escapeHtml("&lt;")).toBe("&amp;lt;");
  });

  it("null et undefined donnent une chaîne vide, pas 'null'", () => {
    expect(P.escapeHtml(null)).toBe("");
    expect(P.escapeHtml(undefined)).toBe("");
  });

  it("laisse le texte normal intact", () => {
    expect(P.escapeHtml("Mötley Crüe")).toBe("Mötley Crüe");
  });
});

describe("norm", () => {
  it.each([
    [null, ""], [undefined, ""], ["", ""], ["  Oslo  ", "Oslo"], [42, "42"],
  ])("norm(%s) === %s", (input, expected) => {
    expect(P.norm(input)).toBe(expected);
  });

  // 0 est une valeur légitime : ne doit pas être avalé comme falsy.
  it("conserve le zéro", () => {
    expect(P.norm(0)).toBe("0");
  });
});

describe("parseYear", () => {
  it("accepte un nombre", () => {
    expect(P.parseYear(1991)).toBe(1991);
  });

  it("extrait une année d'un texte libre", () => {
    expect(P.parseYear("formé en 1991")).toBe(1991);
    expect(P.parseYear("1984-1993")).toBe(1984);
  });

  it("ne reconnaît que les siècles plausibles", () => {
    expect(P.parseYear("an 1234")).toBeNull();
    expect(P.parseYear("2015")).toBe(2015);
  });

  it("renvoie null pour null/undefined", () => {
    expect(P.parseYear(null)).toBeNull();
    expect(P.parseYear(undefined)).toBeNull();
  });

  it("renvoie null pour un texte sans année", () => {
    expect(P.parseYear("inconnu")).toBeNull();
  });
});

describe("splitThemes", () => {
  it("découpe sur virgule, point-virgule et slash", () => {
    expect(P.splitThemes("Death, Satan; War/Occult"))
      .toEqual(["Death", "Satan", "War", "Occult"]);
  });

  it("accepte déjà un tableau", () => {
    expect(P.splitThemes(["  a ", "", "b"])).toEqual(["a", "b"]);
  });

  it("élimine les fragments vides", () => {
    expect(P.splitThemes("a,,;/b")).toEqual(["a", "b"]);
  });

  it("valeur vide → tableau vide", () => {
    expect(P.splitThemes("")).toEqual([]);
    expect(P.splitThemes(null)).toEqual([]);
  });
});

describe("matchesQuery", () => {
  it("une requête vide laisse tout passer", () => {
    expect(P.matchesQuery(band(), "")).toBe(true);
  });

  it.each([
    ["nom", "mayhem"], ["genre", "black"], ["statut", "active"],
    ["pays", "no"], ["lieu", "oslo"],
  ])("cherche dans le %s", (_champ, q) => {
    expect(P.matchesQuery(band(), q)).toBe(true);
  });

  it("cherche dans les thèmes", () => {
    expect(P.matchesQuery(band({ themes: ["Satanism", "Death"] }), "satan")).toBe(true);
  });

  it("ne matche pas ce qui est absent", () => {
    expect(P.matchesQuery(band(), "burzum")).toBe(false);
  });

  it("est insensible à la casse", () => {
    expect(P.matchesQuery(band({ name: "MAYHEM" }), "mayhem")).toBe(true);
  });

  it("ne plante pas sur des champs manquants", () => {
    expect(P.matchesQuery({ name: "X" }, "x")).toBe(true);
    expect(P.matchesQuery({}, "zzz")).toBe(false);
  });
});

describe("matchesFacet — logique tri-état", () => {
  it("une facette absente de la Map passe (facette inconnue ≠ décochée)", () => {
    expect(P.matchesFacet("Active", new Map(), "Unknown")).toBe(true);
  });

  it("une facette cochée passe", () => {
    expect(P.matchesFacet("Active", new Map([["Active", true]]), "Unknown")).toBe(true);
  });

  it("une facette décochée ne passe pas", () => {
    expect(P.matchesFacet("Active", new Map([["Active", false]]), "Unknown")).toBe(false);
  });

  it("une valeur vide retombe sur la clé de repli", () => {
    const map = new Map([["Unknown", false]]);
    expect(P.matchesFacet("", map, "Unknown")).toBe(false);
    expect(P.matchesFacet(null, map, "Unknown")).toBe(false);
    expect(P.matchesFacet("   ", map, "Unknown")).toBe(false);
  });

  it("la clé de repli cochée laisse passer les valeurs vides", () => {
    expect(P.matchesFacet(null, new Map([["Unknown", true]]), "Unknown")).toBe(true);
  });

  it("les espaces autour de la valeur sont ignorés", () => {
    expect(P.matchesFacet("  Active  ", new Map([["Active", false]]), "Unknown")).toBe(false);
  });
});

describe("matchesMacroGenre", () => {
  const MACROS = [
    { key: "Black", terms: ["black"] },
    { key: "Doom", terms: ["doom", "stoner"] },
  ];

  it("aucun macro-genre actif → tout passe", () => {
    expect(P.matchesMacroGenre(band(), MACROS, new Map(), 0)).toBe(true);
  });

  it("matche sur un terme du macro-genre actif", () => {
    const enabled = new Map([["Black", true]]);
    expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(true);
  });

  it("matche n'importe lequel des termes d'un macro-genre", () => {
    const enabled = new Map([["Doom", true]]);
    expect(P.matchesMacroGenre(band({ genre: "Stoner Rock" }), MACROS, enabled, 1)).toBe(true);
  });

  it("ne matche pas un macro-genre inactif", () => {
    const enabled = new Map([["Doom", true], ["Black", false]]);
    expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(false);
  });

  // Quand un filtre de genre est actif, un groupe sans genre ne peut pas y
  // répondre : il doit disparaître, pas passer par défaut.
  it("un groupe sans genre est exclu dès qu'un macro-genre est actif", () => {
    const enabled = new Map([["Black", true]]);
    expect(P.matchesMacroGenre(band({ genre: "" }), MACROS, enabled, 1)).toBe(false);
    expect(P.matchesMacroGenre(band({ genre: null }), MACROS, enabled, 1)).toBe(false);
  });

  it("est insensible à la casse du genre", () => {
    const enabled = new Map([["Black", true]]);
    expect(P.matchesMacroGenre(band({ genre: "BLACK METAL" }), MACROS, enabled, 1)).toBe(true);
  });
});

describe("matchesTheme", () => {
  it("aucun thème connu → tout passe", () => {
    expect(P.matchesTheme(band(), new Map())).toBe(true);
  });

  it("tous les thèmes cochés → tout passe, même sans thème", () => {
    const all = new Map([["War", true], ["Death", true]]);
    expect(P.matchesTheme(band({ themes: [] }), all)).toBe(true);
  });

  it("filtre actif : un groupe sans thème est exclu", () => {
    const partial = new Map([["War", true], ["Death", false]]);
    expect(P.matchesTheme(band({ themes: [] }), partial)).toBe(false);
  });

  it("filtre actif : garde les groupes ayant au moins un thème coché", () => {
    const partial = new Map([["War", true], ["Death", false]]);
    expect(P.matchesTheme(band({ themes: ["War"] }), partial)).toBe(true);
    expect(P.matchesTheme(band({ themes: ["Death"] }), partial)).toBe(false);
  });
});

describe("matchesYear", () => {
  const range = { min: 1970, max: 2020 };

  it("aucun filtre posé → tout passe", () => {
    expect(P.matchesYear(band(), { min: null, max: null }, range)).toBe(true);
  });

  it("plage non initialisée → tout passe", () => {
    expect(P.matchesYear(band(), { min: 1990, max: 2000 }, { min: null, max: null })).toBe(true);
  });

  // Plage complète = pas de filtrage : les groupes sans année restent visibles.
  it("plage complète → tout passe, y compris sans année", () => {
    expect(P.matchesYear(band({ formed_year: null }), { ...range }, range)).toBe(true);
  });

  it("plage resserrée : garde ce qui est dedans", () => {
    const f = { min: 1980, max: 1990 };
    expect(P.matchesYear(band({ formed_year: 1984 }), f, range)).toBe(true);
    expect(P.matchesYear(band({ formed_year: 1980 }), f, range)).toBe(true);
    expect(P.matchesYear(band({ formed_year: 1990 }), f, range)).toBe(true);
  });

  it("plage resserrée : exclut ce qui est dehors", () => {
    const f = { min: 1980, max: 1990 };
    expect(P.matchesYear(band({ formed_year: 1979 }), f, range)).toBe(false);
    expect(P.matchesYear(band({ formed_year: 1991 }), f, range)).toBe(false);
  });

  it("plage resserrée : exclut les groupes sans année", () => {
    const f = { min: 1980, max: 1990 };
    expect(P.matchesYear(band({ formed_year: null }), f, range)).toBe(false);
    expect(P.matchesYear(band({ formed_year: undefined }), f, range)).toBe(false);
  });
});

describe("keyFromLatLon", () => {
  it("produit la même clé pour des coordonnées identiques", () => {
    expect(P.keyFromLatLon(59.9139, 10.7522)).toBe(P.keyFromLatLon("59.9139", "10.7522"));
  });

  it("distingue des coordonnées différentes", () => {
    expect(P.keyFromLatLon(59.9139, 10.7522)).not.toBe(P.keyFromLatLon(59.9140, 10.7522));
  });

  it("normalise à 6 décimales", () => {
    expect(P.keyFromLatLon(1, 2)).toBe("1.000000,2.000000");
    expect(P.keyFromLatLon(1.00000049, 2)).toBe("1.000000,2.000000");
  });
});

describe("sortBands", () => {
  const bands = [
    band({ name: "Zyklon", genre: "Death", country: "NO", status: "Split-up", formed_year: 1998 }),
    band({ name: "abbath", genre: "Black", country: "DE", status: "Active", formed_year: 2005 }),
    band({ name: "Mayhem", genre: "Black", country: "NO", status: "Active", formed_year: 1984 }),
  ];

  it("ne modifie pas le tableau d'entrée", () => {
    const copy = [...bands];
    P.sortBands(bands, "az");
    expect(bands).toEqual(copy);
  });

  it("tri alphabétique insensible à la casse et aux accents", () => {
    expect(P.sortBands(bands, "az").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
  });

  it("mode inconnu retombe sur le tri alphabétique", () => {
    expect(P.sortBands(bands, "nimportequoi").map((b) => b.name))
      .toEqual(["abbath", "Mayhem", "Zyklon"]);
  });

  it("tri par année, décroissant", () => {
    expect(P.sortBands(bands, "year").map((b) => b.formed_year)).toEqual([2005, 1998, 1984]);
  });

  it("les groupes sans année finissent en dernier", () => {
    const withNull = [...bands, band({ name: "Sans", formed_year: null })];
    expect(P.sortBands(withNull, "year").at(-1).name).toBe("Sans");
  });

  it.each(["status", "genre", "country"])("tri par %s, départagé par le nom", (mode) => {
    const sorted = P.sortBands(bands, mode);
    for (let i = 1; i < sorted.length; i++) {
      const prev = (sorted[i - 1][mode] || "").toLowerCase();
      const cur = (sorted[i][mode] || "").toLowerCase();
      expect(prev <= cur).toBe(true);
    }
  });

  it("départage bien à valeur égale", () => {
    // abbath et Mayhem sont tous deux Black/Active → départagés par nom
    expect(P.sortBands(bands, "genre").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
  });

  it("liste vide", () => {
    expect(P.sortBands([], "az")).toEqual([]);
  });
});
