import { PassThrough } from 'stream';
import archiver from 'archiver';
import { PDFDocument } from 'pdf-lib';
import { artifactExportFilename, buildArtifactHtmlExport } from '../core/artifact-export.js';
import { frameStepperSelector } from '../core/artifact-frame-selectors.js';
import { artifactLibrarySourcesForBundle } from './artifact-library-sources.js';
export class ArtifactRenderExportUnavailableError extends Error {
    constructor(message = 'Artifact rendering is unavailable because the Playwright browser runtime is not installed.') {
        super(message);
        this.name = 'ArtifactRenderExportUnavailableError';
    }
}
function renderViewport(bundle) {
    return bundle.display?.fullscreenViewport ?? bundle.viewport;
}
function pdfPageSize(bundle) {
    const viewport = renderViewport(bundle);
    return {
        width: `${Math.max(1, Math.round(viewport.width))}px`,
        height: `${Math.max(1, Math.round(viewport.height))}px`,
    };
}
function pdfPageOptions(size) {
    return {
        width: `${Math.max(1, Math.round(size.width))}px`,
        height: `${Math.max(1, Math.round(size.height))}px`,
        printBackground: true,
        preferCSSPageSize: false,
        margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' },
    };
}
async function renderedPdfPageSize(page, bundle) {
    const fallback = pdfPageSize(bundle);
    if (bundle.viewport.mode !== 'paged')
        return fallback;
    try {
        const box = await page.locator(artifactFrameSelector(bundle)).first().boundingBox();
        if (box && box.width > 0 && box.height > 0) {
            return {
                width: `${Math.max(1, Math.round(box.width))}px`,
                height: `${Math.max(1, Math.round(box.height))}px`,
            };
        }
    }
    catch {
        // Fall through to the declared viewport if the artifact has no measurable page.
    }
    return fallback;
}
function renderBackground(bundle) {
    return bundle.display?.background === 'transparent' ? 'transparent' : '#ffffff';
}
function withoutExtension(filename, extension) {
    return filename.toLowerCase().endsWith(`.${extension}`)
        ? filename.slice(0, -extension.length - 1)
        : filename;
}
export function artifactPngArchiveFilename(bundle) {
    return `${withoutExtension(artifactExportFilename(bundle, 'png'), 'png')}.zip`;
}
export function artifactPngFrameFilename(bundle, index, count) {
    const base = withoutExtension(artifactExportFilename(bundle, 'png'), 'png');
    const pad = String(Math.max(1, count)).length;
    return `${base}-${String(index + 1).padStart(pad, '0')}.png`;
}
export function nameArtifactPngFiles(bundle, files) {
    return files.map((file, index) => ({
        ...file,
        filename: artifactPngFrameFilename(bundle, index, files.length),
    }));
}
/** Zip an arbitrary set of named byte entries. Backing primitive for both the
 *  multi-PNG archive and the self-contained HTML-export bundle. */
