/**
 * SSRF guard for outbound webhook URLs.
 *
 * Blocks IPv4 RFC-1918, loopback, link-local, CGNAT, cloud-metadata IPs, as
 * well as IPv6 loopback, link-local (fe80::/10), unique-local (fc00::/7), and
 * IPv4-mapped IPv6 addresses (::ffff:...). Resolves the hostname with
 * `all: true` so a DNS record returning multiple addresses is rejected if
 * ANY of them is private — an attacker who adds a private A record alongside
 * a public one cannot smuggle past by hoping the fetch picks the "good" IP.
 *
 * DNS-rebinding defence: callers that are about to open a connection should
 * use `resolveAndValidate()` and then pass the returned `address` through
 * a `lookup` override on their HTTPS agent so the TCP connect uses the
 * validated IP, not a freshly-resolved one that the attacker may have
 * flipped after the check. See `webhook-dispatcher.ts`.
 */
import { lookup as dnsLookup } from "node:dns/promises";
import { isIP } from "node:net";
import { TRPCError } from "@trpc/server";

const IPV4_BLOCK_PATTERNS: RegExp[] = [
  /^0\./, // 0.0.0.0/8 — "this network"
  /^10\./, // RFC 1918
  /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
  /^127\./, // loopback
  /^169\.254\./, // link-local incl. AWS/Azure/GCP metadata 169.254.169.254
  /^172\.(1[6-9]|2\d|3[01])\./, // RFC 1918
  /^192\.0\.0\./, // RFC 6890 IETF protocol assignments
  /^192\.0\.2\./, // TEST-NET-1
  /^192\.168\./, // RFC 1918
  /^198\.(1[89])\./, // 198.18.0.0/15 benchmarking
  /^198\.51\.100\./, // TEST-NET-2
  /^203\.0\.113\./, // TEST-NET-3
  /^2(2[4-9]|3\d)\./, // 224.0.0.0/4 multicast
  /^2(4\d|5[0-5])\./, // 240.0.0.0/4 reserved + 255.255.255.255 broadcast
];

function isBlockedIpv4(ip: string): boolean {
  return IPV4_BLOCK_PATTERNS.some((re) => re.test(ip));
}

/**
 * Expand an IPv6 address to its full 8-group form so prefix matches work
 * reliably (::1 → 0000:0000:0000:0000:0000:0000:0000:0001).
 */
function expandIpv6(ip: string): string {
  const lower = ip.toLowerCase().replace(/%.*$/, ""); // strip zone-id
  // Handle IPv4-mapped suffix, e.g. ::ffff:192.168.0.1 → ::ffff:c0a8:0001
  const ipv4MappedMatch = lower.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
  let working = lower;
  if (ipv4MappedMatch) {
    const [, prefix, v4] = ipv4MappedMatch;
    const parts = v4!.split(".").map((n) => Number.parseInt(n, 10));
    if (parts.length === 4 && parts.every((n) => n >= 0 && n <= 255)) {
      const hi = ((parts[0]! << 8) | parts[1]!).toString(16);
      const lo = ((parts[2]! << 8) | parts[3]!).toString(16);
      working = `${prefix}${hi}:${lo}`;
    }
  }
  const parts = working.split("::");
  const head = parts[0] === "" ? [] : parts[0]!.split(":");
  const tail = parts.length > 1 ? (parts[1] === "" ? [] : parts[1]!.split(":")) : [];
  const missing = 8 - head.length - tail.length;
  const zeros = Array.from({ length: Math.max(0, missing) }, () => "0");
  const full = parts.length === 1 ? head : [...head, ...zeros, ...tail];
  return full.map((g) => g.padStart(4, "0")).join(":");
}

function isBlockedIpv6(ip: string): boolean {
  const expanded = expandIpv6(ip);
  // ::1 loopback
  if (expanded === "0000:0000:0000:0000:0000:0000:0000:0001") return true;
  // :: unspecified
  if (expanded === "0000:0000:0000:0000:0000:0000:0000:0000") return true;
  // IPv4-mapped ::ffff:0:0/96 — extract the embedded v4 and run the v4 check
  if (expanded.startsWith("0000:0000:0000:0000:0000:ffff:")) {
    const g6 = expanded.split(":")[6]!;
    const g7 = expanded.split(":")[7]!;
    const v4 = [
      Number.parseInt(g6.slice(0, 2), 16),
      Number.parseInt(g6.slice(2, 4), 16),
      Number.parseInt(g7.slice(0, 2), 16),
      Number.parseInt(g7.slice(2, 4), 16),
    ].join(".");
    return isBlockedIpv4(v4);
  }
  // fc00::/7 unique-local — first byte starts with 1111110x → fc or fd
  if (/^f[cd]/.test(expanded)) return true;
  // fe80::/10 link-local — first 10 bits 1111111010 → fe80..febf
  if (/^fe[89ab]/.test(expanded)) return true;
  // ff00::/8 multicast
  if (/^ff/.test(expanded)) return true;
  // 2001:db8::/32 documentation
  if (expanded.startsWith("2001:0db8:")) return true;
  return false;
}

function isBlockedIp(ip: string): boolean {
  const family = isIP(ip);
  if (family === 4) return isBlockedIpv4(ip);
  if (family === 6) return isBlockedIpv6(ip);
  // Not a valid IP — err on the side of caution.
  return true;
}

const BLOCKED_HOSTNAMES = new Set([
  "localhost",
  "ip6-localhost",
  "ip6-loopback",
  "metadata.google.internal",
  "metadata.goog",
  "169.254.169.254",
]);

export interface ResolvedHost {
  hostname: string;
  /** The pre-validated address to dial. */
  address: string;
  family: 4 | 6;
}

/**
 * Resolve the given URL's hostname, validate every address against the
 * SSRF blocklist, and return the first valid address for connection pinning.
 * Rejects the URL if ANY resolved address is private — an attacker cannot
 * evade by adding a private A record to a public-looking hostname.
 */
export async function resolveAndValidate(urlString: string): Promise<ResolvedHost> {
  let parsed: URL;
  try {
    parsed = new URL(urlString);
  } catch {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid webhook URL." });
  }

  if (parsed.protocol !== "https:") {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URLs must use HTTPS." });
  }

  const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");

  if (BLOCKED_HOSTNAMES.has(hostname)) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL target is not allowed." });
  }

  // Literal IP hostnames: validate directly without DNS.
  const literalFamily = isIP(hostname);
  if (literalFamily !== 0) {
    if (isBlockedIp(hostname)) {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: "Webhook URL target is not allowed.",
      });
    }
    return { hostname, address: hostname, family: literalFamily as 4 | 6 };
  }

  let addresses: Array<{ address: string; family: number }>;
  try {
    addresses = await dnsLookup(hostname, { all: true });
  } catch {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL could not be validated." });
  }
  if (addresses.length === 0) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Webhook URL could not be validated." });
  }

  for (const { address } of addresses) {
    if (isBlockedIp(address) || BLOCKED_HOSTNAMES.has(address)) {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: "Webhook URL target is not allowed.",
      });
    }
  }

  const first = addresses[0]!;
  return { hostname, address: first.address, family: first.family as 4 | 6 };
}

/**
 * Throws a TRPCError if the given URL targets internal/private infrastructure.
 * Preserved as a compatibility entrypoint for callers that only need the
 * allow/deny decision without the pinned address.
 */
export async function assertWebhookUrlAllowed(urlString: string): Promise<void> {
  await resolveAndValidate(urlString);
}

/** Exposed for unit tests. */
export const __test__ = { isBlockedIpv4, isBlockedIpv6, expandIpv6, isBlockedIp };
