import type { Mode } from "./errors.ts";

export type CorsOrigin = "*" | string | string[];

export interface CorsConfig {
  /** Enable CORS headers and preflight handling. Defaults to false. */
  enabled?: boolean;
  /** Allowed origins. Use "*" for public APIs. Defaults to "*". */
  origin?: CorsOrigin;
  /** Allowed methods for preflight responses. */
  methods?: string[];
  /** Allowed request headers. Defaults to the browser's requested headers. */
  allowedHeaders?: string[];
  /** Response headers exposed to browser JavaScript. */
  exposedHeaders?: string[];
  /** Whether to send Access-Control-Allow-Credentials. */
  credentials?: boolean;
  /** Access-Control-Max-Age, in seconds. */
  maxAge?: number;
}

export type CspDirectiveValue = string | string[] | false | null | undefined;

export interface ContentSecurityPolicyConfig {
  /** Defaults to true. */
  enabled?: boolean;
  /** Use Content-Security-Policy-Report-Only instead of enforcing. */
  reportOnly?: boolean;
  /** Merge or remove directives. Set a directive to false/null to remove it. */
  directives?: Record<string, CspDirectiveValue>;
  /** Set false to start from an empty policy instead of WrNexus defaults. */
  useDefaults?: boolean;
}

export interface HstsConfig {
  /** Defaults to true in production, false in development. */
  enabled?: boolean;
  /** Defaults to 31536000 seconds (1 year). */
  maxAge?: number;
  /** Defaults to true. */
  includeSubDomains?: boolean;
  /** Defaults to true. */
  preload?: boolean;
}

export interface TrustedTypesConfig {
  /** Defaults to true in production, false in development. */
  enabled?: boolean;
  /**
   * Defaults to ["*"] in production so browser extensions and dev tooling can
   * create their own policies without noisy console errors. Set this to a
   * concrete list, e.g. ["wrnexus", "default"], for stricter deployments.
   */
  policyNames?: string[];
  /** Defaults to true. */
  requireForScript?: boolean;
  /** Adds "allow-duplicates" to the trusted-types directive. */
  allowDuplicates?: boolean;
}

export type PermissionsPolicyConfig = Record<string, string | string[] | false | null | undefined>;

export interface RequestLimitsConfig {
  maxUrlLength?: number;
  maxHeaderCount?: number;
  maxHeaderBytes?: number;
  maxQueryParameters?: number;
  maxBodyBytes?: number;
  timeoutMs?: number;
  maxConcurrent?: number;
  trustedHosts?: string[];
  fetchMetadata?: boolean;
}

export interface SecurityConfig {
  /** Set false to skip all framework security headers except explicitly enabled CORS. */
  headers?: boolean;
  /** Built-in request size, timeout, concurrency, host, and Fetch Metadata limits. */
  requestLimits?: RequestLimitsConfig;
  /**
   * Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set
   * this when the app runs behind a TLS-terminating reverse proxy (nginx, the
   * WrNexus gateway, a load balancer). Without it, a proxied app sees the internal
   * `http://` request and marks cookies (e.g. CSRF/session) non-`Secure`. Default
   * false; enable ONLY when a trusted proxy actually sets these headers.
   */
  trustProxy?: boolean;
  cors?: boolean | CorsConfig;
  contentSecurityPolicy?: false | ContentSecurityPolicyConfig;
  hsts?: false | HstsConfig;
  trustedTypes?: false | TrustedTypesConfig;
  /** Defaults to "same-origin". */
  crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none";
  /** Defaults to "DENY". */
  frameOptions?: false | "DENY" | "SAMEORIGIN";
  /** Defaults to "strict-origin-when-cross-origin". */
  referrerPolicy?: false | string;
  /** Defaults to "same-origin". */
  crossOriginResourcePolicy?: false | "same-origin" | "same-site" | "cross-origin";
  /** Isolate the origin in its own agent cluster. Defaults to true. */
  originAgentCluster?: boolean;
  /** Disable speculative DNS prefetching. Defaults to true. */
  disableDnsPrefetch?: boolean;
  /** Defaults to a restrictive browser capability policy. */
  permissionsPolicy?: false | PermissionsPolicyConfig;
  /** Extra static headers applied last. */
  extraHeaders?: Record<string, string>;
}

