import { compileGuided, nextId, parseProcessJson, processExamples, restoreGuided, toSourceMode, validateProcess, PROCESS_LIMITS, type ProcessState, type GuidedGraph, type ProcessNode } from './core';
import { clearSavedDraft, confirmReplace, downloadBlob, downloadJson, escapeHtml as esc, loadDraft, saveDraft } from '../shared/ui';
import { renderProcess, svgToPng, processPdf, type ProcessRender } from './render';
import { InputError } from '../shared/validation';
import './mapper.css';

const names = { onboarding: 'Customer onboarding', approval: 'Purchase approval', service: 'Service-request handling', systems: 'Website, CRM and accounting' };
const kinds: Record<ProcessNode['type'], string> = { start: 'Start', end: 'End', process: 'Step', decision: 'Decision', system: 'System', store: 'Data store' };
const empty = (): ProcessState => ({ schemaVersion: 1, tool: 'process-mapper', mode: 'guided', graph: { title: 'My business process', direction: 'TB', nodes: [{ id: 'step_1', type: 'start', label: 'Start here', team: '', description: '' }], edges: [] } });
const activeMounts = new WeakMap<HTMLElement, () => void>();

export function mount(root: HTMLElement): () => void {
  const existing = activeMounts.get(root); if (existing) return existing;
  let state: ProcessState = structuredClone(processExamples.onboarding);
  let selected = 'enquiry', revision = 0, renderedRevision = -1, textOnlyRevision = -1, lastRender: ProcessRender | null = null;
  let imageUrl = '', disposed = false, timer = 0, rendering = false, queued = false, autoPreview = true, exporting = false, remember = false, dirty = false, zoom = 1;
  const abort = new AbortController();
  root.classList.add('et-mapper');
  root.innerHTML = `<div class="et-mapper-top"><div><p class="et-mapper-kicker">Make the steps clear</p><h2>Map how your business works</h2><p>Start with a fictional example, then make it your own. Your process stays in this browser.</p></div><span class="et-mapper-local">No account needed</span></div>
  <div class="et-mapper-toolbar"><label>Start with an example<select data-example>${Object.entries(names).map(([key, name]) => `<option value="${key}">${name}</option>`).join('')}</select></label><button type="button" data-action="example">Load example</button><button type="button" data-action="new">Start a new map</button><button type="button" data-action="import">Import JSON or Mermaid</button><input data-file type="file" accept=".json,.mmd,.txt,application/json,text/plain" hidden></div>
  <p class="et-mapper-notice" data-notice role="status" aria-live="polite"></p>
  <div class="et-mapper-layout"><section class="et-mapper-editor" aria-label="Process editor"><div class="et-mapper-mode" role="group" aria-label="Editing mode"><button type="button" data-action="guided" aria-pressed="true">Step by step</button><button type="button" data-action="source" aria-pressed="false">Mermaid source</button></div><div data-editor></div></section>
  <section class="et-mapper-preview" aria-label="Diagram preview"><div class="et-mapper-preview-heading"><div><h3>Your process map</h3><p data-preview-status role="status" aria-live="polite">Preparing the diagram…</p></div><button type="button" data-action="update">Update preview</button></div><div class="et-mapper-preview-controls" role="group" aria-label="Diagram zoom"><button type="button" data-action="zoom-out" aria-label="Zoom out">−</button><output data-zoom aria-label="Diagram zoom level">Fit</output><button type="button" data-action="zoom-in" aria-label="Zoom in">+</button><button type="button" data-action="fit">Fit to view</button><button type="button" data-action="stop">Pause updates</button></div><div class="et-mapper-canvas" data-canvas tabindex="0" aria-label="Scrollable process diagram"><p data-empty-preview>Your diagram will appear here.</p><img data-image alt="" hidden></div><p class="et-mapper-errors" data-errors role="status" aria-live="polite"></p><div class="et-mapper-exports" role="group" aria-label="Export this process"><button type="button" data-export="pdf" disabled>Download PDF</button><button type="button" data-export="png" disabled>PNG image</button><button type="button" data-export="svg" disabled>SVG image</button><button type="button" data-export="mmd" disabled>Mermaid source</button><button type="button" data-export="json" disabled>Save editable JSON</button></div><p class="et-mapper-small">SVG keeps its detail at any size. PDF and PNG include the complete diagram; very large maps may need to be split for readable printing.</p><details class="et-mapper-text-summary"><summary>Read the process as text</summary><div data-summary></div></details></section></div>
  <div class="et-mapper-storage"><label><input type="checkbox" data-remember> Remember this draft on this device</label><p>Off by default. Browser storage is not encrypted. Use it only on a device you trust; JSON exports let you keep your own copy.</p><div><button type="button" data-action="load-saved">Load saved draft</button><button type="button" data-action="clear-saved">Clear saved draft</button><button type="button" data-action="clear">Clear this map</button></div></div>`;
  const element = <T extends Element = HTMLElement>(selector: string): T => root.querySelector<T>(selector)!;
  const say = (message: string) => { element('[data-notice]').textContent = message; };
  const graph = (): GuidedGraph | null => state.mode === 'guided' ? state.graph : null;
  const currentValid = () => validateProcess(state);

  function refreshActions() {
    const ready = !disposed && !exporting && renderedRevision === revision && lastRender !== null && currentValid().ok;
    root.querySelectorAll<HTMLButtonElement>('[data-export]').forEach(button => { button.disabled = !ready && !(!exporting && currentValid().ok && textOnlyRevision === revision && ['json', 'mmd'].includes(button.dataset.export!)); });
    element<HTMLButtonElement>('[data-action="stop"]').disabled = !autoPreview;
  }

  function persist() {
    if (!remember) return;
    const checked = currentValid();
    if (!checked.ok) { say('The last saved draft is unchanged. Fix the current errors before this draft can be saved.'); return; }
    try { saveDraft('process-mapper', checked.value); }
    catch { remember = false; element<HTMLInputElement>('[data-remember]').checked = false; say('This browser could not save your draft. Use Save editable JSON instead.'); }
  }

  function summaries() {
    const current = graph();
    if (current) {
      element('[data-summary]').innerHTML = `<ol>${current.nodes.map(node => `<li><strong>${esc(node.label || 'Unnamed step')}</strong> · ${esc(kinds[node.type])}${node.team ? ` · ${esc(node.team)}` : ''}${node.description ? `<p>${esc(node.description)}</p>` : ''}</li>`).join('')}</ol><p><strong>Connections</strong></p><ul>${current.edges.map(edge => `<li>${esc(current.nodes.find(node => node.id === edge.from)?.label ?? 'Missing step')} → ${esc(current.nodes.find(node => node.id === edge.to)?.label ?? 'Missing step')}${edge.label ? ` (${esc(edge.label)})` : ''}</li>`).join('') || '<li>No connections yet.</li>'}</ul>`;
    } else { element('[data-summary]').innerHTML = `<p>Source mode preserves exactly what you write. Read the source below as the text alternative to the diagram.</p><pre>${esc(state.mode === 'source' ? state.source : '')}</pre>`; }
  }

  function renderEditor() {
    const current = graph();
    element<HTMLButtonElement>('[data-action="guided"]').setAttribute('aria-pressed', String(!!current));
    element<HTMLButtonElement>('[data-action="source"]').setAttribute('aria-pressed', String(!current));
    if (!current) {
      element('[data-editor]').innerHTML = `<div class="et-mapper-source-notice"><strong>You’re editing Mermaid source.</strong><p>${state.mode === 'source' && state.preservedGraph ? 'Your previous step-by-step map is kept. Returning to it requires confirmation and does not convert these source edits.' : 'Source cannot be converted automatically into editable steps. You can start a new step-by-step map at any time.'}</p></div><label class="et-mapper-field">Mermaid source<textarea data-field="source" spellcheck="false" rows="22" maxlength="30000" aria-describedby="et-source-guide">${esc(state.mode === 'source' ? state.source : '')}</textarea></label>${sourceGuide()}<button type="button" data-action="restore">${state.mode === 'source' && state.preservedGraph ? 'Restore previous step-by-step map' : 'Start a step-by-step map'}</button>`;
      summaries(); return;
    }
    if (!current.nodes.some(node => node.id === selected)) selected = current.nodes[0]?.id ?? '';
    const active = current.nodes.find(node => node.id === selected);
    const option = (value: string) => current.nodes.map(node => `<option value="${esc(node.id)}" ${node.id === value ? 'selected' : ''}>${esc(node.label || 'Unnamed step')}</option>`).join('');
    element('[data-editor]').innerHTML = `<div class="et-mapper-fields"><label class="et-mapper-field">Map title<input data-field="title" value="${esc(current.title)}" maxlength="160" required></label><label class="et-mapper-field">Diagram direction<select data-field="direction"><option value="TB" ${current.direction === 'TB' ? 'selected' : ''}>Top to bottom</option><option value="LR" ${current.direction === 'LR' ? 'selected' : ''}>Left to right</option></select></label></div><div class="et-mapper-section-heading"><h3>1. Add your steps</h3><button type="button" data-action="add-node" ${current.nodes.length >= 100 ? 'disabled' : ''}>+ Add step</button></div><p class="et-mapper-small">Select a step to edit its name, type and details. Use the arrows to change its place in the list.</p><ol class="et-mapper-steps">${current.nodes.map((node, index) => `<li class="${selected === node.id ? 'is-selected' : ''}"><button type="button" class="et-mapper-select-step" data-select="${esc(node.id)}" aria-pressed="${selected === node.id}"><span>${index + 1}</span><strong>${esc(node.label || 'Unnamed step')}</strong><small>${esc(kinds[node.type])}</small></button><div><button type="button" data-move="${esc(node.id)}" data-direction="-1" aria-label="Move step ${index + 1} up" ${index === 0 ? 'disabled' : ''}>↑</button><button type="button" data-move="${esc(node.id)}" data-direction="1" aria-label="Move step ${index + 1} down" ${index === current.nodes.length - 1 ? 'disabled' : ''}>↓</button><button type="button" data-remove-node="${esc(node.id)}" aria-label="Remove step ${index + 1}" ${current.nodes.length === 1 ? 'disabled' : ''}>×</button></div></li>`).join('')}</ol>${active ? `<fieldset class="et-mapper-step-details"><legend>Selected step</legend><label class="et-mapper-field">Step name<input data-node-field="label" value="${esc(active.label)}" maxlength="160" required></label><label class="et-mapper-field">Type<select data-node-field="type">${Object.entries(kinds).map(([key, name]) => `<option value="${key}" ${key === active.type ? 'selected' : ''}>${name}</option>`).join('')}</select></label><label class="et-mapper-field">Responsible team <span>(optional)</span><input data-node-field="team" value="${esc(active.team)}" maxlength="100"></label><label class="et-mapper-field">What happens here? <span>(optional)</span><textarea data-node-field="description" rows="2" maxlength="400">${esc(active.description)}</textarea></label><p class="et-mapper-small">Team and description appear in the text view and editable JSON. Diagram labels use the step name.</p></fieldset>` : ''}<div class="et-mapper-section-heading"><h3>2. Connect the steps</h3><button type="button" data-action="add-edge" ${current.nodes.length < 2 || current.edges.length >= 200 ? 'disabled' : ''}>+ Connect</button></div><div class="et-mapper-connections">${current.edges.map((edge, index) => `<fieldset><legend>Connection ${index + 1}</legend><div class="et-mapper-fields"><label>From<select data-edge="${esc(edge.id)}" data-edge-field="from">${option(edge.from)}</select></label><label>To<select data-edge="${esc(edge.id)}" data-edge-field="to">${option(edge.to)}</select></label></div><label>Branch label <span>(optional, e.g. Yes or No)</span><input data-edge="${esc(edge.id)}" data-edge-field="label" value="${esc(edge.label)}" maxlength="80"></label><button type="button" data-remove-edge="${esc(edge.id)}">Remove connection ${index + 1}</button></fieldset>`).join('') || '<p class="et-mapper-small">Connect a step to the one that follows it. Returning to an earlier step is allowed.</p>'}</div><details class="et-mapper-generated"><summary>View generated Mermaid source</summary><textarea data-generated readonly rows="10" aria-label="Generated Mermaid source" spellcheck="false">${esc(compileGuided(current))}</textarea><button type="button" data-action="source">Edit this source</button></details>`;
    summaries();
  }

  function sourceGuide() { return `<details id="et-source-guide" class="et-mapper-source-guide"><summary>Supported source syntax and limits</summary><p>Flowcharts use one statement per line: named steps with quoted labels, then arrows between their IDs. Supported shapes are steps, rounded starts/ends, decisions, systems and data stores.</p><pre>flowchart LR\n  start(["New request"])\n  check{"Approved?"}\n  done["Process request"]\n  start --> check\n  check -->|"Yes"| done</pre><p>Sequence diagrams support participants/actors, messages, notes, activation, numbering and alt/opt/loop/par blocks.</p><pre>sequenceDiagram\n  participant Customer\n  participant Team\n  Customer->>Team: Submit request\n  Team-->>Customer: Confirm next steps</pre><p>Up to 100 steps or participants, 200 connections or messages, and 30 KB of source. HTML, links, click actions, images, custom CSS, configuration and other diagram types are not supported.</p></details>`; }

  function update() {
    revision++; dirty = true; refreshActions(); summaries(); persist();
    const current = graph(), generated = root.querySelector<HTMLTextAreaElement>('[data-generated]');
    if (current && generated) generated.value = compileGuided(current);
    const check = currentValid();
    element('[data-errors]').textContent = check.ok ? check.warnings.map(issue => issue.message).join(' ') : check.errors.slice(0, 5).map(issue => issue.message).join(' ');
    element('[data-preview-status]').textContent = check.ok ? (autoPreview ? 'Updating your diagram…' : 'Preview updates are paused. Select Update preview to resume.') : 'Current changes need attention. The last valid preview is kept; exports are paused.';
    window.clearTimeout(timer); if (autoPreview && check.ok) timer = window.setTimeout(() => void preview(), 450);
  }

  async function preview() {
    if (disposed || !autoPreview) return;
    if (rendering) { queued = true; return; }
    const checked = currentValid(); if (!checked.ok) return;
    rendering = true; queued = false; const atRevision = revision;
    element('[data-preview-status]').textContent = 'Drawing your process…';
    try {
      const rendered = await renderProcess(structuredClone(checked.value));
      if (disposed || atRevision !== revision || !autoPreview) return;
      const nextUrl = URL.createObjectURL(new Blob([rendered.svg], { type: 'image/svg+xml' }));
      if (imageUrl) URL.revokeObjectURL(imageUrl); imageUrl = nextUrl;
      const image = element<HTMLImageElement>('[data-image]'); image.src = imageUrl; image.hidden = false;
      image.alt = state.mode === 'guided' ? `Process diagram: ${state.graph.title}. The full steps and connections are available in the text view below.` : 'Mermaid process diagram. The exact diagram source is available in the text view below.';
      element('[data-empty-preview]').hidden = true; lastRender = rendered; renderedRevision = atRevision; applyZoom();
      element('[data-preview-status]').textContent = 'Preview is up to date. Ready to export.';
    } catch (error) {
      if (!disposed && atRevision === revision) {
        element('[data-preview-status]').textContent = 'The current diagram could not be drawn. The last valid preview is kept; exports are paused.';
        if (error instanceof InputError) {
          textOnlyRevision = atRevision;
          element('[data-preview-status]').textContent = 'Some characters cannot be drawn with the diagram font. Image/PDF exports are paused; JSON and Mermaid source can still preserve your work.';
          element('[data-errors]').textContent = error.message.replace('PDF font', 'diagram font').replace('for PDF export', 'for the diagram');
        } else element('[data-errors]').textContent = 'Check the source syntax and supported limits, then select Update preview. If the engine or font could not load, reload this page after saving a copy of your text.';
      }
    } finally {
      rendering = false; if (!disposed) { refreshActions(); if (queued && autoPreview) void preview(); }
    }
  }

  function replace(next: ProcessState, message: string) {
    state = structuredClone(next); selected = state.mode === 'guided' ? state.graph.nodes[0].id : ''; autoPreview = true; zoom = 1;
    renderEditor(); update(); say(message);
  }
  function applyZoom() {
    const image = element<HTMLImageElement>('[data-image]'), canvas = element('[data-canvas]');
    const style = getComputedStyle(canvas);
    const width = canvas.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), height = canvas.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
    const scale = lastRender ? Math.min(width / lastRender.width, height / lastRender.height, 1.5) : 1;
    image.style.width = lastRender ? `${lastRender.width * scale * zoom}px` : '100%'; image.style.maxWidth = 'none'; image.style.margin = '0 auto';
    element('[data-zoom]').textContent = zoom === 1 ? 'Fit' : `${Math.round(zoom * 100)}%`;
  }

  root.addEventListener('input', event => {
    const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
    const current = graph();
    if (target.dataset.field === 'source' && state.mode === 'source') state.source = target.value;
    else if (target.dataset.field === 'title' && current) current.title = target.value;
    else if (target.dataset.field === 'direction' && current) current.direction = target.value as 'TB' | 'LR';
    else if (target.dataset.nodeField && current) {
      const active = current.nodes.find(node => node.id === selected); if (!active) return;
      const field = target.dataset.nodeField as 'label' | 'type' | 'team' | 'description';
      if (field === 'type') active.type = target.value as ProcessNode['type']; else active[field] = target.value;
      const selectedLabel = root.querySelector(`[data-select="${selected}"] strong`); if (selectedLabel) selectedLabel.textContent = active.label || 'Unnamed step';
      const selectedType = root.querySelector(`[data-select="${selected}"] small`); if (selectedType) selectedType.textContent = kinds[active.type];
      root.querySelectorAll<HTMLSelectElement>('[data-edge-field="from"],[data-edge-field="to"]').forEach(select => { const option = [...select.options].find(option => option.value === selected); if (option) option.textContent = active.label || 'Unnamed step'; });
    } else if (target.dataset.edge && current) {
      const edge = current.edges.find(edge => edge.id === target.dataset.edge); if (!edge) return;
      edge[target.dataset.edgeField as 'from' | 'to' | 'label'] = target.value;
    } else return;
    update();
  }, { signal: abort.signal });

  root.addEventListener('change', event => {
    const target = event.target as HTMLInputElement;
    if (target.matches('[data-remember]')) {
      remember = target.checked;
      if (remember) { persist(); if (remember && currentValid().ok) say('Remembering valid changes on this device. Clear saved draft removes the stored copy.'); }
      else { try { clearSavedDraft('process-mapper'); say('Remembering is off and the saved copy has been removed.'); } catch { say('Remembering is off. Browser settings prevented clearing the stored copy; clear site data in your browser to remove it.'); } }
    }
    if (target.matches('[data-file]')) void importFile(target.files?.[0]);
  }, { signal: abort.signal });

  async function importFile(file?: File) {
    if (!file) return;
    try {
      const sourceFile = /\.(mmd|txt)$/i.test(file.name), limit = sourceFile ? PROCESS_LIMITS.sourceBytes : PROCESS_LIMITS.jsonBytes;
      if (file.size > limit) { say(`Choose a file smaller than ${sourceFile ? '30' : '150'} KB. Your current process is unchanged.`); return; }
      const text = await file.text();
      const result = sourceFile ? validateProcess({ schemaVersion: 1, tool: 'process-mapper', mode: 'source', source: text, preservedGraph: graph() ? structuredClone(graph()) : state.mode === 'source' ? state.preservedGraph : null }) : parseProcessJson(text);
      if (!result.ok) { say(`Import was not applied. ${result.errors.slice(0, 3).map(issue => issue.message).join(' ')}`); return; }
      if (confirmReplace('Import this file and replace the current map? Export the current map first if you want to keep it.')) replace(result.value, sourceFile ? 'Mermaid imported in source mode. Your previous step-by-step map is kept when available.' : 'Your editable process has been imported.');
    } catch { say('The file could not be read. Your current process is unchanged.'); }
    finally { element<HTMLInputElement>('[data-file]').value = ''; }
  }

  async function exportCurrent(format: string) {
    const textOnly = ['json', 'mmd'].includes(format) && textOnlyRevision === revision;
    if ((!textOnly && (!lastRender || renderedRevision !== revision)) || !currentValid().ok || exporting) return;
    const atRevision = revision, current = lastRender, currentState = structuredClone(state); exporting = true; refreshActions(); say('Preparing your download…');
    try {
      let data: string | Uint8Array = '', mime = '';
      if (format === 'pdf') { data = await processPdf(current!); mime = 'application/pdf'; }
      else if (format === 'png') { data = await svgToPng(current!.svg); mime = 'image/png'; }
      else if (format === 'svg') { data = current!.svg; mime = 'image/svg+xml'; }
      else if (format === 'mmd') { data = currentState.mode === 'guided' ? compileGuided(currentState.graph) : currentState.source; mime = 'text/plain;charset=utf-8'; }
      if (disposed || atRevision !== revision) { if (!disposed) say('Your map changed while the export was being prepared. Export again to download the latest version.'); return; }
      if (format === 'json') downloadJson(currentState, 'essential-process-v1.json'); else downloadBlob(data, `essential-process.${format}`, mime);
      if (format === 'json') dirty = false;
      say(`Your ${format.toUpperCase()} download is ready.`);
      root.dispatchEvent(new CustomEvent('essential-tools:export', { bubbles: true, detail: { tool: 'process-mapper', format } }));
    } catch { say('This export could not be completed. Try SVG for a large map, or split it into smaller diagrams.'); }
    finally { exporting = false; if (!disposed) refreshActions(); }
  }

  root.addEventListener('click', event => {
    const target = (event.target as HTMLElement).closest<HTMLButtonElement>('button'); if (!target || !root.contains(target)) return;
    if (target.dataset.export) { void exportCurrent(target.dataset.export); return; }
    const current = graph();
    if (target.dataset.select && current) { selected = target.dataset.select; renderEditor(); element<HTMLInputElement>('[data-node-field="label"]').focus(); return; }
    if (target.dataset.move && current) {
      const index = current.nodes.findIndex(node => node.id === target.dataset.move), next = index + Number(target.dataset.direction);
      if (next >= 0 && next < current.nodes.length) { [current.nodes[index], current.nodes[next]] = [current.nodes[next], current.nodes[index]]; selected = target.dataset.move; renderEditor(); update(); root.querySelector<HTMLButtonElement>(`[data-select="${selected}"]`)?.focus(); }
      return;
    }
    if (target.dataset.removeNode && current) {
      if (current.nodes.length > 1 && confirmReplace('Remove this step and its connections?')) { current.nodes = current.nodes.filter(node => node.id !== target.dataset.removeNode); current.edges = current.edges.filter(edge => edge.from !== target.dataset.removeNode && edge.to !== target.dataset.removeNode); renderEditor(); update(); }
      return;
    }
    if (target.dataset.removeEdge && current) { current.edges = current.edges.filter(edge => edge.id !== target.dataset.removeEdge); renderEditor(); update(); return; }
    switch (target.dataset.action) {
      case 'example': if (confirmReplace()) replace(processExamples[element<HTMLSelectElement>('[data-example]').value as keyof typeof processExamples], 'Fictional example loaded. Change the steps to fit your business.'); break;
      case 'new': case 'clear': if (confirmReplace('Clear this map and start with one new step? Export first if you want to keep this map.')) replace(empty(), 'A new map is ready. Any saved draft is updated only if remembering is enabled.'); break;
      case 'import': element<HTMLInputElement>('[data-file]').click(); break;
      case 'source': if (state.mode === 'guided' && confirmReplace('Switch to source editing? Your step-by-step map will be kept, but source edits cannot be converted back into steps.')) { replace(toSourceMode(state), 'Source mode is ready. Your previous step-by-step map is kept.'); element<HTMLTextAreaElement>('[data-field="source"]').focus(); } break;
      case 'guided': case 'restore': if (state.mode === 'source' && confirmReplace('Leave source editing and restore the previous step-by-step map, or start a new one if none was saved? Current source edits will be replaced.')) replace(restoreGuided(state) ?? empty(), 'Step-by-step editing is ready.'); break;
      case 'add-node': if (current && current.nodes.length < 100) { selected = nextId('step', current.nodes.map(node => node.id)); current.nodes.push({ id: selected, type: 'process', label: 'New step', team: '', description: '' }); renderEditor(); update(); element<HTMLInputElement>('[data-node-field="label"]').focus(); element<HTMLInputElement>('[data-node-field="label"]').select(); } break;
      case 'add-edge': if (current && current.nodes.length > 1 && current.edges.length < 200) { current.edges.push({ id: nextId('link', current.edges.map(edge => edge.id)), from: selected, to: current.nodes.find(node => node.id !== selected)!.id, label: '' }); renderEditor(); update(); const fields = root.querySelectorAll<HTMLSelectElement>('[data-edge-field="from"]'); fields[fields.length - 1]?.focus(); } break;
      case 'update': autoPreview = true; update(); window.clearTimeout(timer); void preview(); break;
      case 'stop': autoPreview = false; revision++; window.clearTimeout(timer); queued = false; refreshActions(); element('[data-preview-status]').textContent = 'Preview updates paused. A render already in progress may still finish, but will not replace this preview.'; break;
      case 'zoom-in': zoom = Math.min(4, zoom + 0.25); applyZoom(); break;
      case 'zoom-out': zoom = Math.max(0.5, zoom - 0.25); applyZoom(); break;
      case 'fit': zoom = 1; applyZoom(); element('[data-canvas]').scrollTo({ top: 0, left: 0 }); break;
      case 'load-saved': try { const saved = loadDraft('process-mapper', validateProcess); if (!saved) say('There is no saved process on this device.'); else if (confirmReplace()) replace(saved, remember ? 'Saved process loaded. Valid changes will continue to be remembered on this device.' : 'Saved process loaded. Remembering remains off unless you choose to enable it.'); } catch { say('The saved process is unavailable, damaged or uses an unsupported version. Your current map is unchanged.'); } break;
      case 'clear-saved': if (confirmReplace('Remove the saved process from this device? Your current map will stay open.')) { remember = false; element<HTMLInputElement>('[data-remember]').checked = false; try { clearSavedDraft('process-mapper'); say('The saved process was removed. Your current map is still open.'); } catch { say('Browser settings prevented clearing the saved process. Clear site data in your browser to remove it.'); } } break;
    }
  }, { signal: abort.signal });

  window.addEventListener('beforeunload', event => { if (dirty && (!remember || !currentValid().ok)) { event.preventDefault(); event.returnValue = ''; } }, { signal: abort.signal });
  window.addEventListener('resize', applyZoom, { signal: abort.signal });
  renderEditor(); summaries(); void preview();
  const cleanup = () => { if (disposed) return; disposed = true; revision++; window.clearTimeout(timer); abort.abort(); if (imageUrl) URL.revokeObjectURL(imageUrl); root.replaceChildren(); activeMounts.delete(root); };
  activeMounts.set(root, cleanup);
  return cleanup;
}

export { processSchema, processExamples, validateProcess, compileGuided } from './core';
export { renderProcess, sanitizeSvg, svgToPng, processPdf } from './render';
