/**
 * Graph editor — visual entity graph with autolayout, pan/zoom, search.
 */

import { GraphCanvas } from '../core/graph-canvas.js';
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.js';
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
import {
    devicesCache, captureTemplatesCache, ppTemplatesCache,
    streamsCache, audioSourcesCache, audioTemplatesCache,
    valueSourcesCache, colorStripSourcesCache, syncClocksCache,
    outputTargetsCache, patternTemplatesCache, scenePresetsCache,
    automationsCacheObj, csptCache,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast, showConfirm } from '../core/ui.js';
import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
import { showTypePicker } from '../core/icon-select.js';
import * as P from '../core/icon-paths.js';

let _canvas = null;
let _nodeMap = null;
let _edges = null;
let _bounds = null;
let _selectedIds = new Set();
let _initialized = false;
let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })();
let _minimapVisible = true;
let _loading = false;
let _filterVisible = false;
let _filterQuery = '';  // current active filter text
let _filterKinds = new Set();   // empty = all kinds shown
let _filterRunning = null;      // null = all, true = running only, false = stopped only

// Node drag state
let _dragState = null;   // { nodeId, el, startClient, startNode, dragging }
let _justDragged = false;
let _dragListenersAdded = false;

// Manual position overrides (persisted in memory; cleared on relayout)
let _manualPositions = new Map();

// Rubber-band selection state
let _rubberBand = null;
let _rubberBandListenersAdded = false;

// Port-drag connection state
let _connectState = null;  // { sourceNodeId, sourceKind, portType, startPos, dragPath }
let _connectListenersAdded = false;

// Edge context menu
let _edgeContextMenu = null;

// Selected edge for Delete key detach
let _selectedEdge = null;  // { from, to, field, targetKind }

// Minimap position/size persisted in localStorage (with anchor corner)
const _MM_KEY = 'graph_minimap';
function _loadMinimapRect() {
    try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; }
}
function _saveMinimapRect(r) {
    localStorage.setItem(_MM_KEY, JSON.stringify(r));
}
/**
 * Anchor-based positioning: detect closest corner, store offset from that corner,
 * and reposition from that corner on resize. Works for minimap, toolbar, etc.
 */
function _anchorCorner(el, container) {
    const cr = container.getBoundingClientRect();
    const cx = el.offsetLeft + el.offsetWidth / 2;
    const cy = el.offsetTop + el.offsetHeight / 2;
    return (cy > cr.height / 2 ? 'b' : 't') + (cx > cr.width / 2 ? 'r' : 'l');
}
function _saveAnchored(el, container, saveFn) {
    const cr = container.getBoundingClientRect();
    const anchor = _anchorCorner(el, container);
    const data = {
        width: el.offsetWidth,
        height: el.offsetHeight,
        anchor,
        offsetX: anchor.includes('r') ? cr.width - el.offsetLeft - el.offsetWidth : el.offsetLeft,
        offsetY: anchor.includes('b') ? cr.height - el.offsetTop - el.offsetHeight : el.offsetTop,
    };
    saveFn(data);
    return data;
}
function _applyAnchor(el, container, saved) {
    if (!saved?.anchor) return;
    const cr = container.getBoundingClientRect();
    const w = saved.width || el.offsetWidth;
    const h = saved.height || el.offsetHeight;
    const ox = Math.max(0, saved.offsetX || 0);
    const oy = Math.max(0, saved.offsetY || 0);
    let l = saved.anchor.includes('r') ? cr.width - w - ox : ox;
    let t = saved.anchor.includes('b') ? cr.height - h - oy : oy;
    l = Math.max(0, Math.min(cr.width - el.offsetWidth, l));
    t = Math.max(0, Math.min(cr.height - el.offsetHeight, t));
    el.style.left = l + 'px';
    el.style.top = t + 'px';
}
/** True when the graph container is in fullscreen — suppress anchor persistence. */
function _isFullscreen() { return !!document.fullscreenElement; }

// Toolbar position persisted in localStorage
const _TB_KEY = 'graph_toolbar';
const _TB_MARGIN = 12;

// 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br
function _computeDockPositions(container, el) {
    const cr = container.getBoundingClientRect();
    const w = el.offsetWidth, h = el.offsetHeight;
    const m = _TB_MARGIN;
    return {
        tl: { x: m, y: m },
        tc: { x: (cr.width - w) / 2, y: m },
        tr: { x: cr.width - w - m, y: m },
        cl: { x: m, y: (cr.height - h) / 2 },
        cr: { x: cr.width - w - m, y: (cr.height - h) / 2 },
        bl: { x: m, y: cr.height - h - m },
        bc: { x: (cr.width - w) / 2, y: cr.height - h - m },
        br: { x: cr.width - w - m, y: cr.height - h - m },
    };
}

function _nearestDock(container, el) {
    const docks = _computeDockPositions(container, el);
    const cx = el.offsetLeft + el.offsetWidth / 2;
    const cy = el.offsetTop + el.offsetHeight / 2;
    let best = 'tl', bestDist = Infinity;
    for (const [key, pos] of Object.entries(docks)) {
        const dx = (pos.x + el.offsetWidth / 2) - cx;
        const dy = (pos.y + el.offsetHeight / 2) - cy;
        const dist = dx * dx + dy * dy;
        if (dist < bestDist) { bestDist = dist; best = key; }
    }
    return best;
}

function _isVerticalDock(dock) {
    return dock === 'cl' || dock === 'cr';
}

function _applyToolbarDock(el, container, dock, animate = false) {
    const isVert = _isVerticalDock(dock);
    el.classList.toggle('vertical', isVert);
    // Recompute positions after layout change
    requestAnimationFrame(() => {
        const docks = _computeDockPositions(container, el);
        const pos = docks[dock];
        if (!pos) return;
        if (animate) {
            el.style.transition = 'left 0.25s ease, top 0.25s ease';
            el.style.left = pos.x + 'px';
            el.style.top = pos.y + 'px';
            setTimeout(() => { el.style.transition = ''; }, 260);
        } else {
            el.style.left = pos.x + 'px';
            el.style.top = pos.y + 'px';
        }
    });
}

function _loadToolbarPos() {
    try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; }
}
function _saveToolbarPos(r) {
    localStorage.setItem(_TB_KEY, JSON.stringify(r));
}

// Legend position persisted in localStorage
const _LG_KEY = 'graph_legend';
function _loadLegendPos() {
    try { return JSON.parse(localStorage.getItem(_LG_KEY)); } catch { return null; }
}
function _saveLegendPos(r) {
    localStorage.setItem(_LG_KEY, JSON.stringify(r));
}

/**
 * Generic draggable panel setup.
 * @param {HTMLElement} el - The panel element
 * @param {HTMLElement} handle - The drag handle element
 * @param {object} opts - { loadFn, saveFn }
 */
function _makeDraggable(el, handle, { loadFn, saveFn }) {
    if (!el || !handle) return;
    const container = el.closest('.graph-container');
    if (!container) return;

    // Apply saved anchor position or clamp
    const saved = loadFn();
    if (saved?.anchor) {
        _applyAnchor(el, container, saved);
    } else {
        _clampElementInContainer(el, container);
    }

    let dragStart = null, dragStartPos = null;

    handle.addEventListener('pointerdown', (e) => {
        e.preventDefault();
        dragStart = { x: e.clientX, y: e.clientY };
        dragStartPos = { left: el.offsetLeft, top: el.offsetTop };
        handle.classList.add('dragging');
        handle.setPointerCapture(e.pointerId);
    });
    handle.addEventListener('pointermove', (e) => {
        if (!dragStart) return;
        const cr = container.getBoundingClientRect();
        const ew = el.offsetWidth, eh = el.offsetHeight;
        let l = dragStartPos.left + (e.clientX - dragStart.x);
        let t = dragStartPos.top + (e.clientY - dragStart.y);
        l = Math.max(0, Math.min(cr.width - ew, l));
        t = Math.max(0, Math.min(cr.height - eh, t));
        el.style.left = l + 'px';
        el.style.top = t + 'px';
    });
    handle.addEventListener('pointerup', () => {
        if (dragStart) {
            dragStart = null;
            handle.classList.remove('dragging');
            if (!_isFullscreen()) _saveAnchored(el, container, saveFn);
        }
    });
}

/* ── Public API ── */

export async function loadGraphEditor() {
    const container = document.getElementById('graph-editor-content');
    if (!container) return;
    if (_loading) return;
    _loading = true;

    // First load: replace with spinner. Re-layout: overlay spinner on top.
    if (!_initialized) {
        container.innerHTML = '<div class="loading-spinner"></div>';
    } else {
        const overlay = document.createElement('div');
        overlay.className = 'graph-loading-overlay';
        overlay.innerHTML = '<div class="loading-spinner"></div>';
        const gc = container.querySelector('.graph-container');
        if (gc) gc.appendChild(overlay);
    }

    try {
        const entities = await _fetchAllEntities();
        const { nodes, edges, bounds } = await computeLayout(entities);

        // Apply manual position overrides from previous drag operations
        _applyManualPositions(nodes, edges);

        computePorts(nodes, edges);
        _nodeMap = nodes;
        _edges = edges;
        _bounds = _calcBounds(nodes);
        _renderGraph(container);
    } finally {
        _loading = false;
    }
    // Ensure keyboard focus whenever the graph is (re-)loaded
    container.focus();
}

