"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_ASSET_PROBER = exports.DEFAULT_RUNTIME_QA_RUNNER = void 0;
exports.runPreviewQa = runPreviewQa;
exports.extractExternalAssetUrls = extractExternalAssetUrls;
const dns_1 = __importDefault(require("dns"));
const http_1 = __importDefault(require("http"));
const https_1 = __importDefault(require("https"));
const inspirationDesignContext_1 = require("./inspirationDesignContext");
/**
 * Preview QA — objective post-generation health checks for static-preview
 * variants. The orchestrator runs this before accepting a `static_preview`
 * succeeded report so a variant cannot enter `ready` while its referenced
 * assets 404 or its runtime throws.
 *
 * The runner has two layers:
 *
 *   1. A pure static check that extracts external asset URLs from the
 *      generated HTML and fetches each one with a small timeout. This
 *      catches the most common failure mode (broken CDN URLs, missing
 *      `.glb` model assets) without needing a browser.
 *
 *   2. An injectable runtime check that can be wired to Playwright later
 *      to capture `pageerror` / `console.error` events. Tests stub this
 *      layer; production uses `DEFAULT_RUNTIME_QA_RUNNER` which is a
 *      no-op so the gate falls back to the static layer when no real
 *      runner is available.
 */
const DEFAULT_ASSET_TIMEOUT_MS = 4_000;
/**
 * No-op runtime QA runner used when no real (e.g. Playwright-based)
 * runner is injected. Returns an empty issue list so the gate decision
 * is driven entirely by the static asset layer.
 */
const DEFAULT_RUNTIME_QA_RUNNER = async () => [];
exports.DEFAULT_RUNTIME_QA_RUNNER = DEFAULT_RUNTIME_QA_RUNNER;
const MAX_REDIRECTS = 5;
/**
 * Resolves a hostname to public addresses and returns the address that the
 * subsequent HTTP request must use for its own lookup.
 *
 * @effect Performs a DNS lookup via dns.promises.lookup
 */
const resolvePinnedAddress = async (hostname) => {
    if ((0, inspirationDesignContext_1.isLocalLiteralHostname)(hostname)) {
        return {
            kind: 'blocked',
            message: `Host '${hostname}' resolves to a protected target.`,
        };
    }
    let addresses;
    try {
        addresses = await dns_1.default.promises.lookup(hostname, { all: true });
    }
    catch {
        return {
            kind: 'blocked',
            message: `Host '${hostname}' could not be resolved safely.`,
        };
    }
    const protectedAddress = addresses.find(({ address }) => (0, inspirationDesignContext_1.isLocalLiteralHostname)(address));
    if (protectedAddress) {
        return {
            kind: 'blocked',
            message: `Host '${hostname}' resolves to a protected target.`,
        };
    }
    const address = addresses[0];
    if (!address) {
        return {
            kind: 'blocked',
            message: `Host '${hostname}' did not resolve to an address.`,
        };
    }
    return {
        kind: 'resolved',
        address: {
            address: address.address,
            family: address.family === 6 ? 6 : 4,
        },
    };
};
const locationHeader = (headers) => {
    const value = headers.location;
    return Array.isArray(value) ? value[0] : value;
};
/**
 * Issues one HTTP request while pinning Node's lookup callback to the address
 * produced by the SSRF guard.
 *
 * @effect Performs a network request to the validated asset URL
 */
