import { useCoreServices } from '@learnitive/context';
import { encodeProjectZip, projectDirectoryPath, projectFilePath, type ProjectArchiveFile } from '@learnitive/project-storage';
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, toast } from '@learnitive/ui-components';
import { AlertTriangle, Bot, ChevronLeft, ChevronRight, Download, Eye, FileText, History, ListTree, LoaderCircle, PanelLeftClose, PanelLeftOpen, Printer, RotateCcw, Save, Search, Settings2, Sparkles } from 'lucide-react';
import React from 'react';
import { openAiSettingsDialog } from '../components/ai-settings-events';
import { getAuthState } from '../saas/saas-api';
import { saveZipStream } from '../components/project-zip-actions';
import { createInteractiveContentTools } from '../learn/editor/editor-tools';
import { collectInteractiveContentAssetReferences, interactiveContentIssues, interactiveContentToMarkdown, normalizeInteractiveContentDocument } from './document';
import type { InteractiveContentEditorAdapter, InteractiveContentEditorMedia, InteractiveContentMediaKind, InteractiveContentRunInput } from './editor-adapter';
import { InteractiveContentEditor, type InteractiveContentAiContext, type InteractiveContentEditorController } from './editor';
import { INTERACTIVE_DOC_BLOCK_PROFILE } from './profiles';
import { InteractiveContentRenderer } from './renderer';
import type { InteractiveContentBlock, InteractiveContentDocument } from './types';
import { interactiveDocSaveStatus } from './save-status';

const DOCUMENT_PATH = projectFilePath('document.interactive.json');
const ASSETS_DIRECTORY = projectDirectoryPath('assets');
const ASSET_PREFIX = 'project-asset:';
const AUTOSAVE_MS = 900;
const RECOVERY_CHECKPOINT_MAX_BYTES = 4 * 1024 * 1024;
const ASSET_LIMITS:Record<InteractiveContentMediaKind,number>={image:25*1024*1024,video:250*1024*1024,caption:5*1024*1024,audio:100*1024*1024,file:100*1024*1024};

const INTERACTIVE_DOC_PDF_BASE_CSS=`
html { font-family: Inter, "DejaVu Sans", Arial, sans-serif; color:#172033; font-size:10.5pt; line-height:1.58; }
body { margin:0; }
h1 { color:#06233f; font-size:25pt; line-height:1.12; margin:0 0 18pt; padding-bottom:10pt; border-bottom:2px solid #f2c230; }
h2,h3,h4 { color:#082b4c; break-after:avoid; page-break-after:avoid; margin-top:18pt; margin-bottom:7pt; line-height:1.2; }
h2 { font-size:17pt; } h3 { font-size:13.5pt; } h4 { font-size:11.5pt; }
p,ul,ol,blockquote,table,pre { margin-top:0; margin-bottom:9pt; }
a { color:#145ca8; text-decoration:none; overflow-wrap:anywhere; }
img { max-width:100%; height:auto; }
figure,pre,table,blockquote { break-inside:avoid; page-break-inside:avoid; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; background:#0e1724; color:#f8fafc; padding:11pt 12pt; border-radius:7pt; font:8.8pt/1.5 "DejaVu Sans Mono", monospace; }
code { font-family:"DejaVu Sans Mono",monospace; }
blockquote { border-left:3pt solid #f2c230; margin-left:0; padding:7pt 11pt; background:#fff9e8; color:#344054; }
table { width:100%; border-collapse:collapse; font-size:9.5pt; }
th,td { border:0.6pt solid #d0d5dd; padding:5pt 6pt; vertical-align:top; }
th { background:#f5f1e8; color:#082b4c; text-align:left; }
hr { border:0; border-top:1pt solid #d0d5dd; margin:16pt 0; }
`;

function interactiveDocPdfCss(includeBranding:boolean):string{
  const footer=includeBranding?'Created with Learnitive · ':'';
  return `@page { size: A4; margin: 20mm 18mm 22mm; @bottom-right { content: "${footer}" counter(page); color:#667085; font-size:9pt; } }\n${INTERACTIVE_DOC_PDF_BASE_CSS}`;
}


function validateAssetUpload(kind:InteractiveContentMediaKind,file:File):void{
  const limit=ASSET_LIMITS[kind];if(file.size>limit)throw new Error(`${kind[0].toUpperCase()+kind.slice(1)} assets must be ${Math.round(limit/1024/1024)} MB or smaller.`);
  const mime=(file.type||'').toLowerCase();
  if(kind==='image'&&mime&&!mime.startsWith('image/'))throw new Error('Choose a valid image file.');
  if(kind==='video'&&mime&&!mime.startsWith('video/'))throw new Error('Choose a valid video file.');
  if(kind==='audio'&&mime&&!mime.startsWith('audio/'))throw new Error('Choose a valid audio file.');
  if(kind==='caption'&&mime&&!['text/vtt','text/plain'].includes(mime))throw new Error('Captions must be WebVTT text.');
}

type SaveState = 'loading' | 'saved' | 'dirty' | 'saving' | 'conflict' | 'error';
type ViewMode = 'edit' | 'preview' | 'split';
type AiMode = 'selection' | 'document' | 'empty';
type HistoryItem = { id: string; wsName: string; wsPath: string; wordCount: number; createdAt: number; contentHash: string };
const INTERACTIVE_DOC_WS_PATH = (wsName: string) => `${wsName}:document.interactive.json`;

type AiRequest = {
  blocks: InteractiveContentBlock[];
  mode: AiMode;
  insertionIndex?: number;
  nearbyBlocks?: InteractiveContentBlock[];
};

const AI_GENERATABLE = new Set(['paragraph', 'header', 'list', 'checklist', 'quote', 'callout', 'table', 'code']);
const AI_KEYS: Record<string, readonly string[]> = {
  paragraph: ['text'],
  header: ['text', 'level'],
  list: ['style', 'items'],
  checklist: ['items'],
  quote: ['text', 'caption', 'alignment'],
  callout: ['tone', 'title', 'text'],
  table: ['content', 'withHeadings', 'stretched'],
  code: ['language', 'projectModeId', 'filename', 'code', 'runnable', 'allowStdin', 'stdin'],
};

