/**
 * FederationAuthGuard — NestJS CanActivate guard for inbound federation requests.
 *
 * Validates the mTLS client certificate presented by a peer gateway, extracts
 * custom OIDs to identify the grant + subject user, loads the grant from DB,
 * asserts it is active, and verifies the cert serial against the registered peer
 * cert serial as a defense-in-depth measure.
 *
 * On success, attaches `request.federationContext` for downstream verb controllers.
 * On failure, responds with the federation wire-format error envelope (not raw
 * NestJS exception JSON) to match the federation protocol contract.
 *
 * ## Cert-serial check decision
 * The guard validates that the inbound client cert's serial number matches the
 * `certSerial` stored on the associated `federation_peers` row. This is a
 * defense-in-depth measure: even if the mTLS handshake is compromised at the
 * transport layer (e.g. misconfigured TLS terminator that forwards arbitrary
 * client certs), an attacker cannot replay a cert with a different serial than
 * what was registered during enrollment. This check is NOT loosened because:
 *  1. It is O(1) — no additional DB round-trip (peerId is on the grant row,
 *     so we join to federationPeers in the same query).
 *  2. Cert renewal MUST update the stored serial — enforced by M6 scheduler.
 *  3. The OID-only path (without serial check) would allow any cert from the
 *     same CA bearing the same grantId OID to succeed after cert compromise.
 *
 * ## FastifyRequest typing path
 * NestJS + Fastify wraps the raw Node.js IncomingMessage in a FastifyRequest.
 * The underlying TLS socket is accessed via `request.raw.socket`, which is a
 * `tls.TLSSocket` when the server is listening on HTTPS. In development/test
 * the gateway may run over plain HTTP, in which case `getPeerCertificate` is
 * not available. The guard safely handles both cases by checking for the
 * method's existence before calling it.
 *
 * Note: The guard reads the peer certificate from the *already-completed*
 * TLS handshake via `socket.getPeerCertificate(detailed=true)`. This relies
 * on the server being configured with `requestCert: true` at the TLS level
 * so Fastify/Node.js requests the client cert during the handshake.
 * The guard does NOT verify the cert chain itself — that is handled by the
 * TLS layer (Node.js `rejectUnauthorized: true` with the CA cert pinned).
 */