const requestAsset = async (input) => {
    const parsed = new URL(input.url);
    const pinned = await resolvePinnedAddress(parsed.hostname);
    if (pinned.kind === 'blocked') {
        return { kind: 'blocked', status: 0, message: pinned.message };
    }
    const lookup = (_hostname, _options, callback) => {
        callback(null, pinned.address.address, pinned.address.family);
    };
    const transport = parsed.protocol === 'https:' ? https_1.default : http_1.default;
    return new Promise((resolve, reject) => {
        const request = transport.request({
            protocol: parsed.protocol,
            hostname: parsed.hostname,
            port: parsed.port || undefined,
            path: `${parsed.pathname}${parsed.search}`,
            method: input.method,
            headers: input.headers,
            lookup,
            signal: input.signal,
        }, (response) => {
            response.resume();
            resolve({
                kind: 'response',
                response: {
                    status: response.statusCode ?? 0,
                    headers: response.headers,
                },
            });
        });
        request.on('error', reject);
        request.end();
    });
};
const DEFAULT_ASSET_PROBER = async (url, timeoutMs) => {
    // SSRF guard: agent-supplied HTML can carry URLs pointing at localhost,
    // RFC1918, link-local, or cloud-metadata hosts. Validate scheme +
    // local-literal targets before issuing a network request, pin DNS for the
    // request itself, and re-check after redirects.
    const validation = (0, inspirationDesignContext_1.validateInspirationUrl)(url);
    if (!validation.ok) {
        return { ok: false, status: 0, message: validation.reason };
    }
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    const requestWithGuardedRedirects = async (initialUrl, init) => {
        let currentUrl = initialUrl;
        for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
            const currentValidation = (0, inspirationDesignContext_1.validateInspirationUrl)(currentUrl);
            if (!currentValidation.ok) {
                return {
                    kind: 'blocked',
                    status: 0,
                    message: currentValidation.reason,
                };
            }
            const result = await requestAsset({
                url: currentValidation.normalized,
                method: init.method,
                headers: init.headers,
                signal: controller.signal,
            });
            if (result.kind === 'blocked')
                return result;
            const response = result.response;
            const isRedirect = response.status >= 300 && response.status < 400;
            const location = locationHeader(response.headers);
            if (!isRedirect || !location) {
                return { kind: 'response', response };
            }
            if (hop === MAX_REDIRECTS) {
                return {
                    kind: 'blocked',
                    status: 0,
                    message: `Exceeded ${MAX_REDIRECTS} redirects probing asset.`,
                };
            }
            // Resolve relative redirects against the URL we just hit.
            let nextUrl;
            try {
                nextUrl = new URL(location, currentUrl);
            }
            catch {
                return {
                    kind: 'blocked',
                    status: 0,
                    message: `Malformed redirect Location '${location}'.`,
                };
            }
            currentUrl = nextUrl.toString();
        }
        return {
            kind: 'blocked',
            status: 0,
            message: 'Redirect handling exhausted unexpectedly.',
        };
    };
    try {
        let result = await requestWithGuardedRedirects(validation.normalized, {
            method: 'HEAD',
        });
        if (result.kind === 'blocked') {
            return { ok: false, status: result.status, message: result.message };
        }
        let response = result.response;
        // Some CDNs reject HEAD with 405 — fall back to a ranged GET.
        if (response.status === 405 || response.status === 501) {
            result = await requestWithGuardedRedirects(validation.normalized, {
                method: 'GET',
                headers: { Range: 'bytes=0-0' },
            });
            if (result.kind === 'blocked') {
                return { ok: false, status: result.status, message: result.message };
            }
            response = result.response;
        }
        return {
            ok: response.status >= 200 && response.status < 300,
            status: response.status,
        };
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return { ok: false, status: 0, message };
    }
    finally {
        clearTimeout(timer);
    }
};
exports.DEFAULT_ASSET_PROBER = DEFAULT_ASSET_PROBER;
/**
 * Run preview QA against a generated static-preview HTML document. The
 * static layer extracts external asset URLs and probes each one; the
 * injectable runtime layer adds console/runtime checks. Returns a single
 * `VariantQaResult` with the combined verdict.
 */
