/**
 * CSRF middleware — single source of truth for CSRF enforcement.
 *
 * Enforces CSRF token validation on all mutating HTTP methods (POST, PUT,
 * PATCH, DELETE) except for explicitly opted-out routes.
 *
 * Design decisions:
 * - Header-only extraction: reads `x-csrf-token` request header only.
 *   Body extraction is intentionally skipped to avoid consuming the request
 *   stream before route handlers. All client code already sends the header.
 * - No body reads: middleware never calls request.json() — the stream remains
 *   intact for route handlers.
 * - CSRF token MAY be in body or header per client convention; middleware
 *   accepts the header form only (all active clients use x-csrf-token).
 *
 * Opt-out routes (no CSRF check):
 *   - Auth bootstrap routes — no session exists yet when logging in/registering;
 *     these routes use their own credential (Firebase token, password, OTP).
 *     /api/auth/firebase-verify
 *     /api/auth/login-email
 *     /api/auth/magic-link
 *     /api/auth/register
 *   - Logout — privilege-reducing operation; CSRF weaponisation not possible
 *     (OWASP logout exemption). /api/auth/logout
 *   - Token refresh — uses multideal_rt HttpOnly cookie as credential; attacker-triggered
 *     refresh yields no readable token. Without exemption expired JWT → 403 → no recovery.
 *     /api/auth/refresh
 *   - Webhooks (forward-looking) — use HMAC signature auth. /api/webhooks/*
 *   - Routes with defineApi csrf:'after-body' — body validation must run before CSRF so
 *     malformed-body requests get 400 before 403. defineApi enforces CSRF internally
 *     (after Zod validation). Route patterns listed in CSRF_OPT_OUT_PATTERNS.
 *     Currently: /api/purchases/<id>/redeem
 *   - Public unauthenticated POST routes — no session cookie to forge; each is protected
 *     by Zod validation and rate-limiting instead.
 *     /api/reports, /api/purchases/guest, /api/referrals/touch, /api/checkout/intent-guest
 *     /api/group-deals/<id>/reserve-guest (pattern — other subroutes require auth)
 *   - Stripe webhook — uses Stripe HMAC stripe-signature header (whsec_ key).
 *     /api/payments/stripe/webhook
 *
 * Wire this AFTER sessionMiddleware so locals.session is populated.
 */

import { defineMiddleware } from 'astro:middleware';
import { verifyCsrf } from '@/server/auth/csrf.js';

/** HTTP methods that mutate state and require CSRF protection. */
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

/**
 * Exact path prefixes that bypass CSRF enforcement.
 * Match is against `new URL(request.url).pathname`.
 */
const CSRF_OPT_OUT_PREFIXES = [
  // Auth bootstrap — no session yet; credential is the auth token/password itself
  '/api/auth/firebase-verify',
  '/api/auth/login-email',
  '/api/auth/magic-link',
  '/api/auth/magic-link/send',
  '/api/auth/register',
  // Logout — OWASP exemption; privilege-reducing, cannot be weaponised
  '/api/auth/logout',
  // Token refresh — credential is the multideal_rt HttpOnly cookie (path=/api/auth/refresh).
  // CSRF exemption safe: attacker-triggered refresh yields no readable token (cookies are HttpOnly).
  // Without exemption: expired JWT → no session → CSRF check 403 → fetchWithRefresh can't recover.
  '/api/auth/refresh',
  // Admin smoke endpoints — protected by Bearer CRON_SECRET header
  '/api/admin/smoke/',
  // Scheduler lifecycle endpoints — protected by Bearer CRON_SECRET in each handler.
  '/api/internal/scheduler/',
  // Outbox / queue consumer — protected by Bearer CRON_SECRET header (constant-time compare).
  // Session-skipped (no cookie), so CSRF can never pass; Bearer is the auth mechanism.
  // Adapter does not forward the `queue` export, so this HTTP POST is the actual drain path.
  '/api/queues/',
  // Webhooks — protected by HMAC signature (forward-looking paths)
  '/api/webhooks/',
  // E2E test endpoints — protected by E2E_SECRET header (constant-time compare).
  // Endpoint 404s when secret env unset; CSRF would leak existence via 403.
  '/api/test/',
  // Public feed — unauthenticated POST read (filter body, no state mutation).
  // No session required → no session CSRF token to compare against.
  '/api/feed',
  // Public feed markers — unauthenticated POST read for map pins, no state mutation.
  '/api/feed/markers',
  // Public batch deal lookup — unauthenticated POST read for guest recently-viewed.
  // No state mutation; ID list in body, no session required.
  '/api/deals/batch',
  // Public reports — unauthenticated POST; Zod-validated + rate-limited; no session cookie.
  '/api/reports',
  // Guest checkout — unauthenticated POST; Zod-validated + rate-limited; no session cookie.
  '/api/purchases/guest',
  // Guest checkout intent — unauthenticated POST; guest access token in body is the credential,
  // verified against its stored hash before any checkout effects run.
  '/api/checkout/intent-guest',
  // Referral touch — unauthenticated POST; no session cookie; Zod + rate-limited.
  '/api/referrals/touch',
  // Stripe webhook — protected by HMAC stripe-signature header (whsec_ secret).
  // Stripe requests carry no session; signature verification is the auth mechanism.
  '/api/payments/stripe/webhook',
] as const;

