import { JSDOM } from "jsdom";
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const MAX_HTML_BYTES = 256 * 1024;
const MAX_ICON_BYTES = 512 * 1024;
const FETCH_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 5;
const SUCCESS_CACHE_TTL_MS = 30 * 60_000;
const FAILURE_CACHE_TTL_MS = 5 * 60_000;
const IMAGE_CONTENT_TYPES = new Set([
    "image/gif",
    "image/jpeg",
    "image/jpg",
    "image/png",
    "image/svg+xml",
    "image/vnd.microsoft.icon",
    "image/webp",
    "image/x-icon",
]);
function isPrivateIpAddress(value) {
    const normalized = value.replace(/^\[|\]$/gu, "").toLowerCase();
    const ipv4Mapped = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u);
    if (ipv4Mapped)
        return isPrivateIpAddress(ipv4Mapped[1]);
    if (normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"))
        return true;
    const parts = normalized.split(".");
    if (parts.length !== 4)
        return false;
    const octets = parts.map((part) => Number(part));
    if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
        return false;
    const [a, b] = octets;
    if (a === 10 || a === 127 || a === 0)
        return true;
    if (a === 100 && b >= 64 && b <= 127)
        return true;
    if (a === 169 && b === 254)
        return true;
    if (a === 172 && b >= 16 && b <= 31)
        return true;
    if (a === 192 && b === 0)
        return true;
    if (a === 192 && b === 168)
        return true;
    if (a === 198 && (b === 18 || b === 19))
        return true;
    return false;
}
function isPrivateHostname(hostname) {
    const normalized = hostname.replace(/^\[|\]$/gu, "").toLowerCase();
    if (normalized === "localhost" || normalized.endsWith(".localhost"))
        return true;
    if (isIP(normalized))
        return isPrivateIpAddress(normalized);
    return false;
}
export function parsePublicHttpUrl(value, options = {}) {
    const parsed = new URL(value);
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
        throw new Error("Only http and https URLs can be inspected");
    }
    if (!options.allowPrivateHosts && isPrivateHostname(parsed.hostname)) {
        throw new Error("Private network URLs cannot be inspected");
    }
    parsed.hash = "";
    return parsed;
}
async function assertPublicResolvedHost(url, options) {
    if (options.allowPrivateHosts || options.fetchImpl || isIP(url.hostname.replace(/^\[|\]$/gu, "")))
        return;
    const addresses = await lookup(url.hostname, { all: true, verbatim: true });
    if (addresses.some((address) => isPrivateIpAddress(address.address))) {
        throw new Error("Private network URLs cannot be inspected");
    }
}
async function fetchWithTimeout(url, options, init) {
    let currentUrl = parsePublicHttpUrl(url.href, options);
    const fetchImpl = options.fetchImpl ?? fetch;
    for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
        await assertPublicResolvedHost(currentUrl, options);
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
        try {
            const response = await fetchImpl(currentUrl.href, {
                redirect: "manual",
                ...init,
                signal: controller.signal,
                headers: {
                    accept: "text/html,application/xhtml+xml,image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8",
                    "user-agent": "RudderWebsiteMetadata/1.0",
                    ...(init?.headers ?? {}),
                },
            });
            if (response.status < 300 || response.status >= 400)
                return { response, url: currentUrl };
            const location = response.headers.get("location");
            if (!location)
                return { response, url: currentUrl };
            currentUrl = parsePublicHttpUrl(new URL(location, currentUrl).href, options);
        }
        finally {
            clearTimeout(timeout);
        }
    }
    throw new Error("Website metadata redirect limit exceeded");
}
function metadataCacheKey(url) {
    return url.href;
}
const metadataCache = new Map();
const metadataInflight = new Map();
async function readLimitedBuffer(response, maxBytes) {
    const body = response.body;
    if (!body)
        return Buffer.alloc(0);
    const reader = body.getReader();
    const chunks = [];
    let total = 0;
    try {
        while (true) {
            const { value, done } = await reader.read();
            if (done)
                break;
            if (!value)
                continue;
            total += value.byteLength;
            if (total > maxBytes) {
                throw new Error("Response exceeds metadata size limit");
            }
            chunks.push(Buffer.from(value));
        }
    }
    finally {
        reader.releaseLock();
    }
    return Buffer.concat(chunks);
}
function contentTypeBase(value) {
    return value?.split(";")[0]?.trim().toLowerCase() ?? "";
}
function absolutizeHref(href, baseUrl, options) {
    const trimmed = href?.trim();
    if (!trimmed)
        return null;
    try {
        const parsed = parsePublicHttpUrl(new URL(trimmed, baseUrl).href, options);
        parsed.hash = "";
        return parsed.href;
    }
    catch {
        return null;
    }
}
function linkRelTokens(element) {
    return (element.getAttribute("rel") ?? "")
        .split(/\s+/u)
        .map((token) => token.trim().toLowerCase())
        .filter(Boolean);
}
function readSiteName(document) {
    const selectors = [
        'meta[property="og:site_name"]',
        'meta[name="application-name"]',
        'meta[name="apple-mobile-web-app-title"]',
    ];
    for (const selector of selectors) {
        const value = document.querySelector(selector)?.getAttribute("content")?.trim();
        if (value)
            return value;
    }
    const title = document.querySelector("title")?.textContent?.trim();
    return title || null;
}
function iconPriority(element) {
    const rel = new Set(linkRelTokens(element));
    if (rel.has("icon") && rel.has("shortcut"))
        return 0;
    if (rel.has("icon"))
        return 1;
    if (rel.has("apple-touch-icon"))
        return 2;
    if (rel.has("apple-touch-icon-precomposed"))
        return 3;
    if (rel.has("mask-icon"))
        return 4;
    return 10;
}
function findDeclaredIcon(document, baseUrl, options) {
    const candidates = Array.from(document.querySelectorAll("link[rel][href]"))
        .filter((element) => {
        const rel = new Set(linkRelTokens(element));
        return rel.has("icon")
            || rel.has("apple-touch-icon")
            || rel.has("apple-touch-icon-precomposed")
            || rel.has("mask-icon");
    })
        .sort((left, right) => iconPriority(left) - iconPriority(right));
    for (const candidate of candidates) {
        const href = absolutizeHref(candidate.getAttribute("href"), baseUrl, options);
        if (href)
            return href;
    }
    return null;
}
async function validateIconUrl(iconHref, options) {
    try {
        const { response, url } = await fetchWithTimeout(parsePublicHttpUrl(iconHref, options), options, { method: "GET" });
        if (!response.ok)
            return null;
        const contentType = contentTypeBase(response.headers.get("content-type"));
        if (!IMAGE_CONTENT_TYPES.has(contentType))
            return null;
        await readLimitedBuffer(response, MAX_ICON_BYTES);
        return url.href;
    }
    catch {
        return null;
    }
}
async function findImplicitFavicon(baseUrl, options) {
    const faviconUrl = new URL("/favicon.ico", baseUrl);
    return validateIconUrl(faviconUrl.href, options);
}
async function resolveWebsiteMetadataUncached(value, options) {
    const pageUrl = parsePublicHttpUrl(value, options);
    const { response, url: finalPageUrl } = await fetchWithTimeout(pageUrl, options);
    if (!response.ok) {
        return { url: finalPageUrl.href, siteName: null, iconUrl: null };
    }
    const contentType = contentTypeBase(response.headers.get("content-type"));
    if (contentType && contentType !== "text/html" && contentType !== "application/xhtml+xml") {
        return { url: finalPageUrl.href, siteName: null, iconUrl: null };
    }
    const html = (await readLimitedBuffer(response, MAX_HTML_BYTES)).toString("utf8");
    const dom = new JSDOM(html, { url: finalPageUrl.href });
    try {
        const document = dom.window.document;
        const declaredIcon = findDeclaredIcon(document, finalPageUrl, options);
        return {
            url: finalPageUrl.href,
            siteName: readSiteName(document),
            iconUrl: (declaredIcon ? await validateIconUrl(declaredIcon, options) : null) ?? await findImplicitFavicon(finalPageUrl, options),
        };
    }
    finally {
        dom.window.close();
    }
}
export async function resolveWebsiteMetadata(value, options = {}) {
    const pageUrl = parsePublicHttpUrl(value, options);
    const key = metadataCacheKey(pageUrl);
    if (!options.allowPrivateHosts && !options.fetchImpl) {
        const cached = metadataCache.get(key);
        if (cached && cached.expiresAt > Date.now())
            return cached.value;
        const inflight = metadataInflight.get(key);
        if (inflight)
            return inflight;
        const request = resolveWebsiteMetadataUncached(pageUrl.href, options)
            .then((metadata) => {
            metadataCache.set(key, {
                expiresAt: Date.now() + (metadata.iconUrl ? SUCCESS_CACHE_TTL_MS : FAILURE_CACHE_TTL_MS),
                value: metadata,
            });
            return metadata;
        })
            .finally(() => {
            metadataInflight.delete(key);
        });
        metadataInflight.set(key, request);
        return request;
    }
    return resolveWebsiteMetadataUncached(pageUrl.href, options);
}
export async function fetchWebsiteIcon(value, options = {}) {
    const iconUrl = parsePublicHttpUrl(value, options);
    const { response } = await fetchWithTimeout(iconUrl, options);
    if (!response.ok)
        return null;
    const contentType = contentTypeBase(response.headers.get("content-type"));
    if (!IMAGE_CONTENT_TYPES.has(contentType))
        return null;
    const body = await readLimitedBuffer(response, MAX_ICON_BYTES);
    if (body.length <= 0)
        return null;
    return { contentType, body };
}
export function __clearWebsiteMetadataCacheForTests() {
    metadataCache.clear();
    metadataInflight.clear();
}
//# sourceMappingURL=website-metadata.js.map