const QUICK_AI_ACTIONS = [
  ['Continue writing', 'Continue this writing naturally, matching its existing tone and structure.'],
  ['Improve writing', 'Improve clarity, flow, precision and readability while preserving the meaning.'],
  ['Fix grammar', 'Fix spelling, grammar and punctuation without changing the meaning.'],
  ['Make shorter', 'Make this more concise while preserving the important information.'],
  ['Expand', 'Expand this with useful detail and examples without adding unsupported claims.'],
  ['Simplify', 'Rewrite this so it is easier for a beginner to understand.'],
  ['Professional', 'Rewrite this in a clear professional technical-documentation tone.'],
  ['Change tone', 'Rewrite this in the tone I specify here: '],
  ['Translate', 'Translate this into the language I specify here, preserving the document structure: '],
  ['Summarize', 'Summarize this into the most useful concise form.'],
  ['Explain', 'Explain this clearly for a reader who is new to the topic.'],
] as const;


const BLOCK_AI_ACTIONS: Record<string, readonly (readonly [string, string])[]> = {
  header: [
    ['Heading alternatives', 'Suggest several stronger heading alternatives for this section. Return the best option as a heading block and keep the heading level.'],
  ],
  list: [
    ['Expand list', 'Expand this list with useful, non-redundant items while preserving its style and nesting.'],
    ['Condense list', 'Condense this list to its most useful items while preserving the important information.'],
    ['Reorder list', 'Reorder this list into the clearest logical sequence without changing its meaning.'],
  ],
  code: [
    ['Explain code', 'Explain this code clearly for a reader. Keep the code block and add a concise explanation in paragraph blocks.'],
    ['Add comments', 'Add useful comments to this code without changing its behavior. Return the revised code block.'],
    ['Debug code', 'Find likely defects in this code and return a corrected code block. Do not invent external APIs or hidden runtime settings.'],
    ['Optimize code', 'Improve this code for clarity and efficiency while preserving its behavior. Return the revised code block.'],
  ],
  table: [
    ['Generate rows', 'Add useful rows to this table based only on the existing headings and context. Preserve the table structure.'],
    ['Summarize table', 'Summarize the important information in this table in concise paragraph blocks. Keep claims grounded in the table.'],
  ],
  callout: [
    ['Make warning', 'Rewrite this callout as a concise warning. Preserve the factual meaning and return a callout block with warning tone.'],
    ['Make tip', 'Rewrite this callout as a practical tip. Preserve the factual meaning and return a callout block with tip tone.'],
    ['Make note', 'Rewrite this callout as a neutral note. Preserve the factual meaning and return a callout block with info tone.'],
  ],
};

function aiActionsForRequest(request: AiRequest): readonly (readonly [string, string])[] {
  if (request.mode === 'empty' || !request.blocks.length) return QUICK_AI_ACTIONS;
  const types = new Set(request.blocks.map((block) => block.type));
  if (types.size !== 1) return QUICK_AI_ACTIONS;
  const [type] = [...types];
  const contextual = BLOCK_AI_ACTIONS[type] || [];
  return contextual.length ? [...contextual, ...QUICK_AI_ACTIONS] : QUICK_AI_ACTIONS;
}

function textDecoder(bytes: Uint8Array): string {
  return new TextDecoder().decode(bytes);
}

function documentJson(document: InteractiveContentDocument): string {
  return `${JSON.stringify(document, null, 2)}\n`;
}

function displayProjectName(wsName: string): string {
  return wsName.replace(/[-_]+/gu, ' ').replace(/\b\w/gu, (value) => value.toUpperCase());
}

function safeAssetName(name: string): string {
  const normalized = name.normalize('NFKC').replace(/[^a-zA-Z0-9._-]+/gu, '-').replace(/^-+|-+$/gu, '');
  return normalized.slice(0, 100) || 'asset';
}

function mimeForAsset(path: string): string {
  const extension = path.toLowerCase().split('.').pop();
  return ({
    png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml',
    mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', pdf: 'application/pdf',
  } as Record<string, string>)[extension || ''] || 'application/octet-stream';
}

function collectPortableAssetReferences(value: unknown, output = new Set<string>()): Set<string> {
  if (typeof value === 'string') {
    if (value.startsWith(ASSET_PREFIX)) output.add(value);
    return output;
  }
  if (Array.isArray(value)) {
    value.forEach((item) => collectPortableAssetReferences(item, output));
    return output;
  }
  if (value && typeof value === 'object') {
    Object.values(value as Record<string, unknown>).forEach((item) => collectPortableAssetReferences(item, output));
  }
  return output;
}