/**
 * Dynamic route patterns that bypass CSRF enforcement.
 * Used for routes with path parameters (e.g. /api/purchases/<uuid>/redeem)
 * that cannot be expressed as static prefix/exact strings.
 *
 * Routes listed here must use defineApi with csrf:'after-body' so CSRF is
 * enforced by the route handler after body validation, ensuring malformed
 * bodies return 400 before CSRF is evaluated.
 */
const CSRF_OPT_OUT_PATTERNS: RegExp[] = [
  // POST /api/purchases/<id>/redeem — vendor QR scan.
  // defineApi enforces CSRF after body validation (spec.csrf:'after-body').
  /^\/api\/purchases\/[^/]+\/redeem$/,
  // POST /api/group-deals/<id>/reserve-guest — unauthenticated guest reservation.
  // Only this specific subroute is public; all other group-deal routes require auth.
  /^\/api\/group-deals\/[^/]+\/reserve-guest$/,
];

/**
 * Returns true if the pathname matches any opt-out prefix or pattern.
 */
function isOptedOut(pathname: string): boolean {
  if (
    CSRF_OPT_OUT_PREFIXES.some((prefix) =>
      prefix.endsWith('/') ? pathname.startsWith(prefix) : pathname === prefix,
    )
  ) {
    return true;
  }
  return CSRF_OPT_OUT_PATTERNS.some((pattern) => pattern.test(pathname));
}

export const csrfMiddleware = defineMiddleware(async (context, next) => {
  const { request, locals } = context;
  const method = request.method.toUpperCase();

  // Only enforce on mutating methods
  if (!MUTATING_METHODS.has(method)) {
    return next();
  }

  const pathname = new URL(request.url).pathname;

  // Skip opted-out routes
  if (isOptedOut(pathname)) {
    return next();
  }

  // --- Enforce CSRF ---

  // If there's no session, there's no session CSRF token to compare against.
  // Return 403 (not 401) — the client must establish a session first, which
  // issues a csrf_token cookie that clients echo back via x-csrf-token header.
  const sessionCsrfToken = locals.session?.csrfToken;
  if (!sessionCsrfToken) {
    return new Response(
      JSON.stringify({ ok: false, error: 'CSRF validation failed', code: 'CSRF_INVALID' }),
      { status: 403, headers: { 'Content-Type': 'application/json' } },
    );
  }

  const headerToken = request.headers.get('x-csrf-token');

  const valid = await verifyCsrf({
    sessionCsrfToken,
    headerToken,
  });

  if (!valid) {
    return new Response(
      JSON.stringify({ ok: false, error: 'CSRF validation failed', code: 'CSRF_INVALID' }),
      { status: 403, headers: { 'Content-Type': 'application/json' } },
    );
  }

  return next();
});
