import { lookup } from "dns/promises"
import { isIP } from "net"

// ============================================================================
// SSRF protection for outbound webhooks.
//
// Webhook URLs are attacker-controllable input that the server dials on a
// schedule. Without guardrails a tenant could point one at http://169.254.169.254
// (cloud metadata) or an internal service and use our servers as a proxy. We:
//   1. require https (http allowed only outside production, for local testing);
//   2. reject credentials / non-default-ish shapes;
//   3. reject hostnames that ARE private/reserved IP literals; and
//   4. resolve the hostname and reject if ANY resolved address is private.
//
// Set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true to bypass (1) https-in-prod is still
// enforced) and the private-range checks — intended ONLY for local dev where the
// receiver runs on localhost.
// ============================================================================

const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE_HOSTS === "true"

export class WebhookUrlError extends Error {}

/** True for IPv4 addresses in a private, loopback, link-local or reserved range. */
function isPrivateIPv4(ip: string): boolean {
  const parts = ip.split(".").map((n) => parseInt(n, 10))
  if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true
  const [a, b] = parts
  if (a === 0) return true // 0.0.0.0/8 "this network"
  if (a === 10) return true // private
  if (a === 127) return true // loopback
  if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10
  if (a === 169 && b === 254) return true // link-local (incl. 169.254.169.254 metadata)
  if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12
  if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol assignments
  if (a === 192 && b === 168) return true // private
  if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18.0.0/15
  if (a >= 224) return true // multicast (224/4) + reserved (240/4) + broadcast
  return false
}

/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
function isPrivateIPv6(ip: string): boolean {
  const addr = ip.toLowerCase().split("%")[0] // strip zone id
  if (addr === "::1" || addr === "::") return true
  // IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
  const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
  if (mapped) return isPrivateIPv4(mapped[1])
  const head = addr.replace(/^\[|\]$/g, "")
  if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
    return true // fe80::/10 link-local
  if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
  if (head.startsWith("ff")) return true // ff00::/8 multicast
  return false
}

function isPrivateAddress(ip: string): boolean {
  const kind = isIP(ip)
  if (kind === 4) return isPrivateIPv4(ip)
  if (kind === 6) return isPrivateIPv6(ip)
  return true // not a parseable IP → treat as unsafe
}

/**
 * Validate a user-supplied webhook URL and, unless private hosts are allowed,
 * resolve it to confirm it does not point at an internal address. Throws
 * WebhookUrlError with a user-facing message on any violation.
 */
export async function assertSafeWebhookUrl(raw: string): Promise<void> {
  let url: URL
  try {
    url = new URL(raw)
  } catch {
    throw new WebhookUrlError("Enter a valid absolute URL.")
  }

  const isProd = process.env.NODE_ENV === "production"
  if (url.protocol !== "https:" && !(url.protocol === "http:" && !isProd)) {
    throw new WebhookUrlError("Webhook URLs must use https://")
  }
  if (url.username || url.password) {
    throw new WebhookUrlError("Webhook URLs must not contain credentials.")
  }

  const host = url.hostname.replace(/^\[|\]$/g, "")

  if (ALLOW_PRIVATE) return

  if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) {
    throw new WebhookUrlError("Webhook URLs must be publicly reachable, not localhost.")
  }

  // If the host is an IP literal, check it directly.
  if (isIP(host)) {
    if (isPrivateAddress(host)) {
      throw new WebhookUrlError("Webhook URLs must not point at private or reserved IP addresses.")
    }
    return
  }

  // Otherwise resolve it and reject if any address is internal.
  let addresses: { address: string }[]
  try {
    addresses = await lookup(host, { all: true })
  } catch {
    throw new WebhookUrlError("Could not resolve the webhook host.")
  }
  if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address))) {
    throw new WebhookUrlError("Webhook host resolves to a private or reserved address.")
  }
}

/** Non-throwing variant used at delivery time. */
export async function isSafeWebhookUrl(raw: string): Promise<boolean> {
  try {
    await assertSafeWebhookUrl(raw)
    return true
  } catch {
    return false
  }
}