const DEFAULT_CSP: Record<string, string[]> = {
  "default-src": ["'self'"],
  "script-src": ["'self'"],
  "style-src": ["'self'", "'unsafe-inline'"],
  "img-src": ["'self'", "data:", "blob:"],
  "font-src": ["'self'", "data:"],
  "connect-src": ["'self'", "ws:", "wss:"],
  "object-src": ["'none'"],
  "base-uri": ["'self'"],
  "frame-ancestors": ["'none'"],
  "form-action": ["'self'"],
};

const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = {
  accelerometer: [],
  autoplay: [],
  camera: [],
  "display-capture": [],
  "encrypted-media": [],
  fullscreen: ["self"],
  geolocation: [],
  gyroscope: [],
  magnetometer: [],
  microphone: [],
  midi: [],
  payment: [],
  "picture-in-picture": [],
  "sync-xhr": [],
  usb: [],
  "xr-spatial-tracking": [],
};

const DEFAULT_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];

/**
 * Guard a WebSocket upgrade against Cross-Site WebSocket Hijacking: browsers
 * always send an `Origin` header on a WS handshake, and — unlike fetch — WS is
 * NOT subject to CORS, so cookies would otherwise flow cross-site. We allow
 * same-origin (Origin host === Host header), configured CORS origins, and
 * non-browser clients (no Origin, which also carry no ambient cookies).
 */
export function isWebSocketOriginAllowed(req: Request, security?: SecurityConfig): boolean {
  const origin = req.headers.get("origin");
  if (!origin) return true; // native/non-browser client — not the CSWSH threat
  let originHost: string;
  try {
    originHost = new URL(origin).host;
  } catch {
    return false;
  }
  if (originHost === req.headers.get("host")) return true; // same-origin

  const cors = normalizeCors(security?.cors);
  if (cors.enabled) {
    const configured = cors.origin ?? "*";
    if (configured === "*") return true;
    const list = Array.isArray(configured) ? configured : [configured];
    return list.includes(origin);
  }
  return false;
}

export function createCorsPreflightResponse(
  req: Request,
  security?: SecurityConfig,
): Response | null {
  if (req.method.toUpperCase() !== "OPTIONS") return null;
  if (!req.headers.has("origin") || !req.headers.has("access-control-request-method")) return null;

  const cors = normalizeCors(security?.cors);
  if (!cors.enabled) return null;

  const headers = new Headers();
  const allowed = applyCorsHeaders(req, headers, cors);
  if (!allowed) return new Response("CORS origin denied", { status: 403 });

  return new Response(null, { status: 204, headers });
}

/**
 * Build the request URL, honoring `X-Forwarded-Proto` / `X-Forwarded-Host` when
 * `trustProxy` is set (app behind a TLS-terminating reverse proxy). This makes
 * `ctx.url.protocol` reflect the EXTERNAL scheme, so protocol-dependent logic —
 * `Secure` cookies, canonical URLs — is correct behind nginx / the gateway.
 * Security checks that compare the raw `Host`/`Origin` headers don't use this URL,
 * so they are unaffected. An invalid forwarded value is ignored by the URL setter.
 */
export function resolveRequestUrl(req: Request, trustProxy?: boolean): URL {
  const url = new URL(req.url);
  if (!trustProxy) return url;
  const forwarded = req.headers.get("forwarded")?.split(",")[0] ?? "";
  const forwardedProto = /(?:^|;)\s*proto=(?:"([^"]+)"|([^;\s]+))/i.exec(forwarded);
  const forwardedHost = /(?:^|;)\s*host=(?:"([^"]+)"|([^;\s]+))/i.exec(forwarded);
  const proto = req.headers.get("x-forwarded-proto") ?? forwardedProto?.[1] ?? forwardedProto?.[2];
  if (proto) url.protocol = (proto.split(",")[0] ?? "").trim() + ":";
  const host = req.headers.get("x-forwarded-host") ?? forwardedHost?.[1] ?? forwardedHost?.[2];
  if (host) {
    const h = (host.split(",")[0] ?? "").trim();
    url.host = h;
    if (!h.includes(":")) url.port = ""; // drop the internal proxy port when none forwarded
  }
  return url;
}

