/**
 * Unit tests for FederationAuthGuard (FED-M3-03).
 *
 * Coverage:
 *  - Missing cert (no TLS socket / no getPeerCertificate) → 401
 *  - Cert parse failure (corrupt DER raw bytes) → 401
 *  - Missing grantId OID → 401
 *  - Missing subjectUserId OID → 401
 *  - Grant not found (GrantsService throws NotFoundException) → 403
 *  - Grant in `pending` status → 403
 *  - Grant in `revoked` status → 403
 *  - Grant in `expired` status → 403
 *  - Cert serial mismatch → 403
 *  - Happy path: active grant + matching cert serial → context attached, returns true
 */

import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ExecutionContext } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js';
import type { GrantsService, GrantWithPeer } from '../../grants.service.js';

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

const GRANT_ID = 'a1111111-1111-1111-1111-111111111111';
const USER_ID = 'b2222222-2222-2222-2222-222222222222';
const PEER_ID = 'c3333333-3333-3333-3333-333333333333';

// Node.js TLS serialNumber is uppercase hex (no colons)
const CERT_SERIAL_HEX = '01';

const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 };

// ---------------------------------------------------------------------------
// Mock builders
// ---------------------------------------------------------------------------

/**
 * Build a minimal GrantWithPeer-shaped mock.
 */
function makeGrantWithPeer(overrides: Partial<GrantWithPeer> = {}): GrantWithPeer {
  return {
    id: GRANT_ID,
    peerId: PEER_ID,
    subjectUserId: USER_ID,
    scope: VALID_SCOPE,
    status: 'active',
    expiresAt: null,
    createdAt: new Date('2026-01-01T00:00:00Z'),
    revokedAt: null,
    revokedReason: null,
    peer: {
      id: PEER_ID,
      commonName: 'test-peer',
      displayName: 'Test Peer',
      certPem: '',
      certSerial: CERT_SERIAL_HEX,
      certNotAfter: new Date(Date.now() + 86_400_000),
      clientKeyPem: null,
      state: 'active',
      endpointUrl: null,
      lastSeenAt: null,
      createdAt: new Date('2026-01-01T00:00:00Z'),
      revokedAt: null,
    },
    ...overrides,
  };
}

/**
 * Build a mock ExecutionContext with a pre-built TLS peer certificate.
 *
 * `certPem` — PEM string to present as the raw DER cert (converted to Buffer).
 *             Pass null to simulate "no cert presented".
 * `certSerialHex` — serialNumber string returned by the TLS socket.
 *                   Node.js returns uppercase hex.
 * `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP).
 */
function makeContext(opts: {
  certPem: string | null;
  certSerialHex?: string;
  hasTlsSocket?: boolean;
}): {
  ctx: ExecutionContext;
  statusMock: ReturnType<typeof vi.fn>;
  sendMock: ReturnType<typeof vi.fn>;
} {
  const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts;

  // Build peerCert object that Node.js TLS socket.getPeerCertificate() returns
  let peerCert: Record<string, unknown>;
  if (certPem === null) {
    // Simulate no cert: Node.js returns object with empty string fields
    peerCert = { raw: null, serialNumber: '' };
  } else {
    // Convert PEM to DER Buffer (strip headers + base64 decode)
    const b64 = certPem
      .replace(/-----BEGIN CERTIFICATE-----/, '')
      .replace(/-----END CERTIFICATE-----/, '')
      .replace(/\s+/g, '');
    const raw = Buffer.from(b64, 'base64');
    peerCert = { raw, serialNumber: certSerialHex };
  }

  const getPeerCertificate = vi.fn().mockReturnValue(peerCert);

  const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS

  // Fastify reply mocks
  const sendMock = vi.fn().mockReturnValue(undefined);
  const headerMock = vi.fn().mockReturnValue({ send: sendMock });
  const statusMock = vi.fn().mockReturnValue({ header: headerMock });

  const request = {
    raw: {
      socket,
    },
  };

  const reply = {
    status: statusMock,
  };

  const ctx = {
    switchToHttp: () => ({
      getRequest: () => request,
      getResponse: () => reply,
    }),
  } as unknown as ExecutionContext;

  return { ctx, statusMock, sendMock };
}