async function runPreviewQa(input) {
    const html = input.html;
    if (typeof html !== 'string' || html.trim().length === 0) {
        return {
            status: 'failed',
            issues: [
                {
                    kind: 'preview_unavailable',
                    detail: input.options?.source ?? 'inline',
                    message: 'Preview HTML was empty',
                },
            ],
            summary: 'Preview unavailable: generated HTML was empty.',
            checkedSource: input.options?.source,
        };
    }
    const assetProber = input.options?.assetProber ?? exports.DEFAULT_ASSET_PROBER;
    const runtimeRunner = input.options?.runtimeQaRunner ?? exports.DEFAULT_RUNTIME_QA_RUNNER;
    const assetTimeoutMs = input.options?.assetTimeoutMs ?? DEFAULT_ASSET_TIMEOUT_MS;
    const source = input.options?.source;
    const issues = [];
    const assetUrls = extractExternalAssetUrls(html);
    await Promise.all(assetUrls.map(async (url) => {
        const result = await assetProber(url, assetTimeoutMs);
        if (!result.ok) {
            issues.push({
                kind: 'asset_load_failed',
                detail: url,
                message: result.status > 0
                    ? `HTTP ${result.status}`
                    : result.message ?? 'Network error',
            });
        }
    }));
    try {
        const runtimeIssues = await runtimeRunner({ html, source: source ?? 'inline' });
        for (const issue of runtimeIssues) {
            issues.push(issue);
        }
    }
    catch (err) {
        issues.push({
            kind: 'console_error',
            detail: 'runtime_qa_runner',
            message: err instanceof Error ? err.message : String(err),
        });
    }
    // External-asset probe failures don't block — the user's browser fetches
    // these directly when viewing the preview, so a server-side reachability
    // failure (e.g. IPv6/SSRF-guard edge cases on fonts.googleapis.com) doesn't
    // predict whether the iframe will render. Keep them in `issues` for
    // visibility, but only console/runtime errors and missing previews actually
    // fail QA.
    const blockingIssues = issues.filter((issue) => issue.kind !== 'asset_load_failed');
    if (blockingIssues.length === 0) {
        const assetIssueCount = issues.length;
        return {
            status: 'passed',
            issues,
            summary: assetIssueCount === 0
                ? 'Preview QA passed — assets reachable, no runtime errors.'
                : `Preview QA passed — ${assetIssueCount} external asset${assetIssueCount === 1 ? '' : 's'} unreachable from server (browser will fetch independently).`,
            checkedSource: source,
        };
    }
    return {
        status: 'failed',
        issues,
        summary: buildIssueSummary(issues),
        checkedSource: source,
    };
}
const buildIssueSummary = (issues) => {
    const counts = {
        asset_load_failed: 0,
        console_error: 0,
        preview_unavailable: 0,
    };
    for (const issue of issues) {
        counts[issue.kind] += 1;
    }
    const fragments = [];
    if (counts.asset_load_failed > 0) {
        const first = issues.find((i) => i.kind === 'asset_load_failed');
        fragments.push(counts.asset_load_failed === 1
            ? `asset failed to load (${first?.detail ?? 'unknown'})`
            : `${counts.asset_load_failed} assets failed to load`);
    }
    if (counts.console_error > 0) {
        fragments.push(counts.console_error === 1
            ? '1 console/runtime error detected'
            : `${counts.console_error} console/runtime errors detected`);
    }
    if (counts.preview_unavailable > 0) {
        fragments.push('preview unavailable');
    }
    return `Preview QA failed: ${fragments.join('; ')}.`;
};
/**
 * Extract external asset URLs from a generated HTML document. Pulls from
 * the common attributes (`src`, `href`, `poster`, `data-*` images) and
 * inline CSS `url(...)` references. Keeps only http(s) URLs — relative
 * paths and inline data URIs can't fail the QA gate because they don't
 * touch the network.
 */
function extractExternalAssetUrls(html) {
    const seen = new Set();
    const push = (raw) => {
        if (!raw)
            return;
        const trimmed = raw.trim();
        if (!trimmed)
            return;
        if (!/^https?:\/\//i.test(trimmed))
            return;
        seen.add(trimmed);
    };
    const attributeRegex = /\b(?:src|href|poster|data-src|data-href|data-image|model-src)\s*=\s*("|')([^"']+)\1/gi;
    let attrMatch;
    while ((attrMatch = attributeRegex.exec(html))) {
        push(attrMatch[2]);
    }
    // <link rel="stylesheet" href="..."> is already covered above; capture
    // <img srcset> entries here. srcset is a comma list of "url descriptor".
    const srcsetRegex = /\bsrcset\s*=\s*("|')([^"']+)\1/gi;
    let srcsetMatch;
    while ((srcsetMatch = srcsetRegex.exec(html))) {
        const list = srcsetMatch[2];
        for (const entry of list.split(',')) {
            const url = entry.trim().split(/\s+/)[0];
            push(url);
        }
    }
    // Inline CSS `url(...)` references (background-image, etc.). Strip
    // surrounding quotes if present.
    const cssUrlRegex = /url\(\s*("|'|)([^"')]+)\1\s*\)/gi;
    let cssMatch;
    while ((cssMatch = cssUrlRegex.exec(html))) {
        push(cssMatch[2]);
    }
    return [...seen];
}
//# sourceMappingURL=previewQa.js.map