import { h, Fragment } from 'preact';
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
import ShareDialog from './ShareDialog';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
import { withContainedWidgets } from './lib/containers';
import { VersionTree, DiffViewer } from './VersionHistory';

interface Props {
  initial: Interface | null;
  onDone: (iface: Interface | null) => void;
}

function blankInterface(): Interface {
  return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}

// Rename a widget id everywhere it is referenced so logic actions and plot
// layouts keep pointing at the same widget after the user customizes its id.
function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout {
  if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l;
  return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) };
}

function renameWidgetId(f: Interface, oldId: string, newId: string): Interface {
  const next: Interface = {
    ...f,
    widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)),
  };
  if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId);
  if (f.logic) {
    next.logic = {
      ...f.logic,
      nodes: f.logic.nodes.map(n =>
        n.kind === 'action.widget' && n.params.widget === oldId
          ? { ...n, params: { ...n.params, widget: newId } }
          : n),
    };
  }
  return next;
}

// ── Align / distribute helpers ──────────────────────────────────────────────

function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
  const sel = widgets.filter(w => ids.includes(w.id));
  if (sel.length < 2) return widgets;
  const minX = Math.min(...sel.map(w => w.x));
  const maxX = Math.max(...sel.map(w => w.x + w.w));
  const minY = Math.min(...sel.map(w => w.y));
  const maxY = Math.max(...sel.map(w => w.y + w.h));
  const cx   = (minX + maxX) / 2;
  const cy   = (minY + maxY) / 2;
  return widgets.map(w => {
    if (!ids.includes(w.id)) return w;
    switch (mode) {
      case 'left':     return { ...w, x: minX };
      case 'right':    return { ...w, x: maxX - w.w };
      case 'centerH':  return { ...w, x: Math.round(cx - w.w / 2) };
      case 'top':      return { ...w, y: minY };
      case 'bottom':   return { ...w, y: maxY - w.h };
      case 'centerV':  return { ...w, y: Math.round(cy - w.h / 2) };
      default:         return w;
    }
  });
}

function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): Widget[] {
  const sel = [...widgets.filter(w => ids.includes(w.id))];
  if (sel.length < 3) return widgets;
  if (axis === 'h') {
    sel.sort((a, b) => a.x - b.x);
    const totalW = sel.reduce((s, w) => s + w.w, 0);
    const span   = sel[sel.length - 1].x + sel[sel.length - 1].w - sel[0].x;
    const gap    = (span - totalW) / (sel.length - 1);
    let cx = sel[0].x;
    const positions = new Map<string, number>();
    for (const w of sel) { positions.set(w.id, cx); cx += w.w + gap; }
    return widgets.map(w => ids.includes(w.id) ? { ...w, x: Math.round(positions.get(w.id)!) } : w);
  } else {
    sel.sort((a, b) => a.y - b.y);
    const totalH = sel.reduce((s, w) => s + w.h, 0);
    const span   = sel[sel.length - 1].y + sel[sel.length - 1].h - sel[0].y;
    const gap    = (span - totalH) / (sel.length - 1);
    let cy = sel[0].y;
    const positions = new Map<string, number>();
    for (const w of sel) { positions.set(w.id, cy); cy += w.h + gap; }
    return widgets.map(w => ids.includes(w.id) ? { ...w, y: Math.round(positions.get(w.id)!) } : w);
  }
}

const STATIC_WIDGET_TYPES = [
  { type: 'textlabel', label: 'Text Label' },
  { type: 'button',    label: 'Action Button' },
  { type: 'image',     label: 'Image' },
  { type: 'link',      label: 'Link' },
  { type: 'configselect', label: 'Config Selector' },
  { type: 'container', label: 'Container Pane' },
  { type: 'table',     label: 'Table' },
];

