import dns from "node:dns/promises";
import net from "node:net";
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const FETCH_TIMEOUT_MS = 15_000;
const MAX_RETURN_CHARS = 20_000;
const JINA_READER_BASE_URL = "https://r.jina.ai/";
const JINA_TARGET_ERROR_RE = /^Warning:\s+Target URL returned error\s+(\d{3})(?::|\s)/im;
function ok(text) {
    return { content: [{ type: "text", text }] };
}
function err(text) {
    return { content: [{ type: "text", text }], isError: true };
}
function truncateText(text, maxChars = MAX_RETURN_CHARS) {
    if (text.length <= maxChars) {
        return text;
    }
    return `${text.slice(0, maxChars)}\n\n[truncated]`;
}
function decodeHtmlEntities(text) {
    return text
        .replace(/&nbsp;/g, " ")
        .replace(/&amp;/g, "&")
        .replace(/&lt;/g, "<")
        .replace(/&gt;/g, ">")
        .replace(/&quot;/g, "\"")
        .replace(/&#39;/g, "'")
        .replace(/&#(\d+);/g, (_match, code) => {
        const value = Number.parseInt(code, 10);
        return Number.isFinite(value) ? String.fromCodePoint(value) : _match;
    })
        .replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => {
        const value = Number.parseInt(code, 16);
        return Number.isFinite(value) ? String.fromCodePoint(value) : _match;
    });
}
function extractTitle(html) {
    const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
    if (!match?.[1]) {
        return null;
    }
    const title = decodeHtmlEntities(match[1]).replace(/\s+/g, " ").trim();
    return title.length > 0 ? title : null;
}
function htmlToText(html) {
    const stripped = html
        .replace(/<script[\s\S]*?<\/script>/gi, " ")
        .replace(/<style[\s\S]*?<\/style>/gi, " ")
        .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
        .replace(/<svg[\s\S]*?<\/svg>/gi, " ")
        .replace(/<\/(p|div|section|article|main|header|footer|aside|nav|li|ul|ol|h[1-6]|br)>/gi, "\n")
        .replace(/<[^>]+>/g, " ");
    return decodeHtmlEntities(stripped)
        .replace(/\r/g, "")
        .replace(/[ \t]+\n/g, "\n")
        .replace(/\n{3,}/g, "\n\n")
        .replace(/[ \t]{2,}/g, " ")
        .trim();
}
function isTextLikeContentType(contentType) {
    return contentType.startsWith("text/")
        || contentType.includes("json")
        || contentType.includes("xml")
        || contentType.includes("javascript")
        || contentType.includes("x-www-form-urlencoded");
}
function isPrivateIpAddress(address) {
    if (net.isIPv4(address)) {
        const [a = 0, b = 0] = address.split(".").map((part) => Number.parseInt(part, 10));
        return a === 0
            || a === 10
            || a === 127
            || (a === 169 && b === 254)
            || (a === 172 && b >= 16 && b <= 31)
            || (a === 192 && b === 168)
            || a >= 224;
    }
    if (net.isIPv6(address)) {
        const normalized = address.toLowerCase();
        return normalized === "::1"
            || normalized === "::"
            || normalized.startsWith("fc")
            || normalized.startsWith("fd")
            || normalized.startsWith("fe80:")
            || normalized.startsWith("ff");
    }
    return false;
}
/**
 * Best-effort guard that the URL points to a public HTTP(S) destination and
 * does not target localhost or private/reserved IP ranges.
 *
 * Note: this is not a hard SSRF protection. We pre-resolve via `dns.lookup`,
 * but Node's HTTP client re-resolves at connect time, so a DNS-rebinding
 * attacker controlling the target hostname could in theory bypass this guard
 * between the two lookups. We accept that risk because VisionClaw is a
 * single-tenant personal agent and the primary use case is fetching
 * model-chosen public URLs, not attacker-supplied ones.
 */
