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 { createFullApp } = await import("./helpers/testApp.js");

const TOKEN_FOR_APP = "jeton-de-test";

// Deux apps seulement pour tout le fichier : une par jeu d'options de fabrique.
// Chaque test se contente de reprogrammer le pool associé.
let app, pool, tokenApp, tokenPool;

beforeAll(async () => {
  ({ app, pool } = await createFullApp());
  ({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN_FOR_APP }));
});
afterAll(async () => { await app.close(); await tokenApp.close(); });

/** Reprogramme le pool partagé et renvoie l'app publique. */
function build(handlers) {
  pool.reset(handlers ?? []);
  return app;
}

/** Idem pour l'app configurée avec un jeton d'import. */
function buildToken(handlers) {
  tokenPool.reset(handlers ?? []);
  return tokenApp;
}

const anyPool = (extra = []) =>
  [...extra, { match: /./, result: rows({ n: 1, total: 1, value: "x", count: 1 }) }];

describe("routes d'agrégation publiques", () => {
  const ROUTES = ["/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets"];

  it.each(ROUTES)("%s répond 200 avec ok:true", async (url) => {
    const app = build(anyPool());
    const res = await app.inject({ method: "GET", url });
    expect(res.statusCode).toBe(200);
    expect(res.json().ok).toBe(true);
  });

  it.each(ROUTES)("%s répond 500 sans divulguer l'erreur si la base tombe", async (url) => {
    const handlers = ([{ match: /./, throws: new Error("FATAL: role bm does not exist") }]);
    const app = build(handlers);
    const res = await app.inject({ method: "GET", url });
    expect(res.statusCode).toBe(500);
    expect(res.body).not.toMatch(/role bm|FATAL/);
  });

  it.each(ROUTES)("%s est accessible sans authentification (données publiques)", async (url) => {
    const app = build(anyPool());
    expect((await app.inject({ method: "GET", url })).statusCode).not.toBe(401);
  });
});

describe("/api/stats", () => {
  it("expose les compteurs attendus par le frontend", async () => {
    const handlers = ([
      { match: "FROM bands", result: rows({ total: 100, geocoded: 80, no_location: 20, enriched: 60 }) },
    ]);
    const app = build(handlers);
    const body = (await app.inject({ method: "GET", url: "/api/stats" })).json();
    expect(body).toMatchObject({ ok: true, total: 100, geocoded: 80, no_location: 20, enriched: 60 });
  });

  // geocoded compte les GROUPES ayant un point principal (bands.geom), pas les
  // localisations. La carte, elle, clusterise band_locations : les deux chiffres
  // n'ont pas la même unité et ne doivent pas être comparés naïvement.
  it("compte les groupes via bands.geom, pas les localisations", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/stats" });
    const sql = pool.find("FROM bands").sql;
    expect(sql).toContain("geom IS NOT NULL");
    expect(sql).not.toContain("band_locations");
  });
});

describe("/api/facets", () => {
  it("ne propose que des facettes portant sur des groupes géocodés", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/facets" });
    // Sinon l'utilisateur peut cocher une facette qui ne fera apparaître
    // aucun point sur la carte.
    for (const call of pool.calls) {
      expect(call.sql).toContain("geom IS NOT NULL");
    }
  });

  it("borne la liste des genres", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/facets" });
    expect(pool.find("genre as value").sql).toContain("LIMIT 500");
  });

  it("renvoie une plage d'années même quand la base est vide", async () => {
    const handlers = ([{ match: /./, result: rows() }]);
    const app = build(handlers);
    const body = (await app.inject({ method: "GET", url: "/api/facets" })).json();
    expect(body.year_range).toEqual({ min_year: null, max_year: null });
  });

  it("écarte les années aberrantes de la plage", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/facets" });
    const sql = pool.find("MIN(formed_year)").sql;
    expect(sql).toContain("formed_year >= 1900");
    expect(sql).toContain("extract(year from now())");
  });
});

