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

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

const { rows } = await import("./helpers/fakePool.js");
const { createFullApp, createFullAppWith } = await import("./helpers/testApp.js");

const TOKEN = "c4b99106fcd09badce25810e974ec4d6";

// Deux apps pour tout le fichier (cf. helpers/testApp.js : construire une app
// coûte ~14 ms, multipliées par des milliers d'exécutions en tests de mutation).
// Chaque cas 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 }));
});
afterAll(async () => {
  await app.close();
  await tokenApp.close();
});

/** Handlers par défaut : chaque requête renvoie quelque chose de plausible. */
function defaultHandlers() {
  return [
    { match: "FROM band_locations bl", result: rows({ ma_id: 1, name: "Mayhem", lat: 59.9, lon: 10.7 }) },
    { match: "grid_cells", result: rows({ lat: 59.9, lon: 10.7, count: 3, sample_bands: [] }) },
    { match: "FROM bands", result: rows({ ma_id: 1, name: "Mayhem", total: 1 }) },
  ];
}

/**
 * Reprogramme le pool partagé et renvoie l'app publique.
 * @param {import("./helpers/fakePool.js").Handler[]} [handlers]
 */
function build(handlers = defaultHandlers()) {
  pool.reset(handlers);
  return app;
}

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

describe("/api/health et /", () => {
  it("health répond 200 sans toucher la base", async () => {
    const res = await build([]).inject({ method: "GET", url: "/api/health" });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ ok: true });
    expect(pool.calls).toHaveLength(0);
  });

  it("la racine identifie le service", async () => {
    expect((await build([]).inject({ method: "GET", url: "/" })).json().ok).toBe(true);
  });
});

describe("CORS", () => {
  it("renvoie l'en-tête pour une origine autorisée", async () => {
    const res = await build().inject({
      method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu" },
    });
    expect(res.headers["access-control-allow-origin"]).toBe("https://metalfrom.eu");
  });

  it("n'écho PAS une origine non autorisée", async () => {
    const res = await build().inject({
      method: "GET", url: "/api/health", headers: { origin: "https://evil.example" },
    });
    expect(res.headers["access-control-allow-origin"]).toBeUndefined();
  });

  it("ne fait pas de match par préfixe sur l'origine", async () => {
    const res = await build().inject({
      method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu.evil.example" },
    });
    expect(res.headers["access-control-allow-origin"]).toBeUndefined();
  });

  // Régression : sans `return reply`, Fastify poursuivait le cycle de vie et le
  // préflight repartait dans le handler de route (ou un 404).
  it("le préflight OPTIONS répond 204 et court-circuite le routage", async () => {
    const res = await build().inject({ method: "OPTIONS", url: "/api/bands" });
    expect(res.statusCode).toBe(204);
    expect(res.body).toBe("");
  });

  it("OPTIONS répond 204 même sur une route inexistante", async () => {
    expect((await build().inject({ method: "OPTIONS", url: "/nimporte/quoi" })).statusCode).toBe(204);
  });
});

describe("en-têtes de sécurité (helmet)", () => {
  it.each([
    "x-content-type-options",
    "x-frame-options",
    "strict-transport-security",
  ])("pose %s", async (header) => {
    const res = await build().inject({ method: "GET", url: "/api/health" });
    expect(res.headers[header]).toBeDefined();
  });

  it("ne divulgue pas la stack ni les identifiants en cas d'erreur serveur", async () => {
    const res = await build([
      { match: "FROM bands", throws: new Error("connexion perdue: host=10.0.1.7 user=bm") },
    ]).inject({ method: "GET", url: "/api/stats" });
    expect(res.statusCode).toBe(500);
    expect(res.body).not.toMatch(/10\.0\.1\.7|user=bm|at Object/);
  });
});

