// fc-app.jsx — App state, keyboard shortcuts, all operations

const { useState, useEffect, useMemo, useRef, useCallback } = React;

function TopBtn({ children, onClick, active, danger, accent, title, disabled, style: extra }) {
  return (
    <button onClick={onClick} title={title} disabled={disabled} style={{
      background: active ? 'var(--accent)' : accent ? 'var(--accent)' : 'transparent',
      border: `1px solid ${active || accent ? 'var(--accent)' : danger ? '#f8717140' : 'var(--border)'}`,
      borderRadius: 7, padding: '4px 12px', fontSize: 11, fontWeight: 600,
      cursor: disabled ? 'not-allowed' : 'pointer',
      color: active || accent ? '#fff' : danger ? '#f87171' : 'var(--text)',
      fontFamily: 'inherit', transition: 'all 0.12s', whiteSpace: 'nowrap', flexShrink: 0,
      opacity: disabled ? 0.4 : 1, ...extra,
    }}>{children}</button>
  );
}

// ── LoginScreen ────────────────────────────────────────────────────────────
function LoginScreen({ onLogin }) {
  const [projectId, setProjectId] = useState('');
  const [loading,   setLoading]   = useState(false);
  const [error,     setError]     = useState('');
  const recent = loadRecentProjects();

  async function handleSubmit(e) {
    e.preventDefault();
    if (!projectId.trim()) return;
    setLoading(true); setError('');
    try {
      const hash = await sha256(projectId.trim().toLowerCase());
      await onLogin(hash, projectId.trim());
    } catch {
      setError('Something went wrong. Try again.');
    } finally {
      setLoading(false);
    }
  }

  async function openRecent(hash) {
    setLoading(true);
    try { await onLogin(hash, null); }
    catch { setError('Could not open project.'); }
    finally { setLoading(false); }
  }

  return (
    <div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg)', padding: 24 }}>
      <div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 20, padding: '40px 36px', width: 440, maxWidth: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
        <div style={{ marginBottom: 28 }}>
          <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 10 }}>FlowChart Creator</div>
          <h1 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'var(--text)', lineHeight: 1.15 }}>Open your project</h1>
          <p style={{ margin: '10px 0 0', fontSize: 13, color: 'var(--text2)', lineHeight: 1.6 }}>
            Enter any project ID to open or create a workspace.
          </p>
        </div>

        <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
            <label style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text2)' }}>Project ID</label>
            <input
              value={projectId} onChange={e => setProjectId(e.target.value)}
              placeholder="e.g. my-project-2025" autoFocus
              style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 10, padding: '11px 14px', color: 'var(--text)', fontSize: 14, outline: 'none', fontFamily: 'inherit' }}
            />
          </div>
          {error && <p style={{ margin: 0, fontSize: 12, color: '#f87171' }}>{error}</p>}
          <button type="submit" disabled={loading || !projectId.trim()} style={{
            background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 10,
            padding: 12, fontSize: 14, fontWeight: 700, cursor: 'pointer',
            opacity: loading || !projectId.trim() ? 0.5 : 1, fontFamily: 'inherit', transition: 'opacity 0.12s',
          }}>{loading ? 'Opening…' : 'Open project →'}</button>
        </form>

        {/* Recent projects */}
        {recent.length > 0 && (
          <div style={{ marginTop: 24 }}>
            <div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text2)', marginBottom: 8 }}>Recent</div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
              {recent.map(r => (
                <button key={r.hash} onClick={() => openRecent(r.hash)} style={{
                  background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 9,
                  padding: '9px 13px', cursor: 'pointer', color: 'var(--text)',
                  display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                  fontFamily: 'inherit', transition: 'border-color 0.1s', textAlign: 'left',
                }}
                  onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
                  onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
                >
                  <span style={{ fontSize: 13, fontWeight: 600 }}>{r.name || 'Unnamed project'}</span>
                  <span style={{ fontSize: 10, color: 'var(--text2)' }}>
                    {new Date(r.ts).toLocaleDateString()}
                  </span>
                </button>
              ))}
            </div>
          </div>
        )}

        <p style={{ margin: '20px 0 0', fontSize: 11, color: 'var(--text2)', lineHeight: 1.6, textAlign: 'center' }}>
          Share the ID with teammates to give them access.
        </p>
      </div>
    </div>
  );
}

// Three-way merge: base=last synced, local=client changes, remote=server state.
// Applies non-conflicting local changes on top of remote so neither user loses work.
// NODE_MERGE_KEYS must cover every persisted node field the UI can edit (see
// LINKED_SYNC_FIELDS in fc-utils.js plus layout/link fields).
const NODE_MERGE_KEYS = [
  'x', 'y', 'label', 'notes', 'details', 'status', 'typeId', 'collapsed',
  'tags', 'poll', 'images', 'assignees', 'subChartId', 'linkGroupId',
];
const CHART_MERGE_KEYS = ['title', 'description', 'orientation', 'rootNodeId'];
const EDGE_MERGE_KEYS = ['from', 'to', 'route'];

function mergeField(baseVal, localVal, remoteVal) {
  const bv = JSON.stringify(baseVal);
  if (JSON.stringify(localVal) !== bv && JSON.stringify(remoteVal) === bv) return localVal;
  return remoteVal;
}

// Deep-equality via JSON. Order-sensitive, which is fine here: we only use it
// to ask "did remote leave this item exactly as base did?" and our writers
// preserve key/array order.
function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); }

function mergeProjects(base, local, remote) {
  if (!base) return remote;

  const baseChartMap  = new Map((base.charts  || []).map(c => [c.id, c]));
  const localChartMap = new Map((local.charts || []).map(c => [c.id, c]));
  const remoteChartIds = new Set((remote.charts || []).map(c => c.id));

  const mergedCharts = [];
  for (const remoteChart of (remote.charts || [])) {
    const localChart = localChartMap.get(remoteChart.id);
    const baseChart  = baseChartMap.get(remoteChart.id);

    // Chart existed at base, deleted locally, and untouched on remote since
    // base → honour the local delete instead of resurrecting it.
    if (baseChart && !localChart && deepEqual(baseChart, remoteChart)) continue;
    if (!localChart || !baseChart) { mergedCharts.push(remoteChart); continue; }

    const baseNodeMap  = new Map((baseChart.nodes  || []).map(n => [n.id, n]));
    const localNodeMap = new Map((localChart.nodes || []).map(n => [n.id, n]));
    const remoteNodeIds = new Set((remoteChart.nodes || []).map(n => n.id));
    const baseEdgeMap  = new Map((baseChart.edges  || []).map(e => [e.id, e]));
    const localEdgeMap = new Map((localChart.edges || []).map(e => [e.id, e]));
    const remoteEdgeIds = new Set((remoteChart.edges || []).map(e => e.id));

    const mergedChart = { ...remoteChart };
    for (const key of CHART_MERGE_KEYS) {
      mergedChart[key] = mergeField(baseChart[key], localChart[key], remoteChart[key]);
    }

    // Merge existing nodes: take local's field change when remote left it
    // unchanged; drop a node that was deleted locally and untouched on remote.
    const mergedNodes = [];
    for (const remoteNode of (remoteChart.nodes || [])) {
      const localNode = localNodeMap.get(remoteNode.id);
      const baseNode  = baseNodeMap.get(remoteNode.id);
      if (baseNode && !localNode && deepEqual(baseNode, remoteNode)) continue; // deleted locally
      if (!localNode || !baseNode) { mergedNodes.push(remoteNode); continue; }
      const merged = { ...remoteNode };
      for (const key of NODE_MERGE_KEYS) {
        merged[key] = mergeField(baseNode[key], localNode[key], remoteNode[key]);
      }
      mergedNodes.push(merged);
    }

    // Locally-added nodes (not in base, not in remote) → add
    for (const n of (localChart.nodes || [])) {
      if (!baseNodeMap.has(n.id) && !remoteNodeIds.has(n.id)) mergedNodes.push(n);
    }

    const mergedEdges = [];
    for (const remoteEdge of (remoteChart.edges || [])) {
      const localEdge = localEdgeMap.get(remoteEdge.id);
      const baseEdge  = baseEdgeMap.get(remoteEdge.id);
      if (baseEdge && !localEdge && deepEqual(baseEdge, remoteEdge)) continue; // deleted locally
      if (!localEdge || !baseEdge) { mergedEdges.push(remoteEdge); continue; }
      const merged = { ...remoteEdge };
      for (const key of EDGE_MERGE_KEYS) {
        merged[key] = mergeField(baseEdge[key], localEdge[key], remoteEdge[key]);
      }
      mergedEdges.push(merged);
    }

    // Locally-added edges (not in base, not in remote) → add
    const localNewEdges = (localChart.edges || []).filter(
      e => !baseEdgeMap.has(e.id) && !remoteEdgeIds.has(e.id)
    );

    mergedCharts.push({ ...mergedChart, nodes: mergedNodes, edges: [...mergedEdges, ...localNewEdges] });
  }

  // Locally-created charts (not in base, not in remote) → add
  for (const lc of (local.charts || [])) {
    if (!baseChartMap.has(lc.id) && !remoteChartIds.has(lc.id)) mergedCharts.push(lc);
  }

  // Top-level project fields: three-way merge so a local rename or a locally
  // added node type / status survives a conflict instead of being overwritten
  // wholesale by remote.
  const merged = { ...remote, charts: mergedCharts };
  merged.name             = mergeField(base.name,             local.name,             remote.name);
  merged.lastOpenedChartId = mergeField(base.lastOpenedChartId, local.lastOpenedChartId, remote.lastOpenedChartId);
  merged.nodeTypes        = mergeField(base.nodeTypes,        local.nodeTypes,        remote.nodeTypes);
  merged.statuses         = mergeField(base.statuses,         local.statuses,         remote.statuses);
  return merged;
}