export function toggleGraphLegend() {
    _legendVisible = !_legendVisible;
    try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {}
    const legend = document.querySelector('.graph-legend');
    if (!legend) return;
    legend.classList.toggle('visible', _legendVisible);
    const legendBtn = document.getElementById('graph-legend-toggle');
    if (legendBtn) legendBtn.classList.toggle('active', _legendVisible);
    if (_legendVisible) {
        const container = legend.closest('.graph-container');
        if (container) {
            const saved = _loadLegendPos();
            if (saved?.anchor) {
                _applyAnchor(legend, container, saved);
            } else if (!legend.style.left) {
                // Default to top-right
                const cr = container.getBoundingClientRect();
                legend.style.left = (cr.width - legend.offsetWidth - 12) + 'px';
                legend.style.top = '12px';
            }
        }
    }
}

export function toggleGraphMinimap() {
    _minimapVisible = !_minimapVisible;
    const mm = document.querySelector('.graph-minimap');
    if (mm) mm.classList.toggle('visible', _minimapVisible);
    const mmBtn = document.getElementById('graph-minimap-toggle');
    if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible);
}

/* ── Filter type groups ── */

const _FILTER_GROUPS = [
    { key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] },
    { key: 'strip', kinds: ['color_strip_source', 'cspt'] },
    { key: 'audio', kinds: ['audio_source', 'audio_template'] },
    { key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] },
    { key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] },
];

function _buildFilterGroupsHTML() {
    const groupLabels = {
        capture: t('graph.filter_group.capture') || 'Capture',
        strip: t('graph.filter_group.strip') || 'Color Strip',
        audio: t('graph.filter_group.audio') || 'Audio',
        targets: t('graph.filter_group.targets') || 'Targets',
        other: t('graph.filter_group.other') || 'Other',
    };
    return _FILTER_GROUPS.map(g => {
        const items = g.kinds.map(kind => {
            const label = ENTITY_LABELS[kind] || kind;
            const color = ENTITY_COLORS[kind] || '#666';
            return `<label class="graph-filter-type-item" data-kind="${kind}">
                <input type="checkbox" value="${kind}">
                <span class="graph-filter-type-dot" style="background:${color}"></span>
                <span>${label}</span>
            </label>`;
        }).join('');
        return `<div class="graph-filter-type-group" data-group="${g.key}">
            <div class="graph-filter-type-group-header" data-group-toggle="${g.key}">${groupLabels[g.key]}</div>
            ${items}
        </div>`;
    }).join('');
}

function _updateFilterBadge() {
    const badge = document.querySelector('.graph-filter-types-badge');
    if (!badge) return;
    const count = _filterKinds.size;
    badge.textContent = count > 0 ? String(count) : '';
    badge.classList.toggle('visible', count > 0);
    // Also update toolbar button
    const btn = document.querySelector('.graph-filter-btn');
    if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery);
}

function _syncPopoverCheckboxes() {
    const popover = document.querySelector('.graph-filter-types-popover');
    if (!popover) return;
    popover.querySelectorAll('input[type="checkbox"]').forEach(cb => {
        cb.checked = _filterKinds.has(cb.value);
    });
}

export function toggleGraphFilterTypes(btn) {
    const popover = document.querySelector('.graph-filter-types-popover');
    if (!popover) return;
    const isOpen = popover.classList.contains('visible');
    if (isOpen) {
        popover.classList.remove('visible');
    } else {
        _syncPopoverCheckboxes();
        popover.classList.add('visible');
    }
}

export function toggleGraphFilter() {
    _filterVisible = !_filterVisible;
    const bar = document.querySelector('.graph-filter');
    if (!bar) return;
    bar.classList.toggle('visible', _filterVisible);
    if (_filterVisible) {
        const input = bar.querySelector('.graph-filter-input');
        if (input) { input.value = _filterQuery; input.focus(); }
        // Restore running pill states
        bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
            p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
        });
        _syncPopoverCheckboxes();
        _updateFilterBadge();
    } else {
        _filterKinds.clear();
        _filterRunning = null;
        // Close types popover
        const popover = bar.querySelector('.graph-filter-types-popover');
        if (popover) popover.classList.remove('visible');
        _applyFilter('');
        _updateFilterBadge();
    }
}

function _applyFilter(query) {
    if (query !== undefined) _filterQuery = query;
    const q = _filterQuery.toLowerCase().trim();
    const nodeGroup = document.querySelector('.graph-nodes');
    const edgeGroup = document.querySelector('.graph-edges');
    const mm = document.querySelector('.graph-minimap');

    if (!_nodeMap) return;

    // Parse structured filters: type:device, tag:foo, running:true
    let textPart = q;
    const parsedKinds = new Set();
    const parsedTags = [];
    const tokens = q.split(/\s+/);
    const plainTokens = [];
    for (const tok of tokens) {
        if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
        else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
        else { plainTokens.push(tok); }
    }
    textPart = plainTokens.join(' ');

    const hasTextFilter = !!textPart;
    const hasParsedKinds = parsedKinds.size > 0;
    const hasParsedTags = parsedTags.length > 0;
    const hasKindFilter = _filterKinds.size > 0;
    const hasRunningFilter = _filterRunning !== null;
    const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags;

    // Build set of matching node IDs
    const matchIds = new Set();
    for (const node of _nodeMap.values()) {
        const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart);
        const kindMatch = !hasKindFilter || _filterKinds.has(node.kind);
        const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || ''));
        const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t)));
        const runMatch = !hasRunningFilter || (node.running === _filterRunning);
        if (textMatch && kindMatch && parsedKindMatch && tagMatch && runMatch) matchIds.add(node.id);
    }

    // Apply filtered-out class to nodes
    if (nodeGroup) {
        nodeGroup.querySelectorAll('.graph-node').forEach(el => {
            el.classList.toggle('graph-filtered-out', hasAny && !matchIds.has(el.getAttribute('data-id')));
        });
    }

    // Dim edges where either endpoint is filtered out
    if (edgeGroup) {
        edgeGroup.querySelectorAll('.graph-edge').forEach(el => {
            const from = el.getAttribute('data-from');
            const to = el.getAttribute('data-to');
            el.classList.toggle('graph-filtered-out', hasAny && (!matchIds.has(from) || !matchIds.has(to)));
        });
    }

    // Dim minimap nodes
    if (mm) {
        mm.querySelectorAll('.graph-minimap-node').forEach(el => {
            const id = el.getAttribute('data-id');
            el.setAttribute('opacity', (!hasAny || matchIds.has(id)) ? '0.7' : '0.07');
        });
    }

    // Update filter button active state
    const btn = document.querySelector('.graph-filter-btn');
    if (btn) btn.classList.toggle('active', hasAny);
}

export function graphFitAll() {
    if (_canvas && _bounds) _canvas.fitAll(_bounds);
}

export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }

export function graphToggleFullscreen() {
    const container = document.querySelector('#graph-editor-content .graph-container');
    if (!container) return;
    if (document.fullscreenElement) {
        document.exitFullscreen();
    } else {
        // Move bg-anim canvas into container so it's visible in fullscreen
        const bgCanvas = document.getElementById('bg-anim-canvas');
        if (bgCanvas && !container.contains(bgCanvas)) {
            container.insertBefore(bgCanvas, container.firstChild);
        }
        container.requestFullscreen().catch(() => {});
    }
}

// Restore bg-anim canvas to body when exiting fullscreen
document.addEventListener('fullscreenchange', () => {
    if (!document.fullscreenElement) {
        const bgCanvas = document.getElementById('bg-anim-canvas');
        if (bgCanvas && bgCanvas.parentElement !== document.body) {
            document.body.insertBefore(bgCanvas, document.body.firstChild);
        }
    }
});

export async function graphRelayout() {
    if (_manualPositions.size > 0) {
        const ok = await showConfirm(t('graph.relayout_confirm'));
        if (!ok) return;
    }
    _manualPositions.clear();
    await loadGraphEditor();
}

// Entity kind → window function to open add/create modal + icon path
const _ico = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const ADD_ENTITY_MAP = [
    { kind: 'device',             fn: () => window.showAddDevice?.(),            icon: _ico(P.monitor) },
    { kind: 'capture_template',   fn: () => window.showAddTemplateModal?.(),     icon: _ico(P.camera) },
    { kind: 'pp_template',        fn: () => window.showAddPPTemplateModal?.(),   icon: _ico(P.wrench) },
    { kind: 'cspt',               fn: () => window.showAddCSPTModal?.(),         icon: _ico(P.wrench) },
    { kind: 'audio_template',     fn: () => window.showAddAudioTemplateModal?.(),icon: _ico(P.music) },
    { kind: 'picture_source',     fn: () => window.showAddStreamModal?.(),       icon: _ico(P.tv) },
    { kind: 'audio_source',       fn: () => window.showAudioSourceModal?.(),     icon: _ico(P.music) },
    { kind: 'value_source',       fn: () => window.showValueSourceModal?.(),     icon: _ico(P.hash) },
    { kind: 'color_strip_source', fn: () => window.showCSSEditor?.(),            icon: _ico(P.film) },
    { kind: 'output_target',      fn: () => window.showTargetEditor?.(),         icon: _ico(P.zap) },
    { kind: 'automation',         fn: () => window.openAutomationEditor?.(),     icon: _ico(P.clipboardList) },
    { kind: 'sync_clock',         fn: () => window.showSyncClockModal?.(),       icon: _ico(P.clock) },
    { kind: 'scene_preset',       fn: () => window.editScenePreset?.(),          icon: _ico(P.sparkles) },
    { kind: 'pattern_template',   fn: () => window.showPatternTemplateEditor?.(),icon: _ico(P.fileText) },
];

// All caches to watch for new entity creation
const ALL_CACHES = [
    devicesCache, captureTemplatesCache, ppTemplatesCache,
    streamsCache, audioSourcesCache, audioTemplatesCache,
    valueSourcesCache, colorStripSourcesCache, syncClocksCache,
    outputTargetsCache, patternTemplatesCache, scenePresetsCache,
    automationsCacheObj, csptCache,
];