export default function EditMode({ initial, onDone }: Props) {
  const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
  const me = useAuth();
  const writable = canWrite(me.level);
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [dirty, setDirty] = useState(false);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [snapGrid, setSnapGrid] = useState(10);
  const [showSnapGrid, setShowSnapGrid] = useState(true);
  const [showInsertMenu, setShowInsertMenu] = useState(false);
  const [showGroupMenu, setShowGroupMenu] = useState(false);
  const [showHelp, setShowHelp] = useState(false);
  const [helpSection, setHelpSection] = useState('edit');
  // Panel sharing (folder, public visibility, grants). The folders list feeds
  // ShareDialog's folder picker; fetched lazily the first time Share is opened.
  const [showShare, setShowShare] = useState(false);
  const [folders, setFolders] = useState<Folder[]>([]);
  const [leftW, setLeftW] = useState(220);
  const [rightW, setRightW] = useState(260);
  // Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
  const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout');

  // History panel
  const [showHistory, setShowHistory] = useState(false);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
  const [verReload, setVerReload] = useState(0);
  const [tag, setTag] = useState('');
  // Bumped whenever the editor content is replaced wholesale (version load /
  // promote) to force a full canvas remount, so no stale widget state lingers.
  const [canvasNonce, setCanvasNonce] = useState(0);

  // Clipboard
  const clipboard = useRef<Widget[]>([]);

  const startResize = useCallback((side: 'left' | 'right') => {
    return (e: MouseEvent) => {
      e.preventDefault();
      const startX = e.clientX;
      const startW = side === 'left' ? leftW : rightW;
      function onMove(mv: MouseEvent) {
        const dx = mv.clientX - startX;
        if (side === 'left') setLeftW(Math.max(140, startW + dx));
        else setRightW(Math.max(180, startW - dx));
      }
      function onUp() {
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      }
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
    };
  }, [leftW, rightW]);

  const openHelp = useCallback((section = 'edit') => {
    setHelpSection(section);
    setShowHelp(true);
  }, []);

  // Open the panel-sharing dialog, refreshing the folder list it offers first.
  const openShare = useCallback(() => {
    fetch('/api/v1/folders')
      .then(r => r.ok ? r.json() : [])
      .then(data => setFolders(Array.isArray(data) ? data : []))
      .catch(() => {})
      .finally(() => setShowShare(true));
  }, []);

  // Undo / redo. Snapshots capture both widgets and (for plot panels) the split
  // layout, so split/close/resize operations are undone atomically.
  type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
  const undoStack = useRef<Snapshot[]>([]);
  const redoStack = useRef<Snapshot[]>([]);
  const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
  const widgetsRef = useRef(iface.widgets || []);
  widgetsRef.current = iface.widgets || [];
  const layoutRef = useRef(iface.layout);
  layoutRef.current = iface.layout;

  const snapshot = useCallback((): Snapshot => ({
    widgets: [...widgetsRef.current],
    layout: layoutRef.current,
  }), []);

  const pushUndo = useCallback(() => {
    undoStack.current = [...undoStack.current.slice(-49), snapshot()];
    redoStack.current = [];
    setHistoryLen(undoStack.current.length);
  }, [snapshot]);

  const undo = useCallback(() => {
    if (undoStack.current.length === 0) return;
    const prev = undoStack.current[undoStack.current.length - 1];
    redoStack.current = [snapshot(), ...redoStack.current];
    undoStack.current = undoStack.current.slice(0, -1);
    setHistoryLen(undoStack.current.length);
    setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
    setDirty(true);
  }, [snapshot]);

  const redo = useCallback(() => {
    if (redoStack.current.length === 0) return;
    const next = redoStack.current[0];
    undoStack.current = [...undoStack.current, snapshot()];
    redoStack.current = redoStack.current.slice(1);
    setHistoryLen(undoStack.current.length);
    setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
    setDirty(true);
  }, [snapshot]);

  const handleWidgetsChange = useCallback((widgets: Widget[]) => {
    pushUndo();
    setIface(f => ({ ...f, widgets }));
    setDirty(true);
  }, [pushUndo]);

  // Combined widgets + layout change for plot-panel split/close/resize.
  const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => {
    pushUndo();
    setIface(f => ({ ...f, widgets, layout }));
    setDirty(true);
  }, [pushUndo]);

  const handleWidgetChange = useCallback((updated: Widget) => {
    pushUndo();
    setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) }));
    setDirty(true);
  }, [pushUndo]);

  const handleWidgetRename = useCallback((oldId: string, newId: string) => {
    pushUndo();
    setIface(f => renameWidgetId(f, oldId, newId));
    setSelectedIds(ids => ids.map(id => id === oldId ? newId : id));
    setDirty(true);
  }, [pushUndo]);

  const handleIfaceChange = useCallback((updated: Interface) => {
    setIface(updated);
    setDirty(true);
  }, []);

  const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
    setIface(f => ({ ...f, statevars }));
    setDirty(true);
    initLocalState(statevars);
  }, []);

  const handleLogicChange = useCallback((logic: LogicGraph) => {
    setIface(f => ({ ...f, logic }));
    setDirty(true);
  }, []);

  // Instantiate local state variables when the edited panel loads/changes so
  // they can be previewed live in the canvas just like real signals.
  useEffect(() => {
    initLocalState(iface.statevars);
  }, [iface.id]);

  // ── Keyboard shortcuts ─────────────────────────────────────────────────────

  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    const target = e.target as Element;
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
    if (e.key === '?') { openHelp('edit'); return; }

    // The logic editor manages its own undo/redo/clipboard/delete shortcuts.
    if (centerTab === 'logic') return;

    // Plot panels use split/close controls instead of free-form widget editing;
    // only undo/redo apply here.
    if (iface.kind === 'plot') {
      if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
      if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
      return;
    }

    const curWidgets = widgetsRef.current;

    if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
      pushUndo();
      setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) }));
      setSelectedIds([]);
      setDirty(true);
      return;
    }

    // Copy
    if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
      e.preventDefault();
      clipboard.current = curWidgets
        .filter(w => selectedIds.includes(w.id))
        .map(w => ({ ...w }));
      return;
    }

    // Paste
    if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
      e.preventDefault();
      pushUndo();
      const offset = 20;
      const pasted = clipboard.current.map(w => ({
        ...w,
        id: genWidgetId(),
        x: w.x + offset,
        y: w.y + offset,
      }));
      setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] }));
      setSelectedIds(pasted.map(w => w.id));
      setDirty(true);
      return;
    }

    if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
    if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
    if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
      e.preventDefault();
      setSelectedIds(curWidgets.map(w => w.id));
      return;
    }
    // Arrow nudge
    if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
      e.preventDefault();
      const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
      const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
      const dy = e.key === 'ArrowUp'   ? -step : e.key === 'ArrowDown'  ? step : 0;
      // Nudging a container also nudges the widgets sitting on it.
      const movers = withContainedWidgets(selectedIds, curWidgets);
      handleWidgetsChange(curWidgets.map(w =>
        movers.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
      ));
    }
  }, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);

  // ── Align / distribute ─────────────────────────────────────────────────────

  const doAlign = useCallback((mode: string) => {
    const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
    handleWidgetsChange(aligned);
  }, [iface.widgets, selectedIds, handleWidgetsChange]);

  const doDistribute = useCallback((axis: 'h' | 'v') => {
    const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
    handleWidgetsChange(distributed);
  }, [iface.widgets, selectedIds, handleWidgetsChange]);

  // ── Group selection into a container ────────────────────────────────────────
  // Wrap the selected widgets in a new container sized to their bounding box.
  // The container is appended so it paints behind the grouped widgets (the
  // canvas always renders containers first); membership is geometric, so simply
  // enclosing the widgets places them inside. Extra top inset leaves room for
  // the title / tab bar above the grouped content.
  const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => {
    setShowGroupMenu(false);
    const widgets = iface.widgets || [];
    const sel = widgets.filter(w => selectedIds.includes(w.id));
    if (sel.length < 2) return;
    const minX = Math.min(...sel.map(w => w.x));
    const minY = Math.min(...sel.map(w => w.y));
    const maxX = Math.max(...sel.map(w => w.x + w.w));
    const maxY = Math.max(...sel.map(w => w.y + w.h));
    const PAD = 12;
    const TOP = 36; // room for the title / tab bar above the grouped widgets
    const container: Widget = {
      id: genWidgetId(),
      type: 'container',
      x: minX - PAD,
      y: minY - TOP,
      w: (maxX - minX) + PAD * 2,
      h: (maxY - minY) + TOP + PAD,
      signals: [],
      options: variant === 'tabs'
        ? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' }
        : { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' },
    };
    // In tab mode, place the grouped widgets on the first tab so they show by default.
    const rest = variant === 'tabs'
      ? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w)
      : widgets;
    handleWidgetsChange([...rest, container]);
    setSelectedIds([container.id]);
  }, [iface.widgets, selectedIds, handleWidgetsChange]);

  // ── Insert static widget ───────────────────────────────────────────────────

  const insertWidget = useCallback((type: string) => {
    setShowInsertMenu(false);
    const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
    const newWidget: Widget = {
      id: genWidgetId(),
      type,
      x: 40,
      y: 40,
      w: defW,
      h: defH,
      signals: [],
      options:
        type === 'textlabel' ? { label: 'Label' } :
        type === 'button'    ? { label: 'Button', mode: 'oneshot' } :
        type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
        type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } :
        type === 'table'     ? { columns: 'name,value,unit', header: 'true' } :
        {},
    };
    handleWidgetsChange([...(iface.widgets || []), newWidget]);
    setSelectedIds([newWidget.id]);
  }, [iface.widgets, handleWidgetsChange]);

  // ── Save / export / import ─────────────────────────────────────────────────

  const handleSave = useCallback(async (saveTag = '') => {
    setSaving(true);
    setError(null);
    try {
      const xml = serializeInterface(iface);
      const base = iface.id
        ? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
        : '/api/v1/interfaces';
      const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
      const method = iface.id ? 'PUT' : 'POST';
      const res = await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/xml' },
        body: xml,
      });
      if (!res.ok) throw new Error(`Save failed (${res.status}): ${await res.text()}`);
      if (method === 'POST') {
        const json = await res.json();
        setIface(f => ({ ...f, id: json.id }));
      }
      setDirty(false);
      setTag('');
      window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
      setViewingVersion(null);
      setVerReload(k => k + 1);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setSaving(false);
    }
    // loadVersions is stable (declared below); intentionally omitted from deps.
  }, [iface, showHistory]);

  // ── Version history ────────────────────────────────────────────────────────

  const toggleHistory = useCallback(() => {
    setShowHistory(s => {
      if (!s) setVerReload(k => k + 1);
      return !s;
    });
  }, []);

  // Load a past revision into the editor non-destructively: the current id is
  // preserved, so saving the restored content creates a new revision on top.
  const loadVersion = useCallback(async (version: number) => {
    if (!iface.id) return;
    if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return;
    try {
      const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`);
      if (!res.ok) throw new Error(`Load version failed (${res.status})`);
      const restored = parseInterface(await res.text());
      restored.id = iface.id; // keep editing the same interface
      setIface(restored);
      setSelectedIds([]);
      // Viewing a past revision is not itself a change — only subsequent edits
      // dirty the editor (saving then promotes it to a new revision).
      setDirty(false);
      setCanvasNonce(n => n + 1);
      undoStack.current = [];
      redoStack.current = [];
      setHistoryLen(0);
      setViewingVersion(version);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
  }, [iface.id, dirty]);

  // Reload the now-current interface content into the editor (after a promote,
  // which the VersionTree performs server-side).
  const reloadCurrentIface = useCallback(async () => {
    if (!iface.id) return;
    try {
      const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
      if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
      const reloaded = parseInterface(await cur.text());
      setIface(reloaded);
      setSelectedIds([]);
      setDirty(false);
      setCanvasNonce(n => n + 1);
      undoStack.current = [];
      redoStack.current = [];
      setHistoryLen(0);
      setViewingVersion(null);
      window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
      setVerReload(k => k + 1);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
  }, [iface.id]);

  // ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an
  // already-persisted interface, save a new revision automatically. ──────────
  const saveRef = useRef(handleSave);
  saveRef.current = handleSave;
  const autoSaveState = useRef({ dirty, id: iface.id, saving });
  autoSaveState.current = { dirty, id: iface.id, saving };

  useEffect(() => {
    const interval = setInterval(() => {
      const s = autoSaveState.current;
      if (s.dirty && s.id && !s.saving) {
        saveRef.current('auto');
      }
    }, 5 * 60 * 1000);
    return () => clearInterval(interval);
  }, []);

  const handleExport = useCallback(() => {
    const xml = serializeInterface(iface);
    const blob = new Blob([xml], { type: 'application/xml' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
    a.click();
    URL.revokeObjectURL(url);
  }, [iface]);

  const handleImport = useCallback(() => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.xml,application/xml,text/xml';
    input.onchange = async () => {
      const file = input.files?.[0];
      if (!file) return;
      try {
        setIface(parseInterface(await file.text()));
        setSelectedIds([]);
        setDirty(true);
        undoStack.current = [];
        redoStack.current = [];
        setHistoryLen(0);
      } catch (err) {
        setError(`Import failed: ${err instanceof Error ? err.message : err}`);
      }
    };
    input.click();
  }, []);

  const handleLeave = useCallback(async () => {
    if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
    // Brand-new panel that was never saved: nothing to show — return to the
    // previously open panel (signalled by passing null).
    if (!iface.id) { onDone(null); return; }
    // Existing panel closed with unsaved edits: reopen the last saved (active)
    // version rather than carrying the discarded in-memory changes into view.
    if (dirty) {
      try {
        const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        onDone(parseInterface(await res.text()));
      } catch {
        onDone(null);
      }
      return;
    }
    onDone(iface);
  }, [dirty, onDone, iface]);

  const selectedWidget = selectedIds.length === 1
    ? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
    : null;

  const canUndo = historyLen > 0;
  const canRedo = redoStack.current.length > 0;
  const multiSelected = selectedIds.length > 1;

  return (
    <div class="edit-mode-layout" tabIndex={-1}>
      {/* ── Toolbar ── */}
      <header class="toolbar">
        <div class="toolbar-left">
          <span class="app-name">uopi</span>
          <span class="edit-badge">Edit</span>
          <input
            class="iface-name-input"
            value={iface.name}
            onInput={(e) => handleIfaceChange({ ...iface, name: (e.target as HTMLInputElement).value })}
          />
          {dirty && <span class="dirty-dot" title="Unsaved changes">●</span>}
        </div>

        <div class="toolbar-center edit-toolbar-tools">
          {/* Undo / redo */}
          <button class="toolbar-btn" onClick={undo} disabled={!canUndo} title="Undo (Ctrl+Z)">↩</button>
          <button class="toolbar-btn" onClick={redo} disabled={!canRedo} title="Redo (Ctrl+Y)">↪</button>

          <span class="toolbar-sep" />

          {/* Snap to grid */}
          <label class="snap-toggle" title="Snap to grid">
            <input
              type="checkbox"
              checked={showSnapGrid}
              onChange={(e) => {
                const on = (e.target as HTMLInputElement).checked;
                setShowSnapGrid(on);
                setSnapGrid(on ? 10 : 0);
              }}
            />
            <span>Grid</span>
          </label>

          {/* Insert static widget — not applicable to split plot panels */}
          {iface.kind !== 'plot' && (
            <div class="toolbar-dropdown">
              <button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
              {showInsertMenu && (
                <div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
                  {STATIC_WIDGET_TYPES.map(({ type, label }) => (
                    <button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
                  ))}
                </div>
              )}
            </div>
          )}

          {/* Align/distribute — only when ≥2 selected */}
          {selectedIds.length >= 2 && (
            <div class="align-toolbar">
              <span class="toolbar-sep" />
              <button class="toolbar-btn icon-only" onClick={() => doAlign('left')}   title="Align left">⬜◻◻</button>
              <button class="toolbar-btn icon-only" onClick={() => doAlign('centerH')} title="Align center H">◻⬜◻</button>
              <button class="toolbar-btn icon-only" onClick={() => doAlign('right')}  title="Align right">◻◻⬜</button>
              <button class="toolbar-btn icon-only" onClick={() => doAlign('top')}    title="Align top">⬆</button>
              <button class="toolbar-btn icon-only" onClick={() => doAlign('centerV')} title="Align center V">↕</button>
              <button class="toolbar-btn icon-only" onClick={() => doAlign('bottom')} title="Align bottom">⬇</button>
              {selectedIds.length >= 3 && (
                <div>
                  <button class="toolbar-btn icon-only" onClick={() => doDistribute('h')} title="Distribute H">↔</button>
                  <button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V">↕↕</button>
                </div>
              )}
              <span class="toolbar-sep" />
              <div class="toolbar-dropdown">
                <button class="toolbar-btn" onClick={() => setShowGroupMenu(m => !m)} title="Group selection into a container">⊞ Group ▾</button>
                {showGroupMenu && (
                  <div class="toolbar-dropdown-menu" onClick={() => setShowGroupMenu(false)}>
                    <button class="ctx-item" onClick={() => groupIntoContainer('pane')}>Into Pane</button>
                    <button class="ctx-item" onClick={() => groupIntoContainer('tabs')}>Into Tabs</button>
                  </div>
                )}
              </div>
            </div>
          )}

          {error && <span class="parse-error" title={error}>Save error</span>}
        </div>

        <div class="toolbar-right">
          <ZoomControl />
          <button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
          <button class="toolbar-btn" onClick={handleImport}>Import</button>
          <button class="toolbar-btn" onClick={handleExport}>Export</button>
          <button
            class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
            onClick={toggleHistory}
            disabled={!iface.id}
            title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
          >
            🕘 History
          </button>
          <button
            class="toolbar-btn toolbar-btn-primary"
            onClick={() => handleSave(tag)}
            disabled={saving || !writable}
            title={writable ? '' : 'You have read-only access'}
          >
            {saving ? 'Saving…' : 'Save'}
          </button>
          <button class="toolbar-btn" onClick={handleLeave}>✕ Close</button>
        </div>
      </header>

      {/* ── Three-panel body ── */}
      <div class="edit-body">
        {/* The logic tab is a self-contained flow editor with its own palette and
            inspector, so the layout-only side panes are hidden while it is open. */}
        {centerTab !== 'logic' && (
          <Fragment>
            <SignalTree
              width={leftW}
              panelId={iface.id}
              statevars={iface.statevars}
              onStateVarsChange={handleStateVarsChange}
            />
            <div class="panel-resize-handle" onMouseDown={startResize('left')} />
          </Fragment>
        )}
        <div class="edit-center">
          <div class="center-tabs">
            <button
              class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
              onClick={() => setCenterTab('layout')}
            >Layout</button>
            {me.canEditLogic && (
              <button
                class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
                onClick={() => setCenterTab('logic')}
              >Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
            )}
          </div>
          {centerTab === 'logic' && me.canEditLogic ? (
            <LogicEditor
              graph={iface.logic ?? { nodes: [], wires: [] }}
              onChange={handleLogicChange}
              widgets={iface.widgets}
              statevars={iface.statevars}
              onStateVarsChange={handleStateVarsChange}
            />
          ) : iface.kind === 'plot' ? (
            <PlotPanelCanvas
              key={canvasNonce}
              iface={iface}
              selectedIds={selectedIds}
              onSelect={setSelectedIds}
              onChange={handlePlotChange}
            />
          ) : (
            <EditCanvas
              key={canvasNonce}
              iface={iface}
              selectedIds={selectedIds}
              onSelect={setSelectedIds}
              onChange={handleWidgetsChange}
              snapGrid={snapGrid}
            />
          )}
        </div>
        {centerTab !== 'logic' && (
          <Fragment>
            <div class="panel-resize-handle" onMouseDown={startResize('right')} />
            <PropertiesPane
              selected={selectedWidget}
              multiCount={multiSelected ? selectedIds.length : 0}
              iface={iface}
              onChange={handleWidgetChange}
              onRenameId={handleWidgetRename}
              onIfaceChange={handleIfaceChange}
              onShare={writable && iface.id ? openShare : undefined}
              width={rightW}
            />
          </Fragment>
        )}
        {showHistory && (
          <div class="history-pane">
            <div class="history-pane-header">
              <span>Version History</span>
              <button class="icon-btn" onClick={() => setShowHistory(false)} title="Close">✕</button>
            </div>
            <div class="history-save-row">
              <input
                class="history-tag-input"
                placeholder="Optional label…"
                value={tag}
                onInput={(e) => setTag((e.target as HTMLInputElement).value)}
              />
              <button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
                Save snapshot
              </button>
            </div>
            {!iface.id ? (
              <div class="history-empty">Save the interface first to enable history.</div>
            ) : (
              <VersionTree
                base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
                viewing={viewingVersion}
                dirty={dirty}
                canWrite={writable}
                reloadKey={verReload}
                onView={loadVersion}
                onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
                onPromoted={reloadCurrentIface}
                onCompare={(av, bv) => setDiff({ av, bv })}
                onError={setError} />
            )}
          </div>
        )}
      </div>

      {diff && iface.id && (
        <DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
          av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
          onClose={() => setDiff(null)} />
      )}

      {showHelp && (
        <HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
      )}

      {showShare && iface.id && (
        <ShareDialog
          ifaceId={iface.id}
          ifaceName={iface.name}
          folders={folders}
          onClose={() => setShowShare(false)}
          onSaved={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
        />
      )}
    </div>
  );
}
