import { Test } from '@nestjs/testing';
import { trace } from '@opentelemetry/api';
import { ClsService } from 'nestjs-cls';
import { PrismaService } from 'nestjs-prisma';
import { AuditWriter } from './audit.service';
import type { AuditEventInput } from './audit.types';
import { HashUserIdService } from './hash-user-id.service';

/**
 * Fields of an audit row as captured from the parameterised raw
 * INSERT — same positional order as the SQL placeholders. Anything
 * read from the mock goes through `extractInsertedRow` so the
 * tests assert against named fields rather than raw `$executeRawUnsafe`
 * argument indices.
 */
interface InsertedRow {
  eventType: unknown;
  audience: unknown;
  outcome: unknown;
  subject: unknown;
  actorIdHash: unknown;
  traceId: unknown;
  payloadJson: unknown;
}

interface MockTx {
  $executeRawUnsafe: jest.Mock;
}

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

function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } {
  const tx: MockTx = {
    $executeRawUnsafe: jest.fn().mockResolvedValue(0),
  };
  const prisma: MockPrisma = {
    tx,
    $transaction: jest.fn(async (fn: (tx: MockTx) => Promise<unknown>) => fn(tx)),
  };
  const cls = { get: jest.fn() };
  return { prisma, cls };
}

/**
 * Pull the inserted-row fields out of the `$executeRawUnsafe` mock.
 * `recordEvent` calls `$executeRawUnsafe` twice — first to `SET LOCAL
 * ROLE audit_writer`, then to issue the parameterised INSERT. The
 * INSERT call has args (sql, eventType, audience, outcome, subject,
 * actorIdHash, traceId, payloadJson).
 */
function extractInsertedRow(prisma: MockPrisma): InsertedRow {
  const calls = prisma.tx.$executeRawUnsafe.mock.calls;
  const insertCall = calls[1];
  if (!insertCall) {
    throw new Error(
      `expected $executeRawUnsafe to be called at least twice (SET ROLE + INSERT), got ${calls.length}`,
    );
  }
  const [, eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson] = insertCall;
  return { eventType, audience, outcome, subject, actorIdHash, traceId, payloadJson };
}

async function createSubject(): Promise<{
  writer: AuditWriter;
  prisma: MockPrisma;
  cls: { get: jest.Mock };
  hashUserId: { hash: jest.Mock };
}> {
  const { prisma, cls } = buildMocks();
  const hashUserId = { hash: jest.fn((id: string) => `hash(${id})`) };
  const moduleRef = await Test.createTestingModule({
    providers: [
      AuditWriter,
      { provide: PrismaService, useValue: prisma },
      { provide: ClsService, useValue: cls },
      { provide: HashUserIdService, useValue: hashUserId },
    ],
  }).compile();

  return { writer: moduleRef.get(AuditWriter), prisma, cls, hashUserId };
}

const baseInput: AuditEventInput = {
  eventType: 'auth.login',
  audience: 'workforce',
  outcome: 'success',
  subject: 'user:42',
};

