/* Sprig — Canvas: SVG connectors + HTML nodes in a pan/zoom group. */
const { useRef, useState, useMemo, useEffect, useCallback } = React;

// Prefix a scheme when missing so the link opens as an absolute URL, not a path
// relative to the app (which would just navigate within the PWA).
function normalizeUrl(u) {
  const s = (u || '').trim();
  if (!s) return s;
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) || /^(mailto:|tel:)/i.test(s) ? s : 'https://' + s;
}

// Blend hex color `a` toward hex color `b` by `t` (0 = all a, 1 = all b).
function mixHex(a, b, t) {
  const h = (s) => { s = s.replace('#', ''); return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]; };
  const [ar, ag, ab] = h(a), [br, bg, bb] = h(b);
  const m = (x, y) => Math.round(x + (y - x) * t).toString(16).padStart(2, '0');
  return `#${m(ar, br)}${m(ag, bg)}${m(ab, bb)}`;
}

// ---- connector path ----------------------------------------------------
function edgePath(p, c, layoutType, style) {
  const pc = { x: p.x + p.w / 2, y: p.y + p.h / 2 };
  const cc = { x: c.x + c.w / 2, y: c.y + c.h / 2 };
  if (layoutType === 'radial') {
    if (style === 'straight') return `M${pc.x},${pc.y} L${cc.x},${cc.y}`;
    const mx = (pc.x + cc.x) / 2, my = (pc.y + cc.y) / 2;
    // bow slightly toward origin
    const k = 0.12; return `M${pc.x},${pc.y} Q${mx - mx * k},${my - my * k} ${cc.x},${cc.y}`;
  }
  const vertical = layoutType === 'down';
  let sx, sy, ex, ey;
  if (vertical) {
    sx = pc.x; sy = p.y + p.h; ex = cc.x; ey = c.y; // parent bottom -> child top
  } else {
    const childRight = cc.x > pc.x;
    sx = childRight ? p.x + p.w : p.x; sy = pc.y;
    ex = childRight ? c.x : c.x + c.w; ey = cc.y;
  }
  if (style === 'straight') return `M${sx},${sy} L${ex},${ey}`;
  if (vertical) {
    const mid = (sy + ey) / 2;
    if (style === 'elbow') { const mx = (sx + ex) / 2; return `M${sx},${sy} Q${sx},${mid} ${mx},${mid} Q${ex},${mid} ${ex},${ey}`; }
    return `M${sx},${sy} C${sx},${mid} ${ex},${mid} ${ex},${ey}`;
  } else {
    const mid = (sx + ex) / 2;
    if (style === 'elbow') { const my = (sy + ey) / 2; return `M${sx},${sy} Q${mid},${sy} ${mid},${my} Q${mid},${ey} ${ex},${ey}`; }
    if (style === 'bracket') {
      // short stem off the parent -> shared vertical trunk -> straight branch into the child's vertical center
      const ey2 = cc.y;
      const dir = ex >= sx ? 1 : -1;
      const trunkX = sx + dir * Math.min(30, Math.abs(ex - sx) * 0.4);
      const vdir = ey2 >= sy ? 1 : -1;
      const r = Math.max(0, Math.min(8, Math.abs(ey2 - sy) / 2, Math.abs(ex - trunkX), Math.abs(trunkX - sx)));
      return `M${sx},${sy} L${trunkX - dir * r},${sy} Q${trunkX},${sy} ${trunkX},${sy + vdir * r} L${trunkX},${ey2 - vdir * r} Q${trunkX},${ey2} ${trunkX + dir * r},${ey2} L${ex},${ey2}`;
    }
    if (style === 'angled') {
      // short straight shoulder off the parent, then a straight line into the child's underline
      const ey2 = c.y + c.h;
      const dir = ex >= sx ? 1 : -1;
      const stem = Math.min(34, Math.abs(ex - sx) * 0.35);
      const shoulderX = sx + dir * stem;
      return `M${sx},${sy} L${shoulderX},${ey2} L${ex},${ey2}`;
    }
    if (style === 'organic') {
      // sweep into the child's bottom edge so the line merges with its underline
      const ey2 = c.y + c.h;
      return `M${sx},${sy} C${mid},${sy} ${mid},${ey2} ${ex},${ey2}`;
    }
    return `M${sx},${sy} C${mid},${sy} ${mid},${ey} ${ex},${ey}`;
  }
}

