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

/**
 * CORS allowlist for the receipt-scanner API.
 *
 * The app NEVER emits `Access-Control-Allow-Origin: *` because it authenticates
 * with cookies (guest sessions, admin subdomain). A wildcard is refused by
 * browsers for credentialed requests anyway and would defeat the point of the
 * origin check. Instead every cross-origin response echoes the exact requesting
 * origin after an exact allowlist match, plus `Vary: Origin` so shared caches
 * never serve one origin's CORS response to another.
 *
 * Design decisions:
 * - No `Origin` header (same-origin fetch, curl, server-to-server, non-browser
 *   clients) is always allowed — CORS only ever applies to browser cross-origin
 *   requests.
 * - An `Origin` header must match the allowlist exactly (host + port). Scheme
 *   (`http`/`https`) is deliberately ignored for the comparison so operators can
 *   list either form, but the *full* origin from the request is echoed back —
 *   never a wildcard, never a prefix, never the literal string `"null"`.
 * - The allowlist comes from `CORS_ORIGINS` (comma-separated) plus
 *   `NEXT_PUBLIC_APP_URL`, which is always allowed (the app's own origin).
 *
 * Edge-runtime compatible: no Node.js APIs, no fs/net, no timers. `process.env`
 * is available in Next.js middleware, but every helper accepts an optional env
 * object so the pure logic is unit-testable without touching globals.
 */

/** How long the browser may cache a preflight answer (seconds). */
export const CORS_MAX_AGE = "600";

/** Methods the API accepts for cross-origin requests (incl. OPTIONS preflight). */
export const CORS_ALLOWED_METHODS = "GET,POST,PUT,PATCH,DELETE,OPTIONS";

/** Hard byte cap on the `Access-Control-Request-Headers` echo. */
export const CORS_REQUEST_HEADERS_MAX_BYTES = 4096;

/** Hard cap on the number of header names echoed. */
export const CORS_REQUEST_HEADERS_MAX_COUNT = 64;

/** Stable machine-readable code used for every CORS refusal. */
export const CORS_ERROR_CODE = "cors_origin_not_allowed";

type Env = Record<string, string | undefined>;

/** Lazy env resolution: injected object wins, otherwise process.env. */
function resolveEnv(env?: Env): Env {
  if (env) return env;
  // `process` exists in Next.js middleware (Edge) and Node; guard for safety.
  return typeof process !== "undefined" && process.env ? (process.env as Env) : {};
}

/**
 * Trim + strip trailing slashes from an allowlist entry. Keeps the full origin
 * (scheme included) so callers can echo it verbatim as a header value.
 */
export function normalizeOrigin(origin: string): string {
  return origin.trim().replace(/\/+$/, "");
}

/**
 * Comparison key for allowlist matching: lowercase `host[:port]` with the
 * scheme stripped and default ports collapsed (`https://a.com` == `https://a.com:443`).
 * Any path/query/fragment is dropped defensively — only scheme+host+port matter.
 */
