/**
 * Unit tests for the MFA backup-code generator, canonicalisation, and the
 * atomic redemption helper. Together they cover the three guarantees that
 * make backup codes safe:
 *
 *   1. High-entropy, distinct plaintexts (generator).
 *   2. Canonical form is what gets hashed/compared — a user can paste the
 *      code with or without the dash, upper or lower case.
 *   3. Redemption deletes the row under a WHERE-guard so a concurrent
 *      second redemption fails (replay race).
 */

import { describe, expect, it, vi } from "vitest";
import {
  BACKUP_CODE_COUNT,
  generatePlaintextBackupCodes,
  hashBackupCode,
  normalizeBackupCode,
  verifyBackupCode,
} from "../lib/mfa-backup-codes.js";
import { redeemBackupCode } from "../lib/mfa-backup-code-redeem.js";

describe("generatePlaintextBackupCodes", () => {
  it("yields BACKUP_CODE_COUNT distinct codes by default", () => {
    const codes = generatePlaintextBackupCodes();
    expect(codes).toHaveLength(BACKUP_CODE_COUNT);
    expect(new Set(codes).size).toBe(BACKUP_CODE_COUNT);
  });

  it("formats each code as five chars, dash, five chars from the Crockford alphabet", () => {
    for (const code of generatePlaintextBackupCodes(20)) {
      expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
    }
  });
});

describe("normalizeBackupCode", () => {
  it("strips dashes and whitespace and uppercases", () => {
    expect(normalizeBackupCode("ab12c-xy34z")).toBe("AB12CXY34Z");
    expect(normalizeBackupCode("  AB12C XY34Z  ")).toBe("AB12CXY34Z");
    expect(normalizeBackupCode("ab12cxy34z")).toBe("AB12CXY34Z");
  });
});

describe("verifyBackupCode", () => {
  it("accepts the plaintext (with or without dash) that produced the hash", async () => {
    const hash = await hashBackupCode("ABCDE-FGHJK");
    expect(await verifyBackupCode(hash, "ABCDE-FGHJK")).toBe(true);
    expect(await verifyBackupCode(hash, "abcde-fghjk")).toBe(true);
    expect(await verifyBackupCode(hash, "ABCDEFGHJK")).toBe(true);
  });

  it("rejects a different plaintext", async () => {
    const hash = await hashBackupCode("ABCDE-FGHJK");
    expect(await verifyBackupCode(hash, "ZZZZZ-ZZZZZ")).toBe(false);
  });

  it("returns false rather than throwing on a malformed hash", async () => {
    expect(await verifyBackupCode("not-a-real-hash", "anything")).toBe(false);
  });
});

describe("redeemBackupCode", () => {
  it("accepts a valid code, deletes the row, and reports remaining count", async () => {
    const goodHash = await hashBackupCode("GOOD1-CODE1");
    const otherHash = await hashBackupCode("OTHER-CODE2");
    const db = {
      mfaBackupCode: {
        findMany: vi.fn().mockResolvedValue([
          { id: "a", codeHash: otherHash },
          { id: "b", codeHash: goodHash },
        ]),
        deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
        count: vi.fn().mockResolvedValue(1),
      },
    };
    const result = await redeemBackupCode(db, "user_1", "GOOD1-CODE1");
    expect(result).toEqual({ accepted: true, remaining: 1 });
    expect(db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({
      where: { id: "b", usedAt: null },
    });
  });

  it("rejects an unknown code without deleting anything", async () => {
    const db = {
      mfaBackupCode: {
        findMany: vi
          .fn()
          .mockResolvedValue([{ id: "a", codeHash: await hashBackupCode("REAL1-CODE1") }]),
        deleteMany: vi.fn(),
        count: vi.fn().mockResolvedValue(1),
      },
    };
    const result = await redeemBackupCode(db, "user_1", "WRONG-CODE");
    expect(result.accepted).toBe(false);
    expect(result.remaining).toBe(1);
    expect(db.mfaBackupCode.deleteMany).not.toHaveBeenCalled();
  });

  it("treats a racing delete (count=0) as an invalid code", async () => {
    // Simulates the case where another login request redeemed this exact
    // code a millisecond earlier. The SQL WHERE-guard (usedAt: null) stops
    // us from deleting it twice — we must treat that as a failed attempt
    // so the attacker cannot learn the code was valid.
    const goodHash = await hashBackupCode("RACE1-CODE1");
    const db = {
      mfaBackupCode: {
        findMany: vi.fn().mockResolvedValue([{ id: "a", codeHash: goodHash }]),
        deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
        count: vi.fn().mockResolvedValue(0),
      },
    };
    const result = await redeemBackupCode(db, "user_1", "RACE1-CODE1");
    expect(result.accepted).toBe(false);
  });

  it("returns accepted:false / remaining:0 when the user has no codes", async () => {
    const db = {
      mfaBackupCode: {
        findMany: vi.fn().mockResolvedValue([]),
        deleteMany: vi.fn(),
        count: vi.fn().mockResolvedValue(0),
      },
    };
    const result = await redeemBackupCode(db, "user_1", "ANY-CODE");
    expect(result).toEqual({ accepted: false, remaining: 0 });
  });
});
