/**
 * CaService — Step-CA client for federation grant certificate issuance.
 *
 * Responsibilities:
 *  1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
 *     private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
 *     claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
 *     step-ca JWK provisioner protocol.
 *  2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
 *     pinning the trust to the CA root cert supplied via env.
 *  3. Return an IssuedCertDto containing the leaf cert, full chain, and
 *     serial number.
 *
 * Environment variables (all required at runtime — validated in constructor):
 *   STEP_CA_URL                   https://step-ca:9000
 *   STEP_CA_PROVISIONER_KEY_JSON  JWK provisioner private key (JSON)
 *   STEP_CA_ROOT_CERT_PATH        Absolute path to the CA root PEM
 *
 * Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
 *   STEP_CA_PROVISIONER_PASSWORD  JWK provisioner password (raw string)
 *
 * Custom OID registry (PRD §6, docs/federation/SETUP.md):
 *   1.3.6.1.4.1.99999.1  — mosaic_grant_id
 *   1.3.6.1.4.1.99999.2  — mosaic_subject_user_id
 *
 * Fail-loud contract:
 *   Every error path throws CaServiceError with a human-readable `remediation`
 *   field. Silent OID-stripping is NEVER allowed — if the sign response does
 *   not include the cert, we throw rather than return a cert that may be
 *   missing the custom extensions.
 */

import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as https from 'node:https';
import { SignJWT, importJWK } from 'jose';
import { Pkcs10CertificateRequest, X509Certificate } from '@peculiar/x509';
import type { IssueCertRequestDto } from './ca.dto.js';
import { IssuedCertDto } from './ca.dto.js';

// ---------------------------------------------------------------------------
// Custom error class
// ---------------------------------------------------------------------------

export class CaServiceError extends Error {
  readonly cause: unknown;
  readonly remediation: string;
  readonly code?: string;

  constructor(message: string, remediation: string, cause?: unknown, code?: string) {
    super(message);
    this.name = 'CaServiceError';
    this.cause = cause;
    this.remediation = remediation;
    this.code = code;
  }
}

// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------

interface StepSignResponse {
  crt: string;
  ca?: string;
  certChain?: string[];
}

interface JwkKey {
  kty: string;
  kid?: string;
  use?: string;
  alg?: string;
  k?: string; // symmetric
  n?: string; // RSA
  e?: string;
  d?: string;
  x?: string; // EC
  y?: string;
  crv?: string;
  [key: string]: unknown;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** UUID regex for validation */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
 * Derive the JWT algorithm string from a JWK's kty/crv fields.
 * EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
 */
function algFromJwk(jwk: JwkKey): string {
  if (jwk.alg) return jwk.alg;
  if (jwk.kty === 'EC') {
    if (jwk.crv === 'P-384') return 'ES384';
    return 'ES256'; // default for P-256 and Ed25519-style EC keys
  }
  if (jwk.kty === 'RSA') return 'RS256';
  throw new CaServiceError(
    `Unsupported JWK kty: ${jwk.kty}`,
    'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
  );
}

/**
 * Compute SHA-256 fingerprint of the DER-encoded CSR body.
 * step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
 */
function csrFingerprint(csrPem: string): string {
  // Strip PEM headers and decode base64 body
  const b64 = csrPem
    .replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
    .replace(/-----END CERTIFICATE REQUEST-----/, '')
    .replace(/\s+/g, '');

  let derBuf: Buffer;
  try {
    derBuf = Buffer.from(b64, 'base64');
  } catch (err) {
    throw new CaServiceError(
      'Failed to base64-decode the CSR PEM body',
      'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
      err,
    );
  }

  if (derBuf.length === 0) {
    throw new CaServiceError(
      'CSR PEM decoded to empty buffer — malformed input',
      'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
    );
  }

  return crypto.createHash('sha256').update(derBuf).digest('hex');
}

/**
 * Send a JSON POST to the step-ca sign endpoint.
 * Returns the parsed response body or throws CaServiceError.
 */
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
  return new Promise((resolve, reject) => {
    const bodyStr = JSON.stringify(body);
    const parsed = new URL(url);

    const options: https.RequestOptions = {
      hostname: parsed.hostname,
      port: parsed.port ? parseInt(parsed.port, 10) : 443,
      path: parsed.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(bodyStr),
      },
      agent,
      timeout: 5000,
    };

    const req = https.request(options, (res) => {
      const chunks: Buffer[] = [];
      res.on('data', (chunk: Buffer) => chunks.push(chunk));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf8');

        if (res.statusCode === 401) {
          reject(
            new CaServiceError(
              `step-ca returned HTTP 401 — invalid or expired OTT`,
              'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
            ),
          );
          return;
        }

        if (res.statusCode && res.statusCode >= 400) {
          reject(
            new CaServiceError(
              `step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
              `Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
            ),
          );
          return;
        }

        let parsed: unknown;
        try {
          parsed = JSON.parse(raw) as unknown;
        } catch (err) {
          reject(
            new CaServiceError(
              'step-ca returned a non-JSON response',
              'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
              err,
            ),
          );
          return;
        }

        resolve(parsed as StepSignResponse);
      });
    });

    req.setTimeout(5000, () => {
      req.destroy(new Error('Request timed out after 5000ms'));
    });

    req.on('error', (err: Error) => {
      reject(
        new CaServiceError(
          `HTTPS connection to step-ca failed: ${err.message}`,
          'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
          err,
        ),
      );
    });

    req.write(bodyStr);
    req.end();
  });
}

