import { describe, it, expect, beforeAll, afterAll } from "vitest";

process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";

const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
const { rows } = await import("./helpers/fakePool.js");
const { createAdminApp } = await import("./helpers/testApp.js");

let auth, app, pool;
beforeAll(async () => {
  auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
  ({ app, pool } = await createAdminApp());
});
afterAll(async () => { await app.close(); });

const HANDLERS = [
  { match: "count(*)::int AS total FROM bands", result: rows({ total: 1 }) },
  { match: "SELECT ma_id, name, country", result: rows({ ma_id: 1, name: "Mayhem" }) },
];

/** Renvoie le SQL et les paramètres de la requête de données. */
async function query(qs = "") {
  pool.reset(HANDLERS);
  const res = await app.inject({ method: "GET", url: `/admin/api/bands${qs}`, headers: auth });
  const data = pool.find("SELECT ma_id, name, country");
  const count = pool.find("count(*)::int AS total FROM bands");
  // La clause WHERE seule : la liste des colonnes du SELECT contient déjà
  // « country », « genre », « themes »… et fausserait les assertions d'absence.
  const where = (data.sql.split(" WHERE ")[1] || "").split("ORDER BY")[0];
  return { res, sql: data.sql, where, values: data.values,
           countSql: count.sql, countValues: count.values };
}

/**
 * Le filtrage de /admin/api/bands était le plus gros angle mort du projet :
 * chaque drapeau booléen y survivait à sept mutants. Or ce sont précisément
 * ceux que les filtres rapides du dashboard utilisent — ils pouvaient être
 * silencieusement inopérants.
 */
describe("filtres booléens — les deux polarités et l'absence", () => {
  const FLAGS = [
    ["enriched", "enriched = true", "enriched = false"],
    ["has_lat", "lat IS NOT NULL", "lat IS NULL"],
    ["has_location", "location_text IS NOT NULL AND location_text != ''", "(location_text IS NULL OR location_text = '')"],
  ];

  it.each(FLAGS)("%s=true produit la bonne clause", async (flag, truthy) => {
    expect((await query(`?${flag}=true`)).sql).toContain(truthy);
  });

  it.each(FLAGS)("%s=1 est équivalent à true", async (flag, truthy) => {
    expect((await query(`?${flag}=1`)).sql).toContain(truthy);
  });

  it.each(FLAGS)("%s=false produit la clause inverse", async (flag, truthy, falsy) => {
    const { sql } = await query(`?${flag}=false`);
    expect(sql).toContain(falsy);
    expect(sql).not.toContain(truthy);
  });

  it.each(FLAGS)("%s=0 est équivalent à false", async (flag, _t, falsy) => {
    expect((await query(`?${flag}=0`)).sql).toContain(falsy);
  });

  it.each(FLAGS)("%s absent n'ajoute aucune clause", async (flag, truthy, falsy) => {
    const { where } = await query("");
    expect(where).not.toContain(truthy);
    expect(where).not.toContain(falsy);
  });

  it.each(FLAGS)("%s avec une valeur inattendue est ignoré", async (flag, truthy, falsy) => {
    // « oui » n'est ni true/1 ni false/0 : aucune des deux clauses ne doit
    // apparaître, sinon on filtrerait à l'envers en silence.
    const { sql } = await query(`?${flag}=oui`);
    expect(sql).not.toContain(truthy);
    expect(sql).not.toContain(falsy);
  });

  it("has_conflict=true cible les champs en attente d'arbitrage", async () => {
    const { sql } = await query("?has_conflict=true");
    expect(sql).toContain("crawler_pending IS NOT NULL AND crawler_pending <> '{}'::jsonb");
  });

  it("has_conflict=1 est équivalent", async () => {
    expect((await query("?has_conflict=1")).sql).toContain("crawler_pending IS NOT NULL");
  });

  // Il n'existe volontairement pas de « has_conflict=false » : l'absence de
  // conflit est l'état normal, la filtrer n'a pas de sens.
  it("has_conflict=false n'ajoute rien", async () => {
    expect((await query("?has_conflict=false")).sql).not.toContain("crawler_pending");
  });
});

describe("filtres textuels", () => {
  const TEXT = [
    ["country", "country = $1", "FR", "FR"],
    ["status", "COALESCE(NULLIF(trim(status), ''), 'Unknown') = $1", "Active", "Active"],
    ["genre", "genre ILIKE $1", "black", "%black%"],
    ["location_q", "location_text ILIKE $1", "oslo", "%oslo%"],
    ["themes_q", "themes ILIKE $1", "satan", "%satan%"],
  ];

  it.each(TEXT)("%s produit sa clause et lie sa valeur", async (field, clause, input, bound) => {
    const { sql, values } = await query(`?${field}=${input}`);
    expect(sql).toContain(clause);
    expect(values[0]).toBe(bound);
  });

  it.each(TEXT)("%s absent n'ajoute aucune clause", async (field, clause) => {
    expect((await query("")).where).not.toContain(clause);
  });

  it("le pays est normalisé en majuscules", async () => {
    expect((await query("?country=fr")).values[0]).toBe("FR");
  });

  it.each(["location_q", "themes_q", "genre"])("%s tronque au-delà de 100 caractères", async (field) => {
    const { values } = await query(`?${field}=${"a".repeat(400)}`);
    // 100 caractères + les deux % du LIKE (genre/themes/location utilisent tous ILIKE)
    expect(String(values[0]).replace(/%/g, "")).toHaveLength(100);
  });

  it("plusieurs filtres se combinent avec AND et numérotent correctement", async () => {
    // La numérotation suit l'ordre du CODE (location_q, country, status, genre),
    // pas celui de la query string : c'est ce qui garantit que $n et vals[n-1]
    // restent alignés quelle que soit l'URL.
    const { sql, values } = await query("?country=NO&genre=black&location_q=oslo");
    expect(sql).toContain("location_text ILIKE $1");
    expect(sql).toContain("country = $2");
    expect(sql).toContain("genre ILIKE $3");
    expect(values).toEqual(["%oslo%", "NO", "%black%", 50, 0]);
  });
});

