/**
 * Unit tests for the MFA error classes exported from auth.ts.
 *
 * These tests are a regression guard: they verify that the three
 * CredentialsSignin subclasses carry the correct `code` value so that
 * Auth.js v5 forwards them to the client via SignInResponse.code instead of
 * wrapping them as a generic CallbackRouteError.
 *
 * Testing the full authorize() call graph requires mocking the entire Next.js
 * runtime and is covered by E2E tests instead.
 */

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

// ── next-auth imports next/server without .js extension which fails in vitest
// node env. Mock the whole module so the error classes can be imported.
// Capture the config passed to NextAuth() so callbacks can be invoked.
const nextAuthCalls: Array<{
  callbacks?: {
    jwt?: (...args: unknown[]) => unknown;
    session?: (...args: unknown[]) => unknown;
  };
}> = [];
vi.mock("next-auth", () => {
  class CredentialsSignin extends Error {
    code = "credentials";
  }
  return {
    default: vi.fn(
      (cfg: {
        callbacks?: {
          jwt?: (...args: unknown[]) => unknown;
          session?: (...args: unknown[]) => unknown;
        };
      }) => {
        nextAuthCalls.push(cfg);
        return { handlers: {}, auth: vi.fn() };
      },
    ),
    CredentialsSignin,
  };
});

// ── All other side-effectful imports auth.ts pulls in ───────────────────────
vi.mock("./runtime-env.js", () => ({ assertSecureRuntimeEnv: vi.fn() }));

// Capture the config passed to Credentials() so we can call authorize().
const credentialsCalls: Array<{ authorize: (...args: unknown[]) => unknown }> = [];
vi.mock("next-auth/providers/credentials", () => ({
  default: vi.fn((cfg: { authorize: (...args: unknown[]) => unknown }) => {
    credentialsCalls.push(cfg);
    return cfg;
  }),
}));

