import { execFile } from 'child_process';
import { existsSync } from 'fs';
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { BadRequestException, ConflictException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';

const execFileAsync = promisify(execFile);

const CA_KEY = 'ca.key';
const CA_CERT = 'ca.crt';
const SERVER_KEY = 'server.key';
const SERVER_CERT = 'server.crt';

const CA_DAYS = 3650;
const DEVICE_CERT_DAYS = 3650;

// CN charsets are validated here (not just at the DTO boundary) because they
// are interpolated into openssl's "-subj" string, where a stray "/" or "="
// could inject extra subject fields (e.g. "/CN=x/OU=admin") even though
// execFile's argv-array form already rules out shell injection.
const HOSTNAME_PATTERN = /^[a-zA-Z0-9.-]{1,253}$/;
const SERIAL_PATTERN = /^[A-Z0-9-]{1,64}$/;

export interface CaStatus {
  initialized: boolean;
  fingerprint?: string;
  expiresAt?: Date;
}

export interface IssuedCert {
  certificatePem: string;
  privateKeyPem: string;
  fingerprint: string;
  expiresAt: Date;
}

// Acts as the root CA for MQTT device client certificates: shells out to the
// openssl CLI (added to backend/Dockerfile via apk) rather than a JS crypto
// library. The CA/server key pair live only on disk (bind-mounted into the
// mosquitto container at /mosquitto/certs) and are never written to the DB.
@Injectable()
export class PkiService {
  private readonly certsDir = process.env.MQTT_CERTS_DIR || '/mosquitto-certs';

  private path(name: string): string {
    return join(this.certsDir, name);
  }

  private caExists(): boolean {
    return existsSync(this.path(CA_KEY)) && existsSync(this.path(CA_CERT));
  }

  async caStatus(): Promise<CaStatus> {
    if (!this.caExists()) {
      return { initialized: false };
    }
    const [fingerprint, expiresAt] = await Promise.all([
      this.fingerprintOf(this.path(CA_CERT)),
      this.expiryOf(this.path(CA_CERT)),
    ]);
    return { initialized: true, fingerprint, expiresAt };
  }

  async initCa(): Promise<void> {
    if (this.caExists()) {
      throw new ConflictException('CA already initialized');
    }
    await mkdir(this.certsDir, { recursive: true });
    await this.run([
      'req',
      '-x509',
      '-newkey',
      'rsa:4096',
      '-sha256',
      '-days',
      String(CA_DAYS),
      '-nodes',
      '-keyout',
      this.path(CA_KEY),
      '-out',
      this.path(CA_CERT),
      '-subj',
      '/CN=UlHub Device CA',
    ]);
  }

  async provisionServerCert(hostname: string): Promise<void> {
    if (!this.caExists()) {
      throw new NotFoundException('Initialize the CA before provisioning a broker certificate');
    }
    if (!HOSTNAME_PATTERN.test(hostname)) {
      throw new BadRequestException('Invalid hostname');
    }
    const dir = await mkdtemp(join(tmpdir(), 'ulhub-mqtt-server-'));
    try {
      const csrPath = join(dir, 'server.csr');
      await this.run([
        'req',
        '-new',
        '-newkey',
        'rsa:2048',
        '-nodes',
        '-keyout',
        this.path(SERVER_KEY),
        '-out',
        csrPath,
        '-subj',
        `/CN=${hostname}`,
      ]);
      await this.run([
        'x509',
        '-req',
        '-in',
        csrPath,
        '-CA',
        this.path(CA_CERT),
        '-CAkey',
        this.path(CA_KEY),
        '-CAcreateserial',
        '-out',
        this.path(SERVER_CERT),
        '-days',
        String(CA_DAYS),
        '-sha256',
      ]);
      // Mosquitto runs as its own non-root "mosquitto" user in-container;
      // openssl writes -keyout as 0600 (owner-only), which that user can't
      // read. The CA key itself stays 0600 (only this service ever reads
      // it) — only the broker's own key needs to be world-readable, mirroring
      // the reference implementation's documented reasoning exactly.
      await chmod(this.path(SERVER_KEY), 0o644);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  }

  async caCertPem(): Promise<string> {
    if (!this.caExists()) {
      throw new NotFoundException('CA not initialized');
    }
    return readFile(this.path(CA_CERT), 'utf8');
  }

  // `days` defaults to the permanent 10-year validity used by the
  // ORG_ADMIN-issued device cert; callers minting short-lived session certs
  // (see DeviceMqttAuthService) pass a much shorter override.
  async issueDeviceCert(serialNumber: string, days: number = DEVICE_CERT_DAYS): Promise<IssuedCert> {
    if (!this.caExists()) {
      throw new NotFoundException('Initialize the CA before issuing device certificates');
    }
    const cn = serialNumber.toUpperCase();
    if (!SERIAL_PATTERN.test(cn)) {
      throw new BadRequestException(
        'Device serial number must be 1-64 chars of A-Z, 0-9, or hyphen to be used as a certificate CN',
      );
    }

    const dir = await mkdtemp(join(tmpdir(), 'ulhub-device-cert-'));
    try {
      const keyPath = join(dir, 'device.key');
      const csrPath = join(dir, 'device.csr');
      const certPath = join(dir, 'device.crt');

      await this.run(['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]);
      await this.run([
        'x509',
        '-req',
        '-in',
        csrPath,
        '-CA',
        this.path(CA_CERT),
        '-CAkey',
        this.path(CA_KEY),
        '-CAcreateserial',
        '-out',
        certPath,
        '-days',
        String(Math.max(1, Math.round(days))),
        '-sha256',
      ]);

      const [certificatePem, privateKeyPem, fingerprint, expiresAt] = await Promise.all([
        readFile(certPath, 'utf8'),
        readFile(keyPath, 'utf8'),
        this.fingerprintOf(certPath),
        this.expiryOf(certPath),
      ]);
      return { certificatePem, privateKeyPem, fingerprint, expiresAt };
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  }

  // Verifies that `signatureBase64` is a valid RSA-SHA256 signature over
  // `payload`, produced by the private key matching `certificatePem`'s public
  // key — i.e. proof of possession without ever seeing the private key
  // itself. A non-zero openssl exit here means "signature didn't verify",
  // which is an expected outcome, not a server error — so this deliberately
  // doesn't go through the shared `run()` helper, which treats any non-zero
  // exit as a 500.
  async verifySignature(certificatePem: string, payload: string, signatureBase64: string): Promise<boolean> {
    const dir = await mkdtemp(join(tmpdir(), 'ulhub-verify-'));
    try {
      const certPath = join(dir, 'cert.pem');
      const pubkeyPath = join(dir, 'pubkey.pem');
      const payloadPath = join(dir, 'payload.txt');
      const sigPath = join(dir, 'signature.bin');

      await writeFile(certPath, certificatePem, 'utf8');
      await writeFile(payloadPath, payload, 'utf8');
      let signature: Buffer;
      try {
        signature = Buffer.from(signatureBase64, 'base64');
      } catch {
        return false;
      }
      await writeFile(sigPath, signature);

      try {
        await execFileAsync('openssl', ['x509', '-in', certPath, '-pubkey', '-noout', '-out', pubkeyPath]);
      } catch (err: any) {
        throw new InternalServerErrorException(`openssl x509 failed: ${err.stderr || err.message}`);
      }

      try {
        await execFileAsync('openssl', ['dgst', '-sha256', '-verify', pubkeyPath, '-signature', sigPath, payloadPath]);
        return true;
      } catch (err: any) {
        // Exit code 1 with "Verification Failure" / "Verification failure" is
        // the expected shape of a bad signature. Anything else (malformed
        // signature bytes openssl can't even parse, etc.) still resolves to
        // "not verified" from the caller's perspective — a forged or garbled
        // signature is not verified either way.
        return false;
      }
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  }

  private async fingerprintOf(certPath: string): Promise<string> {
    const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']);
    const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/);
    return match ? match[1] : stdout.trim();
  }

  private async expiryOf(certPath: string): Promise<Date> {
    const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-enddate']);
    const match = stdout.match(/notAfter=(.+)/);
    if (!match) {
      throw new InternalServerErrorException('Could not read certificate expiry');
    }
    return new Date(match[1].trim());
  }

  private async run(args: string[]): Promise<{ stdout: string; stderr: string }> {
    try {
      return await execFileAsync('openssl', args);
    } catch (err: any) {
      throw new InternalServerErrorException(`openssl ${args[0]} failed: ${err.stderr || err.message}`);
    }
  }
}
