/**
 * @fileoverview Provides a utility function to make fetch requests with a specified timeout
 * and optional SSRF protection including DNS resolution validation and redirect following.
 * @module src/utils/network/fetchWithTimeout
 */
import { JsonRpcErrorCode, McpError, serviceUnavailable, timeout, validationError, } from '../../types-global/errors.js';
import { logger } from '../../utils/internal/logger.js';
import { runtimeCaps } from '../../utils/internal/runtime.js';
import { httpStatusToErrorCode } from '../../utils/network/httpError.js';
import { createHistogram } from '../../utils/telemetry/metrics.js';
/** Maximum captured bytes of an upstream error response body. Keeps tool errors from poisoning the agent's context. */
const ERROR_BODY_LIMIT = 500;
let clientDurationHistogram;
function getHttpClientMetrics() {
    clientDurationHistogram ??= createHistogram('http.client.request.duration', 'Duration of outbound HTTP requests', 's');
    return { clientDurationHistogram };
}
/** Eagerly creates the HTTP client duration histogram so the series exists from startup. */
export function initHttpClientMetrics() {
    getHttpClientMetrics();
}
/**
 * Redacts a URL to `origin + pathname` for safe inclusion in error messages and
 * logs. Drops the query string — where API keys commonly ride (`?api-key=…`,
 * `?api_key=…`, `?key=…`) — along with the fragment and any embedded
 * `user:pass@` credentials (`URL.origin` omits userinfo). A trailing `?…` marks
 * that a query was present (redacted) so diagnostics still signal it; a
 * bare-domain `/` pathname is elided to keep output clean.
 *
 * Fail-closed: an unparseable URL returns a fixed placeholder rather than
 * echoing the raw (possibly secret-bearing) string.
 *
 * Used only for log/error *text* — never for the actual `fetch` call, which
 * always receives the full URL.
 */
function redactUrl(url) {
    try {
        const parsed = url instanceof URL ? url : new URL(String(url));
        const path = parsed.pathname === '/' ? '' : parsed.pathname;
        const marker = parsed.search ? '?…' : '';
        return `${parsed.origin}${path}${marker}`;
    }
    catch {
        return '[unparseable URL]';
    }
}
/**
 * IPv4 patterns for private/reserved ranges that should be blocked when
 * `rejectPrivateIPs` is enabled. Covers RFC 1918, loopback, link-local,
 * and cloud metadata endpoints.
 */
const PRIVATE_IP_PATTERNS = [
    /^127\./, // Loopback
    /^10\./, // RFC 1918 Class A
    /^172\.(1[6-9]|2\d|3[01])\./, // RFC 1918 Class B
    /^192\.168\./, // RFC 1918 Class C
    /^169\.254\./, // Link-local / cloud metadata
    /^0\./, // Current network
    /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // RFC 6598 (CGNAT)
];
/**
 * IPv6 patterns for private/reserved ranges. Anchored to a 4-hex-digit first
 * segment so canonical forms (e.g. `0fe8::1` → `fe8::1`) don't false-positive
 * against link-local/ULA prefixes.
 */
const PRIVATE_IPV6_PATTERNS = [
    /^fe[89ab][0-9a-f]:/i, // fe80::/10 link-local (third nibble 8/9/a/b)
    /^f[cd][0-9a-f]{2}:/i, // fc00::/7 ULA
];
const PRIVATE_HOSTNAMES = new Set(['localhost', 'metadata.google.internal', 'metadata.internal']);
/** Maximum number of redirects to follow when rejectPrivateIPs is enabled. */
const MAX_SSRF_REDIRECTS = 5;
/**
 * Extracts the embedded IPv4 from an IPv4-mapped IPv6 address. Accepts both
 * the dotted-decimal form (`::ffff:127.0.0.1`) and the all-hex form
 * (`::ffff:7f00:1`) that URL parsers canonicalize the dotted form into.
 *
 * @returns Dotted-decimal IPv4 string, or `undefined` if not an IPv4-mapped form.
 */
function extractMappedIpv4(lower) {
    const dotted = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
    if (dotted)
        return dotted[1];
    const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
    if (hex) {
        const high = Number.parseInt(hex[1] ?? '0', 16);
        const low = Number.parseInt(hex[2] ?? '0', 16);
        return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
    }
    return;
}
/**
 * Checks whether an IP address (v4 or v6, including IPv4-mapped IPv6) falls
 * within a private or reserved range.
 *
 * IPv4-mapped IPv6 addresses are unwrapped and run through the IPv4 patterns,
 * so the full RFC 1918 / CGNAT / link-local space is covered automatically
 * without duplicating the table.
 *
 * @param ip - The IP address string to check (bare, no brackets).
 * @returns `true` if the address is private or reserved, `false` otherwise.
 */
