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(); });

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

/**
 * L'annulation d'un run est COOPÉRATIVE : l'API pose un drapeau, le crawler
 * l'observe et s'arrête. Ces tests verrouillent le contrat côté API — le
 * pendant côté crawler est dans apps/crawler/tests/test_cancellation.py.
 */
describe("POST /admin/api/crawl-runs/:id/cancel", () => {
  const runningPool = () =>
    ([
      { match: "SET cancel_requested = TRUE", result: rows({ id: 7, run_type: "full_europe" }) },
      { match: "INSERT INTO crawl_log", result: rows() },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);

  it("pose le drapeau sans toucher au statut", async () => {
    const handlers = runningPool();
    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(200);
    expect(res.json().cancel_requested).toBe(true);

    const upd = pool.find("UPDATE crawl_run");
    expect(upd.sql).toContain("cancel_requested = TRUE");
    // Le point central de la correction : l'API ne décide plus du statut.
    // C'est le crawler qui écrira 'cancelled' quand il se sera vraiment arrêté.
    // On n'inspecte que la clause SET — `status` a le droit d'apparaître dans
    // le WHERE (on ne cible que les runs en cours).
    const setClause = upd.sql.split(/\bWHERE\b/i)[0];
    expect(setClause).not.toMatch(/status\s*=/);
    expect(setClause).not.toContain("finished_at");
  });

  it("ne cible que les runs réellement en cours", async () => {
    const handlers = runningPool();
    const app = buildApp(handlers);
    await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
    expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
  });

  it("enregistre qui a demandé l'annulation", async () => {
    const handlers = runningPool();
    const app = buildApp(handlers);
    await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
    expect(pool.find("UPDATE crawl_run").values).toEqual([7, "nico"]);
    expect(pool.find("INSERT INTO admin_audit_log")).toBeDefined();
  });

  it("est idempotent : une seconde demande répond 200, pas une erreur", async () => {
    const handlers = ([
      { match: "SET cancel_requested = TRUE", result: rows() }, // 0 ligne : déjà demandé
      { match: "SELECT status, cancel_requested", result: rows({ status: "running", cancel_requested: true }) },
    ]);
    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(200);
    expect(res.json().already_requested).toBe(true);
  });

  it("répond 404 pour un run terminé", async () => {
    const handlers = ([
      { match: "SET cancel_requested = TRUE", result: rows() },
      { match: "SELECT status, cancel_requested", result: rows({ status: "done", cancel_requested: false }) },
    ]);
    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("exige une session admin", async () => {
    const handlers = ([]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", payload: {} });
    expect(res.statusCode).toBe(401);
    expect(pool.calls).toHaveLength(0);
  });
});

describe("POST /admin/api/job-triggers/:id/cancel", () => {
  it("retire un job encore en file", async () => {
    const handlers = ([
      { match: "UPDATE job_triggers", result: rows({ id: 3, job_type: "enrich" }) },
      { match: "INSERT INTO crawl_log", result: rows() },
    ]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
    expect(res.statusCode).toBe(200);
    expect(pool.find("UPDATE job_triggers").sql).toContain("status='pending'");
  });

  // Un job déjà réclamé n'existe plus qu'à travers son crawl_run : le message
  // doit y renvoyer explicitement au lieu d'un « non trouvé » trompeur.
  it("répond 409 avec un message actionnable si le job a déjà démarré", async () => {
    const handlers = ([
      { match: "UPDATE job_triggers", result: rows() },
      { match: "SELECT status FROM job_triggers", result: rows({ status: "running" }) },
    ]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/déjà démarré/);
    expect(res.json().error).toMatch(/run/i);
  });

  it("répond 409 « non trouvé » pour un job inexistant", async () => {
    const handlers = ([
      { match: "UPDATE job_triggers", result: rows() },
      { match: "SELECT status FROM job_triggers", result: rows() },
    ]);
    const app = buildApp(handlers);
    const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/99/cancel", headers: auth, payload: {} });
    expect(res.json().error).toMatch(/non trouvé/);
  });
});

/**
 * L'édition admin de lat/lon doit atteindre band_locations : c'est cette table
 * que la carte clusterise. Sans ça, corriger un point n'avait aucun effet visible.
 */
describe("PATCH /admin/api/bands/:ma_id — cohérence géographique", () => {
  const patchPool = (bandAfter) =>
    ([
      { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
      { match: "UPDATE bands SET", result: rows(bandAfter) },
      { match: "INSERT INTO band_locations", result: rows() },
      { match: "DELETE FROM band_locations", result: rows() },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);

  it("pose un override dans band_locations quand lat/lon sont fournis", async () => {
    const handlers = patchPool({ ma_id: 1, lat: 59.9, lon: 10.7, location_text: "Oslo" });
    const app = buildApp(handlers);
    await app.inject({
      method: "PATCH", url: "/admin/api/bands/1", headers: auth,
      payload: { lat: 59.9, lon: 10.7 },
    });

    const ins = pool.find("INSERT INTO band_locations");
    expect(ins).toBeDefined();
    expect(ins.values).toEqual([1, "Oslo", 59.9, 10.7]);
    // step_order = -1 → l'override passe avant tous les steps parsés et devient
    // le point principal repris par _sync_band_point côté geocoder.
    expect(ins.sql).toContain("-1");
    expect(ins.sql).toContain("'done'");
    expect(ins.sql).toContain("'admin'");
  });

  it("l'override est un upsert, pas un doublon", async () => {
    const handlers = patchPool({ ma_id: 1, lat: 1, lon: 2, location_text: "X" });
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: 1, lon: 2 } });
    expect(pool.find("INSERT INTO band_locations").sql).toContain("ON CONFLICT (ma_id, step_order, location_raw) DO UPDATE");
  });

  it("efface l'override quand lat/lon sont vidés", async () => {
    const handlers = patchPool({ ma_id: 1, lat: null, lon: null, location_text: "Oslo" });
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: null, lon: null } });

    const del = pool.find("DELETE FROM band_locations");
    expect(del).toBeDefined();
    expect(del.sql).toContain("step_order = -1");
    expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
  });

  it("ne touche pas band_locations quand l'édition ne concerne pas les coordonnées", async () => {
    const handlers = patchPool({ ma_id: 1, name: "Après" });
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });
    expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
    expect(pool.find("DELETE FROM band_locations")).toBeUndefined();
  });
});

describe("PATCH /admin/api/bands/:ma_id — transaction", () => {
  const okPool = () =>
    ([
      { 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("encadre l'écriture et l'audit dans une même transaction", async () => {
    const handlers = okPool();
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });

    expect(pool.sequence(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]))
      .toEqual(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]);
    expect(pool.rolledBack).toBe(false);
  });

  it("annule tout si l'écriture d'audit échoue", async () => {
    const handlers = ([
      { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
      { match: "UPDATE bands SET", result: rows({ ma_id: 1 }) },
      { match: "INSERT INTO admin_audit_log", throws: new Error("disque plein") },
    ]);
    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(500);
    expect(pool.rolledBack).toBe(true);
    expect(pool.committed).toBe(false);
  });

  it("libère la connexion même en cas d'erreur", async () => {
    const handlers = ([
      { match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
      { match: "UPDATE bands SET", throws: new Error("boom") },
    ]);
    const app = buildApp(handlers);
    await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
    expect(pool.released).toBe(pool.connections);
    expect(pool.released).toBeGreaterThan(0);
  });

  it("annule la transaction quand le groupe est introuvable", 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);
    expect(pool.rolledBack).toBe(true);
    expect(pool.released).toBe(1);
  });
});
