import { Injectable } from '@nestjs/common';
import { trace } from '@opentelemetry/api';
import { ClsService } from 'nestjs-cls';
import { PrismaService } from 'nestjs-prisma';
import type {
  AdminAccessDeniedInput,
  AdminAuditQueryInput,
  AdminUsersQueryInput,
  AuditEventInput,
  MfaRequiredInput,
  SignInActor,
  SignInFailedInput,
  SignOutInput,
  SessionExpiredInput,
} from './audit.types';
import { HashUserIdService } from './hash-user-id.service';

/**
 * AuditWriter — single entry point for ADR-0013 audit-log writes.
 *
 * Contract
 * --------
 * - **Append-only at the database level.** Every write runs inside a
 *   transaction whose first statement is `SET LOCAL ROLE
 *   audit_writer`. That role only has `INSERT` on `audit.events`
 *   (per the migration that created the table); `UPDATE`, `DELETE`,
 *   `TRUNCATE` all fail at the Postgres level even if the BFF
 *   connection is otherwise privileged. The role is reset
 *   automatically at transaction end.
 *
 * - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes":
 *   no audit ⇒ no action. Callers must propagate the rejection up
 *   so the requested action does not proceed when its audit trail
 *   cannot be written. The service throws the underlying Prisma
 *   error unchanged; do not wrap it in a catch-and-log block.
 *
 * - **trace_id and actor_id_hash are auto-resolved.** trace_id is
 *   read from the active OTel span context (so the audit row joins
 *   with the BFF request span and the Pino log lines on the same
 *   request). actor_id_hash is read from the CLS context populated
 *   by future auth guards (ADR-0009 / ADR-0010); v1 stores `null`
 *   when no actor is established. Callers can override either by
 *   passing them on `AuditEventInput`.
 */
@Injectable()
export class AuditWriter {
  constructor(
    private readonly prisma: PrismaService,
    private readonly cls: ClsService,
    private readonly hashUserId: HashUserIdService,
  ) {}

  /**
   * Typed event: successful sign-in via the OIDC callback. Per
   * ADR-0013's v1 catalogue (`auth.sign_in`).
   *
   * Hashes the user id internally — callers pass the raw Entra
   * `oid`, never the hash, so the salt stays inside the audit
   * module.
   */
  async signIn(input: { actor: SignInActor; sessionId: string }): Promise<void> {
    await this.recordEvent({
      eventType: 'auth.sign_in',
      audience: 'workforce',
      outcome: 'success',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      subject: `session:${input.sessionId}`,
      payload: { amr: input.actor.amr },
    });
  }

  /**
   * Typed event: failed sign-in at the callback. The `failureKind`
   * mirrors the discriminator on `AuthCodeFlowError` so the audit
   * row is self-describing without joining anything.
   *
   * `actorIdHash` is left null on purpose: at the moment of
   * failure we may not have resolved an identity yet (state
   * mismatch, expired flow, token-exchange error before any user
   * claim was parsed). Callers can pass an explicit hash when the
   * identity *was* resolved before rejection.
   */
  async signInFailed(input: SignInFailedInput): Promise<void> {
    await this.recordEvent({
      eventType: 'auth.sign_in.failed',
      audience: 'workforce',
      outcome: 'failure',
      ...(input.actor !== undefined ? { actorIdHash: this.hashUserId.hash(input.actor.oid) } : {}),
      payload: { failureKind: input.failureKind, ...(input.payload ?? {}) },
    });
  }

  async signOut(input: SignOutInput): Promise<void> {
    await this.recordEvent({
      eventType: 'auth.sign_out',
      audience: 'workforce',
      outcome: 'success',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      subject: `session:${input.sessionId}`,
    });
  }

  /**
   * Typed event: session destroyed by the absolute-timeout
   * middleware (12 h hard ceiling, ADR-0010 §"TTL policy"). The
   * idle-TTL expiry is *not* surfaced through this method — it
   * happens silently inside Redis with no BFF observation point.
   */
  async sessionExpired(input: SessionExpiredInput): Promise<void> {
    await this.recordEvent({
      eventType: 'auth.session.expired',
      audience: 'workforce',
      outcome: 'success',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      subject: `session:${input.sessionId}`,
      payload: { reason: input.reason, ageMs: input.ageMs },
    });
  }

