import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { SecurityError } from "./errors.ts";
import { validateUrl, type SafeUrlPolicy } from "./url.ts";

export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
  timeoutMs?: number;
  maxRedirects?: number;
  maxResponseBytes?: number;
  blockPrivateNetworks?: boolean;
  /** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */
  forwardSensitiveHeaders?: boolean;
  /**
   * Connect to the address this module validated instead of re-resolving the
   * hostname inside `fetch`. Without it the private-network guard is advisory
   * only: a low-TTL DNS record can answer with a public address for our lookup
   * and a private one for the connection (DNS rebinding). Defaults to true
   * whenever `blockPrivateNetworks` is on.
   */
  pinDns?: boolean;
  resolver?: (hostname: string) => Promise<string[]>;
}

function isPrivateIpv4(address: string): boolean {
  const parts = address.split(".").map(Number);
  if (
    parts.length !== 4 ||
    parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
  ) {
    return false;
  }
  const [a, b] = parts;
  return (
    a === 0 ||
    a === 10 ||
    a === 127 ||
    (a === 169 && b === 254) ||
    (a === 172 && b! >= 16 && b! <= 31) ||
    (a === 192 && b === 0) ||
    (a === 192 && b === 168) ||
    (a === 198 && b! >= 18 && b! <= 19) ||
    (a === 100 && b! >= 64 && b! <= 127) ||
    a! >= 224
  );
}

/**
 * Any IPv4-mapped form, not just the compact `::ffff:1.2.3.4` spelling.
 * `0:0:0:0:0:ffff:127.0.0.1` and `::ffff:7f00:1` address loopback just as well.
 */
function mappedIpv4Of(normalized: string): string | null {
  const dotted = /^(?:0*:)*0*ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
  if (dotted) return dotted;
  const hex = /^(?:0*:)*0*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized);
  if (!hex) return null;
  const high = Number.parseInt(hex[1]!, 16);
  const low = Number.parseInt(hex[2]!, 16);
  return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
}

function isPrivateIpv6(address: string): boolean {
  const normalized = address.toLowerCase().split("%")[0]!;
  const mappedIpv4 = mappedIpv4Of(normalized);
  if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
  if (/^(?:0*:)+0*1$/.test(normalized)) return true; // ::1 in any expansion
  if (/^(?:0*:)*0*$/.test(normalized)) return true; // :: / all-zero
  return (
    normalized.startsWith("fc") ||
    normalized.startsWith("fd") ||
    // fe80::/10 link-local through fec0::/10 site-local: every fe8-febf plus
    // the deprecated-but-still-routable fec0-feff site-local block.
    /^fe[89abcdef]/.test(normalized) ||
    normalized.startsWith("ff")
  );
}

export function isPrivateAddress(address: string): boolean {
  const version = isIP(address);
  return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false;
}

async function defaultResolver(hostname: string): Promise<string[]> {
  if (isIP(hostname)) return [hostname];
  return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
}

/** Resolve a host and reject it unless every answer is a public address. */
async function resolvePublicHost(url: URL, options: SafeFetchOptions): Promise<string[]> {
  if (options.blockPrivateNetworks === false) return [];
  const addresses = await (options.resolver ?? defaultResolver)(url.hostname);
  if (!addresses.length) {
    throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`);
  }
  const blocked = addresses.find(isPrivateAddress);
  if (blocked) {
    throw new SecurityError(
      "WRN-SEC-SSRF-PRIVATE",
      `Host '${url.hostname}' resolves to blocked address '${blocked}'.`,
      403,
    );
  }
  return addresses;
}

/**
 * Rewrite the request to dial the address we just validated, keeping `Host`
 * (and TLS SNI/certificate verification) pointed at the original hostname.
 * This is what makes the private-network guard binding rather than advisory.
 */
function pinToAddress(url: URL, address: string, init: RequestInit, headers: Headers): URL {
  const pinned = new URL(url);
  pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
  headers.set("host", url.host);
  if (url.protocol === "https:") {
    (init as { tls?: { serverName: string } }).tls = { serverName: url.hostname };
  }
  return pinned;
}

export async function safeFetch(
  input: string | URL,
  options: SafeFetchOptions = {},
): Promise<Response> {
  const timeoutMs = options.timeoutMs ?? 10_000;
  const maxRedirects = options.maxRedirects ?? 3;
  const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024;
  const controller = new AbortController();
  const externalSignal = options.signal;
  const abort = () => controller.abort(externalSignal?.reason);
  externalSignal?.addEventListener("abort", abort, { once: true });
  const timer = setTimeout(
    () => controller.abort(new Error("WRNexus safeFetch timeout")),
    timeoutMs,
  );

  const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" };
  delete (init as Record<string, unknown>).timeoutMs;
  delete (init as Record<string, unknown>).maxRedirects;
  delete (init as Record<string, unknown>).maxResponseBytes;
  delete (init as Record<string, unknown>).blockPrivateNetworks;
  delete (init as Record<string, unknown>).resolver;
  delete (init as Record<string, unknown>).forwardSensitiveHeaders;
  delete (init as Record<string, unknown>).pinDns;
  delete (init as Record<string, unknown>).base;
  delete (init as Record<string, unknown>).allowRelative;
  delete (init as Record<string, unknown>).allowedProtocols;
  delete (init as Record<string, unknown>).allowedHosts;
  delete (init as Record<string, unknown>).blockedHosts;
  delete (init as Record<string, unknown>).allowCredentials;
  delete (init as Record<string, unknown>).allowDataImages;

  try {
    let current = validateUrl(input, {
      ...options,
      allowRelative: false,
      allowedProtocols: options.allowedProtocols ?? ["https:"],
    });
    // Compare against where the caller's credentials were meant to go, not
    // against the previous hop: a→b→b must not re-attach them on the second
    // b request just because that hop did not change origin.
    const credentialOrigin = current.origin;
    for (let redirect = 0; ; redirect++) {
      const addresses = await resolvePublicHost(current, options);
      const requestInit: RequestInit = { ...init };
      const headers = new Headers(init.headers);
      if (!options.forwardSensitiveHeaders && current.origin !== credentialOrigin) {
        headers.delete("authorization");
        headers.delete("cookie");
        headers.delete("proxy-authorization");
      }
      const pinDns = options.pinDns ?? options.blockPrivateNetworks !== false;
      const target =
        pinDns && addresses.length
          ? pinToAddress(current, addresses[0]!, requestInit, headers)
          : current;
      requestInit.headers = headers;
      const response = await fetch(target, requestInit);
      if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
        if (redirect >= maxRedirects) {
          throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502);
        }
        current = validateUrl(new URL(response.headers.get("location")!, current), {
          ...options,
          allowRelative: false,
          allowedProtocols: options.allowedProtocols ?? ["https:"],
        });
        continue;
      }

      const contentLength = Number(response.headers.get("content-length") ?? "0");
      if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
        throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
      }
      const bytes = new Uint8Array(await response.arrayBuffer());
      if (bytes.byteLength > maxResponseBytes) {
        throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
      }
      return new Response(bytes, {
        status: response.status,
        statusText: response.statusText,
        headers: response.headers,
      });
    }
  } finally {
    clearTimeout(timer);
    externalSignal?.removeEventListener("abort", abort);
  }
}