export function withSecurityHeaders(
  req: Request,
  res: Response,
  mode: Mode,
  security?: SecurityConfig,
  nonce?: string,
): Response {
  const headers = new Headers(res.headers);
  const cors = normalizeCors(security?.cors);

  if (cors.enabled) {
    applyCorsHeaders(req, headers, cors);
  }

  if (security?.headers !== false) {
    applyBaseSecurityHeaders(headers, mode, security, nonce);
  }

  if (security?.extraHeaders) {
    Object.entries(security.extraHeaders).forEach(([name, value]) => headers.set(name, value));
  }

  return new Response(res.body, {
    status: res.status,
    statusText: res.statusText,
    headers,
  });
}

function applyBaseSecurityHeaders(
  headers: Headers,
  mode: Mode,
  security?: SecurityConfig,
  nonce?: string,
): void {
  headers.set("X-Content-Type-Options", "nosniff");

  const frameOptions = security?.frameOptions ?? "DENY";
  if (frameOptions !== false) headers.set("X-Frame-Options", frameOptions);

  const coop = security?.crossOriginOpenerPolicy ?? "same-origin";
  if (coop !== false) headers.set("Cross-Origin-Opener-Policy", coop);

  const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin";
  if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy);

  const corp = security?.crossOriginResourcePolicy ?? "same-origin";
  if (corp !== false) headers.set("Cross-Origin-Resource-Policy", corp);
  if (security?.originAgentCluster !== false) headers.set("Origin-Agent-Cluster", "?1");
  if (security?.disableDnsPrefetch !== false) headers.set("X-DNS-Prefetch-Control", "off");

  const configuredPermissions = security?.permissionsPolicy;
  const permissionsPolicy =
    configuredPermissions === false
      ? false
      : { ...DEFAULT_PERMISSIONS_POLICY, ...(configuredPermissions ?? {}) };
  if (permissionsPolicy !== false) {
    const value = serializePermissionsPolicy(permissionsPolicy);
    if (value) headers.set("Permissions-Policy", value);
  }

  const csp = serializeCsp(mode, security, nonce);
  if (csp) {
    const cspConfig = security?.contentSecurityPolicy;
    const reportOnly = typeof cspConfig === "object" && cspConfig.reportOnly === true;
    headers.set(
      reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
      csp,
    );
  }

  const hsts = security?.hsts;
  const hstsEnabled =
    hsts !== false &&
    (mode === "production" || (typeof hsts === "object" && hsts.enabled === true));
  if (hstsEnabled) {
    headers.set("Strict-Transport-Security", serializeHsts(typeof hsts === "object" ? hsts : {}));
  }
}

let warnedCredentialsWildcard = false;

function normalizeCors(cors: SecurityConfig["cors"]): CorsConfig & { enabled: boolean } {
  if (cors === true) return { enabled: true, origin: "*" };
  if (!cors) return { enabled: false };
  const normalized = { ...cors, enabled: cors.enabled !== false };
  // `*` + credentials would reflect ANY origin back with credentials allowed —
  // effectively disabling the same-origin policy. Refuse the combination and
  // drop credentials so it degrades to a safe public (non-credentialed) API.
  if (normalized.credentials && (normalized.origin ?? "*") === "*") {
    if (!warnedCredentialsWildcard) {
      warnedCredentialsWildcard = true;
      console.warn(
        '[wrnexus] CORS `credentials: true` cannot be combined with `origin: "*"`; ' +
          "credentials disabled. Set an explicit origin allowlist to use credentials.",
      );
    }
    normalized.credentials = false;
  }
  return normalized;
}

function applyCorsHeaders(req: Request, headers: Headers, cors: CorsConfig): boolean {
  const origin = req.headers.get("origin");
  if (!origin) return true;

  const allowOrigin = resolveAllowedOrigin(origin, cors);
  if (!allowOrigin) return false;

  headers.set("Access-Control-Allow-Origin", allowOrigin);
  appendVary(headers, "Origin");

  if (cors.credentials) headers.set("Access-Control-Allow-Credentials", "true");
  if (cors.exposedHeaders?.length) {
    headers.set("Access-Control-Expose-Headers", cors.exposedHeaders.join(", "));
  }

  if (req.method.toUpperCase() === "OPTIONS") {
    headers.set("Access-Control-Allow-Methods", (cors.methods ?? DEFAULT_CORS_METHODS).join(", "));

    const requestedHeaders = req.headers.get("access-control-request-headers");
    const allowedHeaders = cors.allowedHeaders?.join(", ") ?? requestedHeaders;
    if (allowedHeaders) headers.set("Access-Control-Allow-Headers", allowedHeaders);
    if (typeof cors.maxAge === "number") {
      headers.set("Access-Control-Max-Age", String(Math.max(0, Math.floor(cors.maxAge))));
    }
  }

  return true;
}

