import { SecurityError } from "./errors.ts";

export interface SafeUrlPolicy {
  base?: string | URL;
  allowRelative?: boolean;
  allowedProtocols?: string[];
  allowedHosts?: string[];
  blockedHosts?: string[];
  allowCredentials?: boolean;
  allowDataImages?: boolean;
}

const DEFAULT_PROTOCOLS = ["http:", "https:"];

const RELATIVE_PREFIX = /^(?:\.{0,2}\/|\/|\?|#)/;

/**
 * `//evil.com` (and the `/\evil.com` spelling browsers normalise to it) reads
 * like a same-site path but navigates cross-origin. It must never be handed
 * back verbatim, or the host checks above are bypassed entirely.
 */
function isProtocolRelative(raw: string): boolean {
  return /^[/\\]{2}/.test(raw);
}

function hasAsciiControlOrSpace(value: string): boolean {
  for (const character of value) {
    const code = character.charCodeAt(0);
    if (code <= 0x20 || code === 0x7f) return true;
  }
  return false;
}

function hostnameMatches(hostname: string, rule: string): boolean {
  const normalized = rule.toLowerCase().replace(/\.$/, "");
  const host = hostname.toLowerCase().replace(/\.$/, "");
  if (normalized.startsWith("*.")) {
    const suffix = normalized.slice(1);
    return host.endsWith(suffix) && host.length > suffix.length;
  }
  return host === normalized;
}

export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): URL {
  const raw = String(value);
  if (!raw || hasAsciiControlOrSpace(raw)) {
    throw new SecurityError(
      "WRN-SEC-URL-CONTROL",
      "URL contains whitespace or control characters.",
    );
  }

  const isRelative = RELATIVE_PREFIX.test(raw);
  if (isRelative && policy.allowRelative === false) {
    throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed.");
  }

  let url: URL;
  try {
    url = new URL(raw, policy.base ?? "http://wrnexus.invalid");
  } catch (error) {
    throw new SecurityError("WRN-SEC-URL-INVALID", "Invalid URL.", 400, { cause: error });
  }

  if (url.protocol === "data:") {
    if (policy.allowDataImages && /^data:image\/(?:png|gif|jpeg|webp|avif);/i.test(raw)) return url;
    throw new SecurityError("WRN-SEC-URL-DATA", "Data URLs are not allowed by this policy.");
  }

  const protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;
  if (!protocols.includes(url.protocol)) {
    throw new SecurityError(
      "WRN-SEC-URL-PROTOCOL",
      `URL protocol '${url.protocol}' is not allowed.`,
    );
  }
  if (!policy.allowCredentials && (url.username || url.password)) {
    throw new SecurityError("WRN-SEC-URL-CREDENTIALS", "Credentials in URLs are not allowed.");
  }

  if (policy.blockedHosts?.some((rule) => hostnameMatches(url.hostname, rule))) {
    throw new SecurityError("WRN-SEC-URL-BLOCKED-HOST", `Host '${url.hostname}' is blocked.`);
  }
  if (
    policy.allowedHosts?.length &&
    !policy.allowedHosts.some((rule) => hostnameMatches(url.hostname, rule))
  ) {
    throw new SecurityError("WRN-SEC-URL-HOST", `Host '${url.hostname}' is not allowlisted.`);
  }

  return url;
}

export function isSafeUrl(value: string | URL, policy: SafeUrlPolicy = {}): boolean {
  try {
    validateUrl(value, policy);
    return true;
  } catch {
    return false;
  }
}

export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string {
  try {
    const raw = String(value ?? "");
    const url = validateUrl(raw, policy);
    // Genuinely relative input round-trips unchanged; protocol-relative input
    // is resolved so the returned string carries the host the policy approved.
    if (RELATIVE_PREFIX.test(raw) && !isProtocolRelative(raw)) return raw;
    return url.toString();
  } catch {
    return "about:blank";
  }
}