function freshBlockId(type: string): string {
  return `${type}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`}`;
}

function sanitizeAiBlock(raw: unknown): InteractiveContentBlock {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('AI returned an invalid block.');
  const source = raw as Record<string, unknown>;
  const type = String(source.type || 'paragraph');
  if (!AI_GENERATABLE.has(type)) throw new Error(`AI returned unsupported block type: ${type}.`);
  const rawData = source.data && typeof source.data === 'object' && !Array.isArray(source.data) ? source.data as Record<string, unknown> : {};
  const data: Record<string, unknown> = {};
  for (const key of AI_KEYS[type] || []) if (key in rawData) data[key] = structuredClone(rawData[key]);
  if (type === 'header') data.level = Math.min(4, Math.max(2, Number(data.level) || 2));
  if (type === 'code') { data.runnable = false; data.allowStdin = false; }
  return { id: freshBlockId(type), type, data };
}

function parseAiBlocks(raw: string): InteractiveContentBlock[] {
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/iu)?.[1]?.trim();
  const candidate = fenced || raw.trim();
  let parsed: unknown;
  try { parsed = JSON.parse(candidate); }
  catch {
    const first = candidate.indexOf('{');
    const last = candidate.lastIndexOf('}');
    if (first < 0 || last <= first) throw new Error('AI did not return valid structured document blocks.');
    parsed = JSON.parse(candidate.slice(first, last + 1));
  }
  const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
  const blocks = Array.isArray(record.blocks) ? record.blocks : Array.isArray(parsed) ? parsed : [];
  if (!Array.isArray(blocks) || blocks.length === 0) throw new Error('AI returned no document blocks.');
  const sanitized = blocks.map(sanitizeAiBlock);
  return normalizeInteractiveContentDocument({ schemaVersion: 1, time: Date.now(), blocks: sanitized }, INTERACTIVE_DOC_BLOCK_PROFILE).blocks;
}

function aiContext(blocks: InteractiveContentBlock[]): string {
  return JSON.stringify(blocks.map(({ type, data }) => ({ type, data })), null, 2);
}

function mergeAiBlocks(document: InteractiveContentDocument, target: AiRequest, generated: InteractiveContentBlock[], action: 'replace' | 'insert'): InteractiveContentDocument {
  const targetIds = new Set(target.blocks.map((block) => block.id));
  if (target.mode === 'document' && action === 'replace') return { ...document, time: Date.now(), blocks: generated };
  if (targetIds.size === 0) { const index = Math.max(0, Math.min(document.blocks.length, Number.isInteger(target.insertionIndex) ? Number(target.insertionIndex) : document.blocks.length)); return { ...document, time: Date.now(), blocks: [...document.blocks.slice(0,index), ...generated, ...document.blocks.slice(index)] }; }
  const matchingIndexes = document.blocks.map((block, index) => targetIds.has(block.id) ? index : -1).filter((index) => index >= 0);
  if (!matchingIndexes.length) return { ...document, time: Date.now(), blocks: [...document.blocks, ...generated] };
  const first = Math.min(...matchingIndexes);
  const last = Math.max(...matchingIndexes);
  if (action === 'insert') return { ...document, time: Date.now(), blocks: [...document.blocks.slice(0, last + 1), ...generated, ...document.blocks.slice(last + 1)] };
  return { ...document, time: Date.now(), blocks: [...document.blocks.slice(0, first), ...generated, ...document.blocks.slice(last + 1)] };
}

function downloadText(filename: string, text: string, type: string): void {
  const url = URL.createObjectURL(new Blob([text], { type }));
  const anchor = document.createElement('a');
  anchor.href = url;
  anchor.download = filename;
  document.body.append(anchor);
  anchor.click();
  anchor.remove();
  URL.revokeObjectURL(url);
}

function downloadBytes(filename:string,bytes:Uint8Array,type:string):void{
  const url=URL.createObjectURL(new Blob([bytes],{type}));const anchor=document.createElement('a');anchor.href=url;anchor.download=filename;document.body.append(anchor);anchor.click();anchor.remove();URL.revokeObjectURL(url);
}
function decodeBase64(value:string):Uint8Array{const raw=atob(value);const bytes=new Uint8Array(raw.length);for(let index=0;index<raw.length;index++)bytes[index]=raw.charCodeAt(index);return bytes;}

function blockSearchText(block: InteractiveContentBlock): string {
  return JSON.stringify(block.data).replace(/[{}"\[\],:]/gu, ' ');
}

export function InteractiveDocProjectEditor({ wsName }: { wsName: string }) {
  const core = useCoreServices();
  const [documentValue, setDocumentValue] = React.useState<InteractiveContentDocument>();
  const [loadError, setLoadError] = React.useState('');
  const [saveState, setSaveState] = React.useState<SaveState>('loading');
  const [saveError, setSaveError] = React.useState('');
  const [viewMode, setViewMode] = React.useState<ViewMode>('edit');
  const [outlineOpen, setOutlineOpen] = React.useState(true);
  const [outlineQuery, setOutlineQuery] = React.useState('');
  const [outlineFilter, setOutlineFilter] = React.useState<'headings'|'all'|'code'|'image'|'table'|'runnable'>('headings');
  const [editorInstanceKey, setEditorInstanceKey] = React.useState(0);
  const [assetsRevision, setAssetsRevision] = React.useState(0);
  const [issuesOpen, setIssuesOpen] = React.useState(false);
  const [previewBlock, setPreviewBlock] = React.useState<InteractiveContentBlock>();
  const [aiOpen, setAiOpen] = React.useState(false);
  const [historyOpen, setHistoryOpen] = React.useState(false);
  const [historyLoading, setHistoryLoading] = React.useState(false);
  const [historyItems, setHistoryItems] = React.useState<HistoryItem[]>([]);
  const [aiRequest, setAiRequest] = React.useState<AiRequest>({ blocks: [], mode: 'empty' });
  const [aiPrompt, setAiPrompt] = React.useState('');
  const [aiRunning, setAiRunning] = React.useState(false);
  const [aiCandidate, setAiCandidate] = React.useState<InteractiveContentBlock[]>([]);
  const [aiError, setAiError] = React.useState('');
  const [pdfState,setPdfState]=React.useState<'idle'|'preparing'|'queued'|'running'>('idle');
  const aiActions = React.useMemo(() => aiActionsForRequest(aiRequest), [aiRequest]);
  const docRef = React.useRef<InteractiveContentDocument>();
  const lastPersistedRawRef = React.useRef('');
  const editRevisionRef = React.useRef(0);
  const savedRevisionRef = React.useRef(0);
  const writeChainRef = React.useRef<Promise<void>>(Promise.resolve());
  const assetUrlsRef = React.useRef(new Map<string, string>());
  const aiAbortRef = React.useRef<AbortController>();
  const editorControllerRef = React.useRef<InteractiveContentEditorController|null>(null);
  const forceNextSaveRef = React.useRef(false);
  const projectTitle = React.useMemo(() => displayProjectName(wsName), [wsName]);

  const revokeAssetUrls = React.useCallback(() => {
    for (const url of assetUrlsRef.current.values()) URL.revokeObjectURL(url);
    assetUrlsRef.current.clear();
  }, []);

  const hydrateAssetUrls = React.useCallback(async (doc: InteractiveContentDocument) => {
    const provider = await core.projectStorage.getProvider(wsName);
    const references = [...collectPortableAssetReferences(doc)];
    const next = new Map<string, string>();
    await Promise.all(references.map(async (reference) => {
      const relative = reference.slice(ASSET_PREFIX.length);
      try {
        const bytes = await provider.readFile(projectFilePath(relative));
        if (!bytes) return;
        next.set(reference, URL.createObjectURL(new Blob([bytes], { type: mimeForAsset(relative) })));
      } catch {
        // Missing media is surfaced as a content issue/empty preview. Never rewrite the document on a read failure.
      }
    }));
    revokeAssetUrls();
    assetUrlsRef.current = next;
    setAssetsRevision((value) => value + 1);
  }, [core.projectStorage, revokeAssetUrls, wsName]);

  const loadDocument = React.useCallback(async () => {
    setSaveState('loading');
    setLoadError('');
    try {
      const provider = await core.projectStorage.getProvider(wsName);
      const bytes = await provider.readFile(DOCUMENT_PATH);
      if (!bytes) throw new Error('The Interactive Doc source file is missing. Restore document.interactive.json from a backup or project copy.');
      const raw = textDecoder(bytes);
      const parsed = JSON.parse(raw) as unknown;
      const normalized = normalizeInteractiveContentDocument(parsed, INTERACTIVE_DOC_BLOCK_PROFILE);
      docRef.current = normalized;
      setDocumentValue(normalized);
      lastPersistedRawRef.current = raw;
      editRevisionRef.current = 0;
      savedRevisionRef.current = 0;
      setSaveState('saved');
      setSaveError('');
      await hydrateAssetUrls(normalized);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      setLoadError(message);
      setSaveState('error');
      setSaveError(message);
    }
  }, [core.projectStorage, hydrateAssetUrls, wsName]);

  React.useEffect(() => {
    void loadDocument();
    return () => {
      aiAbortRef.current?.abort();
      revokeAssetUrls();
    };
  }, [loadDocument, revokeAssetUrls]);

  const saveNow = React.useCallback((force = false) => {
    const requestedRevision = editRevisionRef.current;
    const snapshot = docRef.current;
    if (!snapshot || requestedRevision <= savedRevisionRef.current) return Promise.resolve();
    setSaveState('saving');
    const nextWrite = writeChainRef.current.then(async () => {
      const provider = await core.projectStorage.getProvider(wsName);
      const diskBytes = await provider.readFile(DOCUMENT_PATH);
      if (!diskBytes) throw new Error('The Interactive Doc source disappeared while editing. No data was overwritten.');
      const diskRaw = textDecoder(diskBytes);
      if (!force && !forceNextSaveRef.current && diskRaw !== lastPersistedRawRef.current) {
        setSaveState('conflict');
        setSaveError('This document changed outside this editor. Reload the disk version or choose Keep mine.');
        return;
      }
      forceNextSaveRef.current = false;
      const normalized = normalizeInteractiveContentDocument(snapshot, INTERACTIVE_DOC_BLOCK_PROFILE);
      const raw = documentJson(normalized);
      await provider.writeFile(DOCUMENT_PATH, new TextEncoder().encode(raw));
      lastPersistedRawRef.current = raw;
      savedRevisionRef.current = Math.max(savedRevisionRef.current, requestedRevision);
      if (editRevisionRef.current === requestedRevision) {
        setSaveState('saved');
        setSaveError('');
      } else {
        setSaveState('dirty');
      }
    }).catch((error) => {
      const message = error instanceof Error ? error.message : String(error);
      setSaveState('error');
      setSaveError(message);
      throw error;
    });
    writeChainRef.current = nextWrite.catch(() => undefined);
    return nextWrite;
  }, [core.projectStorage, wsName]);

  React.useEffect(() => {
    if (saveState !== 'dirty') return;
    const timer = globalThis.setTimeout(() => { void saveNow().catch(() => undefined); }, AUTOSAVE_MS);
    return () => globalThis.clearTimeout(timer);
  }, [saveNow, saveState, documentValue]);

  React.useEffect(() => {
    const beforeUnload = (event: BeforeUnloadEvent) => {
      if (saveState !== 'dirty' && saveState !== 'saving' && saveState !== 'conflict') return;
      event.preventDefault();
      event.returnValue = '';
    };
    const keydown = (event: KeyboardEvent) => {
      if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
      event.preventDefault();
      void saveNow().catch((error) => toast.error(error instanceof Error ? error.message : 'Save failed.'));
    };
    window.addEventListener('beforeunload', beforeUnload);
    window.addEventListener('keydown', keydown);
    return () => {
      window.removeEventListener('beforeunload', beforeUnload);
      window.removeEventListener('keydown', keydown);
    };
  }, [saveNow, saveState]);

  React.useEffect(()=>{
    interactiveDocSaveStatus.setWorkspacePending(wsName,saveState==='dirty'||saveState==='saving'||saveState==='conflict'||saveState==='error');
    return()=>interactiveDocSaveStatus.clearWorkspace(wsName);
  },[saveState,wsName]);

  const onDocumentChange = React.useCallback((next: InteractiveContentDocument) => {
    try {
      const normalized = normalizeInteractiveContentDocument(next, INTERACTIVE_DOC_BLOCK_PROFILE);
      docRef.current = normalized;
      setDocumentValue(normalized);
      editRevisionRef.current += 1;
      setSaveState('dirty');
      setSaveError('');
    } catch (error) {
      setSaveState('error');
      setSaveError(error instanceof Error ? error.message : String(error));
    }
  }, []);

  const uploadMedia = React.useCallback(async (kind: InteractiveContentMediaKind, file: File, onProgress?: (progress: { loaded: number; total: number; percent: number }) => void, signal?: AbortSignal): Promise<InteractiveContentEditorMedia> => {
    signal?.throwIfAborted();
    validateAssetUpload(kind,file);
    const provider = await core.projectStorage.getProvider(wsName);
    await provider.createDirectory(ASSETS_DIRECTORY).catch(() => undefined);
    const unique = `${Date.now().toString(36)}-${globalThis.crypto?.randomUUID?.().slice(0, 8) || Math.random().toString(36).slice(2, 10)}-${safeAssetName(file.name)}`;
    const relative = `assets/${unique}`;
    const bytes = new Uint8Array(await file.arrayBuffer());
    signal?.throwIfAborted();
    await provider.writeFile(projectFilePath(relative), bytes, signal);
    onProgress?.({ loaded: bytes.byteLength, total: bytes.byteLength, percent: 100 });
    const stored = `${ASSET_PREFIX}${relative}`;
    const existing = assetUrlsRef.current.get(stored);
    if (existing) URL.revokeObjectURL(existing);
    assetUrlsRef.current.set(stored, URL.createObjectURL(new Blob([bytes], { type: file.type || mimeForAsset(relative) })));
    setAssetsRevision((value) => value + 1);
    return { url: assetUrlsRef.current.get(stored)!, persistedUrl: stored, originalName: file.name, mimeType: file.type, sizeBytes: file.size };
  }, [core.projectStorage, wsName]);

  const runCodePreview = React.useCallback(async (input: InteractiveContentRunInput) => {
    const result = await core.projectExecution.executeSnippet(wsName, input);
    return { status: result.status, exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr };
  }, [core.projectExecution, wsName]);

  const adapter = React.useMemo<InteractiveContentEditorAdapter>(() => ({
    context: 'interactive-doc',
    runtimeDefaultLabel: 'Language / runtime',
    requiredLabel: 'Required',
    uploadMedia,
    resolveMediaUrl: (storedUrl) => assetUrlsRef.current.get(storedUrl) || storedUrl,
    runCodePreview,
  }), [assetsRevision, runCodePreview, uploadMedia]);
  const tools = React.useMemo(() => createInteractiveContentTools(adapter, INTERACTIVE_DOC_BLOCK_PROFILE), [adapter]);

  const issues = React.useMemo(() => documentValue ? interactiveContentIssues(documentValue, INTERACTIVE_DOC_BLOCK_PROFILE,{availableAssets:new Set(assetUrlsRef.current.keys())}) : [], [assetsRevision,documentValue]);
  const navigationItems = React.useMemo(() => (documentValue?.blocks || []).flatMap((block) => {
    const isRunnable = block.type === 'code' && block.data.runnable === true;
    const visible = outlineFilter === 'all' || (outlineFilter === 'headings' && block.type === 'header') || (outlineFilter === 'code' && block.type === 'code') || (outlineFilter === 'image' && (block.type === 'image' || block.type === 'gallery')) || (outlineFilter === 'table' && block.type === 'table') || (outlineFilter === 'runnable' && isRunnable);
    if (!visible) return [];
    const raw = block.type === 'header' ? String(block.data.text || '').replace(/<[^>]*>/gu, '').trim() : String(block.data.title || block.data.filename || block.data.caption || block.data.label || blockSearchText(block)).replace(/<[^>]*>/gu, '').trim();
    const fallback = block.type === 'code' ? (isRunnable ? 'Runnable Code' : 'Code') : block.type === 'image' ? 'Image' : block.type === 'gallery' ? 'Gallery' : block.type === 'table' ? 'Table' : block.type.replace(/([A-Z])/gu, ' $1');
    return [{ id: block.id, title: (raw || fallback).slice(0, 90), level: block.type === 'header' ? Math.min(4, Math.max(2, Number(block.data.level) || 2)) : 2, type: block.type }];
  }), [documentValue, outlineFilter]);
  const filteredNavigationItems = React.useMemo(() => {
    const needle = outlineQuery.toLocaleLowerCase().trim();
    return needle ? navigationItems.filter((item) => item.title.toLocaleLowerCase().includes(needle)) : navigationItems;
  }, [navigationItems, outlineQuery]);


  const scrollToBlock = React.useCallback((id: string) => {
    const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(id) : id.replace(/["\\]/gu, '');
    document.querySelector<HTMLElement>(`[data-block-id="${escaped}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
    const index = documentValue?.blocks.findIndex((block) => block.id === id) ?? -1;
    if (index >= 0) document.querySelectorAll<HTMLElement>('.interactive-content-editor-shell .ce-block')[index]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }, [documentValue]);

  const askAi = React.useCallback((blocks: InteractiveContentBlock[], mode: AiMode, context?: InteractiveContentAiContext) => {
    setAiRequest({ blocks, mode, ...(context?.insertionIndex!==undefined?{insertionIndex:context.insertionIndex}:{}), ...(context?.nearbyBlocks?.length?{nearbyBlocks:context.nearbyBlocks}: {}) });
    setAiCandidate([]);
    setAiError('');
    setAiPrompt(mode === 'empty' ? 'Draft the next useful section for this document.' : 'Improve this content while preserving its meaning and structure.');
    setAiOpen(true);
  }, []);

  const runAi = React.useCallback(async () => {
    if (!documentValue) return;
    aiAbortRef.current?.abort();
    const controller = new AbortController();
    aiAbortRef.current = controller;
    setAiRunning(true);
    setAiError('');
    setAiCandidate([]);
    const contextBlocks = aiRequest.mode === 'document' ? documentValue.blocks : aiRequest.blocks.length ? aiRequest.blocks : (aiRequest.nearbyBlocks || []);
    const nearbyHeading = (() => {
      const sourceBlocks=aiRequest.blocks.length?aiRequest.blocks:(aiRequest.nearbyBlocks||[]);
      if (!sourceBlocks.length) return '';
      const firstId = sourceBlocks[0]?.id;
      const index = documentValue.blocks.findIndex((block) => block.id === firstId);
      for (let current = index - 1; current >= 0; current -= 1) {
        const block = documentValue.blocks[current];
        if (block?.type === 'header') return String(block.data.text || '').replace(/<[^>]*>/gu, '');
      }
      return '';
    })();
    try {
      const response = await core.projectAi.generateConfigured([
        {
          role: 'system',
          content: `You are the Learnitive Interactive Doc writer. Return ONLY valid JSON in the shape {"blocks":[{"type":"paragraph","data":{"text":"..."}}]}. Allowed generated block types: paragraph, header, list, checklist, quote, callout, table, code. Never return IDs, credentials, hidden tests, verifier settings, terminal tasks, LMS fields, arbitrary runtime settings, scripts, HTML event handlers, or unsupported block types. Heading levels must be 2, 3 or 4. Preserve structure when the request asks for editing.`,
        },
        {
          role: 'user',
          content: `DOCUMENT_TITLE: ${projectTitle}\n${nearbyHeading ? `NEARBY_HEADING: ${nearbyHeading}\n` : ''}MODE: ${aiRequest.mode}\nINSTRUCTION: ${aiPrompt.trim()}\n\nCONTENT:\n${aiContext(contextBlocks)}`,
        },
      ], controller.signal);
      controller.signal.throwIfAborted();
      setAiCandidate(parseAiBlocks(response));
    } catch (error) {
      if (!controller.signal.aborted) {
        const message = error instanceof Error ? error.message : String(error);
        setAiError(message);
        if (/configure|provider|api key|model|disabled/iu.test(message)) openAiSettingsDialog();
      }
    } finally {
      if (aiAbortRef.current === controller) aiAbortRef.current = undefined;
      setAiRunning(false);
    }
  }, [aiPrompt, aiRequest, core.projectAi, documentValue, projectTitle]);

  const applyAi = React.useCallback(async(action: 'replace' | 'insert') => {
    if (!documentValue || !aiCandidate.length) return;
    const before=documentJson(documentValue);
    await core.noteSnapshot.captureCheckpoint(INTERACTIVE_DOC_WS_PATH(wsName), before);
    const next = normalizeInteractiveContentDocument(mergeAiBlocks(documentValue, aiRequest, aiCandidate, action), INTERACTIVE_DOC_BLOCK_PROFILE);
    const applied=await editorControllerRef.current?.applyExternalDocument(next, 'AI change applied — Undo available.');
    if(!applied){docRef.current = next;setDocumentValue(next);editRevisionRef.current += 1;setSaveState('dirty');setEditorInstanceKey((value) => value + 1);}
    setAiOpen(false);
    setAiCandidate([]);
  }, [aiCandidate, aiRequest, core.noteSnapshot, documentValue, wsName]);

  const refreshHistory = React.useCallback(async () => {
    setHistoryLoading(true);
    try {
      const rows = await core.noteSnapshot.listSnapshots({ wsName });
      setHistoryItems(rows.filter((row) => row.wsPath === INTERACTIVE_DOC_WS_PATH(wsName)));
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Could not load document history.');
    } finally {
      setHistoryLoading(false);
    }
  }, [core.noteSnapshot, wsName]);

  const createCheckpoint = React.useCallback(async () => {
    if (!documentValue) return;
    const content = documentJson(documentValue);
    if (new Blob([content]).size > RECOVERY_CHECKPOINT_MAX_BYTES) { toast.error('This document is too large for a browser recovery checkpoint. Export a project ZIP for a full backup.'); return; }
    await core.noteSnapshot.captureCheckpoint(INTERACTIVE_DOC_WS_PATH(wsName), content);
    await refreshHistory();
    toast.success('Document checkpoint saved.');
  }, [core.noteSnapshot, documentValue, refreshHistory, wsName]);

  const restoreCheckpoint = React.useCallback(async (snapshotId: string) => {
    if (!documentValue) return;
    try {
      const snapshot = await core.noteSnapshot.getSnapshot(snapshotId);
      if (!snapshot || snapshot.wsPath !== INTERACTIVE_DOC_WS_PATH(wsName)) throw new Error('This recovery checkpoint is no longer available.');
      const restored = normalizeInteractiveContentDocument(JSON.parse(snapshot.content), INTERACTIVE_DOC_BLOCK_PROFILE);
      await core.noteSnapshot.captureCheckpoint(INTERACTIVE_DOC_WS_PATH(wsName), documentJson(documentValue));
      const applied = await editorControllerRef.current?.applyExternalDocument(restored, 'Checkpoint restored — Undo available.');
      if (!applied) {
        docRef.current = restored;
        setDocumentValue(restored);
        editRevisionRef.current += 1;
        setSaveState('dirty');
        setEditorInstanceKey((value) => value + 1);
      }
      setHistoryOpen(false);
      toast.success('Document checkpoint restored.');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Could not restore this checkpoint.');
    }
  }, [core.noteSnapshot, documentValue, wsName]);

  const exportMarkdown = React.useCallback(() => {
    if (!documentValue) return;
    downloadText(`${wsName}.md`, interactiveContentToMarkdown(documentValue, projectTitle), 'text/markdown;charset=utf-8');
    toast.success('Markdown export created.');
  }, [documentValue, projectTitle, wsName]);

  const exportMarkdownPackage = React.useCallback(async()=>{
    if(!documentValue)return;
    try{
      const provider=await core.projectStorage.getProvider(wsName);
      const files:ProjectArchiveFile[]=[{path:projectFilePath('document.md'),data:new TextEncoder().encode(interactiveContentToMarkdown(documentValue,projectTitle))}];
      for(const reference of collectInteractiveContentAssetReferences(documentValue)){
        const relative=reference.slice(ASSET_PREFIX.length);const path=projectFilePath(relative);const bytes=await provider.readFile(path);
        if(!bytes)throw new Error(`Referenced asset is missing: ${relative}`);
        files.push({path,data:bytes});
      }
      const archive=encodeProjectZip(files);
      await saveZipStream(new Blob([archive],{type:'application/zip'}).stream(),`${wsName}-markdown.zip`);
      toast.success(`Markdown package created with ${Math.max(0,files.length-1)} asset${files.length===2?'':'s'}.`);
    }catch(error){toast.error(error instanceof Error?error.message:'Markdown package export failed.');}
  },[core.projectStorage,documentValue,projectTitle,wsName]);

  const cleanUnusedAssets = React.useCallback(async()=>{
    if(!documentValue)return;
    try{const provider=await core.projectStorage.getProvider(wsName);const used=new Set(collectInteractiveContentAssetReferences(documentValue).map((reference)=>reference.slice(ASSET_PREFIX.length)));const entries=await provider.listEntries();const unused=entries.filter((entry)=>entry.kind==='file'&&String(entry.path).startsWith('assets/')&&!used.has(String(entry.path)));for(const entry of unused)await provider.deleteFile(entry.path);if(unused.length){await hydrateAssetUrls(documentValue);toast.success(`${unused.length} unused asset${unused.length===1?'':'s'} removed.`);}else toast.info('No unused project assets found.');}catch(error){toast.error(error instanceof Error?error.message:'Could not clean project assets.');}
  },[core.projectStorage,documentValue,hydrateAssetUrls,wsName]);

  const exportPdf = React.useCallback(async()=>{
    if(!documentValue||pdfState!=='idle')return;setPdfState('preparing');
    try{
      const provider=await core.projectStorage.getProvider(wsName);const assets:Array<{path:string;bytes:Uint8Array}>=[];
      for(const reference of collectInteractiveContentAssetReferences(documentValue)){const relative=reference.slice(ASSET_PREFIX.length);const bytes=await provider.readFile(projectFilePath(relative));if(!bytes)throw new Error(`PDF export cannot find referenced asset: ${relative}`);assets.push({path:relative,bytes});}
      let includeBranding=true;try{const auth=await getAuthState();includeBranding=!(auth.authenticated&&(auth.entitlement.pro||auth.user.role==='admin'));}catch{/* anonymous/offline exports keep the discreet free footer */}
      const result=await core.projectExecution.exportMarkdownPdf(wsName,{markdown:interactiveContentToMarkdown(documentValue,projectTitle),stylesheet:interactiveDocPdfCss(includeBranding),assets},{onStatus:(status)=>setPdfState(status)});
      if(result.status!=='success'||result.exitCode!==0)throw new Error(result.stderr||'PDF conversion failed.');
      const artifact=result.artifacts.find((item)=>item.path.endsWith('/document.pdf')||item.path.endsWith('document.pdf'));
      if(!artifact?.contentBase64)throw new Error('PDF conversion completed without a PDF artifact.');
      const bytes=decodeBase64(artifact.contentBase64);if(bytes.byteLength<5||new TextDecoder().decode(bytes.subarray(0,5))!=='%PDF-')throw new Error('The generated PDF artifact is invalid.');
      downloadBytes(`${wsName}.pdf`,bytes,'application/pdf');toast.success('PDF export created.');
    }catch(error){toast.error(error instanceof Error?error.message:'PDF export failed.');}finally{setPdfState('idle');}
  },[core.projectExecution,core.projectStorage,documentValue,pdfState,projectTitle,wsName]);

  const renderer = React.useCallback((staticPrint = false) => documentValue ? (
    <InteractiveContentRenderer
      document={documentValue}
      interactive={!staticPrint}
      staticPrint={staticPrint}
      resolveMediaUrl={(stored) => assetUrlsRef.current.get(stored) || stored}
      onRunCode={staticPrint ? undefined : async (block, code, stdin) => runCodePreview({
        projectModeId: String(block.data.projectModeId || ''),
        language: String(block.data.language || ''),
        filename: String(block.data.filename || ''),
        code,
        stdin,
      })}
    />
  ) : null, [assetsRevision, documentValue, runCodePreview]);

  const documentPreview=React.useCallback((staticMode=false)=><article className="interactive-doc-document-shell"><header className="interactive-doc-document-header"><h1>{projectTitle}</h1></header>{renderer(staticMode)}</article>,[projectTitle,renderer]);

  if (saveState === 'loading' && !documentValue) return <div className="interactive-doc-loading"><LoaderCircle className="size-5 animate-spin" /> Loading Interactive Doc…</div>;
  if (loadError && !documentValue) return <div className="interactive-doc-load-error"><AlertTriangle className="size-5" /><div><strong>Interactive Doc could not be loaded</strong><p>{loadError}</p><Button size="sm" variant="secondary" onClick={() => void loadDocument()}>Try again</Button></div></div>;
  if (!documentValue) return null;

  const saveLabel = saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : saveState === 'error' ? 'Save error' : 'Saved';

  return (
    <div className="interactive-doc-project" data-testid="interactive-doc-project">
      <header className="interactive-doc-project-toolbar">
        <div className="interactive-doc-project-identity"><FileText className="size-4" /><strong>{projectTitle}</strong><span>Interactive Doc</span></div>
        <div className={`interactive-doc-save-state state-${saveState}`} aria-live="polite"><Save className="size-3.5" />{saveLabel}</div>
        <div className="interactive-doc-view-switch" aria-label="Document view">
          <Button size="sm" variant={viewMode === 'edit' ? 'secondary' : 'ghost'} onClick={() => setViewMode('edit')}>Edit</Button>
          <Button size="sm" variant={viewMode === 'preview' ? 'secondary' : 'ghost'} onClick={() => setViewMode('preview')}><Eye className="mr-1 size-4" />Preview</Button>
          <Button size="sm" className="hidden xl:inline-flex" variant={viewMode === 'split' ? 'secondary' : 'ghost'} onClick={() => setViewMode('split')}>Split</Button>
        </div>
        <Button size="sm" variant="ghost" onClick={() => askAi([], 'empty')}><Sparkles className="mr-1 size-4" />AI</Button>
        <Button size="sm" variant="ghost" onClick={() => { setHistoryOpen(true); void refreshHistory(); }}><History className="mr-1 size-4" />History</Button>
        <Button size="sm" variant={issues.length ? 'secondary' : 'ghost'} onClick={() => setIssuesOpen(true)}><AlertTriangle className="mr-1 size-4" />{issues.length ? `${issues.length} issues` : 'Content issues'}</Button>
        <Button size="sm" variant="ghost" onClick={exportMarkdown}><Download className="mr-1 size-4" />Markdown</Button>
        <Button size="sm" variant="ghost" onClick={()=>void exportMarkdownPackage()}><Download className="mr-1 size-4" />Markdown ZIP</Button>
        <Button size="sm" variant="ghost" onClick={()=>void exportPdf()} disabled={pdfState!=='idle'}><Printer className="mr-1 size-4" />{pdfState==='preparing'?'Preparing PDF…':pdfState==='queued'?'PDF queued…':pdfState==='running'?'Generating PDF…':'PDF'}</Button>
      </header>

      {(saveState === 'conflict' || saveState === 'error') && <div className="interactive-doc-save-problem" role="alert">
        <AlertTriangle className="size-4" /><span>{saveError}</span>
        {saveState === 'conflict' && <>
          <Button size="sm" variant="secondary" onClick={() => void loadDocument()}>Reload disk version</Button>
          <Button size="sm" onClick={() => { forceNextSaveRef.current = true; void saveNow(true).catch(() => undefined); }}>Keep mine</Button>
        </>}
        {saveState === 'error' && <Button size="sm" variant="secondary" onClick={() => void saveNow().catch(() => undefined)}>Retry</Button>}
      </div>}

      <div className="interactive-doc-project-body">
        {outlineOpen ? <aside className="interactive-doc-outline" aria-label="Document outline">
          <div className="interactive-doc-outline-heading"><strong><ListTree className="size-4" /> Outline</strong><Button size="icon" variant="ghost" aria-label="Close outline" onClick={() => setOutlineOpen(false)}><PanelLeftClose className="size-4" /></Button></div>
          <label className="interactive-doc-outline-search"><Search className="size-3.5" /><input value={outlineQuery} onChange={(event) => setOutlineQuery(event.target.value)} placeholder="Find in document…" /></label>
          <select className="interactive-doc-outline-filter" aria-label="Filter document outline" value={outlineFilter} onChange={(event) => setOutlineFilter(event.currentTarget.value as typeof outlineFilter)}><option value="headings">Headings</option><option value="all">All blocks</option><option value="code">Code</option><option value="runnable">Runnable code</option><option value="image">Images</option><option value="table">Tables</option></select>
          <nav>{filteredNavigationItems.length ? filteredNavigationItems.map((item) => <button type="button" key={item.id} className={item.type === 'header' ? '' : 'is-block-result'} style={{ paddingLeft: `${(item.level - 2) * 12 + 8}px` }} onClick={() => scrollToBlock(item.id)}>{item.title}</button>) : <small>No matching content</small>}</nav>
        </aside> : <Button className="interactive-doc-outline-open" size="icon" variant="secondary" aria-label="Open outline" onClick={() => setOutlineOpen(true)}><PanelLeftOpen className="size-4" /></Button>}

        <main className={`interactive-doc-workspace view-${viewMode}`}>
          {(viewMode === 'edit' || viewMode === 'split') && <section className="interactive-doc-edit-pane" aria-label="Interactive Doc editor">
            <InteractiveContentEditor
              key={`${editorInstanceKey}-${wsName}`}
              instanceKey={`${editorInstanceKey}-${wsName}`}
              value={documentValue}
              onChange={onDocumentChange}
              tools={tools}
              onAskAi={askAi}
              onPreviewBlock={setPreviewBlock}
              controllerRef={editorControllerRef}
              hint="Write naturally. Type / for blocks, or press Ctrl/Cmd+J to ask AI. Changes save automatically."
            />
          </section>}
          {(viewMode === 'preview' || viewMode === 'split') && <section className="interactive-doc-preview-pane" aria-label="Interactive Doc preview">{documentPreview(false)}</section>}
        </main>
      </div>


      <Dialog open={Boolean(previewBlock)} onOpenChange={(open) => { if (!open) setPreviewBlock(undefined); }}>
        <DialogContent className="interactive-doc-block-preview-dialog">
          <DialogHeader><DialogTitle>Block preview</DialogTitle><DialogDescription>Preview this block using the same document renderer without editor controls.</DialogDescription></DialogHeader>
          {previewBlock ? <div className="interactive-doc-block-preview"><InteractiveContentRenderer document={{schemaVersion:1,time:Date.now(),blocks:[previewBlock]}} interactive resolveMediaUrl={(stored) => assetUrlsRef.current.get(stored) || stored} onRunCode={async (block, code, stdin) => runCodePreview({projectModeId:String(block.data.projectModeId||''),language:String(block.data.language||''),filename:String(block.data.filename||''),code,stdin})}/></div> : null}
        </DialogContent>
      </Dialog>

      <Dialog open={issuesOpen} onOpenChange={setIssuesOpen}>
        <DialogContent>
          <DialogHeader><DialogTitle>Content issues</DialogTitle><DialogDescription>These checks help catch incomplete blocks without preventing draft saving.</DialogDescription></DialogHeader>
          {issues.length ? <ul className="interactive-doc-issues-list">{issues.map((issue) => <li key={issue}>{issue}</li>)}</ul> : <p>No content issues found.</p>}
          <DialogFooter><Button variant="secondary" onClick={()=>void cleanUnusedAssets()}>Clean unused assets</Button></DialogFooter>
        </DialogContent>
      </Dialog>

      <Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
        <DialogContent className="interactive-doc-history-dialog">
          <DialogHeader><DialogTitle><History className="mr-2 inline size-5" />Document history</DialogTitle><DialogDescription>Recovery checkpoints are browser-local and let you restore an earlier Interactive Doc state.</DialogDescription></DialogHeader>
          <div className="interactive-doc-history-actions"><Button size="sm" variant="secondary" onClick={() => void createCheckpoint()}>Save checkpoint</Button></div>
          <div className="interactive-doc-history-list">
            {historyLoading ? <p><LoaderCircle className="mr-1 inline size-4 animate-spin" />Loading history…</p> : historyItems.length ? historyItems.map((item) => <div key={item.id} className="interactive-doc-history-row"><div><strong>{new Date(item.createdAt).toLocaleString()}</strong><small>{item.wordCount.toLocaleString()} words</small></div><Button size="sm" variant="secondary" onClick={() => void restoreCheckpoint(item.id)}><RotateCcw className="mr-1 size-4" />Restore</Button></div>) : <p>No checkpoints yet. Save one before a major rewrite.</p>}
          </div>
        </DialogContent>
      </Dialog>

      <Dialog open={aiOpen} onOpenChange={(open) => { setAiOpen(open); if (!open) aiAbortRef.current?.abort(); }}>
        <DialogContent className="interactive-doc-ai-dialog">
          <DialogHeader><DialogTitle><Bot className="mr-2 inline size-5" />AI Writer</DialogTitle><DialogDescription>AI creates a candidate first. Your document changes only after you accept it.</DialogDescription></DialogHeader>
          <div className="interactive-doc-ai-actions">{aiActions.map(([label, prompt]) => <Button key={label} size="sm" variant="secondary" onClick={() => setAiPrompt(prompt)}>{label}</Button>)}</div>
          <textarea className="interactive-doc-ai-prompt" value={aiPrompt} onChange={(event) => setAiPrompt(event.target.value)} placeholder="Ask AI to write or edit…" rows={4} />
          <div className="interactive-doc-ai-meta"><span>{aiRequest.mode === 'document' ? `${documentValue.blocks.length} document blocks` : aiRequest.blocks.length ? `${aiRequest.blocks.length} selected block${aiRequest.blocks.length === 1 ? '' : 's'}` : aiRequest.insertionIndex!==undefined?`Insert near block ${aiRequest.insertionIndex+1}`:'Insert at end of document'}</span><Button size="sm" variant="ghost" onClick={() => openAiSettingsDialog()}><Settings2 className="mr-1 size-4" />AI Settings</Button></div>
          {aiError ? <div className="interactive-doc-ai-error" role="alert">{aiError}</div> : null}
          {aiCandidate.length ? <div className="interactive-doc-ai-preview" role="status" aria-live="polite"><strong>AI result ready</strong><InteractiveContentRenderer document={{ schemaVersion: 1, time: Date.now(), blocks: aiCandidate }} interactive={false} staticPrint resolveMediaUrl={(stored) => assetUrlsRef.current.get(stored) || stored} /></div> : null}
          <DialogFooter>
            {aiCandidate.length ? <>
              <Button variant="secondary" onClick={() => void runAi()} disabled={aiRunning}>Try again</Button>
              <Button variant="secondary" onClick={() => void applyAi('insert')}>Insert below</Button>
              <Button onClick={() => void applyAi('replace')}>{aiRequest.mode === 'document' ? 'Replace document' : aiRequest.blocks.length ? 'Replace selection' : 'Insert'}</Button>
            </> : <Button onClick={() => void runAi()} disabled={aiRunning || !aiPrompt.trim()}>{aiRunning ? <><LoaderCircle className="mr-1 size-4 animate-spin" />Writing…</> : <><Sparkles className="mr-1 size-4" />Generate</>}</Button>}
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}