export function graphAddEntity() {
    const items = ADD_ENTITY_MAP.map(item => ({
        value: item.kind,
        icon: item.icon,
        label: ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '),
    }));
    showTypePicker({
        title: t('graph.add_entity') || 'Add Entity',
        items,
        onPick: (kind) => {
            const entry = ADD_ENTITY_MAP.find(e => e.kind === kind);
            if (entry) {
                _watchForNewEntity();
                entry.fn();
            }
        },
    });
}

// Watch for new entity creation after add-entity menu action
let _entityWatchCleanup = null;

function _watchForNewEntity() {
    // Cleanup any previous watcher
    if (_entityWatchCleanup) _entityWatchCleanup();

    // Snapshot all current IDs
    const knownIds = new Set();
    for (const cache of ALL_CACHES) {
        for (const item of (cache.data || [])) {
            if (item.id) knownIds.add(item.id);
        }
    }

    const handler = (data) => {
        if (!Array.isArray(data)) return;
        for (const item of data) {
            if (item.id && !knownIds.has(item.id)) {
                // Found a new entity — reload graph and zoom to it
                const newId = item.id;
                cleanup();
                loadGraphEditor().then(() => {
                    const node = _nodeMap?.get(newId);
                    if (node && _canvas) {
                        // Animate zoom + pan together in one transition
                        _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
                    }
                    // Highlight the node and its chain (without re-panning)
                    const nodeGroup = document.querySelector('.graph-nodes');
                    if (nodeGroup) { highlightNode(nodeGroup, newId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
                    const edgeGroup = document.querySelector('.graph-edges');
                    if (edgeGroup && _edges) { highlightChain(edgeGroup, newId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
                });
                return;
            }
        }
    };

    for (const cache of ALL_CACHES) cache.subscribe(handler);

    // Auto-cleanup after 2 minutes (user might cancel the modal)
    const timeout = setTimeout(cleanup, 120_000);

    function cleanup() {
        clearTimeout(timeout);
        for (const cache of ALL_CACHES) cache.unsubscribe(handler);
        _entityWatchCleanup = null;
    }

    _entityWatchCleanup = cleanup;
}

/* ── Data fetching ── */

async function _fetchAllEntities() {
    const [
        devices, captureTemplates, ppTemplates, pictureSources,
        audioSources, audioTemplates, valueSources, colorStripSources,
        syncClocks, outputTargets, patternTemplates, scenePresets, automations,
        csptTemplates, batchStatesResp,
    ] = await Promise.all([
        devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
        streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
        valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
        outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
        automationsCacheObj.fetch(), csptCache.fetch(),
        fetchWithAuth('/output-targets/batch/states').catch(() => null),
    ]);

    // Enrich output targets with running state from batch states
    let batchStates = {};
    if (batchStatesResp && batchStatesResp.ok) {
        const data = await batchStatesResp.json().catch(() => ({}));
        batchStates = data.states || {};
    }
    const enrichedTargets = (outputTargets || []).map(t => ({
        ...t,
        running: batchStates[t.id]?.processing || false,
    }));

    return {
        devices, captureTemplates, ppTemplates, pictureSources,
        audioSources, audioTemplates, valueSources, colorStripSources,
        syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
        csptTemplates,
    };
}

/* ── Rendering ── */

function _renderGraph(container) {
    // Destroy previous canvas to clean up window event listeners
    if (_canvas) { _canvas.destroy(); _canvas = null; }

    container.innerHTML = _graphHTML();

    const svgEl = container.querySelector('.graph-svg');
    _canvas = new GraphCanvas(svgEl);

    const nodeGroup = svgEl.querySelector('.graph-nodes');
    const edgeGroup = svgEl.querySelector('.graph-edges');

    renderEdges(edgeGroup, _edges);
    renderNodes(nodeGroup, _nodeMap, {
        onNodeClick: _onNodeClick,
        onNodeDblClick: _onNodeDblClick,
        onEditNode: _onEditNode,
        onDeleteNode: _onDeleteNode,
        onStartStopNode: _onStartStopNode,
        onTestNode: _onTestNode,
        onNotificationTest: _onNotificationTest,
        onCloneNode: _onCloneNode,
        onActivatePreset: _onActivatePreset,
    });
    markOrphans(nodeGroup, _nodeMap, _edges);

    // Animated flow dots for running nodes
    const runningIds = new Set();
    for (const node of _nodeMap.values()) {
        if (node.running) runningIds.add(node.id);
    }
    renderFlowDots(edgeGroup, _edges, runningIds);

    // Set bounds for view clamping, then fit
    if (_bounds) _canvas.setBounds(_bounds);
    requestAnimationFrame(() => {
        if (_canvas && _bounds) _canvas.fitAll(_bounds, false);
    });

    _canvas.onZoomChange = (z) => {
        const label = container.querySelector('.graph-zoom-label');
        if (label) label.textContent = `${Math.round(z * 100)}%`;
    };

    _canvas.onViewChange = (vp) => {
        _updateMinimapViewport(container.querySelector('.graph-minimap'), vp);
    };

    const legendEl = container.querySelector('.graph-legend');
    _renderLegend(legendEl);
    _initLegendDrag(legendEl);
    _initMinimap(container.querySelector('.graph-minimap'));
    _initToolbarDrag(container.querySelector('.graph-toolbar'));
    _initResizeClamp(container);
    _initNodeDrag(nodeGroup, edgeGroup);
    _initPortDrag(svgEl, nodeGroup, edgeGroup);
    _initRubberBand(svgEl);

    // Edge click: select edge and its endpoints
    edgeGroup.addEventListener('click', (e) => {
        const edgePath = e.target.closest('.graph-edge');
        if (!edgePath) return;
        e.stopPropagation();
        _onEdgeClick(edgePath, nodeGroup, edgeGroup);
    });

    // Edge right-click: detach connection
    edgeGroup.addEventListener('contextmenu', (e) => {
        const edgePath = e.target.closest('.graph-edge');
        if (!edgePath) return;
        e.preventDefault();
        e.stopPropagation();
        _onEdgeContextMenu(edgePath, e, container);
    });

    const filterInput = container.querySelector('.graph-filter-input');
    if (filterInput) {
        filterInput.addEventListener('input', (e) => _applyFilter(e.target.value));
        filterInput.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') toggleGraphFilter();
        });
    }
    const filterClear = container.querySelector('.graph-filter-clear');
    if (filterClear) {
        filterClear.addEventListener('click', () => {
            if (filterInput) filterInput.value = '';
            _filterKinds.clear();
            _filterRunning = null;
            container.querySelectorAll('.graph-filter-pill').forEach(p => p.classList.remove('active'));
            _applyFilter('');
        });
    }

    // Entity type checkboxes in popover
    container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach(cb => {
        cb.addEventListener('change', () => {
            if (cb.checked) _filterKinds.add(cb.value);
            else _filterKinds.delete(cb.value);
            _updateFilterBadge();
            _applyFilter();
        });
    });

    // Group header toggles (click group label → toggle all in group)
    container.querySelectorAll('[data-group-toggle]').forEach(header => {
        header.addEventListener('click', () => {
            const groupKey = header.dataset.groupToggle;
            const group = _FILTER_GROUPS.find(g => g.key === groupKey);
            if (!group) return;
            const allActive = group.kinds.every(k => _filterKinds.has(k));
            group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); });
            _syncPopoverCheckboxes();
            _updateFilterBadge();
            _applyFilter();
        });
    });

    // Close popover when clicking outside
    container.addEventListener('click', (e) => {
        const popover = container.querySelector('.graph-filter-types-popover');
        if (!popover || !popover.classList.contains('visible')) return;
        if (!e.target.closest('.graph-filter-types-popover') && !e.target.closest('.graph-filter-types-btn')) {
            popover.classList.remove('visible');
        }
    });

    // Running/stopped pills
    container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => {
        pill.addEventListener('click', () => {
            const val = pill.dataset.running === 'true';
            if (_filterRunning === val) {
                _filterRunning = null;
                pill.classList.remove('active');
            } else {
                _filterRunning = val;
                container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active'));
                pill.classList.add('active');
            }
            _updateFilterBadge();
            _applyFilter();
        });
    });

    // Restore active filter if re-rendering
    if ((_filterQuery || _filterKinds.size || _filterRunning !== null) && _filterVisible) {
        const bar = container.querySelector('.graph-filter');
        if (bar) {
            bar.classList.add('visible');
            _syncPopoverCheckboxes();
            bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => {
                p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running);
            });
        }
        _updateFilterBadge();
        _applyFilter(_filterQuery);
    }

    // Deselect on click on empty space (not after a pan gesture)
    svgEl.addEventListener('click', (e) => {
        _dismissEdgeContextMenu();
        if (_canvas.wasPanning) return;
        if (e.shiftKey) return; // Shift+click reserved for rubber-band
        if (!e.target.closest('.graph-node')) {
            _deselect(nodeGroup, edgeGroup);
        }
    });

    // Double-click empty → fit all
    svgEl.addEventListener('dblclick', (e) => {
        if (!e.target.closest('.graph-node')) graphFitAll();
    });

    // Prevent text selection on SVG drag
    svgEl.addEventListener('mousedown', (e) => {
        // Prevent default only on the SVG background / edges, not on inputs
        if (!e.target.closest('input, textarea, select')) {
            e.preventDefault();
        }
    });

    container.addEventListener('keydown', _onKeydown);
    container.setAttribute('tabindex', '0');
    container.style.outline = 'none';
    // Focus the container so keyboard shortcuts work immediately
    container.focus();
    // Re-focus when clicking inside the graph
    svgEl.addEventListener('pointerdown', () => container.focus());
    _initialized = true;
}