describe("/api/bands — filtres", () => {
  it("filtre sur la présence de coordonnées", async () => {
    const mk = async (qs) => {
      const handlers = anyPool();
      const app = build(handlers);
      await app.inject({ method: "GET", url: `/api/bands${qs}` });
      return pool.find("FROM bands").sql;
    };
    expect(await mk("?geocoded=1")).toContain("geom IS NOT NULL");
    expect(await mk("?geocoded=0")).toContain("geom IS NULL");
    expect(await mk("")).not.toContain("geom IS");
  });

  it("only_black=1 filtre sur le genre", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/bands?only_black=1" });
    expect(pool.find("FROM bands").sql).toContain("genre ILIKE '%black%'");
  });

  it("combine plusieurs filtres avec AND", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/bands?countries=NO&geocoded=1&status=Active" });
    const sql = pool.find("FROM bands").sql;
    expect(sql).toContain(" AND ");
    expect(sql.match(/ AND /g).length).toBeGreaterThanOrEqual(2);
  });

  it("les statuts vides sont normalisés en 'Unknown'", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/bands?status=Unknown" });
    expect(pool.find("FROM bands").sql).toContain("COALESCE(NULLIF(trim(status), ''), 'Unknown')");
  });

  it("un tri stable est imposé (pagination cohérente)", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/bands" });
    // Sans ORDER BY déterministe, deux pages successives peuvent renvoyer
    // deux fois le même groupe et en omettre un autre.
    expect(pool.find("FROM bands").sql).toContain("ORDER BY ma_id ASC");
  });
});

describe("/api/clusters — filtres", () => {
  const base = "/api/clusters?bbox=-5,40,10,55&zoom=5";

  it("applique le filtre de genre en paramètre lié", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: `${base}&genre=black` });
    const call = pool.find("grid_cells");
    expect(call.sql).toContain("b.genre ILIKE");
    expect(call.values).toContain("%black%");
  });

  it("rejette un genre trop court ou pathologique", async () => {
    const app = build(anyPool());
    expect((await app.inject({ method: "GET", url: `${base}&genre=a` })).statusCode).toBe(400);
    expect((await app.inject({ method: "GET", url: `${base}&genre=%25%25%25` })).statusCode).toBe(400);
  });

  it.each([
    ["year_min=1799", 400], ["year_min=2101", 400], ["year_min=abc", 400],
    ["year_max=1799", 400], ["year_max=abc", 400],
    ["year_min=1980&year_max=1990", 200],
  ])("%s → %i", async (qs, expected) => {
    const app = build(anyPool());
    expect((await app.inject({ method: "GET", url: `${base}&${qs}` })).statusCode).toBe(expected);
  });

  it("refuse plus de 100 pays et plus de 50 statuts", async () => {
    const app = build(anyPool());
    const c = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
    const s = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
    expect((await app.inject({ method: "GET", url: `${base}&countries=${c}` })).statusCode).toBe(400);
    expect((await app.inject({ method: "GET", url: `${base}&status=${s}` })).statusCode).toBe(400);
  });

  it("la taille de cellule décroît quand le zoom augmente", async () => {
    const cell = async (zoom) => {
      const app = build(anyPool());
      const body = (await app.inject({ method: "GET", url: `/api/clusters?bbox=-5,40,10,55&zoom=${zoom}` })).json();
      return body.cell_size;
    };
    expect(await cell(3)).toBeGreaterThan(await cell(8));
  });

  it("la taille de cellule a un plancher (pas de division infinie)", async () => {
    const app = build(anyPool());
    const body = (await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" })).json();
    expect(body.cell_size).toBeGreaterThanOrEqual(0.01);
  });

  it("borne le nombre de résultats", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: base });
    expect(pool.find("grid_cells").sql).toContain("LIMIT 1000");
  });

  it("borne aussi le mode points bruts", async () => {
    const handlers = anyPool();
    const app = build(handlers);
    await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=14" });
    expect(pool.find("FROM band_locations bl").sql).toContain("LIMIT 2000");
  });
});

describe("/admin/enrich/next", () => {
  const TOKEN = TOKEN_FOR_APP;
  const withToken = { authorization: `Bearer ${TOKEN}` };

  it("exige le jeton Bearer", async () => {
    const handlers = ([]);
    const app = buildToken(handlers);
    expect((await app.inject({ method: "GET", url: "/admin/enrich/next" })).statusCode).toBe(401);
    expect(tokenPool.calls).toHaveLength(0);
  });

  it("ne renvoie que les groupes ayant une URL", async () => {
    const handlers = ([
      { match: "FROM bands", result: rows({ ma_id: 1, url: "http://x" }, { ma_id: 2, url: null }) },
    ]);
    const app = buildToken(handlers);
    const body = (await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken })).json();
    expect(body.count).toBe(1);
    expect(body.items).toEqual([{ ma_id: 1, url: "http://x" }]);
  });

  it("borne la limite entre 1 et 500", async () => {
    const lim = async (q) => {
      const handlers = ([{ match: "FROM bands", result: rows() }]);
      const app = buildToken(handlers);
      await app.inject({ method: "GET", url: `/admin/enrich/next?limit=${q}`, headers: withToken });
      return tokenPool.find("FROM bands").values.at(-1);
    };
    expect(await lim(9999)).toBe(500);
    expect(await lim(0)).toBe(1);
    expect(await lim(-5)).toBe(1);
    expect(await lim("abc")).toBe(50); // valeur par défaut, pas NaN
    expect(await lim(25)).toBe(25);
  });

  it("filtre par pays, normalisé en majuscules et lié", async () => {
    const handlers = ([{ match: "FROM bands", result: rows() }]);
    const app = buildToken(handlers);
    await app.inject({ method: "GET", url: "/admin/enrich/next?country=no", headers: withToken });
    const call = tokenPool.find("FROM bands");
    expect(call.sql).toContain("country = $1");
    expect(call.values[0]).toBe("NO");
  });

  it("ne sélectionne que les groupes jamais enrichis", async () => {
    const handlers = ([{ match: "FROM bands", result: rows() }]);
    const app = buildToken(handlers);
    await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken });
    expect(tokenPool.find("FROM bands").sql).toContain("(data->'band_page') IS NULL");
  });
});