describe("recherche q — bornes", () => {
  it("un seul caractère est ignoré", async () => {
    expect((await query("?q=a")).sql).not.toContain("ILIKE");
  });

  // Borne exacte : le mutant `>= 2` -> `> 2` survivait faute de ce cas.
  it("exactement deux caractères déclenche la recherche", async () => {
    const { sql, values } = await query("?q=ab");
    expect(sql).toContain("(name ILIKE $1 OR genre ILIKE $1)");
    expect(values[0]).toBe("%ab%");
  });

  it("la recherche porte sur le nom ET le genre, avec le même paramètre", async () => {
    const { sql } = await query("?q=mayhem");
    expect(sql).toContain("name ILIKE $1 OR genre ILIKE $1");
  });

  it("les espaces autour sont retirés avant la mesure de longueur", async () => {
    expect((await query("?q=%20%20a%20%20")).sql).not.toContain("ILIKE");
  });
});

describe("tri", () => {
  const SORTABLE = ["ma_id", "name", "country", "status", "genre",
    "formed_year", "enriched", "crawled_at", "updated_at", "first_seen_at"];

  it.each(SORTABLE)("accepte la colonne %s", async (col) => {
    expect((await query(`?sort=${col}`)).sql).toContain(`ORDER BY ${col} ASC`);
  });

  it.each(["lat", "data", "password_hash", "ma_id; DROP TABLE bands"])(
    "refuse la colonne %s et retombe sur ma_id",
    async (col) => {
      const { sql } = await query(`?sort=${encodeURIComponent(col)}`);
      expect(sql).toContain("ORDER BY ma_id");
      expect(sql).not.toContain("DROP TABLE");
    }
  );

  it("dir=desc trie en décroissant, toute autre valeur donne croissant", async () => {
    expect((await query("?dir=desc")).sql).toContain("ORDER BY ma_id DESC");
    expect((await query("?dir=asc")).sql).toContain("ORDER BY ma_id ASC");
    expect((await query("?dir=nimportequoi")).sql).toContain("ORDER BY ma_id ASC");
  });

  it("les valeurs nulles finissent en dernier", async () => {
    expect((await query("?sort=formed_year")).sql).toContain("NULLS LAST");
  });
});

describe("pagination", () => {
  it("les paramètres LIMIT/OFFSET suivent les filtres", async () => {
    // Régression possible : le mutant `i + 1` -> `i - 1` sur l'index d'OFFSET
    // réutiliserait le paramètre du LIMIT.
    const { sql, values } = await query("?country=NO&page=3&pageSize=20");
    expect(sql).toContain("LIMIT $2 OFFSET $3");
    expect(values).toEqual(["NO", 20, 40]);
  });

  it("sans filtre, LIMIT et OFFSET sont les deux premiers paramètres", async () => {
    const { sql, values } = await query("?page=2&pageSize=10");
    expect(sql).toContain("LIMIT $1 OFFSET $2");
    expect(values).toEqual([10, 10]);
  });

  it("le count reçoit les filtres mais PAS la pagination", async () => {
    const { countValues } = await query("?country=NO&genre=black&page=5");
    expect(countValues).toEqual(["NO", "%black%"]);
  });

  it("le count sans filtre ne reçoit aucun paramètre", async () => {
    expect((await query("?page=2")).countValues).toEqual([]);
  });

  it("page et pageSize sont bornés", async () => {
    expect((await query("?page=0")).values).toEqual([50, 0]);
    expect((await query("?page=-3")).values).toEqual([50, 0]);
    expect((await query("?pageSize=99999")).values).toEqual([200, 0]);
    // `Number("0") || 50` vaut 50 : zéro étant falsy, il retombe sur le défaut
    // avant d'atteindre le Math.max(1, …). Comportement voulu (une page vide
    // n'a pas de sens), documenté ici pour éviter une « correction » hâtive.
    expect((await query("?pageSize=0")).values).toEqual([50, 0]);
  });

  it("une page non numérique retombe sur la première", async () => {
    expect((await query("?page=abc")).values).toEqual([50, 0]);
  });
});

describe("bornes d'identifiant", () => {
  // Le mutant `id < 0` -> `id <= 0` survivait : ma_id = 0 n'était jamais testé.
  it("ma_id = 0 est accepté", async () => {
    pool.reset([{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 0 }) }]);
    const res = await app.inject({ method: "GET", url: "/admin/api/bands/0", headers: auth });
    expect(res.statusCode).toBe(200);
    expect(pool.find("SELECT * FROM bands WHERE ma_id").values).toEqual([0]);
  });

  it("ma_id négatif est refusé", async () => {
    pool.reset([]);
    const res = await app.inject({ method: "GET", url: "/admin/api/bands/-1", headers: auth });
    expect(res.statusCode).toBe(400);
    expect(pool.calls).toHaveLength(0);
  });
});