import {
  type CanActivate,
  type ExecutionContext,
  Inject,
  Injectable,
  Logger,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as tls from 'node:tls';
import { X509Certificate } from '@peculiar/x509';
import { FederationForbiddenError, FederationUnauthorizedError } from '@mosaicstack/types';
import { extractMosaicOids } from '../oid.util.js';
import { GrantsService } from '../grants.service.js';
import type { FederationContext } from './federation-context.js';
import './federation-context.js'; // side-effect import: applies FastifyRequest module augmentation

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
 * Send a federation wire-format error response directly on the Fastify reply.
 * Returns false — callers return this value from canActivate.
 */
function sendFederationError(
  reply: FastifyReply,
  error: FederationUnauthorizedError | FederationForbiddenError,
): boolean {
  const statusCode = error.code === 'unauthorized' ? 401 : 403;
  void reply.status(statusCode).header('content-type', 'application/json').send(error.toEnvelope());
  return false;
}

// ---------------------------------------------------------------------------
// Guard
// ---------------------------------------------------------------------------

@Injectable()
export class FederationAuthGuard implements CanActivate {
  private readonly logger = new Logger(FederationAuthGuard.name);

  constructor(@Inject(GrantsService) private readonly grantsService: GrantsService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const http = context.switchToHttp();
    const request = http.getRequest<FastifyRequest>();
    const reply = http.getResponse<FastifyReply>();

    // ── Step 1: Extract peer certificate from TLS socket ────────────────────
    const rawSocket = request.raw.socket;

    // Check TLS socket: getPeerCertificate is only available on TLS connections.
    if (
      !rawSocket ||
      typeof (rawSocket as Partial<tls.TLSSocket>).getPeerCertificate !== 'function'
    ) {
      this.logger.warn('No TLS socket — client cert unavailable (non-mTLS connection)');
      return sendFederationError(
        reply,
        new FederationUnauthorizedError('Client certificate required'),
      );
    }

    const tlsSocket = rawSocket as tls.TLSSocket;
    const peerCert = tlsSocket.getPeerCertificate(true);

    // Node.js returns an object with empty string fields when no cert was presented.
    if (!peerCert || !peerCert.raw) {
      this.logger.warn('Peer certificate not presented (mTLS handshake did not supply cert)');
      return sendFederationError(
        reply,
        new FederationUnauthorizedError('Client certificate required'),
      );
    }

    // ── Step 2: Parse the DER-encoded certificate via @peculiar/x509 ────────
    let cert: X509Certificate;
    try {
      // peerCert.raw is a Buffer containing the DER-encoded cert
      cert = new X509Certificate(peerCert.raw);
    } catch (err) {
      this.logger.warn(
        `Failed to parse peer certificate: ${err instanceof Error ? err.message : String(err)}`,
      );
      return sendFederationError(
        reply,
        new FederationUnauthorizedError('Client certificate could not be parsed'),
      );
    }

    // ── Step 3: Extract Mosaic custom OIDs ──────────────────────────────────
    const oidResult = extractMosaicOids(cert);

    if (!oidResult.ok) {
      const message =
        oidResult.error === 'MISSING_GRANT_ID'
          ? 'Client certificate is missing required OID: mosaic_grant_id (1.3.6.1.4.1.99999.1)'
          : oidResult.error === 'MISSING_SUBJECT_USER_ID'
            ? 'Client certificate is missing required OID: mosaic_subject_user_id (1.3.6.1.4.1.99999.2)'
            : `Client certificate OID extraction failed: ${oidResult.detail ?? 'unknown error'}`;
      this.logger.warn(`OID extraction failure [${oidResult.error}]: ${message}`);
      return sendFederationError(reply, new FederationUnauthorizedError(message));
    }

    const { grantId, subjectUserId } = oidResult.value;

    // ── Step 4: Load grant from DB ───────────────────────────────────────────
    let grant: Awaited<ReturnType<GrantsService['getGrantWithPeer']>>;
    try {
      grant = await this.grantsService.getGrantWithPeer(grantId);
    } catch {
      // getGrantWithPeer throws NotFoundException when not found
      this.logger.warn(`Grant not found: ${grantId}`);
      return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
    }

    // ── Step 5: Assert grant is active ──────────────────────────────────────
    if (grant.status !== 'active') {
      this.logger.warn(`Grant ${grantId} is not active — status=${grant.status}`);
      return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
    }

    // ── Step 5b: Validate cert-extracted subjectUserId against DB (CRIT-1) ──
    // The cert claim is untrusted input; the DB row is authoritative.
    if (subjectUserId !== grant.subjectUserId) {
      this.logger.warn(`subjectUserId mismatch for grant ${grantId}`);
      return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
    }

    // ── Step 6: Defense-in-depth — cert serial must match registered peer ───
    // The serial number from Node.js TLS is upper-case hex without colons.
    // The @peculiar/x509 serialNumber is decimal. We compare using the native
    // Node.js crypto cert serial which is uppercase hex, matching DB storage.
    // Both are derived from the peerCert.serialNumber Node.js provides.
    const inboundSerial: string = peerCert.serialNumber ?? '';

    if (!grant.peer.certSerial) {
      // Peer row exists but has no stored serial — something is wrong with enrollment
      this.logger.error(`Peer ${grant.peerId} has no stored certSerial — enrollment incomplete`);
      return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
    }

    // Normalize both to uppercase for comparison (Node.js serialNumber is
    // already uppercase hex; DB value was stored from extractSerial() which
    // returns crypto.X509Certificate.serialNumber — also uppercase hex).
    if (inboundSerial.toUpperCase() !== grant.peer.certSerial.toUpperCase()) {
      this.logger.warn(
        `Cert serial mismatch for grant ${grantId}: ` +
          `inbound=${inboundSerial} registered=${grant.peer.certSerial}`,
      );
      return sendFederationError(reply, new FederationForbiddenError('Federation access denied'));
    }

    // ── Step 7: Attach FederationContext to request ──────────────────────────
    // Use grant.subjectUserId from DB (authoritative) — not the cert-extracted value.
    const federationContext: FederationContext = {
      grantId,
      subjectUserId: grant.subjectUserId,
      peerId: grant.peerId,
      scope: grant.scope as Record<string, unknown>,
    };

    request.federationContext = federationContext;

    this.logger.debug(
      `Federation auth OK — grantId=${grantId} peerId=${grant.peerId} subjectUserId=${grant.subjectUserId}`,
    );

    return true;
  }
}