export async function zipArtifactFiles(files) {
    return await new Promise((resolve, reject) => {
        const archive = archiver('zip', { zlib: { level: 6 } });
        const stream = new PassThrough();
        const chunks = [];
        stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
        stream.on('end', () => resolve(Buffer.concat(chunks)));
        stream.on('error', reject);
        archive.on('warning', () => { });
        archive.on('error', reject);
        archive.pipe(stream);
        for (const file of files) {
            archive.append(file.bytes, { name: file.filename });
        }
        void archive.finalize();
    });
}
/** @deprecated naming — use {@link zipArtifactFiles}. Kept for callers. */
export async function zipArtifactPngFiles(files) {
    return zipArtifactFiles(files);
}
function artifactFrameSelector(bundle) {
    return frameStepperSelector(bundle.display?.controls?.stepper?.selector);
}
async function artifactFrameBoxes(page, bundle) {
    const frameSelector = artifactFrameSelector(bundle);
    let frameCount = 0;
    try {
        frameCount = await page.locator(frameSelector).count();
    }
    catch {
        frameCount = 0;
    }
    if (frameCount <= 0)
        return [];
    const frames = page.locator(frameSelector);
    const boxes = [];
    for (let index = 0; index < frameCount; index += 1) {
        const box = await frames.nth(index).boundingBox().catch(() => null);
        if (!box || box.width <= 0 || box.height <= 0)
            continue;
        boxes.push({
            index,
            x: box.x,
            y: box.y,
            width: box.width,
            height: box.height,
        });
    }
    return boxes;
}
function requestIsAllowed(url, baseUrl) {
    if (url.startsWith('about:') || url.startsWith('data:') || url.startsWith('blob:'))
        return true;
    if (!baseUrl)
        return false;
    try {
        const requested = new URL(url);
        const base = new URL(baseUrl);
        return requested.origin === base.origin && requested.pathname.startsWith('/api/assets/');
    }
    catch {
        return false;
    }
}
function isMissingBrowserError(err) {
    const message = err instanceof Error ? err.message : String(err ?? '');
    return /Executable doesn't exist|browserType\.launch|Host system is missing|Chromium distribution/i.test(message);
}
async function launchChromium(playwright) {
    const options = {
        headless: true,
        args: ['--no-sandbox', '--disable-dev-shm-usage'],
    };
    try {
        return await playwright.chromium.launch(options);
    }
    catch (err) {
        if (!isMissingBrowserError(err))
            throw err;
        try {
            return await playwright.chromium.launch({ ...options, channel: 'chrome' });
        }
        catch (fallbackErr) {
            const message = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr ?? '');
            throw new ArtifactRenderExportUnavailableError(`${message}. Install the Playwright Chromium runtime with: npx playwright install chromium`);
        }
    }
}
function screenshotOptions(format, quality) {
    return format === 'jpeg'
        ? { type: 'jpeg', quality: Math.max(1, Math.min(100, quality ?? 76)) }
        : { type: 'png' };
}
function imagePayload(bytes, format, dimensions) {
    const buffer = Buffer.from(bytes);
    const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
    return {
        mimeType,
        byteLength: buffer.length,
        width: Math.max(1, Math.round(dimensions.width)),
        height: Math.max(1, Math.round(dimensions.height)),
        base64: buffer.toString('base64'),
    };
}
export async function withRenderedArtifact(bundle, options, callback) {
    let playwright;
    try {
        playwright = await import('playwright');
    }
    catch (err) {
        throw new ArtifactRenderExportUnavailableError(err?.message);
    }
    let browser = null;
    try {
        const viewport = renderViewport(bundle);
        const context = options.context ?? 'fullscreen';
        browser = await launchChromium(playwright);
        const page = await browser.newPage({
            viewport: {
                width: Math.max(1, Math.round(viewport.width)),
                height: Math.max(1, Math.round(viewport.height)),
            },
            deviceScaleFactor: options.deviceScaleFactor ?? 1,
        });
        await options.onPage?.(page);
        page.setDefaultTimeout(options.timeoutMs ?? 15_000);
        await page.route('**/*', (route) => {
            if (requestIsAllowed(route.request().url(), options.baseUrl)) {
                void route.continue();
            }
            else {
                void route.abort();
            }
        });
        const html = buildArtifactHtmlExport(bundle, {
            context,
            baseHref: options.baseUrl ? `${options.baseUrl.replace(/\/+$/, '')}/` : undefined,
            includeRenderHostStyles: context === 'fullscreen' || context === 'export',
            librarySources: artifactLibrarySourcesForBundle(bundle),
            // Headless render = token-mode resolution: cookieless image loads carry
            // `?at=` and the document CSP allowlists <baseUrl origin>/api/assets/
            // (the page.route allowlist above stays the outer belt). Without a
            // baseUrl, root-relative asset URLs can't resolve at all → no access.
            assets: options.baseUrl
                ? { mode: 'token', token: options.assetToken ?? null, origin: options.baseUrl }
                : { mode: 'none' },
        });
        // Artifacts are self-contained (inlined HTML/CSS/JS + library sources), so
        // `load` is enough — `networkidle` only adds latency + flakiness waiting for
        // a 500ms quiet window. Asset-backed artifacts still settle via the
        // libraries-ready + double-rAF wait below (and inspect's own networkidle
        // wait, which is image-load gated and wrapped in a catch).
        await page.setContent(html, { waitUntil: 'load', timeout: options.timeoutMs ?? 15_000 });
        await page.evaluate(({ background, context }) => {
            const doc = globalThis.document;
            // 'important' so the render knob beats the export host styles' present
            // backdrop (`!important` rules) in fullscreen context — inspect/PNG
            // output stays governed by renderBackground(), not the on-screen gray.
            doc.documentElement.style.setProperty('background', background, 'important');
            doc.body.style.setProperty('background', background, 'important');
            if (context === 'fullscreen') {
                const frames = Array.from(doc.querySelectorAll('[data-artifact-frame]'));
                frames.forEach((frame, index) => {
                    frame.toggleAttribute('data-studiograph-active-frame', index === 0);
                    frame.setAttribute('aria-hidden', index === 0 ? 'false' : 'true');
                });
            }
        }, { background: renderBackground(bundle), context });
        await page.evaluate(async () => {
            const win = globalThis;
            await (win.__STUDIOGRAPH_ARTIFACT_LIBRARIES_READY__ || Promise.resolve());
            // Webfonts: `load` fires before `font-display: swap` faces finish, so an
            // immediate capture can show fallback fonts. Wait for the font set to
            // settle, bounded so a hung font download can't stall the capture.
            const fonts = win.document?.fonts;
            if (fonts?.ready) {
                await Promise.race([
                    Promise.resolve(fonts.ready).catch(() => { }),
                    new Promise((resolve) => win.setTimeout(resolve, 3000)),
                ]);
            }
            await new Promise((resolve) => {
                win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()));
            });
        });
        return await callback(page);
    }
    catch (err) {
        if (err instanceof ArtifactRenderExportUnavailableError)
            throw err;
        if (isMissingBrowserError(err)) {
            throw new ArtifactRenderExportUnavailableError(`${err.message}. Install the Playwright Chromium runtime with: npx playwright install chromium`);
        }
        throw err;
    }
    finally {
        await browser?.close().catch(() => { });
    }
}
export async function renderArtifactPng(bundle, options = {}) {
    const files = await renderArtifactPngFiles(bundle, options);
    return files[0]?.bytes ?? Buffer.alloc(0);
}
export async function renderArtifactPngFiles(bundle, options = {}) {
    return withRenderedArtifact(bundle, { ...options, context: 'export' }, async (page) => {
        const boxes = await artifactFrameBoxes(page, bundle);
        if (boxes.length > 0) {
            const files = [];
            const frames = page.locator(artifactFrameSelector(bundle));
            for (const box of boxes) {
                const bytes = await frames.nth(box.index).screenshot({
                    type: 'png',
                    animations: 'disabled',
                    scale: 'css',
                });
                files.push({ index: box.index, bytes: Buffer.from(bytes) });
            }
            if (files.length > 0)
                return files;
        }
        const bytes = await page.screenshot({
            type: 'png',
            fullPage: true,
            animations: 'disabled',
            scale: 'css',
        });
        return [{ index: 0, bytes: Buffer.from(bytes) }];
    });
}
/** In-page script for the PDF canvas pre-pass — exported for the real-browser
 *  regression test. Chromium's print pipeline (page.pdf) re-lays-out the
 *  document through print rendering, which does NOT composite GPU-backed
 *  canvas content: a three.js scene prints as an empty box while the DOM
 *  around it survives. Swap every visible canvas for a same-geometry <img> of
 *  its current pixels so the PDF carries the rendered scene (surrounding text
 *  stays vector — only the canvas itself becomes raster).
 *
 *  Capture runs inside a double-requestAnimationFrame: a continuously
 *  rendering scene's draw for the current frame has already run by our
 *  callback (its rAF was registered first), and same-task reads see the WebGL
 *  drawing buffer even without preserveDrawingBuffer. A scene that rendered
 *  ONCE long ago without preserveDrawingBuffer still snapshots blank — no
 *  worse than today's blank print. Tainted canvases and snapshot failures
 *  leave the canvas untouched. */
