// Copyright (c) 2026 Modelware. All rights reserved.

import { createMarkdownRendererRegistry } from '../renderers/registry.js';
import { setIriLabelSnapshot, displayLabelFromIri } from '../renderers/renderer.js';

// Replace [[iri]] tokens in an HTML string with wikilink anchors, skipping content inside tags.
function expandWikilinksInHtml(html: string): string {
    return html.replace(/(<[^>]*>)|\[\[([^\]]+)\]\]/g, (match, tag: string | undefined, iri: string | undefined) => {
        if (tag !== undefined) {
            // Only pass through real HTML tags: <tagname, </tagname, <!-- , <!doctype, <?.
            // Tag names are letters/digits/hyphens only — no colons or slashes.
            // <http://...> fails because ':' immediately follows the tag name letters.
            if (/^<[!?]/.test(tag) || /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:[\s>\/]|$)/.test(tag)) return tag;
            return tag.replace(/</g, '&lt;').replace(/>/g, '&gt;');
        }
        const trimmed = (iri ?? '').trim();
        if (!trimmed) return match;
        const label = displayLabelFromIri(trimmed);
        const safeIri = trimmed.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
        const safeLabel = label.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        return `<a class="wikilink" iri="${safeIri}" href="#" title="${safeIri}">${safeLabel}</a>`;
    });
}

const SUPPORTED = new Set(['table', 'tree', 'graph', 'chart', 'diagram', 'list', 'text', 'matrix', 'table-editor']);

type ManifestEntry = { blockId: string; path: string };
type WikilinkConfig = { linkingEnabled?: boolean };
type HoverAnchorRect = {
    left: number;
    right: number;
    top: number;
    bottom: number;
    width: number;
    height: number;
};

let wikilinkPreviewCard: HTMLDivElement | undefined;
let wikilinkPreviewHideTimer: number | undefined;
let wikilinkPreviewRequestToken = 0;
let tabPreviewModifierActive = false;
let activeWikilinkHover: { link: HTMLAnchorElement; iri: string } | undefined;

function parseJsonNode<T>(id: string, fallback: T): T {
    const node = document.getElementById(id);
    if (!node) {
        return fallback;
    }
    try {
        return JSON.parse(node.textContent ?? '') as T;
    } catch {
        return fallback;
    }
}

function safeDecode(value: string): string {
    try {
        return decodeURIComponent(value);
    } catch {
        return value;
    }
}

function normalizeWikiPathKey(raw: string): string {
    return String(raw || '')
        .replace(/\\/g, '/')
        .replace(/^\/+/, '')
        .replace(/[/]+/g, '/')
        .replace(/\.html$/i, '')
        .replace(/^\.\//, '')
        .toLowerCase();
}

function resolveWikiHref(
    iri: string,
    wikilinkIndex: Record<string, string>,
    iriAliasIndex: Record<string, string>
): string | undefined {
    const text = String(iri || '').trim();
    if (!text) {
        return undefined;
    }
    const alias =
        iriAliasIndex[text]
        || iriAliasIndex[text.replace(/[.,;:!?]+$/, '')]
        || iriAliasIndex[safeDecode(text)];
    if (typeof alias === 'string' && alias.length > 0) {
        return alias;
    }
    const hashIndex = text.indexOf('#');
    const fragment = hashIndex >= 0 ? text.slice(hashIndex) : '';
    const iriWithoutFragment = hashIndex >= 0 ? text.slice(0, hashIndex) : text;
    let parsed: URL;
    try {
        parsed = new URL(iriWithoutFragment);
    } catch {
        return undefined;
    }
    const rawPath = safeDecode((parsed.pathname || '').replace(/^\/+/, ''));
    const hostPath = `${(parsed.hostname || '').toLowerCase()}/${rawPath}`.replace(/^\/+/, '');
    const candidates = [normalizeWikiPathKey(hostPath), normalizeWikiPathKey(rawPath)]
        .filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
    for (const candidate of candidates) {
        const direct = wikilinkIndex[candidate];
        if (typeof direct === 'string' && direct.length > 0) {
            return direct + fragment;
        }
    }
    let bestMatch: { key: string; href: string } | undefined;
    for (const candidate of candidates) {
        for (const [key, href] of Object.entries(wikilinkIndex)) {
            if (!candidate.endsWith('/' + key) && candidate !== key && !key.endsWith('/' + candidate)) {
                continue;
            }
            if (!bestMatch || key.length > bestMatch.key.length) {
                bestMatch = { key, href };
            }
        }
    }
    if (bestMatch && bestMatch.href.length > 0) {
        return bestMatch.href + fragment;
    }
    if (fragment) {
        const currentPath = normalizeWikiPathKey((window.location && window.location.pathname) || '');
        if (currentPath && candidates.some((candidate) => candidate === currentPath)) {
            return fragment;
        }
    }
    return undefined;
}

function applyWikilinks(
    scope: ParentNode,
    wikilinkIndex: Record<string, string>,
    iriAliasIndex: Record<string, string>,
    linkingEnabled: boolean
): void {
    const links = Array.from(scope.querySelectorAll<HTMLAnchorElement>('a.wikilink[iri]'));
    for (const link of links) {
        const iri = link.getAttribute('iri') || '';
        const label = link.textContent || iri;
        if (!linkingEnabled) {
            const span = document.createElement('span');
            span.className = 'wikilink';
            span.setAttribute('iri', iri);
            span.textContent = label;
            link.replaceWith(span);
            continue;
        }
        const href = resolveWikiHref(iri, wikilinkIndex, iriAliasIndex);
        if (href) {
            link.href = href;
            continue;
        }
        const span = document.createElement('span');
        span.className = 'wikilink';
        span.setAttribute('iri', iri);
        span.textContent = label;
        link.replaceWith(span);
    }
}

function installWikilinkObserver(
    wikilinkIndex: Record<string, string>,
    iriAliasIndex: Record<string, string>,
    linkingEnabled: boolean
): void {
    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const added of Array.from(mutation.addedNodes)) {
                if (!(added instanceof Element)) {
                    continue;
                }
                if (added.matches('a.wikilink[iri]')) {
                    applyWikilinks(added.parentElement ?? document, wikilinkIndex, iriAliasIndex, linkingEnabled);
                    continue;
                }
                if (added.querySelector('a.wikilink[iri]')) {
                    applyWikilinks(added, wikilinkIndex, iriAliasIndex, linkingEnabled);
                }
            }
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });
}

function setupDownloadHandler(): void {
    window.addEventListener('md-download-file', (event: Event) => {
        const detail = (event as CustomEvent<{ filename?: string; content?: string }>).detail;
        if (!detail || typeof detail.filename !== 'string') {
            return;
        }
        const blob = new Blob([detail.content ?? ''], { type: 'text/plain;charset=utf-8' });
        const anchor = document.createElement('a');
        const objectUrl = URL.createObjectURL(blob);
        anchor.href = objectUrl;
        anchor.download = detail.filename;
        anchor.style.display = 'none';
        document.body.appendChild(anchor);
        anchor.click();
        anchor.remove();
        URL.revokeObjectURL(objectUrl);
    });
}

function setupIriNavigationHandler(
    wikilinkIndex: Record<string, string>,
    iriAliasIndex: Record<string, string>,
    linkingEnabled: boolean
): void {
    window.addEventListener('md-navigate-iri', (event: Event) => {
        const detail = (event as CustomEvent<{ iri?: string }>).detail;
        const iri = typeof detail?.iri === 'string' ? detail.iri.trim() : '';
        if (!iri || !linkingEnabled) {
            return;
        }
        const href = resolveWikiHref(iri, wikilinkIndex, iriAliasIndex);
        if (!href) {
            return;
        }
        const targetHref = new URL(href, document.baseURI).toString();
        if (window.parent && window.parent !== window) {
            window.parent.postMessage({ type: 'oml-md:navigate', href: targetHref }, '*');
        } else {
            window.location.assign(targetHref);
        }
    });
}

function isHoverPreviewEnabled(event: MouseEvent | undefined): boolean {
    if (!event) {
        return false;
    }
    return event.altKey || tabPreviewModifierActive;
}