describe("/admin/auth/me et logout", () => {
  it("me répond 401 sans cookie", async () => {
    const app = build(anyPool());
    expect((await app.inject({ method: "GET", url: "/admin/auth/me" })).statusCode).toBe(401);
  });

  it("me renvoie le username de la session", async () => {
    const app = build(anyPool());
    const res = await app.inject({
      method: "GET", url: "/admin/auth/me",
      headers: { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` },
    });
    expect(res.json()).toEqual({ ok: true, username: "nico" });
  });

  it("logout efface le cookie", async () => {
    const app = build(anyPool());
    const res = await app.inject({ method: "POST", url: "/admin/auth/logout" });
    expect(res.statusCode).toBe(200);
    const cookie = String(res.headers["set-cookie"]);
    expect(cookie).toContain(ADMIN_COOKIE_NAME);
    expect(cookie).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
  });

  it("logout ne nécessite pas de session valide (idempotent)", async () => {
    const app = build(anyPool());
    expect((await app.inject({ method: "POST", url: "/admin/auth/logout" })).statusCode).toBe(200);
  });
});

describe("/admin/import — upsert", () => {
  const TOKEN = TOKEN_FOR_APP;
  const withToken = { authorization: `Bearer ${TOKEN}` };

  const importPool = () => ([{ match: "INSERT INTO bands", result: rows() }]);

  it("préserve les valeurs existantes quand le champ est absent (COALESCE)", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
    });
    const sql = tokenPool.find("INSERT INTO bands").sql;
    expect(sql).toContain("ON CONFLICT (ma_id) DO UPDATE");
    expect(sql).toContain("COALESCE(EXCLUDED.name, bands.name)");
  });

  it("accepte `location` comme alias de `location_text`", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1, location: "Oslo" }] },
    });
    expect(tokenPool.find("INSERT INTO bands").values[5]).toBe("Oslo");
  });

  it("marque enriched quand un objet data est fourni", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1, data: { band_page: "..." } }] },
    });
    expect(tokenPool.find("INSERT INTO bands").values[7]).toBe(true);
  });

  it("un data non-objet n'active pas enriched", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1, data: "pas-un-objet" }] },
    });
    const vals = tokenPool.find("INSERT INTO bands").values;
    expect(vals[6]).toBeNull();
    expect(vals[7]).toBeNull();
  });

  it("tous les champs passent en paramètres liés", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1, name: "'; DROP TABLE bands--" }] },
    });
    const call = tokenPool.find("INSERT INTO bands");
    expect(call.sql).not.toContain("DROP TABLE");
    expect(call.values).toContain("'; DROP TABLE bands--");
  });

  it("compte exactement les lignes traitées", async () => {
    const handlers = importPool();
    const app = buildToken(handlers);
    const res = await app.inject({
      method: "POST", url: "/admin/import", headers: withToken,
      payload: { bands: [{ ma_id: 1 }, { ma_id: 2 }, { ma_id: 3 }] },
    });
    expect(res.json().upserted).toBe(3);
    expect(tokenPool.findAll("INSERT INTO bands")).toHaveLength(3);
  });

  it("accepte un lot de exactement 1000 groupes", async () => {
    const app = buildToken(importPool());
    const bands = Array.from({ length: 1000 }, (_, i) => ({ ma_id: i + 1 }));
    const res = await app.inject({
      method: "POST", url: "/admin/import", headers: withToken, payload: { bands },
    });
    expect(res.statusCode).toBe(200);
  });
});