function _deselect(nodeGroup, edgeGroup) {
    _selectedIds.clear();
    _selectedEdge = null;
    if (nodeGroup) {
        updateSelection(nodeGroup, _selectedIds);
        nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
    }
    if (edgeGroup) clearEdgeHighlights(edgeGroup);
}

function _graphHTML() {
    const mmRect = _loadMinimapRect();
    // Only set size from saved state; position is applied in _initMinimap via anchor logic
    const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : '';
    return `
        <div class="graph-container">
            <div class="graph-toolbar">
                <span class="graph-toolbar-drag" title="Drag to move">⠿</span>
                <button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
                    <svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
                </button>
                <button class="btn-icon" onclick="graphZoomIn()" title="${t('graph.zoom_in')}">
                    <svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>
                </button>
                <span class="graph-zoom-label">100%</span>
                <button class="btn-icon" onclick="graphZoomOut()" title="${t('graph.zoom_out')}">
                    <svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
                </button>
                <span class="graph-toolbar-sep"></span>
                <button class="btn-icon" onclick="openCommandPalette()" title="${t('graph.search')} (/)">
                    <svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
                </button>
                <button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
                    <svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
                </button>
                <button class="btn-icon${_legendVisible ? ' active' : ''}" id="graph-legend-toggle" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
                    <svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
                </button>
                <button class="btn-icon${_minimapVisible ? ' active' : ''}" id="graph-minimap-toggle" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
                    <svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
                </button>
                <span class="graph-toolbar-sep"></span>
                <button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
                    <svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
                </button>
                <button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
                    <svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
                </button>
                <span class="graph-toolbar-sep"></span>
                <button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
                    <svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
                </button>
                <button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)">
                    <svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
                </button>
                <span class="graph-toolbar-sep"></span>
                <button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
                    <svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
                </button>
                <button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
                    <svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
                </button>
            </div>

            <div class="graph-legend${_legendVisible ? ' visible' : ''}">
                <div class="graph-legend-header">
                    <span class="graph-legend-title">${t('graph.legend')}</span>
                </div>
                <div class="graph-legend-body"></div>
            </div>

            <div class="graph-minimap${_minimapVisible ? ' visible' : ''}" style="${mmStyle}">
                <div class="graph-minimap-header"><span>${t('graph.minimap')}</span></div>
                <div class="graph-minimap-resize graph-minimap-resize-br"></div>
                <div class="graph-minimap-resize graph-minimap-resize-bl"></div>
                <svg></svg>
            </div>

            <div class="graph-filter">
                <div class="graph-filter-row">
                    <svg class="graph-filter-icon" viewBox="0 0 24 24" width="16" height="16"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
                    <input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
                    <button class="graph-filter-clear" title="${t('graph.filter_clear')}">&times;</button>
                </div>
                <div class="graph-filter-row graph-filter-actions">
                    <button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
                        ${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
                    </button>
                    <button class="graph-filter-pill graph-filter-running" data-running="true" style="--pill-color:var(--success-color)" title="${t('graph.filter_running') || 'Running'}">${t('graph.filter_running') || 'Running'}</button>
                    <button class="graph-filter-pill graph-filter-running" data-running="false" style="--pill-color:var(--text-muted)" title="${t('graph.filter_stopped') || 'Stopped'}">${t('graph.filter_stopped') || 'Stopped'}</button>
                </div>
                <div class="graph-filter-types-popover">
                    ${_buildFilterGroupsHTML()}
                </div>
            </div>

            <svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
                <defs>
                    <linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
                        <stop offset="0%" stop-color="var(--primary-color)"/>
                        <stop offset="50%" stop-color="var(--success-color)"/>
                        <stop offset="100%" stop-color="var(--primary-color)"/>
                    </linearGradient>
                </defs>
                <rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
                <g class="graph-root">
                    <g class="graph-edges"></g>
                    <g class="graph-nodes"></g>
                    <rect class="graph-selection-rect" x="0" y="0" width="0" height="0" style="display:none"/>
                </g>
            </svg>

        </div>`;
}

/* ── Legend ── */

function _renderLegend(legendEl) {
    if (!legendEl) return;
    const body = legendEl.querySelector('.graph-legend-body');
    if (!body) return;
    let html = '';
    for (const [kind, color] of Object.entries(ENTITY_COLORS)) {
        const label = ENTITY_LABELS[kind] || kind;
        html += `<div class="graph-legend-item">
            <span class="graph-legend-dot" style="background:${color}"></span>
            <span>${label}</span>
        </div>`;
    }
    body.innerHTML = html;
}

function _initLegendDrag(legendEl) {
    if (!legendEl) return;
    const handle = legendEl.querySelector('.graph-legend-header');
    _makeDraggable(legendEl, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos });
}

/* ── Minimap (draggable header & resize handle) ── */

function _initMinimap(mmEl) {
    if (!mmEl || !_nodeMap || !_bounds) return;
    const svg = mmEl.querySelector('svg');
    if (!svg) return;
    const container = mmEl.closest('.graph-container');

    const pad = 10;
    const vb = `${_bounds.x - pad} ${_bounds.y - pad} ${_bounds.width + pad * 2} ${_bounds.height + pad * 2}`;
    svg.setAttribute('viewBox', vb);

    let html = '';
    for (const node of _nodeMap.values()) {
        const color = getNodeDisplayColor(node.id, node.kind);
        html += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
    }
    // Add viewport rect (updated live via _updateMinimapViewport)
    html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
    svg.innerHTML = html;

    // Apply saved anchored position or default to bottom-right
    const saved = _loadMinimapRect();
    if (saved?.anchor) {
        if (saved.width) mmEl.style.width = saved.width + 'px';
        if (saved.height) mmEl.style.height = saved.height + 'px';
        _applyAnchor(mmEl, container, saved);
    } else if (!mmEl.style.left || mmEl.style.left === '0px') {
        const cr = container.getBoundingClientRect();
        mmEl.style.width = '200px';
        mmEl.style.height = '130px';
        mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
        mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
    }

    // Initial viewport update
    if (_canvas) {
        _updateMinimapViewport(mmEl, _canvas.getViewport());
    }

    // Helper to clamp minimap within container
    function _clampMinimap() {
        _clampElementInContainer(mmEl, container);
    }

    // ── Click on minimap SVG → pan main canvas to that point ──
    let mmDraggingViewport = false;
    svg.addEventListener('pointerdown', (e) => {
        if (!_canvas || !_bounds) return;
        e.preventDefault();
        e.stopPropagation();
        mmDraggingViewport = true;
        svg.setPointerCapture(e.pointerId);
        _panToMinimapPoint(svg, e);
    });
    svg.addEventListener('pointermove', (e) => {
        if (!mmDraggingViewport) return;
        _panToMinimapPoint(svg, e);
    });
    svg.addEventListener('pointerup', () => { mmDraggingViewport = false; });

    // ── Drag via header (uses shared _makeDraggable) ──
    const header = mmEl.querySelector('.graph-minimap-header');
    _makeDraggable(mmEl, header, { loadFn: () => null, saveFn: _saveMinimapRect });

    // ── Resize handles ──
    _initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br');
    _initResizeHandle(mmEl.querySelector('.graph-minimap-resize-bl'), 'bl');

    function _initResizeHandle(rh, corner) {
        if (!rh) return;
        let rs = null, rss = null;
        rh.addEventListener('pointerdown', (e) => {
            e.preventDefault(); e.stopPropagation();
            rs = { x: e.clientX, y: e.clientY };
            rss = { w: mmEl.offsetWidth, h: mmEl.offsetHeight, left: mmEl.offsetLeft };
            rh.setPointerCapture(e.pointerId);
        });
        rh.addEventListener('pointermove', (e) => {
            if (!rs) return;
            const cr = container.getBoundingClientRect();
            const dy = e.clientY - rs.y;
            const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
            mmEl.style.height = newH + 'px';

            if (corner === 'br') {
                // Bottom-right: grow width rightward, left stays fixed
                const dx = e.clientX - rs.x;
                const maxW = cr.width - mmEl.offsetLeft - 4;
                mmEl.style.width = Math.max(120, Math.min(maxW, rss.w + dx)) + 'px';
            } else {
                // Bottom-left: grow width leftward, right edge stays fixed
                const dx = rs.x - e.clientX;
                const newW = Math.max(120, Math.min(cr.width - 20, rss.w + dx));
                const newLeft = rss.left - (newW - rss.w);
                mmEl.style.width = newW + 'px';
                mmEl.style.left = Math.max(0, newLeft) + 'px';
            }
            _clampMinimap();
        });
        rh.addEventListener('pointerup', () => { if (rs) { rs = null; if (!_isFullscreen()) _saveAnchored(mmEl, container, _saveMinimapRect); } });
    }
}

function _panToMinimapPoint(svg, e) {
    if (!_canvas || !_bounds) return;
    const svgRect = svg.getBoundingClientRect();
    const pad = 10;
    const bx = _bounds.x - pad, by = _bounds.y - pad;
    const bw = _bounds.width + pad * 2, bh = _bounds.height + pad * 2;
    const gx = bx + ((e.clientX - svgRect.left) / svgRect.width) * bw;
    const gy = by + ((e.clientY - svgRect.top) / svgRect.height) * bh;
    _canvas.panTo(gx, gy, false);
}

function _updateMinimapViewport(mmEl, vp) {
    if (!mmEl) return;
    const rect = mmEl.querySelector('.graph-minimap-viewport');
    if (!rect) return;
    rect.setAttribute('x', vp.x);
    rect.setAttribute('y', vp.y);
    rect.setAttribute('width', vp.width);
    rect.setAttribute('height', vp.height);
}

function _mmRect(mmEl) {
    return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight };
}

/* ── Shared element clamping ── */

