/**
 * Security headers middleware.
 *
 * Sets hardened HTTP security headers on every response:
 * - Content-Security-Policy (strict, nonce-based)
 * - Strict-Transport-Security
 * - X-Content-Type-Options
 * - Referrer-Policy
 * - Permissions-Policy
 * - X-Frame-Options (belt-and-suspenders alongside CSP frame-ancestors)
 *
 * The CSP nonce is generated per-request via crypto.getRandomValues() and
 * stored in locals.cspNonce for use in Astro layouts.
 */

import { defineMiddleware } from 'astro:middleware';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

export function buildCsp(
  frameAncestors: "'none'" | "'self'",
  upgradeInsecureRequests = true,
): string {
  const directives: string[] = [
    // Default to same-origin only
    "default-src 'self'",

    // Scripts: same-origin + unsafe-inline.
    // Astro injects inline <script> tags for island hydration without nonce support.
    // As of Astro 6.1.5, there is no experimental.security or nonce propagation
    // config option - the framework does not add nonce attributes to its injected
    // <script> tags. Per CSP spec, 'unsafe-inline' is ignored when a nonce is
    // present, so switching to nonce-based CSP would break all Astro-injected
    // scripts. We generate and store a nonce in locals.cspNonce for future use
    // (e.g. manually nonced scripts in layouts), but the directive must remain
    // 'unsafe-inline' until Astro adds native nonce propagation support.
    // TODO: revisit when Astro ships experimental.security.nonce or equivalent.
    // Allow Stripe.js + Cloudflare Web Analytics beacon.
    // Stripe.js does not require 'unsafe-eval' — no conditional needed.
    [
      "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'",

      'https://js.stripe.com',
      // Stripe Connect embedded components (StripeVendorDashboard)
      'https://connect-js.stripe.com',
      'https://static.cloudflareinsights.com',
      // vConsole debug panel — loaded only when ENVIRONMENT=preview && ?vconsole=1.
      // Restricted to the exact versioned path to prevent CSP bypass via other jsdelivr content.
      'https://cdn.jsdelivr.net/npm/vconsole@3/dist/vconsole.min.js',
      // Firebase Auth reCAPTCHA (phone OTP verification)
      'https://www.google.com/recaptcha/',
      'https://www.gstatic.com/recaptcha/',
      'https://apis.google.com',
      // Cloudflare Turnstile — loads turnstile/v0/api.js on the login page.
      'https://challenges.cloudflare.com',
    ].join(' '),

    // Styles: same-origin + unsafe-inline (needed for Tailwind's inline styles in Workers)
    "style-src 'self' 'unsafe-inline'",

    // Images: self + blob: (ImageUploadField dimension validation) + Cloudflare Images CDN
    // + imagedelivery.net (Cloudflare Images delivery CDN — offer/deal/vendor images)
    // + multideal-preview.workers.dev for cross-origin image requests
    // + OSM tile servers (StoresMap + MapView Leaflet tiles)
    // + unpkg.com for Leaflet default marker icon PNGs (Vite asset-hash workaround)
    // + placehold.co for E2E fixture placeholder images in test/dev environments
    "img-src 'self' data: blob: https://imagedelivery.net https://multideal-preview.workers.dev https://*.tile.openstreetmap.org https://unpkg.com https://placehold.co https://www.gravatar.com",

    // Fonts: self (we self-host @fontsource fonts)
    "font-src 'self'",

    // Fetch/XHR: self + backend services
    [
      "connect-src 'self'",
      'https://verify.twilio.com',
      'https://*.resend.com',
      // Sentry envelope egress — EU/DE region ingest (DSN o…ingest.de.sentry.io)
      'https://*.ingest.de.sentry.io',
      // Stripe.js + Stripe Connect embedded components
      'https://api.stripe.com',
      'https://connect.stripe.com',
      // Cloudflare Web Analytics beacon ping — both origins required:
      // static.cloudflareinsights.com serves beacon.min.js (script-src covers load,
      // connect-src covers the XHR/fetch the script itself makes back to report telemetry).
      'https://cloudflareinsights.com',
      'https://static.cloudflareinsights.com',
      // Firebase Auth (identitytoolkit + securetoken + firebaseinstallations)
      'https://*.googleapis.com',
      // reCAPTCHA telemetry/verification callbacks
      'https://www.google.com',
    ].join(' '),

    // Frame: OpenStreetMap address picker + Firebase reCAPTCHA challenge iframe
    // + Stripe Elements iframes (js.stripe.com) + Connect embedded iframes (connect.stripe.com)
    // + Cloudflare Turnstile challenge iframe (challenges.cloudflare.com)
    "frame-src 'self' https://js.stripe.com https://connect-js.stripe.com https://connect.stripe.com https://www.openstreetmap.org https://www.google.com/recaptcha/ https://challenges.cloudflare.com",

    // Workers: allow blob: for PWA service worker registration (workbox/vite-plugin-pwa uses blob URLs)
    "worker-src 'self' blob:",

    // No plugins ever
    "object-src 'none'",

    // Clickjacking: framing is denied by default; the admin live-preview surfaces
    // opt into same-origin framing per-request (see middleware below). SECURITY.md §A05.
    `frame-ancestors ${frameAncestors}`,

    // Base URI locked to self (prevents base-tag hijacking)
    "base-uri 'self'",

    // Form submissions only to self
    "form-action 'self'",

    ...(upgradeInsecureRequests ? ['upgrade-insecure-requests'] : []),
  ];

  return directives.join('; ');
}

// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------

// Default: framing fully denied (frame-ancestors 'none'). Frameable variant: same-origin
// only, used solely for the admin live-preview surfaces selected in the middleware below.
export function shouldUpgradeInsecureRequests(url: URL): boolean {
  return !(
    url.protocol === 'http:' &&
    (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
  );
}

export const securityHeaders = defineMiddleware(async (context, next) => {
  // Nonce is stored in locals but Astro 6.1.5 does not propagate it to injected
  // scripts (CSP uses 'unsafe-inline'). Generating it per request burns CPU for
  // no current consumer — kept here only because env.d.ts types `cspNonce`.
  // We assign an empty string to avoid the `crypto.getRandomValues` + base64
  // cost on every request. When Astro adds nonce support, replace with
  // `generateNonce()` and switch CSP to nonce-based.
  context.locals.cspNonce = '';

  const response = await next();

  // Skip header mutation for WS upgrade (101) — response.headers are immutable.
  if (response.status === 101) return response;

  const headers = new Headers(response.headers);
  const securedResponse = new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });

  // Ensure text/html responses always declare charset=utf-8 so the browser
  // never falls back to charset sniffing (Win-1252) on Hebrew-heavy pages.
  const ct = headers.get('Content-Type');
  if (ct && ct.startsWith('text/html') && !ct.includes('charset')) {
    headers.set('Content-Type', `${ct}; charset=utf-8`);
  }

  // Same-origin framing is permitted ONLY for an authenticated admin viewing the two
  // live-preview surfaces (mirrors the page-level gate `isAdmin && ?__preview`, index.astro:15):
  //  - Page Organizer previews the storefront via `/?__preview=1` (PreviewPanel.tsx)
  //  - Email Log previews rendered emails via `/api/admin/email-mock/<id>` (EmailLogTab.tsx)
  // Every other response denies framing entirely (clickjacking defense, SECURITY.md §A05).
  // `locals.user` is populated here because securityHeaders reads it AFTER `await next()`,
  // by which point the downstream `session` middleware has run (see middleware.ts order).
  // The relax only widens DENY→SAMEORIGIN — cross-origin framing stays blocked either way.
  const isAdmin = !!context.locals.user?.isAdmin;
  const allowSameOriginFrame =
    isAdmin &&
    (context.url.searchParams.has('__preview') ||
      context.url.pathname.startsWith('/api/admin/email-mock/'));

  const includeUpgradeInsecureRequests = shouldUpgradeInsecureRequests(context.url);
  headers.set(
    'Content-Security-Policy',
    buildCsp(allowSameOriginFrame ? "'self'" : "'none'", includeUpgradeInsecureRequests),
  );
  headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
  headers.set('X-Content-Type-Options', 'nosniff');
  headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
  // Prevents this window from being opened by cross-origin openers (tabnabbing / XS-Leaks).
  headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  // Belt-and-suspenders for browsers that don't support frame-ancestors.
  headers.set('X-Frame-Options', allowSameOriginFrame ? 'SAMEORIGIN' : 'DENY');

  return securedResponse;
});