/**
 * Build a mock GrantsService.
 */
function makeGrantsService(
  overrides: Partial<Pick<GrantsService, 'getGrantWithPeer'>> = {},
): GrantsService {
  return {
    getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()),
    ...overrides,
  } as unknown as GrantsService;
}

// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------

describe('FederationAuthGuard', () => {
  let certPem: string;

  beforeEach(async () => {
    // Generate a real Mosaic-issued cert with the standard OIDs
    certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID });
  });

  // ── 401: No TLS socket ────────────────────────────────────────────────────

  it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => {
    const { ctx, statusMock, sendMock } = makeContext({
      certPem: certPem,
      hasTlsSocket: false,
    });

    const guard = new FederationAuthGuard(makeGrantsService());
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(401);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
      }),
    );
  });

  // ── 401: Cert not presented ───────────────────────────────────────────────

  it('returns 401 when the peer did not present a certificate', async () => {
    const { ctx, statusMock, sendMock } = makeContext({ certPem: null });

    const guard = new FederationAuthGuard(makeGrantsService());
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(401);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
      }),
    );
  });

  // ── 401: Cert parse failure ───────────────────────────────────────────────

  it('returns 401 when the certificate DER bytes are corrupt', async () => {
    // Build context with a cert that has garbage DER bytes
    const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----';
    const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem });

    const guard = new FederationAuthGuard(makeGrantsService());
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(401);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
      }),
    );
  });

  // ── 401: Missing grantId OID ─────────────────────────────────────────────

  it('returns 401 when the cert is missing the grantId OID', async () => {
    // makeSelfSignedCert produces a cert without any Mosaic OIDs
    const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js');
    const plainCert = await makeSelfSignedCert();
    const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert });

    const guard = new FederationAuthGuard(makeGrantsService());
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(401);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
      }),
    );
  });

  // ── 401: Missing subjectUserId OID ───────────────────────────────────────

  it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => {
    // Build a cert with only the grantId OID by importing cert generator internals
    const { webcrypto } = await import('node:crypto');
    const {
      X509CertificateGenerator,
      Extension,
      KeyUsagesExtension,
      KeyUsageFlags,
      BasicConstraintsExtension,
      cryptoProvider,
    } = await import('@peculiar/x509');

    cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);

    const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
    const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
    const now = new Date();
    const tomorrow = new Date(now.getTime() + 86_400_000);

    // Encode grantId only — missing subjectUserId extension
    const utf8 = new TextEncoder().encode(GRANT_ID);
    const encoded = new Uint8Array(2 + utf8.length);
    encoded[0] = 0x0c;
    encoded[1] = utf8.length;
    encoded.set(utf8, 2);

    const cert = await X509CertificateGenerator.createSelfSigned({
      serialNumber: '01',
      name: 'CN=partial-oid-test',
      notBefore: now,
      notAfter: tomorrow,
      signingAlgorithm: alg,
      keys,
      extensions: [
        new BasicConstraintsExtension(false),
        new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
        new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only
      ],
    });

    const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') });

    const guard = new FederationAuthGuard(makeGrantsService());
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(401);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }),
      }),
    );
  });

  // ── 403: Grant not found ─────────────────────────────────────────────────

  it('returns 403 when the grantId from the cert does not exist in DB', async () => {
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi
        .fn()
        .mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)),
    });

    const { ctx, statusMock, sendMock } = makeContext({ certPem });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── 403: Grant in `pending` status ───────────────────────────────────────

  it('returns 403 when the grant is in pending status', async () => {
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })),
    });

    const { ctx, statusMock, sendMock } = makeContext({ certPem });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── 403: Grant in `revoked` status ───────────────────────────────────────

  it('returns 403 when the grant is in revoked status', async () => {
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi
        .fn()
        .mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })),
    });

    const { ctx, statusMock, sendMock } = makeContext({ certPem });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── 403: Grant in `expired` status ───────────────────────────────────────

  it('returns 403 when the grant is in expired status', async () => {
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })),
    });

    const { ctx, statusMock, sendMock } = makeContext({ certPem });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── 403: Cert serial mismatch ─────────────────────────────────────────────

  it('returns 403 when the cert serial does not match the registered peer cert serial', async () => {
    // Return a grant whose peer has a different stored serial
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi.fn().mockResolvedValue(
        makeGrantWithPeer({
          peer: {
            id: PEER_ID,
            commonName: 'test-peer',
            displayName: 'Test Peer',
            certPem: '',
            certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01'
            certNotAfter: new Date(Date.now() + 86_400_000),
            clientKeyPem: null,
            state: 'active',
            endpointUrl: null,
            lastSeenAt: null,
            createdAt: new Date('2026-01-01T00:00:00Z'),
            revokedAt: null,
          },
        }),
      ),
    });

    // Context presents cert with serial '01' but DB has 'DEADBEEF'
    const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ─────────

  it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => {
    // Build a cert that claims an attacker's subjectUserId
    const attackerSubjectUserId = 'attacker-user-id';
    const attackerCertPem = await makeMosaicIssuedCert({
      grantId: GRANT_ID,
      subjectUserId: attackerSubjectUserId,
    });

    // DB returns a grant with the legitimate USER_ID
    const grantsService = makeGrantsService({
      getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })),
    });

    // Cert presents attacker-user-id but DB has USER_ID — should be rejected
    const { ctx, statusMock, sendMock } = makeContext({
      certPem: attackerCertPem,
      certSerialHex: CERT_SERIAL_HEX,
    });

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(false);
    expect(statusMock).toHaveBeenCalledWith(403);
    expect(sendMock).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }),
      }),
    );
  });

  // ── Happy path ────────────────────────────────────────────────────────────

  it('returns true and attaches federationContext on happy path', async () => {
    const grant = makeGrantWithPeer({
      status: 'active',
      peer: {
        id: PEER_ID,
        commonName: 'test-peer',
        displayName: 'Test Peer',
        certPem: '',
        certSerial: CERT_SERIAL_HEX,
        certNotAfter: new Date(Date.now() + 86_400_000),
        clientKeyPem: null,
        state: 'active',
        endpointUrl: null,
        lastSeenAt: null,
        createdAt: new Date('2026-01-01T00:00:00Z'),
        revokedAt: null,
      },
    });

    const grantsService = makeGrantsService({
      getGrantWithPeer: vi.fn().mockResolvedValue(grant),
    });

    // Build context manually to capture what gets set on request.federationContext
    const b64 = certPem
      .replace(/-----BEGIN CERTIFICATE-----/, '')
      .replace(/-----END CERTIFICATE-----/, '')
      .replace(/\s+/g, '');
    const raw = Buffer.from(b64, 'base64');
    const peerCert = { raw, serialNumber: CERT_SERIAL_HEX };

    const sendMock = vi.fn().mockReturnValue(undefined);
    const headerMock = vi.fn().mockReturnValue({ send: sendMock });
    const statusMock = vi.fn().mockReturnValue({ header: headerMock });

    const request: Record<string, unknown> = {
      raw: {
        socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) },
      },
    };

    const reply = { status: statusMock };

    const ctx = {
      switchToHttp: () => ({
        getRequest: () => request,
        getResponse: () => reply,
      }),
    } as unknown as ExecutionContext;

    const guard = new FederationAuthGuard(grantsService);
    const result = await guard.canActivate(ctx);

    expect(result).toBe(true);
    expect(statusMock).not.toHaveBeenCalled();

    // Verify the context was attached correctly
    expect(request['federationContext']).toEqual({
      grantId: GRANT_ID,
      subjectUserId: USER_ID,
      peerId: PEER_ID,
      scope: VALID_SCOPE,
    });
  });
});