// Fraction of a sibling target, measured from each end along the axis siblings are
// stacked on, that reads as "reorder beside me" rather than "nest inside me".
const REORDER_BAND = 0.3;

function descendantSet(nodes, id) {
  const kids = {}; nodes.forEach(n => { (kids[n.parentId] = kids[n.parentId] || []).push(n.id); });
  const set = new Set([id]); const stack = [id];
  while (stack.length) { const x = stack.pop(); (kids[x] || []).forEach(k => { set.add(k); stack.push(k); }); }
  return set;
}

// which side a node's children fan out toward (works even when collapsed)
function childDir(layout, p, rc) {
  if (layout === 'down') return 'down';
  if (layout === 'right') return 'right';
  const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
  if (layout === 'both') return cx >= rc.x ? 'right' : 'left';
  const dx = cx - rc.x, dy = cy - rc.y; // radial
  if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left';
  return dy >= 0 ? 'down' : 'up';
}

function Node({ node, p, theme, tw, selected, editing, isDropTarget, dimmed, titleCandidates, onPointerDown, onSelect, onStartEdit, onCommit, onToggleCollapse, onToggleDone, hasKids, dir, childCount, connectorHover, scale, onResize, onPreviewImage }) {
  const pal = window.SPRIG.PALETTE[node.color] || window.SPRIG.PALETTE.neutral;
  const filled = !!pal.fill;
  const ref = useRef(null);
  const [hov, setHov] = useState(false);
  const [suggestions, setSuggestions] = useState([]); // [{text,count,idx}], filtered title matches
  const [activeIdx, setActiveIdx] = useState(-1); // -1 = nothing highlighted; Enter then commits the typed text
  useEffect(() => {
    if (editing && ref.current) {
      ref.current.focus();
      const r = document.createRange(); r.selectNodeContents(ref.current);
      const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
    }
    setSuggestions([]); setActiveIdx(-1);
  }, [editing]);

  // Replace the node's text with `text`, close the dropdown, and commit —
  // used by both Enter-on-highlighted-item and clicking a suggestion.
  const pickSuggestion = (text) => {
    if (!ref.current) return;
    ref.current.textContent = text;
    setSuggestions([]);
    ref.current.blur(); // -> onBlur -> onCommit
  };

  const radius = node.shape === 'pill' ? 999 : node.shape === 'rect' ? 5 : node.shape === 'underline' ? 0 : 11;
  // 'underline' is the plain "text" shape: no box, no border, no underline.
  const text = node.shape === 'underline';
  const bg = filled ? pal.fill : (text ? 'transparent' : theme.nodeBg);
  const fg = filled ? pal.text : theme.text;
  const border = filled ? 'transparent' : (text ? 'transparent' : theme.nodeBorder);

  const style = {
    position: 'absolute', left: p.x, top: p.y,
    // While editing, always let the node grow with the text (single line) so typing
    // doesn't wrap inside a fixed width; the dragged width applies once editing ends.
    ...(node.width && !editing ? { width: p.w } : { minWidth: p.w }), minHeight: p.h,
    boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: '0.5em',
    padding: text ? '4px 4px' : `0 ${node.shape === 'pill' ? 18 : 14}px`,
    borderRadius: radius, background: bg, color: fg,
    border: `1.5px solid ${border}`,
    borderBottom: `1.5px solid ${border}`,
    font: `${node.shape === 'pill' ? 600 : 500} ${tw.fontSize}px/${1.32} ${tw.fontFamily}`,
    letterSpacing: node.shape === 'pill' ? '0.01em' : '0',
    cursor: 'default', userSelect: editing ? 'text' : 'none',
    boxShadow: filled && !text ? '0 1px 2px rgba(0,0,0,0.12)' : 'none',
    transition: 'box-shadow .12s, border-color .12s, opacity .15s, filter .15s',
    opacity: dimmed ? 0.18 : (node.task && node.done ? 0.5 : 1),
    filter: dimmed ? 'saturate(0.35)' : 'none',
  };
  if (selected) style.boxShadow = `0 0 0 2px ${theme.bg}, 0 0 0 3.5px ${theme.accent}, 0 8px 22px ${theme.accent}33`;
  if (isDropTarget) style.boxShadow = `0 0 0 2px ${theme.bg}, 0 0 0 3.5px ${theme.good}`;

  const dueLabel = node.due ? new Date(node.due + 'T00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : null;
  const overdue = node.due && !node.done && node.due < tw._today;

  const onResizeDown = (e) => {
    e.stopPropagation(); e.preventDefault();
    const startX = e.clientX, startW = p.w;
    const mv = (ev) => onResize(node.id, Math.max(60, Math.round(startW + (ev.clientX - startX) / scale)));
    const up = () => { window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up); };
    window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up);
  };

  return (
    React.createElement('div', {
      style, 'data-node': node.id,
      onPointerDown: (e) => onPointerDown(e, node.id),
      onClick: (e) => { e.stopPropagation(); onSelect(node.id); },
      onDoubleClick: (e) => { e.stopPropagation(); hasKids ? onToggleCollapse(node.id) : onStartEdit(node.id); },
      onMouseEnter: () => setHov(true), onMouseLeave: () => setHov(false),
    },
      node.task && React.createElement('span', {
        onPointerDown: (e) => e.stopPropagation(),
        onClick: (e) => { e.stopPropagation(); onToggleDone(node.id); },
        style: { width: 16, height: 16, borderRadius: 5, flexShrink: 0, cursor: 'pointer',
          border: `1.5px solid ${node.done ? theme.good : (filled ? fg : theme.muted)}`,
          background: node.done ? theme.good : 'transparent', display: 'flex', alignItems: 'center',
          justifyContent: 'center', color: '#fff', fontSize: 11 } }, node.done ? '✓' : ''),
      node.icon && React.createElement('span', { style: { fontSize: tw.fontSize * 1.05, flexShrink: 0 } }, node.icon),
      React.createElement('span', {
        ref, contentEditable: editing, suppressContentEditableWarning: true,
        // Double-clicking the title text always edits — even on a node with
        // children (whose body double-click folds instead). Without this, a
        // node that has children (e.g. anything pasted, since paste brings the
        // whole branch) can never be renamed by double-click, only by F2.
        onDoubleClick: (e) => { e.stopPropagation(); if (!editing) onStartEdit(node.id); },
        onBlur: (e) => onCommit(node.id, e.currentTarget.textContent),
        onInput: (e) => {
          if (!editing || !tw.titleAutocomplete) return;
          const list = window.SPRIG.filterTitleSuggestions(titleCandidates, e.currentTarget.textContent, node.text);
          setSuggestions(list); setActiveIdx(-1);
        },
        // Titles are plain text. Without this, pasting rich content (e.g. text
        // copied from another node's title, or from an outside app) inserts the
        // clipboard's HTML as-is — some sources wrap it in a nested
        // contenteditable="false" fragment, which then silently blocks all
        // further typing in that node even though it still looks editable.
        onPaste: (e) => {
          if (!editing) return;
          e.preventDefault();
          const text = (e.clipboardData || window.clipboardData).getData('text/plain').replace(/[\r\n]+/g, ' ');
          if (!document.execCommand('insertText', false, text)) {
            const sel = window.getSelection();
            if (sel.rangeCount) {
              const range = sel.getRangeAt(0);
              range.deleteContents();
              range.insertNode(document.createTextNode(text));
              range.collapse(false);
            }
          }
          if (tw.titleAutocomplete) {
            const list = window.SPRIG.filterTitleSuggestions(titleCandidates, ref.current.textContent, node.text);
            setSuggestions(list); setActiveIdx(-1);
          }
        },
        onKeyDown: (e) => {
          if (editing && suggestions.length) {
            if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActiveIdx(i => Math.min(suggestions.length - 1, i + 1)); return; }
            if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setActiveIdx(i => Math.max(-1, i - 1)); return; }
            if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setSuggestions([]); setActiveIdx(-1); return; }
            // Enter picks a suggestion only after the user highlighted one (arrows/hover);
            // otherwise it falls through and commits the typed text as-is.
            if (e.key === 'Enter' && !e.shiftKey && activeIdx >= 0) { e.preventDefault(); e.stopPropagation(); pickSuggestion(suggestions[activeIdx].text); return; }
          }
          if (editing && e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.currentTarget.blur(); }
          if (editing) e.stopPropagation();
        },
        // Single line by default. Only wrap once the user has explicitly set a width by
        // dragging the resize handle (node.width); otherwise keep the text on one line.
        style: { outline: 'none', whiteSpace: (editing || !node.width) ? 'nowrap' : 'pre-wrap', textDecoration: node.done ? 'line-through' : 'none',
          maxWidth: (editing || !node.width) ? 'none' : Math.max(80, p.w - 40), flex: '0 1 auto' },
      }, node.text),
      editing && tw.titleAutocomplete && suggestions.length > 0 && React.createElement('div', {
        onPointerDown: (e) => e.stopPropagation(), // don't let mousedown blur the field before onMouseDown below fires
        onMouseLeave: () => setActiveIdx(-1), // hover highlight shouldn't linger and make Enter pick it
        style: { position: 'absolute', left: 0, top: 'calc(100% + 6px)', zIndex: 60,
          minWidth: 180, maxWidth: 320, background: theme.panel, border: `1px solid ${theme.line}`,
          borderRadius: 10, boxShadow: theme.shadow, padding: 4, font: `500 ${tw.fontSize * 0.92}px/1.4 ${tw.fontFamily}` },
      }, suggestions.map((s, i) => React.createElement('div', {
        key: s.text,
        onMouseEnter: () => setActiveIdx(i),
        onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); pickSuggestion(s.text); },
        style: { padding: '7px 10px', borderRadius: 7, cursor: 'pointer', whiteSpace: 'nowrap',
          overflow: 'hidden', textOverflow: 'ellipsis', color: theme.text,
          background: i === activeIdx ? theme.hover : 'transparent' },
      },
        s.text.slice(0, s.idx),
        React.createElement('mark', { key: 'm', style: { background: 'transparent', color: theme.accent, fontWeight: 700 } }, s.text.slice(s.idx, s.idx + s.len)),
        s.text.slice(s.idx + s.len),
      ))),
      dueLabel && React.createElement('span', { style: {
        fontSize: tw.fontSize * 0.72, fontFamily: tw.monoFamily, flexShrink: 0, whiteSpace: 'nowrap',
        padding: '1px 6px', borderRadius: 6, marginLeft: 2,
        color: overdue ? '#fff' : (filled ? fg : theme.muted),
        background: overdue ? theme.bad : (filled ? 'rgba(255,255,255,.18)' : theme.chipBg),
      } }, dueLabel),
      node.badge != null && React.createElement('span', { style: {
        minWidth: tw.fontSize * 1.35, height: tw.fontSize * 1.35, padding: '0 5px', flexShrink: 0, whiteSpace: 'nowrap',
        borderRadius: 999, fontSize: tw.fontSize * 0.74, fontFamily: tw.monoFamily,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: filled ? 'rgba(255,255,255,.2)' : theme.chipBg, color: filled ? fg : theme.muted } }, node.badge),
      node.link && React.createElement('a', {
        href: normalizeUrl(node.link), target: '_blank', rel: 'noopener noreferrer', title: node.link,
        // Let the native click navigation proceed (the WKWebView shell routes it to the
        // system browser). Only stop the event from bubbling up to node select/drag —
        // do NOT preventDefault, or the link won't open inside the Swift PWA shell.
        onPointerDown: (e) => e.stopPropagation(),
        onClick: (e) => e.stopPropagation(),
        style: { flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontSize: tw.fontSize * 0.9, lineHeight: 1, textDecoration: 'none', cursor: 'pointer',
          color: filled ? fg : theme.muted, opacity: 0.85 },
      }, '🔗'),
      node.image && React.createElement('img', {
        src: node.image.thumbUrl, alt: '', title: 'Click to preview',
        onPointerDown: (e) => e.stopPropagation(),
        onClick: (e) => { e.stopPropagation(); onPreviewImage && onPreviewImage(node.image.url); },
        style: { height: tw.fontSize * 1.6, width: tw.fontSize * 1.6, flexShrink: 0,
          objectFit: 'cover', borderRadius: 5, cursor: 'zoom-in',
          border: `1px solid ${filled ? 'rgba(255,255,255,.35)' : theme.line}` },
      }),
      (hov || selected) && React.createElement('div', {
        title: 'Drag to resize',
        onPointerDown: onResizeDown,
        style: { position: 'absolute', right: -5, top: '15%', height: '70%', width: 6,
          cursor: 'ew-resize', borderRadius: 3, background: theme.accent, opacity: 0.75,
          zIndex: 15, flexShrink: 0 },
      }),
      hasKids && React.createElement('span', {
        onPointerDown: (e) => e.stopPropagation(),
        onClick: (e) => { e.stopPropagation(); onToggleCollapse(node.id); },
        title: node.collapsed ? 'Unfold' : 'Fold',
        className: 'sprig-fold',
        style: Object.assign({
          position: 'absolute', width: 18, height: 18, borderRadius: 999,
          fontSize: 12, lineHeight: '14px', fontWeight: 700, textAlign: 'center',
          display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
          border: `2px solid ${theme.bg}`, transition: 'opacity .12s, background .12s, transform .12s',
          background: node.collapsed ? theme.accent : theme.nodeBg,
          color: node.collapsed ? '#fff' : theme.muted,
          boxShadow: node.collapsed ? 'none' : `0 0 0 1px ${theme.nodeBorder}`,
          // Collapsed: always show the count. Expanded: reveal the minus only when the
          // node or its outgoing connectors are hovered (or it's selected).
          opacity: node.collapsed ? 1 : ((hov || selected || connectorHover) ? 1 : 0),
          pointerEvents: node.collapsed ? 'auto' : ((hov || selected || connectorHover) ? 'auto' : 'none'),
          zIndex: 5,
        },
          dir === 'left' ? { left: -10, top: '50%', transform: 'translateY(-50%)' } :
          dir === 'down' ? { bottom: -10, left: '50%', transform: 'translateX(-50%)' } :
          dir === 'up' ? { top: -10, left: '50%', transform: 'translateX(-50%)' } :
          // right side: clear the resize handle (which lives at the right edge center)
          { right: -32, top: '50%', transform: 'translateY(-50%)' }),
      }, node.collapsed ? childCount : '–'),
    )
  );
}

