import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName } from '../security/csrf-cookie';
import { sessionCookieName } from './session-cookie';
import { UserSessionIndexService } from './user-session-index.service';

/**
 * Build the absolute-timeout middleware per ADR-0010 §"TTL policy":
 *
 *   absoluteExpiresAt is recorded at session creation and checked
 *   on every request. If exceeded, the BFF deletes the session key
 *   and clears the cookie.
 *
 * Behaviour on expired session (decided 2026-05-12 with the project
 * lead):
 *   - Destroy the Redis-side session (`req.session.destroy()`).
 *   - Remove the session id from the `user_sessions:{userId}` index.
 *   - Clear the `portal_session` cookie on the response so the
 *     browser stops sending it.
 *   - Call `next()` — the request keeps flowing with no session.
 *
 * The non-intrusive choice over a hard 401: `/me` answers 401 on its
 * own when the session is gone, public routes keep serving, and the
 * user isn't surprised by a 401 on a route that never needed auth.
 *
 * Idle expiry (Redis EXPIRE on the session key) is *not* handled
 * here — `connect-redis` already lets the key disappear on its own
 * and the next request sees no session.
 */
export function createAbsoluteTimeoutMiddleware(
  index: UserSessionIndexService,
  logger: Logger,
  audit: AuditWriter,
  now: () => number = Date.now,
): RequestHandler {
  return async function absoluteTimeout(
    req: Request,
    res: Response,
    next: NextFunction,
  ): Promise<void> {
    const session = req.session;
    const absoluteExpiresAt = session?.absoluteExpiresAt;
    const user = session?.user;

    // Anonymous, freshly-created, or pre-hardening session — nothing
    // to enforce. `absoluteExpiresAt` is only set together with `user`
    // in the callback (per ADR-0010), so a session without it either
    // isn't post-auth or is a leftover from a deploy that predates
    // this PR. Pass through.
    if (!session || !user || !absoluteExpiresAt) {
      next();
      return;
    }

    if (now() <= absoluteExpiresAt) {
      next();
      return;
    }

    // Past the hard ceiling — tear it down. Capture identifiers
    // before destroy() drops `req.session`.
    const userId = user.oid;
    const sessionId = req.sessionID;
    const ageMs = now() - (session.createdAt ?? absoluteExpiresAt);

    try {
      await destroySession(req);
    } catch (err) {
      logger.error(
        {
          event: 'session.absolute_timeout.destroy_failed',
          sessionId,
          message: err instanceof Error ? err.message : String(err),
        },
        'AbsoluteTimeout',
      );
      // Fall through: still clear the cookie + index so the client
      // is effectively signed out from the BFF's perspective.
    }

    await index.remove(userId, sessionId);
    res.clearCookie(sessionCookieName(), { path: '/' });
    res.clearCookie(csrfCookieName(), { path: '/' });

    // Audit the expiry — blocking per ADR-0013: if the audit row
    // can't be written, the request fails (the user sees a 5xx,
    // ops gets a critical Pino line). The session was already
    // destroyed above, so the user is signed out regardless — the
    // failure surfaces in the response, not the security posture.
    await audit.sessionExpired({
      actor: { oid: userId },
      sessionId,
      reason: 'absolute',
      ageMs,
    });

    logger.log(
      {
        event: 'session.absolute_timeout',
        userId,
        sessionId,
        ageMs,
      },
      'AbsoluteTimeout',
    );

    next();
  };
}

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