/**
 * PlanVisualizer.jsx
 * Owns: rendering an agent's plan as a live collapsible hierarchical tree.
 *       Status badges, expand/collapse, slide-in animations, keyboard nav, result preview.
 * Does NOT own: routing, plan orchestration, or session state.
 *
 * AG-UI events consumed (via PlanVisualizerContainer):
 *   PLAN_PROPOSED      { detail: { plan: PlanNode } }
 *   PLAN_NODE_ADDED    { detail: { parentId, node: PlanNode } }
 *   PLAN_NODE_UPDATED  { detail: { nodeId, patch: Partial<PlanNode> } }
 *   PLAN_NODE_COMPLETED{ detail: { nodeId } }
 *   PLAN_NODE_FAILED   { detail: { nodeId } }
 *
 * PlanNode shape: { id, label, description?, status: 'proposed'|'in_progress'|
 *                   'completed'|'failed'|'cancelled', children?: PlanNode[], result?: string }
 *
 * Props (PlanVisualizer — controlled):
 *   plan: PlanNode
 *   theme?: 'light'|'dark'|'auto'  (default: 'light')
 *   defaultExpanded?: boolean|number  (number = max initial depth to auto-expand)
 *   onNodeClick?: (node) => void
 *   density?: 'compact'|'spacious'  (default: 'compact')
 *
 * Zero dependencies beyond React.
 */

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

// ─── Design tokens (matches ProgressTracker / ToolCallCard / TokenMeter) ─────
const TOKENS = {
  light: {
    bg: '#FAFAF8',
    card: '#FFFFFF',
    border: '#E8E6E0',
    fg: '#1a1a1a',
    fgMuted: '#888',
    accent: '#F5A623',
    success: '#22c55e',
    successBg: 'rgba(34,197,94,0.1)',
    error: '#ef4444',
    errorBg: 'rgba(239,68,68,0.08)',
    running: '#3b82f6',
    runningBg: 'rgba(59,130,246,0.1)',
    proposed: '#94a3b8',
    proposedBg: 'rgba(148,163,184,0.08)',
    cancelled: '#94a3b8',
    cancelledBg: 'rgba(148,163,184,0.05)',
    connector: '#E8E6E0',
    shadow: '0 2px 14px rgba(0,0,0,0.07)',
    tooltipBg: '#1C1C1A',
    tooltipFg: '#F0EEE8',
    glow: 'rgba(59,130,246,0.25)',
  },
  dark: {
    bg: '#111110',
    card: '#1C1C1A',
    border: '#2E2E2C',
    fg: '#F0EEE8',
    fgMuted: '#666',
    accent: '#F5A623',
    success: '#4ade80',
    successBg: 'rgba(74,222,128,0.1)',
    error: '#f87171',
    errorBg: 'rgba(248,113,113,0.08)',
    running: '#60a5fa',
    runningBg: 'rgba(96,165,250,0.1)',
    proposed: '#475569',
    proposedBg: 'rgba(71,85,105,0.15)',
    cancelled: '#475569',
    cancelledBg: 'rgba(71,85,105,0.1)',
    connector: '#2E2E2C',
    shadow: '0 2px 14px rgba(0,0,0,0.5)',
    tooltipBg: '#2E2E2C',
    tooltipFg: '#F0EEE8',
    glow: 'rgba(96,165,250,0.3)',
  },
};