export function originComparisonKey(origin: string): string {
  let candidate = origin.trim().replace(/\/+$/, "");

  // Remember + strip the scheme for the comparison.
  let scheme = "";
  const schemeMatch = candidate.match(/^([a-z][a-z0-9+.-]*):\/\//i);
  if (schemeMatch) {
    scheme = schemeMatch[1].toLowerCase();
    candidate = candidate.slice(schemeMatch[0].length);
  }

  // Drop anything after host[:port].
  const cut = candidate.search(/[/?#]/);
  if (cut !== -1) candidate = candidate.slice(0, cut);

  // Split host and port. IPv6 literals are wrapped in brackets, so their
  // colons are never mistaken for the port separator.
  let host = candidate;
  let port = "";
  const bracketClose = candidate.lastIndexOf("]");
  if (bracketClose !== -1) {
    const rest = candidate.slice(bracketClose + 1);
    if (rest.startsWith(":")) {
      host = candidate.slice(0, bracketClose + 1);
      port = rest.slice(1);
    }
  } else {
    const lastColon = candidate.lastIndexOf(":");
    // A bare IPv6 literal without brackets keeps its colons.
    if (lastColon !== -1 && !candidate.slice(0, lastColon).includes(":")) {
      host = candidate.slice(0, lastColon);
      port = candidate.slice(lastColon + 1);
    }
  }
  host = host.toLowerCase();

  const defaultPort = scheme === "https" ? "443" : scheme === "http" ? "80" : "";
  return port && port !== defaultPort ? `${host}:${port}` : host;
}

/**
 * The complete origin allowlist: `CORS_ORIGINS` (comma-separated) plus
 * `NEXT_PUBLIC_APP_URL`, normalized and de-duplicated (by comparison key),
 * with the app's own origin always present.
 */
export function getAllowedOrigins(env?: Env): string[] {
  const e = resolveEnv(env);
  const seen = new Set<string>();
  const out: string[] = [];
  const push = (raw: string | undefined) => {
    if (!raw) return;
    for (const part of raw.split(",")) {
      const origin = normalizeOrigin(part);
      if (!origin) continue;
      const key = originComparisonKey(origin);
      if (seen.has(key)) continue;
      seen.add(key);
      out.push(origin);
    }
  };
  // The app's own origin is always present and wins over any duplicate a
  // CORS_ORIGINS entry might list (de-duplication is by comparison key).
  push(e.NEXT_PUBLIC_APP_URL);
  push(e.CORS_ORIGINS);
  return out;
}

/**
 * True when the request may proceed:
 * - no `Origin` header (same-origin fetch without one, curl, server-to-server)
 *   → allowed;
 * - an `Origin` header that equals the request's OWN origin (same-origin
 *   browser request — browsers send `Origin` on every POST/PUT/DELETE, so the
 *   `app.*`/`admin.*` subdomains and any host the app runs on must not be
 *   refused) → allowed;
 * - any other `Origin` header must match the allowlist EXACTLY. Never `*`.
 * The literal origin string `"null"` (sandboxed iframe / `file://`) is always
 * rejected — it can never be a legitimate trusted origin.
 */
export function isOriginAllowed(origin: string | null, env?: Env): boolean {
  if (!origin) return true;
  if (origin === "null") return false;
  const key = originComparisonKey(origin);
  return getAllowedOrigins(env).some((o) => originComparisonKey(o) === key);
}

/**
 * Same-origin check for a concrete request: compares the `Origin` header
 * against the request's own URL origin (`scheme://host[:port]`), so the app's
 * own pages — including the `app.*`/`admin.*` subdomains — never trip the CORS
 * gate.
 */
export function isSameOrigin(origin: string | null, request: NextRequest): boolean {
  if (!origin) return true;
  const own = originComparisonKey(request.nextUrl.origin);
  return originComparisonKey(origin) === own;
}

/**
 * Headers to attach to a (non-preflight) response when the requesting origin
 * is allowed: exact echo + `Vary: Origin` + credentials support. Returns `{}`
 * when there is nothing to grant. Never contains `*`.
 */
export function corsHeadersFor(origin: string | null, env?: Env): Record<string, string> {
  if (!origin || !isOriginAllowed(origin, env)) return {};
  return {
    "Access-Control-Allow-Origin": origin,
    "Vary": "Origin",
    // The app authenticates with cookies (guest sessions, admin subdomain), so
    // credentialed CORS must be advertised. Safe here because the allow-origin
    // value is an exact echo, never a wildcard.
    "Access-Control-Allow-Credentials": "true",
  };
}

/**
 * A browser preflight is OPTIONS carrying `Access-Control-Request-Method`.
 * A bare OPTIONS (health checks, monitoring) is not a preflight.
 */
function isPreflight(request: NextRequest): boolean {
  return request.method === "OPTIONS" && request.headers.has("access-control-request-method");
}

/**
 * Validate + bound the `Access-Control-Request-Headers` echo: only RFC 7230
 * token characters, de-duplicated, lower-cased, capped by count and total
 * length. Returns null (header omitted) when the input is absent or garbage.
 */
function sanitizeRequestHeaders(raw: string | null): string | null {
  if (!raw) return null;
  if (raw.length > CORS_REQUEST_HEADERS_MAX_BYTES) return null;
  const seen = new Set<string>();
  const names: string[] = [];
  for (const part of raw.split(",")) {
    const name = part.trim().toLowerCase();
    if (!name || seen.has(name)) continue;
    if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) continue;
    seen.add(name);
    names.push(name);
    if (names.length >= CORS_REQUEST_HEADERS_MAX_COUNT) break;
  }
  return names.length ? names.join(", ") : null;
}

function corsRefusal(): NextResponse {
  return NextResponse.json({ error: CORS_ERROR_CODE }, { status: 403 });
}

/**
 * Middleware entry point. Returns a terminal response when the request must be
 * stopped (preflight answer or disallowed cross-origin request), otherwise
 * `undefined` so the caller continues processing.
 *
 * - Same-origin requests (Origin header equals the request's own origin, or no
 *   Origin header at all) always pass — browsers send `Origin` on same-origin
 *   POST/PUT/DELETE, so the app's own pages and the `app.*`/`admin.*`
 *   subdomains must never be refused by the CORS gate.
 * - Preflight from an allowed origin → 204 with echo + `Vary: Origin` +
 *   allow-methods + bounded allow-headers echo + max-age. Credentials are
 *   advertised (the app uses cookies).
 * - Preflight (or any request) from a disallowed origin → 403 JSON.
 * - Allowed-origin non-preflight → `undefined`.
 */
export function handleCors(request: NextRequest, env?: Env): NextResponse | undefined {
  const origin = request.headers.get("origin");
  const allowed = isSameOrigin(origin, request) || isOriginAllowed(origin, env);

  if (isPreflight(request)) {
    if (!allowed) return corsRefusal();
    const headers: Record<string, string> = {
      "Access-Control-Allow-Credentials": "true",
      "Access-Control-Allow-Methods": CORS_ALLOWED_METHODS,
      "Access-Control-Max-Age": CORS_MAX_AGE,
      "Vary": "Origin",
    };
    // Echo the exact origin (browsers always send one on preflights).
    if (origin) headers["Access-Control-Allow-Origin"] = origin;
    const requestedHeaders = sanitizeRequestHeaders(
      request.headers.get("access-control-request-headers")
    );
    if (requestedHeaders) headers["Access-Control-Allow-Headers"] = requestedHeaders;
    return new NextResponse(null, { status: 204, headers });
  }

  if (!allowed) return corsRefusal();
  return undefined;
}
