import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { principalCoversResource, type ScopableResource } from 'shared-auth';
import { AuditWriter } from '../audit/audit.service';
import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor';
import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator';

/**
 * `RequireScopeGuard` — enforces ADR-0025 scope gates.
 *
 * Contract
 * --------
 * - **No session → 401.** Same posture as the privilege / role
 *   guards.
 * - **Extractor returned `null` → 403 + audit.** The route's
 *   "what resource am I protecting?" question has no answer for
 *   this request; deny is the safe direction. Audit row carries
 *   `required: []` so an auditor can spot the missing-resource
 *   pattern.
 * - **No matching scope → 403 + audit.** Standard denial; audit
 *   captures the resource descriptor as `required` and the
 *   principal's actual scope list as `held`.
 * - **Matching scope → pass.**
 *
 * Like the other two new guards, the response body is the
 * ADR-0021 structured-error envelope with a generic `forbidden`
 * code — no resource fingerprint leaks back to the caller.
 */
@Injectable()
export class RequireScopeGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly audit: AuditWriter,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const extractor = this.reflector.getAllAndOverride<RequireScopeExtractor>(
      REQUIRE_SCOPE_METADATA,
      [context.getHandler(), context.getClass()],
    );
    if (extractor === undefined) {
      return true;
    }

    const req = context.switchToHttp().getRequest<Request>();
    const principal = readSessionPrincipal(req);
    if (principal === null) {
      throw new UnauthorizedException({
        code: 'unauthenticated',
        message: 'Unauthenticated',
      });
    }

    const resource = await extractor(req);
    if (resource === null) {
      await this.audit.authorizationDenied({
        actor: { oid: principal.user.entraOid },
        attemptedRoute: `${req.method} ${req.originalUrl}`,
        kind: 'scope',
        required: [],
        held: scopesToAuditStrings(principal.scopes),
      });
      throw new ForbiddenException({
        code: 'forbidden',
        message: 'Forbidden',
      });
    }

    if (principalCoversResource(principal, resource)) {
      return true;
    }

    await this.audit.authorizationDenied({
      actor: { oid: principal.user.entraOid },
      attemptedRoute: `${req.method} ${req.originalUrl}`,
      kind: 'scope',
      required: describeResource(resource),
      held: scopesToAuditStrings(principal.scopes),
    });
    throw new ForbiddenException({
      code: 'forbidden',
      message: 'Forbidden',
    });
  }
}

/**
 * Serialise the resource descriptor into the `string[]` shape the
 * audit row's `required` field expects. Mirrors the `scope:value`
 * convention of {@link scopesToAuditStrings} so the two sides of
 * the audit row read consistently — easy `requiredVs.held` diff
 * in a log dashboard.
 */
function describeResource(resource: ScopableResource): string[] {
  const out: string[] = [];
  if (resource.personId !== undefined) {
    out.push(`self:${resource.personId}`);
  }
  if (resource.etablissementFiness !== undefined) {
    out.push(`etablissement:${resource.etablissementFiness}`);
  }
  if (resource.delegationCode !== undefined) {
    out.push(`delegation:${resource.delegationCode}`);
  }
  if (resource.regionCode !== undefined) {
    out.push(`region:${resource.regionCode}`);
  }
  if (resource.isSiege === true) {
    out.push('siege');
  }
  return out;
}