function normalizeHoverAnchorRect(value: unknown): HoverAnchorRect | undefined {
    if (!value || typeof value !== 'object') {
        return undefined;
    }
    const obj = value as Record<string, unknown>;
    if (typeof obj.left !== 'number' || typeof obj.right !== 'number' ||
        typeof obj.top !== 'number' || typeof obj.bottom !== 'number' ||
        typeof obj.width !== 'number' || typeof obj.height !== 'number') {
        return undefined;
    }
    return {
        left: obj.left,
        right: obj.right,
        top: obj.top,
        bottom: obj.bottom,
        width: obj.width,
        height: obj.height,
    };
}

function ensureWikilinkPreviewCard(): HTMLDivElement {
    if (wikilinkPreviewCard) {
        return wikilinkPreviewCard;
    }
    const card = document.createElement('div');
    card.className = 'oml-wikilink-preview-card';
    card.style.position = 'fixed';
    card.style.zIndex = '1200';
    card.style.display = 'none';
    card.style.width = 'min(640px, calc(100vw - 48px))';
    card.style.height = 'min(480px, calc(100vh - 48px))';
    card.style.overflow = 'hidden';
    card.style.padding = '0';
    card.style.border = '1px solid var(--vscode-editorWidget-border, #c8ccd1)';
    card.style.borderRadius = '10px';
    card.style.background = 'var(--vscode-editor-background, #ffffff)';
    card.style.color = 'var(--vscode-editor-foreground, #1f2328)';
    card.style.boxShadow = '0 8px 24px rgb(0 0 0 / 24%)';
    card.style.fontFamily = 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif';
    card.addEventListener('mouseenter', () => {
        if (wikilinkPreviewHideTimer !== undefined) {
            window.clearTimeout(wikilinkPreviewHideTimer);
            wikilinkPreviewHideTimer = undefined;
        }
    });
    // Don't hide on mouse leave - only ESC closes the preview now
    document.body.appendChild(card);
    wikilinkPreviewCard = card;
    return card;
}

function scheduleHideWikilinkPreview(): void {
    wikilinkPreviewRequestToken += 1;
    if (wikilinkPreviewHideTimer !== undefined) {
        window.clearTimeout(wikilinkPreviewHideTimer);
        wikilinkPreviewHideTimer = undefined;
    }
    // Immediately hide the preview (no delay)
    if (wikilinkPreviewCard) {
        wikilinkPreviewCard.style.display = 'none';
        wikilinkPreviewCard.replaceChildren();
    }
}

async function scheduleIriPreview(
    iri: string,
    href: string,
    anchorRect: HoverAnchorRect
): Promise<void> {
    if (wikilinkPreviewHideTimer !== undefined) {
        window.clearTimeout(wikilinkPreviewHideTimer);
        wikilinkPreviewHideTimer = undefined;
    }
    const token = ++wikilinkPreviewRequestToken;
    await new Promise<void>((resolve) => window.setTimeout(resolve, 220));
    if (token !== wikilinkPreviewRequestToken) {
        return;
    }
    
    const card = ensureWikilinkPreviewCard();
    
    // Create iframe to show the linked page
    const iframe = document.createElement('iframe');
    // Add preview mode parameter to prevent nested previews
    const iframeSrc = new URL(href, document.baseURI);
    iframeSrc.searchParams.set('_oml_preview', '1');
    iframe.src = iframeSrc.toString();
    iframe.style.width = '100%';
    iframe.style.height = '100%';
    iframe.style.border = 'none';
    iframe.style.borderRadius = '4px';
    iframe.style.background = 'var(--vscode-editor-background, #ffffff)';
    iframe.style.display = 'block';
    
    // Intercept clicks in the iframe to navigate the parent page
    iframe.addEventListener('load', () => {
        try {
            const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
            if (!iframeDoc) {
                return;
            }
            
            // Remove download attributes from all links to prevent file downloads
            const allLinks = iframeDoc.querySelectorAll('a[download]');
            allLinks.forEach(link => link.removeAttribute('download'));
            
            // Add click listener to all links and interactive elements in the iframe
            iframeDoc.addEventListener('click', (event) => {
                const target = event.target;
                if (!(target instanceof Element)) {
                    return;
                }
                
                const link = target.closest('a[href]');
                if (!link || !(link instanceof HTMLAnchorElement)) {
                    return;
                }
                
                const linkHref = link.getAttribute('href');
                if (!linkHref) {
                    return;
                }
                
                // Allow internal anchor links within the iframe
                if (linkHref.startsWith('#')) {
                    return;
                }
                
                // Prevent any navigation in the iframe
                event.preventDefault();
                event.stopPropagation();
                event.stopImmediatePropagation();
                
                // Get the absolute URL, accounting for any base URL in the iframe
                const iframeLocation = iframe.contentWindow?.location.href;
                const absoluteUrl = new URL(linkHref, iframeLocation || iframe.src);
                
                // Remove the preview parameter for the main navigation
                absoluteUrl.searchParams.delete('_oml_preview');
                
                // Close the preview and navigate the parent page
                scheduleHideWikilinkPreview();
                
                // Navigate the parent window (not the iframe)
                const targetUrl = absoluteUrl.toString();
                window.location.assign(targetUrl);
                
                return false;
            }, true); // Use capture phase to catch events early
        } catch (error) {
            // If we can't access iframe content (CORS), silently fail
            console.warn('Could not access iframe content for click interception:', error);
        }
    });
    
    card.replaceChildren(iframe);
    card.style.display = 'block';
    const cardRect = card.getBoundingClientRect();
    const margin = 12;
    const left = Math.min(
        Math.max(16, anchorRect.left),
        Math.max(16, window.innerWidth - cardRect.width - 16)
    );
    let top = anchorRect.bottom + margin;
    if (top + cardRect.height > window.innerHeight - 16) {
        top = Math.max(16, window.innerHeight - cardRect.height - 16);
    }
    card.style.left = `${left}px`;
    card.style.top = `${top}px`;
}

function setupWikilinkHoverPreview(
    wikilinkIndex: Record<string, string>,
    iriAliasIndex: Record<string, string>,
    linkingEnabled: boolean
): void {
    // Don't enable previews if we're already in preview mode (prevents nested previews)
    const urlParams = new URLSearchParams(window.location.search);
    if (urlParams.has('_oml_preview')) {
        return;
    }
    
    // Close preview when clicking anywhere outside the preview card
    document.addEventListener('click', (event) => {
        if (!wikilinkPreviewCard || wikilinkPreviewCard.style.display === 'none') {
            return;
        }
        const target = event.target;
        if (!(target instanceof Element)) {
            return;
        }
        // Check if click is outside the preview card
        if (!target.closest('.oml-wikilink-preview-card')) {
            scheduleHideWikilinkPreview();
            activeWikilinkHover = undefined;
        }
    }, true); // Use capture to catch clicks before they propagate
    
    // Handle hover events for wikilinks in text content
    document.body.addEventListener('mouseover', (event) => {
        const target = event.target instanceof Element
            ? event.target
            : (event.target instanceof Node ? event.target.parentElement : null);
        if (!target || target.closest('.oml-wikilink-preview-card')) {
            return;
        }
        const link = target.closest<HTMLAnchorElement>('a.wikilink[iri]');
        if (!link) {
            return;
        }
        const iri = link.getAttribute('iri')?.trim();
        const href = link.getAttribute('href')?.trim();
        if (!iri || !href || href === '#') {
            return;
        }
        activeWikilinkHover = { link, iri };
        if (isHoverPreviewEnabled(event)) {
            void scheduleIriPreview(iri, href, link.getBoundingClientRect());
        }
    });

    window.addEventListener('keydown', (event) => {
        // ESC key closes the preview
        if (event.key === 'Escape') {
            scheduleHideWikilinkPreview();
            activeWikilinkHover = undefined;
            return;
        }
        
        if (event.key === 'Tab') {
            tabPreviewModifierActive = true;
        }
        if (!activeWikilinkHover) {
            return;
        }
        if (!(event.altKey || event.key === 'Alt' || (event.key === 'Tab' && tabPreviewModifierActive))) {
            return;
        }
        const link = activeWikilinkHover.link;
        if (!link.isConnected || !link.matches(':hover')) {
            return;
        }
        const href = link.getAttribute('href')?.trim();
        if (!href || href === '#') {
            return;
        }
        void scheduleIriPreview(activeWikilinkHover.iri, href, link.getBoundingClientRect());
    });

    window.addEventListener('keyup', (event) => {
        if (event.key === 'Tab') {
            tabPreviewModifierActive = false;
        }
    });

    window.addEventListener('blur', () => {
        tabPreviewModifierActive = false;
    });

    // Handle hover events dispatched by renderers (diagram, graph, table, etc.)
    window.addEventListener('md-show-iri-hover', (event: Event) => {
        if (!(event instanceof CustomEvent)) {
            return;
        }
        const eventTarget = event.target;
        if (eventTarget instanceof Element && eventTarget.closest('.oml-wikilink-preview-card')) {
            return;
        }
        const detail = event.detail as { iri?: unknown; anchorRect?: unknown; previewEnabled?: unknown } | undefined;
        const iri = typeof detail?.iri === 'string' ? detail.iri.trim() : '';
        const anchorRect = normalizeHoverAnchorRect(detail?.anchorRect);
        const previewEnabled = detail?.previewEnabled === true;
        if (!iri || !anchorRect || !previewEnabled || !linkingEnabled) {
            return;
        }
        const href = resolveWikiHref(iri, wikilinkIndex, iriAliasIndex);
        if (!href || href === '#') {
            return;
        }
        void scheduleIriPreview(iri, href, anchorRect);
    });
}

