import { Test } from '@nestjs/testing';
import { PrismaService } from 'nestjs-prisma';
import type { AdminAuditQueryDto } from './audit-query.dto';
import { AuditReader, type AdminAuditEventDto } from './audit-reader.service';

interface MockTx {
  $executeRawUnsafe: jest.Mock;
  $queryRawUnsafe: jest.Mock;
}

interface MockPrisma {
  $transaction: jest.Mock;
  tx: MockTx;
}

function buildMocks(opts?: {
  countRows?: Array<{ count: bigint }>;
  selectRows?: unknown[];
}): MockPrisma {
  const tx: MockTx = {
    $executeRawUnsafe: jest.fn().mockResolvedValue(0),
    $queryRawUnsafe: jest
      .fn()
      // First $queryRawUnsafe call is the COUNT; second is the SELECT.
      .mockResolvedValueOnce(opts?.countRows ?? [{ count: 0n }])
      .mockResolvedValueOnce(opts?.selectRows ?? []),
  };
  return {
    tx,
    $transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
  };
}

async function createSubject(prisma: MockPrisma): Promise<AuditReader> {
  const moduleRef = await Test.createTestingModule({
    providers: [AuditReader, { provide: PrismaService, useValue: prisma }],
  }).compile();
  return moduleRef.get(AuditReader);
}

const ROW = {
  id: '11111111-1111-1111-1111-111111111111',
  created_at: new Date('2026-05-13T10:30:00.000Z'),
  event_type: 'admin.audit.query',
  audience: 'workforce',
  actor_id_hash: 'hash(admin)',
  trace_id: 'trace-abc',
  subject: null,
  outcome: 'success',
  payload: { filters: {}, resultCount: 0 },
};

describe('AuditReader.findEvents', () => {
  it('locks the transaction to audit_reader before SELECT', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);

    await reader.findEvents({});

    const setRoleCalls = prisma.tx.$executeRawUnsafe.mock.calls;
    expect(setRoleCalls[0]?.[0]).toBe('SET LOCAL ROLE audit_reader');
  });

  it('issues a COUNT(*) then a SELECT in the same transaction', async () => {
    const prisma = buildMocks({ countRows: [{ count: 42n }], selectRows: [ROW] });
    const reader = await createSubject(prisma);

    const page = await reader.findEvents({});

    expect(prisma.$transaction).toHaveBeenCalledTimes(1);
    const queries = prisma.tx.$queryRawUnsafe.mock.calls;
    expect(queries[0]?.[0]).toMatch(/SELECT COUNT\(\*\)::bigint AS count FROM "audit"\."events"/);
    expect(queries[1]?.[0]).toMatch(/SELECT id, created_at, event_type, audience/);
    expect(page.total).toBe(42);
    expect(page.items).toHaveLength(1);
  });

  it('projects rows into the SPA-facing shape (ISO timestamp + null payload tolerance)', async () => {
    const prisma = buildMocks({
      selectRows: [{ ...ROW, payload: null }, ROW],
    });
    const reader = await createSubject(prisma);
    const page = await reader.findEvents({});
    const first = page.items[0] as AdminAuditEventDto;
    expect(first.createdAt).toBe('2026-05-13T10:30:00.000Z');
    expect(first.payload).toBeNull();
    const second = page.items[1] as AdminAuditEventDto;
    expect(second.payload).toEqual({ filters: {}, resultCount: 0 });
  });

  it('emits no WHERE clause when no filter is provided', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    await reader.findEvents({});
    const countSql = prisma.tx.$queryRawUnsafe.mock.calls[0]?.[0] as string;
    expect(countSql).not.toMatch(/WHERE/);
  });

  it('orders by created_at DESC, id DESC for deterministic pagination', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    await reader.findEvents({});
    const selectSql = prisma.tx.$queryRawUnsafe.mock.calls[1]?.[0] as string;
    expect(selectSql).toMatch(/ORDER BY created_at DESC, id DESC/);
  });

  it('passes filter values as positional parameters (never inlined into SQL)', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);

    await reader.findEvents({
      eventType: "admin.audit.query'; DROP TABLE events; --",
      audience: 'workforce',
    });

    const countCall = prisma.tx.$queryRawUnsafe.mock.calls[0] ?? [];
    const sql = countCall[0] as string;
    const params = countCall.slice(1);
    expect(sql).toContain('$1');
    expect(sql).toContain('$2');
    expect(sql).not.toContain('DROP TABLE');
    expect(params).toEqual(["admin.audit.query'; DROP TABLE events; --", 'workforce']);
  });

  it('casts enum filters through the proper enum types in SQL', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    await reader.findEvents({ audience: 'workforce', outcome: 'denied' });
    const sql = prisma.tx.$queryRawUnsafe.mock.calls[0]?.[0] as string;
    expect(sql).toMatch(/audience = \$\d+::"audit"\."AuditAudience"/);
    expect(sql).toMatch(/outcome = \$\d+::"audit"\."AuditOutcome"/);
  });

  it('uses LIKE with ESCAPE for subjectPrefix and escapes %/_ in the literal', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    await reader.findEvents({ subjectPrefix: 'session:abc%_' });
    const countCall = prisma.tx.$queryRawUnsafe.mock.calls[0] ?? [];
    const sql = countCall[0] as string;
    const params = countCall.slice(1) as string[];
    expect(sql).toMatch(/subject LIKE \$\d+ ESCAPE '\\'/);
    expect(params[0]).toBe('session:abc\\%\\_%');
  });

  it('translates createdAt ISO strings to Date instances in the params', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    await reader.findEvents({
      createdAtFrom: '2026-05-01T00:00:00.000Z',
      createdAtTo: '2026-05-13T23:59:59.999Z',
    });
    const params = prisma.tx.$queryRawUnsafe.mock.calls[0]?.slice(1) as unknown[];
    expect(params[0]).toBeInstanceOf(Date);
    expect((params[0] as Date).toISOString()).toBe('2026-05-01T00:00:00.000Z');
    expect((params[1] as Date).toISOString()).toBe('2026-05-13T23:59:59.999Z');
  });

  it('applies the default limit (50) and offset (0) when not provided', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    const page = await reader.findEvents({});
    const selectCall = prisma.tx.$queryRawUnsafe.mock.calls[1] ?? [];
    // The last two params are LIMIT and OFFSET in that order.
    const params = selectCall.slice(1) as unknown[];
    expect(params[params.length - 2]).toBe(50);
    expect(params[params.length - 1]).toBe(0);
    expect(page.limit).toBe(50);
    expect(page.offset).toBe(0);
  });

  it('caps limit at MAX_LIMIT (200) even if a caller bypasses the DTO', async () => {
    const prisma = buildMocks();
    const reader = await createSubject(prisma);
    // Direct service call with limit above the ceiling — simulates
    // a future caller (worker, batch job) that doesn't pass
    // through the controller's ValidationPipe.
    await reader.findEvents({ limit: 1000 } as AdminAuditQueryDto);
    const params = prisma.tx.$queryRawUnsafe.mock.calls[1]?.slice(1) as unknown[];
    expect(params[params.length - 2]).toBe(200);
  });
});
