/**
 * SSRF guard for tenant-controlled outbound URLs.
 *
 * Cloudflare Workers cannot resolve DNS before fetch — rebinding domains that
 * resolve to private IPs at registration time but public IPs at delivery time
 * are not detectable here. Enforce every statically checkable rule at save time
 * and again immediately before each outbound fetch.
 */

export const SAFE_OUTBOUND_URL_MESSAGE = 'URL must be a public HTTPS endpoint'

export class UnsafeOutboundUrlError extends Error {
  constructor(message = SAFE_OUTBOUND_URL_MESSAGE) {
    super(message)
    this.name = 'UnsafeOutboundUrlError'
  }
}

export type SafeUrlValidationResult =
  | { ok: true }
  | { ok: false; reason: string }

/** Known browser push service host suffixes (RFC 8030). */
export const PUSH_ENDPOINT_HOST_SUFFIXES = [
  'fcm.googleapis.com',
  'updates.push.services.mozilla.com',
  'push.services.mozilla.com',
  'notify.windows.com',
  'web.push.apple.com',
  'push.apple.com',
] as const

export function validateSafeOutboundUrl(rawUrl: string): SafeUrlValidationResult {
  let parsed: URL
  try {
    parsed = new URL(rawUrl)
  } catch {
    return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
  }

  if (parsed.protocol !== 'https:') {
    return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
  }

  const hostname = normalizeHostname(parsed.hostname.toLowerCase())

  if (isBlockedHostname(hostname)) {
    return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
  }

  const ipv4 = parseIpv4Literal(hostname)
  if (ipv4) {
    if (isPrivateOrReservedIpv4(ipv4)) {
      return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
    }
    return { ok: true }
  }

  if (hostname.includes(':')) {
    if (isPrivateOrReservedIpv6(hostname)) {
      return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
    }
    return { ok: true }
  }

  // Reject bare numeric hostnames that are not valid dotted IPv4 (decimal/octal/hex literals).
  if (/^(?:0x[0-9a-f]+|\d+)$/i.test(hostname)) {
    return { ok: false, reason: SAFE_OUTBOUND_URL_MESSAGE }
  }

  return { ok: true }
}

export function assertSafeOutboundUrl(rawUrl: string): void {
  const result = validateSafeOutboundUrl(rawUrl)
  if (!result.ok) {
    throw new UnsafeOutboundUrlError(result.reason)
  }
}

/** @deprecated Use assertSafeOutboundUrl — kept for webhook call sites. */
export const assertSafeWebhookUrl = assertSafeOutboundUrl

export function validateSafePushEndpointUrl(rawUrl: string): SafeUrlValidationResult {
  let parsed: URL
  try {
    parsed = new URL(rawUrl)
  } catch {
    return { ok: false, reason: 'Push endpoint must be a known public HTTPS push service URL' }
  }

  if (parsed.protocol !== 'https:') {
    return { ok: false, reason: 'Push endpoint must be a known public HTTPS push service URL' }
  }

  const hostname = normalizeHostname(parsed.hostname.toLowerCase())
  if (!isAllowedPushHost(hostname)) {
    return { ok: false, reason: 'Push endpoint must be a known public HTTPS push service URL' }
  }

  return validateSafeOutboundUrl(rawUrl)
}

export function assertSafePushEndpointUrl(rawUrl: string): void {
  const result = validateSafePushEndpointUrl(rawUrl)
  if (!result.ok) {
    throw new UnsafeOutboundUrlError(result.reason)
  }
}

/** Strip FQDN trailing dot so blocklist / IP literal checks cannot be bypassed. */
function normalizeHostname(hostname: string): string {
  return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname
}