describe("/api/bands — validation", () => {
  it("répond 200 sans paramètre", async () => {
    const res = await build().inject({ method: "GET", url: "/api/bands" });
    expect(res.statusCode).toBe(200);
    expect(res.json().limit).toBe(1000);
  });

  // Régression : Number("abc") = NaN partait dans LIMIT $n → erreur pg → 500.
  it.each([
    ["limit=abc", "/api/bands?limit=abc"],
    ["limit=0", "/api/bands?limit=0"],
    ["limit=-5", "/api/bands?limit=-5"],
    ["offset=abc", "/api/bands?offset=abc"],
    ["offset=-1", "/api/bands?offset=-1"],
    ["offset>1M", "/api/bands?offset=1000001"],
  ])("répond 400 (pas 500) pour %s", async (_label, url) => {
    const res = await build().inject({ method: "GET", url });
    expect(res.statusCode).toBe(400);
    expect(res.json().ok).toBe(false);
  });

  it("plafonne le limit sans erreur", async () => {
    expect((await build().inject({ method: "GET", url: "/api/bands?limit=999999" })).json().limit).toBe(150000);
  });

  it("rejette une recherche d'un seul caractère", async () => {
    expect((await build().inject({ method: "GET", url: "/api/bands?q=a" })).statusCode).toBe(400);
  });

  it("rejette les patterns ILIKE pathologiques", async () => {
    expect((await build().inject({ method: "GET", url: "/api/bands?q=%25%25%25" })).statusCode).toBe(400);
  });

  it("rejette plus de 100 pays", async () => {
    const many = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
    const res = await build().inject({ method: "GET", url: `/api/bands?countries=${many}` });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/Max 100/);
  });

  it("rejette plus de 50 statuts", async () => {
    const many = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
    expect((await build().inject({ method: "GET", url: `/api/bands?status=${many}` })).statusCode).toBe(400);
  });

  it("passe la recherche en paramètre lié", async () => {
    await build().inject({
      method: "GET", url: "/api/bands?q=" + encodeURIComponent("'; DROP TABLE bands--"),
    });
    const call = pool.find("FROM bands");
    expect(call.sql).not.toContain("DROP TABLE");
    expect(call.values.some((v) => String(v).includes("DROP TABLE"))).toBe(true);
  });

  it("normalise les codes pays en majuscules", async () => {
    await build().inject({ method: "GET", url: "/api/bands?countries=fr,de" });
    expect(pool.find("FROM bands").values).toContainEqual(["FR", "DE"]);
  });
});

describe("/api/clusters — validation géographique", () => {
  it.each([
    ["bbox absente", "/api/clusters?zoom=5"],
    ["zoom absent", "/api/clusters?bbox=-5,40,10,55"],
    ["bbox à 3 valeurs", "/api/clusters?bbox=1,2,3&zoom=5"],
    ["bbox non numérique", "/api/clusters?bbox=a,b,c,d&zoom=5"],
    ["latitude > 90", "/api/clusters?bbox=-5,40,10,95&zoom=5"],
    ["longitude < -180", "/api/clusters?bbox=-181,40,10,55&zoom=5"],
    ["bbox inversée", "/api/clusters?bbox=10,40,-5,55&zoom=5"],
    ["zoom négatif", "/api/clusters?bbox=-5,40,10,55&zoom=-1"],
    ["zoom > 22", "/api/clusters?bbox=-5,40,10,55&zoom=23"],
  ])("répond 400 pour %s", async (_label, url) => {
    expect((await build().inject({ method: "GET", url })).statusCode).toBe(400);
  });

  it("accepte une bbox valide", async () => {
    const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
    expect(res.statusCode).toBe(200);
  });

  // Le seuil de zoom 12 fait basculer entre agrégation en grille et points bruts.
  it("agrège en clusters en dessous du zoom 12", async () => {
    const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" });
    expect(res.json().type).toBe("clusters");
    expect(pool.find("grid_cells")).toBeDefined();
  });

  it("renvoie les groupes bruts à partir du zoom 12", async () => {
    const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=12" });
    expect(res.json().type).toBe("bands");
    expect(pool.find("grid_cells")).toBeUndefined();
  });

  it("ne lit que les localisations réellement géocodées", async () => {
    await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
    const sql = pool.find("grid_cells").sql;
    expect(sql).toContain("bl.geom IS NOT NULL");
    expect(sql).toContain("geocode_status IN ('done','country_only')");
  });

  it("utilise l'index spatial via ST_MakeEnvelope avec des paramètres liés", async () => {
    await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
    const call = pool.find("grid_cells");
    expect(call.sql).toContain("bl.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)");
    expect(call.values.slice(0, 4)).toEqual([-5, 40, 10, 55]);
  });
});

