import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import type { MfaRequiredReason } from '../audit/audit.types';
import { readMfaConfig } from '../config/check-mfa-config';
import { wasMultiFactor } from './mfa';
import type { RequireMfaOptions } from './require-mfa.decorator';

/**
 * Reflector metadata key carrying the per-route `RequireMfaOptions`.
 * Re-exported by the decorator module so the two stay in lockstep.
 */
export const REQUIRE_MFA_METADATA = 'auth:require-mfa';

/**
 * 401 response code emitted on every MFA-step-up rejection. The SPA
 * interceptor (per ADR-0011 §"Step-up MFA — SPA cooperation") will
 * pivot on this exact value to distinguish a step-up challenge from
 * a plain "no session" 401 — and trigger an Entra re-auth round
 * trip with a claims challenge in a later PR. v1 surfaces the code
 * without the `WWW-Authenticate` header / MSAL claims blob; both
 * land when the SPA interceptor is built.
 */
const MFA_REQUIRED_CODE = 'mfa_required';

/**
 * `RequireMfaGuard` — enforces ADR-0011's step-up MFA freshness
 * window.
 *
 * Contract
 * --------
 * - **No session → 401 `unauthenticated`.** Same shape as the
 *   `AdminRoleGuard` 401 branch: no audit row (unauthenticated
 *   traffic is noise). The user just needs to sign in.
 *
 * - **Session with no MFA-class `amr` → 401 `mfa_required`.** The
 *   session is authenticated but does not reflect an MFA assertion
 *   at all (password-only sign-in, CA policy mis-skipped MFA, etc.).
 *   Emits `auth.mfa_required` with `reason=no-mfa-in-amr`.
 *
 * - **Session with stale `mfaVerifiedAt` → 401 `mfa_required`.**
 *   The user did MFA at some point but the assertion is older than
 *   the freshness window. Emits `auth.mfa_required` with
 *   `reason=mfa-stale`. The `mfaAgeMs` payload field lets auditors
 *   see how far past freshness the rejection landed.
 *
 * - **Session with no `mfaVerifiedAt` at all → 401 `mfa_required`.**
 *   Defensive — sessions created before this field was introduced
 *   would otherwise leak past the guard. Emits
 *   `auth.mfa_required` with `reason=no-mfa-verified-at`.
 *
 * - **Audit-write failures propagate.** Same posture as
 *   `AdminRoleGuard`: no audit ⇒ no action per ADR-0013.
 *
 * Freshness resolution: per-route `RequireMfaOptions.freshness`
 * wins; otherwise the env-driven `MFA_FRESHNESS_SECONDS` (default
 * 600 s, min 60 s — validated by `readMfaConfig`).
 */
@Injectable()
export class RequireMfaGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly audit: AuditWriter,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request>();
    const user = req.session?.user;

    if (!user) {
      throw new UnauthorizedException({
        code: 'unauthenticated',
        message: 'Unauthenticated',
      });
    }

    const options = this.resolveOptions(context);
    const freshnessSeconds = options.freshness ?? readMfaConfig().freshnessSeconds;
    const route = `${req.method} ${req.originalUrl}`;
    const now = Date.now();

    if (!wasMultiFactor(user.amr)) {
      await this.audit.mfaRequired({
        actor: { oid: user.oid },
        attemptedRoute: route,
        reason: 'no-mfa-in-amr',
        freshnessSeconds,
      });
      throw this.mfaChallenge('no-mfa-in-amr');
    }

    const verifiedAt = req.session?.mfaVerifiedAt;
    if (verifiedAt === undefined) {
      await this.audit.mfaRequired({
        actor: { oid: user.oid },
        attemptedRoute: route,
        reason: 'no-mfa-verified-at',
        freshnessSeconds,
      });
      throw this.mfaChallenge('no-mfa-verified-at');
    }

    const ageMs = now - verifiedAt;
    if (ageMs > freshnessSeconds * 1000) {
      await this.audit.mfaRequired({
        actor: { oid: user.oid },
        attemptedRoute: route,
        reason: 'mfa-stale',
        freshnessSeconds,
        mfaAgeMs: ageMs,
      });
      throw this.mfaChallenge('mfa-stale');
    }

    return true;
  }

  private resolveOptions(context: ExecutionContext): RequireMfaOptions {
    // Method-level metadata wins over class-level — same merge
    // strategy Nest's built-in guards use. `getAllAndOverride`
    // returns the first non-undefined match in the lookup order.
    return (
      this.reflector.getAllAndOverride<RequireMfaOptions>(REQUIRE_MFA_METADATA, [
        context.getHandler(),
        context.getClass(),
      ]) ?? {}
    );
  }

  private mfaChallenge(reason: MfaRequiredReason): UnauthorizedException {
    // Structured-error envelope per ADR-0021 — the
    // StructuredErrorFilter expects `code` + `message` on the
    // exception response body. The `reason` is intentionally NOT
    // surfaced over the wire: an attacker shouldn't be able to
    // distinguish a stale session from a no-MFA session by probing.
    // The audit row carries the reason for forensic use.
    void reason;
    return new UnauthorizedException({
      code: MFA_REQUIRED_CODE,
      message: 'Fresh multi-factor authentication required',
    });
  }
}