function resolveTheme(theme) {
  if (theme !== 'auto') return theme || 'light';
  if (typeof window === 'undefined') return 'light';
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

// CSS keyframes — injected once
const PV_STYLES = `
@keyframes pv-pop    { 0%{transform:scale(0.7);opacity:0} 70%{transform:scale(1.12)} 100%{transform:scale(1);opacity:1} }
@keyframes pv-fade   { from{opacity:0;transform:translateX(-6px)} to{opacity:1;transform:translateX(0)} }
@keyframes pv-slide  { from{opacity:0;max-height:0} to{opacity:1;max-height:600px} }
@keyframes pv-spin   { to { transform: rotate(360deg); } }
@keyframes pv-pulse  { 0%,100%{opacity:1;box-shadow:0 0 0 0 var(--pv-glow)} 50%{opacity:0.7;box-shadow:0 0 0 4px var(--pv-glow)} }
@keyframes pv-check  { 0%{transform:scale(0);opacity:0} 60%{transform:scale(1.25);opacity:1} 100%{transform:scale(1);opacity:1} }
`;

let _pvStylesInjected = false;
function injectPVStyles() {
  if (_pvStylesInjected || typeof document === 'undefined') return;
  const el = document.createElement('style');
  el.textContent = PV_STYLES;
  document.head.appendChild(el);
  _pvStylesInjected = true;
}

// ─── Status icons ─────────────────────────────────────────────────────────────

function ProposedIcon({ color, size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
      <circle cx="8" cy="8" r="6.5" stroke={color} strokeWidth="1.5" strokeDasharray="3 2" strokeOpacity="0.6" />
      <circle cx="8" cy="8" r="2" fill={color} fillOpacity="0.3" />
    </svg>
  );
}

function RunningIcon({ color, size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
      <circle cx="8" cy="8" r="6" stroke={color} strokeWidth="1.5" strokeOpacity="0.4" />
      <circle cx="8" cy="8" r="2.5" fill={color} />
      <circle cx="8" cy="8" r="5" stroke={color} strokeWidth="0.5" strokeOpacity="0.2" />
    </svg>
  );
}

function CompletedIcon({ color, size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none"
      style={{ animation: 'pv-check 0.3s ease', flexShrink: 0 }}>
      <circle cx="8" cy="8" r="7" fill={color} fillOpacity="0.12" />
      <path d="M4.5 8.5l2.5 2.5 4.5-5" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function FailedIcon({ color, size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none"
      style={{ animation: 'pv-pop 0.2s ease', flexShrink: 0 }}>
      <circle cx="8" cy="8" r="7" fill={color} fillOpacity="0.12" />
      <path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke={color} strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function CancelledIcon({ color, size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
      <circle cx="8" cy="8" r="6.5" stroke={color} strokeWidth="1" strokeOpacity="0.35" />
      <path d="M5 8h6" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeOpacity="0.5" />
    </svg>
  );
}

function StatusIcon({ status, tok, size = 14 }) {
  switch (status) {
    case 'proposed':    return <ProposedIcon  color={tok.proposed} size={size} />;
    case 'in_progress': return <RunningIcon   color={tok.running}  size={size} />;
    case 'completed':   return <CompletedIcon color={tok.success}  size={size} />;
    case 'failed':      return <FailedIcon    color={tok.error}    size={size} />;
    case 'cancelled':   return <CancelledIcon  color={tok.cancelled}size={size} />;
    default:            return <ProposedIcon   color={tok.proposed} size={size} />;
  }
}

function statusColor(status, tok) {
  switch (status) {
    case 'proposed':    return tok.proposed;
    case 'in_progress': return tok.running;
    case 'completed':   return tok.success;
    case 'failed':      return tok.error;
    case 'cancelled':   return tok.cancelled;
    default:            return tok.proposed;
  }
}

// Status label text
function statusLabel(status) {
  switch (status) {
    case 'proposed':    return 'proposed';
    case 'in_progress': return 'running';
    case 'completed':   return 'done';
    case 'failed':      return 'failed';
    case 'cancelled':   return 'cancelled';
    default:            return status;
  }
}

// ─── Result preview ───────────────────────────────────────────────────────────

function ResultPreview({ result, tok, dense }) {
  const [expanded, setExpanded] = useState(false);
  const MAX = dense ? 60 : 120;

  if (!result) return null;

  const truncated = result.length > MAX;
  const display = truncated && !expanded ? result.slice(0, MAX) + '…' : result;

  return (
    <div style={{ marginTop: 6 }}>
      <div style={{
        fontSize: dense ? '0.68rem' : '0.75rem',
        color: tok.fgMuted,
        background: tok.card,
        border: `1px solid ${tok.border}`,
        borderRadius: 5,
        padding: dense ? '2px 7px' : '3px 9px',
        fontFamily: "'Fira Code', monospace",
        lineHeight: 1.5,
        display: 'inline-block',
        maxWidth: '100%',
        wordBreak: 'break-word',
      }}>
        <span style={{ color: tok.success, marginRight: 5, opacity: 0.7 }}>↳</span>
        {display}
        {truncated && (
          <button
            onClick={() => setExpanded(!expanded)}
            style={{
              background: 'none', border: 'none', padding: '0 4px',
              color: tok.accent, cursor: 'pointer', fontSize: '0.75em',
              fontFamily: "'Space Grotesk', sans-serif",
            }}
          >
            {expanded ? ' collapse' : ' more'}
          </button>
        )}
      </div>
    </div>
  );
}

// ─── Single tree node row ─────────────────────────────────────────────────────

function TreeNodeRow({ node, depth, tok, expanded, onToggle, onNodeClick, density, isNew }) {
  const hasChildren = node.children && node.children.length > 0;
  const isRunning = node.status === 'in_progress';
  const color = statusColor(node.status, tok);
  const dense = density === 'compact';

  return (
    <div
      role="treeitem"
      aria-expanded={hasChildren ? expanded : undefined}
      tabIndex={0}
      data-node-id={node.id}
      style={{
        animation: isNew ? 'pv-fade 0.25s ease' : 'none',
        paddingLeft: dense ? depth * 20 + 8 : depth * 28 + 10,
        paddingTop: dense ? 5 : 8,
        paddingBottom: dense ? 5 : 8,
        paddingRight: 8,
        position: 'relative',
        cursor: 'default',
        userSelect: 'none',
        outline: 'none',
      }}
      onClick={() => onNodeClick && onNodeClick(node)}
    >
      {/* Indent guide: vertical line from this depth down to children */}
      {depth > 0 && (
        <div style={{
          position: 'absolute',
          left: (depth - 1) * (dense ? 20 : 28) + 15,
          top: 0,
          bottom: 0,
          width: 1,
          background: tok.connector,
          opacity: 0.5,
        }} />
      )}

      {/* Expand/collapse chevron */}
      {hasChildren ? (
        <button
          onClick={(e) => { e.stopPropagation(); onToggle(node.id); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            padding: '0 4px', marginRight: 5, color: tok.fgMuted,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
            transition: 'transform 0.15s ease',
            flexShrink: 0,
          }}
        >
          <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
            <path d="M3 2l4 3-4 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>
      ) : (
        <span style={{ width: 18, display: 'inline-block', flexShrink: 0 }} />
      )}

      {/* Status icon */}
      <span style={{ marginRight: 7, display: 'inline-flex', flexShrink: 0 }}>
        <StatusIcon status={node.status} tok={tok} size={dense ? 13 : 14} />
      </span>

      {/* Node label */}
      <span style={{
        fontSize: dense ? '0.78rem' : '0.82rem',
        fontWeight: 600,
        color: node.status === 'cancelled' ? tok.cancelled
          : node.status === 'failed' ? tok.error
          : node.status === 'in_progress' ? tok.fg
          : tok.fg,
        textDecoration: node.status === 'cancelled' ? 'line-through' : 'none',
        opacity: node.status === 'cancelled' ? 0.6 : 1,
        letterSpacing: isRunning ? '0.02em' : 'normal',
      }}>
        {node.label}
      </span>

      {/* Status badge */}
      <span style={{
        fontSize: '0.62rem',
        fontWeight: 700,
        color: color,
        background: node.status === 'in_progress' ? tok.runningBg
          : node.status === 'failed' ? tok.errorBg
          : node.status === 'completed' ? tok.successBg
          : node.status === 'cancelled' ? tok.cancelledBg
          : tok.proposedBg,
        padding: '1px 5px',
        borderRadius: 4,
        marginLeft: 7,
        letterSpacing: '0.04em',
        textTransform: 'uppercase',
        border: node.status === 'proposed' ? `1px dashed ${tok.proposed}` : 'none',
        animation: isRunning ? `pv-pulse 1.8s ease-in-out infinite` : 'none',
        '--pv-glow': tok.glow,
      }}>
        {statusLabel(node.status)}
      </span>

      {/* Description (only when running or with description) */}
      {node.description && (isRunning || node.status === 'proposed') && (
        <span style={{
          marginLeft: 8,
          fontSize: dense ? '0.68rem' : '0.72rem',
          color: tok.fgMuted,
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap',
          maxWidth: dense ? 160 : 240,
        }}>
          {node.description}
        </span>
      )}
    </div>
  );
}

// ─── Tree node with children (recursive) ─────────────────────────────────────

function TreeNode({ node, depth, tok, expandedIds, onToggle, onNodeClick, density, newNodeIds }) {
  const hasChildren = node.children && node.children.length > 0;
  const expanded = !!expandedIds[node.id];
  const isNew = !!newNodeIds[node.id];

  return (
    <div role="group" style={{ position: 'relative' }}>
      {/* Horizontal connector line */}
      {depth > 0 && (
        <div style={{
          position: 'absolute',
          top: '50%',
          left: (depth - 1) * (density === 'compact' ? 20 : 28) + 15,
          width: density === 'compact' ? 20 : 28,
          height: 1,
          background: tok.connector,
          opacity: 0.5,
        }} />
      )}

      <TreeNodeRow
        node={node}
        depth={depth}
        tok={tok}
        expanded={expanded}
        onToggle={onToggle}
        onNodeClick={onNodeClick}
        density={density}
        isNew={isNew}
      />

      {/* Children: animate height open/close */}
      {hasChildren && (
        <div style={{
          overflow: 'hidden',
          maxHeight: expanded ? '9999px' : '0',
          opacity: expanded ? 1 : 0,
          transition: 'max-height 0.22s ease, opacity 0.15s ease',
        }}>
          {node.children.map(child => (
            <TreeNode
              key={child.id}
              node={child}
              depth={depth + 1}
              tok={tok}
              expandedIds={expandedIds}
              onToggle={onToggle}
              onNodeClick={onNodeClick}
              density={density}
              newNodeIds={newNodeIds}
            />
          ))}
        </div>
      )}
    </div>
  );
}

// ─── PlanVisualizer (controlled) ─────────────────────────────────────────────

function PlanVisualizer({
  plan = null,
  theme = 'light',
  defaultExpanded = false,
  onNodeClick,
  density = 'compact',
} = {}) {
  injectPVStyles();

  const [resolvedTheme, setResolvedTheme] = useState(resolveTheme(theme));
  useEffect(() => {
    if (theme !== 'auto') { setResolvedTheme(theme); return; }
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = e => setResolvedTheme(e.matches ? 'dark' : 'light');
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, [theme]);

  const tok = TOKENS[resolvedTheme] || TOKENS.light;

  // Expanded IDs tracking
  const [expandedIds, setExpandedIds] = useState(() => {
    const ids = {};
    if (!plan) return ids;
    if (defaultExpanded === true) return ids; // all collapsed initially
    if (typeof defaultExpanded === 'number') {
      // BFS: expand up to `defaultExpanded` depth
      const queue = [{ node: plan, d: 0 }];
      while (queue.length) {
        const { node, d } = queue.shift();
        if (d < defaultExpanded && node.children?.length) {
          ids[node.id] = true;
          node.children.forEach(c => queue.push({ node: c, d: d + 1 }));
        }
      }
    }
    return ids;
  });

  // Track newly added nodes for slide-in animation
  const [newNodeIds, setNewNodeIds] = useState({});

  // Seed expandedIds when plan changes (new plan = reset)
  useEffect(() => {
    setExpandedIds(prev => {
      if (Object.keys(prev).length === 0 && defaultExpanded === true) {
        const ids = {};
        const expandAll = n => {
          if (n.children?.length) { ids[n.id] = true; n.children.forEach(expandAll); }
        };
        expandAll(plan);
        return ids;
      }
      return prev;
    });
  }, [plan]);

  const handleToggle = useCallback((nodeId) => {
    setExpandedIds(prev => ({ ...prev, [nodeId]: !prev[nodeId] }));
  }, []);

  // Keyboard navigation
  const handleKeyDown = useCallback((e) => {
    const focused = document.activeElement;
    if (!focused || focused.getAttribute('role') !== 'treeitem') return;

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

    function findNode(n, id) {
      if (n.id === id) return n;
      for (const c of (n.children || [])) {
        const f = findNode(c, id);
        if (f) return f;
      }
      return null;
    }

    if (!plan) return;
    const node = findNode(plan, nodeId);
    if (!node) return;

    if (e.key === 'ArrowRight') {
      // Expand or go to first child
      if (node.children?.length) {
        if (!expandedIds[nodeId]) {
          setExpandedIds(prev => ({ ...prev, [nodeId]: true }));
        } else if (node.children.length) {
          const el = document.querySelector(`[data-node-id="${node.children[0].id}"]`);
          el?.focus();
        }
      }
      e.preventDefault();
    } else if (e.key === 'ArrowLeft') {
      // Collapse or go to parent
      if (node.children?.length && expandedIds[nodeId]) {
        setExpandedIds(prev => ({ ...prev, [nodeId]: false }));
      } else {
        // Find parent in tree — use depth attribute
        const parent = focused.closest('[role="group"]')?.previousElementSibling;
        if (parent) parent.focus();
      }
      e.preventDefault();
    } else if (e.key === 'ArrowDown') {
      // Go to next sibling
      e.preventDefault();
    } else if (e.key === 'Enter' || e.key === ' ') {
      if (node.children?.length) {
        setExpandedIds(prev => ({ ...prev, [nodeId]: !prev[nodeId] }));
      }
      e.preventDefault();
    }
  }, [plan, expandedIds]);

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

  if (!plan) {
    return (
      <div style={{
        background: tok.card,
        border: `1px solid ${tok.border}`,
        borderRadius: 12,
        padding: '1.5rem',
        boxShadow: tok.shadow,
        fontFamily: "'Space Grotesk', sans-serif",
        color: tok.fgMuted,
        fontSize: '0.85rem',
        textAlign: 'center',
      }}>
        No plan yet — waiting for agent to propose steps.
      </div>
    );
  }

  const dense = density === 'compact';

  return (
    <div
      role="tree"
      style={{
        background: tok.card,
        border: `1px solid ${tok.border}`,
        borderRadius: 12,
        boxShadow: tok.shadow,
        fontFamily: "'Space Grotesk', sans-serif",
        overflow: 'hidden',
        animation: 'pv-fade 0.2s ease',
      }}
    >
      {/* Plan header */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        padding: dense ? '10px 14px' : '12px 16px',
        borderBottom: `1px solid ${tok.border}`,
        background: tok.bg,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
            <rect x="1" y="1" width="14" height="3" rx="1.5" fill={tok.accent} fillOpacity="0.6" />
            <rect x="1" y="6.5" width="9" height="3" rx="1.5" fill={tok.accent} fillOpacity="0.4" />
            <rect x="1" y="12" width="12" height="3" rx="1.5" fill={tok.accent} fillOpacity="0.25" />
          </svg>
          <span style={{ fontWeight: 700, fontSize: dense ? '0.78rem' : '0.85rem', color: tok.fg }}>
            Agent Plan
          </span>
        </div>
        <span style={{ fontSize: '0.65rem', color: tok.fgMuted, fontFamily: "'Fira Code', monospace" }}>
          {countNodes(plan)} steps
        </span>
      </div>

      {/* Tree body */}
      <div style={{ paddingTop: 4, paddingBottom: 4 }}>
        <TreeNode
          node={plan}
          depth={0}
          tok={tok}
          expandedIds={expandedIds}
          onToggle={handleToggle}
          onNodeClick={onNodeClick}
          density={density}
          newNodeIds={newNodeIds}
        />
      </div>
    </div>
  );
}

// Count total nodes in tree (for display)
function countNodes(node) {
  if (!node.children?.length) return 1;
  return 1 + node.children.reduce((acc, c) => acc + countNodes(c), 0);
}

// ─── PlanVisualizerContainer (AG-UI auto-wiring) ──────────────────────────────
/**
 * Subscribes to AG-UI plan events and maintains the plan tree.
 * Handles streaming insertions (PLAN_NODE_ADDED) with slide-in animation.
 *
 * Props:
 *   eventSource: EventTarget | EventEmitter
 *   initialPlan?: PlanNode
 *   theme, defaultExpanded, density, onNodeClick — passed through
 */
function PlanVisualizerContainer({
  eventSource,
  initialPlan = null,
  theme = 'light',
  defaultExpanded = false,
  density = 'compact',
  onNodeClick,
} = {}) {
  const [plan, setPlan] = useState(initialPlan);
  const [newIds, setNewIds] = useState({});

  // Mark node as "new" for slide-in animation, then clear after animation
  const markNew = useCallback((nodeId) => {
    setNewIds(prev => ({ ...prev, [nodeId]: true }));
    setTimeout(() => {
      setNewIds(prev => {
        const next = { ...prev };
        delete next[nodeId];
        return next;
      });
    }, 600);
  }, []);

  // Insert a node into the tree (by parentId)
  const insertNode = useCallback((parentId, newNode) => {
    function insert(children) {
      if (!children) return children;
      return children.map(child => {
        if (child.id === parentId) {
          return {
            ...child,
            children: [...(child.children || []), newNode],
          };
        }
        if (child.children) {
          return { ...child, children: insert(child.children) };
        }
        return child;
      });
    }
    setPlan(prev => prev ? { ...prev, children: insert(prev.children) } : prev);
    markNew(newNode.id);
  }, [markNew]);

  // Update a node's fields
  const updateNode = useCallback((nodeId, patch) => {
    function update(node) {
      if (node.id === nodeId) return { ...node, ...patch };
      if (node.children) return { ...node, children: node.children.map(update) };
      return node;
    }
    setPlan(prev => prev ? update(prev) : prev);
  }, []);

  useEffect(() => {
    if (!eventSource) return;

    function onProposed(e) {
      const d = e.detail || e;
      setPlan(d.plan || null);
      setNewIds({});
    }
    function onNodeAdded(e) {
      const d = e.detail || e;
      if (d.parentId) {
        insertNode(d.parentId, d.node);
      } else if (d.node && plan) {
        // Root-level addition
        setPlan(prev => prev ? {
          ...prev,
          children: [...(prev.children || []), d.node],
        } : prev);
        markNew(d.node.id);
      }
    }
    function onNodeUpdated(e) {
      const d = e.detail || e;
      if (d.nodeId && d.patch) updateNode(d.nodeId, d.patch);
    }
    function onCompleted(e) {
      const d = e.detail || e;
      if (d.nodeId) updateNode(d.nodeId, { status: 'completed' });
    }
    function onFailed(e) {
      const d = e.detail || e;
      if (d.nodeId) updateNode(d.nodeId, { status: 'failed' });
    }

    if (typeof eventSource.addEventListener === 'function') {
      eventSource.addEventListener('PLAN_PROPOSED',        onProposed);
      eventSource.addEventListener('PLAN_NODE_ADDED',     onNodeAdded);
      eventSource.addEventListener('PLAN_NODE_UPDATED',   onNodeUpdated);
      eventSource.addEventListener('PLAN_NODE_COMPLETED',  onCompleted);
      eventSource.addEventListener('PLAN_NODE_FAILED',     onFailed);
      return () => {
        eventSource.removeEventListener('PLAN_PROPOSED',        onProposed);
        eventSource.removeEventListener('PLAN_NODE_ADDED',     onNodeAdded);
        eventSource.removeEventListener('PLAN_NODE_UPDATED',   onNodeUpdated);
        eventSource.removeEventListener('PLAN_NODE_COMPLETED',  onCompleted);
        eventSource.removeEventListener('PLAN_NODE_FAILED',     onFailed);
      };
    }

    if (typeof eventSource.on === 'function') {
      eventSource.on('PLAN_PROPOSED',        onProposed);
      eventSource.on('PLAN_NODE_ADDED',     onNodeAdded);
      eventSource.on('PLAN_NODE_UPDATED',   onNodeUpdated);
      eventSource.on('PLAN_NODE_COMPLETED',  onCompleted);
      eventSource.on('PLAN_NODE_FAILED',     onFailed);
      return () => {
        eventSource.off('PLAN_PROPOSED',        onProposed);
        eventSource.off('PLAN_NODE_ADDED',     onNodeAdded);
        eventSource.off('PLAN_NODE_UPDATED',   onNodeUpdated);
        eventSource.off('PLAN_NODE_COMPLETED',  onCompleted);
        eventSource.off('PLAN_NODE_FAILED',     onFailed);
      };
    }
  }, [eventSource, insertNode, updateNode, plan, markNew]);

  return (
    <PlanVisualizer
      plan={plan}
      theme={theme}
      defaultExpanded={defaultExpanded}
      density={density}
      onNodeClick={onNodeClick}
    />
  );
}

// ─── Exports ───────────────────────────────────────────────────────────────────
if (typeof module !== 'undefined') {
  module.exports = { PlanVisualizer, PlanVisualizerContainer };
}