/**
 * SSRF guard.
 *
 * Resolves a hostname to its addresses and rejects if any of them sits in
 * a private / reserved range. The "any" rule (not "all") is deliberate:
 * a hostile DNS server can return both a public and a private record so
 * the first probe sees public and subsequent probes hit internal hosts
 * (classic DNS rebinding). Refusing on mixed results blocks that vector.
 *
 * Loose coupling: pure function + plain error type. No HTTP, no Prisma,
 * no monitor types. Reusable by http/tcp/ping strategies and any future
 * outbound-probe code path.
 */
import dns from 'dns';
import { isPrivateIp, isIpLiteral, isAlwaysBlocked, isLoopbackOrUla } from './private-ranges';

export class BlockedHostError extends Error {
    readonly status = 400;
    constructor(public readonly hostname: string, public readonly ip?: string) {
        const detail = ip ? ` resolves to private address ${ip}` : ' is not a public address';
        super(`Blocked: ${hostname}${detail}`);
        this.name = 'BlockedHostError';
    }
}

export interface ResolvedTarget {
    ip: string;
    family: 4 | 6;
    hostname: string;
}

interface ResolveOptions {
    /** Bypass the private-range check. Driven by ALLOW_PRIVATE_NETWORK env. */
    allowPrivate?: boolean;
    /**
     * Second-tier opt-in for `127.0.0.0/8`, `::1`, `fc00::/7`. Driven by
     * `ALLOW_LOOPBACK_PROBES=true`. ALLOW_PRIVATE_NETWORK does NOT unlock
     * these — same-host private networks are too easy to probe by accident.
     * See AUDIT-2 #2 (2026-05-24).
     */
    allowLoopback?: boolean;
}

function ipFamily(ip: string): 4 | 6 {
    return ip.includes(':') ? 6 : 4;
}

/**
 * Resolve `hostname` to a single public IP, or throw BlockedHostError.
 *
 * - IP literals are checked directly with no DNS round-trip.
 * - Hostnames trigger parallel A + AAAA lookups.
 * - If *any* address sits in a private range → reject (DNS-rebinding defence).
 * - Prefer IPv4 when both families are available (matches existing HTTP
 *   strategy behaviour at http.strategy.ts:49 which only uses resolve4).
 */
export async function resolveAndValidate(
    hostname: string,
    opts: ResolveOptions = {}
): Promise<ResolvedTarget> {
    const cleaned = hostname.replace(/^\[|\]$/g, '');

    if (isIpLiteral(cleaned)) {
        // ALWAYS_BLOCKED (cloud metadata, link-local) overrides allowPrivate.
        if (isAlwaysBlocked(cleaned)) {
            throw new BlockedHostError(hostname, cleaned);
        }
        // Loopback + ULA need their own opt-in (allowLoopback). allowPrivate
        // is not enough — see AUDIT-2 #2.
        if (!opts.allowLoopback && isLoopbackOrUla(cleaned)) {
            throw new BlockedHostError(hostname, cleaned);
        }
        if (!opts.allowPrivate && isPrivateIp(cleaned)) {
            throw new BlockedHostError(hostname, cleaned);
        }
        return { ip: cleaned, family: ipFamily(cleaned), hostname };
    }

    const [v4, v6] = await Promise.all([
        dns.promises.resolve4(cleaned).catch(() => [] as string[]),
        dns.promises.resolve6(cleaned).catch(() => [] as string[]),
    ]);

    const all = [...v4, ...v6];
    if (all.length === 0) {
        throw new BlockedHostError(hostname);
    }

    // Always-blocked check applies regardless of allowPrivate.
    const offendingAlways = all.find(isAlwaysBlocked);
    if (offendingAlways) {
        throw new BlockedHostError(hostname, offendingAlways);
    }

    // Loopback + ULA need allowLoopback; allowPrivate is not enough.
    if (!opts.allowLoopback) {
        const offendingLoopback = all.find(isLoopbackOrUla);
        if (offendingLoopback) {
            throw new BlockedHostError(hostname, offendingLoopback);
        }
    }

    if (!opts.allowPrivate) {
        const offending = all.find(isPrivateIp);
        if (offending) {
            throw new BlockedHostError(hostname, offending);
        }
    }

    const chosen = v4[0] ?? v6[0];
    return { ip: chosen, family: ipFamily(chosen), hostname };
}
