import { BadRequestException } from '@nestjs/common';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';

/**
 * SSRF guard for operator-supplied outbound URLs (webhooks today; any future
 * "we POST to a URL you give us" feature should reuse this).
 *
 * The danger: an operator (or anyone who can create a webhook) points the URL at
 * an internal address — 127.0.0.1, the NATS/DB ports, 192.168.x, or the cloud
 * metadata endpoint 169.254.169.254 — and turns our server into a request proxy
 * into the private network. We defend by resolving the host and refusing any
 * private / loopback / link-local / reserved destination.
 *
 * Validate at storage (early, clear 400) AND immediately before each delivery
 * (a hostname can resolve public at create time and private at send time — DNS
 * rebinding / TOCTOU). `redirect: 'manual'` at the fetch call closes the
 * redirect-bounce variant.
 */

function isBlockedIpv4(ip: string): boolean {
  const parts = ip.split('.').map((p) => parseInt(p, 10));
  if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
    return true; // unparseable → block defensively
  }
  const [a, b] = parts;
  if (a === 0) return true; // 0.0.0.0/8 "this network"
  if (a === 10) return true; // 10.0.0.0/8 private
  if (a === 127) return true; // 127.0.0.0/8 loopback
  if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
  if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
  if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
  if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
  if (a === 255) return true; // 255.x broadcast space
  return false;
}

function isBlockedIpv6(ip: string): boolean {
  const addr = ip.toLowerCase();
  // IPv4-mapped (::ffff:1.2.3.4) — unwrap and apply the v4 rules.
  const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
  if (mapped) return isBlockedIpv4(mapped[1]);
  if (addr === '::' || addr === '::1') return true; // unspecified / loopback
  const head = addr.split(':')[0];
  if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 ULA
  if (/^fe[89ab]/.test(head)) return true; // fe80::/10 link-local
  return false;
}

function isBlockedIp(ip: string): boolean {
  const fam = isIP(ip);
  if (fam === 4) return isBlockedIpv4(ip);
  if (fam === 6) return isBlockedIpv6(ip);
  return true; // not a recognizable IP → block defensively
}

/** Parse + require http/https scheme. Throws BadRequestException on anything else. */
export function parseHttpUrl(raw: string): URL {
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    throw new BadRequestException('Webhook URL is not a valid URL');
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new BadRequestException('Webhook URL must use http:// or https://');
  }
  return url;
}

/**
 * Resolve the host and reject if it maps to any private / reserved address.
 * If a hostname resolves to multiple addresses, ANY blocked one rejects the
 * whole URL (a DNS-rebinding response that mixes a public and a private answer
 * must not slip through). Returns the parsed URL on success.
 */
export async function assertPublicHttpUrl(raw: string): Promise<URL> {
  const url = parseHttpUrl(raw);
  // URL keeps IPv6 literals bracketed ("[::1]") — strip so isIP/lookup see the
  // bare address; otherwise IPv6 literals never reach the classifier.
  const host = url.hostname.replace(/^\[|\]$/g, '');

  let addresses: Array<{ address: string }>;
  if (isIP(host)) {
    addresses = [{ address: host }];
  } else {
    try {
      addresses = await lookup(host, { all: true });
    } catch {
      throw new BadRequestException(`Webhook host could not be resolved: ${host}`);
    }
  }

  if (addresses.length === 0 || addresses.some((a) => isBlockedIp(a.address))) {
    throw new BadRequestException(
      'Webhook URL resolves to a private or reserved address and is not allowed',
    );
  }
  return url;
}
