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

/** Un groupe dont le crawler propose d'autres valeurs pour trois champs. */
const WITH_CONFLICTS = {
  ma_id: 1, name: "Mayhem", genre: "Black Metal", country: "NO",
  crawler_pending: { name: "MAYHEM", genre: "True Black Metal", country: "SE" },
  locked_fields: { name: true },
};

function patchHandlers() {
  pool.reset([
    { match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
    { match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
    { match: "INSERT INTO admin_audit_log", result: rows() },
  ]);
  return app;
}

/**
 * `crawler_pending` contient les conflits NON RÉSOLUS. La convention est posée
 * par /resolve-conflict, qui retire la clé du champ arbitré
 * (`crawler_pending - $2`). Le PATCH doit suivre la même règle.
 */
describe("PATCH — effet sur crawler_pending", () => {
  it("éditer un champ retire SA clé des conflits", async () => {
    const a = patchHandlers();
    await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });

    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("crawler_pending = COALESCE(crawler_pending, '{}'::jsonb) - $");
    expect(upd.values.at(-1)).toEqual(["name"]);
  });

  // Régression : la reconstruction par jsonb_build_object() ne conservait que
  // les champs édités. Éditer le nom effaçait donc en silence les conflits de
  // genre et de pays, sans trace ni possibilité de les retrouver.
  it("éditer un champ NE DÉTRUIT PAS les conflits des autres champs", async () => {
    const a = patchHandlers();
    await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });

    const upd = pool.find("UPDATE bands SET");
    // L'opérateur de suppression ne cible que les clés listées ; tout le reste
    // de l'objet est conservé. Une reconstruction par jsonb_build_object, elle,
    // repartait d'un objet vide.
    expect(upd.sql).not.toContain("jsonb_build_object");
    expect(upd.values.at(-1)).not.toContain("genre");
    expect(upd.values.at(-1)).not.toContain("country");
  });

  it("éditer plusieurs champs retire toutes leurs clés d'un coup", async () => {
    const a = patchHandlers();
    await a.inject({
      method: "PATCH", url: "/admin/api/bands/1", headers: auth,
      payload: { name: "X", genre: "Y" },
    });
    expect(pool.find("UPDATE bands SET").values.at(-1)).toEqual(["name", "genre"]);
  });

  it("fonctionne quand crawler_pending est NULL", async () => {
    const a = patchHandlers();
    await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
    // Sans COALESCE, `NULL - ARRAY[...]` vaut NULL : inoffensif ici, mais on
    // garantit un objet vide plutôt qu'un NULL qui ferait diverger le filtre
    // has_conflict (`crawler_pending IS NOT NULL AND <> '{}'`).
    expect(pool.find("UPDATE bands SET").sql).toContain("COALESCE(crawler_pending, '{}'::jsonb)");
  });

  it("les champs édités sont verrouillés sans écraser les verrous existants", async () => {
    const a = patchHandlers();
    await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { genre: "Y" } });
    const upd = pool.find("UPDATE bands SET");
    // `||` fusionne : les verrous précédents (name) survivent.
    expect(upd.sql).toContain("locked_fields = locked_fields ||");
    expect(JSON.parse(upd.values.at(-2))).toEqual({ genre: true });
  });
});

describe("resolve-conflict — cohérence avec le PATCH", () => {
  const conflictHandlers = () => {
    pool.reset([
      { match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
      { match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
      { match: "INSERT INTO admin_audit_log", result: rows() },
    ]);
    return app;
  };

  it("keep_mine retire la clé sans toucher aux autres ni au verrou", async () => {
    const a = conflictHandlers();
    await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field: "genre", action: "keep_mine" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("crawler_pending = crawler_pending - $2");
    expect(upd.values).toEqual([1, "genre"]);
    // Garder sa propre valeur maintient le verrou : le crawler ne doit pas
    // revenir l'écraser au prochain passage.
    expect(upd.sql).not.toContain("locked_fields");
  });

  it("accept_crawler applique la valeur, déverrouille et retire la clé", async () => {
    const a = conflictHandlers();
    await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field: "genre", action: "accept_crawler" },
    });
    const upd = pool.find("UPDATE bands SET");
    expect(upd.sql).toContain("genre = $2::text");
    expect(upd.sql).toContain("locked_fields = locked_fields - $3");
    expect(upd.sql).toContain("crawler_pending = crawler_pending - $3");
    expect(upd.values[1]).toBe("True Black Metal");
  });

  it.each(["name", "country", "status", "genre", "themes", "location_text"])(
    "accepte le champ texte %s avec le bon cast",
    async (field) => {
      const a = conflictHandlers();
      const res = await a.inject({
        method: "POST", url: "/admin/api/bands/1/resolve-conflict",
        headers: auth, payload: { field, action: "accept_crawler" },
      });
      expect(res.statusCode).toBe(200);
      expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2::text`);
    }
  );

  it.each([
    ["formed_year", "::int"],
    ["lat", "::double precision"],
    ["lon", "::double precision"],
  ])("%s utilise un cast numérique", async (field, cast) => {
    const a = conflictHandlers();
    await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field, action: "accept_crawler" },
    });
    expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2${cast}`);
  });

  it.each([
    "enriched", "locked_fields", "crawler_pending", "data", "ma_id",
    "geom", "password_hash", "name = 'pwned'",
  ])("refuse le champ non modifiable %s", async (field) => {
    const a = conflictHandlers();
    const res = await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field, action: "accept_crawler" },
    });
    expect(res.statusCode).toBe(400);
    expect(pool.find("UPDATE bands SET")).toBeUndefined();
  });

  it("accepter une valeur absente des pending écrit NULL sans planter", async () => {
    const a = conflictHandlers();
    // 'themes' n'est pas dans crawler_pending du groupe de test.
    const res = await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field: "themes", action: "accept_crawler" },
    });
    expect(res.statusCode).toBe(200);
    expect(pool.find("UPDATE bands SET").values[1]).toBeUndefined();
  });

  it("404 quand le groupe n'existe pas", async () => {
    pool.reset([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
    const res = await app.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field: "name", action: "keep_mine" },
    });
    expect(res.statusCode).toBe(404);
  });

  it("trace l'arbitrage avec l'action choisie", async () => {
    const a = conflictHandlers();
    await a.inject({
      method: "POST", url: "/admin/api/bands/1/resolve-conflict",
      headers: auth, payload: { field: "genre", action: "keep_mine" },
    });
    expect(pool.find("INSERT INTO admin_audit_log").values[1]).toBe("resolve_conflict:keep_mine");
  });
});