describe("/api/band/:ma_id", () => {
  it("répond 400 pour un identifiant non numérique", async () => {
    expect((await build().inject({ method: "GET", url: "/api/band/abc" })).statusCode).toBe(400);
  });

  it("répond 404 quand le groupe n'existe pas", async () => {
    const res = await build([{ match: "FROM bands", result: rows() }])
      .inject({ method: "GET", url: "/api/band/42" });
    expect(res.statusCode).toBe(404);
  });

  it("accepte un ma_id au-delà de 2^31 (colonne BIGINT)", async () => {
    const res = await build([{ match: "FROM bands", result: rows({ ma_id: 3500000000 }) }])
      .inject({ method: "GET", url: "/api/band/3500000000" });
    expect(res.statusCode).toBe(200);
    expect(pool.find("FROM bands").values).toEqual([3500000000]);
  });
});

describe("/admin/import — jeton Bearer", () => {
  it.each([
    ["sans en-tête", undefined],
    ["jeton vide", "Bearer "],
    ["jeton faux de même longueur", `Bearer ${"0".repeat(TOKEN.length)}`],
    ["jeton faux plus court", "Bearer abc"],
    ["schéma Basic", "Basic " + Buffer.from("a:b").toString("base64")],
  ])("répond 401 %s", async (_label, authorization) => {
    const res = await buildToken().inject({
      method: "POST", url: "/admin/import",
      headers: authorization ? { authorization } : {},
      payload: { bands: [] },
    });
    expect(res.statusCode).toBe(401);
  });

  it("ne touche pas la base quand le jeton est invalide", async () => {
    await buildToken().inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: "Bearer faux" },
      payload: { bands: [{ ma_id: 1 }] },
    });
    expect(tokenPool.calls).toHaveLength(0);
  });

  it("accepte le bon jeton", async () => {
    const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: `Bearer ${TOKEN}` },
      payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json().upserted).toBe(1);
  });

  // Un jeton multi-octets de même longueur en caractères mais pas en octets
  // faisait lever timingSafeEqual → 500 au lieu d'un 401 propre.
  it("répond 401 (pas 500) pour un jeton multi-octets", async () => {
    const res = await buildToken().inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: `Bearer ${"é".repeat(TOKEN.length)}` },
      payload: { bands: [] },
    });
    expect(res.statusCode).toBe(401);
  });

  it("refuse un corps sans tableau bands", async () => {
    for (const payload of [{}, { bands: "pas-un-tableau" }, { bands: null }]) {
      const res = await buildToken().inject({
        method: "POST", url: "/admin/import",
        headers: { authorization: `Bearer ${TOKEN}` }, payload,
      });
      expect(res.statusCode).toBe(400);
    }
  });

  it("refuse plus de 1000 groupes par lot", async () => {
    const bands = Array.from({ length: 1001 }, (_, i) => ({ ma_id: i }));
    const res = await buildToken().inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands },
    });
    expect(res.statusCode).toBe(400);
  });

  it("ignore les entrées sans ma_id numérique sans planter", async () => {
    const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: `Bearer ${TOKEN}` },
      payload: { bands: [{ ma_id: "abc" }, null, "chaine", { ma_id: 7 }] },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json().upserted).toBe(1);
  });

  // Cas nécessitant une fabrique différente : app sans jeton configuré.
  it("quand aucun jeton n'est configuré, tout est refusé", async () => {
    const noTokenApp = await createFullAppWith({ importToken: "", pool: null });
    const res = await noTokenApp.inject({
      method: "POST", url: "/admin/import",
      headers: { authorization: "Bearer nimportequoi" }, payload: { bands: [] },
    });
    expect(res.statusCode).toBe(401);
    await noTokenApp.close();
  });
});

