/**
 * Security hooks for @beignet/core/server
 */

import type { HttpContractConfig } from "../../contracts/index.js";
import { AppError, httpErrors } from "../../errors/index.js";
import {
  resolveTrustedRequest,
  type TrustedProxyConfig,
  type TrustedProxyOptions,
} from "../trusted-proxy.js";
import type {
  HttpRequestLike,
  HttpResponseHeaders,
  ServerHook,
} from "../types.js";

/**
 * Strict-Transport-Security configuration.
 *
 * HSTS is disabled by default because it should only be sent by HTTPS
 * deployments that intentionally commit browsers to the configured host policy.
 */
export interface StrictTransportSecurityOptions {
  /**
   * HSTS max-age value in seconds.
   *
   * Defaults to one year when `strictTransportSecurity` is configured as an
   * object.
   */
  maxAgeSec?: number;
  /**
   * Include subdomains in the HSTS policy.
   */
  includeSubDomains?: boolean;
  /**
   * Mark the policy as eligible for browser preload lists.
   */
  preload?: boolean;
}

/**
 * Response security headers applied by `createSecurityHeadersHooks(...)`.
 *
 * Headers are only added when the response does not already define the same
 * header name. Route handlers can therefore opt into route-specific CSP or
 * download headers without fighting the global hook.
 */
export interface SecurityHeadersOptions {
  /**
   * Content Security Policy value. Disabled by default because browser apps need
   * an app-owned asset, image, frame, and script policy.
   */
  contentSecurityPolicy?: string | false;
  /**
   * Cross-Origin-Opener-Policy value.
   *
   * Defaults to `"same-origin"`.
   */
  crossOriginOpenerPolicy?: string | false;
  /**
   * Cross-Origin-Resource-Policy value.
   *
   * Defaults to `"same-origin"`.
   */
  crossOriginResourcePolicy?: string | false;
  /**
   * Permissions-Policy value.
   *
   * Defaults to disabling camera, microphone, and geolocation.
   */
  permissionsPolicy?: string | false;
  /**
   * Referrer-Policy value.
   *
   * Defaults to `"strict-origin-when-cross-origin"`.
   */
  referrerPolicy?: string | false;
  /**
   * Strict-Transport-Security value. Pass a string for full control or an object
   * for Beignet to format the header. Disabled by default.
   */
  strictTransportSecurity?: StrictTransportSecurityOptions | string | false;
  /**
   * X-Content-Type-Options value.
   *
   * Defaults to `"nosniff"`.
   */
  xContentTypeOptions?: "nosniff" | false;
  /**
   * X-Frame-Options value.
   *
   * Defaults to `"DENY"`. Use `contentSecurityPolicy` with `frame-ancestors`
   * for more precise frame control.
   */
  xFrameOptions?: "DENY" | "SAMEORIGIN" | false;
}

export type CsrfFailureReason =
  | "missing_origin"
  | "untrusted_origin"
  | "missing_token"
  | "invalid_token";

/**
 * Double-submit cookie token configuration for `createCsrfHooks(...)`.
 */
export interface CsrfTokenOptions {
  /**
   * Header that must carry the CSRF token.
   *
   * Defaults to `"x-csrf-token"`.
   */
  headerName?: string;
  /**
   * Cookie that stores the expected CSRF token.
   *
   * Defaults to `"beignet.csrf"`.
   */
  cookieName?: string;
}

/**
 * Options for `createCsrfHooks(...)`.
 */
export interface CsrfHooksOptions {
  /**
   * Unsafe HTTP methods protected by the hook.
   *
   * Defaults to `POST`, `PUT`, `PATCH`, and `DELETE`.
   */
  protectedMethods?: readonly string[];
  /**
   * Additional trusted origins allowed to send protected requests.
   *
   * The request URL's own origin is always trusted. Use this for sibling
   * frontends such as `https://app.example.com` calling `https://api.example.com`.
   */
  trustedOrigins?:
    | readonly string[]
    | ((args: {
        origin: string;
        req: HttpRequestLike;
        contract: HttpContractConfig;
      }) => boolean);
  /**
   * Whether unsafe requests without `Origin` or `Referer` are allowed.
   *
   * Defaults to `true` so server-to-server calls, tests, and older same-origin
   * clients keep working. Set to `false` for cookie-backed browser-only APIs.
   */
  allowMissingOrigin?: boolean;
  /**
   * Optional double-submit cookie token check.
   *
   * When configured, protected requests must send the same token in the
   * configured header and cookie.
   */
  token?: false | CsrfTokenOptions;
  /**
   * Hook-local trusted-proxy policy used when comparing the request's external
   * origin against `Origin` or `Referer`. This overrides the server-level
   * policy.
   *
   * Configure this only when the app is always behind a platform or reverse
   * proxy that strips or normalizes forwarding headers. Without this option,
   * CSRF uses the `requestInfo` resolved by `createServer(...)`.
   */
  trustedProxy?: TrustedProxyConfig;
  /**
   * App-owned escape hatch for routes that have another verifier, such as
   * provider webhooks or auth callbacks.
   */
  skip?: (args: {
    req: HttpRequestLike;
    contract: HttpContractConfig;
    params: Record<string, string>;
  }) => boolean | Promise<boolean>;
}

