import { NextResponse } from "next/server";
import { cookieSecurityOptions, isMobileClientRequest, isProduction } from "@/lib/auth/config";
// Edge-safe import — NOT from "@/lib/auth/tokens", which pulls in
// "node:crypto" and would crash this module's evaluation in middleware.
import { bearerTokenFrom } from "@/lib/auth/bearer";
import { hostIsFirstParty, siteUrl } from "@/lib/seo/site";

/**
 * CSRF protection (double-submit cookie).
 *
 * Every mutating API route calls `requireCsrf(request)` first. The browser
 * receives a non-HttpOnly `sr_csrf` cookie on the first page load (issued in
 * `src/middleware.ts`) and the client echoes its value back in the
 * `x-csrf-token` header via `apiFetch` (`@/lib/csrf/client`). A cross-site
 * attacker cannot read the cookie (no CORS read access) and therefore cannot
 * reproduce the header, so matching cookie+header proves the request came from
 * our own page.
 *
 * Runtime note: this module is imported by `src/middleware.ts`, which runs on
 * the Edge runtime — `node:crypto` is not available there. The implementation
 * therefore uses the standard Web Crypto API (`crypto.getRandomValues` for a
 * CSPRNG, `btoa` for base64url encoding) plus an XOR-based constant-time
 * string comparison. Behaviour is identical to the `node:crypto` variant
 * (`randomBytes(32).toString("base64url")` / `timingSafeEqual`) and works on
 * Node route handlers, Edge middleware and in tests alike.
 */

/** Double-submit cookie name. */
export const CSRF_COOKIE = "sr_csrf";

/** Header the client echoes the token back in. */
export const CSRF_HEADER = "x-csrf-token";

/** Local dev origins accepted on top of `siteUrl` while not in production. */
const LOCAL_DEV_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"];

/**
 * First-party subdomains that `src/middleware.ts` routes back into this app
 * (`app.*` → dashboard, `admin.*` → admin); the label list lives in
 * `src/lib/seo/site.ts`. Requests originating from these hosts are
 * same-application traffic even though their Origin is not the bare site
 * origin, so the CSRF gate must accept them. Any `Origin` that differs from
 * this exact one-label prefix is still treated as foreign.
 */
function isFirstPartySubdomain(origin: string, site: string): boolean {
  try {
    return hostIsFirstParty(new URL(origin).host, new URL(site).host);
  } catch {
    return false;
  }
}

/** Fresh random token: 32 bytes, base64url — exactly 43 chars. */
export function issueCsrfToken(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return toBase64Url(bytes);
}