async function fetchServerProject(hash) {
  try {
    const res = await fetch(`/api/project/${encodeURIComponent(hash)}`);
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

async function openServerProject(hash) {
  try {
    const res = await fetch('/api/open', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash }),
    });
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

async function saveServerProject(hash, project, expectedVersion) {
  const res = await fetch(`/api/project/${encodeURIComponent(hash)}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ project: { ...project, id: hash }, expectedVersion }),
  });
  if (!res.ok) {
    let body = null;
    try { body = await res.json(); } catch {}
    const err = new Error(`Save failed (${res.status})`);
    err.status = res.status;
    err.body = body;
    throw err;
  }
  return await res.json();
}

// Stack of colored avatar pills showing teammates currently in the project
function PresencePills({ users, fallbackCount }) {
  const list = (users || []).slice(0, 5);
  if (list.length === 0 && fallbackCount > 0) {
    return (
      <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-muted)', borderRadius: 99, padding: '2px 8px', flexShrink: 0 }}>
        {fallbackCount} {fallbackCount === 1 ? 'other' : 'others'}
      </span>
    );
  }
  if (list.length === 0) return null;
  return (
    <div style={{ display: 'flex', alignItems: 'center', flexShrink: 0, paddingLeft: 4 }}>
      {list.map((u, i) => {
        const initials = (u.name || '??').split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase();
        return (
          <div key={u.id} title={`${u.name || 'Editor'}${u.nodeId ? ' · editing a node' : ''}`}
            style={{
              width: 22, height: 22, borderRadius: '50%',
              background: u.color || '#888', color: '#fff',
              fontSize: 9, fontWeight: 800, letterSpacing: '0.02em',
              display: 'grid', placeItems: 'center',
              border: '2px solid var(--surface)',
              marginLeft: i === 0 ? 0 : -7,
              boxShadow: '0 0 0 1px ' + (u.color || '#888'),
              position: 'relative', zIndex: 10 - i,
            }}>{initials}</div>
        );
      })}
      {users.length > list.length && (
        <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, color: 'var(--text2)' }}>
          +{users.length - list.length}
        </span>
      )}
    </div>
  );
}

// ── App ────────────────────────────────────────────────────────────────────
function App({ theme, setTheme, roundedNodes }) {
  const [isLoggedIn,      setIsLoggedIn]      = useState(false);
  const [readOnly,        setReadOnly]        = useState(false);
  const [projectHash,     setProjectHash]     = useState(null);
  const [project,         setProject]         = useState(null);
  const [selectedChartId, setSelectedChartId] = useState('');
  const [selectedNodeId,  setSelectedNodeId]  = useState('');
  // Multi-selection: set of node IDs in the current chart that are part of
  // a marquee/multi-select group. Independent of `selectedNodeId` (which is
  // the single "primary" selection used by the Inspector). When this set has
  // 2+ entries, the bulk-action bar appears and operations like delete /
  // bulk-update target the whole set.
  const [selectedNodeIds, setSelectedNodeIds] = useState(() => new Set());
  const [navStack,        setNavStack]        = useState([]);
  const [scale,           setScale]           = useState(1);
  const [pan,             setPan]             = useState({ x: 80, y: 60 });
  const [connectMode,     setConnectMode]     = useState(false);
  const [connectSource,   setConnectSource]   = useState(null);
  const [showSettings,    setShowSettings]    = useState(false);
  const [showPolls,       setShowPolls]       = useState(false);
  const [toast,           setToast]           = useState('');
  const [editingNodeId,   setEditingNodeId]   = useState(null);
  const [editingField,    setEditingField]    = useState(null);
  const [expandedNodeId,  setExpandedNodeId]  = useState(null);
  const [fitTrigger,      setFitTrigger]      = useState(0);
  const [collapsedMap,    setCollapsedMap]    = useState(new Map());
  const [hidingAncMap,    setHidingAncMap]    = useState(new Map());
  const [layoutAlgorithm, setLayoutAlgorithm] = useState('improved');
  const DEFAULT_SPACING = {
    vertical:   { sib: 80, depth: 120 },
    horizontal: { sib: 40, depth: 180 },
  };
  const [layoutSpacing, setLayoutSpacing] = useState(() => {
    try {
      const stored = localStorage.getItem('fc-layout-spacing');
      if (stored) {
        const parsed = JSON.parse(stored);
        return {
          vertical:   { ...DEFAULT_SPACING.vertical,   ...(parsed.vertical   || {}) },
          horizontal: { ...DEFAULT_SPACING.horizontal, ...(parsed.horizontal || {}) },
        };
      }
    } catch (e) {}
    return DEFAULT_SPACING;
  });
  React.useEffect(() => {
    try { localStorage.setItem('fc-layout-spacing', JSON.stringify(layoutSpacing)); } catch (e) {}
  }, [layoutSpacing]);
  const historyRef     = useRef({ past: [], future: [] });
  const skipHistoryRef = useRef(false);
  const clipboardRef   = useRef(null); // copied node data
  const isApplyingRemoteRef = useRef(false);
  const hasLocalChangesRef = useRef(false);
  const saveTimerRef = useRef(null);
  const saveInFlightRef = useRef(false);
  const savePendingRef = useRef(false);
  const saveRetryTimerRef = useRef(null);
  const quotaWarnedRef = useRef(false);
  const syncBaseRef = useRef(null);       // last version both client+server agreed on (merge base)
  const latestProjectRef = useRef(null);  // always mirrors current project (avoids stale WS closure)
  const wsRef = useRef(null);
  const wsConnectedRef = useRef(false);
  const [othersOnline, setOthersOnline] = useState(0);
  const userRef = useRef(getOrCreateUser());
  // Force-rerender hook for when the user updates their own display name —
  // userRef.current is mutated in place, but state-bound UI (top bar, etc.)
  // needs to re-render. Bump this when identity changes.
  const [myIdentityRev, setMyIdentityRev] = useState(0);
  function updateMyIdentity(changes) {
    const next = updateUserIdentity(changes);
    userRef.current = next;
    setMyIdentityRev(r => r + 1);
    // Broadcast the new name to teammates so their PresencePills update too.
    sendWs({
      type: 'cursor', userId: next.id, name: next.name, color: next.color,
      chartId: selectedChartId || null, nodeId: selectedNodeId || null,
    });
  }
  const editingNodeIdRef = useRef(null);
  const editingFieldRef  = useRef(null);
  const opThrottleRef    = useRef({});
  const [remoteUsers, setRemoteUsers] = useState({}); // {userId: {name, color, chartId, nodeId, ts}}

  function showToast(msg, dur = 2600) {
    setToast(msg);
    setTimeout(() => setToast(''), dur);
  }

  // ── WebSocket op senders ──
  function sendWs(msg) {
    const ws = wsRef.current;
    if (ws && ws.readyState === 1 /* OPEN */) {
      try { ws.send(JSON.stringify(msg)); } catch {}
    }
  }
  // Send move ops immediately (drag is 60 FPS, network can handle it)
  function sendOpMove(chartId, nodeId, x, y) {
    sendWs({ type: 'op', op: 'node.move', chartId, nodeId, x, y, userId: userRef.current.id });
  }
  // Throttle text updates (merge field changes within window)
  function sendThrottledUpdate(chartId, nodeId, changes, ms = 100) {
    const key = 'u:' + nodeId;
    const slot = opThrottleRef.current[key] || (opThrottleRef.current[key] = { pendingFields: {} });
    slot.pendingFields = { ...slot.pendingFields, ...changes };
    if (slot.timer) return;
    const fields = slot.pendingFields; slot.pendingFields = {};
    sendWs({ type: 'op', op: 'node.update', chartId, nodeId, fields, userId: userRef.current.id });
    slot.timer = setTimeout(function flush() {
      slot.timer = null;
      if (Object.keys(slot.pendingFields).length) {
        const f = slot.pendingFields; slot.pendingFields = {};
        sendWs({ type: 'op', op: 'node.update', chartId, nodeId, fields: f, userId: userRef.current.id });
        slot.timer = setTimeout(flush, ms);
      }
    }, ms);
  }

  // Apply a remote op to local state without triggering save/history
  function applyRemoteOp(op) {
    if (!op || op.userId === userRef.current.id) return;
    if (op.op === 'node.update' && op.nodeId === editingNodeIdRef.current) {
      // Don't overwrite the field the user is currently typing in
      const ef = editingFieldRef.current;
      const fields = { ...op.fields };
      if (ef && ef in fields) delete fields[ef];
      if (Object.keys(fields).length === 0) return;
      op = { ...op, fields };
    }
    setProject(prev => {
      if (!prev) return prev;
      if (op.op === 'node.move') {
        return {
          ...prev,
          charts: prev.charts.map(c => c.id !== op.chartId ? c : {
            ...c, nodes: c.nodes.map(n => n.id !== op.nodeId ? n : { ...n, x: op.x, y: op.y }),
          }),
        };
      }
      if (op.op === 'node.update') {
        return {
          ...prev,
          charts: prev.charts.map(c => c.id !== op.chartId ? c : {
            ...c, nodes: c.nodes.map(n => n.id !== op.nodeId ? n : { ...n, ...op.fields }),
          }),
        };
      }
      return prev;
    });
  }

  // Keep editing-field refs current (consumed by applyRemoteOp closure)
  useEffect(() => {
    editingNodeIdRef.current = editingNodeId;
    editingFieldRef.current  = editingField;
  }, [editingNodeId, editingField]);

  const collapsedSet      = useMemo(() => collapsedMap.get(selectedChartId)    || new Set(), [collapsedMap, selectedChartId]);
  const hidingAncestorSet = useMemo(() => hidingAncMap.get(selectedChartId)    || new Set(), [hidingAncMap, selectedChartId]);
  const remoteSelections  = useMemo(() => {
    const map = {};
    for (const [id, u] of Object.entries(remoteUsers)) {
      if (!u || !u.nodeId || u.chartId !== selectedChartId) continue;
      (map[u.nodeId] = map[u.nodeId] || []).push({ id, name: u.name, color: u.color });
    }
    return map;
  }, [remoteUsers, selectedChartId]);
  const currentChart      = useMemo(() => project?.charts.find(c => c.id === selectedChartId) || project?.charts[0] || null, [project, selectedChartId]);
  const selectedNode      = useMemo(() => currentChart?.nodes.find(n => n.id === selectedNodeId) || null, [currentChart, selectedNodeId]);
  const nodeTypes         = useMemo(() => project?.nodeTypes || DEFAULT_NODE_TYPES, [project]);
  const statuses          = useMemo(() => project?.statuses  || DEFAULT_STATUSES,  [project]);

  // ── Session restore ──
  useEffect(() => {
    let cancelled = false;

    async function restore() {
    // Parse `#share=…` from the URL hash. We DON'T use URLSearchParams here
    // because it would decode '+' in the base64 payload to a space and corrupt
    // it — the bug that caused pasted share links to fail. Match the share
    // token directly from the raw hash string instead.
    const rawHash = window.location.hash.replace(/^#/, '');
    let shareToken = null;
    const m = rawHash.match(/(?:^|&)share=([^&]+)/);
    if (m) shareToken = m[1];
    if (shareToken) {
      if (shareToken.startsWith('encoded_')) {
        try {
          // Accept both base64url (- _) and legacy base64 (+ /) so old links
          // still resolve. Pad to a multiple of 4 for atob.
          let b64 = shareToken.slice(8).replace(/-/g, '+').replace(/_/g, '/');
          while (b64.length % 4) b64 += '=';
          const data = JSON.parse(decodeURIComponent(atob(b64)));
          if (cancelled) return;
          const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data;
          setProject(migrated);
          setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || '');
          setReadOnly(true); setIsLoggedIn(true); return;
        } catch (err) {
          console.warn('Share link decode failed:', err);
          showToast('Share link is invalid or corrupted');
        }
      }
      // Fallback: try server lookup by token (works if owner has saved the
      // project on this domain). Quick lookup against /api/share/{token}.
      try {
        const res = await fetch(`/api/share/${encodeURIComponent(shareToken)}`);
        if (res.ok) {
          const data = await res.json();
          if (!cancelled && data && data.charts) {
            const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data;
            setProject(migrated);
            setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || '');
            setReadOnly(true); setIsLoggedIn(true); return;
          }
        }
      } catch (err) { /* fall through to local scan */ }
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        if (!key?.startsWith('fc_v1_')) continue;
        try {
          const data = JSON.parse(localStorage.getItem(key));
          if (data?.readOnlyShareToken === shareToken) {
            if (cancelled) return;
            const migrated = (typeof migrateProject === 'function') ? migrateProject(data) : data;
            setProject(migrated);
            setSelectedChartId(migrated.lastOpenedChartId || migrated.charts[0]?.id || '');
            setReadOnly(true); setIsLoggedIn(true); return;
          }
        } catch {}
      }
    }
    const session = loadSession();
    if (session?.hash) {
      const localProj = loadProject(session.hash);
      const remoteProj = await fetchServerProject(session.hash);
      let proj = (typeof reconcileProjectsOnLoad === 'function')
        ? reconcileProjectsOnLoad(localProj, remoteProj)
        : (localProj || remoteProj);
      if (proj) {
        const cache = saveProject(session.hash, proj);
        if (cache?.strippedImages) showToast('Reloaded from server — images are stored online, not in browser cache.');
      }
      if (proj) {
        if (cancelled) return;
        proj = (typeof migrateProject === 'function') ? migrateProject(proj) : proj;
        syncBaseRef.current = proj;
        setProjectHash(session.hash); setProject(proj);
        setSelectedChartId(proj.lastOpenedChartId || proj.charts[0]?.id || '');
        setIsLoggedIn(true);
      }
    }
    }

    restore();
    return () => { cancelled = true; };
  }, []);

  // ── Keep latestProjectRef current for stable WS callbacks ──
  useEffect(() => { latestProjectRef.current = project; }, [project]);

  // ── Persist local cache ──
  useEffect(() => {
    if (!project || !projectHash) return;
    const cache = saveProject(projectHash, project);
    if (cache?.quota && !readOnly && !quotaWarnedRef.current) {
      quotaWarnedRef.current = true;
      showToast('Browser storage full — saving to server only. Wait for save before refreshing.');
    }
  }, [project, projectHash, readOnly]);

  // ── Persist to backend with optimistic concurrency + three-way merge on conflict ──
  const flushSave = useCallback(async () => {
    if (!projectHash || readOnly || !hasLocalChangesRef.current) return;
    if (saveInFlightRef.current) {
      savePendingRef.current = true;
      return;
    }

    const localSnapshot = latestProjectRef.current;
    if (!localSnapshot) return;

    saveInFlightRef.current = true;
    try {
      const saved = await saveServerProject(projectHash, localSnapshot, Number(localSnapshot.version || 0));
      syncBaseRef.current = saved;

      if (latestProjectRef.current === localSnapshot) {
        hasLocalChangesRef.current = false;
        isApplyingRemoteRef.current = true;
        const cache = saveProject(projectHash, saved);
        setProject(saved);
        if (cache?.quota) showToast('Saved to server; browser cache skipped images (storage full).');
      } else {
        // Edits happened during the PUT — keep local content, adopt server version.
        hasLocalChangesRef.current = true;
        setProject(prev => prev ? { ...prev, version: saved.version } : saved);
        saveProject(projectHash, latestProjectRef.current);
      }
    } catch (err) {
      if (err?.status === 409) {
        const serverProject = err?.body?.detail?.project;
        if (serverProject) {
          const merged = mergeProjects(syncBaseRef.current, localSnapshot, serverProject);
          merged.id = projectHash;
          syncBaseRef.current = serverProject;
          hasLocalChangesRef.current = true;
          saveProject(projectHash, merged);
          setProject(merged);
          showToast('Merged with teammate\'s changes');
        }
      } else {
        hasLocalChangesRef.current = true;
        showToast(err?.status === 413
          ? 'Project too large to save — remove some images and try again.'
          : 'Save failed — retrying…');
      }
    } finally {
      saveInFlightRef.current = false;
      if (savePendingRef.current) {
        savePendingRef.current = false;
        flushSave();
      }
    }
  }, [projectHash, readOnly]);

  useEffect(() => {
    if (!project || !projectHash || readOnly) return;

    if (isApplyingRemoteRef.current) {
      isApplyingRemoteRef.current = false;
      return;
    }
    if (!hasLocalChangesRef.current) return;

    if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
    saveTimerRef.current = setTimeout(() => { flushSave(); }, 450);

    return () => {
      if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
    };
  }, [project, projectHash, readOnly, flushSave]);

  // Retry failed saves even if the user stops typing
  useEffect(() => {
    if (!projectHash || readOnly) return;
    saveRetryTimerRef.current = setInterval(() => {
      if (hasLocalChangesRef.current && !saveInFlightRef.current) flushSave();
    }, 5000);
    return () => {
      if (saveRetryTimerRef.current) clearInterval(saveRetryTimerRef.current);
    };
  }, [projectHash, readOnly, flushSave]);

  // Best-effort save when the tab closes
  useEffect(() => {
    if (!projectHash || readOnly) return;
    function onBeforeUnload() {
      if (!hasLocalChangesRef.current) return;
      const snap = latestProjectRef.current;
      if (!snap) return;
      try {
        fetch(`/api/project/${encodeURIComponent(projectHash)}`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            project: { ...snap, id: projectHash },
            expectedVersion: Number(snap.version || 0),
          }),
          keepalive: true,
        });
      } catch {}
    }
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [projectHash, readOnly]);

  // ── WebSocket live sync (primary): full updates + ops + cursor relay ──
  useEffect(() => {
    if (!isLoggedIn || !projectHash || readOnly) return;
    let alive = true;
    let reconnectTimer = null;
    let reconnectDelay = 500;

    function applyRemote(latest) {
      const remoteVersion = Number(latest.version || 0);
      const localVersion  = Number(latestProjectRef.current?.version || 0);
      if (remoteVersion <= localVersion) return;
      if (saveInFlightRef.current) return; // in-flight save will reconcile via 409 → merge
      if (hasLocalChangesRef.current && syncBaseRef.current) {
        // Merge remote into local without losing unsaved work
        const merged = mergeProjects(syncBaseRef.current, latestProjectRef.current, latest);
        merged.id = projectHash;
        syncBaseRef.current = latest;
        saveProject(projectHash, merged);
        setProject(merged);
        return;
      }
      isApplyingRemoteRef.current = true;
      syncBaseRef.current = latest;
      saveProject(projectHash, latest);
      setProject(latest);
    }

    function announceCursor() {
      const u = userRef.current;
      sendWs({
        type: 'cursor', userId: u.id, name: u.name, color: u.color,
        chartId: latestProjectRef.current?.lastOpenedChartId || null,
        nodeId: null,
      });
    }

    function connect() {
      if (!alive) return;
      const proto = location.protocol === 'https:' ? 'wss' : 'ws';
      const ws = new WebSocket(`${proto}://${location.host}/ws/${encodeURIComponent(projectHash)}`);
      wsRef.current = ws;

      ws.onopen = () => {
        wsConnectedRef.current = true;
        reconnectDelay = 500;
        announceCursor();
      };

      ws.onmessage = evt => {
        try {
          const msg = JSON.parse(evt.data);
          if (msg.type === 'presence') {
            setOthersOnline(Math.max(0, (msg.count || 1) - 1));
          } else if (msg.type === 'project_update') {
            applyRemote(msg.project);
          } else if (msg.type === 'op') {
            applyRemoteOp(msg);
          } else if (msg.type === 'cursor') {
            if (msg.userId === userRef.current.id) return;
            setRemoteUsers(prev => ({
              ...prev,
              [msg.userId]: { name: msg.name, color: msg.color, chartId: msg.chartId, nodeId: msg.nodeId, ts: Date.now() },
            }));
          } else if (msg.type === 'leave') {
            if (!msg.userId) return;
            setRemoteUsers(prev => {
              const next = { ...prev }; delete next[msg.userId]; return next;
            });
          }
        } catch {}
      };

      ws.onclose = () => {
        wsConnectedRef.current = false;
        wsRef.current = null;
        setOthersOnline(0);
        setRemoteUsers({});
        if (alive) {
          reconnectTimer = setTimeout(connect, Math.min(reconnectDelay, 8000));
          reconnectDelay = Math.min(Math.round(reconnectDelay * 1.6), 8000);
        }
      };

      ws.onerror = () => { try { ws.close(); } catch {} };
    }

    connect();
    return () => {
      alive = false;
      clearTimeout(reconnectTimer);
      wsConnectedRef.current = false;
      setOthersOnline(0);
      setRemoteUsers({});
      const u = userRef.current;
      try { wsRef.current?.send(JSON.stringify({ type: 'leave', userId: u.id })); } catch {}
      if (wsRef.current) { try { wsRef.current.close(); } catch {} ; wsRef.current = null; }
    };
  }, [isLoggedIn, projectHash, readOnly]);

  // ── Broadcast my cursor / focused node when selection or chart changes ──
  useEffect(() => {
    if (!isLoggedIn || readOnly || !wsConnectedRef.current) return;
    const u = userRef.current;
    sendWs({
      type: 'cursor', userId: u.id, name: u.name, color: u.color,
      chartId: selectedChartId || null, nodeId: selectedNodeId || null,
    });
  }, [selectedChartId, selectedNodeId, isLoggedIn, readOnly, othersOnline]);

  // ── Fallback polling when WebSocket is not connected ──
  useEffect(() => {
    if (!isLoggedIn || !projectHash || readOnly) return;
    const timer = setInterval(async () => {
      if (wsConnectedRef.current) return;
      try {
        const latest = await fetchServerProject(projectHash);
        if (!latest) return;
        const localVersion  = Number(latestProjectRef.current?.version || 0);
        const remoteVersion = Number(latest.version || 0);
        if (remoteVersion <= localVersion) return;
        if (hasLocalChangesRef.current || saveInFlightRef.current) return;
        isApplyingRemoteRef.current = true;
        syncBaseRef.current = latest;
        saveProject(projectHash, latest);
        setProject(latest);
        showToast('Live update received');
      } catch {}
    }, 4000);
    return () => clearInterval(timer);
  }, [isLoggedIn, projectHash, readOnly]);

  async function handleLogin(hash, nameHint) {
    const localProj = loadProject(hash);
    const remoteProj = await fetchServerProject(hash);
    let proj = (typeof reconcileProjectsOnLoad === 'function')
      ? reconcileProjectsOnLoad(localProj, remoteProj)
      : (localProj || remoteProj);
    let recoveredFromServer = false;
    let createdFresh = false;
    if (proj && remoteProj && proj === remoteProj && !localProj) {
      recoveredFromServer = true;
      saveProject(hash, proj);
    } else if (proj) {
      const cache = saveProject(hash, proj);
      if (cache?.strippedImages) showToast('Project loaded — large projects keep images on the server only.');
      if (remoteProj && proj !== localProj) recoveredFromServer = true;
    }
    if (!proj) {
      proj = await openServerProject(hash);
      if (proj) saveProject(hash, proj);
    }
    if (!proj) {
      // Fresh project — use the typed display name as the initial project
      // name. The user can rename later in Settings; the ID stays the same.
      proj = createSeedProject(hash, nameHint);
      createdFresh = true;
    }
    // Migrations
    if (!proj.statuses) proj = { ...proj, statuses: DEFAULT_STATUSES.map(s => ({ ...s })) };
    // Use migrateProject so node.linkGroupId / edge.route default to null
    // and old saves with missing fields still work.
    proj = (typeof migrateProject === 'function') ? migrateProject(proj) : proj;
    // Server-recovered projects without a name → backfill with the typed ID
    // so the chart list never shows "Unnamed project" right after login.
    if (!proj.name && nameHint) proj = { ...proj, name: nameHint };
    hasLocalChangesRef.current = createdFresh; // flush the fresh seed to server
    isApplyingRemoteRef.current = !createdFresh;
    syncBaseRef.current = proj;
    setProjectHash(hash); setProject(proj);
    setSelectedChartId(proj.lastOpenedChartId || proj.charts[0]?.id || '');
    saveSession(hash); setIsLoggedIn(true); setReadOnly(false);
    // Save to recents
    saveRecentProject(hash, proj.name || nameHint || 'Unnamed project');
    if (recoveredFromServer) showToast('Recovered your previous server save.');
  }

  // ── updateProject — central mutator with undo ──
  // History is pushed OUTSIDE the setProject updater so it's safe in React 18
  // Strict Mode (updaters may be called twice; side-effects inside would corrupt history).
  function updateProject(updater) {
    // Read-and-reset the skip flag synchronously, before any async work.
    const doHistory = !skipHistoryRef.current;
    skipHistoryRef.current = false;

    if (doHistory) {
      // latestProjectRef is kept in sync via useEffect; use it so we always
      // push the true latest state, not a potentially-stale closure capture.
      const cur = latestProjectRef.current;
      if (cur) {
        historyRef.current.past.push(cur);
        if (historyRef.current.past.length > 80) historyRef.current.past.shift();
        historyRef.current.future = [];
      }
    }

    setProject(prev => {
      if (!prev) return prev;
      const next = updater(prev);
      if (!isApplyingRemoteRef.current) hasLocalChangesRef.current = true;
      // Persisting to localStorage is handled by the project-change effect.
      // Doing it here too double-writes every keystroke and, under React 18
      // Strict Mode, runs twice because updaters are invoked twice.
      return next;
    });
  }

  function undo() {
    const h = historyRef.current;
    if (!h.past.length || !project) { showToast('Nothing to undo'); return; }
    const prev = h.past.pop();
    h.future.push(project);
    skipHistoryRef.current = true;
    hasLocalChangesRef.current = true;
    setProject(prev);
    if (projectHash) saveProject(projectHash, prev);
    showToast('↶ Undo');
  }

  function redo() {
    const h = historyRef.current;
    if (!h.future.length || !project) { showToast('Nothing to redo'); return; }
    const next = h.future.pop();
    h.past.push(project);
    skipHistoryRef.current = true;
    hasLocalChangesRef.current = true;
    setProject(next);
    if (projectHash) saveProject(projectHash, next);
    showToast('↷ Redo');
  }

  // ── Navigation ──
  function selectChart(id) {
    setSelectedChartId(id); setSelectedNodeId(''); setNavStack([]);
    setConnectMode(false); setConnectSource(null);
    setPan({ x: 80, y: 60 }); setScale(1);
    setEditingNodeId(null); setEditingField(null);
    updateProject(p => ({ ...p, lastOpenedChartId: id }));
  }

  function openSubChart(subChartId) {
    if (!subChartId || !project) return;
    const target = project.charts.find(c => c.id === subChartId);
    if (!target) { showToast('Sub-chart not found'); return; }
    if (!currentChart) return;
    setNavStack(prev => [...prev, { chartId: currentChart.id, label: currentChart.title }]);
    setSelectedChartId(subChartId); setSelectedNodeId('');
    setConnectMode(false); setConnectSource(null);
    setPan({ x: 80, y: 60 }); setScale(1);
    setEditingNodeId(null); setEditingField(null);
    setExpandedNodeId(null);
    setTimeout(() => setFitTrigger(n => n + 1), 80);
  }

  function navigateBreadcrumb(index) {
    const target = navStack[index];
    if (!target) return;
    setNavStack(prev => prev.slice(0, index));
    setSelectedChartId(target.chartId); setSelectedNodeId('');
    setConnectMode(false); setConnectSource(null);
    setPan({ x: 80, y: 60 }); setScale(1);
  }

  // ── Chart operations ──
  function createChart() {
    const rootId = uid('node');
    const newChart = {
      id: uid('chart'), title: 'New Chart', description: '', orientation: 'vertical', rootNodeId: rootId,
      nodes: [mkNode(rootId, 'Start', '', 400, 80, 'generic', statuses[0]?.id || 'todo')],
      edges: [],
    };
    updateProject(p => ({ ...p, charts: [...p.charts, newChart], lastOpenedChartId: newChart.id }));
    setSelectedChartId(newChart.id); setSelectedNodeId(newChart.rootNodeId); setNavStack([]);
    setTimeout(() => setFitTrigger(n => n + 1), 80);
  }

  function reorderCharts(fromId, toId) {
    if (!fromId || !toId || fromId === toId) return;
    updateProject(p => {
      const charts = [...p.charts];
      const fromIdx = charts.findIndex(c => c.id === fromId);
      const toIdx   = charts.findIndex(c => c.id === toId);
      if (fromIdx === -1 || toIdx === -1) return p;
      const [moved] = charts.splice(fromIdx, 1);
      charts.splice(toIdx, 0, moved);
      return { ...p, charts };
    });
  }

  // Clone an entire chart (nodes, edges, layout) as an independent copy.
  // Cloned nodes get fresh ids and have their linkGroupId stripped so the
  // clone is truly independent of the original (per user request).
  function cloneChart(chartId) {
    if (!project) return;
    const src = project.charts.find(c => c.id === chartId);
    if (!src) return;
    const dup = cloneChartDeep(src, { preserveLinks: false });
    updateProject(p => ({ ...p, charts: [...p.charts, dup], lastOpenedChartId: dup.id }));
    setSelectedChartId(dup.id); setSelectedNodeId(''); setNavStack([]);
    setTimeout(() => setFitTrigger(n => n + 1), 80);
    showToast(`Cloned chart "${src.title}"`);
  }

  function deleteChart(chartId) {
    const remaining = project.charts.filter(c => c.id !== chartId);
    if (remaining.length === 0) {
      // If this is the only chart, replace it with a fresh empty one so the
      // app has something to show, but still honour the user's request to
      // delete the original.
      const rootId = uid('node');
      const fresh = {
        id: uid('chart'), title: 'New Chart', description: '', orientation: 'vertical', rootNodeId: rootId,
        nodes: [mkNode(rootId, 'Start', '', 400, 80, 'generic', statuses[0]?.id || 'todo')],
        edges: [],
      };
      updateProject(p => ({ ...p, charts: [fresh], lastOpenedChartId: fresh.id }));
      setSelectedChartId(fresh.id); setSelectedNodeId(fresh.rootNodeId); setNavStack([]);
      showToast('Chart deleted · fresh chart created');
      return;
    }
    const newSelected = chartId === selectedChartId ? remaining[0].id : selectedChartId;
    updateProject(p => ({ ...p, charts: remaining, lastOpenedChartId: newSelected }));
    if (chartId === selectedChartId) {
      setSelectedChartId(newSelected); setSelectedNodeId(''); setNavStack([]);
    }
    showToast('Chart deleted');
  }

  // ── Node operations ──
  function addChildNode(parentId) {
    if (!currentChart) return;
    const parent = currentChart.nodes.find(n => n.id === parentId);
    if (!parent) return;
    const vert    = currentChart.orientation === 'vertical';
    const preferX = parent.x + (vert ? 0 : 330);
    const preferY = parent.y + (vert ? 230 : 0);
    const pos     = findFreePosition(currentChart.nodes, preferX, preferY, vert);
    const newNode = mkNode(uid('node'), 'New node', '', pos.x, pos.y, parent.typeId, statuses[0]?.id || 'todo');
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c,
        nodes: [...c.nodes, newNode],
        edges: [...c.edges, { id: uid('edge'), from: parentId, to: newNode.id }],
      }),
    }));
    setSelectedNodeId(newNode.id);
    setTimeout(() => { setEditingNodeId(newNode.id); setEditingField('label'); }, 60);
  }

  function addNodeAt(x, y) {
    if (!currentChart) return;
    const newNode = mkNode(uid('node'), 'New node', '', x, y, 'generic', statuses[0]?.id || 'todo');
    updateProject(p => ({ ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, newNode] }) }));
    setSelectedNodeId(newNode.id);
    setTimeout(() => { setEditingNodeId(newNode.id); setEditingField('label'); }, 60);
  }

  function deleteNode(nodeId) {
    if (!currentChart) return;
    const isRoot = nodeId === currentChart.rootNodeId;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => {
        if (c.id !== currentChart.id) return c;
        const remainingNodes = c.nodes.filter(n => n.id !== nodeId);
        const remainingEdges = c.edges.filter(e => e.from !== nodeId && e.to !== nodeId);
        let newRoot = c.rootNodeId;
        if (isRoot) {
          // Pick a new root: prefer a node with no remaining parents, else first
          const hasParent = new Set(remainingEdges.map(e => e.to));
          const noParent = remainingNodes.find(n => !hasParent.has(n.id));
          newRoot = (noParent || remainingNodes[0])?.id || null;
        }
        return { ...c, rootNodeId: newRoot, nodes: remainingNodes, edges: remainingEdges };
      }),
    }));
    if (selectedNodeId === nodeId) setSelectedNodeId('');
    if (editingNodeId === nodeId) { setEditingNodeId(null); setEditingField(null); }
    if (expandedNodeId === nodeId) setExpandedNodeId(null);
  }

  function deleteEdge(edgeId) {
    if (!currentChart) return;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, edges: c.edges.filter(e => e.id !== edgeId),
      }),
    }));
    showToast('Connection removed');
  }

  function updateNode(nodeId, changes) {
    if (!currentChart) return;
    if ('label' in changes || 'notes' in changes || 'details' in changes) {
      skipHistoryRef.current = true;
    }
    sendThrottledUpdate(currentChart.id, nodeId, changes);
    updateProject(p => {
      // Apply changes to the target node in its own chart.
      let next = {
        ...p,
        charts: p.charts.map(c => c.id !== currentChart.id ? c : {
          ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, ...changes } : n),
        }),
      };
      // Then propagate to every other node sharing the same link group.
      // propagateLinkedFields filters changes to syncable fields only and
      // skips the source node itself, so position/sub-chart edits stay local.
      if (typeof propagateLinkedFields === 'function') {
        next = propagateLinkedFields(next, nodeId, changes);
      }
      return next;
    });
  }

  // Link an existing node in this chart to another node anywhere in the
  // project. The picked target adopts THIS node's content (this node is
  // treated as master, per the user's spec). Existing content on the target
  // is overwritten.
  function linkExistingNodes(masterNodeId, targetChartId, targetNodeId) {
    if (!currentChart) return;
    updateProject(p => linkNodes(p, currentChart.id, masterNodeId, targetChartId, targetNodeId));
    showToast('Nodes linked');
  }

  // Drop a node out of its link group (leaves siblings untouched).
  function unlinkNodeFromGroup(nodeId) {
    updateProject(p => unlinkNode(p, nodeId));
    showToast('Node unlinked');
  }

  function duplicateNode(nodeId) {
    if (!currentChart) return;
    const orig = currentChart.nodes.find(n => n.id === nodeId);
    if (!orig) return;
    const copy = { ...orig, id: uid('node'), label: `${orig.label} (copy)`, x: orig.x + 40, y: orig.y + 40, subChartId: null, poll: null };
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, copy] }),
    }));
    setSelectedNodeId(copy.id);
    showToast('Node duplicated');
  }

  function copyNode(nodeId) {
    if (!currentChart) return;
    const node = currentChart.nodes.find(n => n.id === nodeId);
    if (!node) return;
    clipboardRef.current = { ...node };
    showToast('Node copied');
  }

  function pasteNode() {
    if (!clipboardRef.current || !currentChart) return;
    const src = clipboardRef.current;
    const newNode = { ...src, id: uid('node'), label: src.label, x: src.x + 60, y: src.y + 60, subChartId: null, poll: null };
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, nodes: [...c.nodes, newNode] }),
    }));
    setSelectedNodeId(newNode.id);
    showToast('Node pasted');
  }

  // ── Multi-select bulk operations ─────────────────────────────────────────
  // Each operates on the current selectedNodeIds set within the active chart.

  // Bulk delete: removes every selected node and any edge that touches one.
  // Promotes a new root if the chart's rootNodeId was in the selection.
  function bulkDelete() {
    if (!currentChart || !selectedNodeIds.size) return;
    const ids = new Set(selectedNodeIds);
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => {
        if (c.id !== currentChart.id) return c;
        const nodes = c.nodes.filter(n => !ids.has(n.id));
        const edges = c.edges.filter(e => !ids.has(e.from) && !ids.has(e.to));
        let rootNodeId = c.rootNodeId;
        if (ids.has(rootNodeId)) {
          const hasParent = new Set(edges.map(e => e.to));
          const noParent  = nodes.find(n => !hasParent.has(n.id));
          rootNodeId = (noParent || nodes[0])?.id || null;
        }
        return { ...c, nodes, edges, rootNodeId };
      }),
    }));
    setSelectedNodeIds(new Set());
    if (ids.has(selectedNodeId)) setSelectedNodeId('');
    showToast(`Deleted ${ids.size} nodes`);
  }

  // Copy a group as JSON to a refs-only clipboard. Preserves relative
  // positions and any internal edges. Paste re-inserts with fresh ids.
  function bulkCopy() {
    if (!currentChart || !selectedNodeIds.size) return;
    const ids = new Set(selectedNodeIds);
    const nodes = currentChart.nodes.filter(n => ids.has(n.id));
    const edges = currentChart.edges.filter(e => ids.has(e.from) && ids.has(e.to));
    clipboardRef.current = { kind: 'group', nodes, edges };
    showToast(`Copied ${nodes.length} nodes`);
  }

  function bulkPaste() {
    if (!clipboardRef.current || clipboardRef.current.kind !== 'group' || !currentChart) {
      pasteNode(); return; // fall through to single-node paste
    }
    const { nodes, edges } = clipboardRef.current;
    const idMap = new Map();
    const newNodes = nodes.map(n => {
      const newId = uid('node');
      idMap.set(n.id, newId);
      // Clear subChartId + linkGroupId on paste — a duplicated branch
      // becomes independent, matching how single-node paste behaves today.
      return { ...n, id: newId, x: n.x + 60, y: n.y + 60, subChartId: null, linkGroupId: null };
    });
    const newEdges = edges.map(e => ({
      ...e, id: uid('edge'),
      from: idMap.get(e.from) || e.from,
      to:   idMap.get(e.to)   || e.to,
    }));
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, nodes: [...c.nodes, ...newNodes], edges: [...c.edges, ...newEdges],
      }),
    }));
    setSelectedNodeIds(new Set(newNodes.map(n => n.id)));
    showToast(`Pasted ${newNodes.length} nodes`);
  }

  // Apply a single change patch to every selected node. Use for bulk type /
  // status / tag-add updates from the floating action bar.
  function bulkUpdate(changes) {
    if (!currentChart || !selectedNodeIds.size) return;
    const ids = new Set(selectedNodeIds);
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, nodes: c.nodes.map(n => ids.has(n.id) ? { ...n, ...changes } : n),
      }),
    }));
  }

  // Bulk-add a tag (deduped per node).
  function bulkAddTag(tag) {
    if (!currentChart || !selectedNodeIds.size || !tag) return;
    const ids = new Set(selectedNodeIds);
    const t = tag.replace(/^#/, '').trim().toLowerCase();
    if (!t) return;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, nodes: c.nodes.map(n => {
          if (!ids.has(n.id)) return n;
          const cur = n.tags || [];
          return cur.includes(t) ? n : { ...n, tags: [...cur, t] };
        }),
      }),
    }));
    showToast(`Tag #${t} added`);
  }

  // Move every selected node by (dx, dy). Used when the user drags any
  // member of the multi-select group on the canvas.
  function bulkMove(dx, dy) {
    if (!currentChart || !selectedNodeIds.size) return;
    const ids = new Set(selectedNodeIds);
    skipHistoryRef.current = true;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, nodes: c.nodes.map(n => ids.has(n.id) ? { ...n, x: n.x + dx, y: n.y + dy } : n),
      }),
    }));
  }

  // Group the current multi-selection into a new sub-chart. The first
  // selected node (closest to the chart root) becomes the new sub-chart's
  // root; in the source chart, that root remains as a link node pointing at
  // the new sub-chart and the rest of the selected branch is removed.
  function bulkGroupIntoSubChart() {
    if (!currentChart || selectedNodeIds.size < 1) return;
    const ids = new Set(selectedNodeIds);
    const group = currentChart.nodes.filter(n => ids.has(n.id));
    if (!group.length) return;
    // Pick branch root: prefer the chart's root if it's in the selection,
    // else a node with no parents inside the group, else the first.
    const inGroup = new Set(group.map(n => n.id));
    let branchRoot = group.find(n => n.id === currentChart.rootNodeId)
      || group.find(n => !currentChart.edges.some(e => inGroup.has(e.from) && e.to === n.id))
      || group[0];
    const branchEdges = currentChart.edges.filter(e => inGroup.has(e.from) && inGroup.has(e.to));
    const newChartId = uid('chart');
    // Normalise positions so root lands near (400, 80).
    const minX = Math.min(...group.map(n => n.x));
    const minY = Math.min(...group.map(n => n.y));
    const offX = 400 - (branchRoot.x - minX);
    const offY = 80  - (branchRoot.y - minY);
    const normalised = group.map(n => ({ ...n, x: n.x - minX + offX, y: n.y - minY + offY }));
    const newChart = {
      id: newChartId,
      title: `${branchRoot.label} — Group`,
      description: `Grouped from "${currentChart.title}"`,
      orientation: currentChart.orientation,
      rootNodeId: branchRoot.id,
      nodes: normalised,
      edges: branchEdges.map(e => ({ ...e })),
    };
    updateProject(p => ({
      ...p,
      charts: [
        ...p.charts.map(c => {
          if (c.id !== currentChart.id) return c;
          return {
            ...c,
            nodes: c.nodes.map(n => n.id === branchRoot.id ? { ...n, subChartId: newChartId } : n)
                          .filter(n => !ids.has(n.id) || n.id === branchRoot.id),
            edges: c.edges.filter(e => !ids.has(e.from) && !ids.has(e.to)
                                    || e.from === branchRoot.id || e.to === branchRoot.id),
          };
        }),
        newChart,
      ],
    }));
    setSelectedNodeIds(new Set());
    showToast(`Grouped ${group.length} nodes into sub-chart`);
  }

  function updateNodePos(nodeId, dx, dy) {
    if (!currentChart) return;
    skipHistoryRef.current = true;
    // Read live position so we can emit absolute coords for the move op (send immediately, no throttle)
    const liveNode = latestProjectRef.current?.charts?.find(c => c.id === currentChart.id)?.nodes?.find(n => n.id === nodeId);
    if (liveNode) sendOpMove(currentChart.id, nodeId, liveNode.x + dx, liveNode.y + dy);
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c, nodes: c.nodes.map(n => n.id !== nodeId ? n : { ...n, x: n.x + dx, y: n.y + dy }),
      }),
    }));
  }

  function updateChartField(field, value) {
    if (!currentChart) return;
    if (field === 'title' || field === 'description') skipHistoryRef.current = true;
    updateProject(p => ({
      ...p, charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, [field]: value }),
    }));
  }

  // ── Sub-chart ──
  // When a sub-chart is created from a node, the new root is auto-linked to
  // the originating node via a shared linkGroupId. Editing either node's
  // content (label/notes/details/etc.) now syncs to the other through
  // propagateLinkedFields. The originating node also gets subChartId set so
  // navigation still works.
  function createSubChart(nodeId, nodeLabel) {
    if (!currentChart) return;
    const sourceNode = currentChart.nodes.find(n => n.id === nodeId);
    // Reuse the existing link group if the source node already has one, so
    // multiple sub-charts of the same node share the same content.
    const groupId   = (sourceNode && sourceNode.linkGroupId) || ('link_' + Math.random().toString(36).slice(2,10) + Date.now().toString(36));
    const rootId    = uid('node');
    // Build root with the same content as the source so the auto-link starts
    // in sync from second 1.
    const rootNode  = sourceNode
      ? { ...mkNode(rootId, sourceNode.label, sourceNode.notes, 400, 80, sourceNode.typeId, sourceNode.status),
          details: sourceNode.details || '', tags: [...(sourceNode.tags || [])],
          images: [...(sourceNode.images || [])], poll: sourceNode.poll || null,
          linkGroupId: groupId }
      : { ...mkNode(rootId, nodeLabel, '', 400, 80, 'generic', statuses[0]?.id || 'todo'),
          linkGroupId: groupId };
    const newChart = {
      id: uid('chart'), title: `${nodeLabel} — Detail`, description: `Sub-chart for: ${nodeLabel}`,
      orientation: 'vertical', rootNodeId: rootId,
      nodes: [rootNode],
      edges: [],
    };
    updateProject(p => ({
      ...p,
      charts: [
        ...p.charts.map(c => c.id !== currentChart.id ? c : {
          ...c, nodes: c.nodes.map(n => n.id === nodeId
            ? { ...n, subChartId: newChart.id, linkGroupId: groupId }
            : n),
        }),
        newChart,
      ],
    }));
    showToast(`Sub-chart "${newChart.title}" created · root linked`);
  }

  // Split a node + its descendants into a new sub-chart. The node stays in
  // the current chart as a "link" node (with subChartId set), the branch is
  // extracted into a new chart.
  function splitToSubChart(nodeId) {
    if (!currentChart) return;
    const branchNode = currentChart.nodes.find(n => n.id === nodeId);
    if (!branchNode) return;
    const descendants = getDescendants(currentChart, nodeId);
    if (descendants.size === 0) { showToast('No descendants to split'); return; }
    const branchIds = new Set([nodeId, ...descendants]);
    const branchNodes = currentChart.nodes.filter(n => branchIds.has(n.id));
    const branchEdges = currentChart.edges.filter(e => branchIds.has(e.from) && branchIds.has(e.to));

    // Normalise positions so the branch starts near (400, 80) in the new chart
    const minX = Math.min(...branchNodes.map(n => n.x));
    const minY = Math.min(...branchNodes.map(n => n.y));
    const newChartId = uid('chart');
    // The branch root STAYS in the source chart as a link node, so the copy in
    // the new chart needs a fresh id — reusing nodeId would put two nodes with
    // the same id in the project and break link-group/jump lookups.
    const newRootId = uid('node');
    const normalisedNodes = branchNodes.map(n => {
      const moved = { ...n, x: n.x - minX + 400 - (branchNode.x - minX), y: n.y - minY + 80 };
      return n.id === nodeId ? { ...moved, id: newRootId } : moved;
    });

    const newChart = {
      id: newChartId,
      title: `${branchNode.label} — Branch`,
      description: `Split from "${currentChart.title}"`,
      orientation: currentChart.orientation,
      rootNodeId: newRootId,
      nodes: normalisedNodes,
      edges: branchEdges.map(e => ({
        ...e,
        from: e.from === nodeId ? newRootId : e.from,
        to:   e.to   === nodeId ? newRootId : e.to,
      })),
    };

    // In the current chart, remove the descendant nodes + their edges, keep
    // the branch root as a link node pointing at the new sub-chart.
    updateProject(p => ({
      ...p,
      charts: [
        ...p.charts.map(c => {
          if (c.id !== currentChart.id) return c;
          return {
            ...c,
            nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: newChartId } : n)
                          .filter(n => !descendants.has(n.id)),
            edges: c.edges.filter(e => !descendants.has(e.from) && !descendants.has(e.to)),
          };
        }),
        newChart,
      ],
    }));
    showToast(`Split into sub-chart "${newChart.title}"`);
  }

  function promoteToRoot(nodeId) {
    if (!currentChart) return;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : { ...c, rootNodeId: nodeId }),
    }));
    showToast('Chart root updated');
  }

  // Attach an existing chart as the sub-chart of a node.
  function attachChartAsSubChart(nodeId, chartId) {
    if (!currentChart || !chartId || chartId === currentChart.id) return;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c,
        nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: chartId } : n),
      }),
    }));
    const attached = project.charts.find(c => c.id === chartId);
    showToast(`Linked "${attached?.title || 'chart'}" as sub-chart`);
  }

  // Detach the sub-chart link from a node (does not delete the sub-chart itself).
  function detachSubChart(nodeId) {
    if (!currentChart) return;
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart.id ? c : {
        ...c,
        nodes: c.nodes.map(n => n.id === nodeId ? { ...n, subChartId: null } : n),
      }),
    }));
    showToast('Sub-chart unlinked');
  }

  // ── Images ──
  function addImage(nodeId, dataUrl) {
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart?.id ? c : {
        ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, images: [...(n.images || []), dataUrl] } : n),
      }),
    }));
  }

  function removeImage(nodeId, index) {
    updateProject(p => ({
      ...p,
      charts: p.charts.map(c => c.id !== currentChart?.id ? c : {
        ...c, nodes: c.nodes.map(n => n.id === nodeId ? { ...n, images: (n.images || []).filter((_, i) => i !== index) } : n),
      }),
    }));
  }

  // Handle image drop from canvas (files array)
  async function handleImageDrop(nodeId, files) {
    let added = 0;
    for (const file of files) {
      if (!file.type.startsWith('image/')) continue;
      try {
        const dataUrl = (typeof readAndCompressImageFile === 'function')
          ? await readAndCompressImageFile(file)
          : await new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = () => reject(new Error('Read failed'));
            reader.readAsDataURL(file);
          });
        addImage(nodeId, dataUrl);
        added++;
      } catch (err) {
        if (err?.message === 'too_large') showToast('Image too large (max 4 MB)');
        else showToast('Could not add image');
      }
    }
    if (added) showToast(added === 1 ? 'Image added to node' : `${added} images added`);
  }

  // ── Poll ──
  function updatePoll(nodeId, poll) { updateNode(nodeId, { poll }); }

  // ── Node types ──
  function addNodeType(type) {
    updateProject(p => ({ ...p, nodeTypes: [...(p.nodeTypes || DEFAULT_NODE_TYPES), type] }));
  }

  function deleteNodeType(typeId) {
    if (!project) return;
    const allNodes = (project.charts || []).flatMap(c => c.nodes);
    if (allNodes.some(n => n.typeId === typeId)) { showToast('Cannot delete: type is in use'); return; }
    const types = (project.nodeTypes || DEFAULT_NODE_TYPES).filter(t => t.id !== typeId);
    if (types.length === 0) { showToast('Cannot delete the last type'); return; }
    updateProject(p => ({ ...p, nodeTypes: types }));
  }

  // ── Statuses ──
  function addStatus(status) {
    updateProject(p => ({ ...p, statuses: [...(p.statuses || DEFAULT_STATUSES), status] }));
  }

  function deleteStatus(statusId) {
    if (!project) return;
    const currentStatuses = project.statuses || DEFAULT_STATUSES;
    if (currentStatuses.length <= 1) { showToast('Cannot delete the last status'); return; }
    const allNodes = (project.charts || []).flatMap(c => c.nodes);
    if (allNodes.some(n => n.status === statusId)) { showToast('Cannot delete: status is in use'); return; }
    updateProject(p => ({ ...p, statuses: (p.statuses || DEFAULT_STATUSES).filter(s => s.id !== statusId) }));
  }

  // ── Connect ──
  function handleConnectNode(nodeId) {
    if (!connectSource) { setConnectSource(nodeId); return; }
    if (connectSource !== nodeId) {
      const exists = currentChart?.edges.some(e => e.from === connectSource && e.to === nodeId);
      if (!exists && currentChart) {
        updateProject(p => ({
          ...p,
          charts: p.charts.map(c => c.id !== currentChart.id ? c : {
            ...c, edges: [...c.edges, { id: uid('edge'), from: connectSource, to: nodeId }],
          }),
        }));
        showToast('Connected');
      }
    }
    setConnectMode(false); setConnectSource(null);
  }

  // ── Collapse ──
  function toggleCollapse(nodeId) {
    setCollapsedMap(prev => {
      const map = new Map(prev);
      const set = new Set(map.get(selectedChartId) || []);
      if (set.has(nodeId)) set.delete(nodeId); else set.add(nodeId);
      map.set(selectedChartId, set); return map;
    });
  }

  function toggleHideAncestors(nodeId) {
    setHidingAncMap(prev => {
      const map = new Map(prev);
      const set = new Set(map.get(selectedChartId) || []);
      if (set.has(nodeId)) set.delete(nodeId); else set.add(nodeId);
      map.set(selectedChartId, set); return map;
    });
  }

  // ── Inline editing ──
  function handleEditStart(nodeId, field) { setEditingNodeId(nodeId); setEditingField(field); }
  function handleEditChange(nodeId, field, value) { updateNode(nodeId, { [field]: value }); }
  function handleEditEnd() { setEditingNodeId(null); setEditingField(null); }

  // ── Auto-arrange (auto-fits after) ──
  function autoArrange() {
    if (!currentChart) return;
    const opts = { spacing: layoutSpacing };
    const arranged = layoutAlgorithm === 'improved'     ? arrangeImproved(currentChart, opts)
                   : layoutAlgorithm === 'hierarchical' ? arrangeHierarchicalClassic(currentChart, opts)
                   : layoutAlgorithm === 'radial'       ? arrangeRadial(currentChart)
                   : layoutAlgorithm === 'force'        ? arrangeForceDirected(currentChart)
                   : arrangeTree(currentChart, opts); // 'compact' fallback
    updateProject(p => ({ ...p, charts: p.charts.map(c => c.id === currentChart.id ? arranged : c) }));
    setTimeout(() => setFitTrigger(n => n + 1), 60);
    const label = { improved: 'Tree (RT)', hierarchical: 'Hierarchical', radial: 'Radial', force: 'Force-Directed', compact: 'Compact' }[layoutAlgorithm] || 'Tree';
    showToast('Arranged · ' + label);
  }

  // ── Navigate to adjacent node ──
  function navigateToNode(direction) {
    if (!currentChart || !selectedNodeId) return;
    let candidates = [];
    if (direction === 'child')  candidates = getChildIds(currentChart, selectedNodeId);
    if (direction === 'parent') candidates = getParentIds(currentChart, selectedNodeId);
    if (candidates.length > 0) setSelectedNodeId(candidates[0]);
  }

  // ── Export / Import ──
  function exportProject() {
    if (!project) return;
    const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `${(project.name || 'project').replace(/\s+/g, '-')}.json`;
    a.click(); URL.revokeObjectURL(url);
  }

  function handleImport(e) {
    const file = e.target.files?.[0]; if (!file) return;
    file.text().then(text => {
      try {
        let parsed = JSON.parse(text);
        if (parsed?.charts && Array.isArray(parsed.charts)) {
          if (!parsed.statuses) parsed.statuses = DEFAULT_STATUSES.map(s => ({ ...s }));
          // Full migration (node linkGroupId, edge.route, missing node fields).
          parsed = (typeof migrateProject === 'function') ? migrateProject(parsed) : parsed;
          // Import overwrites the CURRENT project. Adopt the current project's
          // id + version so the next save is a clean PUT, and reset the merge
          // base to the imported state — otherwise a stale syncBase would make
          // the first conflict three-way-merge the import away.
          const cur = latestProjectRef.current;
          if (cur) { parsed.id = cur.id; parsed.version = Number(cur.version || 0); }
          syncBaseRef.current = parsed;
          hasLocalChangesRef.current = true;
          setProject(parsed);
          setSelectedChartId(parsed.lastOpenedChartId || parsed.charts[0]?.id || '');
          setSelectedNodeId(''); setNavStack([]); showToast('Project imported ✓');
        } else { showToast('Invalid project file'); }
      } catch { showToast('Invalid project file'); }
    });
    e.target.value = '';
  }

  // Share link generator — produces a self-contained URL containing the
  // whole project as base64URL-encoded JSON. The previous implementation
  // used plain base64, which contains '+' and '/' characters — URLSearchParams
  // decodes '+' to space when reading the hash, corrupting the payload and
  // making pasted links unusable. We now use the URL-safe base64URL alphabet
  // (- _) and strip padding; the reader normalises before decoding. We also
  // keep the legacy 'encoded_' prefix for back-compat with existing links.
  function copyShareLink() {
    if (!project) return;
    try {
      const json = JSON.stringify(project);
      // encodeURIComponent → percent-escapes any non-ASCII so btoa won't choke.
      const b64    = btoa(encodeURIComponent(json));
      const b64url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
      const link = `${window.location.origin}${window.location.pathname}#share=encoded_${b64url}`;
      navigator.clipboard.writeText(link)
        .then(() => showToast('Share link copied'))
        .catch(() => showToast('Could not copy link'));
    } catch (err) {
      console.warn('copyShareLink failed:', err);
      showToast('Could not copy link');
    }
  }

  function handleAIExport() {
    if (!project) return;
    const text = generateAIExport(project);
    navigator.clipboard.writeText(text).catch(() => {});
    const blob = new Blob([text], { type: 'text/markdown' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `${(project.name || 'project').replace(/\s+/g, '-')}-design-doc.md`;
    a.click(); URL.revokeObjectURL(url);
    showToast('Design doc exported');
  }

  // ── Keyboard shortcuts ──
  useEffect(() => {
    function onKey(e) {
      const tag = e.target?.tagName?.toLowerCase();
      const isInput = tag === 'input' || tag === 'textarea' || e.target?.isContentEditable;

      if (e.key === 'Escape') {
        if (expandedNodeId) { setExpandedNodeId(null); return; }
        if (selectedNodeIds.size) { setSelectedNodeIds(new Set()); return; }
        setConnectMode(false); setConnectSource(null);
        setEditingNodeId(null); setEditingField(null);
        return;
      }

      // Undo / Redo
      if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) {
        if (isInput) return; e.preventDefault(); undo(); return;
      }
      if ((e.ctrlKey || e.metaKey) && ((e.shiftKey && (e.key === 'z' || e.key === 'Z')) || e.key === 'y')) {
        if (isInput) return; e.preventDefault(); redo(); return;
      }

      if (isInput) return;

      // Copy / Paste — group-aware when a multi-selection is active.
      if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
        if (selectedNodeIds.size > 1) { e.preventDefault(); bulkCopy(); return; }
        if (selectedNodeId)            { e.preventDefault(); copyNode(selectedNodeId); return; }
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
        e.preventDefault(); bulkPaste(); return;
      }

      // Duplicate
      if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) {
        if (selectedNodeId) { e.preventDefault(); duplicateNode(selectedNodeId); } return;
      }

      // Open expanded view
      if (e.key === 'Enter' && selectedNodeId && !expandedNodeId) {
        e.preventDefault(); setExpandedNodeId(selectedNodeId); return;
      }

      // Delete node(s) — group delete when multi-select is active.
      if ((e.key === 'Delete' || e.key === 'Backspace') && currentChart) {
        if (selectedNodeIds.size > 1) {
          e.preventDefault(); bulkDelete(); return;
        }
        if (selectedNodeId) {
          e.preventDefault(); deleteNode(selectedNodeId); return;
        }
      }

      // Single-key shortcuts below must not fire while a modifier is held —
      // otherwise Ctrl/Cmd+C falls through to "connect", Ctrl+A to "arrange",
      // Ctrl+F to "fit", etc. Those combos are handled above or by the browser.
      if (e.ctrlKey || e.metaKey || e.altKey) return;

      // Add child
      if (e.key === 'n' || e.key === 'N') { if (selectedNodeId) addChildNode(selectedNodeId); return; }

      // Connect
      if (e.key === 'c' || e.key === 'C') {
        if (!connectMode) { setConnectMode(true); setConnectSource(selectedNodeId || null); }
        else { setConnectMode(false); setConnectSource(null); }
        return;
      }

      // Fit
      if (e.key === 'f' || e.key === 'F') { setFitTrigger(n => n + 1); return; }

      // Auto-arrange
      if (e.key === 'a' || e.key === 'A') { autoArrange(); return; }

      // Navigate between nodes
      if (e.key === 'Tab') {
        e.preventDefault();
        navigateToNode(e.shiftKey ? 'parent' : 'child');
        return;
      }

      // Arrow key move
      if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selectedNodeId) {
        const step = e.shiftKey ? 40 : 8;
        const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
        const dy = e.key === 'ArrowUp'   ? -step : e.key === 'ArrowDown'  ? step : 0;
        updateNodePos(selectedNodeId, dx, dy);
        e.preventDefault();
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selectedNodeId, selectedNodeIds, currentChart, connectMode, editingNodeId, expandedNodeId, project, statuses, layoutAlgorithm]);

  if (!isLoggedIn) return <LoginScreen onLogin={handleLogin} />;

  const expandedNode = expandedNodeId
    ? (currentChart?.nodes.find(n => n.id === expandedNodeId) || null)
    : null;

  return (
    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--text)', fontFamily: 'inherit' }}>

      {/* Topbar */}
      <header style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', height: 48, borderBottom: '1px solid var(--border)', flexShrink: 0, background: 'var(--surface)' }}>
        <span style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--accent)', flexShrink: 0 }}>FlowChart</span>
        <span style={{ width: 1, height: 16, background: 'var(--border)', flexShrink: 0 }} />
        <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {project?.name || 'Project'}
          {readOnly && <span style={{ marginLeft: 8, fontSize: 9, fontWeight: 700, color: '#f59e0b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>read-only</span>}
        </span>
        <PresencePills users={Object.entries(remoteUsers).map(([id, u]) => ({ id, ...u }))} fallbackCount={othersOnline} />
        {wsConnectedRef.current === false && othersOnline === 0 && isLoggedIn && (
          <span title="Reconnecting…" style={{ fontSize: 9, fontWeight: 700, color: '#f59e0b', background: '#f59e0b22', borderRadius: 99, padding: '2px 8px', flexShrink: 0, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
            offline
          </span>
        )}
        <div style={{ flex: 1 }} />
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          {!readOnly && (
            <>
              <TopBtn onClick={undo} title="Undo (Ctrl+Z)" disabled={!historyRef.current.past.length}>↶</TopBtn>
              <TopBtn onClick={redo} title="Redo (Ctrl+Shift+Z)" disabled={!historyRef.current.future.length}>↷</TopBtn>
              <span style={{ width: 1, height: 16, background: 'var(--border)' }} />
            </>
          )}
          <TopBtn accent onClick={handleAIExport} title="Export design doc">✦ Export for AI</TopBtn>
          <TopBtn onClick={() => setShowPolls(true)} title="View all polls">◉ Polls</TopBtn>
          <TopBtn onClick={() => setShowSettings(true)}>Settings</TopBtn>
          <TopBtn onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} title="Toggle theme">
            {theme === 'dark' ? '☀︎' : '◑'}
          </TopBtn>
          {!readOnly && (
            <TopBtn danger onClick={() => { clearSession(); setIsLoggedIn(false); setProject(null); setProjectHash(null); setNavStack([]); }}>
              Exit
            </TopBtn>
          )}
        </div>
      </header>

      {/* Body */}
      <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
        <ChartSidebar
          charts={project?.charts || []}
          selectedChartId={selectedChartId}
          onSelect={selectChart}
          onCreate={createChart}
          onDelete={deleteChart}
          onClone={cloneChart}
          onReorder={reorderCharts}
          readOnly={readOnly}
          remoteUsers={remoteUsers}
          myUser={userRef.current}
          mySelectedChartId={selectedChartId}
        />

        <FcCanvas
          chart={currentChart}
          project={project}
          allCharts={project.charts}
          nodeTypes={nodeTypes}
          statuses={statuses}
          selectedNodeId={selectedNodeId}
          selectedNodeIds={selectedNodeIds}
          onSelectedNodeIdsChange={setSelectedNodeIds}
          connectSource={connectSource}
          connectMode={connectMode}
          navStack={navStack}
          scale={scale}
          pan={pan}
          roundedNodes={roundedNodes}
          collapsedSet={collapsedSet}
          hidingAncestorSet={hidingAncestorSet}
          editingNodeId={editingNodeId}
          editingField={editingField}
          fitTrigger={fitTrigger}
          readOnly={readOnly}
          remoteSelections={remoteSelections}
          onSelectNode={setSelectedNodeId}
          onConnectNode={handleConnectNode}
          onUpdateNodePos={updateNodePos}
          onBulkMove={bulkMove}
          onBulkDelete={bulkDelete}
          onBulkCopy={bulkCopy}
          onBulkPaste={bulkPaste}
          onBulkUpdate={bulkUpdate}
          onBulkAddTag={bulkAddTag}
          onBulkGroupIntoSubChart={bulkGroupIntoSubChart}
          onOpenSubChart={openSubChart}
          onScaleChange={setScale}
          onPanChange={setPan}
          onNavigate={navigateBreadcrumb}
          onAddChild={addChildNode}
          onDeleteNode={deleteNode}
          onDeleteEdge={deleteEdge}
          onUpdateEdge={(edgeId, changes) => {
            if (!currentChart) return;
            skipHistoryRef.current = true;
            updateProject(p => ({
              ...p,
              charts: p.charts.map(c => c.id !== currentChart.id ? c : {
                ...c, edges: c.edges.map(e => e.id === edgeId ? { ...e, ...changes } : e),
              }),
            }));
          }}
          onToggleCollapse={toggleCollapse}
          onToggleHideAncestors={toggleHideAncestors}
          onCreateSubChart={createSubChart}
          onAddNodeAt={addNodeAt}
          onEditStart={handleEditStart}
          onEditChange={handleEditChange}
          onEditEnd={handleEditEnd}
          onArrange={autoArrange}
          onToggleConnect={(fromId) => {
            if (connectMode) { setConnectMode(false); setConnectSource(null); }
            else { setConnectMode(true); setConnectSource(fromId || selectedNodeId || null); }
          }}
          onOpenExpanded={(id) => setExpandedNodeId(id)}
          onDuplicateNode={duplicateNode}
          onImageDrop={handleImageDrop}
          onSplitToSubChart={splitToSubChart}
          onPromoteToRoot={promoteToRoot}
          onAttachChartAsSubChart={attachChartAsSubChart}
          onDetachSubChart={detachSubChart}
          onLinkNodes={linkExistingNodes}
          onUnlinkNode={unlinkNodeFromGroup}
          onJumpToLinkedNode={(targetChartId, targetNodeId) => {
            // Navigate to the linked node's chart, select it.
            if (targetChartId !== selectedChartId) {
              setSelectedChartId(targetChartId);
              setNavStack([]);
              setPan({ x: 80, y: 60 }); setScale(1);
            }
            setSelectedNodeId(targetNodeId);
            setTimeout(() => setFitTrigger(n => n + 1), 80);
          }}
          layoutAlgorithm={layoutAlgorithm}
          onLayoutAlgorithmChange={setLayoutAlgorithm}
        />

        <Inspector
          node={selectedNode}
          chart={currentChart}
          project={project}
          allCharts={project?.charts || []}
          nodeTypes={nodeTypes}
          statuses={statuses}
          onUpdateNode={updateNode}
          onUpdateChart={updateChartField}
          onAddChild={addChildNode}
          onDeleteNode={deleteNode}
          onAddNodeType={addNodeType}
          onDeleteNodeType={deleteNodeType}
          onAddStatus={addStatus}
          onDeleteStatus={deleteStatus}
          onOpenSubChart={openSubChart}
          onCreateSubChart={createSubChart}
          onOpenExpanded={(id) => setExpandedNodeId(id)}
          onAddImage={addImage}
          onLinkNodes={linkExistingNodes}
          onUnlinkNode={unlinkNodeFromGroup}
          onJumpToLinkedNode={(targetChartId, targetNodeId) => {
            if (targetChartId !== selectedChartId) {
              setSelectedChartId(targetChartId);
              setNavStack([]);
              setPan({ x: 80, y: 60 }); setScale(1);
            }
            setSelectedNodeId(targetNodeId);
            setTimeout(() => setFitTrigger(n => n + 1), 80);
          }}
          readOnly={readOnly}
        />
      </div>

      {/* Toast */}
      {toast && (
        <div style={{ position: 'fixed', bottom: 72, left: '50%', transform: 'translateX(-50%)', background: 'var(--text)', color: 'var(--bg)', borderRadius: 99, padding: '8px 20px', fontSize: 12, fontWeight: 600, boxShadow: '0 4px 20px rgba(0,0,0,0.25)', zIndex: 300, pointerEvents: 'none', whiteSpace: 'nowrap' }}>
          {toast}
        </div>
      )}

      {showSettings && (
        <SettingsModal
          project={project} readOnly={readOnly}
          onClose={() => setShowSettings(false)}
          onUpdateName={name => {
            updateProject(p => ({ ...p, name }));            if (projectHash) saveRecentProject(projectHash, name);
          }}
          onExport={exportProject}
          onImport={handleImport}
          onCopyShare={copyShareLink}
          onAIExport={() => { handleAIExport(); setShowSettings(false); }}
          layoutSpacing={layoutSpacing}
          onLayoutSpacingChange={setLayoutSpacing}
          defaultSpacing={DEFAULT_SPACING}
          myUser={userRef.current}
          onUpdateUser={updateMyIdentity}
        />
      )}

      {expandedNode && (
        <ExpandedNodeView
          node={expandedNode}
          chart={currentChart}
          nodeTypes={nodeTypes}
          statuses={statuses}
          readOnly={readOnly}
          onClose={() => setExpandedNodeId(null)}
          onUpdateNode={updateNode}
          onUpdatePoll={updatePoll}
          onDeleteNode={deleteNode}
          onOpenSubChart={openSubChart}
          onCreateSubChart={createSubChart}
          onAddImage={addImage}
          onRemoveImage={removeImage}
        />
      )}

      {showPolls && (
        <PollsModal
          project={project}
          onClose={() => setShowPolls(false)}
          onOpenNode={(chartId, nodeId) => {
            // Switch chart if needed, then open the node's expanded view
            if (chartId !== selectedChartId) {
              setSelectedChartId(chartId);
              setNavStack([]);
              setPan({ x: 80, y: 60 }); setScale(1);
            }
            setSelectedNodeId(nodeId);
            setExpandedNodeId(nodeId);
            setTimeout(() => setFitTrigger(n => n + 1), 100);
          }}
        />
      )}
    </div>
  );
}

// ── TweaksPanel ────────────────────────────────────────────────────────────
function TweaksPanel({ visible, tweaks, onChange }) {
  if (!visible) return null;
  return (
    <div style={{ position: 'fixed', bottom: 72, right: 20, zIndex: 300, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: 16, width: 220, boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
      <div style={{ fontWeight: 700, fontSize: 12, color: 'var(--text)', marginBottom: 14 }}>Tweaks</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        {[
          { label: 'Accent color', ctrl: <input type="color" value={tweaks.accentColor} onChange={e => onChange({ accentColor: e.target.value })} style={{ width: 32, height: 24, border: 'none', borderRadius: 4, cursor: 'pointer' }} /> },
          { label: 'Dark mode',    ctrl: <input type="checkbox" checked={tweaks.darkMode !== false} onChange={e => onChange({ darkMode: e.target.checked })} /> },
          { label: 'Rounded nodes', ctrl: <input type="checkbox" checked={tweaks.roundedNodes !== false} onChange={e => onChange({ roundedNodes: e.target.checked })} /> },
        ].map(({ label, ctrl }) => (
          <div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 11, color: 'var(--text2)' }}>{label}{ctrl}</div>
        ))}
      </div>
    </div>
  );
}

// ── Root ───────────────────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accentColor": "#3b82f6",
  "darkMode": true,
  "roundedNodes": true
}/*EDITMODE-END*/;

function Root() {
  const [tweaks,        setTweaks]        = useState(TWEAK_DEFAULTS);
  const [tweaksVisible, setTweaksVisible] = useState(false);
  const [theme,         setTheme]         = useState(() => localStorage.getItem('fc_theme') || 'dark');

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('fc_theme', theme);
  }, [theme]);

  useEffect(() => {
    document.documentElement.style.setProperty('--accent', tweaks.accentColor);
    document.documentElement.style.setProperty('--accent-muted', tweaks.accentColor + '22');
  }, [tweaks.accentColor]);

  useEffect(() => {
    function onMsg(e) {
      if (e.data?.type === '__activate_edit_mode')   setTweaksVisible(true);
      if (e.data?.type === '__deactivate_edit_mode') setTweaksVisible(false);
    }
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  function handleTweakChange(changes) {
    const next = { ...tweaks, ...changes };
    setTweaks(next);
    if ('darkMode' in changes) setTheme(changes.darkMode ? 'dark' : 'light');
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*');
  }

  function handleSetTheme(t) {
    setTheme(t);
    setTweaks(p => ({ ...p, darkMode: t === 'dark' }));
  }

  return (
    <>
      <App theme={theme} setTheme={handleSetTheme} roundedNodes={tweaks.roundedNodes} />
      <TweaksPanel visible={tweaksVisible} tweaks={{ ...tweaks, darkMode: theme === 'dark' }} onChange={handleTweakChange} />
    </>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Root />);
