/**
 * Validate an outbound base/fetch URL before use. Throws UnsafeFetchUrlError on:
 *  - non-https scheme (require https unconditionally; the single preview env is https, no http providers)
 *  - IP-literal host in private/loopback/link-local/ULA/CGNAT/metadata ranges. Alt IPv4 encodings
 *    (octal/hex/shorthand/decimal) are normalized to canonical dotted-quad by the WHATWG URL parser
 *    before these checks; ipv4ToInt additionally parses decimal/hex/dotted host forms directly
 *    (defense-in-depth).
 *  - localhost-family hostnames (localhost, *.localhost, *.internal, *.local, metadata.google.internal)
 * Returns the parsed URL on success. Pure — no DNS resolution (not possible pre-fetch on Workers; the
 * platform routing boundary is the primary control, this guard is defense-in-depth + future-proofing).
 */
import { captureCaught } from '@/server/observability/capture.server.js';

export class UnsafeFetchUrlError extends Error {
  constructor(reason: string) {
    super(`unsafe fetch URL: ${reason}`);
    this.name = 'UnsafeFetchUrlError';
  }
}

const BLOCKED_HOST_SUFFIXES = ['.localhost', '.internal', '.local'];
const BLOCKED_HOST_EXACT = new Set(['localhost', 'metadata.google.internal']);

function ipv4ToInt(host: string): number | null {
  // Accept dotted-quad, decimal, hex, octal — the forms a URL host may carry.
  if (/^\d+$/.test(host)) {
    const n = Number(host);
    return Number.isInteger(n) && n >= 0 && n <= 0xffffffff ? n >>> 0 : null;
  }
  if (/^0x[0-9a-f]+$/i.test(host)) {
    const n = parseInt(host, 16);
    return n >= 0 && n <= 0xffffffff ? n >>> 0 : null;
  }
  const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
  if (!m) return null;
  const parts = m.slice(1).map(Number);
  if (parts.some((p) => p > 255)) return null;
  return ((parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]!) >>> 0;
}

function isPrivateIpv4(n: number): boolean {
  const inRange = (a: number, bits: number) => n >>> (32 - bits) === a >>> (32 - bits);
  return (
    inRange(0x0a000000, 8) || // 10/8
    inRange(0x7f000000, 8) || // 127/8 loopback
    inRange(0xac100000, 12) || // 172.16/12
    inRange(0xc0a80000, 16) || // 192.168/16
    inRange(0xa9fe0000, 16) || // 169.254/16 link-local + metadata
    inRange(0x64400000, 10) || // 100.64/10 CGNAT
    inRange(0x00000000, 8) // 0/8
  );
}

function isBlockedIpv6(host: string): boolean {
  // Caller strips URL.hostname brackets before invoking; host here is bracket-free.
  const h = host.toLowerCase();
  if (h === '::1') return true; // loopback
  if (h === '::' || h === '::0') return true; // unspecified — some stacks route to local
  if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb'))
    return true; // fe80::/10 link-local
  if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 ULA
  const mapped = h.match(/^::ffff:(.+)$/); // IPv4-mapped
  if (mapped) {
    const tail = mapped[1]!;
    const n = ipv4ToInt(tail);
    if (n !== null && isPrivateIpv4(n)) return true;
    const hexPair = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
    if (hexPair) {
      const packed = ((parseInt(hexPair[1]!, 16) << 16) | parseInt(hexPair[2]!, 16)) >>> 0;
      if (isPrivateIpv4(packed)) return true;
    }
  }
  return false;
}

export function assertSafeFetchUrl(raw: string): URL {
  let url: URL;
  try {
    url = new URL(raw);
  } catch (err) {
    captureCaught(err, { scope: 'server.security.safe-fetch-url.parse', severity: 'info' });
    throw new UnsafeFetchUrlError('unparseable');
  }
  if (url.protocol !== 'https:') throw new UnsafeFetchUrlError(`scheme ${url.protocol}`);

  const host = url.hostname.toLowerCase();
  if (BLOCKED_HOST_EXACT.has(host)) throw new UnsafeFetchUrlError(`host ${host}`);
  if (BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s)))
    throw new UnsafeFetchUrlError(`host ${host}`);

  const v4 = ipv4ToInt(host);
  if (v4 !== null && isPrivateIpv4(v4)) throw new UnsafeFetchUrlError(`private ipv4 ${host}`);
  const ipv6Host = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
  if (ipv6Host.includes(':') && isBlockedIpv6(ipv6Host))
    throw new UnsafeFetchUrlError(`private ipv6 ${host}`);

  return url;
}