const prismaMock = {
  user: { findUnique: vi.fn(), update: vi.fn() },
  systemSettings: { findUnique: vi.fn() },
  activeSession: { create: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn(), delete: vi.fn() },
};
vi.mock("@capakraken/db", () => ({ prisma: prismaMock }));
vi.mock("@capakraken/api/middleware/rate-limit", () => ({
  authRateLimiter: vi.fn().mockResolvedValue({ allowed: true }),
}));
vi.mock("@capakraken/api/lib/audit", () => ({ createAuditEntry: vi.fn() }));
vi.mock("@capakraken/api/lib/logger", () => ({
  logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const argonVerifyMock = vi.fn();
vi.mock("@node-rs/argon2", () => ({ verify: argonVerifyMock }));

// ── Import the exported error classes after mocks are in place ───────────────
const { MfaRequiredError, MfaRequiredSetupError, InvalidTotpError } = await import("./auth.js");

describe("MFA CredentialsSignin error classes — code property", () => {
  it("MfaRequiredError.code is 'MFA_REQUIRED'", () => {
    expect(new MfaRequiredError().code).toBe("MFA_REQUIRED");
  });

  it("MfaRequiredSetupError.code is 'MFA_REQUIRED_SETUP'", () => {
    expect(new MfaRequiredSetupError().code).toBe("MFA_REQUIRED_SETUP");
  });

  it("InvalidTotpError.code is 'INVALID_TOTP'", () => {
    expect(new InvalidTotpError().code).toBe("INVALID_TOTP");
  });

  it("all three extend the mocked CredentialsSignin base class", async () => {
    const { CredentialsSignin } = await import("next-auth");
    expect(new MfaRequiredError()).toBeInstanceOf(CredentialsSignin);
    expect(new MfaRequiredSetupError()).toBeInstanceOf(CredentialsSignin);
    expect(new InvalidTotpError()).toBeInstanceOf(CredentialsSignin);
  });

  it("error class names are descriptive (not generic 'Error')", () => {
    expect(new MfaRequiredError().constructor.name).toBe("MfaRequiredError");
    expect(new MfaRequiredSetupError().constructor.name).toBe("MfaRequiredSetupError");
    expect(new InvalidTotpError().constructor.name).toBe("InvalidTotpError");
  });
});

describe("session() — does not leak JTI to client", () => {
  const sessionCb = nextAuthCalls[0]?.callbacks?.session;
  if (!sessionCb) {
    it.skip("session callback not captured", () => {});
    return;
  }

  it("never assigns token.sid onto session.user.jti", async () => {
    const session = await sessionCb({
      session: { user: { email: "x@e.com" }, expires: "2030-01-01" },
      token: { sub: "u1", role: "USER", sid: "secret-session-id" },
    });
    const user = (session as { user: Record<string, unknown> }).user;
    expect(user["jti"]).toBeUndefined();
    expect(user["sid"]).toBeUndefined();
    expect(user["id"]).toBe("u1");
    expect(user["role"]).toBe("USER");
  });
});

describe("jwt() — concurrent-session enforcement is fail-closed", () => {
  const jwtCb = nextAuthCalls[0]?.callbacks?.jwt;
  if (!jwtCb) {
    it.skip("jwt callback not captured", () => {});
    return;
  }

  beforeEach(() => {
    prismaMock.systemSettings.findUnique.mockReset();
    prismaMock.activeSession.create.mockReset();
    prismaMock.activeSession.findMany.mockReset();
    prismaMock.activeSession.deleteMany.mockReset();
  });

  it("throws if activeSession.create fails", async () => {
    prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 });
    prismaMock.activeSession.create.mockRejectedValue(new Error("db down"));

    await expect(jwtCb({ token: {}, user: { id: "u1", role: "USER" } })).rejects.toThrow(
      /Session registration failed/,
    );
  });

  it("returns the token when session-registry writes succeed", async () => {
    prismaMock.systemSettings.findUnique.mockResolvedValue({ maxConcurrentSessions: 3 });
    prismaMock.activeSession.create.mockResolvedValue({});
    prismaMock.activeSession.findMany.mockResolvedValue([]);

    const result = (await jwtCb({ token: {}, user: { id: "u1", role: "USER" } })) as Record<
      string,
      unknown
    >;
    expect(result["role"]).toBe("USER");
    expect(typeof result["sid"]).toBe("string");
  });
});

describe("authorize() — login timing / enumeration defence", () => {
  const authorize = credentialsCalls[0]?.authorize;

  if (!authorize) {
    it.skip("authorize was not captured", () => {});
    return;
  }

  beforeEach(() => {
    argonVerifyMock.mockReset();
    prismaMock.user.findUnique.mockReset();
    prismaMock.user.update.mockReset();
    prismaMock.systemSettings.findUnique.mockReset();
  });

  it("runs argon2.verify against a dummy hash when the user is not found", async () => {
    prismaMock.user.findUnique.mockResolvedValue(null);
    argonVerifyMock.mockResolvedValue(false);

    const result = await authorize(
      { email: "nobody@example.com", password: "s3cret-password" },
      undefined,
    );

    expect(result).toBeNull();
    expect(argonVerifyMock).toHaveBeenCalledTimes(1);
    const [hashArg, passwordArg] = argonVerifyMock.mock.calls[0]!;
    expect(typeof hashArg).toBe("string");
    expect(hashArg).toMatch(/^\$argon2id\$/);
    expect(passwordArg).toBe("s3cret-password");
  });

  it("runs argon2.verify against a dummy hash when the account is deactivated", async () => {
    prismaMock.user.findUnique.mockResolvedValue({
      id: "u1",
      email: "x@example.com",
      isActive: false,
      passwordHash: "$argon2id$real$hash",
    });
    argonVerifyMock.mockResolvedValue(false);

    const result = await authorize({ email: "x@example.com", password: "wrong" }, undefined);

    expect(result).toBeNull();
    expect(argonVerifyMock).toHaveBeenCalledTimes(1);
    expect(argonVerifyMock.mock.calls[0]![0]).toMatch(/^\$argon2id\$/);
  });

  it("records a uniform 'Login failed' audit summary for every failure branch", async () => {
    const { createAuditEntry } = await import("@capakraken/api/lib/audit");
    const auditMock = createAuditEntry as unknown as ReturnType<typeof vi.fn>;
    auditMock.mockClear();

    // Branch 1: user not found
    prismaMock.user.findUnique.mockResolvedValueOnce(null);
    argonVerifyMock.mockResolvedValueOnce(false);
    await authorize({ email: "a@example.com", password: "p" }, undefined);

    // Branch 2: deactivated account
    prismaMock.user.findUnique.mockResolvedValueOnce({
      id: "u1",
      email: "b@example.com",
      isActive: false,
      passwordHash: "$argon2id$h",
    });
    argonVerifyMock.mockResolvedValueOnce(false);
    await authorize({ email: "b@example.com", password: "p" }, undefined);

    // Branch 3: wrong password
    prismaMock.user.findUnique.mockResolvedValueOnce({
      id: "u2",
      email: "c@example.com",
      isActive: true,
      passwordHash: "$argon2id$h",
    });
    argonVerifyMock.mockResolvedValueOnce(false);
    await authorize({ email: "c@example.com", password: "p" }, undefined);

    const summaries = auditMock.mock.calls.map(
      (call: unknown[]) => (call[0] as { summary: string }).summary,
    );
    expect(summaries).toEqual(["Login failed", "Login failed", "Login failed"]);
  });
});
