/**
 * Unit tests for EnrollmentService — federation enrollment token flow (FED-M2-07).
 *
 * Coverage:
 *  createToken:
 *   - inserts token row with correct grantId, peerId, and future expiresAt
 *   - returns { token, expiresAt } with a 64-char hex token
 *   - clamps ttlSeconds to 900
 *
 *  redeem — error paths:
 *   - NotFoundException when token row not found
 *   - GoneException when token already used (usedAt set)
 *   - GoneException when token expired (expiresAt < now)
 *   - GoneException when grant status is not pending
 *
 *  redeem — success path:
 *   - atomically claims token BEFORE cert issuance (claim → issueCert → tx)
 *   - calls CaService.issueCert with correct args
 *   - activates grant + updates peer + writes audit log inside a transaction
 *   - returns { certPem, certChainPem }
 *
 *  redeem — replay protection:
 *   - GoneException when claim UPDATE returns empty array (concurrent request won)
 */

import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { GoneException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db';
import { EnrollmentService } from '../enrollment.service.js';
import { makeSelfSignedCert } from './helpers/test-cert.js';

// ---------------------------------------------------------------------------
// Test constants
// ---------------------------------------------------------------------------

const GRANT_ID = 'g1111111-1111-1111-1111-111111111111';
const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
const USER_ID = 'u3333333-3333-3333-3333-333333333333';
const TOKEN = 'a'.repeat(64); // 64-char hex

// Real self-signed EC P-256 cert — populated once in beforeAll.
// Required because EnrollmentService.extractCertNotAfter calls new X509Certificate(certPem)
// with strict parsing (PR #501 HIGH-2: no silent fallback).
let REAL_CERT_PEM: string;

const MOCK_CHAIN_PEM = () => REAL_CERT_PEM + REAL_CERT_PEM;
const MOCK_SERIAL = 'ABCD1234';

beforeAll(async () => {
  REAL_CERT_PEM = await makeSelfSignedCert();
});

// ---------------------------------------------------------------------------
// Factory helpers
// ---------------------------------------------------------------------------

function makeTokenRow(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    token: TOKEN,
    grantId: GRANT_ID,
    peerId: PEER_ID,
    expiresAt: new Date(Date.now() + 60_000), // 1 min from now
    usedAt: null,
    createdAt: new Date(),
    ...overrides,
  };
}

function makeGrant(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    id: GRANT_ID,
    peerId: PEER_ID,
    subjectUserId: USER_ID,
    scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
    status: 'pending',
    expiresAt: null,
    createdAt: new Date(),
    revokedAt: null,
    revokedReason: null,
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Mock DB builder
// ---------------------------------------------------------------------------

function makeDb({
  tokenRows = [makeTokenRow()],
  // claimedRows is returned by the .returning() on the token-claim UPDATE.
  // Empty array = concurrent request won the race (GoneException).
  claimedRows = [{ token: TOKEN }],
}: {
  tokenRows?: unknown[];
  claimedRows?: unknown[];
} = {}) {
  // insert().values() — for createToken (outer db, not tx)
  const insertValues = vi.fn().mockResolvedValue(undefined);
  const insertMock = vi.fn().mockReturnValue({ values: insertValues });

  // select().from().where().limit() — for fetching the token row
  const limitSelect = vi.fn().mockResolvedValue(tokenRows);
  const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
  const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
  const selectMock = vi.fn().mockReturnValue({ from: fromSelect });

  // update().set().where().returning() — for the atomic token claim (outer db)
  const returningMock = vi.fn().mockResolvedValue(claimedRows);
  const whereClaimUpdate = vi.fn().mockReturnValue({ returning: returningMock });
  const setClaimMock = vi.fn().mockReturnValue({ where: whereClaimUpdate });
  const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });

  // transaction(cb) — cb receives txMock; txMock has update + insert
  //
  // The tx mock must support two tx.update() call patterns (CRIT-2, PR #501):
  //   1. Grant activation:  .update().set().where().returning() → resolves to [{ id }]
  //   2. Peer update:       .update().set().where()             → resolves to undefined
  //
  // We achieve this by making txWhereUpdate return an object with BOTH a thenable
  // interface (so `await tx.update().set().where()` works) AND a .returning() method.
  const txGrantActivatedRow = { id: GRANT_ID };
  const txReturningMock = vi.fn().mockResolvedValue([txGrantActivatedRow]);
  const txWhereUpdate = vi.fn().mockReturnValue({
    // .returning() for grant activation (first tx.update call)
    returning: txReturningMock,
    // thenables so `await tx.update().set().where()` also works for peer update
    then: (resolve: (v: undefined) => void) => resolve(undefined),
    catch: () => undefined,
    finally: () => undefined,
  });
  const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
  const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
  const txInsertValues = vi.fn().mockResolvedValue(undefined);
  const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
  const txMock = { update: txUpdateMock, insert: txInsertMock };
  const transactionMock = vi
    .fn()
    .mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock));

  return {
    insert: insertMock,
    select: selectMock,
    update: claimUpdateMock,
    transaction: transactionMock,
    _mocks: {
      insertValues,
      insertMock,
      limitSelect,
      whereSelect,
      fromSelect,
      selectMock,
      returningMock,
      whereClaimUpdate,
      setClaimMock,
      claimUpdateMock,
      txInsertValues,
      txInsertMock,
      txWhereUpdate,
      txReturningMock,
      txSetMock,
      txUpdateMock,
      txMock,
      transactionMock,
    },
  };
}

