import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SupportService } from '../support/support.service';
import { MessageService } from '../messaging/message.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { DsrService } from './dsr.service';
import type { PrismaService } from '../prisma/prisma.service';

const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);

const dsr = () => new DsrService(asService, actors);
const support = () => new SupportService(asService, makeFakePorts(), actors, new IdempotencyService(asService));
const messages = () => new MessageService(asService, makeFakePorts(), actors);

const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Jane Doe' };

/** Seed the caller as a data subject with PII: a ticket subject + an authored message body. */
async function seedSubject(p: MessagePrincipal, subject: string, body: string) {
  const ticket = await support().createTicket(p, { subject });
  const m = messages();
  const { threadId } = await m.openThread(null, p);
  const msg = await m.send(threadId, p, { content: body }, `k-${randomUUID()}`);
  return { ticketId: ticket.id, interactionId: msg.id };
}

beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });

describe('DsrService — right-to-erasure (P9 slice 8)', () => {
  it('redacts PII in place, preserves externalId, and stamps redactedAt', async () => {
    const { ticketId, interactionId } = await seedSubject(cust, 'my SSN is 123-45', 'call me at 555-0100');
    const before = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'cust' } });

    const res = await dsr().eraseSubject(cust);
    expect(res.status).toBe('ERASED');

    const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } });
    expect(part.bodyText).toBe('[redacted]');
    const ticket = await prisma.iiosTicket.findUniqueOrThrow({ where: { id: ticketId } });
    expect(ticket.subject).toBe('[redacted]');
    const handle = await prisma.iiosSourceHandle.findUniqueOrThrow({ where: { id: before.id } });
    expect(handle.displayName).toBe('[redacted]');
    expect(handle.externalId).toBe('cust'); // source preservation — external id kept
    expect(handle.redactedAt).toBeTruthy();
  });

  it('preserves the audit trail + structure (ledgers and rows survive)', async () => {
    const { ticketId, interactionId } = await seedSubject(cust, 'secret', 'secret body');
    await dsr().eraseSubject(cust);

    // audit link recorded, and the ticket-created ledger event still exists
    expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.subject.erased' } })).toBe(1);
    expect(await prisma.iiosOutboxEvent.count()).toBeGreaterThan(0);
    // structure kept: the interaction + ticket rows still exist (redacted, not deleted)
    expect(await prisma.iiosInteraction.findUnique({ where: { id: interactionId } })).not.toBeNull();
    expect(await prisma.iiosTicket.findUnique({ where: { id: ticketId } })).not.toBeNull();
  });

  it('is idempotent — a second erase is a no-op', async () => {
    await seedSubject(cust, 's', 'b');
    expect((await dsr().eraseSubject(cust)).status).toBe('ERASED');
    expect((await dsr().eraseSubject(cust)).status).toBe('ALREADY_ERASED');
  });

  it('an active compliance hold blocks erasure until released', async () => {
    const { interactionId } = await seedSubject(cust, 's', 'private body');
    await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'litigation' });

    await expect(dsr().eraseSubject(cust)).rejects.toBeInstanceOf(ConflictException);
    const part = await prisma.iiosMessagePart.findFirstOrThrow({ where: { interactionId } });
    expect(part.bodyText).toBe('private body'); // NOT redacted while held
    expect(await prisma.iiosAuditLink.count({ where: { action: 'dsr.erasure.blocked' } })).toBe(1);

    const hold = (await dsr().listHolds(cust))[0]!;
    await dsr().releaseHold(cust, hold.id);
    expect((await dsr().eraseSubject(cust)).status).toBe('ERASED');
  });

  it('holds are tenant-fenced (a second tenant cannot see or release them)', async () => {
    await seedSubject(cust, 's', 'b');
    const hold = await dsr().placeHold(cust, { targetType: 'data_subject', targetId: 'self', reason: 'legal' });

    const other: MessagePrincipal = { userId: 'intruder', orgId: 'org_other', appId: 'portal-demo', displayName: 'B' };
    expect(await dsr().listHolds(other)).toHaveLength(0);
    await expect(dsr().releaseHold(other, hold.id)).rejects.toThrow();
  });
});
