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

// Doit être défini avant l'import d'adminAuth (jwtSecret() lit process.env à l'appel,
// mais on fixe la valeur ici pour que tous les tests partagent le même secret).
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 sessionCookie, app, pool;

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

/** Reprogramme le pool partagé et renvoie l'app (construite une seule fois). */
function buildApp(handlers) {
  pool.reset(handlers ?? []);
  return app;
}

/** Requête authentifiée par défaut. */
function auth(headers = {}) {
  return { cookie: sessionCookie, ...headers };
}

describe("garde d'authentification", () => {
  it("refuse toute route admin sans cookie de session", async () => {
    const app = buildApp(([]));
    for (const url of ["/admin/api/stats", "/admin/api/bands", "/admin/api/live", "/admin/api/activity"]) {
      const res = await app.inject({ method: "GET", url });
      expect(res.statusCode, url).toBe(401);
    }
  });

  it("refuse un cookie de session forgé", async () => {
    const app = buildApp(([]));
    const res = await app.inject({
      method: "GET",
      url: "/admin/api/stats",
      headers: { cookie: `${ADMIN_COOKIE_NAME}=pas.un.jwt` },
    });
    expect(res.statusCode).toBe(401);
  });

  it("ne touche pas la base quand la session est invalide", async () => {
    const handlers = ([]);
    const app = buildApp(handlers);
    await app.inject({ method: "GET", url: "/admin/api/stats" });
    expect(pool.calls).toHaveLength(0);
  });

  it("accepte une session valide", async () => {
    const handlers = ([{ match: "FROM bands", result: rows({ total: 0 }) }]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth() });
    expect(res.statusCode).toBe(200);
  });
});

describe("GET /admin/api/bands — construction de la requête", () => {
  const listPool = () =>
    ([
      { match: "count(*)::int AS total", result: rows({ total: 3 }) },
      { match: "SELECT ma_id, name", result: rows({ ma_id: 1, name: "Mayhem" }) },
    ]);

  it("n'accepte que les colonnes de tri de l'allowlist", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "GET",
      url: "/admin/api/bands?sort=name;DROP TABLE bands--&dir=desc",
      headers: auth(),
    });
    const data = pool.find("SELECT ma_id, name");
    expect(data.sql).not.toContain("DROP TABLE");
    expect(data.sql).toContain("ORDER BY ma_id DESC");
  });

  it("accepte une colonne de tri légitime", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    await app.inject({ method: "GET", url: "/admin/api/bands?sort=formed_year", headers: auth() });
    expect(pool.find("SELECT ma_id, name").sql).toContain("ORDER BY formed_year ASC");
  });

  it("passe les filtres en paramètres liés, jamais en interpolation", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "GET",
      url: "/admin/api/bands?q=' OR 1=1--&country=fr",
      headers: auth(),
    });
    const data = pool.find("SELECT ma_id, name");
    expect(data.sql).not.toContain("OR 1=1");
    expect(data.values).toContain("%' OR 1=1--%");
    expect(data.values).toContain("FR"); // pays normalisé en majuscules
  });

  it("ignore une recherche de moins de 2 caractères", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    await app.inject({ method: "GET", url: "/admin/api/bands?q=a", headers: auth() });
    expect(pool.find("SELECT ma_id, name").sql).not.toContain("ILIKE");
  });

  it("le count et la page utilisent le même WHERE", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    await app.inject({ method: "GET", url: "/admin/api/bands?country=de&enriched=true", headers: auth() });
    const count = pool.find("count(*)::int AS total");
    const data = pool.find("SELECT ma_id, name");
    expect(count.sql).toContain("country = $1");
    expect(data.sql).toContain("country = $1");
    // le count ne doit pas recevoir les paramètres de pagination
    expect(count.values).toEqual(["DE"]);
    expect(data.values).toEqual(["DE", 50, 0]);
  });

  it("borne pageSize à 200", async () => {
    const handlers = listPool();
    const app = buildApp(handlers);
    const res = await app.inject({ method: "GET", url: "/admin/api/bands?pageSize=99999", headers: auth() });
    expect(res.json().pageSize).toBe(200);
  });
});

describe("PATCH /admin/api/bands/:ma_id", () => {
  const patchPool = () =>
    ([
      { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
      { match: "UPDATE bands SET", result: rows({ ma_id: 1, name: "Après" }) },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);

  it("refuse un ma_id non numérique", async () => {
    const app = buildApp(patchPool());
    const res = await app.inject({
      method: "PATCH",
      url: "/admin/api/bands/abc",
      headers: auth(),
      payload: { name: "x" },
    });
    expect(res.statusCode).toBe(400);
  });

  it("refuse un corps sans champ modifiable", async () => {
    const app = buildApp(patchPool());
    const res = await app.inject({
      method: "PATCH",
      url: "/admin/api/bands/1",
      headers: auth(),
      payload: { ma_id: 999, enriched: true, crawled_at: "2020-01-01" },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/Aucun champ/);
  });

  it("ignore les champs hors allowlist mais applique les autres", async () => {
    const handlers = patchPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "PATCH",
      url: "/admin/api/bands/1",
      headers: auth(),
      payload: { name: "Darkthrone", enriched: false, locked_fields: "{}" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("name = $2");
    expect(upd.sql).not.toContain("enriched =");
    expect(upd.sql).not.toContain("locked_fields = $");
  });

  it.each([
    ["formed_year", { formed_year: 1799 }],
    ["formed_year", { formed_year: 2101 }],
    ["lat", { lat: 91 }],
    ["lon", { lon: -181 }],
  ])("rejette %s hors bornes", async (_field, payload) => {
    const app = buildApp(patchPool());
    const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload });
    expect(res.statusCode).toBe(400);
  });

  it("verrouille les champs édités", async () => {
    const handlers = patchPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "PATCH",
      url: "/admin/api/bands/1",
      headers: auth(),
      payload: { name: "X", country: "NO" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("locked_fields = locked_fields ||");
    // Derniers paramètres : [ …valeurs, locked_fields JSON, clés à retirer ]
    expect(upd.values.at(-2)).toBe(JSON.stringify({ name: true, country: true }));
    expect(upd.values.at(-1)).toEqual(["name", "country"]);
  });

  it("écrit une entrée d'audit", async () => {
    const handlers = patchPool();
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } });
    const audit = pool.find("INSERT INTO admin_audit_log");
    expect(audit).toBeDefined();
    expect(audit.values[0]).toBe("nico"); // l'utilisateur de la session, pas une valeur du corps
  });

  it("404 si le groupe n'existe pas", async () => {
    const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth(), payload: { name: "X" } });
    expect(res.statusCode).toBe(404);
  });
});