/**
 * Extract a decimal serial number from a PEM certificate.
 * Throws CaServiceError on failure — never silently returns 'unknown'.
 */
function extractSerial(certPem: string): string {
  let cert: crypto.X509Certificate;
  try {
    cert = new crypto.X509Certificate(certPem);
  } catch (err) {
    throw new CaServiceError(
      'Failed to parse the issued certificate PEM',
      'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
      err,
      'CERT_PARSE',
    );
  }
  return cert.serialNumber;
}

// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------

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

  private readonly caUrl: string;
  private readonly rootCertPath: string;
  private readonly httpsAgent: https.Agent;
  private readonly jwk: JwkKey;
  private cachedPrivateKey: crypto.KeyObject | null = null;
  private readonly jwtAlg: string;
  private readonly kid: string;

  constructor() {
    const caUrl = process.env['STEP_CA_URL'];
    const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
    const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];

    if (!caUrl) {
      throw new CaServiceError(
        'STEP_CA_URL is not set',
        'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
      );
    }

    // Enforce HTTPS-only URL
    let parsedUrl: URL;
    try {
      parsedUrl = new URL(caUrl);
    } catch (err) {
      throw new CaServiceError(
        `STEP_CA_URL is not a valid URL: ${caUrl}`,
        'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
        err,
      );
    }
    if (parsedUrl.protocol !== 'https:') {
      throw new CaServiceError(
        `STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
        'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
      );
    }

    if (!provisionerKeyJson) {
      throw new CaServiceError(
        'STEP_CA_PROVISIONER_KEY_JSON is not set',
        'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
      );
    }
    if (!rootCertPath) {
      throw new CaServiceError(
        'STEP_CA_ROOT_CERT_PATH is not set',
        'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
      );
    }

    // Parse JWK once — do NOT store the raw JSON string as a class field
    let jwk: JwkKey;
    try {
      jwk = JSON.parse(provisionerKeyJson) as JwkKey;
    } catch (err) {
      throw new CaServiceError(
        'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
        'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
        err,
      );
    }

    // Derive algorithm from JWK metadata
    const jwtAlg = algFromJwk(jwk);
    const kid = jwk.kid ?? 'mosaic-fed';

    // Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
    // We do this synchronously here by calling the async importJWK via a blocking workaround.
    // Actually importJWK is async, so we store it for use during token building.
    // We keep the raw jwk object for later async import inside buildOtt.
    // NOTE: We do NOT store provisionerKeyJson string as a class field.
    this.jwk = jwk;
    this.jwtAlg = jwtAlg;
    this.kid = kid;

    this.caUrl = caUrl;
    this.rootCertPath = rootCertPath;

    // Read the root cert and pin it for all HTTPS connections.
    let rootCert: string;
    try {
      rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
    } catch (err) {
      throw new CaServiceError(
        `Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
        'Ensure the file exists and is readable by the gateway process.',
        err,
      );
    }

    this.httpsAgent = new https.Agent({
      ca: rootCert,
      rejectUnauthorized: true,
    });

    this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
  }

  /**
   * Lazily import the private key from JWK on first use.
   * The key is cached in cachedPrivateKey after first import.
   */
  private async getPrivateKey(): Promise<crypto.KeyObject> {
    if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
    try {
      const key = await importJWK(this.jwk, this.jwtAlg);
      // importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
      this.cachedPrivateKey = key as unknown as crypto.KeyObject;
      return this.cachedPrivateKey;
    } catch (err) {
      throw new CaServiceError(
        'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
        'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
        err,
      );
    }
  }

  /**
   * Build the JWK-provisioner OTT signed with the provisioner private key.
   * Algorithm is derived from the JWK kty/crv fields.
   */
  private async buildOtt(params: {
    csrPem: string;
    grantId: string;
    subjectUserId: string;
    ttlSeconds: number;
    csrCn: string;
  }): Promise<string> {
    const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;

    // Validate UUID shape for grant id and subject user id
    if (!UUID_RE.test(grantId)) {
      throw new CaServiceError(
        `grantId is not a valid UUID: ${grantId}`,
        'Provide a valid UUID (RFC 4122) for grantId.',
        undefined,
        'INVALID_GRANT_ID',
      );
    }
    if (!UUID_RE.test(subjectUserId)) {
      throw new CaServiceError(
        `subjectUserId is not a valid UUID: ${subjectUserId}`,
        'Provide a valid UUID (RFC 4122) for subjectUserId.',
        undefined,
        'INVALID_GRANT_ID',
      );
    }

    const sha = csrFingerprint(csrPem);
    const now = Math.floor(Date.now() / 1000);
    const privateKey = await this.getPrivateKey();

    const ott = await new SignJWT({
      iss: this.kid,
      sub: csrCn, // M1: set sub to identity from CSR CN
      aud: [`${this.caUrl}/1.0/sign`],
      iat: now,
      nbf: now - 30, // 30 s clock-skew tolerance
      exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
      jti: crypto.randomUUID(), // M2: unique token ID
      // step.sha is the canonical field name used in the template — M3: keep only step.sha
      step: { sha },
      // Mosaic custom claims consumed by federation.tpl
      mosaic_grant_id: grantId,
      mosaic_subject_user_id: subjectUserId,
    })
      .setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
      .sign(privateKey);

    return ott;
  }

  /**
   * Validate a PEM-encoded CSR using @peculiar/x509.
   * Verifies the self-signature, key type/size, and signature algorithm.
   * Optionally verifies that the CSR's SANs match the expected set.
   *
   * Throws CaServiceError with code 'INVALID_CSR' on failure.
   */
  private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
    let csr: Pkcs10CertificateRequest;
    try {
      csr = new Pkcs10CertificateRequest(pem);
    } catch (err) {
      throw new CaServiceError(
        'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
        'Provide a valid PEM-encoded PKCS#10 CSR.',
        err,
        'INVALID_CSR',
      );
    }

    // Verify self-signature
    let valid: boolean;
    try {
      valid = await csr.verify();
    } catch (err) {
      throw new CaServiceError(
        'CSR signature verification threw an error',
        'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
        err,
        'INVALID_CSR',
      );
    }
    if (!valid) {
      throw new CaServiceError(
        'CSR self-signature is invalid',
        'The CSR must be self-signed with the corresponding private key.',
        undefined,
        'INVALID_CSR',
      );
    }

    // Validate signature algorithm — reject MD5 and SHA-1
    // signatureAlgorithm is HashedAlgorithm which extends Algorithm.
    // Cast through unknown to access .name and .hash.name without DOM lib globals.
    const sigAlgAny = csr.signatureAlgorithm as unknown as {
      name?: string;
      hash?: { name?: string };
    };
    const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
    const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
    if (
      sigAlgName.includes('md5') ||
      sigAlgName.includes('sha1') ||
      hashName === 'sha-1' ||
      hashName === 'sha1'
    ) {
      throw new CaServiceError(
        `CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
        'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
        undefined,
        'INVALID_CSR',
      );
    }

    // Validate public key algorithm and strength via the algorithm descriptor on the key.
    // csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
    // We cast to an extended interface to access curve/modulus info without DOM globals.
    const pubKeyAlgo = csr.publicKey.algorithm as {
      name: string;
      namedCurve?: string;
      modulusLength?: number;
    };
    const keyAlgoName = pubKeyAlgo.name;

    if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
      const modulusLength = pubKeyAlgo.modulusLength ?? 0;
      if (modulusLength < 2048) {
        throw new CaServiceError(
          `CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
          'Use an RSA key of at least 2048 bits.',
          undefined,
          'INVALID_CSR',
        );
      }
    } else if (keyAlgoName === 'ECDSA') {
      const namedCurve = pubKeyAlgo.namedCurve ?? '';
      const allowedCurves = new Set(['P-256', 'P-384']);
      if (!allowedCurves.has(namedCurve)) {
        throw new CaServiceError(
          `CSR EC key uses disallowed curve: ${namedCurve}`,
          'Use EC P-256 or P-384. Other curves are not permitted.',
          undefined,
          'INVALID_CSR',
        );
      }
    } else if (keyAlgoName === 'Ed25519') {
      // Ed25519 is explicitly allowed
    } else {
      throw new CaServiceError(
        `CSR uses unsupported key algorithm: ${keyAlgoName}`,
        'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
        undefined,
        'INVALID_CSR',
      );
    }

    // Extract SANs if expectedSans provided
    if (expectedSans && expectedSans.length > 0) {
      // Get SANs from CSR extensions
      const sanExtension = csr.extensions?.find(
        (ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
      );
      const csrSans: string[] = [];
      if (sanExtension) {
        // Parse the raw SAN extension — store as stringified for comparison
        // @peculiar/x509 exposes SANs through the parsed extension
        const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
        if (sanExt.names) {
          for (const name of sanExt.names) {
            csrSans.push(name.value);
          }
        }
      }

      const csrSanSet = new Set(csrSans);
      const expectedSanSet = new Set(expectedSans);
      const missing = expectedSans.filter((s) => !csrSanSet.has(s));
      const extra = csrSans.filter((s) => !expectedSanSet.has(s));

      if (missing.length > 0 || extra.length > 0) {
        throw new CaServiceError(
          `CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
          'The CSR must include exactly the SANs specified in the issuance request.',
          undefined,
          'INVALID_CSR',
        );
      }
    }

    // Return the CN from the CSR subject for use as JWT sub
    const cn = csr.subjectName.getField('CN')?.[0] ?? '';
    return cn;
  }

  /**
   * Submit a CSR to step-ca and return the issued certificate.
   *
   * Throws `CaServiceError` on any failure (network, auth, malformed input).
   * Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
   */
  async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
    // Clamp TTL to 15-minute maximum (H2)
    const ttl = Math.min(req.ttlSeconds ?? 300, 900);

    this.logger.debug(
      `issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
    );

    // Validate CSR — real cryptographic validation (H3)
    const csrCn = await this.validateCsr(req.csrPem);

    const ott = await this.buildOtt({
      csrPem: req.csrPem,
      grantId: req.grantId,
      subjectUserId: req.subjectUserId,
      ttlSeconds: ttl,
      csrCn,
    });

    const signUrl = `${this.caUrl}/1.0/sign`;
    const requestBody = {
      csr: req.csrPem,
      ott,
      validity: {
        duration: `${ttl}s`,
      },
    };

    this.logger.debug(`Posting CSR to ${signUrl}`);
    const response = await httpsPost(signUrl, requestBody, this.httpsAgent);

    if (!response.crt) {
      throw new CaServiceError(
        'step-ca sign response missing the "crt" field',
        'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
      );
    }

    // Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
    let certChainPem: string;
    if (response.certChain && response.certChain.length > 0) {
      certChainPem = response.certChain.join('\n');
    } else if (response.ca) {
      certChainPem = response.crt + '\n' + response.ca;
    } else {
      certChainPem = response.crt;
    }

    const serialNumber = extractSerial(response.crt);

    // CRIT-1: Verify the issued certificate contains both Mosaic OID extensions
    // with the correct values. Step-CA's federation.tpl encodes each as an ASN.1
    // UTF8String TLV: tag 0x0C + 1-byte length + UUID bytes. We skip 2 bytes
    // (tag + length) to extract the raw UUID string.
    const issuedCert = new X509Certificate(response.crt);
    const decoder = new TextDecoder();

    const grantIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.1');
    if (!grantIdExt) {
      throw new CaServiceError(
        'Issued certificate is missing required Mosaic OID: mosaic_grant_id',
        'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.1. Check the provisioner template configuration.',
        undefined,
        'OID_MISSING',
      );
    }
    const grantIdInCert = decoder.decode(grantIdExt.value.slice(2));
    if (grantIdInCert !== req.grantId) {
      throw new CaServiceError(
        `Issued certificate mosaic_grant_id mismatch: expected ${req.grantId}, got ${grantIdInCert}`,
        'The Step-CA issued a certificate with a different grant ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
        undefined,
        'OID_MISMATCH',
      );
    }

    const subjectUserIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.2');
    if (!subjectUserIdExt) {
      throw new CaServiceError(
        'Issued certificate is missing required Mosaic OID: mosaic_subject_user_id',
        'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.2. Check the provisioner template configuration.',
        undefined,
        'OID_MISSING',
      );
    }
    const subjectUserIdInCert = decoder.decode(subjectUserIdExt.value.slice(2));
    if (subjectUserIdInCert !== req.subjectUserId) {
      throw new CaServiceError(
        `Issued certificate mosaic_subject_user_id mismatch: expected ${req.subjectUserId}, got ${subjectUserIdInCert}`,
        'The Step-CA issued a certificate with a different subject user ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
        undefined,
        'OID_MISMATCH',
      );
    }

    this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);

    const result = new IssuedCertDto();
    result.certPem = response.crt;
    result.certChainPem = certChainPem;
    result.serialNumber = serialNumber;
    return result;
  }
}