// ---------------------------------------------------------------------------
// Mock CaService
// ---------------------------------------------------------------------------

function makeCaService() {
  return {
    // REAL_CERT_PEM is populated by beforeAll — safe to reference via closure here
    // because makeCaService() is only called after the suite's beforeAll runs.
    issueCert: vi.fn().mockImplementation(async () => ({
      certPem: REAL_CERT_PEM,
      certChainPem: MOCK_CHAIN_PEM(),
      serialNumber: MOCK_SERIAL,
    })),
  };
}

// ---------------------------------------------------------------------------
// Mock GrantsService
// ---------------------------------------------------------------------------

function makeGrantsService(grantOverrides: Partial<Record<string, unknown>> = {}) {
  return {
    getGrant: vi.fn().mockResolvedValue(makeGrant(grantOverrides)),
    activateGrant: vi.fn().mockResolvedValue(makeGrant({ status: 'active' })),
  };
}

// ---------------------------------------------------------------------------
// Helper: build service under test
// ---------------------------------------------------------------------------

function buildService({
  db = makeDb(),
  caService = makeCaService(),
  grantsService = makeGrantsService(),
}: {
  db?: ReturnType<typeof makeDb>;
  caService?: ReturnType<typeof makeCaService>;
  grantsService?: ReturnType<typeof makeGrantsService>;
} = {}) {
  return new EnrollmentService(db as unknown as Db, caService as never, grantsService as never);
}

// ---------------------------------------------------------------------------
// Tests: createToken
// ---------------------------------------------------------------------------

describe('EnrollmentService.createToken', () => {
  it('inserts a token row and returns { token, expiresAt }', async () => {
    const db = makeDb();
    const service = buildService({ db });

    const result = await service.createToken({
      grantId: GRANT_ID,
      peerId: PEER_ID,
      ttlSeconds: 900,
    });

    expect(result.token).toHaveLength(64); // 32 bytes hex
    expect(result.expiresAt).toBeDefined();
    expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now());
    expect(db._mocks.insertValues).toHaveBeenCalledWith(
      expect.objectContaining({ grantId: GRANT_ID, peerId: PEER_ID }),
    );
  });

  it('clamps ttlSeconds to 900', async () => {
    const db = makeDb();
    const service = buildService({ db });

    const before = Date.now();
    const result = await service.createToken({
      grantId: GRANT_ID,
      peerId: PEER_ID,
      ttlSeconds: 9999,
    });
    const after = Date.now();

    const expiresMs = new Date(result.expiresAt).getTime();
    // Should be at most 900s from now
    expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
    expect(expiresMs - after).toBeGreaterThanOrEqual(0);
  });
});

// ---------------------------------------------------------------------------
// Tests: redeem — error paths
// ---------------------------------------------------------------------------