function isAllowedPushHost(hostname: string): boolean {
  return PUSH_ENDPOINT_HOST_SUFFIXES.some(
    (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
  )
}

function isBlockedHostname(hostname: string): boolean {
  if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true
  if (hostname.endsWith('.internal')) return true
  if (hostname.endsWith('.local')) return true
  if (hostname === 'metadata' || hostname.startsWith('metadata.')) return true
  return false
}

type Ipv4Octets = [number, number, number, number]

function parseIpv4Literal(hostname: string): Ipv4Octets | null {
  const bare = parseBareIpv4Integer(hostname)
  if (bare) return bare

  if (!hostname.includes('.')) return null

  const parts = hostname.split('.')
  if (parts.length < 1 || parts.length > 4) return null

  const octets: number[] = []
  for (const part of parts) {
    if (!part) return null
    const value = parseIpv4Part(part)
    if (value === null) return null
    octets.push(value)
  }

  while (octets.length < 4) octets.push(0)
  if (octets.length > 4) return null

  return octets as Ipv4Octets
}

function parseBareIpv4Integer(hostname: string): Ipv4Octets | null {
  let value: number | null = null

  if (/^0x[0-9a-f]+$/i.test(hostname)) {
    value = Number.parseInt(hostname, 16)
  } else if (/^0[0-7]+$/i.test(hostname)) {
    value = Number.parseInt(hostname, 8)
  } else if (/^\d+$/.test(hostname)) {
    value = Number.parseInt(hostname, 10)
  } else {
    return null
  }

  if (!Number.isFinite(value) || value < 0 || value > 0xffff_ffff) return null

  return [
    (value >>> 24) & 0xff,
    (value >>> 16) & 0xff,
    (value >>> 8) & 0xff,
    value & 0xff,
  ]
}

function parseIpv4Part(part: string): number | null {
  if (/^0x[0-9a-f]+$/i.test(part)) {
    const value = Number.parseInt(part, 16)
    return value >= 0 && value <= 0xff ? value : null
  }
  if (/^0[0-7]+$/i.test(part)) {
    const value = Number.parseInt(part, 8)
    return value >= 0 && value <= 0xff ? value : null
  }
  if (/^\d+$/.test(part)) {
    const value = Number.parseInt(part, 10)
    return value >= 0 && value <= 0xff ? value : null
  }
  return null
}

function isPrivateOrReservedIpv4([a, b]: Ipv4Octets): boolean {
  if (a === 0) return true
  if (a === 10) return true
  if (a === 127) return true
  if (a === 169 && b === 254) return true
  if (a === 172 && b >= 16 && b <= 31) return true
  if (a === 192 && b === 168) return true
  return false
}

function isPrivateOrReservedIpv6(hostname: string): boolean {
  const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()

  if (normalized === '::' || normalized === '::1') return true

  const mappedTail = normalized.match(/^::ffff:(.+)$/i)
  if (mappedTail?.[1]) {
    const ipv4 = parseIpv4Literal(mappedTail[1])
    if (!ipv4) return true
    return isPrivateOrReservedIpv4(ipv4)
  }

  const hextets = expandIpv6(normalized)
  if (!hextets) return true

  const first = hextets[0]
  if (first === undefined) return true
  if ((first & 0xffc0) === 0xfe80) return true
  if ((first & 0xfe00) === 0xfc00) return true
  if (hextets.every((h, i) => (i < 7 ? h === 0 : h === 1))) return true

  const mapped = parseIpv4MappedHextets(hextets)
  if (mapped) return isPrivateOrReservedIpv4(mapped)

  return false
}

function expandIpv6(address: string): number[] | null {
  const parts = address.split('::')
  if (parts.length > 2) return null

  const parseSide = (side: string): number[] => {
    if (!side) return []
    return side.split(':').map((part) => Number.parseInt(part || '0', 16))
  }

  let hextets: number[]
  if (parts.length === 2) {
    const left = parseSide(parts[0] ?? '')
    const right = parseSide(parts[1] ?? '')
    const missing = 8 - left.length - right.length
    if (missing < 0) return null
    hextets = [...left, ...Array<number>(missing).fill(0), ...right]
  } else {
    hextets = parseSide(parts[0] ?? '')
  }

  if (hextets.length !== 8 || hextets.some((h) => h < 0 || h > 0xffff)) return null
  return hextets
}

function parseIpv4MappedHextets(hextets: number[]): Ipv4Octets | null {
  const isZeroPrefix = hextets.slice(0, 5).every((h) => h === 0)
  if (!isZeroPrefix || hextets[5] !== 0xffff) return null

  const high = hextets[6]
  const low = hextets[7]
  if (high === undefined || low === undefined) return null
  return [
    (high >>> 8) & 0xff,
    high & 0xff,
    (low >>> 8) & 0xff,
    low & 0xff,
  ]
}