function getMdKindFromCodeElement(code: Element): string | undefined {
    for (const className of Array.from(code.classList)) {
        if (!className.startsWith('language-')) {
            continue;
        }
        const language = className.slice('language-'.length).toLowerCase();
        if (SUPPORTED.has(language)) {
            return language;
        }
    }
    return undefined;
}

async function loadResult(
    manifestEntry: ManifestEntry,
    inlineResults: Record<string, unknown>
): Promise<unknown> {
    if (manifestEntry.blockId && Object.prototype.hasOwnProperty.call(inlineResults, manifestEntry.blockId)) {
        return inlineResults[manifestEntry.blockId];
    }
    const response = await fetch(manifestEntry.path);
    if (!response.ok) {
        return { status: 'error', message: `Failed to load block result: ${manifestEntry.path}` };
    }
    return await response.json();
}

async function applyExecutionResults(): Promise<void> {
    const inlineResults = parseJsonNode<Record<string, unknown>>('oml-md-block-inline-results', {});
    const wikilinkIndex = parseJsonNode<Record<string, string>>('oml-md-wikilink-index', {});
    const iriAliasIndex = parseJsonNode<Record<string, string>>('oml-md-wikilink-iri-aliases', {});
    const wikilinkConfig = parseJsonNode<WikilinkConfig>('oml-md-wikilink-config', { linkingEnabled: false });
    const memberLabelSnapshot = parseJsonNode<Record<string, string>>('oml-md-member-labels', {});
    setIriLabelSnapshot(memberLabelSnapshot);
    const linkingEnabled = Boolean(wikilinkConfig?.linkingEnabled);
    // Resolve wikilinks in regular markdown content (outside code-block panels).
    applyWikilinks(document, wikilinkIndex, iriAliasIndex, linkingEnabled);
    // Re-apply wikilinks for dynamic renderer re-renders (tables, filters, paging, etc.).
    installWikilinkObserver(wikilinkIndex, iriAliasIndex, linkingEnabled);
    setupIriNavigationHandler(wikilinkIndex, iriAliasIndex, linkingEnabled);
    setupWikilinkHoverPreview(wikilinkIndex, iriAliasIndex, linkingEnabled);

    const manifest = parseJsonNode<ManifestEntry[]>('oml-md-block-manifest', []);
    if (!Array.isArray(manifest) || manifest.length === 0) {
        return;
    }

    const codeNodes = Array.from(document.querySelectorAll('pre > code'))
        .filter((code) => getMdKindFromCodeElement(code) !== undefined);
    if (codeNodes.length === 0) {
        return;
    }

    const registry = createMarkdownRendererRegistry();
    for (let index = 0; index < codeNodes.length && index < manifest.length; index += 1) {
        const code = codeNodes[index];
        const pre = code.parentElement;
        if (!pre) {
            continue;
        }
        try {
            const result = await loadResult(manifest[index], inlineResults);
            const panel = registry.render(result as any);
            // Resolve wikilinks newly introduced by dynamic block renderers.
            applyWikilinks(panel, wikilinkIndex, iriAliasIndex, linkingEnabled);
            pre.replaceWith(panel);
        } catch (error) {
            const panel = document.createElement('div');
            panel.className = 'oml-md-result oml-md-result-error';
            const message = document.createElement('div');
            message.className = 'oml-md-result-message';
            message.textContent = `Render error: ${error instanceof Error ? error.message : String(error)}`;
            panel.appendChild(message);
            pre.replaceWith(panel);
        }
    }
}

setupDownloadHandler();

function ensureInitialContentLoaded(): void {
    const content = document.getElementById('oml-md-content');
    if (!content) {
        return;
    }
    if (content.childNodes.length > 0) {
        return;
    }
    const template = document.getElementById('oml-md-initial-content');
    if (!(template instanceof HTMLTemplateElement)) {
        return;
    }
    content.innerHTML = template.innerHTML;
}

async function bootstrapStaticRender(): Promise<void> {
    void pruneOldRuntimeCaches();
    // Hard gate first paint to prevent transient unhydrated flashes on reload,
    // even for pages generated before wrapper/boot-class support.
    const body = document.body;
    const previousBodyVisibility = body.style.visibility;
    body.style.visibility = 'hidden';

    // Always start from template content so code blocks are re-hydrated deterministically.
    // Full-page HTML cache can preserve stale interactive table state across reloads.
    ensureInitialContentLoaded();
    try {
        await applyExecutionResults();
        await Promise.all([
            applyJsBlocks(),
            applyPyBlocks(),
            applyRBlocks(),
        ]);
    } finally {
        document.documentElement.classList.remove('oml-md-booting');
        document.body.classList.remove('oml-md-booting');
        body.style.visibility = previousBodyVisibility || 'visible';
    }
}

// ── Shared types ─────────────────────────────────────────────────────────────

type QueryResult = {
    success: boolean;
    columns: string[];
    rows: Record<string, string>[];
    quads?: Array<{ subject: string; predicate: string; object: string; graph?: string }>;
    error?: string;
};

function getScriptSparqlCache(): Record<string, Record<string, QueryResult>> {
    return parseJsonNode<Record<string, Record<string, QueryResult>>>('oml-md-script-sparql-cache', {});
}

function getScriptIncludeCache(): Record<string, string> {
    return parseJsonNode<Record<string, string>>('oml-md-script-include-cache', {});
}

function extractIncludePaths(code: string): string[] {
    const re = /\binclude\s*\(\s*(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)'|`((?:[^`\\]|\\[\s\S])*)`)\s*\)/g;
    const seen = new Set<string>();
    let m: RegExpExecArray | null;
    while ((m = re.exec(code)) !== null) {
        seen.add(m[1] ?? m[2] ?? m[3]);
    }
    return [...seen];
}

function buildIncludePreamble(code: string, cache: Record<string, string>): string {
    const paths = extractIncludePaths(code);
    const parts = paths.map((p) => cache[p] ?? '').filter(Boolean);
    return parts.length > 0 ? parts.join('\n') + '\n' : '';
}

const PYTHON_PANEL_CACHE_STORAGE_KEY = 'oml-md-python-panel-cache-v2';
const R_PANEL_CACHE_STORAGE_KEY = 'oml-md-r-panel-cache-v2';
const JS_PANEL_CACHE_STORAGE_KEY = 'oml-md-js-panel-cache-v2';
const MAX_PYTHON_PANEL_CACHE_ENTRIES = 64;
const MAX_R_PANEL_CACHE_ENTRIES = 64;
const MAX_JS_PANEL_CACHE_ENTRIES = 64;
let pythonPanelHtmlCache = readPythonPanelCache();
let rPanelHtmlCache = readRPanelCache();
let jsPanelHtmlCache = readJsPanelCache();

function readPythonPanelCache(): Record<string, string> {
    try {
        const raw = window.localStorage.getItem(PYTHON_PANEL_CACHE_STORAGE_KEY);
        const parsed = raw ? JSON.parse(raw) as unknown : {};
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            return {};
        }
        const cache: Record<string, string> = {};
        for (const [key, value] of Object.entries(parsed)) {
            if (typeof value === 'string') {
                cache[key] = value;
            }
        }
        return cache;
    } catch {
        return {};
    }
}