export const snapshotCanvasesInPage = () => new Promise((resolve) => {
    const win = globalThis;
    const doc = win.document;
    win.requestAnimationFrame(() => {
        win.requestAnimationFrame(() => {
            let swapped = 0;
            const decodes = [];
            for (const canvas of Array.from(doc.querySelectorAll('canvas'))) {
                const rect = canvas.getBoundingClientRect();
                if (rect.width < 1 || rect.height < 1)
                    continue;
                let dataUrl = '';
                try {
                    dataUrl = canvas.toDataURL('image/png');
                }
                catch {
                    continue; // tainted canvas — leave it as-is
                }
                if (!dataUrl.startsWith('data:image/png'))
                    continue;
                const img = doc.createElement('img');
                img.src = dataUrl;
                img.className = canvas.className;
                if (canvas.id)
                    img.id = canvas.id;
                // Keep the canvas's exact on-screen box: canvas layout often comes
                // from width/height attributes or canvas-specific stylesheet rules
                // that don't transfer to an <img>.
                img.style.cssText = canvas.style.cssText;
                img.style.width = `${rect.width}px`;
                img.style.height = `${rect.height}px`;
                img.width = Math.max(1, Math.round(rect.width));
                img.height = Math.max(1, Math.round(rect.height));
                canvas.replaceWith(img);
                decodes.push(typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve());
                swapped += 1;
            }
            void Promise.all(decodes).then(() => resolve(swapped));
        });
    });
});
/** Best-effort: a failed snapshot must never fail the export — the worst case
 *  is the pre-pass's own absence (blank canvas boxes in the PDF, the old
 *  behavior). */
