import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileTypeFromBuffer } from "file-type";
import mime from "mime";
import { userTextAndImageMessage } from "../llm/prompt.js";
const MAX_ASSET_BYTES_DEFAULT = 50 * 1024 * 1024;
export function resolveFileUrlPath(url, options) {
    const normalized = new URL(url.href);
    normalized.search = "";
    normalized.hash = "";
    if (typeof options?.windows === "boolean") {
        return fileURLToPath(normalized, { windows: options.windows });
    }
    return fileURLToPath(normalized);
}
function normalizeUrlInput(raw) {
    return (raw
        // Common shell copy/paste mistakes: `\?` / `\=` / `\&` inside quotes.
        .replaceAll(/\\([?&=])/g, "$1")
        // Sometimes backslashes get percent-encoded (`%5C`) and end up right before separators.
        .replaceAll(/%5c(?=[?&=])/gi, ""));
}
function trimLikelyUrlPunctuation(raw) {
    let value = raw.trim();
    const hasUnbalancedClosing = (input, open, close) => {
        let openCount = 0;
        let closeCount = 0;
        for (const char of input) {
            if (char === open)
                openCount += 1;
            else if (char === close)
                closeCount += 1;
        }
        return closeCount > openCount;
    };
    while (value.length > 0 && /[)\].,;:'">}”’»]/.test(value[value.length - 1] ?? "")) {
        const last = value[value.length - 1] ?? "";
        if (last === ")" && !hasUnbalancedClosing(value, "(", ")"))
            break;
        if (last === "]" && !hasUnbalancedClosing(value, "[", "]"))
            break;
        if (last === "}" && !hasUnbalancedClosing(value, "{", "}"))
            break;
        value = value.slice(0, -1);
    }
    while (value.length > 0 && /^[('"<{[\]“‘«]/.test(value[0] ?? "")) {
        value = value.slice(1);
    }
    return value;
}
function extractHttpUrlsFromText(raw) {
    return [...raw.matchAll(/https?:\/\/\S+/g)]
        .map((match) => trimLikelyUrlPunctuation(match[0] ?? ""))
        .filter((candidate) => candidate.length > 0);
}
function normalizeHeaderMediaType(value) {
    if (!value)
        return null;
    const trimmed = value.trim();
    if (!trimmed)
        return null;
    return trimmed.split(";")[0]?.trim().toLowerCase() ?? null;
}
function isHtmlMediaType(mediaType) {
    if (!mediaType)
        return false;
    return mediaType === "text/html" || mediaType === "application/xhtml+xml";
}
function isLikelyAssetMediaType(mediaType) {
    if (!mediaType)
        return false;
    if (isHtmlMediaType(mediaType))
        return false;
    return true;
}
function isTranscribableMediaType(mediaType) {
    if (!mediaType)
        return false;
    return mediaType.startsWith("audio/") || mediaType.startsWith("video/");
}
function parseContentDispositionFilename(header) {
    if (!header)
        return null;
    const match = /filename\*\s*=\s*([^;]+)/i.exec(header) ?? /filename\s*=\s*([^;]+)/i.exec(header);
    if (!match?.[1])
        return null;
    let value = match[1].trim();
    if (value.toLowerCase().startsWith("utf-8''")) {
        value = value.slice(7);
    }
    value = value.replace(/^"|"$/g, "");
    try {
        return decodeURIComponent(value);
    }
    catch {
        return value;
    }
}
function classifyResponseHeaders(headers) {
    const mediaType = normalizeHeaderMediaType(headers.get("content-type"));
    if (isTranscribableMediaType(mediaType))
        return { kind: "media", mediaType };
    const filename = parseContentDispositionFilename(headers.get("content-disposition"));
    const filenameMediaType = filename ? normalizeHeaderMediaType(mime.getType(filename)) : null;
    if ((!mediaType || mediaType === "application/octet-stream") &&
        isTranscribableMediaType(filenameMediaType)) {
        return { kind: "media", mediaType: filenameMediaType };
    }
    if (isLikelyAssetMediaType(mediaType))
        return { kind: "asset" };
    if (filename && isLikelyAssetPathname(filename))
        return { kind: "asset" };
    return null;
}
function looksLikeHtml(bytes) {
    const head = new TextDecoder().decode(bytes.slice(0, 256)).trimStart().toLowerCase();
    return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head");
}
function looksLikePlainText(bytes) {
    try {
        new TextDecoder("utf-8", { fatal: true }).decode(bytes);
    }
    catch {
        return false;
    }
    let suspicious = 0;
    for (const byte of bytes.slice(0, 512)) {
        if (byte === 0)
            return false;
        if (byte < 0x08 || (byte > 0x0d && byte < 0x20))
            suspicious += 1;
    }
    return bytes.byteLength === 0 || suspicious / Math.min(bytes.byteLength, 512) <= 0.1;
}
export function isLikelyAssetPathname(pathname) {
    const ext = path.extname(pathname).toLowerCase();
    if (!ext)
        return false;
    if (ext === ".html" || ext === ".htm" || ext === ".php" || ext === ".asp" || ext === ".aspx") {
        return false;
    }
    return true;
}
export function isLikelyAssetUrl(url) {
    try {
        return isLikelyAssetPathname(new URL(url).pathname);
    }
    catch {
        return false;
    }
}
export function shouldProbeUnknownAssetUrl(url) {
    try {
        const parsed = new URL(url);
        if (isLikelyAssetPathname(parsed.pathname))
            return true;
        const pathname = parsed.pathname.toLowerCase();
        if (/(^|\/)(attachment|download|export|file|raw)(\/|$)/u.test(pathname))
            return true;
        for (const key of ["download", "filename", "format", "response-content-disposition"]) {
            const value = parsed.searchParams.get(key)?.trim().toLowerCase();
            if (!value)
                continue;
            if (key !== "format" || value === "pdf" || value === "csv" || value === "txt")
                return true;
        }
        return false;
    }
    catch {
        return false;
    }
}
export function resolveInputTarget(raw) {
    const normalized = raw.trim();
    if (!normalized) {
        throw new Error("Missing input");
    }
    const asPath = path.resolve(normalized);
    if (existsSync(asPath)) {
        return { kind: "file", filePath: asPath };
    }
    if (normalized === "-") {
        return { kind: "stdin" };
    }
    const extractedUrls = extractHttpUrlsFromText(normalized);
    const extractedLast = extractedUrls.at(-1) ?? null;
    if (extractedLast && extractedLast !== normalized) {
        for (let i = extractedUrls.length - 1; i >= 0; i -= 1) {
            const candidate = extractedUrls[i];
            if (!candidate)
                continue;
            try {
                return resolveInputTarget(candidate);
            }
            catch {
                // keep trying earlier candidates
            }
        }
    }
    let parsed;
    const normalizedUrlInput = normalizeUrlInput(normalized);
    try {
        parsed = new URL(normalizedUrlInput);
    }
    catch {
        throw new Error(`Invalid URL or file path: ${raw}`);
    }
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:" && parsed.protocol !== "file:") {
        const embedded = normalized.lastIndexOf("https://");
        const embeddedHttp = normalized.lastIndexOf("http://");
        const idx = Math.max(embedded, embeddedHttp);
        if (idx >= 0) {
            const candidate = normalized.slice(idx);
            return resolveInputTarget(candidate);
        }
    }
    if (parsed.protocol === "file:") {
        const filePath = resolveFileUrlPath(parsed);
        return { kind: "file", filePath };
    }
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
        throw new Error("Only HTTP and HTTPS URLs can be summarized");
    }
    // Preserve user input (do not canonicalize like adding trailing slashes),
    // but apply our minimal normalization fixes (e.g. `\\?` -> `?`).
    return { kind: "url", url: normalizedUrlInput };
}
export async function classifyUrl({ url, fetchImpl, timeoutMs, }) {
    const parsed = new URL(url);
    if (isLikelyAssetPathname(parsed.pathname)) {
        return { kind: "asset" };
    }
    const tryDetectFromHead = async () => {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const res = await fetchImpl(url, { method: "HEAD", signal: controller.signal });
            if (!res.ok)
                return null;
            return classifyResponseHeaders(res.headers);
        }
        catch {
            return null;
        }
        finally {
            clearTimeout(timeout);
        }
    };
    const tryDetectFromRange = async () => {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const res = await fetchImpl(url, {
                method: "GET",
                headers: { Range: "bytes=0-2047" },
                signal: controller.signal,
            });
            if (!res.ok)
                return null;
            const headerKind = classifyResponseHeaders(res.headers);
            if (headerKind)
                return headerKind;
            const buffer = new Uint8Array(await res.arrayBuffer());
            return !looksLikeHtml(buffer) ? { kind: "asset" } : null;
        }
        catch {
            return null;
        }
        finally {
            clearTimeout(timeout);
        }
    };
    const headKind = await tryDetectFromHead();
    if (headKind)
        return headKind;
    const rangeKind = await tryDetectFromRange();
    if (rangeKind)
        return rangeKind;
    return { kind: "website" };
}
async function detectMediaType({ bytes, headerContentType, nameHint, }) {
    const sniffed = await fileTypeFromBuffer(bytes);
    if (sniffed?.mime)
        return sniffed.mime;
    const header = normalizeHeaderMediaType(headerContentType);
    if (header && header !== "application/octet-stream")
        return header;
    if (nameHint) {
        const byExt = mime.getType(nameHint);
        if (typeof byExt === "string" && byExt.length > 0)
            return byExt;
    }
    if (looksLikePlainText(bytes))
        return "text/plain";
    return "application/octet-stream";
}
function buildAttachment({ bytes, mediaType, filename, }) {
    if (mediaType.startsWith("image/")) {
        return { mediaType, filename, kind: "image", bytes };
    }
    return { mediaType, filename, kind: "file", bytes };
}
export async function loadLocalAsset({ filePath, maxBytes = MAX_ASSET_BYTES_DEFAULT, }) {
    const stat = await fs.stat(filePath);
    if (!stat.isFile()) {
        throw new Error(`Not a file: ${filePath}`);
    }
    if (stat.size > maxBytes) {
        throw new Error(`File too large (${stat.size} bytes). Limit is ${maxBytes} bytes.`);
    }
    const bytes = new Uint8Array(await fs.readFile(filePath));
    const filename = path.basename(filePath);
    const mediaType = await detectMediaType({ bytes, headerContentType: null, nameHint: filename });
    return {
        sourceLabel: filePath,
        attachment: buildAttachment({ bytes, mediaType, filename }),
    };
}
export async function loadRemoteAsset({ url, fetchImpl, timeoutMs, maxBytes = MAX_ASSET_BYTES_DEFAULT, }) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), timeoutMs);
    try {
        const res = await fetchImpl(url, { signal: controller.signal });
        if (!res.ok) {
            throw new Error(`Download failed: ${res.status} ${res.statusText}`);
        }
        const contentLength = res.headers.get("content-length");
        if (contentLength) {
            const parsed = Number(contentLength);
            if (Number.isFinite(parsed) && parsed > maxBytes) {
                throw new Error(`Remote file too large (${parsed} bytes). Limit is ${maxBytes} bytes.`);
            }
        }
        const arrayBuffer = await res.arrayBuffer();
        if (arrayBuffer.byteLength > maxBytes) {
            throw new Error(`Remote file too large (${arrayBuffer.byteLength} bytes). Limit is ${maxBytes} bytes.`);
        }
        const bytes = new Uint8Array(arrayBuffer);
        const parsedUrl = new URL(url);
        const filename = path.basename(parsedUrl.pathname) || null;
        const headerContentType = res.headers.get("content-type");
        const mediaType = await detectMediaType({ bytes, headerContentType, nameHint: filename });
        if (isHtmlMediaType(mediaType) || looksLikeHtml(bytes)) {
            throw new Error("URL appears to be a website (HTML), not a file");
        }
        return {
            sourceLabel: url,
            attachment: buildAttachment({ bytes, mediaType, filename }),
        };
    }
    finally {
        clearTimeout(timeout);
    }
}
export function buildAssetPromptMessages({ promptText, attachment, }) {
    if (attachment.kind !== "image") {
        throw new Error(`Internal error: tried to build model messages for non-image attachment (${attachment.mediaType}).`);
    }
    return [
        userTextAndImageMessage({
            text: promptText,
            imageBytes: attachment.bytes,
            mimeType: attachment.mediaType,
        }),
    ];
}
//# sourceMappingURL=asset.js.map