import { describe, expect, it, vi } from "vitest";
import { __test__, createAuditEntry } from "../lib/audit.js";

const { redactSensitive } = __test__;

describe("audit log redaction", () => {
  describe("redactSensitive", () => {
    it("redacts top-level password fields", () => {
      const result = redactSensitive({ userId: "u1", password: "hunter2" });
      expect(result).toEqual({ userId: "u1", password: "[REDACTED]" });
    });

    it("redacts nested password fields", () => {
      const result = redactSensitive({
        params: { userId: "u1", password: "hunter2" },
        executed: true,
      });
      expect(result).toEqual({
        params: { userId: "u1", password: "[REDACTED]" },
        executed: true,
      });
    });

    it("redacts password inside arrays", () => {
      const result = redactSensitive({
        users: [
          { id: "1", password: "secret" },
          { id: "2", password: "other" },
        ],
      });
      expect(result).toEqual({
        users: [
          { id: "1", password: "[REDACTED]" },
          { id: "2", password: "[REDACTED]" },
        ],
      });
    });

    it("is case-insensitive", () => {
      const result = redactSensitive({
        Password: "x",
        PASSWORD: "y",
        newPassword: "z",
        currentPassword: "a",
      });
      expect(result).toEqual({
        Password: "[REDACTED]",
        PASSWORD: "[REDACTED]",
        newPassword: "[REDACTED]",
        currentPassword: "[REDACTED]",
      });
    });

    it("redacts tokens, secrets, and cookies", () => {
      const result = redactSensitive({
        token: "t",
        accessToken: "a",
        refreshToken: "r",
        apiKey: "k",
        secret: "s",
        totpSecret: "ts",
        authorization: "Bearer x",
        cookie: "sid=abc",
      });
      for (const v of Object.values(result as Record<string, unknown>)) {
        expect(v).toBe("[REDACTED]");
      }
    });

    it("leaves non-sensitive fields untouched", () => {
      const result = redactSensitive({ name: "Alice", email: "a@b.c", count: 42, flag: true });
      expect(result).toEqual({ name: "Alice", email: "a@b.c", count: 42, flag: true });
    });

    it("handles null, undefined, and primitives", () => {
      expect(redactSensitive(null)).toBe(null);
      expect(redactSensitive(undefined)).toBe(undefined);
      expect(redactSensitive("string")).toBe("string");
      expect(redactSensitive(123)).toBe(123);
    });

    it("stops recursion at MAX_REDACT_DEPTH", () => {
      // Build a ~15-deep nested object; redaction should still work near the
      // top but bail past the depth limit without throwing.
      let v: Record<string, unknown> = { password: "leaf" };
      for (let i = 0; i < 15; i++) {
        v = { nested: v };
      }
      expect(() => redactSensitive(v)).not.toThrow();
    });
  });

  describe("createAuditEntry", () => {
    it("redacts passwords in `after` before the DB write", async () => {
      const create = vi.fn().mockResolvedValue({});
      const db = { auditLog: { create } };

      await createAuditEntry({
        db: db as never,
        entityType: "AiToolExecution",
        entityId: "call_1",
        action: "CREATE",
        after: { params: { userId: "u1", password: "cleartext" }, executed: true },
      });

      expect(create).toHaveBeenCalledTimes(1);
      const data = create.mock.calls[0]![0]!.data;
      const changes = data.changes as { after?: { params?: { password?: string } } };
      expect(changes.after?.params?.password).toBe("[REDACTED]");
      expect(changes.after?.params).toMatchObject({ userId: "u1" });
    });

    it("redacts passwords in before/after when non-sensitive fields also changed", async () => {
      const create = vi.fn().mockResolvedValue({});
      const db = { auditLog: { create } };

      await createAuditEntry({
        db: db as never,
        entityType: "User",
        entityId: "u1",
        action: "UPDATE",
        before: { password: "old", name: "Alice" },
        after: { password: "new", name: "Bob" },
      });

      expect(create).toHaveBeenCalledTimes(1);
      const changes = create.mock.calls[0]![0]!.data.changes as {
        before?: Record<string, unknown>;
        after?: Record<string, unknown>;
        diff?: Record<string, { old: unknown; new: unknown }>;
      };
      expect(changes.before?.["password"]).toBe("[REDACTED]");
      expect(changes.after?.["password"]).toBe("[REDACTED]");
      // The name change survives in the diff, but the password diff collapses
      // (both values are the same placeholder).
      expect(changes.diff).toEqual({ name: { old: "Alice", new: "Bob" } });
    });

    it("skips UPDATE when both snapshots redact to the same value (empty diff)", async () => {
      const create = vi.fn().mockResolvedValue({});
      const db = { auditLog: { create } };

      await createAuditEntry({
        db: db as never,
        entityType: "User",
        entityId: "u1",
        action: "UPDATE",
        before: { password: "old" },
        after: { password: "new" },
      });

      // Both redact to [REDACTED], diff is empty, create should NOT be called.
      expect(create).not.toHaveBeenCalled();
    });

    it("redacts sensitive fields in metadata", async () => {
      const create = vi.fn().mockResolvedValue({});
      const db = { auditLog: { create } };

      await createAuditEntry({
        db: db as never,
        entityType: "Webhook",
        entityId: "wh_1",
        action: "CREATE",
        after: { url: "https://example.com/hook" },
        metadata: { signingSecret: "ss", apiKey: "leak" },
      });

      const changes = create.mock.calls[0]![0]!.data.changes as {
        metadata?: Record<string, unknown>;
      };
      expect(changes.metadata?.["apiKey"]).toBe("[REDACTED]");
      // signingSecret is not in the set — verify the list is intentional
      expect(changes.metadata?.["signingSecret"]).toBe("ss");
    });
  });
});