async function snapshotCanvasesForPrint(page) {
    try {
        await page.evaluate(snapshotCanvasesInPage);
    }
    catch {
        /* keep printing without the pre-pass */
    }
}
async function mergePdfPages(files) {
    if (files.length === 1)
        return files[0] ?? Buffer.alloc(0);
    const merged = await PDFDocument.create();
    for (const bytes of files) {
        const source = await PDFDocument.load(bytes);
        const pages = await merged.copyPages(source, source.getPageIndices());
        for (const page of pages)
            merged.addPage(page);
    }
    return Buffer.from(await merged.save());
}
async function renderArtifactPdfFrame(page, box, background) {
    await page.evaluate(({ frame, bg }) => {
        const doc = globalThis.document;
        let style = doc.getElementById('studiograph-artifact-pdf-frame-style');
        if (!style) {
            style = doc.createElement('style');
            style.id = 'studiograph-artifact-pdf-frame-style';
            doc.head.appendChild(style);
        }
        const width = Math.max(1, Math.round(frame.width));
        const height = Math.max(1, Math.round(frame.height));
        style.textContent = `
@media print {
  html,
  body {
    margin: 0 !important;
    padding: 0 !important;
    width: ${width}px !important;
    height: ${height}px !important;
    min-width: ${width}px !important;
    min-height: ${height}px !important;
    overflow: hidden !important;
    background: ${bg} !important;
  }
  body {
    transform: translate(${-frame.x}px, ${-frame.y}px) !important;
    transform-origin: 0 0 !important;
  }
}`;
    }, { frame: box, bg: background });
    const bytes = await page.pdf(pdfPageOptions(box));
    return Buffer.from(bytes);
}
export async function renderArtifactPdf(bundle, options = {}) {
    return withRenderedArtifact(bundle, { ...options, context: 'export' }, async (page) => {
        // PDF only — PNG screenshots composite GPU canvases fine; the print
        // pipeline does not (see snapshotCanvasesInPage).
        await snapshotCanvasesForPrint(page);
        const boxes = await artifactFrameBoxes(page, bundle);
        if (boxes.length > 0) {
            const background = renderBackground(bundle);
            const files = [];
            for (const box of boxes) {
                files.push(await renderArtifactPdfFrame(page, box, background));
            }
            return mergePdfPages(files);
        }
        const size = await renderedPdfPageSize(page, bundle);
        const bytes = await page.pdf({
            ...size,
            printBackground: true,
            preferCSSPageSize: false,
            margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' },
        });
        return Buffer.from(bytes);
    });
}
export async function inspectArtifactRender(bundle, options = {}) {
    const context = options.context ?? 'editor';
    const frameSelector = options.frameSelector ?? artifactFrameSelector(bundle);
    const screenshotFormat = options.screenshotFormat ?? 'jpeg';
    const includePageScreenshot = options.includePageScreenshot !== false;
    const includeFrameScreenshots = options.includeFrameScreenshots !== false;
    const maxFrames = Math.max(0, Math.min(24, Math.round(options.maxFrames ?? 6)));
    const requestedIndexes = options.frameIndexes
        ?.filter(Number.isFinite)
        .map((index) => Math.max(0, Math.floor(index)));
    const consoleMessages = [];
    const pageErrors = [];
    const requestFailures = [];
    // Downscale inspect screenshots by rendering at a fractional device pixel
    // ratio: with `scale:'device'`, each screenshot's output px = cssPx * dsf, so
    // a 1920-wide slide capped to 1024 renders at dsf≈0.53 → ~1024px. Vision
    // tokens scale with pixel area, so this is the main cost lever. Export/PDF are
    // untouched (they keep dsf=1 + scale:'css' for exact full-resolution output).
    const maxImageDimension = Math.max(1, Math.round(options.maxImageDimension ?? 1024));
    const inspectViewport = renderViewport(bundle);
    const longestSide = Math.max(inspectViewport.width, inspectViewport.height, 1);
    const deviceScaleFactor = options.deviceScaleFactor ?? Math.min(1, maxImageDimension / longestSide);
    return withRenderedArtifact(bundle, {
        ...options,
        context,
        deviceScaleFactor,
        onPage: (page) => {
            page.on('console', (message) => {
                consoleMessages.push({ type: message.type(), text: message.text().slice(0, 1000) });
            });
            page.on('pageerror', (error) => {
                pageErrors.push(error.message.slice(0, 1000));
            });
            page.on('requestfailed', (request) => {
                const url = request.url();
                if (url.startsWith('about:') || url.startsWith('data:'))
                    return;
                requestFailures.push({
                    url: url.slice(0, 1000),
                    errorText: request.failure()?.errorText ?? 'request failed',
                });
            });
            return options.onPage?.(page);
        },
    }, async (page) => {
        await page.waitForLoadState('networkidle', { timeout: options.timeoutMs ?? 15_000 }).catch(() => { });
        const metrics = await page.evaluate((selector) => {
            const doc = globalThis.document;
            const body = doc.body;
            const root = doc.documentElement;
            const frames = Array.from(doc.querySelectorAll(selector)).map((el, index) => {
                const rect = el.getBoundingClientRect();
                return {
                    index,
                    tagName: el.tagName.toLowerCase(),
                    id: el.id || undefined,
                    className: typeof el.className === 'string' ? el.className || undefined : undefined,
                    textSnippet: ((el.textContent ?? '').replace(/\s+/g, ' ').trim()).slice(0, 500) || undefined,
                    rect: {
                        x: Math.round((rect.x + Number.EPSILON) * 100) / 100,
                        y: Math.round((rect.y + Number.EPSILON) * 100) / 100,
                        width: Math.round((rect.width + Number.EPSILON) * 100) / 100,
                        height: Math.round((rect.height + Number.EPSILON) * 100) / 100,
                    },
                };
            });
            return {
                documentSize: {
                    width: Math.max(body?.scrollWidth ?? 0, root?.scrollWidth ?? 0, root?.clientWidth ?? 0),
                    height: Math.max(body?.scrollHeight ?? 0, root?.scrollHeight ?? 0, root?.clientHeight ?? 0),
                },
                bodyTextSnippet: ((body?.textContent ?? '').replace(/\s+/g, ' ').trim()).slice(0, 1200),
                frames,
            };
        }, frameSelector);
        // `scale:'device'` (the default — note no `scale:'css'`) lets the page's
        // fractional deviceScaleFactor downscale the output. fullPage captures the
        // whole scrollable area, so its px dimensions are documentSize * dsf.
        const pageScreenshot = includePageScreenshot
            ? imagePayload(await page.screenshot({
                ...screenshotOptions(screenshotFormat, options.jpegQuality),
                fullPage: true,
                animations: 'disabled',
            }), screenshotFormat, {
                width: metrics.documentSize.width * deviceScaleFactor,
                height: metrics.documentSize.height * deviceScaleFactor,
            })
            : undefined;
        const frameIndexes = (requestedIndexes && requestedIndexes.length > 0)
            ? requestedIndexes.slice(0, maxFrames)
            : metrics.frames.slice(0, maxFrames).map((frame) => frame.index);
        const frameIndexSet = new Set(frameIndexes);
        const frames = [];
        for (const frame of metrics.frames) {
            if (!frameIndexSet.has(frame.index)) {
                frames.push(frame);
                continue;
            }
            if (!includeFrameScreenshots || frame.rect.width <= 0 || frame.rect.height <= 0) {
                frames.push(frame);
                continue;
            }
            try {
                const bytes = await page.locator(frameSelector).nth(frame.index).screenshot({
                    ...screenshotOptions(screenshotFormat, options.jpegQuality),
                    animations: 'disabled',
                });
                frames.push({
                    ...frame,
                    screenshot: imagePayload(bytes, screenshotFormat, {
                        width: frame.rect.width * deviceScaleFactor,
                        height: frame.rect.height * deviceScaleFactor,
                    }),
                });
            }
            catch (err) {
                frames.push({
                    ...frame,
                    screenshotError: err instanceof Error ? err.message.slice(0, 1000) : String(err).slice(0, 1000),
                });
            }
        }
        const viewport = renderViewport(bundle);
        return {
            artifactId: bundle.artifactId,
            title: bundle.title,
            kind: bundle.kind,
            context,
            viewport: {
                width: Math.max(1, Math.round(viewport.width)),
                height: Math.max(1, Math.round(viewport.height)),
            },
            documentSize: metrics.documentSize,
            frameSelector,
            frameCount: metrics.frames.length,
            bodyTextSnippet: metrics.bodyTextSnippet,
            console: consoleMessages,
            pageErrors,
            requestFailures,
            ...(pageScreenshot ? { screenshot: pageScreenshot } : {}),
            frames,
        };
    });
}
/**
 * Split an inspection into (a) compact METADATA text (everything except the
 * base64 pixels) and (b) ordered IMAGE blocks (page first if present, then each
 * frame that has a screenshot). Callers send the metadata as a text block and
 * the images as real vision blocks, so the model actually SEES the slides for
 * ~1.6k tokens each instead of ~300k of undecoded base64 text. Frame metadata
 * keeps a `screenshotIndex` pointing at the matching image block's position.
 */
