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

function buildApp(handlers) {
  pool.reset(handlers ?? []);
  return app;
}

const listHandlers = () => [
  { match: "SELECT bl.id, bl.ma_id", result: rows({ id: 9, ma_id: 1, band_name: "Mayhem", location_raw: "Oslo" }) },
  { match: "count(*)::int AS total", result: rows({ total: 1 }) },
];

/**
 * Ces routes existent pour éviter le seul recours disponible auparavant :
 * relancer des milliers d'appels Geoapify/Groq payants pour débloquer un lieu.
 */
describe("GET /admin/api/locations", () => {
  it("exige une session et ne touche pas la base sinon", async () => {
    const a = buildApp([]);
    expect((await a.inject({ method: "GET", url: "/admin/api/locations" })).statusCode).toBe(401);
    expect(pool.calls).toHaveLength(0);
  });

  it("joint le nom et le pays du groupe", async () => {
    const a = buildApp(listHandlers());
    const body = (await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth })).json();
    expect(body.items[0].band_name).toBe("Mayhem");
    expect(pool.find("SELECT bl.id").sql).toContain("JOIN bands b ON b.ma_id = bl.ma_id");
  });

  it("filtre sur une liste de statuts, en paramètre lié", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations?status=error,llm_needed", headers: auth });
    const call = pool.find("SELECT bl.id");
    expect(call.sql).toContain("bl.geocode_status = ANY($1::text[])");
    expect(call.values[0]).toEqual(["error", "llm_needed"]);
  });

  // Sans allowlist, une faute de frappe donnerait une liste vide sans expliquer
  // pourquoi — le pire cas pour une vue de diagnostic.
  it("rejette un statut inconnu au lieu de renvoyer une liste vide", async () => {
    const a = buildApp(listHandlers());
    const res = await a.inject({ method: "GET", url: "/admin/api/locations?status=erreur", headers: auth });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/statut inconnu/);
    expect(pool.find("SELECT bl.id")).toBeUndefined();
  });

  it.each(["queued", "processing", "done", "country_only", "error", "llm_needed", "manual"])(
    "accepte le statut %s",
    async (status) => {
      const a = buildApp(listHandlers());
      const res = await a.inject({ method: "GET", url: `/admin/api/locations?status=${status}`, headers: auth });
      expect(res.statusCode).toBe(200);
    }
  );

  it("cherche à la fois dans le lieu brut et le nom du groupe", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations?q=oslo", headers: auth });
    const call = pool.find("SELECT bl.id");
    expect(call.sql).toContain("bl.location_raw ILIKE $1 OR b.name ILIKE $1");
    expect(call.values[0]).toBe("%oslo%");
  });

  it("normalise le pays en majuscules", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations?country=no", headers: auth });
    expect(pool.find("SELECT bl.id").values[0]).toBe("NO");
  });

  it("le compte applique le même filtre que la page", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations?status=error", headers: auth });
    const count = pool.find("count(*)::int AS total");
    expect(count.sql).toContain("bl.geocode_status = ANY($1::text[])");
    expect(count.values).toEqual([["error"]]);
  });

  it("trie par date de mise à jour décroissante", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
    expect(pool.find("SELECT bl.id").sql).toContain("ORDER BY bl.updated_at DESC");
  });

  it("expose l'erreur et le nombre d'essais (le diagnostic tient dans la liste)", async () => {
    const a = buildApp(listHandlers());
    await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
    const sql = pool.find("SELECT bl.id").sql;
    for (const col of ["geocode_error", "geocode_tries_geo", "geocode_tries_llm", "geocode_query"]) {
      expect(sql).toContain(col);
    }
  });

  it("borne pageSize", async () => {
    const a = buildApp(listHandlers());
    const body = (await a.inject({ method: "GET", url: "/admin/api/locations?pageSize=9999", headers: auth })).json();
    expect(body.pageSize).toBe(200);
  });
});

describe("POST /admin/api/locations/:id/requeue", () => {
  const okHandlers = () => [
    { match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, location_raw: "Oslo" }) },
    { match: "INSERT INTO admin_audit_log", result: rows() },
  ];

  it("exige une session", async () => {
    const a = buildApp([]);
    expect((await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", payload: {} })).statusCode).toBe(401);
    expect(pool.calls).toHaveLength(0);
  });

  it("ne remet en file QUE la ligne visée", async () => {
    const a = buildApp(okHandlers());
    const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
    expect(res.statusCode).toBe(200);
    const upd = pool.find("UPDATE band_locations");
    expect(upd.sql).toContain("WHERE id = $1");
    expect(upd.values).toEqual([9]);
  });

  it("réinitialise les compteurs d'essais et l'erreur", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
    const sql = pool.find("UPDATE band_locations").sql;
    expect(sql).toContain("geocode_status='queued'");
    expect(sql).toContain("geocode_tries_geo=0");
    expect(sql).toContain("geocode_tries_llm=0");
    expect(sql).toContain("geocode_error=NULL");
  });

  it("trace l'action dans le journal d'audit", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
    const audit = pool.find("INSERT INTO admin_audit_log");
    expect(audit.values[0]).toBe("nico");
    expect(audit.values[1]).toBe("requeue_location");
  });

  it.each(["abc", "-1", "1.5"])("refuse l'identifiant %s", async (bad) => {
    const a = buildApp(okHandlers());
    const res = await a.inject({ method: "POST", url: `/admin/api/locations/${bad}/requeue`, headers: auth, payload: {} });
    expect(res.statusCode).toBe(400);
  });

  it("404 quand la localisation n'existe pas", async () => {
    const a = buildApp([{ match: "UPDATE band_locations", result: rows() }]);
    const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
    expect(res.statusCode).toBe(404);
  });
});

describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
  const okHandlers = () => [
    { match: "SELECT * FROM band_locations", result: rows({ id: 9, lat: null, lon: null }) },
    { match: "UPDATE band_locations", result: rows({ id: 9, lat: 59.9, lon: 10.7 }) },
    { match: "INSERT INTO admin_audit_log", result: rows() },
  ];

  it("exige une session", async () => {
    const a = buildApp([]);
    const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", payload: { lat: 1, lon: 2 } });
    expect(res.statusCode).toBe(401);
    expect(pool.calls).toHaveLength(0);
  });

  it("enregistre les coordonnées et marque le lieu résolu par admin", async () => {
    const a = buildApp(okHandlers());
    const res = await a.inject({
      method: "PATCH", url: "/admin/api/locations/9", headers: auth,
      payload: { lat: 59.9, lon: 10.7 },
    });
    expect(res.statusCode).toBe(200);
    const upd = pool.find("UPDATE band_locations");
    expect(upd.values).toEqual([9, 59.9, 10.7]);
    expect(upd.sql).toContain("geocode_status = 'done'");
    // 'admin' distingue une saisie manuelle d'un résultat automatique dans les
    // statistiques par provider.
    expect(upd.sql).toContain("geocode_provider = 'admin'");
    expect(upd.sql).toContain("geocode_error = NULL");
  });

  it.each([
    ["lat hors bornes", { lat: 91, lon: 0 }],
    ["lon hors bornes", { lat: 0, lon: 181 }],
    ["lat non numérique", { lat: "nord", lon: 0 }],
    ["lat absente", { lon: 10 }],
    ["lon absente", { lat: 59 }],
    ["corps vide", {}],
  ])("refuse %s", async (_label, payload) => {
    const a = buildApp(okHandlers());
    const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload });
    expect(res.statusCode).toBe(400);
    expect(pool.find("UPDATE band_locations")).toBeUndefined();
  });

  /**
   * Invariant à ne pas « corriger » : le statut doit rester 'done'.
   * 'manual' signifie « à traiter manuellement », pas « traité manuellement ».
   */
  it("le statut reste visible par la carte et hors de portée de reset-llm", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
    const sql = pool.find("UPDATE band_locations").sql;

    // /api/clusters ne retient que ('done','country_only') : avec 'manual', le
    // point saisi n'apparaîtrait jamais sur la carte.
    expect(sql).toContain("geocode_status = 'done'");
    // /locations/reset-llm remet en file ('llm_needed','manual') : avec
    // 'manual', ce bouton effacerait la saisie de l'admin.
    expect(sql).not.toContain("'manual'");
    // C'est le provider qui rend la saisie distinguable dans les statistiques.
    expect(sql).toContain("geocode_provider = 'admin'");
  });

  /**
   * bands.lat/lon est une dénormalisation du lieu d'origine (voir
   * sync_bands_primary dans geocoder/worker.py). Sans cette synchronisation, la
   * correction atteignait la carte — qui lit band_locations — mais jamais la
   * liste, les statistiques ni la heatmap, qui lisent bands.
   */
  it("propage la correction au point principal du groupe", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 59.9, lon: 10.7 } });

    const sync = pool.find("UPDATE bands b");
    expect(sync).toBeDefined();
    // Même règle de tri que le worker Python : le lieu d'ORIGINE gagne.
    expect(sync.sql).toContain("ORDER BY step_order ASC, id ASC");
    expect(sync.sql).toContain("geocode_status IN ('done', 'country_only')");
    expect(sync.sql).toContain("ST_SetSRID(ST_MakePoint(src.lon, src.lat), 4326)");
  });

  it("l'écriture et la synchronisation sont dans une même transaction", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
    expect(pool.sequence(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]))
      .toEqual(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]);
  });

  it("annule tout si la synchronisation échoue", async () => {
    const a = buildApp([
      { match: "SELECT * FROM band_locations", result: rows({ id: 9 }) },
      { match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, lat: 1, lon: 2 }) },
      { match: "UPDATE bands b", throws: new Error("deadlock") },
    ]);
    const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
    expect(res.statusCode).toBe(500);
    expect(pool.rolledBack).toBe(true);
    expect(pool.committed).toBe(false);
  });

  it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
    const a = buildApp(okHandlers());
    const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
    expect(res.statusCode).toBe(200);
    expect(pool.find("UPDATE band_locations").values).toEqual([9, 0, 0]);
  });

  it("404 quand la localisation n'existe pas", async () => {
    const a = buildApp([{ match: "SELECT * FROM band_locations", result: rows() }]);
    const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
    expect(res.statusCode).toBe(404);
  });

  it("journalise l'avant et l'après", async () => {
    const a = buildApp(okHandlers());
    await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
    const audit = pool.find("INSERT INTO admin_audit_log");
    expect(audit.values[1]).toBe("set_location_coords");
    expect(audit.values[4]).toBeTruthy();
    expect(audit.values[5]).toBeTruthy();
  });
});