describe("/admin/auth/login", () => {
  it("refuse un corps mal formé sans requête SQL", async () => {
    const a = build([]);
    for (const payload of [{}, { username: "a" }, { username: 1, password: 2 }]) {
      expect((await a.inject({ method: "POST", url: "/admin/auth/login", payload })).statusCode).toBe(400);
    }
    expect(pool.calls).toHaveLength(0);
  });

  it("répond 429 quand le compte est verrouillé, sans vérifier le mot de passe", async () => {
    const res = await build([
      { match: "admin_login_attempts", result: rows({ n: 99 }) },
      { match: "FROM admin_users", result: rows({ password_hash: "x" }) },
    ]).inject({
      method: "POST", url: "/admin/auth/login",
      payload: { username: "nico", password: "x" },
    });
    expect(res.statusCode).toBe(429);
    expect(pool.find("FROM admin_users")).toBeUndefined();
  });

  it("enregistre la tentative échouée et ne pose pas de cookie", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    // Hash à coût 4 : le DUMMY_HASH de production est à coût 12 (~330 ms).
    const hash = bcrypt.hashSync("autre-chose", 4);
    const res = await build([
      { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
      { match: "FROM admin_users", result: rows({ password_hash: hash }) },
      { match: "INSERT INTO admin_login_attempts", result: rows() },
    ]).inject({
      method: "POST", url: "/admin/auth/login",
      payload: { username: "nico", password: "mauvais" },
    });
    expect(res.statusCode).toBe(401);
    expect(res.headers["set-cookie"]).toBeUndefined();
    expect(pool.find("INSERT INTO admin_login_attempts").values[2]).toBe(false);
  });

  it("le cookie de session est httpOnly, secure, sameSite=strict", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    const hash = bcrypt.hashSync("bon", 4);
    const res = await build([
      { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
      { match: "FROM admin_users", result: rows({ password_hash: hash }) },
      { match: "INSERT INTO admin_login_attempts", result: rows() },
      { match: "UPDATE admin_users", result: rows() },
    ]).inject({
      method: "POST", url: "/admin/auth/login",
      payload: { username: "nico", password: "bon" },
    });
    expect(res.statusCode).toBe(200);
    const cookie = String(res.headers["set-cookie"]);
    expect(cookie).toMatch(/HttpOnly/i);
    expect(cookie).toMatch(/Secure/i);
    expect(cookie).toMatch(/SameSite=Strict/i);
    // Le jeton ne doit jamais apparaître dans le corps de la réponse
    expect(res.json()).toEqual({ ok: true, username: "nico" });
  });

  it("tronque un username exagérément long avant la requête", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    const hash = bcrypt.hashSync("x", 4);
    await build([
      { match: "SELECT count(*)::int AS n", result: rows({ n: 0 }) },
      { match: "FROM admin_users", result: rows({ password_hash: hash }) },
      { match: "INSERT INTO admin_login_attempts", result: rows() },
    ]).inject({
      method: "POST", url: "/admin/auth/login",
      payload: { username: "a".repeat(5000), password: "x" },
    });
    expect(pool.find("FROM admin_users").values[0]).toHaveLength(100);
  });
});

describe("gestionnaire d'erreurs global", () => {
  // Régression : setErrorHandler écrasait TOUS les statuts en 500, y compris
  // les 4xx légitimes (payload trop gros, JSON invalide, rate-limit).
  it("préserve un 400 sur JSON invalide", async () => {
    const res = await build([]).inject({
      method: "POST", url: "/admin/auth/login",
      headers: { "content-type": "application/json" },
      payload: "{ceci n'est pas du json",
    });
    expect(res.statusCode).toBe(400);
  });

  it("renvoie 500 générique pour une erreur inattendue", async () => {
    const res = await build([{ match: "FROM bands", throws: new Error("boom") }])
      .inject({ method: "GET", url: "/api/stats" });
    expect(res.statusCode).toBe(500);
    expect(res.json().error).not.toContain("boom");
  });
});

describe("sans base de données configurée", () => {
  it("les routes de données répondent 500 mais le service reste debout", async () => {
    const noDbApp = await createFullAppWith({ pool: null });
    expect((await noDbApp.inject({ method: "GET", url: "/api/stats" })).statusCode).toBe(500);
    expect((await noDbApp.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
    await noDbApp.close();
  });
});