describe('AuditWriter', () => {
  it('locks the transaction to audit_writer before INSERTing', async () => {
    const { writer, prisma } = await createSubject();
    await writer.recordEvent(baseInput);

    expect(prisma.$transaction).toHaveBeenCalledTimes(1);
    // recordEvent runs two raw statements per call: SET ROLE then
    // INSERT. SET ROLE must come first so the INSERT's privilege
    // check is against audit_writer, not the BFF's connection role.
    const calls = prisma.tx.$executeRawUnsafe.mock.calls;
    expect(calls.length).toBeGreaterThanOrEqual(2);
    expect(calls[0]?.[0]).toBe('SET LOCAL ROLE audit_writer');
    expect(calls[1]?.[0]).toMatch(/INSERT INTO "audit"\."events"/);
  });

  it('does NOT use Prisma ORM create() — the raw INSERT keeps audit_writer free of SELECT', async () => {
    // Bypassing the ORM is the explicit choice that lets ADR-0013's
    // append-only role contract hold. Prisma's create() emits
    // `INSERT … RETURNING *`, and RETURNING requires SELECT. Pin
    // the constraint so a well-meaning refactor can't silently
    // reintroduce the bug.
    const { writer, prisma } = await createSubject();
    await writer.recordEvent(baseInput);

    // Sanity check: the only writes go through $executeRawUnsafe.
    // No other tx method should be called.
    const allowedTxKeys = new Set(['$executeRawUnsafe']);
    const observedTxKeys = Object.keys(prisma.tx);
    expect(observedTxKeys.every((k) => allowedTxKeys.has(k))).toBe(true);
  });

  it('parameterises the INSERT — never inlines values into SQL', async () => {
    const { writer, prisma } = await createSubject();
    await writer.recordEvent({
      ...baseInput,
      eventType: "auth'; DROP TABLE events; --",
    });

    const insertCall = prisma.tx.$executeRawUnsafe.mock.calls[1];
    const sql = insertCall?.[0] as string;
    // SQL itself must be the parameterised template; the malicious
    // eventType has to land in the params array, not concatenated
    // into the SQL.
    expect(sql).toContain('$1');
    expect(sql).not.toContain('DROP TABLE');
    expect(insertCall?.[1]).toBe("auth'; DROP TABLE events; --");
  });

  it('passes the input fields through positionally to the parameterised INSERT', async () => {
    const { writer, prisma } = await createSubject();
    await writer.recordEvent({
      ...baseInput,
      payload: { route: '/auth/login', clientId: 'spa' },
    });

    const row = extractInsertedRow(prisma);
    expect(row.eventType).toBe('auth.login');
    expect(row.audience).toBe('workforce');
    expect(row.outcome).toBe('success');
    expect(row.subject).toBe('user:42');
    expect(row.payloadJson).toBe(JSON.stringify({ route: '/auth/login', clientId: 'spa' }));
  });

  it('sends payload as null (JSONB column nullable) when no payload is provided', async () => {
    const { writer, prisma } = await createSubject();
    await writer.recordEvent(baseInput);

    const row = extractInsertedRow(prisma);
    expect(row.payloadJson).toBeNull();
  });

  it('reads actorIdHash from CLS when not passed explicitly', async () => {
    const { writer, prisma, cls } = await createSubject();
    cls.get.mockReturnValue('hash-from-cls');

    await writer.recordEvent(baseInput);

    expect(cls.get).toHaveBeenCalledWith('actorIdHash');
    expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-cls');
  });

  it('prefers an explicit actorIdHash over the CLS-resolved one', async () => {
    const { writer, prisma, cls } = await createSubject();
    cls.get.mockReturnValue('hash-from-cls');

    await writer.recordEvent({ ...baseInput, actorIdHash: 'hash-from-input' });

    expect(extractInsertedRow(prisma).actorIdHash).toBe('hash-from-input');
  });

  it('stores actorIdHash = null when neither input nor CLS has one', async () => {
    const { writer, prisma, cls } = await createSubject();
    cls.get.mockReturnValue(undefined);

    await writer.recordEvent(baseInput);

    expect(extractInsertedRow(prisma).actorIdHash).toBeNull();
  });

  it('captures the active OTel trace id', async () => {
    const fakeSpan = { spanContext: () => ({ traceId: 'abc123', spanId: 'def', traceFlags: 0 }) };
    const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(fakeSpan as never);

    try {
      const { writer, prisma } = await createSubject();
      await writer.recordEvent(baseInput);

      expect(extractInsertedRow(prisma).traceId).toBe('abc123');
    } finally {
      getActiveSpanSpy.mockRestore();
    }
  });

  it('stores traceId = null when no span is active', async () => {
    const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined);

    try {
      const { writer, prisma } = await createSubject();
      await writer.recordEvent(baseInput);

      expect(extractInsertedRow(prisma).traceId).toBeNull();
    } finally {
      getActiveSpanSpy.mockRestore();
    }
  });

  it('propagates the underlying error — no catch-and-swallow', async () => {
    const { writer, prisma } = await createSubject();
    const dbError = new Error('permission denied for table events');
    // First call (SET ROLE) succeeds, second (INSERT) rejects.
    prisma.tx.$executeRawUnsafe.mockResolvedValueOnce(0).mockRejectedValueOnce(dbError);

    await expect(writer.recordEvent(baseInput)).rejects.toThrow(
      'permission denied for table events',
    );
  });
});