const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()";
const DEFAULT_PROTECTED_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
const DEFAULT_CSRF_HEADER = "x-csrf-token";
const DEFAULT_CSRF_COOKIE = "beignet.csrf";

function headerKey(
  headers: HttpResponseHeaders,
  name: string,
): string | undefined {
  const lowerName = name.toLowerCase();
  return Object.keys(headers).find((key) => key.toLowerCase() === lowerName);
}

function setHeaderIfMissing(
  headers: HttpResponseHeaders,
  name: string,
  value: string | false | undefined,
): void {
  if (value === false || value === undefined) return;
  if (headerKey(headers, name)) return;
  headers[name] = value;
}

function formatStrictTransportSecurity(
  config: SecurityHeadersOptions["strictTransportSecurity"],
): string | false | undefined {
  if (typeof config === "string" || config === false || config === undefined) {
    return config;
  }

  const directives = [`max-age=${config.maxAgeSec ?? 31_536_000}`];
  if (config.includeSubDomains) directives.push("includeSubDomains");
  if (config.preload) directives.push("preload");
  return directives.join("; ");
}

/**
 * Apply Beignet's default security response headers to a mutable header record.
 *
 * Existing headers are preserved case-insensitively so route-owned responses can
 * provide more specific policies.
 */
export function applySecurityHeaders(
  headers: HttpResponseHeaders,
  options: SecurityHeadersOptions = {},
): void {
  setHeaderIfMissing(
    headers,
    "Content-Security-Policy",
    options.contentSecurityPolicy,
  );
  setHeaderIfMissing(
    headers,
    "Cross-Origin-Opener-Policy",
    options.crossOriginOpenerPolicy ?? "same-origin",
  );
  setHeaderIfMissing(
    headers,
    "Cross-Origin-Resource-Policy",
    options.crossOriginResourcePolicy ?? "same-origin",
  );
  setHeaderIfMissing(
    headers,
    "Permissions-Policy",
    options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY,
  );
  setHeaderIfMissing(
    headers,
    "Referrer-Policy",
    options.referrerPolicy ?? "strict-origin-when-cross-origin",
  );
  setHeaderIfMissing(
    headers,
    "Strict-Transport-Security",
    formatStrictTransportSecurity(options.strictTransportSecurity),
  );
  setHeaderIfMissing(
    headers,
    "X-Content-Type-Options",
    options.xContentTypeOptions ?? "nosniff",
  );
  setHeaderIfMissing(
    headers,
    "X-Frame-Options",
    options.xFrameOptions ?? "DENY",
  );
}

/**
 * Create a server hook that adds common browser security headers to every
 * response, including native streamed responses.
 */
export function createSecurityHeadersHooks<Ctx>(
  options: SecurityHeadersOptions = {},
): ServerHook<Ctx> {
  return {
    name: "security-headers",
    beforeSend: ({ response }) => {
      const headers = { ...(response.headers ?? {}) };
      applySecurityHeaders(headers, options);
      return {
        ...response,
        headers,
      };
    },
  };
}

function normalizeOrigin(value: string, source: string): string {
  try {
    return new URL(value).origin;
  } catch {
    throw new Error(`${source} must be an absolute URL origin.`);
  }
}

function requestOrigin(
  req: HttpRequestLike,
  trustedProxy: TrustedProxyConfig | undefined,
): string {
  return resolveTrustedRequest(req, trustedProxyOriginConfig(trustedProxy))
    .origin;
}

function trustedProxyOriginConfig(
  trustedProxy: TrustedProxyConfig | undefined,
): TrustedProxyConfig | undefined {
  if (!trustedProxy) return trustedProxy;

  const originConfig: TrustedProxyOptions = {};
  if (trustedProxy.hostHeader !== undefined) {
    originConfig.hostHeader = trustedProxy.hostHeader;
  }
  if (trustedProxy.protocolHeader !== undefined) {
    originConfig.protocolHeader = trustedProxy.protocolHeader;
  }
  return originConfig;
}

function headerOrigin(value: string | null): string | undefined {
  if (!value) return undefined;

  try {
    return new URL(value).origin;
  } catch {
    return undefined;
  }
}

function originFromRequestHeaders(req: HttpRequestLike): string | undefined {
  const origin = req.headers.get("origin");
  if (origin) return headerOrigin(origin) ?? origin;

  return headerOrigin(req.headers.get("referer"));
}

function normalizeTrustedOrigins(
  trustedOrigins: CsrfHooksOptions["trustedOrigins"],
): CsrfHooksOptions["trustedOrigins"] {
  if (!Array.isArray(trustedOrigins)) return trustedOrigins;
  return trustedOrigins.map((origin) =>
    normalizeOrigin(origin, "trustedOrigins entries"),
  );
}