  /**
   * Typed event: a request was rejected by `RequireMfaGuard` because
   * the session did not satisfy the route's MFA freshness gate.
   * Per ADR-0011 §"Step-up MFA — designed-in, dormant", every
   * step-up challenge emitted by the BFF lands here so auditors can
   * track the distribution of step-up prompts (and the rate of stale
   * `mfaVerifiedAt` rejections that would suggest the default
   * freshness is too tight). `outcome=denied` matches the
   * `AdminRoleGuard` posture: the user *is* authenticated, the
   * action is just refused.
   */
  async mfaRequired(input: MfaRequiredInput): Promise<void> {
    await this.recordEvent({
      eventType: 'auth.mfa_required',
      audience: 'workforce',
      outcome: 'denied',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      subject: input.attemptedRoute,
      payload: {
        reason: input.reason,
        freshnessSeconds: input.freshnessSeconds,
        ...(input.mfaAgeMs !== undefined ? { mfaAgeMs: input.mfaAgeMs } : {}),
      },
    });
  }

  /**
   * Typed event: an admin queried the audit-log viewer. Recorded at
   * **coarser granularity** per ADR-0020 §"Audit log captures every
   * admin write action … Read actions (audit log viewing, user list
   * browsing) are also captured … to deter fishing expeditions".
   *
   * The filter object the admin used is serialised into the payload
   * so a reviewer can see what was searched (event type, time range,
   * actor hash, etc.). `resultCount` is the number of rows the SPA
   * received in this trip — paged calls each emit one event; an
   * auditor spotting a sequence of `admin.audit.query` with growing
   * offsets is the v1 detection signal for a sweep.
   *
   * `outcome=success` regardless of whether the query returned any
   * rows: the event captures "the admin ran a query", not "the
   * query matched something". An `outcome=failure` audit row from
   * this surface is reserved for the day we add per-row authZ.
   */
  async adminAuditQuery(input: AdminAuditQueryInput): Promise<void> {
    await this.recordEvent({
      eventType: 'admin.audit.query',
      audience: 'workforce',
      outcome: 'success',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      payload: {
        filters: input.filters,
        resultCount: input.resultCount,
      },
    });
  }

  /**
   * Typed event: an admin queried the user-directory viewer. Same
   * deterrent posture as {@link adminAuditQuery} per ADR-0020
   * §"Read actions ... to deter fishing expeditions" — captures the
   * filter and the result count so a reviewer can spot a sweep
   * (admin paging through everyone) without joining anything.
   */
  async adminUsersQuery(input: AdminUsersQueryInput): Promise<void> {
    await this.recordEvent({
      eventType: 'admin.users.query',
      audience: 'workforce',
      outcome: 'success',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      payload: {
        filters: input.filters,
        resultCount: input.resultCount,
      },
    });
  }

  /**
   * Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
   * because the session's `roles` claim does not include `Portal.Admin`.
   * Per ADR-0020 §"Auth — same Entra ID … `Portal.Admin` role claim", every
   * 403 from the admin surface is captured here with the attempted
   * route and the roles the user actually held — auditors looking
   * for privilege-escalation attempts pivot on `subject` (the route)
   * and `outcome=denied`.
   */
  async adminAccessDenied(input: AdminAccessDeniedInput): Promise<void> {
    await this.recordEvent({
      eventType: 'admin.access_denied',
      audience: 'workforce',
      outcome: 'denied',
      actorIdHash: this.hashUserId.hash(input.actor.oid),
      subject: input.attemptedRoute,
      payload: { rolesHeld: input.rolesHeld },
    });
  }

  async recordEvent(input: AuditEventInput): Promise<void> {
    const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
    const actorIdHash =
      input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
    const payloadJson = input.payload === undefined ? null : JSON.stringify(input.payload);

    await this.prisma.$transaction(async (tx) => {
      // Lock the connection to audit_writer for the duration of this
      // transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the
      // pool's next consumer sees the original role.
      await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);

      // Deliberately NOT `tx.auditEvent.create(...)`. The Prisma ORM
      // create() emits `INSERT … RETURNING *` to hydrate the entity
      // it returns, and Postgres requires SELECT on every column
      // listed in RETURNING. `audit_writer` is granted INSERT only
      // (ADR-0013 §"Append-only by role grants"); RETURNING fails
      // with the deeply misleading "permission denied for table
      // events" error code 42501. Raw parameterised INSERT keeps
      // the role contract strict — audit_writer never needs SELECT.
      //
      // `gen_random_uuid()` is built into Postgres 13+ (the dev /
      // prod target is 17). The enum + jsonb casts are needed
      // because the parameter values are sent as TEXT over the
      // wire.
      await tx.$executeRawUnsafe(
        `INSERT INTO "audit"."events"
           (id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
         VALUES (
           gen_random_uuid(),
           $1,
           $2::"audit"."AuditAudience",
           $3::"audit"."AuditOutcome",
           $4,
           $5,
           $6,
           $7::jsonb
         )`,
        input.eventType,
        input.audience,
        input.outcome,
        input.subject ?? null,
        actorIdHash,
        traceId,
        payloadJson,
      );
    });
  }
}