function toBase64Url(bytes: Uint8Array): string {
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  // btoa is a Web API available in browsers, Edge and Node >= 16.
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

/**
 * Cookie attributes for the double-submit token.
 *
 * `httpOnly: false` is deliberate — the client JS must be able to read the
 * cookie to echo it as a header. The token is meaningless to an attacker who
 * can see it in transit anyway; its protection comes from the cookie not being
 * readable cross-site, which the SameSite+Lax policy reinforces.
 */
export function csrfCookieOptions() {
  return cookieSecurityOptions({
    httpOnly: false,
    maxAge: 86400, // 24h, matching the session lifetime
  });
}

/**
 * Constant-time comparison of two values; undefined-safe.
 *
 * Both inputs are base64url strings of equal length when valid, so a char-code
 * XOR accumulation over equal-length strings gives the same timing profile as
 * `crypto.timingSafeEqual` on equal-length buffers. Unequal lengths (and
 * undefined inputs) short-circuit to false.
 */
export function tokensMatch(a: string | undefined, b: string | undefined): boolean {
  if (a === undefined || b === undefined) return false;
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

/**
 * Origin allow-list check.
 *
 * Returns true when the Origin header is absent (non-browser client such as
 * curl or a server-to-server call). Otherwise the header is parsed and compared
 * against the configured site origin; in development the localhost variants are
 * accepted on top so `next dev` works from any of the three aliases.
 */
export function isAllowedOrigin(request: Request): boolean {
  const origin = request.headers.get("origin");
  // No Origin header at all — non-browser client (curl, server-to-server).
  if (origin === null) return true;
  // Present but empty is never produced by a real browser: treat it as foreign
  // rather than letting the falsy string fall through the "absent" branch.
  if (origin === "") return false;

  const normalized = normalizeOrigin(origin);
  if (!normalized) return false;

  const site = normalizeOrigin(siteUrl);
  if (site && normalized === site) return true;
  // Same-application traffic from the routed app./admin. subdomains.
  if (site && isFirstPartySubdomain(normalized, site)) return true;

  if (!isProduction && LOCAL_DEV_ORIGINS.includes(normalized)) return true;

  return false;
}

/** Normalises an origin string to its canonical `scheme://host[:port]` form. */
function normalizeOrigin(origin: string): string | null {
  try {
    return new URL(origin).origin;
  } catch {
    return null;
  }
}

/**
 * Reads the CSRF token from the request's cookies.
 *
 * Next.js passes a `NextRequest` to route handlers and middleware, which
 * exposes a typed `cookies` map (preferred, per the shared contract). Plain
 * `fetch()` Requests — as constructed in tests — lack it, so the raw Cookie
 * header is parsed as a fallback.
 */
function readCsrfCookie(request: Request): string | undefined {
  const withCookies = request as Request & {
    cookies?: { get(name: string): { value?: string } | undefined };
  };
  const fromMap = withCookies.cookies?.get(CSRF_COOKIE)?.value;
  if (fromMap !== undefined) return fromMap;

  const cookieHeader = request.headers.get("cookie");
  if (!cookieHeader) return undefined;

  for (const part of cookieHeader.split(";")) {
    const eqIndex = part.indexOf("=");
    if (eqIndex === -1) continue;
    if (part.slice(0, eqIndex).trim() !== CSRF_COOKIE) continue;
    const raw = part.slice(eqIndex + 1).trim();
    try {
      return decodeURIComponent(raw);
    } catch {
      return raw;
    }
  }
  return undefined;
}

/**
 * Full check: origin allow-list AND cookie == header (constant-time).
 * True = the request is CSRF-safe.
 */
export function validateCsrf(request: Request): boolean {
  if (!isAllowedOrigin(request)) return false;
  const cookieValue = readCsrfCookie(request);
  const headerValue = request.headers.get(CSRF_HEADER);
  return tokensMatch(cookieValue, headerValue ?? undefined);
}

/**
 * True when the request authenticates with an `Authorization: Bearer` header
 * (native iOS/Android clients — see `src/lib/auth/session.ts`) rather than the
 * ambient session cookie. CSRF exists to stop a third-party page from riding a
 * browser's cookie jar; a request with no cookie in play has nothing to ride,
 * so the double-submit check is meaningless for it. This is not a hole for
 * browser traffic: a cross-site page cannot attach a custom `Authorization`
 * header without triggering a CORS preflight, and `src/lib/http/cors.ts`
 * rejects any origin outside the configured allowlist before the real request
 * is ever sent.
 */
export function hasBearerAuth(request: Request): boolean {
  return bearerTokenFrom(request.headers) !== null;
}

/**
 * True when the request is exempt from the double-submit check: either it is
 * already Bearer-authenticated (see `hasBearerAuth`), or it identifies as a
 * native client via `X-Client` (login/signup, which run BEFORE a token
 * exists, so there is no Bearer header yet to check). Both signals are custom
 * headers a cross-site browser page cannot attach without a CORS preflight
 * that `src/lib/http/cors.ts` refuses for any non-allowlisted origin — so
 * neither can be forged by the traffic CSRF actually defends against.
 */
function isCsrfExempt(request: Request): boolean {
  return hasBearerAuth(request) || isMobileClientRequest(request);
}

/**
 * Route guard. Returns null when the request is CSRF-safe, otherwise a 403
 * `{ error: "csrf_failed" }` response. Every mutating API route calls this as
 * its first line (after `requireDatabase`, before reading the body).
 */
export function requireCsrf(request: Request): NextResponse | null {
  if (isCsrfExempt(request)) return null;
  if (validateCsrf(request)) return null;
  return NextResponse.json({ error: "csrf_failed" }, { status: 403 });
}