function isPrivateIP(ip) {
    if (PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(ip)))
        return true;
    const lower = ip.toLowerCase();
    if (lower === '::1' || lower === '0:0:0:0:0:0:0:1')
        return true;
    const mapped = extractMappedIpv4(lower);
    if (mapped !== undefined)
        return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(mapped));
    return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(lower));
}
/**
 * Validates that a URL string does not target private or reserved IP space.
 *
 * Performs three checks in order:
 * 1. Known private hostnames (`localhost`, `metadata.google.internal`, etc.)
 * 2. Literal IPv4/IPv6 addresses in the URL hostname
 * 3. DNS resolution — resolves A and AAAA records and validates each
 *    (Node, Bun, Workers under `nodejs_compat`; skipped in pure-browser envs)
 *
 * DNS resolution failures (ENOTFOUND, etc.) are swallowed and left for the native
 * `fetch` to handle; only confirmed private IPs cause rejection.
 *
 * **Best-effort guard, not a hard SSRF boundary.** This validation runs before the
 * native `fetch` call performs its own DNS resolution. A malicious authoritative
 * DNS server can return a public IP for this lookup and a private IP for the
 * fetch's lookup (DNS rebinding / TOCTOU). Strong SSRF isolation requires
 * out-of-band controls — see {@link FetchWithTimeoutOptions.rejectPrivateIPs}.
 *
 * @param urlString - The fully-qualified URL string to validate.
 * @throws {McpError} `ValidationError` if the URL is malformed, the hostname is a known
 *   internal name, the literal IP is private, or DNS resolves to a private address.
 */
async function assertNotPrivateUrl(urlString) {
    let parsed;
    try {
        parsed = new URL(urlString);
    }
    catch {
        // Don't echo the raw string — it failed to parse and may carry a secret query.
        throw validationError('Invalid URL: could not be parsed.');
    }
    const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); // Strip IPv6 brackets
    // Check known private hostnames
    if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) {
        throw validationError(`Request to private/internal hostname blocked: ${hostname}`);
    }
    // Check literal IP (v4, v6, or IPv4-mapped IPv6)
    if (isPrivateIP(hostname)) {
        throw validationError(`Request to private/reserved IP blocked: ${hostname}`);
    }
    // `node:dns/promises` is available in Node, Bun, and Workers under
    // `nodejs_compat`. `isNode` covers all three.
    if (runtimeCaps.isNode) {
        await assertDnsNotPrivate(hostname);
    }
}
/**
 * Resolves DNS for a hostname (A and AAAA records in parallel) and confirms
 * that none of the resolved IP addresses fall within private or reserved ranges.
 *
 * DNS resolution errors (e.g., `ENOTFOUND`) are silently swallowed — they are
 * not an SSRF signal and are better handled by the native `fetch` call.
 *
 * **TOCTOU caveat:** the resolved addresses are not pinned to the subsequent
 * `fetch` connection — the native fetch performs its own resolution and may
 * receive a different answer (DNS rebinding, low-TTL race). This is a guard,
 * not a guarantee.
 *
 * @param hostname - The bare hostname to resolve (no brackets, no port).
 * @throws {McpError} `ValidationError` if any resolved address is private.
 */