function rememberPythonPanelCache(key: string, html: string): void {
    const nextEntries = Object.entries(pythonPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
    nextEntries.push([key, html]);
    while (nextEntries.length > MAX_PYTHON_PANEL_CACHE_ENTRIES) {
        nextEntries.shift();
    }
    pythonPanelHtmlCache = Object.fromEntries(nextEntries);
    try {
        window.localStorage.setItem(PYTHON_PANEL_CACHE_STORAGE_KEY, JSON.stringify(pythonPanelHtmlCache));
    } catch {
        // Ignore storage quota/availability errors; Python still renders normally.
    }
}

function readRPanelCache(): Record<string, string> {
    try {
        const raw = window.localStorage.getItem(R_PANEL_CACHE_STORAGE_KEY);
        const parsed = raw ? JSON.parse(raw) as unknown : {};
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            return {};
        }
        const cache: Record<string, string> = {};
        for (const [key, value] of Object.entries(parsed)) {
            if (typeof value === 'string') {
                cache[key] = value;
            }
        }
        return cache;
    } catch {
        return {};
    }
}

function rememberRPanelCache(key: string, html: string): void {
    const nextEntries = Object.entries(rPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
    nextEntries.push([key, html]);
    while (nextEntries.length > MAX_R_PANEL_CACHE_ENTRIES) {
        nextEntries.shift();
    }
    rPanelHtmlCache = Object.fromEntries(nextEntries);
    try {
        window.localStorage.setItem(R_PANEL_CACHE_STORAGE_KEY, JSON.stringify(rPanelHtmlCache));
    } catch {
        // Ignore storage quota/availability errors; R still renders normally.
    }
}

function readJsPanelCache(): Record<string, string> {
    try {
        const raw = window.localStorage.getItem(JS_PANEL_CACHE_STORAGE_KEY);
        const parsed = raw ? JSON.parse(raw) as unknown : {};
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            return {};
        }
        const cache: Record<string, string> = {};
        for (const [key, value] of Object.entries(parsed)) {
            if (typeof value === 'string') {
                cache[key] = value;
            }
        }
        return cache;
    } catch {
        return {};
    }
}

function rememberJsPanelCache(key: string, html: string): void {
    const nextEntries = Object.entries(jsPanelHtmlCache).filter(([entryKey]) => entryKey !== key);
    nextEntries.push([key, html]);
    while (nextEntries.length > MAX_JS_PANEL_CACHE_ENTRIES) {
        nextEntries.shift();
    }
    jsPanelHtmlCache = Object.fromEntries(nextEntries);
    try {
        window.localStorage.setItem(JS_PANEL_CACHE_STORAGE_KEY, JSON.stringify(jsPanelHtmlCache));
    } catch {
        // Ignore storage quota/availability errors; JavaScript still renders normally.
    }
}

function buildPythonPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
    const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
    return `python-v2|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
}

function buildRPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
    const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
    return `r-v3|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
}

function buildJsPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
    const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
    return `js|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
}

function hash32(input: string): string {
    let hash = 2166136261;
    for (let i = 0; i < input.length; i++) {
        hash ^= input.charCodeAt(i);
        hash = Math.imul(hash, 16777619);
    }
    return (hash >>> 0).toString(16);
}

function sparqlResultToHtmlTable(result: QueryResult): string {
    if (!result.success || result.rows.length === 0) {
        const msg = result.error ?? 'No results';
        return result.error
            ? `<p class="oml-md-js-error">${msg}</p>`
            : `<p class="oml-md-js-empty">No results</p>`;
    }
    const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    const header = result.columns.map((c) => `<th>${esc(c)}</th>`).join('');
    const body = result.rows.map((row) =>
        `<tr>${result.columns.map((c) => `<td>${esc(row[c] ?? '')}</td>`).join('')}</tr>`
    ).join('');
    return `<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`;
}

function unescapeRString(raw: string): string {
    return raw.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\'/g, "'").replace(/\\\\/g, '\\');
}

function applyJsQueryTemplate(sparql: string, params?: Record<string, unknown>): string {
    if (!params) {
        return sparql;
    }
    let out = sparql;
    for (const [name, value] of Object.entries(params)) {
        const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        out = out.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, 'g'), String(value ?? ''));
    }
    return out;
}

function applyRQueryTemplate(sparql: string, params: Record<string, string>): string {
    let out = sparql;
    for (const [name, value] of Object.entries(params)) {
        const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        out = out.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}`, 'g'), value);
    }
    return out;
}

function extractRStringAssignments(code: string): Map<string, string> {
    const assignments = new Map<string, string>();
    const re = /(?:^|[\r\n;])\s*([A-Za-z.][A-Za-z0-9._]*)\s*<-\s*(?:c\s*\(\s*)?(?:"((?:[^"\\]|\\[\s\S])*)"|'((?:[^'\\]|\\[\s\S])*)')\s*\)?/g;
    let m: RegExpExecArray | null;
    while ((m = re.exec(code)) !== null) {
        assignments.set(m[1]!, unescapeRString(m[2] ?? m[3] ?? ''));
    }
    return assignments;
}

function splitRTopLevelArgs(text: string): string[] {
    const parts: string[] = [];
    let start = 0;
    let depth = 0;
    let quote: "'" | '"' | null = null;
    let escaped = false;
    for (let i = 0; i < text.length; i += 1) {
        const ch = text[i]!;
        if (quote) {
            if (escaped) {
                escaped = false;
            } else if (ch === '\\') {
                escaped = true;
            } else if (ch === quote) {
                quote = null;
            }
            continue;
        }
        if (ch === '"' || ch === "'") {
            quote = ch as '"' | "'";
            continue;
        }
        if (ch === '(') {
            depth += 1;
            continue;
        }
        if (ch === ')') {
            depth = Math.max(0, depth - 1);
            continue;
        }
        if (ch === ',' && depth === 0) {
            parts.push(text.slice(start, i).trim());
            start = i + 1;
        }
    }
    const tail = text.slice(start).trim();
    if (tail) {
        parts.push(tail);
    }
    return parts;
}

function parseRQueryParamsArg(text: string, assignments: Map<string, string>): Record<string, string> | undefined {
    const trimmed = text.trim();
    const namedParts = trimmed.startsWith('list(') && trimmed.endsWith(')')
        ? splitRTopLevelArgs(trimmed.slice(5, -1))
        : splitRTopLevelArgs(trimmed);
    const params: Record<string, string> = {};
    let positionalIndex = 1;
    for (const part of namedParts) {
        const eq = part.indexOf('=');
        const key = eq >= 1 ? part.slice(0, eq).trim() : String(positionalIndex++);
        const valueExpr = eq >= 1 ? part.slice(eq + 1).trim() : part.trim();
        const literal = valueExpr.match(/^"((?:[^"\\]|\\[\s\S])*)"$/) ?? valueExpr.match(/^'((?:[^'\\]|\\[\s\S])*)'$/);
        if (literal) {
            params[key] = unescapeRString(literal[1] ?? '');
            continue;
        }
        if (/^[A-Za-z.][A-Za-z0-9._]*$/.test(valueExpr) && assignments.has(valueExpr)) {
            params[key] = assignments.get(valueExpr)!;
            continue;
        }
        return undefined;
    }
    return params;
}

type ResolvedRQueryCall = {
    start: number;
    end: number;
    resolvedSparql: string;
};