function Canvas({ nodes, rootId, L, theme, tw, selectedId, editingId, view, setView, focusSet,
  onSelect, onCommit, onReparent, onReorder, onResize, onToggleCollapse, onToggleDone, onStartEdit, fitToken, registerApi, onPreviewImage }) {
  const wrapRef = useRef(null);
  const [pan, setPan] = useState(null);
  const [drag, setDrag] = useState(null); // {id, dx, dy, x, y, target}
  const [hoverEdgeParent, setHoverEdgeParent] = useState(null); // parent id whose connectors are hovered
  const titleCandidates = useMemo(() => tw.titleAutocomplete ? window.SPRIG.buildTitleCandidates(nodes) : [], [nodes, tw.titleAutocomplete]);

  // fit-to-screen
  const doFit = useCallback(() => {
    const el = wrapRef.current; if (!el) return;
    const b = L.bounds; const pad = 80;
    const w = el.clientWidth, h = el.clientHeight;
    const cw = b.maxX - b.minX + pad * 2, ch = b.maxY - b.minY + pad * 2;
    const scale = Math.min(1.1, Math.min(w / cw, h / ch));
    setView({ scale, tx: w / 2 - ((b.minX + b.maxX) / 2) * scale, ty: h / 2 - ((b.minY + b.maxY) / 2) * scale });
  }, [L, setView]);
  // padRight excludes a strip on the right edge (e.g. the Inspector panel) from the
  // centering math, so the target node lands in the middle of the space that's
  // actually visible instead of behind the panel.
  const centerOn = useCallback((id, scale, padRight) => {
    const el = wrapRef.current; if (!el) return; const p = L.pos[id]; if (!p) return;
    const pr = padRight || 0;
    setView(v => { const s = scale || v.scale;
      return { scale: s, tx: (el.clientWidth - pr) / 2 - (p.x + p.w / 2) * s, ty: el.clientHeight / 2 - (p.y + p.h / 2) * s }; });
  }, [L, setView]);
  useEffect(() => { if (registerApi) registerApi({ fit: doFit, center: centerOn }); }, [doFit, centerOn, registerApi]);
  useEffect(() => { doFit(); /* eslint-disable-next-line */ }, [fitToken]);
  useEffect(() => { const id = setTimeout(doFit, 60); return () => clearTimeout(id); }, []);

  // wheel zoom / trackpad pan
  const onWheel = useCallback((e) => {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const el = wrapRef.current.getBoundingClientRect();
      const mx = e.clientX - el.left, my = e.clientY - el.top;
      setView(v => { const ns = Math.min(2.4, Math.max(0.2, v.scale * (1 - e.deltaY * 0.01)));
        const k = ns / v.scale; return { scale: ns, tx: mx - (mx - v.tx) * k, ty: my - (my - v.ty) * k }; });
    } else { setView(v => ({ ...v, tx: v.tx - e.deltaX, ty: v.ty - e.deltaY })); }
  }, [setView]);
  useEffect(() => { const el = wrapRef.current; if (!el) return;
    el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [onWheel]);

  // background pan
  const onBgDown = (e) => { if (e.button !== 0) return;
    onSelect(null); setPan({ sx: e.clientX, sy: e.clientY, tx: view.tx, ty: view.ty }); };
  useEffect(() => { if (!pan) return;
    const mv = (e) => setView(v => ({ ...v, tx: pan.tx + (e.clientX - pan.sx), ty: pan.ty + (e.clientY - pan.sy) }));
    const up = () => setPan(null);
    window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up);
    return () => { window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up); };
  }, [pan, setView]);

  // node drag -> reorder siblings or reparent
  const onNodeDown = (e, id) => {
    if (id === rootId || editingId) return;
    e.stopPropagation();
    const startX = e.clientX, startY = e.clientY; let moved = false;
    const desc = descendantSet(nodes, id);
    const draggedNode = nodes.find(n => n.id === id);
    const mv = (ev) => {
      const dx = ev.clientX - startX, dy = ev.clientY - startY;
      if (!moved && Math.hypot(dx, dy) < 6) return; moved = true;
      let best = null, bd = 1e9;
      for (const nid in L.pos) { if (desc.has(nid)) continue; const p = L.pos[nid];
        const cx = view.tx + (p.x + p.w / 2) * view.scale, cy = view.ty + (p.y + p.h / 2) * view.scale;
        const d = Math.hypot(ev.clientX - cx, ev.clientY - cy); if (d < bd) { bd = d; best = nid; } }
      const target = bd < 160 ? best : null;
      let isSiblingDrop = false, insertPos = null;
      if (target && target !== id) {
        const targetNode = nodes.find(n => n.id === target);
        const sameParent = !!(targetNode && draggedNode && targetNode.parentId === draggedNode.parentId);
        if (sameParent) {
          // Siblings stack along x in a "down" layout and along y otherwise. Along
          // that axis the target's outer bands mean "insert beside me" (reorder),
          // while its middle means "drop inside me" (become my child).
          const tp = L.pos[target];
          const isDown = L.type === 'down';
          const start = isDown ? view.tx + tp.x * view.scale : view.ty + tp.y * view.scale;
          const size = (isDown ? tp.w : tp.h) * view.scale;
          const at = ((isDown ? ev.clientX : ev.clientY) - start) / (size || 1);
          if (at < REORDER_BAND) { isSiblingDrop = true; insertPos = 'before'; }
          else if (at > 1 - REORDER_BAND) { isSiblingDrop = true; insertPos = 'after'; }
        }
      }
      setDrag({ id, dx, dy, target, isSiblingDrop, insertPos });
    };
    const up = () => {
      window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up);
      setDrag(d => {
        if (d && d.target) {
          if (d.isSiblingDrop && d.insertPos) {
            onReorder(id, d.target, d.insertPos);
          } else if (d.target !== draggedNode?.parentId) {
            onReparent(id, d.target);
          }
        }
        return null;
      });
      if (!moved) onSelect(id);
    };
    window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up);
  };

  // Under a "Day"-typed node, each direct branch (e.g. LEVEIN, PERSONAL) and
  // everything below it gets a faint connector tint derived from that branch
  // root's own color — so the green branch reads green, the amber branch amber, etc.
  const dayTint = useMemo(() => {
    const kids = {}; nodes.forEach(n => { (kids[n.parentId] = kids[n.parentId] || []).push(n.id); });
    const byId = {}; nodes.forEach(n => { byId[n.id] = n; });
    const map = {}; // nodeId -> muted tint hex
    nodes.forEach(n => {
      if (n.nodeType !== 'day') return;
      (kids[n.id] || []).forEach(childId => {
        const fill = (window.SPRIG.PALETTE[(byId[childId] || {}).color] || {}).fill;
        if (!fill) return; // neutral branch -> keep normal edge color
        const tint = mixHex(fill, theme.edge, 0.5);
        const stack = [childId];
        while (stack.length) { const x = stack.pop(); map[x] = tint; (kids[x] || []).forEach(k => stack.push(k)); }
      });
    });
    return map;
  }, [nodes, theme.edge]);

  const edges = [];
  for (const n of nodes) { if (!n.parentId) continue;
    const p = L.pos[n.parentId], c = L.pos[n.id]; if (!p || !c) continue;
    const par = L.byId[n.parentId]; if (par && par.collapsed) continue;
    edges.push({ id: n.id, parent: n.parentId, d: edgePath(p, c, L.type, tw.connector),
      tint: dayTint[n.id] || null,
      dimmed: !!(focusSet && !focusSet.has(n.id)),
      color: (window.SPRIG.PALETTE[n.color] && window.SPRIG.PALETTE[n.color].fill) || theme.edge }); }

  const rp = L.pos[rootId] || { x: 0, y: 0, w: 0, h: 0 };
  const rootCenter = { x: rp.x + rp.w / 2, y: rp.y + rp.h / 2 };

  const bg = tw.background;
  const bgStyle = bg === 'dots'
    ? { backgroundImage: `radial-gradient(${theme.dot} 1.3px, transparent 1.3px)`, backgroundSize: `${22 * view.scale}px ${22 * view.scale}px`, backgroundPosition: `${view.tx}px ${view.ty}px` }
    : bg === 'grid'
    ? { backgroundImage: `linear-gradient(${theme.dot} 1px, transparent 1px), linear-gradient(90deg, ${theme.dot} 1px, transparent 1px)`, backgroundSize: `${26 * view.scale}px ${26 * view.scale}px`, backgroundPosition: `${view.tx}px ${view.ty}px` }
    : {};

  return React.createElement('div', {
    ref: wrapRef, onPointerDown: onBgDown,
    style: { position: 'absolute', inset: 0, overflow: 'hidden', background: theme.bg,
      cursor: pan ? 'grabbing' : 'default', ...bgStyle },
  },
    React.createElement('div', { style: { position: 'absolute', left: 0, top: 0,
      transform: `translate(${view.tx}px,${view.ty}px) scale(${view.scale})`, transformOrigin: '0 0' } },
      React.createElement('svg', { style: { position: 'absolute', overflow: 'visible', pointerEvents: 'none', left: 0, top: 0 } },
        edges.map(e => React.createElement('path', { key: e.id, d: e.d, fill: 'none',
          stroke: e.tint || (tw.connectorColor === 'branch' ? e.color : theme.edge),
          strokeWidth: tw.connectorWidth, strokeLinecap: 'round', opacity: e.dimmed ? 0.12 : 0.85,
          style: { transition: 'opacity .15s' } })),
        // wide transparent hit-areas: hovering a connector reveals its parent's fold control
        edges.map(e => React.createElement('path', { key: e.id + '-hit', d: e.d, fill: 'none',
          stroke: 'transparent', strokeWidth: Math.max(16, tw.connectorWidth + 14),
          strokeLinecap: 'round', pointerEvents: 'stroke', style: { cursor: 'pointer' },
          onMouseEnter: () => setHoverEdgeParent(e.parent),
          onMouseLeave: () => setHoverEdgeParent(p => p === e.parent ? null : p) }))),
      nodes.map(n => { const p = L.pos[n.id]; if (!p) return null;
        const par = L.byId[n.parentId];
        if (par && par.collapsed) return null;
        const dragging = drag && drag.id === n.id;
        const st = dragging ? { transform: `translate(${drag.dx / view.scale}px,${drag.dy / view.scale}px)`, zIndex: 50, position: 'relative' } : null;
        return React.createElement('div', { key: n.id, style: st },
          React.createElement(Node, { node: n, p, theme, tw, titleCandidates,
            selected: selectedId === n.id, editing: editingId === n.id,
            isDropTarget: drag && drag.target === n.id && !drag.isSiblingDrop,
            dimmed: !!(focusSet && !focusSet.has(n.id)),
            hasKids: (L.kids[n.id] || []).length > 0,
            childCount: (L.kids[n.id] || []).length,
            connectorHover: hoverEdgeParent === n.id,
            dir: childDir(L.type, p, rootCenter),
            onPointerDown: onNodeDown, onSelect, onStartEdit, onCommit, onToggleCollapse, onToggleDone,
            scale: view.scale, onResize, onPreviewImage }));
      }),
      // Drop-line indicator for sibling reorder
      drag && drag.isSiblingDrop && drag.target && drag.insertPos && (() => {
        const tp = L.pos[drag.target]; if (!tp) return null;
        const isDown = L.type === 'down';
        const x = isDown ? (drag.insertPos === 'before' ? tp.x - 4 : tp.x + tp.w + 2) : tp.x - 2;
        const y = isDown ? tp.y - 2 : (drag.insertPos === 'before' ? tp.y - 4 : tp.y + tp.h + 2);
        const w = isDown ? 2 : tp.w + 4;
        const h = isDown ? tp.h + 4 : 2;
        return React.createElement('div', { key: '__dropline', style: {
          position: 'absolute', left: x, top: y, width: w, height: h,
          background: theme.good, borderRadius: 2, pointerEvents: 'none', zIndex: 100,
        }});
      })()
    )
  );
}

window.SprigCanvas = Canvas;