async function assertDnsNotPrivate(hostname) {
    try {
        const dns = await import('node:dns/promises');
        const [ipv4Results, ipv6Results] = await Promise.allSettled([
            dns.resolve4(hostname),
            dns.resolve6(hostname),
        ]);
        const resolvedIPs = [
            ...(ipv4Results.status === 'fulfilled' ? ipv4Results.value : []),
            ...(ipv6Results.status === 'fulfilled' ? ipv6Results.value : []),
        ];
        for (const ip of resolvedIPs) {
            if (isPrivateIP(ip)) {
                throw validationError(`DNS resolved ${hostname} to private IP ${ip} — SSRF blocked`);
            }
        }
    }
    catch (error) {
        if (error instanceof McpError)
            throw error;
        // DNS resolution failures (ENOTFOUND, etc.) are not SSRF — let fetch handle them
    }
}
/**
 * Fetches a resource with a configurable timeout and optional SSRF protection.
 *
 * Internally manages an `AbortController` that fires after `timeoutMs`. An optional
 * external `signal` (e.g., `ctx.signal`) can be passed via `options` to support
 * early cancellation by the caller. The two signals are composed — whichever fires
 * first wins.
 *
 * When `options.rejectPrivateIPs` is `true`, the target URL is validated before the
 * request is sent, and all redirects are followed manually with per-hop SSRF checks
 * (up to 5 hops). This mode forces `redirect: 'manual'` on the underlying fetch.
 *
 * Non-2xx responses are treated as errors. The response body is read (truncated
 * to {@link ERROR_BODY_LIMIT} bytes to avoid context-window poisoning when an
 * upstream returns an HTML error page), logged, and wrapped in a `McpError`
 * whose code is mapped from the HTTP status via {@link httpStatusToErrorCode}
 * (e.g. 400 → `InvalidParams`, 403 → `Forbidden`, 404 → `NotFound`, 429 →
 * `RateLimited`, 5xx → `ServiceUnavailable`/`InternalError`/`Timeout`).
 *
 * @param url - The URL to fetch (string or `URL` instance).
 * @param timeoutMs - Maximum duration in milliseconds before the request is aborted.
 * @param context - Log bindings for correlated logging (`requestId`, `operation`, etc.).
 *   Passing the handler `Context` is safe — the logger strips non-serializable fields
 *   such as `signal`, `log`, `state`, and protocol method handles. Pass
 *   `options.signal = ctx.signal` explicitly to wire cancellation.
 * @param options - Optional fetch configuration extending `RequestInit`.
 *   - `rejectPrivateIPs`: Block requests to private/internal IP space (SSRF protection).
 *   - `signal`: External `AbortSignal` to cancel the request independently of the timeout.
 *   - All other standard `RequestInit` fields (method, headers, body, etc.) are forwarded.
 * @returns A promise resolving to the `Response` object on HTTP 2xx.
 * @throws {McpError} `ValidationError` if the URL targets a private/reserved address
 *   and `rejectPrivateIPs` is enabled.
 * @throws {McpError} `Timeout` if the request exceeds `timeoutMs`.
 * @throws {McpError} `InternalError` if the request is cancelled via the external signal.
 * @throws {McpError} A status-mapped code (`InvalidParams`/`Unauthorized`/`Forbidden`/
 *   `NotFound`/`RateLimited`/`ServiceUnavailable`/...) if the server returns a non-2xx
 *   status. `error.data` carries `{ statusCode, statusText, responseBody, retryAfter? }`.
 * @throws {McpError} `ServiceUnavailable` if a network-level error occurs.
 * @example
 * ```ts
 * // Basic GET with a 5-second timeout
 * const response = await fetchWithTimeout(
 *   'https://api.example.com/data',
 *   5000,
 *   ctx,
 * );
 * const data = await response.json();
 *
 * // POST with SSRF protection and caller-cancellable signal
 * const response = await fetchWithTimeout(
 *   userProvidedUrl,
 *   10_000,
 *   ctx,
 *   {
 *     method: 'POST',
 *     headers: { 'Content-Type': 'application/json' },
 *     body: JSON.stringify(payload),
 *     rejectPrivateIPs: true,
 *     signal: ctx.signal,
 *   },
 * );
 * ```
 */