function resolveRQueryCalls(code: string): ResolvedRQueryCall[] {
    const calls: ResolvedRQueryCall[] = [];
    const assignments = extractRStringAssignments(code);
    const callRe = /\bquery\s*\(([\s\S]*?)\)/g;
    let match: RegExpExecArray | null;
    while ((match = callRe.exec(code)) !== null) {
        const inside = match[1] ?? '';
        const args = splitRTopLevelArgs(inside);
        if (args.length === 0) {
            continue;
        }
        const first = args[0]!;
        const literal = first.match(/^"((?:[^"\\]|\\[\s\S])*)"$/) ?? first.match(/^'((?:[^'\\]|\\[\s\S])*)'$/);
        let sparql: string | undefined;
        if (literal) {
            sparql = unescapeRString(literal[1] ?? '');
        } else if (/^[A-Za-z.][A-Za-z0-9._]*$/.test(first)) {
            sparql = assignments.get(first);
        }
        if (sparql === undefined) {
            continue;
        }
        const paramsParts = args.slice(1);
        let params: Record<string, string> = {};
        if (paramsParts.length > 0) {
            const parsed = parseRQueryParamsArg(paramsParts.join(','), assignments);
            if (!parsed) {
                continue;
            }
            params = parsed;
        }
        calls.push({
            start: match.index,
            end: match.index + match[0].length,
            resolvedSparql: applyRQueryTemplate(sparql, params),
        });
    }
    return calls;
}

function extractRQueryStrings(code: string): string[] {
    const seen = new Set<string>();
    for (const call of resolveRQueryCalls(code)) {
        seen.add(call.resolvedSparql);
    }
    return [...seen];
}

function rewriteRQueryCalls(code: string, queryMap: Map<string, string>): string {
    const calls = resolveRQueryCalls(code);
    if (calls.length === 0) {
        return code;
    }
    let out = '';
    let cursor = 0;
    for (const call of calls) {
        out += code.slice(cursor, call.start);
        out += queryMap.get(call.resolvedSparql) ?? 'data.frame()';
        cursor = call.end;
    }
    out += code.slice(cursor);
    return out;
}

function makeScriptPanel(): HTMLDivElement {
    const panel = document.createElement('div');
    panel.className = 'oml-md-js-result';
    return panel;
}

function appendResult(
    panel: HTMLElement,
    result: { error?: string },
    showEmptyOutput = true
): void {
    if (result.error) {
        const el = document.createElement('div');
        el.className = 'oml-md-js-error';
        el.textContent = result.error;
        panel.appendChild(el);
    }
    if (showEmptyOutput && panel.childElementCount === 0) {
        const el = document.createElement('div');
        el.className = 'oml-md-js-empty';
        el.textContent = '(no output)';
        panel.appendChild(el);
    }
}

// ── JavaScript blocks ─────────────────────────────────────────────────────────

const loadedScripts = new Set<string>();

async function loadScript(url: string): Promise<void> {
    if (loadedScripts.has(url)) {
        return;
    }
    await new Promise<void>((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = () => { loadedScripts.add(url); resolve(); };
        script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
        document.head.appendChild(script);
    });
}

function isPyodideNoise(text: string): boolean {
    return /^(Loading|Loaded|Unloading) /.test(text)
        || text.includes('already loaded from')
        || text.includes('channel');
}

async function executeJsCode(
    code: string,
    blockCache: Record<string, QueryResult>,
    container: HTMLElement
): Promise<{ error?: string }> {
    let statusEl: HTMLElement | undefined = document.createElement('div');
    statusEl.className = 'oml-md-js-text-output';
    statusEl.textContent = 'Running JavaScript block…';
    container.appendChild(statusEl);
    const clearStatus = (): void => {
        if (statusEl?.parentElement === container) {
            container.removeChild(statusEl);
        }
        statusEl = undefined;
    };
    const appendHtml = (html: string): void => {
        clearStatus();
        const el = document.createElement('div');
        el.className = 'oml-md-js-html-output';
        el.style.color = window.getComputedStyle(document.body).color;
        el.innerHTML = expandWikilinksInHtml(html);
        container.appendChild(el);
    };
    const appendText = (text: string): void => {
        clearStatus();
        const pre = document.createElement('pre');
        pre.className = 'oml-md-js-text-output';
        pre.textContent = text;
        container.appendChild(pre);
    };
    const display = (content: unknown): void => {
        if (typeof content === 'string') {
            appendHtml(content);
        } else if (content instanceof Node) {
            clearStatus();
            const el = document.createElement('div');
            el.className = 'oml-md-js-html-output';
            el.style.color = window.getComputedStyle(document.body).color;
            el.appendChild(content);
            container.appendChild(el);
        } else {
            appendText(String(content));
        }
    };
    const query = async (sparql: string, params?: Record<string, unknown>): Promise<QueryResult> => {
        const resolved = applyJsQueryTemplate(sparql, params);
        return blockCache[resolved] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched for this query' };
    };
    const table = (result: QueryResult): string => sparqlResultToHtmlTable(result);
    const load = async (url: string): Promise<void> => loadScript(url);
    const update = async (_ops: unknown): Promise<{ errors?: unknown[] }> => ({});
    const interactive = (_html: string, _ops: unknown): string => '';
    const includeCache = getScriptIncludeCache();
    const includePreamble = buildIncludePreamble(code, includeCache);

    const savedLog = console.log;
    const savedWarn = console.warn;
    const savedError = console.error;
    console.log = (...args: unknown[]): void => {
        const text = args.map((v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))).join(' ');
        if (!isPyodideNoise(text)) { appendText(text); }
    };
    console.warn = console.log;
    console.error = (...args: unknown[]): void => {
        const text = '[error] ' + args.map((v) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))).join(' ');
        if (!isPyodideNoise(text)) { appendText(text); }
    };
    try {
        const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => (...a: unknown[]) => Promise<void>;
        const include = async (_path: string): Promise<void> => { /* content already prepended */ };
        const finalCode = includePreamble ? includePreamble + code : code;
        const fn = new AsyncFunction('display', 'query', 'table', 'load', 'update', 'interactive', 'include', finalCode);
        await fn(display, query, table, load, update, interactive, include);
        clearStatus();
        return {};
    } catch (error) {
        clearStatus();
        return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
    } finally {
        console.log = savedLog;
        console.warn = savedWarn;
        console.error = savedError;
    }
}

async function applyJsBlocks(): Promise<void> {
    const cache = getScriptSparqlCache();
    const codeNodes = Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-javascript, pre > code.language-js'));
    const sourceBlocks = codeNodes
        .map((code) => code.parentElement)
        .filter((pre): pre is HTMLElement => pre instanceof HTMLElement);
    for (const pre of sourceBlocks) {
        pre.style.visibility = 'hidden';
    }
    try {
        for (const code of codeNodes) {
            const pre = code.parentElement as HTMLElement;
            const blockId = pre.dataset.omlBlockId ?? '';
            const blockCache = cache[blockId] ?? {};
            const source = code.textContent ?? '';
            const cacheKey = buildJsPanelCacheKey(blockId, source, blockCache);
            const cachedHtml = jsPanelHtmlCache[cacheKey];
            if (typeof cachedHtml === 'string') {
                const panel = makeScriptPanel();
                panel.innerHTML = cachedHtml;
                pre.replaceWith(panel);
                continue;
            }
            const panel = makeScriptPanel();
            pre.replaceWith(panel);
            const result = await executeJsCode(source, blockCache, panel);
            if (!panel.isConnected) {
                continue;
            }
            appendResult(panel, result);
            if (!result.error && !panel.querySelector('.oml-md-js-empty')) {
                rememberJsPanelCache(cacheKey, panel.innerHTML);
            }
        }
    } finally {
        for (const pre of sourceBlocks) {
            if (pre.isConnected) {
                pre.style.visibility = '';
            }
        }
    }
}

// ── Runtime WASM cache ────────────────────────────────────────────────────────
//
// Pyodide runs entirely on the main thread, so patching globalThis.fetch lets us
// intercept and cache every resource it fetches (WASM, Python stdlib, packages).
//
// WebR spawns a Web Worker for R's WASM execution; those fetches happen inside
// the worker and are NOT visible to a main-thread fetch patch. Caching WebR's
// heavy assets would require a Service Worker. The interceptor below still covers
// any resources WebR fetches from the main thread (e.g. the .mjs entry module
// when future WebR versions do so), and the infrastructure is ready if a SW is
// added later.
//
// Version bump: update PYODIDE_VERSION / WEBR_VERSION constants. The old versioned
// cache is automatically deleted by pruneOldRuntimeCaches() on the next page load.

