import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

/**
 * Per-request CSP nonce - drops `'unsafe-inline'` from script-src in
 * prod by giving every inline script a unique nonce that Next reads
 * from the `content-security-policy` REQUEST header and threads through
 * its RSC bootstrap + Server Actions. build-auditor H1.
 *
 * Dev keeps `'unsafe-inline' 'unsafe-eval'` because Next HMR injects
 * runtime-evaluated scripts the nonce mechanism doesn't reach.
 * style-src stays at `'unsafe-inline'` because Tailwind/Radix runtime
 * style injection has no nonce story yet (revisit when Tailwind v5
 * ships a nonce-able API).
 */
function buildCspWithNonce(nonce: string, isProd: boolean): string {
  const scriptSrc = isProd
    ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`
    : "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://unpkg.com https://unpkg.com";

  const connectSrc = isProd
    ? "connect-src 'self' ws: wss: https:"
    : "connect-src 'self' ws: wss: https: http://unpkg.com https://unpkg.com";

  return [
    "default-src 'self'",
    scriptSrc,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: blob: https:",
    // https: so react-pdf/pdf.js can pull its standard-font pack (the PDF
    // viewers fetch LiberationSans etc. from a CDN) and port-branding fonts.
    "font-src 'self' data: https:",
    connectSrc,
    // PDF previews (signed EOIs etc.) iframe a presigned storage URL, and the
    // embedded-signing card iframes the Documenso host. Both are per-port /
    // per-env hosts, so allow https: (matching img-src/connect-src). This is
    // what WE may embed; frame-ancestors 'none' below still blocks others
    // from embedding us.
    "frame-src 'self' blob: https:",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'",
  ].join('; ');
}

function generateNonce(): string {
  // crypto.randomUUID is collision-free across requests; base64-encode
  // to match the CSP spec shape (no `-`/`_`-only restrictions for
  // CSP3 nonce values, but base64 stays safe across HTTP header
  // serialization).
  return Buffer.from(crypto.randomUUID()).toString('base64');
}

/**
 * Paths that do not require an authenticated session.
 * Checked with startsWith, so /auth/ covers /auth/callback etc.
 */
const PUBLIC_PATHS: string[] = [
  '/login',
  '/reset-password',
  '/set-password',
  '/auth/',
  '/api/auth/',
  '/api/public/',
  '/api/health',
  '/api/webhooks/',
  // First-run / cold-start: the unauthenticated /setup and /login pages
  // call /api/v1/bootstrap/status to decide whether to render the setup
  // form. The route handlers self-protect via hasAnySuperAdmin().
  '/setup',
  '/api/v1/bootstrap/',
  '/scan',
  // Tracked-link redirector. Outbound sales email embeds public
  // `<APP_URL>/q/<slug>` links whose only audience is unauthenticated
  // external recipients. The route self-protects (validates the slug
  // regex before any DB hit and only 302s to an admin-stored target),
  // so it belongs on the anonymous allowlist. Without this, every
  // tracked link bounced recipients to /login (audit C4).
  '/q/',
  // §7.1: public sales-playbook docs (deal pulse, etc) so the "Full
  // guide" link inside the in-app popover is reachable without a
  // session - and shareable to external collaborators.
  '/docs/',
  // M-R01: portal allowlist narrowed from blanket `/portal/` to the
  // unauthenticated entry-point routes only. Other `/portal/*` paths
  // now flow through the middleware backstop below which redirects to
  // `/portal/login` when the portal_session cookie is missing. Closes
  // the silent-bypass class where a new portal route landed without
  // its own session check.
  '/portal/login',
  '/portal/activate',
  '/portal/reset-password',
  // Portal API endpoints handle their own session checks (better-auth).
  '/api/portal/',
  // Token-gated email-change endpoints. The confirm/cancel links land in
  // a fresh browser (the user may not be signed in on this device), so
  // they need to bypass the session 401 gate. The endpoints validate a
  // signed sha256-hashed token instead - that's the auth.
  '/api/v1/me/email/confirm/',
  '/api/v1/me/email/cancel/',
];

function isPublicPath(pathname: string): boolean {
  // Per-port PWA manifests sit under `/<portSlug>/scan/manifest.webmanifest`
  // and need to be fetchable without a session - browsers fetch them eagerly
  // during install / first paint. The manifest only contains port name +
  // icon paths, no sensitive data, so making it public is safe.
  if (pathname.endsWith('/scan/manifest.webmanifest')) return true;
  return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
}

function isApiRoute(pathname: string): boolean {
  return pathname.startsWith('/api/');
}

const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

/**
 * SameSite=Lax cookies block top-level cross-site POSTs in modern browsers,
 * but defense-in-depth: every state-changing request to a session-authed
 * `/api/v1/**` endpoint must originate from the same origin as the app.
 * Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are
 * exempt because they're called by external systems with no session
 * cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle
 * their own origin/CSRF checks via better-auth.
 */
function isOriginCheckedPath(pathname: string): boolean {
  if (!pathname.startsWith('/api/v1/')) return false;
  return true;
}

function originAllowed(request: NextRequest): boolean {
  const origin = request.headers.get('origin');
  const referer = request.headers.get('referer');
  // Compare HOSTS, not full origins. TLS terminates at the reverse proxy,
  // so the upstream request the app sees is http://127.0.0.1 — its
  // protocol is unreliable, and request.nextUrl.origin reads `http` while
  // the browser's Origin is `https`, which would reject every same-site
  // mutation in production. The Host header is preserved across the proxy,
  // and a matching host is what same-origin CSRF defense actually needs
  // (a cross-site attacker can't forge the browser-set Origin host).
  const hostOf = (value: string | null): string | null => {
    if (!value) return null;
    try {
      return new URL(value).host;
    } catch {
      return null;
    }
  };
  // Acceptable hosts. Behind the TLS-terminating proxy request.nextUrl.host
  // can be the upstream bind (127.0.0.1:PORT) rather than the public host,
  // so it can't be the sole source of truth. The Host header is forwarded
  // verbatim by nginx (`proxy_set_header Host $host`), and APP_URL is the
  // canonical configured origin — trust those too. Comparing hosts (not
  // full origins) is intentional: TLS terminates upstream so the protocol
  // is unreliable, and a matching host is what CSRF defense needs.
  const allowedHosts = new Set(
    [request.headers.get('host'), hostOf(process.env.APP_URL ?? null), request.nextUrl.host].filter(
      (h): h is string => Boolean(h),
    ),
  );
  const candidate = origin ? hostOf(origin) : referer ? hostOf(referer) : null;
  if (candidate !== null) return allowedHosts.has(candidate);
  // Neither header present: most browser fetches always send Origin on
  // POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
  // call (e.g. Next.js internal fetch). Allow.
  return true;
}

/**
 * Apply per-request CSP to a NextResponse. Skipped for API routes
 * (they don't render HTML so script-src is irrelevant) and for
 * already-built responses that have set their own CSP (e.g. redirects
 * where the static next.config CSP applies).
 */
function applyCsp(response: NextResponse, nonce: string, pathname: string): NextResponse {
  if (isApiRoute(pathname)) return response;
  const isProd = process.env.NODE_ENV === 'production';
  response.headers.set('Content-Security-Policy', buildCspWithNonce(nonce, isProd));
  response.headers.set('x-nonce', nonce);
  return response;
}

export function proxy(request: NextRequest): NextResponse {
  const { pathname } = request.nextUrl;

  // Mint a per-request nonce up-front so HTML responses can carry it.
  // Cheap (one UUID + base64) so we always do it; the apply step
  // skips API routes that don't need it.
  const nonce = generateNonce();
  const isProd = process.env.NODE_ENV === 'production';

  // CSRF defense-in-depth: state-changing requests to authed /api/v1
  // endpoints must come from the app's own origin. Skipped in dev so
  // LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while
  // a Mac browser tab is loaded from localhost) doesn't trip on the
  // origin mismatch. Production keeps the check.
  if (
    process.env.NODE_ENV !== 'development' &&
    STATE_CHANGING_METHODS.has(request.method) &&
    isOriginCheckedPath(pathname) &&
    !originAllowed(request)
  ) {
    return NextResponse.json(
      { error: 'Cross-origin state-changing request rejected' },
      { status: 403 },
    );
  }

  // Always allow public paths through
  if (isPublicPath(pathname)) {
    // Forward the nonce in the REQUEST header so Next's RSC bootstrap
    // can read it via `headers()` and stamp it onto every inline
    // <script>. The browser-facing CSP is set on the response below.
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
    requestHeaders.set('x-nonce', nonce);
    return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
  }

  // M-R01: portal pages use a distinct cookie (`portal_session`) from
  // the CRM (`pn-crm.session_token`). Backstop here so any future
  // /portal/* page that forgets its own session check gets caught.
  if (pathname.startsWith('/portal/')) {
    const portalSession = request.cookies.get('portal_session');
    if (!portalSession?.value) {
      const loginUrl = new URL('/portal/login', request.url);
      loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
      return NextResponse.redirect(loginUrl);
    }
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
    requestHeaders.set('x-nonce', nonce);
    return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
  }

  // better-auth prefixes the cookie with `__Secure-` whenever it issues
  // secure cookies (production / HTTPS), so the name on the wire is
  // `__Secure-pn-crm.session_token` in prod but bare `pn-crm.session_token`
  // in dev. Check both, or every authenticated request in prod gets
  // bounced to /login because the gate can't find the (prefixed) cookie.
  const sessionToken =
    request.cookies.get('pn-crm.session_token') ??
    request.cookies.get('__Secure-pn-crm.session_token');

  if (!sessionToken?.value) {
    if (isApiRoute(pathname)) {
      // API routes return 401 JSON - never redirect
      return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
    }

    // Page routes redirect to /login, preserving the intended destination
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
    return NextResponse.redirect(loginUrl);
  }

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('content-security-policy', buildCspWithNonce(nonce, isProd));
  requestHeaders.set('x-nonce', nonce);
  return applyCsp(NextResponse.next({ request: { headers: requestHeaders } }), nonce, pathname);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static     (static files)
     * - _next/image      (Next.js image optimisation)
     * - favicon.ico      (browser tab icon)
     * - /images/         (public image assets)
     * - manifest.json    (PWA manifest - must be unauthed for installability)
     * - icon-*.png       (PWA + apple-touch icons referenced by manifest)
     * - apple-touch-icon (iOS home-screen icon)
     */
    '/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
  ],
};
