import { describe, it, expect, vi } from "vitest";

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

const {
  signAdminSession,
  verifyAdminSession,
  isLockedOut,
  verifyPassword,
  requireAdminSession,
  ADMIN_COOKIE_NAME,
} = await import("../src/adminAuth.js");
const { makeFakePool, rows } = await import("./helpers/fakePool.js");

describe("sessions JWT", () => {
  it("aller-retour signature / vérification", () => {
    expect(verifyAdminSession(signAdminSession("nico"))).toBe("nico");
  });

  it("rejette un jeton signé avec un autre secret", async () => {
    const jwt = (await import("jsonwebtoken")).default;
    const forged = jwt.sign({ sub: "nico" }, "un-autre-secret-de-32-caracteres-ok");
    expect(verifyAdminSession(forged)).toBeNull();
  });

  it("rejette un jeton `alg: none` (attaque classique)", async () => {
    const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
    const payload = Buffer.from(JSON.stringify({ sub: "nico" })).toString("base64url");
    expect(verifyAdminSession(`${header}.${payload}.`)).toBeNull();
  });

  it("rejette un jeton expiré", async () => {
    const jwt = (await import("jsonwebtoken")).default;
    const expired = jwt.sign({ sub: "nico" }, process.env.ADMIN_JWT_SECRET, { expiresIn: -10 });
    expect(verifyAdminSession(expired)).toBeNull();
  });

  it("rejette un jeton sans sub exploitable", async () => {
    const jwt = (await import("jsonwebtoken")).default;
    const noSub = jwt.sign({ sub: { admin: true } }, process.env.ADMIN_JWT_SECRET);
    expect(verifyAdminSession(noSub)).toBeNull();
  });

  it("rejette une chaîne quelconque sans lever d'exception", () => {
    expect(verifyAdminSession("")).toBeNull();
    expect(verifyAdminSession("aaa.bbb.ccc")).toBeNull();
  });
});

describe("requireAdminSession", () => {
  function fakeReply() {
    const reply = {
      statusCode: null,
      body: null,
      code(c) { reply.statusCode = c; return reply; },
      send(b) { reply.body = b; return reply; },
    };
    return reply;
  }

  it("renvoie le username et ne répond pas quand le cookie est valide", () => {
    const reply = fakeReply();
    const req = { cookies: { [ADMIN_COOKIE_NAME]: signAdminSession("nico") } };
    expect(requireAdminSession(req, reply)).toBe("nico");
    expect(reply.statusCode).toBeNull();
  });

  it("répond 401 et renvoie null sans cookie", () => {
    const reply = fakeReply();
    expect(requireAdminSession({ cookies: {} }, reply)).toBeNull();
    expect(reply.statusCode).toBe(401);
  });

  it("ne plante pas quand `cookies` est absent", () => {
    const reply = fakeReply();
    expect(requireAdminSession({}, reply)).toBeNull();
    expect(reply.statusCode).toBe(401);
  });
});

describe("verifyPassword", () => {
  it("renvoie false pour un utilisateur inexistant", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    // Le DUMMY_HASH de production est à coût 12 (~330 ms par comparaison).
    // On neutralise le calcul : ce test porte sur la valeur de retour, et le
    // test suivant vérifie séparément que la comparaison a bien lieu.
    const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(true);
    const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
    // Même si bcrypt dit « vrai », l'absence d'utilisateur doit l'emporter.
    expect(await verifyPassword(pool, "inconnu", "peu-importe")).toBe(false);
    spy.mockRestore();
  });

  // Anti-énumération : le hash factice doit être comparé même quand l'utilisateur
  // n'existe pas, sinon le temps de réponse révèle les comptes valides.
  it("compare quand même un hash pour un utilisateur inexistant", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    const spy = vi.spyOn(bcrypt, "compare").mockResolvedValue(false);
    const pool = makeFakePool([{ match: "FROM admin_users", result: rows() }]);
    await verifyPassword(pool, "inconnu", "x");
    expect(spy).toHaveBeenCalled();
    // La comparaison doit porter sur un hash bcrypt valide, sinon le temps de
    // réponse trahirait l'absence de compte.
    expect(spy.mock.calls[0][1]).toMatch(/^\$2[aby]\$\d\d\$/);
    spy.mockRestore();
  });

  it("renvoie true pour le bon mot de passe", async () => {
    const bcrypt = (await import("bcryptjs")).default;
    const hash = bcrypt.hashSync("bon-mot-de-passe", 4); // coût faible : test rapide
    const pool = makeFakePool([{ match: "FROM admin_users", result: rows({ password_hash: hash }) }]);
    expect(await verifyPassword(pool, "nico", "bon-mot-de-passe")).toBe(true);
    expect(await verifyPassword(pool, "nico", "mauvais")).toBe(false);
  });
});

describe("isLockedOut", () => {
  it("verrouille à partir de 5 tentatives échouées", async () => {
    const under = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 4 }) }]);
    const at = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 5 }) }]);
    expect(await isLockedOut(under, "nico", "1.2.3.4")).toBe(false);
    expect(await isLockedOut(at, "nico", "1.2.3.4")).toBe(true);
  });

  it("compte les tentatives par username OU par IP, sur 15 minutes", async () => {
    const pool = makeFakePool([{ match: "admin_login_attempts", result: rows({ n: 0 }) }]);
    await isLockedOut(pool, "nico", "1.2.3.4");
    const call = pool.find("admin_login_attempts");
    expect(call.sql).toMatch(/username = \$2 OR ip = \$3/);
    expect(call.values).toEqual([15, "nico", "1.2.3.4"]);
  });
});
