import {lookup} from 'node:dns/promises'
import {request as httpRequest, type IncomingMessage} from 'node:http'
import {request as httpsRequest} from 'node:https'
import {isIP} from 'node:net'
import {Readable} from 'node:stream'

import {HTTPException} from 'hono/http-exception'

import {readStreamCapped} from './stream'

// ssrf-guarded outbound fetch, shared by link preview + remote image fetch:
// public hosts only (re-checked on every redirect), manual redirect following,
// and a capped body reader. the connection is pinned to the address that was
// validated — the request never re-resolves dns, so a rebinding server cannot
// pass the check with a public address and serve the fetch from a private one

const FETCH_TIMEOUT_MS = 8_000
const MAX_REDIRECTS = 3
const USER_AGENT = 'crumbs-link-preview/1.0'

const isPrivateIpv4 = (ip: string): boolean => {
  const octets = ip.split('.').map(Number)
  const [a, b, c] = octets as [number, number, number]
  return (
    a === 0 ||
    a === 10 ||
    a === 127 ||
    (a === 100 && b >= 64 && b <= 127) ||
    (a === 169 && b === 254) ||
    (a === 172 && b >= 16 && b <= 31) ||
    (a === 192 && b === 0 && c === 0) ||
    (a === 192 && b === 168) ||
    (a === 198 && (b === 18 || b === 19)) ||
    a >= 224 // multicast + reserved + broadcast
  )
}

export const isPrivateAddress = (ip: string): boolean => {
  if (isIP(ip) === 4) return isPrivateIpv4(ip)
  const lower = ip.toLowerCase()
  if (lower === '::' || lower === '::1') return true
  if (lower.startsWith('::ffff:')) {
    const mapped = lower.slice(7)
    return isIP(mapped) === 4 ? isPrivateIpv4(mapped) : true
  }
  return (
    lower.startsWith('fc') ||
    lower.startsWith('fd') ||
    lower.startsWith('fe8') ||
    lower.startsWith('fe9') ||
    lower.startsWith('fea') ||
    lower.startsWith('feb')
  )
}

type ResolvedAddress = {address: string; family: number}

// resolve and validate a url's host, returning the one address the connection
// may use
export const resolvePublicAddress = async (
  url: URL,
): Promise<ResolvedAddress> => {
  if (url.protocol !== 'http:' && url.protocol !== 'https:')
    throw new HTTPException(400, {message: 'invalid_url'})
  const host = url.hostname
  const literal = isIP(host)
  const addresses = literal
    ? [{address: host, family: literal}]
    : await lookup(host, {all: true}).catch(() => [])
  if (!addresses.length)
    throw new HTTPException(400, {message: 'unresolvable_url'})
  if (addresses.some(({address}) => isPrivateAddress(address)))
    throw new HTTPException(400, {message: 'invalid_url'})
  return addresses[0] as ResolvedAddress
}

const toResponse = (res: IncomingMessage): Response => {
  const headers = new Headers()
  for (const [key, value] of Object.entries(res.headers)) {
    if (typeof value === 'string') headers.set(key, value)
    else if (Array.isArray(value))
      for (const entry of value) headers.append(key, entry)
  }
  const status = res.statusCode ?? 502
  const body =
    status === 204 || status === 304
      ? null
      : (Readable.toWeb(res) as unknown as ReadableStream)
  return new Response(body, {status, headers})
}

// one pinned hop: connects to the pre-validated address while tls sni and
// certificate verification still use the url's hostname
export const fetchPinned = (
  url: URL,
  {address, family}: ResolvedAddress,
  accept: string,
): Promise<Response> =>
  new Promise((resolve, reject) => {
    const request =
      url.protocol === 'https:' ? httpsRequest : httpRequest
    const req = request(
      {
        host: url.hostname,
        port: url.port || (url.protocol === 'https:' ? 443 : 80),
        path: `${url.pathname}${url.search}`,
        headers: {Accept: accept, 'User-Agent': USER_AGENT},
        signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
        lookup: (_host, _options, callback) =>
          callback(null, [{address, family}]),
      },
      (res) => resolve(toResponse(res)),
    )
    req.on('error', reject)
    req.end()
  })

type FetchHop = typeof fetchPinned

const parseUrl = (raw: string, base?: URL): URL | null => {
  try {
    return new URL(raw, base)
  } catch {
    return null
  }
}

// fetch following up to MAX_REDIRECTS manually, validating + pinning the host
// at each hop. returns null when a url is unparseable, a redirect lacks a
// location, or the budget is exhausted; a private/invalid host throws 400,
// an unreachable host throws 502. redirects may downgrade the scheme and
// target any port — allowed by design, only the host is policed
export const createGuardedFetch =
  (fetchHop: FetchHop) =>
  async (
    rawUrl: string,
    accept: string,
  ): Promise<Response | null> => {
    let url = parseUrl(rawUrl)
    for (let i = 0; i <= MAX_REDIRECTS; i++) {
      if (!url) return null
      const resolved = await resolvePublicAddress(url)
      const res = await fetchHop(url, resolved, accept).catch(() => {
        throw new HTTPException(502, {message: 'unreachable_url'})
      })
      if (res.status >= 300 && res.status < 400) {
        await res.body?.cancel().catch(() => {})
        const location = res.headers.get('location')
        if (!location) return null
        url = parseUrl(location, url)
        continue
      }
      return res
    }
    return null
  }

export const guardedFetch = createGuardedFetch(fetchPinned)

// read a response body into bytes, capped at max (the last chunk may overshoot
// slightly before the read is cancelled)
export const readCapped = (
  res: Response,
  max: number,
): Promise<Uint8Array> =>
  readStreamCapped(res.body?.getReader(), max, 'truncate')