/** Clamp an absolutely-positioned element within its container. */
function _clampElementInContainer(el, container) {
    if (!el || !container) return;
    const cr = container.getBoundingClientRect();
    const ew = el.offsetWidth, eh = el.offsetHeight;
    if (!ew || !eh) return;
    let l = el.offsetLeft, t = el.offsetTop;
    const cl = Math.max(0, Math.min(cr.width - ew, l));
    const ct = Math.max(0, Math.min(cr.height - eh, t));
    if (cl !== l || ct !== t) {
        el.style.left = cl + 'px';
        el.style.top = ct + 'px';
    }
    return { left: cl, top: ct };
}

let _resizeObserver = null;

function _reanchorPanel(el, container, loadFn) {
    if (!el) return;
    if (_isFullscreen()) {
        _clampElementInContainer(el, container);
    } else {
        const saved = loadFn();
        if (saved?.anchor) {
            _applyAnchor(el, container, saved);
        } else {
            _clampElementInContainer(el, container);
        }
    }
}

function _initResizeClamp(container) {
    if (_resizeObserver) _resizeObserver.disconnect();
    _resizeObserver = new ResizeObserver(() => {
        _reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
        _reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos);
        _reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos);
        // Toolbar uses dock system, not anchor system
        const tb = container.querySelector('.graph-toolbar');
        if (tb) {
            const saved = _loadToolbarPos();
            const dock = saved?.dock || 'tl';
            _applyToolbarDock(tb, container, dock, false);
        }
    });
    _resizeObserver.observe(container);
}

/* ── Toolbar drag ── */

let _dockIndicators = null;

function _showDockIndicators(container) {
    _hideDockIndicators();
    const cr = container.getBoundingClientRect();
    const m = _TB_MARGIN + 16; // offset from edges
    // 8 dock positions as percentage-based fixed points
    const positions = {
        tl: { x: m, y: m },
        tc: { x: cr.width / 2, y: m },
        tr: { x: cr.width - m, y: m },
        cl: { x: m, y: cr.height / 2 },
        cr: { x: cr.width - m, y: cr.height / 2 },
        bl: { x: m, y: cr.height - m },
        bc: { x: cr.width / 2, y: cr.height - m },
        br: { x: cr.width - m, y: cr.height - m },
    };
    const wrap = document.createElement('div');
    wrap.className = 'graph-dock-indicators';
    for (const [key, pos] of Object.entries(positions)) {
        const dot = document.createElement('div');
        dot.className = 'graph-dock-dot';
        dot.dataset.dock = key;
        dot.style.left = pos.x + 'px';
        dot.style.top = pos.y + 'px';
        wrap.appendChild(dot);
    }
    container.appendChild(wrap);
    _dockIndicators = wrap;
}

function _updateDockHighlight(container, tbEl) {
    if (!_dockIndicators) return;
    const nearest = _nearestDock(container, tbEl);
    _dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => {
        d.classList.toggle('nearest', d.dataset.dock === nearest);
    });
}

function _hideDockIndicators() {
    if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; }
}

function _initToolbarDrag(tbEl) {
    if (!tbEl) return;
    const container = tbEl.closest('.graph-container');
    if (!container) return;
    const handle = tbEl.querySelector('.graph-toolbar-drag');
    if (!handle) return;

    // Restore saved dock position
    const saved = _loadToolbarPos();
    const dock = saved?.dock || 'tl';
    _applyToolbarDock(tbEl, container, dock, false);

    let dragStart = null, dragStartPos = null;

    handle.addEventListener('pointerdown', (e) => {
        e.preventDefault();
        // If vertical, temporarily switch to horizontal for free dragging
        tbEl.classList.remove('vertical');
        requestAnimationFrame(() => {
            dragStart = { x: e.clientX, y: e.clientY };
            dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop };
            handle.classList.add('dragging');
            handle.setPointerCapture(e.pointerId);
            _showDockIndicators(container);
        });
    });
    handle.addEventListener('pointermove', (e) => {
        if (!dragStart) return;
        const cr = container.getBoundingClientRect();
        const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
        let l = dragStartPos.left + (e.clientX - dragStart.x);
        let t = dragStartPos.top + (e.clientY - dragStart.y);
        l = Math.max(0, Math.min(cr.width - ew, l));
        t = Math.max(0, Math.min(cr.height - eh, t));
        tbEl.style.left = l + 'px';
        tbEl.style.top = t + 'px';
        _updateDockHighlight(container, tbEl);
    });
    handle.addEventListener('pointerup', () => {
        if (!dragStart) return;
        dragStart = null;
        handle.classList.remove('dragging');
        _hideDockIndicators();
        // Snap to nearest dock position
        const newDock = _nearestDock(container, tbEl);
        _applyToolbarDock(tbEl, container, newDock, true);
        _saveToolbarPos({ dock: newDock });
    });
}


/* ── Node callbacks ── */

function _onNodeClick(node, e) {
    if (_justDragged) return; // suppress click after node drag

    const nodeGroup = document.querySelector('.graph-nodes');
    const edgeGroup = document.querySelector('.graph-edges');

    if (e.shiftKey) {
        if (_selectedIds.has(node.id)) _selectedIds.delete(node.id);
        else _selectedIds.add(node.id);
    } else {
        _selectedIds.clear();
        _selectedIds.add(node.id);
    }
    if (nodeGroup) updateSelection(nodeGroup, _selectedIds);

    if (_selectedIds.size === 1 && edgeGroup && _edges) {
        const chain = highlightChain(edgeGroup, node.id, _edges);
        if (nodeGroup) {
            nodeGroup.querySelectorAll('.graph-node').forEach(n => {
                n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
            });
        }
    } else if (edgeGroup) {
        clearEdgeHighlights(edgeGroup);
        if (nodeGroup) nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
    }
}

function _onNodeDblClick(node) {
    // Zoom to node and center it in one step
    if (_canvas) {
        _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
    }
}

