import { describe, it, expect, vi, beforeEach } from "vitest";
import type { SanitizedConfig } from "payload";
import { PayloadDataDelete } from "@/payload-data-delete";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";

type MockPayload = {
  find: ReturnType<typeof vi.fn>;
  update: ReturnType<typeof vi.fn>;
  delete: ReturnType<typeof vi.fn>;
};

function makeMockPayload(): MockPayload {
  return {
    find: vi.fn(),
    update: vi.fn().mockResolvedValue({}),
    delete: vi.fn().mockResolvedValue({}),
  };
}

function makeMockConfig(
  collections: Array<{ slug: string; custom?: unknown }>,
): SanitizedConfig {
  return { collections } as unknown as SanitizedConfig;
}

describe("PayloadDataDelete", () => {
  let auditLog: RecordingAuditLog;
  let mockPayload: MockPayload;
  let mockGetPayload: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    auditLog = new RecordingAuditLog();
    mockPayload = makeMockPayload();
    mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
  });

  describe("soft mode", () => {
    it("happy path — self role: NULLs exportable PII fields and emits RESTRICT", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true }, name: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "alice", email: "a@ex.com", name: "Alice" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "soft");

      expect(mockPayload.update).toHaveBeenCalledWith({
        collection: "users",
        id: "alice",
        data: expect.objectContaining({ email: null, name: null }),
        overrideAccess: true,
      });

      expect(cert.subjectId).toBe("alice");
      expect(cert.mode).toBe("soft");
      expect(cert.affected).toHaveLength(1);
      expect(cert.affected[0]).toMatchObject({
        collection: "users",
        action: "redacted",
        fields: expect.arrayContaining(["email", "name"]),
      });
    });

    it("self role: sets processingRestrictedAt in the update call", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "alice", email: "a@ex.com" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "soft");

      expect(mockPayload.update).toHaveBeenCalledWith(
        expect.objectContaining({
          data: expect.objectContaining({
            processingRestrictedAt: expect.any(String),
          }),
        }),
      );
    });

    it("owner role: does NOT set processingRestrictedAt", async () => {
      const config = makeMockConfig([
        {
          slug: "orders",
          custom: {
            subject: { field: "userId", kind: "owner" },
            pii: { shippingAddress: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "soft");

      expect(mockPayload.update).toHaveBeenCalledWith({
        collection: "orders",
        id: "o-1",
        data: { shippingAddress: null },
        overrideAccess: true,
      });
    });

    it("happy path — owner role: NULLs exportable fields in owned rows", async () => {
      const config = makeMockConfig([
        {
          slug: "orders",
          custom: {
            subject: { field: "userId", kind: "owner" },
            pii: { shippingAddress: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "soft");

      expect(mockPayload.update).toHaveBeenCalledWith({
        collection: "orders",
        id: "o-1",
        data: { shippingAddress: null },
        overrideAccess: true,
      });
      expect(cert.affected[0]?.action).toBe("redacted");
    });

    it("happy path — reference role: NULLs only the linked field, preserves row", async () => {
      const config = makeMockConfig([
        {
          slug: "comments",
          custom: {
            subject: { field: "mentionedUser", kind: "reference" },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "c-1", mentionedUser: "alice", body: "hello alice" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "soft");

      // Only the linking field is NULLed — not the body
      expect(mockPayload.update).toHaveBeenCalledWith({
        collection: "comments",
        id: "c-1",
        data: { mentionedUser: null },
        overrideAccess: true,
      });
      // Row is NOT deleted
      expect(mockPayload.delete).not.toHaveBeenCalled();
    });

    it("multi-subject row: only the requesting subject's link is redacted", async () => {
      // Two rows in a reference collection: one for alice, one for bob
      const config = makeMockConfig([
        {
          slug: "comments",
          custom: {
            subject: { field: "mentionedUser", kind: "reference" },
          },
        },
      ]);

      // find returns only alice's row (Payload's where clause filters for alice)
      mockPayload.find.mockResolvedValue({
        docs: [{ id: "c-alice", mentionedUser: "alice", body: "hi alice" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "soft");

      // Only c-alice is updated
      expect(mockPayload.update).toHaveBeenCalledTimes(1);
      expect(mockPayload.update).toHaveBeenCalledWith(
        expect.objectContaining({ id: "c-alice" }),
      );
    });

    it("emits RESTRICT audit entries per affected collection", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true } },
          },
        },
        {
          slug: "orders",
          custom: {
            subject: { field: "userId", kind: "owner" },
            pii: { total: { exportable: true } },
          },
        },
      ]);

      mockPayload.find
        .mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
        .mockResolvedValueOnce({
          docs: [{ id: "o-1", userId: "alice", total: 50 }],
        });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "soft");

      const restrictEntries = auditLog.recorded.filter(
        (e) => e.action === "RESTRICT",
      );
      expect(restrictEntries).toHaveLength(2);
      expect(restrictEntries.map((e) => e.resource.type)).toEqual(
        expect.arrayContaining(["users", "orders"]),
      );
    });

    it("returns DeletionCertificate with correct shape", async () => {
      const config = makeMockConfig([{ slug: "posts" }]);
      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "soft");

      expect(cert.subjectId).toBe("alice");
      expect(cert.mode).toBe("soft");
      expect(cert.reason).toBe("art-17-request");
      expect(cert.auditEntryId).toBeTruthy();
      expect(typeof cert.timestamp).toBe("string");
    });
  });

  describe("cascade-hard mode", () => {
    it("happy path — self/owner: hard-deletes rows", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "alice", email: "a@ex.com" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "cascade-hard");

      expect(mockPayload.delete).toHaveBeenCalledWith({
        collection: "users",
        id: "alice",
        overrideAccess: true,
      });
      expect(mockPayload.update).not.toHaveBeenCalled();
      expect(cert.affected[0]?.action).toBe("deleted");
    });

    it("cascade-hard — reference: NULLs linked field (does not delete row)", async () => {
      const config = makeMockConfig([
        {
          slug: "comments",
          custom: { subject: { field: "mentionedUser", kind: "reference" } },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "c-1", mentionedUser: "alice" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "cascade-hard");

      expect(mockPayload.delete).not.toHaveBeenCalled();
      expect(mockPayload.update).toHaveBeenCalledWith(
        expect.objectContaining({ data: { mentionedUser: null } }),
      );
    });

    it("emits DELETE audit entries for self/owner collections", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true } },
          },
        },
      ]);

      mockPayload.find.mockResolvedValue({
        docs: [{ id: "alice" }],
      });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      await deleter.deleteSubjectData("alice", "cascade-hard");

      const deleteEntries = auditLog.recorded.filter(
        (e) => e.action === "DELETE",
      );
      expect(deleteEntries).toHaveLength(1);
      expect(deleteEntries[0]!.resource.type).toBe("users");
    });

    it("anonymises the subjectId in the certificate", async () => {
      const config = makeMockConfig([{ slug: "posts" }]);
      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "cascade-hard");

      expect(cert.subjectId).toMatch(/^erased-[0-9a-f]+$/);
      expect(cert.subjectId).not.toContain("alice");
    });

    it("correlationId is shared across all audit entries for one operation", async () => {
      const config = makeMockConfig([
        {
          slug: "users",
          custom: {
            subject: { field: "id", kind: "self" },
            pii: { email: { exportable: true } },
          },
        },
        {
          slug: "orders",
          custom: {
            subject: { field: "userId", kind: "owner" },
            pii: { total: { exportable: true } },
          },
        },
      ]);

      mockPayload.find
        .mockResolvedValueOnce({ docs: [{ id: "alice" }] })
        .mockResolvedValueOnce({ docs: [{ id: "o-1", userId: "alice" }] });

      const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
      const cert = await deleter.deleteSubjectData("alice", "cascade-hard");

      const ids = auditLog.recorded.map((e) => e.correlationId);
      expect(new Set(ids).size).toBe(1);
      expect(ids[0]).toBe(cert.auditEntryId);
    });
  });
});