async function assertPublicHttpUrl(parsedUrl) {
    if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
        return err(`Unsupported URL protocol: ${parsedUrl.protocol}`);
    }
    const hostname = parsedUrl.hostname.toLowerCase();
    if (hostname === "localhost" || hostname.endsWith(".localhost")) {
        return err("Refusing to fetch localhost URLs");
    }
    if (net.isIP(hostname) && isPrivateIpAddress(hostname)) {
        return err(`Refusing to fetch private or reserved IP address: ${hostname}`);
    }
    try {
        const records = await dns.lookup(hostname, { all: true, verbatim: true });
        const blocked = records.find((record) => isPrivateIpAddress(record.address));
        if (blocked) {
            return err(`Refusing to fetch URL because ${hostname} resolves to private or reserved IP address: ${blocked.address}`);
        }
    }
    catch (error) {
        return err(`Failed to resolve hostname ${hostname}: ${error instanceof Error ? error.message : String(error)}`);
    }
    return null;
}
async function fetchWithTimeout(url, init) {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
    try {
        return await fetch(url, {
            ...init,
            signal: abortController.signal,
        });
    }
    catch (error) {
        if (abortController.signal.aborted) {
            throw new Error(`Timed out after ${FETCH_TIMEOUT_MS}ms`);
        }
        throw error;
    }
    finally {
        clearTimeout(timeout);
    }
}
async function tryFetchViaJinaReader(targetUrl, maxChars) {
    const readerUrl = `${JINA_READER_BASE_URL}${targetUrl}`;
    try {
        const response = await fetchWithTimeout(readerUrl, {
            redirect: "follow",
            headers: {
                "User-Agent": "VisionClaw/0.1 WebFetch",
                Accept: "text/plain, text/markdown;q=0.9, application/json;q=0.1",
            },
        });
        if (!response.ok) {
            return null;
        }
        const rawText = (await response.text()).trim();
        if (rawText.length === 0) {
            return null;
        }
        const targetError = JINA_TARGET_ERROR_RE.exec(rawText);
        if (targetError) {
            return null;
        }
        const text = truncateText(rawText, maxChars);
        return ok([
            `Fetched: ${targetUrl}`,
            "Fetched-Via: Jina Reader",
            `Reader-URL: ${readerUrl}`,
            "",
            text,
        ].join("\n"));
    }
    catch {
        return null;
    }
}
export async function fetchWebContent(url, options) {
    const maxChars = options?.maxChars ?? MAX_RETURN_CHARS;
    let parsedUrl;
    try {
        parsedUrl = new URL(url);
    }
    catch {
        return err(`Invalid URL: ${url}`);
    }
    const publicUrlError = await assertPublicHttpUrl(parsedUrl);
    if (publicUrlError) {
        return publicUrlError;
    }
    const readerResult = await tryFetchViaJinaReader(parsedUrl.toString(), maxChars);
    if (readerResult) {
        return readerResult;
    }
    try {
        const response = await fetchWithTimeout(parsedUrl, {
            redirect: "follow",
            headers: {
                "User-Agent": "VisionClaw/0.1 WebFetch",
                Accept: "text/html, text/plain, application/json, application/xml;q=0.9, */*;q=0.1",
            },
        });
        if (!response.ok) {
            return err(`Failed to fetch ${url}: HTTP ${response.status}`);
        }
        const finalUrl = response.url || url;
        const finalParsedUrl = new URL(finalUrl);
        const finalUrlError = await assertPublicHttpUrl(finalParsedUrl);
        if (finalUrlError) {
            return finalUrlError;
        }
        const contentType = response.headers.get("content-type")?.toLowerCase() ?? "unknown";
        if (contentType.includes("text/html") || contentType.includes("application/xhtml+xml")) {
            const html = await response.text();
            const title = extractTitle(html);
            const text = truncateText(htmlToText(html), maxChars);
            return ok([
                `Fetched: ${finalUrl}`,
                `Content-Type: ${contentType}`,
                ...(title ? [`Title: ${title}`] : []),
                "",
                text || "[empty page body]",
            ].join("\n"));
        }
        if (isTextLikeContentType(contentType)) {
            const text = truncateText((await response.text()).trim(), maxChars);
            return ok([
                `Fetched: ${finalUrl}`,
                `Content-Type: ${contentType}`,
                "",
                text || "[empty response body]",
            ].join("\n"));
        }
        return err(`Unsupported content type for WebFetch: ${contentType}. This tool only returns text-like content.`);
    }
    catch (error) {
        if (error instanceof Error && error.message.startsWith("Timed out after")) {
            return err(`Timed out fetching ${url} after ${FETCH_TIMEOUT_MS}ms`);
        }
        return err(`Failed to fetch ${url}: ${error instanceof Error ? error.message : String(error)}`);
    }
}
export const webFetchTool = tool("WebFetch", "Fetch the content of an exact public URL and return a readable text version. This tool tries Jina Reader first for cleaner page extraction, then falls back to a direct fetch when needed.", {
    url: z
        .string()
        .describe("The exact public http(s) URL to fetch"),
}, (args) => fetchWebContent(args.url));
//# sourceMappingURL=web-fetch.js.map