/** Navigate graph to a node by entity ID — zoom + highlight. */
export function graphNavigateToNode(entityId) {
    const node = _nodeMap?.get(entityId);
    if (!node || !_canvas) return false;
    _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
    const nodeGroup = document.querySelector('.graph-nodes');
    if (nodeGroup) { highlightNode(nodeGroup, entityId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
    const edgeGroup = document.querySelector('.graph-edges');
    if (edgeGroup && _edges) { highlightChain(edgeGroup, entityId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
    return true;
}

function _onEditNode(node) {
    const fnMap = {
        device: () => window.showSettings?.(node.id),
        capture_template: () => window.editTemplate?.(node.id),
        pp_template: () => window.editPPTemplate?.(node.id),
        audio_template: () => window.editAudioTemplate?.(node.id),
        pattern_template: () => window.showPatternTemplateEditor?.(node.id),
        picture_source: () => window.editStream?.(node.id),
        audio_source: () => window.editAudioSource?.(node.id),
        value_source: () => window.editValueSource?.(node.id),
        color_strip_source: () => window.showCSSEditor?.(node.id),
        sync_clock: () => window.editSyncClock?.(node.id),
        output_target: () => window.showTargetEditor?.(node.id),
        cspt: () => window.editCSPT?.(node.id),
        scene_preset: () => window.editScenePreset?.(node.id),
        automation: () => window.openAutomationEditor?.(node.id),
    };
    fnMap[node.kind]?.();
}

function _onDeleteNode(node) {
    const fnMap = {
        device: () => window.removeDevice?.(node.id),
        capture_template: () => window.deleteTemplate?.(node.id),
        pp_template: () => window.deletePPTemplate?.(node.id),
        audio_template: () => window.deleteAudioTemplate?.(node.id),
        pattern_template: () => window.deletePatternTemplate?.(node.id),
        picture_source: () => window.deleteStream?.(node.id),
        audio_source: () => window.deleteAudioSource?.(node.id),
        value_source: () => window.deleteValueSource?.(node.id),
        color_strip_source: () => window.deleteColorStrip?.(node.id),
        output_target: () => window.deleteTarget?.(node.id),
        scene_preset: () => window.deleteScenePreset?.(node.id),
        automation: () => window.deleteAutomation?.(node.id),
        cspt: () => window.deleteCSPT?.(node.id),
        sync_clock: () => window.deleteSyncClock?.(node.id),
    };
    fnMap[node.kind]?.();
}

async function _bulkDeleteSelected() {
    const count = _selectedIds.size;
    if (count < 2) return;
    const ok = await showConfirm(
        (t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count))
    );
    if (!ok) return;
    for (const id of _selectedIds) {
        const node = _nodeMap.get(id);
        if (node) _onDeleteNode(node);
    }
    _selectedIds.clear();
}

function _onCloneNode(node) {
    const fnMap = {
        device: () => window.cloneDevice?.(node.id),
        capture_template: () => window.cloneCaptureTemplate?.(node.id),
        pp_template: () => window.clonePPTemplate?.(node.id),
        audio_template: () => window.cloneAudioTemplate?.(node.id),
        pattern_template: () => window.clonePatternTemplate?.(node.id),
        picture_source: () => window.cloneStream?.(node.id),
        audio_source: () => window.cloneAudioSource?.(node.id),
        value_source: () => window.cloneValueSource?.(node.id),
        color_strip_source: () => window.cloneColorStrip?.(node.id),
        output_target: () => window.cloneTarget?.(node.id),
        scene_preset: () => window.cloneScenePreset?.(node.id),
        automation: () => window.cloneAutomation?.(node.id),
        cspt: () => window.cloneCSPT?.(node.id),
        sync_clock: () => window.cloneSyncClock?.(node.id),
    };
    _watchForNewEntity();
    fnMap[node.kind]?.();
}

async function _onActivatePreset(node) {
    if (node.kind !== 'scene_preset') return;
    try {
        const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' });
        if (resp.ok) {
            showToast(t('scene_preset.activated') || 'Preset activated', 'success');
            setTimeout(() => loadGraphEditor(), 500);
        } else {
            const err = await resp.json().catch(() => ({}));
            showToast(err.detail || 'Activation failed', 'error');
        }
    } catch (e) {
        showToast(e.message, 'error');
    }
}

function _onStartStopNode(node) {
    const newRunning = !node.running;
    // Optimistic update — toggle UI immediately
    _updateNodeRunning(node.id, newRunning);

    if (node.kind === 'output_target') {
        const action = newRunning ? 'start' : 'stop';
        fetchWithAuth(`/output-targets/${node.id}/${action}`, { method: 'POST' }).then(resp => {
            if (resp.ok) {
                showToast(t(action === 'start' ? 'device.started' : 'device.stopped'), 'success');
            } else {
                resp.json().catch(() => ({})).then(err => {
                    showToast(err.detail || t(`target.error.${action}_failed`), 'error');
                });
                _updateNodeRunning(node.id, !newRunning); // revert
            }
        }).catch(() => { _updateNodeRunning(node.id, !newRunning); });
    } else if (node.kind === 'sync_clock') {
        const action = newRunning ? 'resume' : 'pause';
        fetchWithAuth(`/sync-clocks/${node.id}/${action}`, { method: 'POST' }).then(resp => {
            if (resp.ok) {
                showToast(t(action === 'pause' ? 'sync_clock.paused' : 'sync_clock.resumed'), 'success');
            } else {
                _updateNodeRunning(node.id, !newRunning); // revert
            }
        }).catch(() => { _updateNodeRunning(node.id, !newRunning); });
    } else if (node.kind === 'automation') {
        fetchWithAuth(`/automations/${node.id}`, {
            method: 'PUT',
            body: JSON.stringify({ enabled: newRunning }),
        }).then(resp => {
            if (resp.ok) {
                showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success');
            } else {
                _updateNodeRunning(node.id, !newRunning);
            }
        }).catch(() => { _updateNodeRunning(node.id, !newRunning); });
    }
}

/** Update a node's running state in the model and patch it in-place (no re-render). */
function _updateNodeRunning(nodeId, running) {
    const node = _nodeMap?.get(nodeId);
    if (!node) return;
    node.running = running;
    const nodeGroup = document.querySelector('.graph-nodes');
    const edgeGroup = document.querySelector('.graph-edges');
    if (nodeGroup) {
        patchNodeRunning(nodeGroup, node);
    }
    // Update flow dots since running set changed
    if (edgeGroup) {
        const runningIds = new Set();
        for (const n of _nodeMap.values()) {
            if (n.running) runningIds.add(n.id);
        }
        renderFlowDots(edgeGroup, _edges, runningIds);
    }
}

function _onTestNode(node) {
    const fnMap = {
        capture_template: () => window.showTestTemplateModal?.(node.id),
        pp_template: () => window.showTestPPTemplateModal?.(node.id),
        audio_template: () => window.showTestAudioTemplateModal?.(node.id),
        picture_source: () => window.showTestStreamModal?.(node.id),
        audio_source: () => window.testAudioSource?.(node.id),
        value_source: () => window.testValueSource?.(node.id),
        color_strip_source: () => window.testColorStrip?.(node.id),
        cspt: () => window.testCSPT?.(node.id),
        output_target: () => window.testKCTarget?.(node.id),
    };
    fnMap[node.kind]?.();
}

function _onNotificationTest(node) {
    if (node.kind === 'color_strip_source' && node.subtype === 'notification') {
        window.testNotification?.(node.id);
    }
}

/* ── Keyboard ── */

function _onKeydown(e) {
    // Trap Tab inside the graph to prevent focus escaping to footer
    if (e.key === 'Tab') { e.preventDefault(); return; }

    // Skip when typing in search input (except Escape/F11)
    const inInput = e.target.matches('input, textarea, select');

    if (e.key === '/' && !inInput) { e.preventDefault(); window.openCommandPalette?.(); }
    if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); }
    if (e.key === 'Escape') {
        if (_filterVisible) { toggleGraphFilter(); }
        else {
            const ng = document.querySelector('.graph-nodes');
            const eg = document.querySelector('.graph-edges');
            _deselect(ng, eg);
        }
    }
    // Delete key → detach selected edge or delete selected node(s)
    if (e.key === 'Delete' && !inInput) {
        if (_selectedEdge) {
            _detachSelectedEdge();
        } else if (_selectedIds.size === 1) {
            const nodeId = [..._selectedIds][0];
            const node = _nodeMap.get(nodeId);
            if (node) _onDeleteNode(node);
        } else if (_selectedIds.size > 1) {
            _bulkDeleteSelected();
        }
    }
    // Ctrl+A → select all
    if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !inInput) {
        e.preventDefault();
        _selectAll();
    }
    // F11 → fullscreen
    if (e.key === 'F11') {
        e.preventDefault();
        graphToggleFullscreen();
    }
    // + → add entity
    if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
        graphAddEntity();
    }
    // ? → keyboard shortcuts help
    if (e.key === '?' && !inInput) {
        e.preventDefault();
        toggleGraphHelp();
    }
    // Ctrl+Z / Ctrl+Shift+Z → undo/redo
    if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) {
        e.preventDefault();
        if (e.shiftKey) _redo(); else _undo();
    }
    // Arrow keys / WASD → spatial navigation between nodes
    if (_selectedIds.size <= 1 && !inInput) {
        const dir = _arrowDir(e);
        if (dir) {
            e.preventDefault();
            _navigateDirection(dir);
        }
    }
}

function _arrowDir(e) {
    if (e.ctrlKey || e.metaKey) return null;
    switch (e.key) {
        case 'ArrowLeft':  case 'a': case 'A': return 'left';
        case 'ArrowRight': case 'd': case 'D': return 'right';
        case 'ArrowUp':    case 'w': case 'W': return 'up';
        case 'ArrowDown':  case 's': case 'S': return 'down';
        default: return null;
    }
}

function _navigateDirection(dir) {
    if (!_nodeMap || _nodeMap.size === 0) return;

    // Get current anchor node
    let anchor = null;
    if (_selectedIds.size === 1) {
        anchor = _nodeMap.get([..._selectedIds][0]);
    }
    if (!anchor) {
        // Select first visible node (topmost-leftmost)
        let best = null;
        for (const n of _nodeMap.values()) {
            if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
        }
        if (best) {
            _selectedIds.clear();
            _selectedIds.add(best.id);
            const ng = document.querySelector('.graph-nodes');
            const eg = document.querySelector('.graph-edges');
            if (ng) updateSelection(ng, _selectedIds);
            if (eg) clearEdgeHighlights(eg);
            if (_canvas) _canvas.panTo(best.x + best.width / 2, best.y + best.height / 2, true);
        }
        return;
    }

    const cx = anchor.x + anchor.width / 2;
    const cy = anchor.y + anchor.height / 2;
    let bestNode = null;
    let bestDist = Infinity;

    for (const n of _nodeMap.values()) {
        if (n.id === anchor.id) continue;
        const nx = n.x + n.width / 2;
        const ny = n.y + n.height / 2;
        const dx = nx - cx;
        const dy = ny - cy;

        // Check direction constraint
        let valid = false;
        if (dir === 'right' && dx > 10) valid = true;
        if (dir === 'left' && dx < -10) valid = true;
        if (dir === 'down' && dy > 10) valid = true;
        if (dir === 'up' && dy < -10) valid = true;
        if (!valid) continue;

        // Distance with directional bias (favor the primary axis)
        const primaryDist = dir === 'left' || dir === 'right' ? Math.abs(dx) : Math.abs(dy);
        const crossDist = dir === 'left' || dir === 'right' ? Math.abs(dy) : Math.abs(dx);
        const dist = primaryDist + crossDist * 2;

        if (dist < bestDist) {
            bestDist = dist;
            bestNode = n;
        }
    }

    if (bestNode) {
        _selectedIds.clear();
        _selectedIds.add(bestNode.id);
        const ng = document.querySelector('.graph-nodes');
        const eg = document.querySelector('.graph-edges');
        if (ng) updateSelection(ng, _selectedIds);
        if (eg && _edges) {
            const chain = highlightChain(eg, bestNode.id, _edges);
            // Dim non-chain nodes like _onNodeClick does
            if (ng) {
                ng.querySelectorAll('.graph-node').forEach(n => {
                    n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25';
                });
            }
        }
        if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true);
    }
}

function _selectAll() {
    if (!_nodeMap) return;
    _selectedIds.clear();
    for (const id of _nodeMap.keys()) _selectedIds.add(id);
    const ng = document.querySelector('.graph-nodes');
    if (ng) {
        updateSelection(ng, _selectedIds);
        ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
    }
    const eg = document.querySelector('.graph-edges');
    if (eg) clearEdgeHighlights(eg);
}

/* ── Edge click ── */

function _onEdgeClick(edgePath, nodeGroup, edgeGroup) {
    const fromId = edgePath.getAttribute('data-from');
    const toId = edgePath.getAttribute('data-to');
    const field = edgePath.getAttribute('data-field') || '';

    // Track selected edge for Delete key detach
    const toNode = _nodeMap?.get(toId);
    if (toNode && isEditableEdge(field)) {
        _selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind };
    } else {
        _selectedEdge = null;
    }

    _selectedIds.clear();
    _selectedIds.add(fromId);
    _selectedIds.add(toId);

    if (nodeGroup) {
        updateSelection(nodeGroup, _selectedIds);
        nodeGroup.querySelectorAll('.graph-node').forEach(n => {
            n.style.opacity = _selectedIds.has(n.getAttribute('data-id')) ? '1' : '0.25';
        });
    }
    if (edgeGroup) {
        edgeGroup.querySelectorAll('.graph-edge').forEach(p => {
            const isThis = p === edgePath;
            p.classList.toggle('highlighted', isThis);
            p.classList.toggle('dimmed', !isThis);
        });
    }
}