describe('EnrollmentService.redeem — error paths', () => {
  it('throws NotFoundException when token row not found', async () => {
    const db = makeDb({ tokenRows: [] });
    const service = buildService({ db });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(NotFoundException);
  });

  it('throws GoneException when usedAt is set (already redeemed)', async () => {
    const db = makeDb({ tokenRows: [makeTokenRow({ usedAt: new Date(Date.now() - 1000) })] });
    const service = buildService({ db });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
  });

  it('throws GoneException when token has expired', async () => {
    const db = makeDb({ tokenRows: [makeTokenRow({ expiresAt: new Date(Date.now() - 1000) })] });
    const service = buildService({ db });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
  });

  it('throws GoneException when grant status is not pending', async () => {
    const db = makeDb();
    const grantsService = makeGrantsService({ status: 'active' });
    const service = buildService({ db, grantsService });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
  });

  it('throws GoneException when token claim UPDATE returns empty array (concurrent replay)', async () => {
    const db = makeDb({ claimedRows: [] });
    const caService = makeCaService();
    const grantsService = makeGrantsService();
    const service = buildService({ db, caService, grantsService });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
  });

  it('does NOT call issueCert when token claim fails (no double minting)', async () => {
    const db = makeDb({ claimedRows: [] });
    const caService = makeCaService();
    const service = buildService({ db, caService });

    await expect(service.redeem(TOKEN, '---CSR---')).rejects.toBeInstanceOf(GoneException);
    expect(caService.issueCert).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// Tests: redeem — success path
// ---------------------------------------------------------------------------

describe('EnrollmentService.redeem — success path', () => {
  let db: ReturnType<typeof makeDb>;
  let caService: ReturnType<typeof makeCaService>;
  let grantsService: ReturnType<typeof makeGrantsService>;
  let service: EnrollmentService;

  beforeEach(() => {
    db = makeDb();
    caService = makeCaService();
    grantsService = makeGrantsService();
    service = buildService({ db, caService, grantsService });
  });

  it('claims token BEFORE calling issueCert (prevents double minting)', async () => {
    const callOrder: string[] = [];
    db._mocks.returningMock.mockImplementation(async () => {
      callOrder.push('claim');
      return [{ token: TOKEN }];
    });
    caService.issueCert.mockImplementation(async () => {
      callOrder.push('issueCert');
      return { certPem: REAL_CERT_PEM, certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL };
    });

    await service.redeem(TOKEN, '---CSR---');

    expect(callOrder).toEqual(['claim', 'issueCert']);
  });

  it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
    await service.redeem(TOKEN, '---CSR---');

    expect(caService.issueCert).toHaveBeenCalledWith(
      expect.objectContaining({
        grantId: GRANT_ID,
        subjectUserId: USER_ID,
        csrPem: '---CSR---',
        ttlSeconds: 300,
      }),
    );
  });

  it('runs activate grant + peer update + audit inside a transaction', async () => {
    await service.redeem(TOKEN, '---CSR---');

    expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
    // tx.update called twice: activate grant + update peer
    expect(db._mocks.txUpdateMock).toHaveBeenCalledTimes(2);
    // tx.insert called once: audit log
    expect(db._mocks.txInsertMock).toHaveBeenCalledOnce();
  });

  it('activates grant (sets status=active) inside the transaction', async () => {
    await service.redeem(TOKEN, '---CSR---');

    expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
  });

  it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
    await service.redeem(TOKEN, '---CSR---');

    expect(db._mocks.txSetMock).toHaveBeenCalledWith(
      expect.objectContaining({
        certPem: REAL_CERT_PEM,
        certSerial: MOCK_SERIAL,
        state: 'active',
      }),
    );
  });

  it('inserts an audit log row inside the transaction', async () => {
    await service.redeem(TOKEN, '---CSR---');

    expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
      expect.objectContaining({
        peerId: PEER_ID,
        grantId: GRANT_ID,
        verb: 'enrollment',
      }),
    );
  });

  it('returns { certPem, certChainPem } from CaService', async () => {
    const result = await service.redeem(TOKEN, '---CSR---');

    expect(result).toEqual({
      certPem: REAL_CERT_PEM,
      certChainPem: MOCK_CHAIN_PEM(),
    });
  });
});