export function inspectionToModelPayload(inspection) {
    const images = [];
    const describe = (img, screenshotIndex) => ({
        mimeType: img.mimeType,
        byteLength: img.byteLength,
        width: img.width,
        height: img.height,
        screenshotIndex,
    });
    let pageMeta;
    if (inspection.screenshot) {
        const index = images.length;
        images.push({ mimeType: inspection.screenshot.mimeType, data: inspection.screenshot.base64 });
        pageMeta = describe(inspection.screenshot, index);
    }
    const frames = inspection.frames.map((frame) => {
        const { screenshot, ...rest } = frame;
        if (!screenshot)
            return rest;
        const index = images.length;
        images.push({ mimeType: screenshot.mimeType, data: screenshot.base64 });
        return { ...rest, screenshot: describe(screenshot, index) };
    });
    const metadata = {
        artifactId: inspection.artifactId,
        title: inspection.title,
        kind: inspection.kind,
        context: inspection.context,
        viewport: inspection.viewport,
        documentSize: inspection.documentSize,
        frameSelector: inspection.frameSelector,
        frameCount: inspection.frameCount,
        bodyTextSnippet: inspection.bodyTextSnippet,
        console: inspection.console,
        pageErrors: inspection.pageErrors,
        requestFailures: inspection.requestFailures,
        ...(pageMeta ? { screenshot: pageMeta } : {}),
        frames,
    };
    if (images.length > 0) {
        metadata.screenshotNote =
            'Screenshots are attached as separate image blocks following this metadata, in order: '
                + (pageMeta ? 'the full page first, then ' : '')
                + 'each frame that has a `screenshot` (use its `screenshotIndex`).';
    }
    return { metadata, images };
}
//# sourceMappingURL=artifact-render-export.js.map