/* ── Node dragging (supports multi-node) ── */

const DRAG_DEAD_ZONE = 4;

function _initNodeDrag(nodeGroup, edgeGroup) {
    nodeGroup.addEventListener('pointerdown', (e) => {
        if (e.button !== 0) return;
        const nodeEl = e.target.closest('.graph-node');
        if (!nodeEl) return;
        if (e.target.closest('.graph-node-overlay-btn')) return;
        if (e.target.closest('.graph-port-out')) return;  // handled by port drag

        const nodeId = nodeEl.getAttribute('data-id');
        const node = _nodeMap.get(nodeId);
        if (!node) return;

        // Multi-node drag: if dragged node is part of a multi-selection
        if (_selectedIds.size > 1 && _selectedIds.has(nodeId)) {
            _dragState = {
                multi: true,
                nodes: [..._selectedIds].map(id => ({
                    id,
                    el: nodeGroup.querySelector(`.graph-node[data-id="${id}"]`),
                    startX: _nodeMap.get(id)?.x || 0,
                    startY: _nodeMap.get(id)?.y || 0,
                })).filter(n => n.el),
                startClient: { x: e.clientX, y: e.clientY },
                dragging: false,
            };
        } else {
            _dragState = {
                multi: false,
                nodeId,
                el: nodeEl,
                startClient: { x: e.clientX, y: e.clientY },
                startNode: { x: node.x, y: node.y },
                dragging: false,
            };
        }

        e.stopPropagation();
    });

    if (!_dragListenersAdded) {
        window.addEventListener('pointermove', _onDragPointerMove);
        window.addEventListener('pointerup', _onDragPointerUp);
        _dragListenersAdded = true;
    }
}

function _onDragPointerMove(e) {
    if (!_dragState) return;

    const dx = e.clientX - _dragState.startClient.x;
    const dy = e.clientY - _dragState.startClient.y;

    if (!_dragState.dragging) {
        if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
        _dragState.dragging = true;
        if (_canvas) _canvas.blockPan = true;
        if (_dragState.multi) {
            _dragState.nodes.forEach(n => n.el?.classList.add('dragging'));
        } else {
            _dragState.el.classList.add('dragging');
            // Clear chain highlights during single-node drag
            const eg = document.querySelector('.graph-edges');
            if (eg) clearEdgeHighlights(eg);
            const ng = document.querySelector('.graph-nodes');
            if (ng) ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
        }
    }

    if (!_canvas) return;
    const gdx = dx / _canvas.zoom;
    const gdy = dy / _canvas.zoom;

    const edgeGroup = document.querySelector('.graph-edges');

    if (_dragState.multi) {
        for (const item of _dragState.nodes) {
            const node = _nodeMap.get(item.id);
            if (!node) continue;
            node.x = item.startX + gdx;
            node.y = item.startY + gdy;
            if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
            if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
            _updateMinimapNode(item.id, node);
        }
        if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
    } else {
        const node = _nodeMap.get(_dragState.nodeId);
        if (!node) return;
        node.x = _dragState.startNode.x + gdx;
        node.y = _dragState.startNode.y + gdy;
        _dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
        if (edgeGroup) {
            updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
            updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
        }
        _updateMinimapNode(_dragState.nodeId, node);
    }
}

function _onDragPointerUp() {
    if (!_dragState) return;

    if (_dragState.dragging) {
        if (_canvas) _canvas.blockPan = false;
        _justDragged = true;
        requestAnimationFrame(() => { _justDragged = false; });

        if (_dragState.multi) {
            _dragState.nodes.forEach(n => {
                if (n.el) n.el.classList.remove('dragging');
                const node = _nodeMap.get(n.id);
                if (node) _manualPositions.set(n.id, { x: node.x, y: node.y });
            });
        } else {
            _dragState.el.classList.remove('dragging');
            const node = _nodeMap.get(_dragState.nodeId);
            if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y });
        }

        _bounds = _calcBounds(_nodeMap);
        if (_canvas && _bounds) _canvas.setBounds(_bounds);

        // Re-render flow dots (paths changed)
        const edgeGroup = document.querySelector('.graph-edges');
        if (edgeGroup && _edges && _nodeMap) {
            const runningIds = new Set();
            for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
            renderFlowDots(edgeGroup, _edges, runningIds);
        }
    }

    _dragState = null;
}

/* ── Rubber-band selection (Shift+drag on empty space) ── */

function _initRubberBand(svgEl) {
    // Capture-phase: intercept Shift+click on empty space before canvas panning
    svgEl.addEventListener('pointerdown', (e) => {
        if (e.button !== 0 || !e.shiftKey) return;
        if (e.target.closest('.graph-node')) return;

        e.stopPropagation();
        e.preventDefault();

        _rubberBand = {
            startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
            startClient: { x: e.clientX, y: e.clientY },
            active: false,
        };
    }, true); // capture phase

    if (!_rubberBandListenersAdded) {
        window.addEventListener('pointermove', _onRubberBandMove);
        window.addEventListener('pointerup', _onRubberBandUp);
        _rubberBandListenersAdded = true;
    }
}

function _onRubberBandMove(e) {
    if (!_rubberBand || !_canvas) return;

    if (!_rubberBand.active) {
        const dx = e.clientX - _rubberBand.startClient.x;
        const dy = e.clientY - _rubberBand.startClient.y;
        if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
        _rubberBand.active = true;
    }

    const gp = _canvas.screenToGraph(e.clientX, e.clientY);
    const s = _rubberBand.startGraph;
    const x = Math.min(s.x, gp.x), y = Math.min(s.y, gp.y);
    const w = Math.abs(gp.x - s.x), h = Math.abs(gp.y - s.y);

    const rect = document.querySelector('.graph-selection-rect');
    if (rect) {
        rect.setAttribute('x', x);
        rect.setAttribute('y', y);
        rect.setAttribute('width', w);
        rect.setAttribute('height', h);
        rect.style.display = '';
    }
}

function _onRubberBandUp() {
    if (!_rubberBand) return;

    const rect = document.querySelector('.graph-selection-rect');

    if (_rubberBand.active && rect && _nodeMap) {
        const rx = parseFloat(rect.getAttribute('x'));
        const ry = parseFloat(rect.getAttribute('y'));
        const rw = parseFloat(rect.getAttribute('width'));
        const rh = parseFloat(rect.getAttribute('height'));

        _selectedIds.clear();
        for (const node of _nodeMap.values()) {
            if (node.x + node.width > rx && node.x < rx + rw &&
                node.y + node.height > ry && node.y < ry + rh) {
                _selectedIds.add(node.id);
            }
        }

        const ng = document.querySelector('.graph-nodes');
        const eg = document.querySelector('.graph-edges');
        if (ng) {
            updateSelection(ng, _selectedIds);
            ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
        }
        if (eg) clearEdgeHighlights(eg);
    }

    if (rect) {
        rect.style.display = 'none';
        rect.setAttribute('width', '0');
        rect.setAttribute('height', '0');
    }
    _rubberBand = null;
}

function _updateMinimapNode(nodeId, node) {
    const mm = document.querySelector('.graph-minimap');
    if (!mm) return;
    const mmNode = mm.querySelector(`rect.graph-minimap-node[data-id="${nodeId}"]`);
    if (mmNode) {
        mmNode.setAttribute('x', node.x);
        mmNode.setAttribute('y', node.y);
    }
}

/* ── Manual position helpers ── */

function _applyManualPositions(nodeMap, edges) {
    if (_manualPositions.size === 0) return;
    for (const [id, pos] of _manualPositions) {
        const node = nodeMap.get(id);
        if (node) {
            node.x = pos.x;
            node.y = pos.y;
        }
    }
    // Invalidate ELK edge routing for edges connected to moved nodes
    for (const edge of edges) {
        if (_manualPositions.has(edge.from) || _manualPositions.has(edge.to)) {
            edge.points = null; // forces default bezier
        }
    }
}

function _calcBounds(nodeMap) {
    if (!nodeMap || nodeMap.size === 0) return { x: 0, y: 0, width: 400, height: 300 };
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const n of nodeMap.values()) {
        minX = Math.min(minX, n.x);
        minY = Math.min(minY, n.y);
        maxX = Math.max(maxX, n.x + n.width);
        maxY = Math.max(maxY, n.y + n.height);
    }
    return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}

/* ── Port drag (connect/reconnect) ── */

const SVG_NS = 'http://www.w3.org/2000/svg';