describe('AuditWriter — typed event methods', () => {
  describe('signIn()', () => {
    it('hashes the actor oid and records auth.sign_in (success / workforce)', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.signIn({
        actor: { oid: 'user-oid', amr: ['pwd', 'mfa'] },
        sessionId: 'sid-1',
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.sign_in');
      expect(row.audience).toBe('workforce');
      expect(row.outcome).toBe('success');
      expect(row.actorIdHash).toBe('hash(user-oid)');
      expect(row.subject).toBe('session:sid-1');
      expect(row.payloadJson).toBe(JSON.stringify({ amr: ['pwd', 'mfa'] }));
    });
  });

  describe('signInFailed()', () => {
    it('records auth.sign_in.failed with the failureKind and no actor by default', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.signInFailed({ failureKind: 'state-mismatch' });
      expect(hashUserId.hash).not.toHaveBeenCalled();
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.sign_in.failed');
      expect(row.outcome).toBe('failure');
      expect(row.actorIdHash).toBeNull();
      expect(row.payloadJson).toBe(JSON.stringify({ failureKind: 'state-mismatch' }));
    });

    it('merges payload with failureKind under the same key', async () => {
      const { writer, prisma } = await createSubject();
      await writer.signInFailed({
        failureKind: 'entra-error',
        payload: { entraError: 'access_denied', entraErrorDescription: 'user cancelled' },
      });
      const row = extractInsertedRow(prisma);
      expect(row.payloadJson).toBe(
        JSON.stringify({
          failureKind: 'entra-error',
          entraError: 'access_denied',
          entraErrorDescription: 'user cancelled',
        }),
      );
    });

    it('hashes the actor oid when one is provided (rare — identity-after-rejection path)', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.signInFailed({ failureKind: 'amr-missing', actor: { oid: 'user-oid' } });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      expect(extractInsertedRow(prisma).actorIdHash).toBe('hash(user-oid)');
    });
  });

  describe('signOut()', () => {
    it('records auth.sign_out (success) with the hashed actor + session subject', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.signOut({ actor: { oid: 'user-oid' }, sessionId: 'sid-2' });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.sign_out');
      expect(row.outcome).toBe('success');
      expect(row.actorIdHash).toBe('hash(user-oid)');
      expect(row.subject).toBe('session:sid-2');
    });
  });

  describe('sessionExpired()', () => {
    it('records auth.session.expired with reason + ageMs', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.sessionExpired({
        actor: { oid: 'user-oid' },
        sessionId: 'sid-3',
        reason: 'absolute',
        ageMs: 13 * 60 * 60 * 1000,
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.session.expired');
      expect(row.outcome).toBe('success');
      expect(row.subject).toBe('session:sid-3');
      expect(row.payloadJson).toBe(
        JSON.stringify({ reason: 'absolute', ageMs: 13 * 60 * 60 * 1000 }),
      );
    });
  });

  describe('adminAuditQuery()', () => {
    it('records admin.audit.query (success) with filters + resultCount in payload', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.adminAuditQuery({
        actor: { oid: 'admin-oid' },
        filters: { eventType: 'auth.sign_in', limit: 50 },
        resultCount: 12,
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('admin-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('admin.audit.query');
      expect(row.audience).toBe('workforce');
      expect(row.outcome).toBe('success');
      expect(row.actorIdHash).toBe('hash(admin-oid)');
      // `subject` is left null — the event is system-wide on the
      // audit log itself, not tied to a specific entity URI.
      expect(row.subject).toBeNull();
      expect(row.payloadJson).toBe(
        JSON.stringify({
          filters: { eventType: 'auth.sign_in', limit: 50 },
          resultCount: 12,
        }),
      );
    });

    it('persists an empty filters object verbatim (the admin asked for everything)', async () => {
      const { writer, prisma } = await createSubject();
      await writer.adminAuditQuery({
        actor: { oid: 'admin-oid' },
        filters: {},
        resultCount: 0,
      });
      expect(extractInsertedRow(prisma).payloadJson).toBe(
        JSON.stringify({ filters: {}, resultCount: 0 }),
      );
    });
  });

  describe('adminUsersQuery()', () => {
    it('records admin.users.query (success) with filters + resultCount in payload', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.adminUsersQuery({
        actor: { oid: 'admin-oid' },
        filters: { username: 'jane', limit: 50 },
        resultCount: 7,
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('admin-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('admin.users.query');
      expect(row.audience).toBe('workforce');
      expect(row.outcome).toBe('success');
      expect(row.actorIdHash).toBe('hash(admin-oid)');
      expect(row.subject).toBeNull();
      expect(row.payloadJson).toBe(
        JSON.stringify({
          filters: { username: 'jane', limit: 50 },
          resultCount: 7,
        }),
      );
    });

    it('persists an empty filters object verbatim (admin paged through everyone)', async () => {
      const { writer, prisma } = await createSubject();
      await writer.adminUsersQuery({
        actor: { oid: 'admin-oid' },
        filters: {},
        resultCount: 200,
      });
      expect(extractInsertedRow(prisma).payloadJson).toBe(
        JSON.stringify({ filters: {}, resultCount: 200 }),
      );
    });
  });

  describe('mfaRequired()', () => {
    it('records auth.mfa_required (denied) with reason + freshnessSeconds in payload', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.mfaRequired({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/admin/me',
        reason: 'no-mfa-in-amr',
        freshnessSeconds: 600,
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.mfa_required');
      expect(row.audience).toBe('workforce');
      expect(row.outcome).toBe('denied');
      expect(row.actorIdHash).toBe('hash(user-oid)');
      expect(row.subject).toBe('GET /api/admin/me');
      expect(row.payloadJson).toBe(
        JSON.stringify({ reason: 'no-mfa-in-amr', freshnessSeconds: 600 }),
      );
    });

    it('includes mfaAgeMs in the payload when supplied (stale-session path)', async () => {
      const { writer, prisma } = await createSubject();
      await writer.mfaRequired({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'POST /api/admin/cms/pages',
        reason: 'mfa-stale',
        freshnessSeconds: 600,
        mfaAgeMs: 720_000,
      });
      expect(extractInsertedRow(prisma).payloadJson).toBe(
        JSON.stringify({ reason: 'mfa-stale', freshnessSeconds: 600, mfaAgeMs: 720_000 }),
      );
    });

    it('omits mfaAgeMs from the payload when undefined (no-mfa-verified-at path)', async () => {
      const { writer, prisma } = await createSubject();
      await writer.mfaRequired({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/admin/me',
        reason: 'no-mfa-verified-at',
        freshnessSeconds: 600,
      });
      const payload = JSON.parse(extractInsertedRow(prisma).payloadJson as string) as Record<
        string,
        unknown
      >;
      expect(payload).not.toHaveProperty('mfaAgeMs');
    });
  });

  describe('adminAccessDenied()', () => {
    it('records admin.access_denied with outcome=denied, attempted route as subject, and rolesHeld payload', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.adminAccessDenied({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/admin/audit',
        rolesHeld: ['editor'],
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('admin.access_denied');
      expect(row.audience).toBe('workforce');
      expect(row.outcome).toBe('denied');
      expect(row.actorIdHash).toBe('hash(user-oid)');
      expect(row.subject).toBe('GET /api/admin/audit');
      expect(row.payloadJson).toBe(JSON.stringify({ rolesHeld: ['editor'] }));
    });

    it('persists an empty rolesHeld array verbatim (distinguishable from missing payload)', async () => {
      const { writer, prisma } = await createSubject();
      await writer.adminAccessDenied({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/admin/me',
        rolesHeld: [],
      });
      expect(extractInsertedRow(prisma).payloadJson).toBe(JSON.stringify({ rolesHeld: [] }));
    });
  });

  describe('authorizationDenied()', () => {
    it('records auth.authorization_denied with kind=privilege payload', async () => {
      const { writer, prisma, hashUserId } = await createSubject();
      await writer.authorizationDenied({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/audit',
        kind: 'privilege',
        required: ['Portal.Auditor'],
        held: ['Portal.Admin'],
      });
      expect(hashUserId.hash).toHaveBeenCalledWith('user-oid');
      const row = extractInsertedRow(prisma);
      expect(row.eventType).toBe('auth.authorization_denied');
      expect(row.outcome).toBe('denied');
      expect(row.subject).toBe('GET /api/audit');
      expect(row.payloadJson).toBe(
        JSON.stringify({
          kind: 'privilege',
          required: ['Portal.Auditor'],
          held: ['Portal.Admin'],
        }),
      );
    });

    it('records kind=role with the role lists', async () => {
      const { writer, prisma } = await createSubject();
      await writer.authorizationDenied({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/hr/payroll',
        kind: 'role',
        required: ['rh', 'responsable-paie'],
        held: ['collaborateur'],
      });
      const row = extractInsertedRow(prisma);
      expect(row.payloadJson).toBe(
        JSON.stringify({
          kind: 'role',
          required: ['rh', 'responsable-paie'],
          held: ['collaborateur'],
        }),
      );
    });

    it('records kind=scope with the scope descriptors', async () => {
      const { writer, prisma } = await createSubject();
      await writer.authorizationDenied({
        actor: { oid: 'user-oid' },
        attemptedRoute: 'GET /api/etablissement/0330800021',
        kind: 'scope',
        required: ['etablissement:0330800021'],
        held: ['etablissement:0330800013'],
      });
      const row = extractInsertedRow(prisma);
      expect(row.payloadJson).toBe(
        JSON.stringify({
          kind: 'scope',
          required: ['etablissement:0330800021'],
          held: ['etablissement:0330800013'],
        }),
      );
    });
  });
});
