import { randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
import { readSessionTimeouts } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import { PersonAndUserProvisioner } from '../users/person-and-user-provisioner.service';
import { UserDirectoryService } from '../users/user-directory.service';
import type { AuthenticatedUser } from './auth.service';
import { PrincipalBuilder } from './principal-builder';

export type AuthSurface = 'user' | 'admin';

/**
 * Shared session-establishment recipe used by both `AuthController`
 * (user-portal) and `AdminAuthController` (admin-portal). Per
 * ADR-0020 §"Sessions — distinct from `portal-shell`", the two
 * surfaces use distinct cookies / Redis namespaces — but the
 * *recipe* for persisting an authenticated user into a session is
 * identical:
 *
 *   1. Mint a CSRF token (per ADR-0009 §"Double-submit CSRF").
 *   2. Populate the session fields (`user`, `createdAt`,
 *      `absoluteExpiresAt`, `csrfToken`, `mfaVerifiedAt`).
 *   3. Force `req.session.save()` before the 302 — `express-session`
 *      writes on response end, but the redirect closes the response
 *      before the async store write would otherwise complete.
 *   4. Mirror the CSRF token to the JS-readable cookie.
 *   5. Register the session id in the per-user index for future
 *      "logout everywhere" — best-effort, a Redis hiccup does NOT
 *      fail the sign-in.
 *   6. Emit the `auth.sign_in` audit row (blocking per ADR-0013).
 *   7. Log the success event.
 *
 * Surface-specific concerns — the redirect destination, the pre-auth
 * cookie clearing, the error paths — stay in the controllers.
 *
 * The middleware in `main.ts` has already resolved `req.session` to
 * the correct surface (user vs admin) by the time the controller's
 * callback handler runs; this service therefore writes to whichever
 * session was loaded without needing to know which one.
 */
@Injectable()
export class SessionEstablisher {
  constructor(
    private readonly logger: Logger,
    private readonly userSessionIndex: UserSessionIndexService,
    private readonly audit: AuditWriter,
    private readonly userDirectory: UserDirectoryService,
    private readonly personUserProvisioner: PersonAndUserProvisioner,
    private readonly principalBuilder: PrincipalBuilder,
  ) {}

  async establish(opts: {
    user: AuthenticatedUser;
    req: Request;
    res: Response;
    /**
     * Tag forwarded into the success log so dashboards can split
     * user-portal sign-ins from admin-portal sign-ins. Audit rows
     * keep the same `auth.sign_in` event type in both cases —
     * adding a surface field to the audit catalogue is a follow-up
     * decision (current ADR-0013 catalogue is single-tier).
     */
    surface: AuthSurface;
  }): Promise<void> {
    const { user, req, res, surface } = opts;
    const now = Date.now();
    const { idleSeconds, absoluteSeconds } = readSessionTimeouts();
    const csrfToken = randomBytes(32).toString('base64url');

    req.session.user = user;
    req.session.createdAt = now;
    // Hard ceiling per ADR-0010 §"TTL policy" — checked on every
    // request by the absolute-timeout middleware, independent of
    // idle TTL.
    req.session.absoluteExpiresAt = now + absoluteSeconds * 1000;
    req.session.csrfToken = csrfToken;
    // MFA freshness anchor per ADR-0011 §"Confirmation". Entra's
    // CA policy decides whether MFA actually happened — the BFF
    // does not re-validate factors. Refreshed by future step-up
    // re-auth flows.
    req.session.mfaVerifiedAt = now;
    // Provision Person + User (blocking per ADR-0026 §"Lifecycle"):
    // the BFF cannot build a Principal without User.id / Person.id,
    // so a Postgres outage here fails the sign-in. Idempotent —
    // returns the existing pair's UUIDs on subsequent sign-ins.
    // Note: the Entra `preferred_username` claim maps to Person.email
    // (it's typically the user's UPN / email); `AuthenticatedUser.username`
    // is the BFF's name for the same field.
    const identity = await this.personUserProvisioner.ensureUser({
      oid: user.oid,
      tenantId: user.tid,
      displayName: user.displayName,
      email: user.username,
    });

    // Authorization principal per ADR-0025: composes the three
    // axes (privileges / functional roles / scopes) once at
    // sign-in so guards read a single coherent shape instead of
    // re-parsing claims on every request. Built before the
    // session is persisted so a Redis hiccup either persists
    // everything or nothing.
    const principal = await this.principalBuilder.build(user, identity);
    req.session.principal = { ...principal, mfaVerifiedAt: now };

    await saveSession(req);

    // Cookie maxAge matches the session's idle TTL so the CSRF
    // cookie expires alongside the session itself (rolling,
    // refreshed on each request).
    res.cookie(csrfCookieName(), csrfToken, csrfCookieOptions(idleSeconds * 1000));

    // Best-effort: a Redis hiccup here doesn't fail sign-in.
    await this.userSessionIndex.add(user.oid, req.sessionID);

    // Blocking audit per ADR-0013. If this throws the user does
    // NOT see a successful sign-in: the exception propagates and
    // the controller emits a 5xx via the StructuredErrorFilter.
    await this.audit.signIn({ actor: user, sessionId: req.sessionID });

    // Best-effort directory upsert (ADR-0020 §"User list"). MUST
    // NOT fail the sign-in — the directory is a convenience for
    // admin browsing, not a security boundary. The service catches
    // its own errors and logs them; awaiting it means an admin
    // that just signed in sees themselves on the user list
    // immediately, without racing the response.
    await this.userDirectory.recordSignIn({
      oid: user.oid,
      tid: user.tid,
      username: user.username,
      displayName: user.displayName,
      // v1: single workforce-tenant audience per ADR-0008. The
      // dual-audience design ships a real value here when External
      // ID is activated.
      audience: 'workforce',
    });

    this.logger.log(
      {
        event: 'auth.signed_in',
        surface,
        oid: user.oid,
        tid: user.tid,
        username: user.username,
        amr: user.amr,
      },
      'AuthCallback',
    );
  }

  /**
   * Symmetric helper for the sign-out path. When `actor` is set,
   * removes the session id from the per-user index and emits the
   * `auth.sign_out` audit row (blocking per ADR-0013 — if the audit
   * row can't be written, the user does NOT get a "you're logged
   * out" experience). Then tears the session down unconditionally
   * — anonymous sign-outs still benefit from a `req.session.destroy()`
   * call to clear any orphan state in the store. A Redis hiccup on
   * `destroy()` is logged but non-fatal: clearing the cookie at the
   * HTTP layer (the controller's job) is sufficient to log the user
   * out from the BFF's point of view; the orphan Redis key will hit
   * its idle TTL on its own.
   *
   * The controller is responsible for HTTP-layer cleanup — the
   * session-cookie name is surface-specific (`portal_session` vs
   * `portal_admin_session`) and lives on the controller.
   */
  async destroy(opts: { actor: AuthenticatedUser | undefined; req: Request }): Promise<void> {
    const { actor, req } = opts;

    if (actor !== undefined) {
      const sessionId = req.sessionID;
      await this.userSessionIndex.remove(actor.oid, sessionId);
      await this.audit.signOut({ actor, sessionId });
    }

    try {
      await destroySession(req);
    } catch (err) {
      this.logger.error(
        {
          event: 'session.destroy_failed',
          message: err instanceof Error ? err.message : String(err),
        },
        'AuthLogout',
      );
    }
  }
}

function saveSession(req: Request): Promise<void> {
  return new Promise((resolve, reject) => {
    req.session.save((err) => (err ? reject(err) : resolve()));
  });
}

function destroySession(req: Request): Promise<void> {
  return new Promise((resolve, reject) => {
    req.session.destroy((err) => (err ? reject(err) : resolve()));
  });
}