function isTrustedOrigin(args: {
  origin: string;
  req: HttpRequestLike;
  contract: HttpContractConfig;
  requestOrigin: string;
  trustedOrigins: CsrfHooksOptions["trustedOrigins"];
}): boolean {
  if (args.origin === args.requestOrigin) return true;

  if (Array.isArray(args.trustedOrigins)) {
    return args.trustedOrigins.includes(args.origin);
  }

  if (typeof args.trustedOrigins === "function") {
    return args.trustedOrigins({
      origin: args.origin,
      req: args.req,
      contract: args.contract,
    });
  }

  return false;
}

function decodeCookiePart(value: string): string {
  try {
    return decodeURIComponent(value);
  } catch {
    return value;
  }
}

function parseCookieHeader(
  cookieHeader: string | null,
): Record<string, string> {
  if (!cookieHeader) return {};

  return Object.fromEntries(
    cookieHeader
      .split(";")
      .map((entry) => entry.trim())
      .filter(Boolean)
      .map((entry) => {
        const separator = entry.indexOf("=");
        if (separator === -1) return [entry, ""];
        return [
          decodeCookiePart(entry.slice(0, separator).trim()),
          decodeCookiePart(entry.slice(separator + 1).trim()),
        ];
      }),
  );
}

function tokensMatch(left: string, right: string): boolean {
  if (left.length !== right.length) return false;

  let result = 0;
  for (let index = 0; index < left.length; index += 1) {
    result |= left.charCodeAt(index) ^ right.charCodeAt(index);
  }
  return result === 0;
}

function csrfError(reason: CsrfFailureReason, message: string): AppError {
  return new AppError(httpErrors.Forbidden, { reason }, message);
}

function enforceOrigin(args: {
  req: HttpRequestLike;
  contract: HttpContractConfig;
  allowMissingOrigin: boolean;
  trustedOrigins: CsrfHooksOptions["trustedOrigins"];
  requestOrigin: string;
}): void {
  const origin = originFromRequestHeaders(args.req);
  if (!origin) {
    if (args.allowMissingOrigin) return;
    throw csrfError(
      "missing_origin",
      "CSRF check failed because the request did not include an Origin or Referer header.",
    );
  }

  if (
    !isTrustedOrigin({
      origin,
      req: args.req,
      contract: args.contract,
      requestOrigin: args.requestOrigin,
      trustedOrigins: args.trustedOrigins,
    })
  ) {
    throw csrfError(
      "untrusted_origin",
      "CSRF check failed because the request origin is not trusted.",
    );
  }
}

function enforceToken(
  req: HttpRequestLike,
  token: CsrfTokenOptions | false,
): void {
  if (!token) return;

  const headerName = token.headerName ?? DEFAULT_CSRF_HEADER;
  const cookieName = token.cookieName ?? DEFAULT_CSRF_COOKIE;
  const headerToken = req.headers.get(headerName);
  const cookieToken = parseCookieHeader(req.headers.get("cookie"))[cookieName];

  if (!headerToken || !cookieToken) {
    throw csrfError(
      "missing_token",
      `CSRF check failed because ${headerName} and ${cookieName} must both be present.`,
    );
  }

  if (!tokensMatch(headerToken, cookieToken)) {
    throw csrfError(
      "invalid_token",
      "CSRF check failed because the submitted token does not match the cookie token.",
    );
  }
}

/**
 * Create CSRF protection for unsafe HTTP methods.
 *
 * The default protects cookie-backed browser routes from cross-origin unsafe
 * requests while still allowing server-to-server calls and tests that do not
 * send browser origin headers. Set `allowMissingOrigin: false` and configure
 * `token` for stricter browser-only APIs.
 */
export function createCsrfHooks<Ctx>(
  options: CsrfHooksOptions = {},
): ServerHook<Ctx> {
  const protectedMethods = new Set(
    (options.protectedMethods ?? DEFAULT_PROTECTED_METHODS).map((method) =>
      method.toUpperCase(),
    ),
  );
  const allowMissingOrigin = options.allowMissingOrigin ?? true;
  const trustedOrigins = normalizeTrustedOrigins(options.trustedOrigins);
  const token = options.token ?? false;

  return {
    name: "csrf",
    onRequest: async ({ req, requestInfo, contract, params }) => {
      if (!protectedMethods.has(req.method.toUpperCase())) return undefined;
      if (await options.skip?.({ req, contract, params })) return undefined;

      enforceOrigin({
        req,
        contract,
        allowMissingOrigin,
        trustedOrigins,
        requestOrigin:
          options.trustedProxy !== undefined
            ? requestOrigin(req, options.trustedProxy)
            : (requestInfo?.origin ?? requestOrigin(req, false)),
      });
      enforceToken(req, token);
      return undefined;
    },
  };
}