describe("POST /admin/api/bands/:ma_id/resolve-conflict", () => {
  const conflictPool = () =>
    ([
      {
        match: "SELECT * FROM bands WHERE ma_id",
        result: rows({ ma_id: 1, crawler_pending: { name: "MA", formed_year: 1991 } }),
      },
      { match: "UPDATE bands SET", result: rows({ ma_id: 1 }) },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);

  // Régression sécurité : `field` était interpolé dans le SET sans allowlist,
  // ce qui donnait une injection SQL à tout admin authentifié.
  it("refuse un nom de champ hors allowlist", async () => {
    const handlers = conflictPool();
    const app = buildApp(handlers);
    const res = await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "name = 'pwned', enriched", action: "accept_crawler" },
    });
    expect(res.statusCode).toBe(400);
    expect(pool.find("UPDATE bands SET")).toBeUndefined();
  });

  it("refuse une action inconnue", async () => {
    const app = buildApp(conflictPool());
    const res = await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "name", action: "drop_everything" },
    });
    expect(res.statusCode).toBe(400);
  });

  it("keep_mine ne touche que crawler_pending", async () => {
    const handlers = conflictPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "name", action: "keep_mine" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("crawler_pending = crawler_pending - $2");
    expect(upd.sql).not.toContain("locked_fields");
  });

  it("accept_crawler applique la valeur et déverrouille", async () => {
    const handlers = conflictPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "name", action: "accept_crawler" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("name = $2::text");
    expect(upd.sql).toContain("locked_fields = locked_fields - $3");
    expect(upd.values[1]).toBe("MA");
  });

  // Régression : le cast `::text` en dur cassait les colonnes numériques.
  it("utilise un cast numérique pour formed_year", async () => {
    const handlers = conflictPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "formed_year", action: "accept_crawler" },
    });
    expect(pool.find("UPDATE bands SET").sql).toContain("formed_year = $2::int");
  });

  it("utilise un cast flottant pour lat/lon", async () => {
    const handlers = conflictPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/bands/1/resolve-conflict",
      headers: auth(),
      payload: { field: "lat", action: "accept_crawler" },
    });
    expect(pool.find("UPDATE bands SET").sql).toContain("lat = $2::double precision");
  });
});

describe("POST /admin/api/job-triggers", () => {
  const jobPool = () =>
    ([
      { match: "INSERT INTO job_triggers", result: rows({ id: 42 }) },
      { match: "INSERT INTO crawl_log", result: rows() },
    ]);

  it.each(["enrich", "incremental", "full_crawl", "geocoder_enqueue"])(
    "accepte le job_type %s",
    async (job_type) => {
      const app = buildApp(jobPool());
      const res = await app.inject({
        method: "POST",
        url: "/admin/api/job-triggers",
        headers: auth(),
        payload: { job_type },
      });
      expect(res.statusCode).toBe(200);
      expect(res.json().id).toBe(42);
    }
  );

  it.each([["inconnu"], [""], ["DROP TABLE bands"]])("refuse le job_type %s", async (job_type) => {
    const handlers = jobPool();
    const app = buildApp(handlers);
    const res = await app.inject({
      method: "POST",
      url: "/admin/api/job-triggers",
      headers: auth(),
      payload: { job_type },
    });
    expect(res.statusCode).toBe(400);
    expect(pool.find("INSERT INTO job_triggers")).toBeUndefined();
  });

  it("attribue le job à l'utilisateur de la session", async () => {
    const handlers = jobPool();
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/job-triggers",
      headers: auth(),
      payload: { job_type: "enrich", requested_by: "quelquun-dautre" },
    });
    expect(pool.find("INSERT INTO job_triggers").values).toEqual(["enrich", "nico"]);
  });
});

describe("annulations", () => {
  it("404 quand le run n'est plus en cours", async () => {
    const handlers = ([{ match: "UPDATE crawl_run", result: rows() }]);
    const app = buildApp(handlers);
    const res = await app.inject({
      method: "POST",
      url: "/admin/api/crawl-runs/7/cancel",
      headers: auth(),
      payload: {},
    });
    expect(res.statusCode).toBe(404);
  });

  it("cleanup borne older_than_minutes à 1 minimum", async () => {
    const handlers = ([
      { match: "UPDATE crawl_run", result: rows() },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);
    const app = buildApp(handlers);
    await app.inject({
      method: "POST",
      url: "/admin/api/crawl-runs/cleanup",
      headers: auth(),
      payload: { older_than_minutes: -999 },
    });
    expect(pool.find("UPDATE crawl_run").values).toEqual([1]);
  });
});
