import { timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { errorResponse } from './structured-error.filter';

/**
 * Methods that, per RFC 7231 / 9110, MUST NOT alter server state.
 * The CSRF check skips them — there is nothing to forge for routes
 * that don't change anything.
 */
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

/**
 * Paths the CSRF middleware lets through unconditionally. These are
 * the OIDC entry points that *create* the session (and the token
 * along with it) — there is no token to verify yet.
 *
 * `GET /auth/logout` is a safe method so it's already covered by
 * `SAFE_METHODS`; if a future PR moves logout to POST, the bypass
 * here would need extending.
 */
const CSRF_EXEMPT_PREFIXES = ['/api/auth/login', '/api/auth/callback'];

/**
 * Double-submit CSRF middleware (per ADR-0009 §"CSRF defense").
 *
 * Contract:
 *   - GET / HEAD / OPTIONS pass through.
 *   - Unauthenticated requests pass through. CSRF protects an
 *     authenticated identity from being abused by another origin;
 *     there is no identity to abuse on anonymous routes. (Anonymous
 *     POST endpoints, if they ever exist, will need a different
 *     defense — site-key, captcha, etc.)
 *   - `/api/auth/login` and `/api/auth/callback` are exempt: they
 *     are the routes that *establish* the session and thereby
 *     mint the CSRF token.
 *   - Any other request must carry `X-CSRF-Token` whose value
 *     equals `req.session.csrfToken`. Comparison is
 *     `crypto.timingSafeEqual` to avoid leaking the token via
 *     response-time differences.
 *   - Mismatch / missing → 403 `{ error: 'csrf' }`. No further
 *     processing.
 *
 * The middleware reads the token from the **session**, not the
 * cookie. The cookie is the SPA's read-only mirror; an attacker
 * who can plant a cookie (subdomain takeover, etc.) cannot also
 * plant a matching session-stored token without already being the
 * authenticated user. Tying the check to the server-side session
 * is what distinguishes "session-bound double-submit" from the
 * weaker pure cookie-vs-header comparison.
 */
export function createCsrfMiddleware(logger: Logger): RequestHandler {
  return function csrf(req: Request, res: Response, next: NextFunction): void {
    if (SAFE_METHODS.has(req.method.toUpperCase())) {
      next();
      return;
    }

    const session = req.session;
    if (!session?.user) {
      // Anonymous mutating request — out of scope for CSRF v1.
      next();
      return;
    }

    if (CSRF_EXEMPT_PREFIXES.some((p) => req.path.startsWith(p))) {
      next();
      return;
    }

    const expected = session.csrfToken;
    const headerToken = req.get('X-CSRF-Token');

    if (!expected || !headerToken || !equalsTimingSafe(headerToken, expected)) {
      logger.warn(
        {
          event: 'csrf.reject',
          path: req.path,
          method: req.method,
          hasHeaderToken: Boolean(headerToken),
          hasSessionToken: Boolean(expected),
        },
        'Csrf',
      );
      res.status(403).json(errorResponse('csrf', 'CSRF token missing or invalid'));
      return;
    }

    next();
  };
}

function equalsTimingSafe(a: string, b: string): boolean {
  // `timingSafeEqual` requires equal-length buffers. Different
  // lengths short-circuit as unequal — we accept the early return
  // here because the length itself is not a secret.
  const ba = Buffer.from(a, 'utf8');
  const bb = Buffer.from(b, 'utf8');
  if (ba.length !== bb.length) {
    return false;
  }
  return timingSafeEqual(ba, bb);
}