const PYODIDE_VERSION = 'v0.27.0';
const WEBR_VERSION = 'v0.4.2';
const PYODIDE_CDN_BASE = `https://cdn.jsdelivr.net/pyodide/${PYODIDE_VERSION}/full`;
const WEBR_CDN_BASE = `https://webr.r-wasm.org/${WEBR_VERSION}`;
const PYODIDE_CACHE_NAME = `oml-pyodide-${PYODIDE_VERSION}`;
const WEBR_CACHE_NAME = `oml-webr-${WEBR_VERSION}`;
const RUNTIME_CACHE_PREFIXES = ['oml-pyodide-', 'oml-webr-'];

// Registry mapping CDN URL prefix → Cache API cache name. The patched fetch
// below consults this map so multiple runtimes can be intercepted concurrently
// without re-patching / un-patching (which would race when both load in parallel).
const runtimeCacheInterceptors = new Map<string, string>();
const _originalFetch: typeof fetch = globalThis.fetch.bind(globalThis);

if ('caches' in globalThis) {
    globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
        const url = typeof input === 'string' ? input
            : input instanceof URL ? input.href
            : (input as Request).url;
        for (const [prefix, cacheName] of runtimeCacheInterceptors) {
            if (url.startsWith(prefix)) {
                try {
                    const cache = await caches.open(cacheName);
                    const hit = await cache.match(url);
                    if (hit) { return hit; }
                    const response = await _originalFetch(input as RequestInfo, init);
                    if (response.ok) { await cache.put(url, response.clone()); }
                    return response;
                } catch {
                    break;
                }
            }
        }
        return _originalFetch(input as RequestInfo, init);
    };
}

async function pruneOldRuntimeCaches(): Promise<void> {
    if (!('caches' in globalThis)) { return; }
    const current = new Set([PYODIDE_CACHE_NAME, WEBR_CACHE_NAME]);
    try {
        const all = await caches.keys();
        await Promise.all(
            all
                .filter((n) => RUNTIME_CACHE_PREFIXES.some((p) => n.startsWith(p)) && !current.has(n))
                .map((n) => caches.delete(n))
        );
    } catch { /* best-effort */ }
}

// ── Python (Pyodide) blocks ───────────────────────────────────────────────────

interface PyodideInterface {
    runPythonAsync(code: string): Promise<unknown>;
    globals: { set(name: string, value: unknown): void };
    setStdout(opts: { batched: (msg: string) => void }): void;
    setStderr(opts: { batched: (msg: string) => void }): void;
    loadPackage(pkg: string | string[]): Promise<void>;
}

let pyodideInstance: PyodideInterface | null = null;
let pyodideLoading: Promise<PyodideInterface> | null = null;

async function getPyodide(): Promise<PyodideInterface> {
    if (pyodideInstance) { return pyodideInstance; }
    if (pyodideLoading) { return pyodideLoading; }
    pyodideLoading = (async () => {
        // Register the CDN interceptor before any fetches so that loadPyodide()'s
        // internal WASM + stdlib requests are served from / stored in the Cache API.
        runtimeCacheInterceptors.set(PYODIDE_CDN_BASE, PYODIDE_CACHE_NAME);
        // Use a variable for the import URL so esbuild does not try to bundle it.
        // Avoid new Function('url','return import(url)') — Firefox throws
        // "URL constructor: null is not a valid URL" for import() inside new Function
        // on file:// pages because Firefox resolves the URL against a null base.
        const pyodideUrl = `${PYODIDE_CDN_BASE}/pyodide.mjs`;
        const { loadPyodide } = await import(pyodideUrl) as unknown as { loadPyodide: (opts: { indexURL: string }) => Promise<PyodideInterface> };
        const py = await loadPyodide({ indexURL: `${PYODIDE_CDN_BASE}/` });
        await py.loadPackage('micropip');
        pyodideInstance = py;
        return py;
    })();
    return pyodideLoading;
}

const PY_BOOTSTRAP = `
def display(html):
    _js_display(str(html))

async def query(sparql, params=None, **kwargs):
    merged = dict(params) if params else {}
    merged.update(kwargs)
    for name, value in merged.items():
        sparql = sparql.replace('{{' + name + '}}', str(value))
    result = _js_query(sparql)
    return result.to_py() if hasattr(result, 'to_py') else result

async def load(package):
    import micropip as _micropip
    await _micropip.install(package)

def table(data):
    rows, columns = [], []
    if isinstance(data, dict) and 'rows' in data:
        rows = data['rows']
        columns = data.get('columns', list(rows[0].keys()) if rows else [])
    elif isinstance(data, list) and data and isinstance(data[0], dict):
        rows, columns = data, list(data[0].keys())
    else:
        try:
            import pandas as _pd
            if isinstance(data, _pd.DataFrame):
                columns, rows = list(data.columns), data.to_dict('records')
        except ImportError:
            pass
    if not rows or not columns:
        display(str(data))
        return
    def _e(s):
        return str(s).replace('&','&amp;').replace('<','&lt;').replace('>','&gt;')
    hdr = ''.join(f'<th>{_e(c)}</th>' for c in columns)
    bdy = ''.join(
        f'<tr>{"".join(f"<td>{_e(r.get(c,"") if isinstance(r,dict) else "")}</td>" for c in columns)}</tr>'
        for r in rows
    )
    display(f'<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>{hdr}</tr></thead><tbody>{bdy}</tbody></table></div>')

async def update(_operations):
    return {}

def interactive(_html, _operations):
    return ''

def include(_path):
    pass

async def js(code, **kwargs):
    code = code.replace('{{', '\x01').replace('}}', '\x02')
    for k, v in kwargs.items():
        code = code.replace(f'{{{k}}}', str(v))
    code = code.replace('\x01', '{').replace('\x02', '}')
    result = await _js_eval(code)
    return result.to_py() if hasattr(result, 'to_py') else result
`;

let pyodideExecQueue: Promise<unknown> = Promise.resolve();

async function executePyCode(
    code: string,
    blockCache: Record<string, QueryResult>,
    container: HTMLElement
): Promise<{ error?: string }> {
    let resolve!: (r: { error?: string }) => void;
    const queued = new Promise<{ error?: string }>((res) => { resolve = res; });
    pyodideExecQueue = pyodideExecQueue.then(() => _executePyCode(code, blockCache, container).then(resolve, (e) => resolve({ error: String(e) })));
    return queued;
}

