/**
 * EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
 *
 * Responsibilities:
 *  1. Generate time-limited single-use enrollment tokens (admin action).
 *  2. Redeem a token: validate → atomically claim token → issue cert via
 *     CaService → transactionally activate grant + update peer + write audit.
 *
 * Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
 * cert issuance. This prevents double cert minting on concurrent requests.
 * If cert issuance fails after claim, the token is consumed and the grant
 * stays pending — admin must create a new grant.
 */

import {
  BadRequestException,
  ConflictException,
  GoneException,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
// X509Certificate is available as a named export in Node.js ≥ 15.6
const { X509Certificate } = crypto;
import {
  type Db,
  and,
  eq,
  isNull,
  sql,
  federationEnrollmentTokens,
  federationGrants,
  federationPeers,
  federationAuditLog,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { CaService } from './ca.service.js';
import { GrantsService } from './grants.service.js';
import { FederationScopeError } from './scope-schema.js';
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';

export interface EnrollmentTokenResult {
  token: string;
  expiresAt: string;
}

export interface RedeemResult {
  certPem: string;
  certChainPem: string;
}

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

  constructor(
    @Inject(DB) private readonly db: Db,
    private readonly caService: CaService,
    private readonly grantsService: GrantsService,
  ) {}

  /**
   * Generate a single-use enrollment token for an admin to distribute
   * out-of-band to the remote peer operator.
   */
  async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
    const ttl = Math.min(dto.ttlSeconds, 900);

    // MED-3: Verify the grantId ↔ peerId binding — prevents attacker from
    // cross-wiring grants to attacker-controlled peers.
    const [grant] = await this.db
      .select({ peerId: federationGrants.peerId })
      .from(federationGrants)
      .where(eq(federationGrants.id, dto.grantId))
      .limit(1);
    if (!grant) {
      throw new NotFoundException(`Grant ${dto.grantId} not found`);
    }
    if (grant.peerId !== dto.peerId) {
      throw new BadRequestException(`peerId does not match the grant's registered peer`);
    }

    const token = crypto.randomBytes(32).toString('hex');
    const expiresAt = new Date(Date.now() + ttl * 1000);

    await this.db.insert(federationEnrollmentTokens).values({
      token,
      grantId: dto.grantId,
      peerId: dto.peerId,
      expiresAt,
    });

    this.logger.log(
      `Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
    );

    return { token, expiresAt: expiresAt.toISOString() };
  }

  /**
   * Redeem an enrollment token.
   *
   * Full flow:
   *  1. Fetch token row — NotFoundException if not found
   *  2. usedAt set → GoneException (already used)
   *  3. expiresAt < now → GoneException (expired)
   *  4. Load grant — verify status is 'pending'
   *  5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
   *     — if no rows returned, concurrent request won → GoneException
   *  6. Issue cert via CaService (network call, outside transaction)
   *     — if this fails, token is consumed; grant stays pending; admin must recreate
   *  7. Transaction: activate grant + update peer record + write audit log
   *  8. Return { certPem, certChainPem }
   */
  async redeem(token: string, csrPem: string): Promise<RedeemResult> {
    // HIGH-5: Track outcome so we can write a failure audit row on any error.
    let outcome: 'allowed' | 'denied' = 'denied';
    // row may be undefined if the token is not found — used defensively in catch.
    let row: typeof federationEnrollmentTokens.$inferSelect | undefined;

    try {
      // 1. Fetch token row
      const [fetchedRow] = await this.db
        .select()
        .from(federationEnrollmentTokens)
        .where(eq(federationEnrollmentTokens.token, token))
        .limit(1);

      if (!fetchedRow) {
        throw new NotFoundException('Enrollment token not found');
      }
      row = fetchedRow;

      // 2. Already used?
      if (row.usedAt !== null) {
        throw new GoneException('Enrollment token has already been used');
      }

      // 3. Expired?
      if (row.expiresAt < new Date()) {
        throw new GoneException('Enrollment token has expired');
      }

      // 4. Load grant and verify it is still pending
      let grant;
      try {
        grant = await this.grantsService.getGrant(row.grantId);
      } catch (err) {
        if (err instanceof FederationScopeError) {
          throw new BadRequestException(err.message);
        }
        throw err;
      }

      if (grant.status !== 'pending') {
        throw new GoneException(
          `Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
        );
      }

      // 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
      // WHERE used_at IS NULL ensures only one concurrent request wins.
      // Using .returning() works on both node-postgres and PGlite without rowCount inspection.
      const claimed = await this.db
        .update(federationEnrollmentTokens)
        .set({ usedAt: sql`NOW()` })
        .where(
          and(
            eq(federationEnrollmentTokens.token, token),
            isNull(federationEnrollmentTokens.usedAt),
          ),
        )
        .returning({ token: federationEnrollmentTokens.token });

      if (claimed.length === 0) {
        throw new GoneException('Enrollment token has already been used (concurrent request)');
      }

      // 6. Issue certificate via CaService (network call — outside any transaction).
      // If this throws, the token is already consumed. The grant stays pending.
      // Admin must revoke the grant and create a new one.
      let issued;
      try {
        issued = await this.caService.issueCert({
          csrPem,
          grantId: row.grantId,
          subjectUserId: grant.subjectUserId,
          ttlSeconds: 300,
        });
      } catch (err) {
        // HIGH-4: Log only the first 8 hex chars of the token for correlation — never log the full token.
        this.logger.error(
          `issueCert failed after token ${token.slice(0, 8)}... was claimed — grant ${row.grantId} is stranded pending`,
          err instanceof Error ? err.stack : String(err),
        );
        if (err instanceof FederationScopeError) {
          throw new BadRequestException((err as Error).message);
        }
        throw err;
      }

      // 7. Atomically activate grant, update peer record, and write audit log.
      const certNotAfter = this.extractCertNotAfter(issued.certPem);
      await this.db.transaction(async (tx) => {
        // CRIT-2: Guard activation with WHERE status='pending' to prevent double-activation.
        const [activated] = await tx
          .update(federationGrants)
          .set({ status: 'active' })
          .where(and(eq(federationGrants.id, row!.grantId), eq(federationGrants.status, 'pending')))
          .returning({ id: federationGrants.id });
        if (!activated) {
          throw new ConflictException(
            `Grant ${row!.grantId} is no longer pending — cannot activate`,
          );
        }

        // CRIT-2: Guard peer update with WHERE state='pending'.
        await tx
          .update(federationPeers)
          .set({
            certPem: issued.certPem,
            certSerial: issued.serialNumber,
            certNotAfter,
            state: 'active',
          })
          .where(and(eq(federationPeers.id, row!.peerId), eq(federationPeers.state, 'pending')));

        await tx.insert(federationAuditLog).values({
          requestId: crypto.randomUUID(),
          peerId: row!.peerId,
          grantId: row!.grantId,
          verb: 'enrollment',
          resource: 'federation_grant',
          statusCode: 200,
          outcome: 'allowed',
        });
      });

      this.logger.log(
        `Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
      );

      outcome = 'allowed';

      // 8. Return cert material
      return {
        certPem: issued.certPem,
        certChainPem: issued.certChainPem,
      };
    } catch (err) {
      // HIGH-5: Best-effort audit write on failure — do not let this throw.
      if (outcome === 'denied') {
        await this.db
          .insert(federationAuditLog)
          .values({
            requestId: crypto.randomUUID(),
            peerId: row?.peerId ?? null,
            grantId: row?.grantId ?? null,
            verb: 'enrollment',
            resource: 'federation_grant',
            statusCode:
              err instanceof GoneException ? 410 : err instanceof NotFoundException ? 404 : 500,
            outcome: 'denied',
          })
          .catch(() => {});
      }
      throw err;
    }
  }

  /**
   * Extract the notAfter date from a PEM certificate.
   * HIGH-2: No silent fallback — a cert that cannot be parsed should fail loud.
   */
  private extractCertNotAfter(certPem: string): Date {
    const cert = new X509Certificate(certPem);
    return new Date(cert.validTo);
  }
}