export async function fetchWithTimeout(url, timeoutMs, context, options) {
    const urlString = url.toString();
    // SSRF protection: reject private/internal targets when enabled
    if (options?.rejectPrivateIPs) {
        await assertNotPrivateUrl(urlString);
    }
    const operationDescription = `fetch ${options?.method || 'GET'} ${redactUrl(urlString)}`;
    logger.debug(`Attempting ${operationDescription} with ${timeoutMs}ms timeout.`, context);
    // Strip custom options before passing to native fetch
    const { rejectPrivateIPs: rejectPrivate, signal: externalSignal, ...fetchInit } = options ?? {};
    // When SSRF protection is active, handle redirects manually to validate each hop
    if (rejectPrivate) {
        fetchInit.redirect = 'manual';
    }
    // Use AbortController instead of AbortSignal.timeout() for cross-runtime compatibility
    // (AbortSignal.timeout() can fail in Bun's stdio transport due to realm mismatch)
    const controller = new AbortController();
    const timeoutSentinel = 'FETCH_TIMEOUT';
    const timeoutId = setTimeout(() => controller.abort(timeoutSentinel), timeoutMs);
    // Compose the timeout signal with any caller-supplied signal. AbortSignal.any
    // is available on all supported floors (Node ≥24, Bun ≥1.3, workerd).
    const fetchSignal = externalSignal
        ? AbortSignal.any([controller.signal, externalSignal])
        : controller.signal;
    const startTime = performance.now();
    const parsedUrl = new URL(urlString);
    const method = (fetchInit.method ?? 'GET').toUpperCase();
    let statusCode = 0;
    try {
        let currentUrl = url;
        let redirectCount = 0;
        for (;;) {
            const response = await fetch(currentUrl, {
                ...fetchInit,
                signal: fetchSignal,
            });
            // Handle redirects manually when SSRF protection is active
            if (rejectPrivate && response.status >= 300 && response.status < 400) {
                const location = response.headers.get('location');
                if (!location) {
                    throw serviceUnavailable(`Redirect response missing Location header from ${redactUrl(currentUrl)}`);
                }
                redirectCount++;
                if (redirectCount > MAX_SSRF_REDIRECTS) {
                    throw validationError(`Too many redirects (${MAX_SSRF_REDIRECTS}) — possible SSRF redirect loop`);
                }
                // Resolve relative redirect URLs against the current URL
                const redirectUrl = new URL(location, currentUrl.toString()).toString();
                // Validate the redirect target against SSRF rules
                await assertNotPrivateUrl(redirectUrl);
                logger.debug(`Following validated redirect ${redirectCount}: ${redactUrl(redirectUrl)}`, context);
                currentUrl = redirectUrl;
                continue;
            }
            statusCode = response.status;
            if (!response.ok) {
                const rawBody = await response.text().catch(() => 'Could not read response body');
                const responseBody = rawBody.length > ERROR_BODY_LIMIT ? `${rawBody.slice(0, ERROR_BODY_LIMIT)}…` : rawBody;
                logger.error(`Fetch failed for ${redactUrl(currentUrl)} with status ${response.status}.`, {
                    ...context,
                    statusCode: response.status,
                    statusText: response.statusText,
                    responseBody,
                    errorSource: 'FetchHttpError',
                });
                const code = httpStatusToErrorCode(response.status) ?? JsonRpcErrorCode.InternalError;
                const retryAfter = response.headers.get('retry-after');
                throw new McpError(code, `Fetch failed for ${redactUrl(currentUrl)}. Status: ${response.status}`, {
                    requestId: context.requestId,
                    operation: context.operation,
                    statusCode: response.status,
                    statusText: response.statusText,
                    responseBody,
                    ...(retryAfter !== null && { retryAfter }),
                    errorSource: 'FetchHttpError',
                });
            }
            logger.debug(`Successfully fetched ${redactUrl(currentUrl)}. Status: ${response.status}`, context);
            return response;
        }
    }
    catch (error) {
        if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
            const isTimeout = error.name === 'TimeoutError' || controller.signal.reason === timeoutSentinel;
            if (isTimeout) {
                logger.error(`${operationDescription} timed out after ${timeoutMs}ms.`, {
                    ...context,
                    errorSource: 'FetchTimeout',
                });
                throw timeout(`${operationDescription} timed out.`, {
                    requestId: context.requestId,
                    operation: context.operation,
                    errorSource: 'FetchTimeout',
                });
            }
            // External signal abort (e.g., client disconnect) — not a timeout
            logger.info(`${operationDescription} aborted by caller.`, {
                ...context,
                errorSource: 'FetchAborted',
            });
            throw new McpError(JsonRpcErrorCode.InternalError, `${operationDescription} was aborted.`, {
                requestId: context.requestId,
                operation: context.operation,
                errorSource: 'FetchAborted',
            });
        }
        const errorMessage = error instanceof Error ? error.message : String(error);
        logger.error(`Network error during ${operationDescription}: ${errorMessage}`, {
            ...context,
            originalErrorName: error instanceof Error ? error.name : 'UnknownError',
            errorSource: 'FetchNetworkError',
        });
        if (error instanceof McpError) {
            throw error;
        }
        throw serviceUnavailable(`Network error during ${operationDescription}: ${errorMessage}`, {
            requestId: context.requestId,
            operation: context.operation,
            originalErrorName: error instanceof Error ? error.name : 'UnknownError',
            errorSource: 'FetchNetworkErrorWrapper',
        });
    }
    finally {
        clearTimeout(timeoutId);
        const durationS = (performance.now() - startTime) / 1000;
        const attrs = {
            'http.request.method': method,
            'server.address': parsedUrl.hostname,
        };
        if (statusCode > 0) {
            attrs['http.response.status_code'] = statusCode;
        }
        getHttpClientMetrics().clientDurationHistogram.record(durationS, attrs);
    }
}
//# sourceMappingURL=fetchWithTimeout.js.map