async function _executePyCode(
    code: string,
    blockCache: Record<string, QueryResult>,
    container: HTMLElement
): Promise<{ error?: string }> {
    if (!container.isConnected) { return {}; }
    let statusEl: HTMLElement | undefined = document.createElement('div');
    statusEl.className = 'oml-md-js-text-output';
    statusEl.textContent = 'Running Python block…';
    container.appendChild(statusEl);
    const clearStatus = (): void => {
        if (statusEl?.parentElement === container) {
            container.removeChild(statusEl);
        }
        statusEl = undefined;
    };
    const appendHtml = (html: string): void => {
        clearStatus();
        const el = document.createElement('div');
        el.className = 'oml-md-js-html-output';
        el.style.color = window.getComputedStyle(document.body).color;
        el.innerHTML = expandWikilinksInHtml(html);
        container.appendChild(el);
    };
    const appendText = (text: string): void => {
        clearStatus();
        const pre = document.createElement('pre');
        pre.className = 'oml-md-js-text-output';
        pre.textContent = text;
        container.appendChild(pre);
    };
    let pyodide: PyodideInterface;
    try {
        pyodide = await getPyodide();
        // Keep a single stable status message until output appears.
    } catch (e) {
        clearStatus();
        return { error: `Failed to load Pyodide: ${e instanceof Error ? e.message : String(e)}` };
    }
    try {
        await pyodide.runPythonAsync(PY_BOOTSTRAP);
    } catch (e) {
        clearStatus();
        return { error: `Python bootstrap failed: ${e instanceof Error ? e.message : String(e)}` };
    }
    pyodide.globals.set('_js_display', appendHtml);
    pyodide.globals.set('_js_query', (sparql: string) =>
        blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' });
    pyodide.globals.set('_js_update', async (_opsJson: string) => ({}));
    pyodide.globals.set('_js_eval', async (code: string) => {
        // eslint-disable-next-line no-new-func
        const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => () => Promise<unknown>;
        return new AsyncFunction(code)();
    });
    pyodide.setStdout({ batched: appendText });
    pyodide.setStderr({ batched: () => { /* suppress library noise */ } });
    const preamble = buildIncludePreamble(code, getScriptIncludeCache());
    try {
        await pyodide.runPythonAsync(preamble ? preamble + code : code);
        clearStatus();
        return {};
    } catch (error) {
        clearStatus();
        return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
    }
}

async function applyPyBlocks(): Promise<void> {
    const cache = getScriptSparqlCache();
    for (const code of Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-python'))) {
        const pre = code.parentElement as HTMLElement;
        const blockId = pre.dataset.omlBlockId ?? '';
        const blockCache = cache[blockId] ?? {};
        const source = code.textContent ?? '';
        const cacheKey = buildPythonPanelCacheKey(blockId, source, blockCache);
        const cachedHtml = pythonPanelHtmlCache[cacheKey];
        if (typeof cachedHtml === 'string') {
            const panel = makeScriptPanel();
            panel.innerHTML = cachedHtml;
            pre.replaceWith(panel);
            continue;
        }
        const panel = makeScriptPanel();
        pre.replaceWith(panel);
        const result = await executePyCode(source, blockCache, panel);
        if (!panel.isConnected) {
            continue;
        }
        appendResult(panel, result);
        if (!result.error) {
            rememberPythonPanelCache(cacheKey, panel.innerHTML);
        }
    }
}

// ── R (WebR) blocks ───────────────────────────────────────────────────────────

interface WebRInterface {
    init(): Promise<void>;
    evalRVoid(code: string): Promise<void>;
    evalRString(code: string): Promise<string>;
}

type WebRModule = {
    WebR: new (options?: { baseUrl?: string; channelType?: unknown }) => WebRInterface;
    ChannelType: {
        SharedArrayBuffer: unknown;
        PostMessage: unknown;
    };
};

let webRInstance: WebRInterface | null = null;
let webRLoading: Promise<WebRInterface> | null = null;

async function getWebR(): Promise<WebRInterface> {
    if (webRInstance) { return webRInstance; }
    if (webRLoading) { return webRLoading; }
    webRLoading = (async () => {
        // Register the interceptor for any resources WebR fetches from the main
        // thread. Note: R's WASM is loaded inside a Web Worker, so those fetches
        // are not visible here and are not cached. Full offline support for WebR
        // would require a Service Worker to intercept worker-origin requests.
        runtimeCacheInterceptors.set(WEBR_CDN_BASE, WEBR_CACHE_NAME);
        // Use a variable for the import URL so esbuild does not try to bundle it.
        const webRUrl = `${WEBR_CDN_BASE}/webr.mjs`;
        const { WebR: WebRClass, ChannelType } = await import(webRUrl) as unknown as WebRModule;
        const webR = new WebRClass({
            baseUrl: `${WEBR_CDN_BASE}/`,
            channelType: canUseWebRAsyncQueryBridge()
                ? ChannelType.SharedArrayBuffer
                : ChannelType.PostMessage,
        });
        await webR.init();
        webRInstance = webR;
        return webR;
    })();
    return webRLoading;
}

const R_BOOTSTRAP = `
.oml_output_events_ <- character(0)

.pct_encode_ <- function(s) {
    bytes <- as.integer(charToRaw(enc2utf8(s)))
    paste0(sapply(bytes, function(b) {
        if ((b >= 65 && b <= 90) || (b >= 97 && b <= 122) || (b >= 48 && b <= 57) ||
            b == 45 || b == 95 || b == 46 || b == 33 || b == 126 ||
            b == 42 || b == 39 || b == 40 || b == 41) {
            rawToChar(as.raw(b))
        } else {
            sprintf('%%%02X', b)
        }
    }), collapse='')
}

.oml_emit_output_ <- function(kind, value) {
    .oml_output_events_ <<- c(
        .oml_output_events_,
        paste0(kind, ':', .pct_encode_(paste(as.character(value), collapse='')))
    )
    invisible(NULL)
}

display <- function(html) {
    .oml_emit_output_('display', html)
}
table <- function(data) {
    if (is.data.frame(data) || is.matrix(data)) {
        df <- as.data.frame(data)
        cols <- colnames(df)
        hdr <- paste0('<th>', cols, '</th>', collapse='')
        rows <- apply(df, 1, function(r) {
            cells <- paste0('<td>', r, '</td>', collapse='')
            paste0('<tr>', cells, '</tr>')
        })
        bdy <- paste(rows, collapse='')
        paste0('<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>',
               hdr, '</tr></thead><tbody>', bdy, '</tbody></table></div>')
    } else {
        paste(capture.output(print(data)), collapse='\\n')
    }
}
load <- function(pkg) {
    if (!requireNamespace(pkg, quietly=TRUE)) { webr::install(pkg) }
    library(pkg, character.only=TRUE)
}

.oml_to_json_ <- function(x) {
    if (is.list(x) && !is.null(names(x))) {
        pairs <- mapply(function(k, v) paste0('"', k, '":', .oml_to_json_(v)), names(x), x, SIMPLIFY=TRUE)
        paste0('{', paste(pairs, collapse=','), '}')
    } else if (is.list(x)) {
        paste0('[', paste(sapply(x, .oml_to_json_), collapse=','), ']')
    } else if (is.null(x) || length(x) == 0) {
        'null'
    } else if (length(x) > 1) {
        paste0('[', paste(sapply(as.list(x), .oml_to_json_), collapse=','), ']')
    } else if (anyNA(x)) {
        'null'
    } else if (is.character(x)) {
        paste0('"', gsub('\\\\', '\\\\\\\\', gsub('"', '\\\\"', x, fixed=TRUE), fixed=TRUE), '"')
    } else if (is.numeric(x) || is.integer(x)) {
        if (is.finite(x)) as.character(x) else 'null'
    } else if (is.logical(x)) {
        tolower(as.character(x))
    } else {
        paste0('"', as.character(x), '"')
    }
}

query <- function(sparql, ...) {
    dots <- list(...)
    if (length(dots) == 1 && is.list(dots[[1]]) &&
        (is.null(names(dots)) || length(names(dots)) == 0 || !nzchar(names(dots)[1]))) {
        dots <- dots[[1]]
    }
    if (length(dots) > 0) {
        dotNames <- names(dots)
        for (i in seq_along(dots)) {
            key <- if (!is.null(dotNames) && length(dotNames) >= i && nzchar(dotNames[i])) dotNames[i] else as.character(i)
            sparql <- gsub(paste0("{{", key, "}}"), paste(as.character(dots[[i]]), collapse=''), sparql, fixed=TRUE)
        }
    }
    if (!isTRUE(getOption('oml.async.query', FALSE))) {
        stop('query(...) requires a literal query or a string variable assigned to a literal query in this webview environment')
    }
    sparql <- paste(as.character(sparql), collapse='')
    expression <- webr::eval_js(
        paste0('globalThis.__omlRQueryDataFrameExpression(', .oml_to_json_(sparql), ')'),
        await=TRUE
    )
    eval(parse(text=expression), envir=parent.frame())
}

interactive <- function(html, operations) ''
include <- function(path) invisible(NULL)
js <- function(code, ...) {
    args <- list(...)
    code <- gsub('{{', '\x01', code, fixed=TRUE)
    code <- gsub('}}', '\x02', code, fixed=TRUE)
    for (nm in names(args)) {
        code <- gsub(paste0('{', nm, '}'), as.character(args[[nm]]), code, fixed=TRUE)
    }
    code <- gsub('\x01', '{', code, fixed=TRUE)
    code <- gsub('\x02', '}', code, fixed=TRUE)
    .oml_emit_output_('js', code)
}
`;

function canUseWebRAsyncQueryBridge(): boolean {
    return globalThis.crossOriginIsolated === true && typeof SharedArrayBuffer !== 'undefined';
}

async function replayROutputEvents(
    rawEvents: string,
    appendHtml: (html: string) => void,
    runJs: (code: string) => Promise<void>
): Promise<void> {
    for (const line of rawEvents.split('\n')) {
        if (!line) {
            continue;
        }
        const sep = line.indexOf(':');
        if (sep < 0) {
            throw new Error(`Malformed R output event: ${line}`);
        }
        const kind = line.slice(0, sep);
        const value = decodeURIComponent(line.slice(sep + 1));
        if (kind === 'display') {
            appendHtml(value);
        } else if (kind === 'js') {
            await runJs(value);
        } else {
            throw new Error(`Unknown R output event: ${kind}`);
        }
    }
}

function buildRDataFrameExpression(result: QueryResult): string {
    const rStr = (s: string): string =>
        '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '') + '"';
    if (!result.success) {
        return `(function() stop(${rStr(result.error ?? 'SPARQL query failed')}))()`;
    }
    if (result.rows.length === 0) {
        const colDefs = result.columns.map((c) => `  ${rStr(c)} = character(0)`).join(',\n');
        return `data.frame(${colDefs ? '\n' + colDefs + ',\n' : ''}  stringsAsFactors = FALSE, check.names = FALSE)`;
    }
    const colDefs = result.columns.map((col) => {
        const vals = result.rows.map((row) => rStr(String(row[col] ?? ''))).join(', ');
        return `  ${rStr(col)} = c(${vals})`;
    }).join(',\n');
    return `data.frame(\n${colDefs},\n  stringsAsFactors = FALSE, check.names = FALSE\n)`;
}

function buildRDataFrameCode(result: QueryResult, varName: string): string {
    return `${varName} <- ${buildRDataFrameExpression(result)}`;
}

async function executeRCode(
    code: string,
    blockCache: Record<string, QueryResult>,
    container: HTMLElement
): Promise<{ error?: string }> {
    let statusEl: HTMLElement | undefined = document.createElement('div');
    statusEl.className = 'oml-md-js-text-output';
    statusEl.setAttribute('data-r-status', '');
    statusEl.textContent = 'Running R block…';
    container.appendChild(statusEl);
    const clearStatus = (): void => {
        if (statusEl?.parentElement === container) {
            container.removeChild(statusEl);
        }
        statusEl = undefined;
    };
    const appendHtml = (html: string): void => {
        clearStatus();
        const el = document.createElement('div');
        el.className = 'oml-md-js-html-output';
        el.style.color = window.getComputedStyle(document.body).color;
        el.innerHTML = expandWikilinksInHtml(html);
        container.appendChild(el);
    };
    const appendText = (text: string): void => {
        clearStatus();
        const pre = document.createElement('pre');
        pre.className = 'oml-md-js-text-output';
        pre.textContent = text;
        container.appendChild(pre);
    };
    const display = (content: unknown): void => {
        if (typeof content === 'string') {
            appendHtml(content);
        } else if (content instanceof Node) {
            clearStatus();
            const el = document.createElement('div');
            el.className = 'oml-md-js-html-output';
            el.style.color = window.getComputedStyle(document.body).color;
            el.appendChild(content);
            container.appendChild(el);
        } else {
            appendText(String(content));
        }
    };
    const runJs = async (jsCode: string): Promise<void> => {
        const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => (...a: unknown[]) => Promise<void>;
        const query = async (sparql: string): Promise<QueryResult> =>
            blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched for this query' };
        const table = (result: QueryResult): string => sparqlResultToHtmlTable(result);
        const load = async (url: string): Promise<void> => loadScript(url);
        const update = async (_ops: unknown): Promise<{ errors?: unknown[] }> => ({});
        const interactive = (_html: string, _ops: unknown): string => '';
        const include = async (_path: string): Promise<void> => { /* R js() cannot include additional files after R execution starts. */ };
        const fn = new AsyncFunction('display', 'query', 'table', 'load', 'update', 'interactive', 'include', jsCode);
        await fn(display, query, table, load, update, interactive, include);
    };
    let webR: WebRInterface;
    try {
        webR = await getWebR();
    } catch (e) {
        clearStatus();
        const msg = e instanceof Error ? e.message : String(e);
        if (window.location.protocol === 'file:' && /URL constructor.*null/i.test(msg)) {
            return { error: 'WebR cannot run when this page is opened directly from the local filesystem (file://) in this browser, because Web Workers cannot be created from a null origin. Serve the rendered output over HTTP (e.g. `npx http-server`) or open it in Chrome.' };
        }
        return { error: `Failed to load WebR: ${msg}` };
    }
    container.setAttribute('data-oml-r-target', 'true');
    try {
        await webR.evalRVoid(R_BOOTSTRAP);
    } catch (e) {
        clearStatus();
        return { error: `R bootstrap failed: ${e instanceof Error ? e.message : String(e)}` };
    } finally {
        container.removeAttribute('data-oml-r-target');
    }
    const includePreamble = buildIncludePreamble(code, getScriptIncludeCache());
    const useAsyncRQuery = canUseWebRAsyncQueryBridge();
    let finalCode = code;
    if (useAsyncRQuery) {
        finalCode = ['options(oml.async.query=TRUE)', includePreamble, code].filter(Boolean).join('\n');
    } else {
        const queryStrings = extractRQueryStrings(code);
        const prefetchResults = queryStrings.map((sparql, i) => {
            const varName = `.oml_q${i}_`;
            const result = blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' };
            return { sparql, varName, preamble: buildRDataFrameCode(result, varName) };
        });
        const queryMap = new Map(prefetchResults.map((r) => [r.sparql, r.varName] as [string, string]));
        finalCode = [
            includePreamble,
            prefetchResults.map((r) => r.preamble).join('\n'),
            rewriteRQueryCalls(code, queryMap),
        ].filter(Boolean).join('\n');
    }
    const rGlobal = globalThis as typeof globalThis & {
        __omlRQueryDataFrameExpression?: (sparql: string) => Promise<string> | string;
    };
    const previousRQuery = rGlobal.__omlRQueryDataFrameExpression;
    if (useAsyncRQuery) {
        rGlobal.__omlRQueryDataFrameExpression = (sparql: string): string =>
            buildRDataFrameExpression(blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' });
    }
    container.setAttribute('data-oml-r-target', 'true');
    try {
        await webR.evalRVoid(
            '.oml_buf_ <- character(0)\n' +
            'tryCatch({\n' +
            finalCode + '\n' +
            '}, error = function(e) { .oml_buf_ <<- c(.oml_buf_, paste0("Error: ", conditionMessage(e))) })\n'
        );
        const outputEvents = await webR.evalRString('paste(.oml_output_events_, collapse="\\n")');
        await replayROutputEvents(outputEvents, appendHtml, runJs);
        const errors = await webR.evalRString('paste(.oml_buf_, collapse="")');
        if (errors.trim()) { appendText(errors.trim()); }
        clearStatus();
        return {};
    } catch (error) {
        clearStatus();
        return { error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
    } finally {
        container.removeAttribute('data-oml-r-target');
        if (useAsyncRQuery) {
            if (previousRQuery) {
                rGlobal.__omlRQueryDataFrameExpression = previousRQuery;
            } else {
                delete rGlobal.__omlRQueryDataFrameExpression;
            }
        }
    }
}

async function applyRBlocks(): Promise<void> {
    const cache = getScriptSparqlCache();
    for (const code of Array.from(document.querySelectorAll<HTMLElement>('pre > code.language-r'))) {
        const pre = code.parentElement as HTMLElement;
        const blockId = pre.dataset.omlBlockId ?? '';
        const blockCache = cache[blockId] ?? {};
        const source = code.textContent ?? '';
        const cacheKey = buildRPanelCacheKey(blockId, source, blockCache);
        const cachedHtml = rPanelHtmlCache[cacheKey];
        if (typeof cachedHtml === 'string' && cachedHtml.trim().length > 0) {
            const panel = makeScriptPanel();
            panel.innerHTML = cachedHtml;
            pre.replaceWith(panel);
            continue;
        }
        const panel = makeScriptPanel();
        pre.replaceWith(panel);
        const result = await executeRCode(source, blockCache, panel);
        if (!panel.isConnected) {
            continue;
        }
        appendResult(panel, result);
        if (!result.error && panel.innerHTML.trim().length > 0) {
            rememberRPanelCache(cacheKey, panel.innerHTML);
        }
    }
}

void bootstrapStaticRender();