function _initPortDrag(svgEl, nodeGroup, edgeGroup) {
    // Capture-phase on output ports to prevent node drag
    nodeGroup.addEventListener('pointerdown', (e) => {
        const port = e.target.closest('.graph-port-out');
        if (!port || e.button !== 0) return;

        e.stopPropagation();
        e.preventDefault();

        const sourceNodeId = port.getAttribute('data-node-id');
        const sourceKind = port.getAttribute('data-node-kind');
        const portType = port.getAttribute('data-port-type');
        const sourceNode = _nodeMap?.get(sourceNodeId);
        if (!sourceNode) return;

        // Compute start position in graph coords (output port = right side of node)
        const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2;
        const startX = sourceNode.x + sourceNode.width;
        const startY = sourceNode.y + portY;

        // Create temporary drag edge in SVG
        const dragPath = document.createElementNS(SVG_NS, 'path');
        dragPath.setAttribute('class', 'graph-drag-edge');
        dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
        const root = svgEl.querySelector('.graph-root');
        root.appendChild(dragPath);

        _connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };

        if (_canvas) _canvas.blockPan = true;
        svgEl.classList.add('connecting');

        // Highlight compatible input ports
        const compatible = getCompatibleInputs(sourceKind);
        const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`));

        nodeGroup.querySelectorAll('.graph-port-in').forEach(p => {
            const nKind = p.getAttribute('data-node-kind');
            const pType = p.getAttribute('data-port-type');
            const nId = p.getAttribute('data-node-id');
            // Don't connect to self
            if (nId === sourceNodeId) {
                p.classList.add('graph-port-incompatible');
                return;
            }
            if (compatibleSet.has(`${nKind}:${pType}`)) {
                p.classList.add('graph-port-compatible');
            } else {
                p.classList.add('graph-port-incompatible');
            }
        });
    }, true); // capture phase to beat node drag

    if (!_connectListenersAdded) {
        window.addEventListener('pointermove', _onConnectPointerMove);
        window.addEventListener('pointerup', _onConnectPointerUp);
        _connectListenersAdded = true;
    }
}

function _onConnectPointerMove(e) {
    if (!_connectState || !_canvas) return;

    const gp = _canvas.screenToGraph(e.clientX, e.clientY);
    const { startX, startY, dragPath } = _connectState;
    const dx = Math.abs(gp.x - startX) * 0.4;
    dragPath.setAttribute('d',
        `M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}`
    );

    // Highlight drop target port
    const svgEl = document.querySelector('.graph-svg');
    if (!svgEl) return;
    const elem = document.elementFromPoint(e.clientX, e.clientY);
    const port = elem?.closest?.('.graph-port-compatible');

    svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
    if (port) port.classList.add('graph-port-drop-target');
}

function _onConnectPointerUp(e) {
    if (!_connectState) return;

    const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;

    // Clean up drag edge
    dragPath.remove();
    const svgEl = document.querySelector('.graph-svg');
    if (svgEl) svgEl.classList.remove('connecting');
    if (_canvas) _canvas.blockPan = false;

    // Clean up port highlights
    const nodeGroup = document.querySelector('.graph-nodes');
    if (nodeGroup) {
        nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
            p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
        });
    }

    // Check if dropped on a compatible input port
    const elem = document.elementFromPoint(e.clientX, e.clientY);
    const targetPort = elem?.closest?.('.graph-port-in');
    if (targetPort) {
        const targetNodeId = targetPort.getAttribute('data-node-id');
        const targetKind = targetPort.getAttribute('data-node-kind');
        const targetPortType = targetPort.getAttribute('data-port-type');

        if (targetNodeId !== sourceNodeId) {
            // Find the matching connection
            const matches = findConnection(targetKind, sourceKind, targetPortType);
            if (matches.length === 1) {
                _doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
            } else if (matches.length > 1) {
                // Multiple possible fields (e.g., template → picture_source could be capture or pp template)
                // Resolve by source kind
                const exact = matches.find(m => m.sourceKind === sourceKind);
                if (exact) {
                    _doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
                }
            }
        }
    }

    _connectState = null;
}

async function _doConnect(targetId, targetKind, field, sourceId) {
    const ok = await updateConnection(targetId, targetKind, field, sourceId);
    if (ok) {
        showToast(t('graph.connection_updated') || 'Connection updated', 'success');
        await loadGraphEditor();
    } else {
        showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
    }
}

/* ── Undo / Redo ── */

const _undoStack = [];
const _redoStack = [];
const _MAX_UNDO = 30;

/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
export function pushUndoAction(action) {
    _undoStack.push(action);
    if (_undoStack.length > _MAX_UNDO) _undoStack.shift();
    _redoStack.length = 0;
    _updateUndoRedoButtons();
}

function _updateUndoRedoButtons() {
    const undoBtn = document.getElementById('graph-undo-btn');
    const redoBtn = document.getElementById('graph-redo-btn');
    if (undoBtn) undoBtn.disabled = _undoStack.length === 0;
    if (redoBtn) redoBtn.disabled = _redoStack.length === 0;
}

export async function graphUndo() { await _undo(); }
export async function graphRedo() { await _redo(); }

async function _undo() {
    if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
    const action = _undoStack.pop();
    try {
        await action.undo();
        _redoStack.push(action);
        showToast(t('graph.undone') || `Undone: ${action.label}`, 'info');
        _updateUndoRedoButtons();
        await loadGraphEditor();
    } catch (e) {
        showToast(e.message, 'error');
        _updateUndoRedoButtons();
    }
}

async function _redo() {
    if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
    const action = _redoStack.pop();
    try {
        await action.redo();
        _undoStack.push(action);
        showToast(t('graph.redone') || `Redone: ${action.label}`, 'info');
        _updateUndoRedoButtons();
        await loadGraphEditor();
    } catch (e) {
        showToast(e.message, 'error');
        _updateUndoRedoButtons();
    }
}

/* ── Keyboard shortcuts help ── */

let _helpVisible = false;

function _loadHelpPos() {
    try {
        const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
        return saved || { anchor: 'br', offsetX: 12, offsetY: 12 };
    } catch { return { anchor: 'br', offsetX: 12, offsetY: 12 }; }
}
function _saveHelpPos(pos) {
    localStorage.setItem('graph_help_pos', JSON.stringify(pos));
}

export function toggleGraphHelp() {
    _helpVisible = !_helpVisible;
    const helpBtn = document.getElementById('graph-help-toggle');
    if (helpBtn) helpBtn.classList.toggle('active', _helpVisible);
    let panel = document.querySelector('.graph-help-panel');
    if (_helpVisible) {
        if (!panel) {
            const container = document.querySelector('#graph-editor-content .graph-container');
            if (!container) return;
            panel = document.createElement('div');
            panel.className = 'graph-help-panel visible';
            panel.innerHTML = `
                <div class="graph-help-header">
                    <span>${t('graph.help_title')}</span>
                </div>
                <div class="graph-help-body">
                    <div class="graph-help-row"><kbd>/</kbd> <span>${t('graph.help.search')}</span></div>
                    <div class="graph-help-row"><kbd>F</kbd> <span>${t('graph.help.filter')}</span></div>
                    <div class="graph-help-row"><kbd>+</kbd> <span>${t('graph.help.add')}</span></div>
                    <div class="graph-help-row"><kbd>?</kbd> <span>${t('graph.help.shortcuts')}</span></div>
                    <div class="graph-help-row"><kbd>Del</kbd> <span>${t('graph.help.delete')}</span></div>
                    <div class="graph-help-row"><kbd>Ctrl+A</kbd> <span>${t('graph.help.select_all')}</span></div>
                    <div class="graph-help-row"><kbd>Ctrl+Z</kbd> <span>${t('graph.help.undo')}</span></div>
                    <div class="graph-help-row"><kbd>Ctrl+Shift+Z</kbd> <span>${t('graph.help.redo')}</span></div>
                    <div class="graph-help-row"><kbd>F11</kbd> <span>${t('graph.help.fullscreen')}</span></div>
                    <div class="graph-help-row"><kbd>Esc</kbd> <span>${t('graph.help.deselect')}</span></div>
                    <div class="graph-help-row"><kbd>\u2190\u2191\u2192\u2193</kbd> <span>${t('graph.help.navigate')}</span></div>
                    <div class="graph-help-sep"></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.click')}</span> <span>${t('graph.help.click_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.dblclick')}</span> <span>${t('graph.help.dblclick_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_click')}</span> <span>${t('graph.help.shift_click_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_drag')}</span> <span>${t('graph.help.shift_drag_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_node')}</span> <span>${t('graph.help.drag_node_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_port')}</span> <span>${t('graph.help.drag_port_desc')}</span></div>
                    <div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.right_click')}</span> <span>${t('graph.help.right_click_desc')}</span></div>
                </div>`;
            container.appendChild(panel);
            // Make draggable with anchor persistence
            const header = panel.querySelector('.graph-help-header');
            _makeDraggable(panel, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos });
        } else {
            panel.classList.add('visible');
        }
    } else if (panel) {
        panel.classList.remove('visible');
    }
}

/* ── Edge context menu (right-click to detach) ── */

function _onEdgeContextMenu(edgePath, e, container) {
    _dismissEdgeContextMenu();

    const field = edgePath.getAttribute('data-field') || '';
    if (!isEditableEdge(field)) return;  // nested fields can't be detached from graph

    const toId = edgePath.getAttribute('data-to');
    const toNode = _nodeMap?.get(toId);
    if (!toNode) return;

    const menu = document.createElement('div');
    menu.className = 'graph-edge-menu';
    menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
    menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;

    const btn = document.createElement('button');
    btn.className = 'graph-edge-menu-item danger';
    btn.textContent = t('graph.disconnect') || 'Disconnect';
    btn.addEventListener('click', async () => {
        _dismissEdgeContextMenu();
        const ok = await detachConnection(toId, toNode.kind, field);
        if (ok) {
            showToast(t('graph.connection_removed') || 'Connection removed', 'success');
            await loadGraphEditor();
        } else {
            showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
        }
    });
    menu.appendChild(btn);

    container.querySelector('.graph-container').appendChild(menu);
    _edgeContextMenu = menu;
}

function _dismissEdgeContextMenu() {
    if (_edgeContextMenu) {
        _edgeContextMenu.remove();
        _edgeContextMenu = null;
    }
}

async function _detachSelectedEdge() {
    if (!_selectedEdge) return;
    const { to, field, targetKind } = _selectedEdge;
    _selectedEdge = null;

    const ok = await detachConnection(to, targetKind, field);
    if (ok) {
        showToast(t('graph.connection_removed') || 'Connection removed', 'success');
        await loadGraphEditor();
    } else {
        showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
    }
}

// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
document.addEventListener('languageChanged', () => {
    if (_initialized && _nodeMap) {
        const container = document.getElementById('graph-editor-content');
        if (container) _renderGraph(container);
    }
});