function resolveAllowedOrigin(origin: string, cors: CorsConfig): string | null {
  const configured = cors.origin ?? "*";
  if (configured === "*") return cors.credentials ? origin : "*";
  if (typeof configured === "string") return configured === origin ? origin : null;
  return configured.includes(origin) ? origin : null;
}

function appendVary(headers: Headers, value: string): void {
  const existing = headers.get("Vary");
  if (!existing) {
    headers.set("Vary", value);
    return;
  }

  const values = existing.split(",").map((item) => item.trim().toLowerCase());
  if (!values.includes(value.toLowerCase())) headers.set("Vary", `${existing}, ${value}`);
}

function serializeCsp(mode: Mode, security?: SecurityConfig, nonce?: string): string {
  const config = security?.contentSecurityPolicy;
  if (config === false || config?.enabled === false) return "";

  const directives = new Map<string, string[]>();
  if (config?.useDefaults !== false) {
    for (const [name, value] of Object.entries(DEFAULT_CSP)) {
      directives.set(name, [...value]);
    }
    if (mode === "development") {
      directives.set("script-src", ["'self'", "'unsafe-inline'"]);
    } else {
      directives.set("upgrade-insecure-requests", []);
    }
  }

  for (const [name, value] of Object.entries(config?.directives ?? {})) {
    if (value === false || value === null) {
      directives.delete(name);
      continue;
    }
    if (value === undefined) continue;
    directives.set(name, Array.isArray(value) ? value : value.split(/\s+/).filter(Boolean));
  }

  // A per-request nonce lets inline framework scripts run under a strict policy:
  // add 'nonce-…' to script-src and drop 'unsafe-inline' (browsers ignore
  // 'unsafe-inline' when a nonce is present anyway).
  if (nonce) {
    const scriptSrc = directives.get("script-src") ?? ["'self'"];
    directives.set("script-src", [
      ...scriptSrc.filter((v) => v !== "'unsafe-inline'"),
      `'nonce-${nonce}'`,
    ]);
  }

  applyTrustedTypesDirectives(directives, mode, security?.trustedTypes);

  return [...directives.entries()]
    .map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name))
    .join("; ");
}

function applyTrustedTypesDirectives(
  directives: Map<string, string[]>,
  mode: Mode,
  trustedTypes: SecurityConfig["trustedTypes"],
): void {
  if (trustedTypes === false) return;

  const enabled =
    typeof trustedTypes === "object" ? trustedTypes.enabled !== false : mode === "production";
  if (!enabled) return;

  const policyNames =
    typeof trustedTypes === "object" && trustedTypes.policyNames?.length
      ? trustedTypes.policyNames
      : ["*"];
  const trustedTypesValues = [...policyNames];
  if (typeof trustedTypes === "object" && trustedTypes.allowDuplicates) {
    trustedTypesValues.push("'allow-duplicates'");
  }
  directives.set("trusted-types", trustedTypesValues);

  const requireForScript =
    typeof trustedTypes === "object" ? trustedTypes.requireForScript !== false : true;
  if (requireForScript) directives.set("require-trusted-types-for", ["'script'"]);
}

function serializeHsts(config: HstsConfig): string {
  const parts = [`max-age=${config.maxAge ?? 31536000}`];
  if (config.includeSubDomains !== false) parts.push("includeSubDomains");
  if (config.preload !== false) parts.push("preload");
  return parts.join("; ");
}

function serializePermissionsPolicy(policy: PermissionsPolicyConfig): string {
  return Object.entries(policy)
    .flatMap(([feature, value]) => {
      if (value === false || value === null || value === undefined) return [];
      if (typeof value === "string") return [`${feature}=${value}`];
      return [`${feature}=(${value.join(" ")})`];
    })
    .join(", ");
}
