// Fabric — Graph tab. Split out of fabric.jsx. Loads after fabric.jsx & graph_layout.jsx.
const { useState: useFg, useEffect: useFgE, useRef: useFgR, useMemo: useFgM } = React;

// --------- GRAPH TAB — snapshot-driven, two-view hierarchical layout ---------

const GRAPH_COLORS = {
  table:   '#0F766E',
  doc:     '#2563EB',
  concept: '#5B4FFF',
  bridge:  '#7C3AED',
};

// Used by CatalogTab for the domain chip color. Keys match the return values
// of `deriveNodeDomain`.
const DOMAIN_COLORS = {
  db:     GRAPH_COLORS.table,
  doc:    GRAPH_COLORS.doc,
  bridge: GRAPH_COLORS.bridge,
  orphan: '#6B7280',
};

const EDGE_COLORS = {
  structure:  '#64748B',
  mapping:    '#7C3AED',
  dependency: '#F59E0B',
  relation:   '#10B981',
  category:   '#EC4899',
};

function GraphTab({ snapshot, loading, error, reload, openEntity, reveal, onRevealConsumed }) {
  const [view, setView] = useFg('db');
  const [edgeFilt, setEdgeFilt] = useFg({ structure: true, mapping: true, dependency: true, relation: true, category: true });
  const [confMin, setConfMin] = useFg(0);
  const [selected, setSelected] = useFg(null);
  const [layoutVersion, setLayoutVersion] = useFg(0);
  const pendingFocusRef = useFgR(null);
  const [focusTarget, setFocusTarget] = useFg(null);

  // Learned-concept reveal sequence state.
  const [revealPhase, setRevealPhase] = useFg(null);   // null|'intro'|'reloading'|'morph'|'highlight'
  const [pulseId, setPulseId] = useFg(null);
  const [morphNonce, setMorphNonce] = useFg(0);
  const revealTokenRef = useFgR(null);
  const revealTimersRef = useFgR([]);
  const revealNameRef = useFgR(null);
  const revealTriedOtherViewRef = useFgR(false);

  // Only the active view's layout is computed — FR is O(N^2), no need to pay
  // for the inactive view. Determinism is preserved by `seedSalt` (same input
  // → same layout), so switching back and forth is visually stable.
  const layout = useFgM(
    () => snapshot ? buildViewLayout(snapshot, view, { seedSalt: String(layoutVersion) }) : null,
    [snapshot, view, layoutVersion]
  );

  const visibleEdges = useFgM(() => {
    if (!layout) return [];
    return layout.edges.filter(e =>
      edgeFilt[e.type] !== false &&
      (e.confidence == null || e.confidence >= confMin)
    );
  }, [layout, edgeFilt, confMin]);

  // Ordinary tab switch clears selection + focus
  useFgE(() => {
    setSelected(null);
    setFocusTarget(null);
  }, [view]);

  // After a view-switch (including bridge jump), apply any pending focus.
  // Guard with layout.view === view so a snapshot refresh mid-transition
  // doesn't consume the ref against a stale layout.
  useFgE(() => {
    if (!pendingFocusRef.current) return;
    if (!layout || layout.view !== view) return;
    const id = pendingFocusRef.current;
    pendingFocusRef.current = null;
    const n = layout.nodeIndex.get(id);
    if (n) {
      setSelected(n);
      setFocusTarget(n);
    }
  }, [view, layout]);

  // Learned-concept reveal sequence: intro card → reloading overlay → graph
  // morph (re-layout + entrance animation) → pan-to + pulse highlight. Runs
  // once per reveal request (keyed on token).
  useFgE(() => {
    if (!reveal || !reveal.ids || !reveal.ids.length) return;
    if (revealTokenRef.current === reveal.token) return;
    revealTokenRef.current = reveal.token;
    const targetId = reveal.ids[0];
    revealNameRef.current = reveal.name || targetId;

    revealTimersRef.current.forEach(clearTimeout);
    revealTimersRef.current = [];
    const at = (ms, fn) => revealTimersRef.current.push(setTimeout(fn, ms));

    revealTriedOtherViewRef.current = false;   // view targeting happens at highlight
    setSelected(null);
    setPulseId(null);
    setRevealPhase('intro');
    at(1500, () => setRevealPhase('reloading'));
    at(2100, () => { setRevealPhase('morph'); setLayoutVersion(v => v + 1); setMorphNonce(n => n + 1); });
    at(2900, () => { setRevealPhase('highlight'); setPulseId(targetId); });
    at(5200, () => {
      setRevealPhase(null);
      // Keep pulseId: the learned node stays specially animated until the next
      // reveal (which clears it at sequence start) replaces it.
      if (typeof onRevealConsumed === 'function') onRevealConsumed();
    });
  }, [reveal && reveal.token]);

  // Pan to the learned concept once the highlight phase begins (does NOT select
  // it — no detail panel / dimming; the node just gets the persistent pulse).
  // Waits for layout (handles snapshot still loading). If the concept isn't in
  // the active view, switch to the other view ONCE and let this effect re-run
  // against the recomputed layout; if it's in neither view, it degrades
  // gracefully (no pan/pulse, overlay just fades).
  useFgE(() => {
    if (revealPhase !== 'highlight' || !pulseId || !layout) return;
    const n = layout.nodeIndex.get(pulseId);
    if (n) { setFocusTarget(n); return; }
    if (!revealTriedOtherViewRef.current) {
      revealTriedOtherViewRef.current = true;
      setView(v => (v === 'db' ? 'docs' : 'db'));
    }
  }, [revealPhase, pulseId, layout, view]);

  // Clear pending reveal timers on unmount.
  useFgE(() => () => { revealTimersRef.current.forEach(clearTimeout); }, []);

  const handleTabClick = (v) => {
    if (v === view) return;
    pendingFocusRef.current = null;
    setView(v);
  };

  const handleBridgeJump = (conceptId) => {
    pendingFocusRef.current = conceptId;
    setView(view === 'db' ? 'docs' : 'db');
  };

  if (loading && !snapshot) return <GraphLoading/>;
  if (error)                return <GraphError error={error} reload={reload}/>;
  if (!snapshot || !layout) return null;

  const totalNodes = (snapshot.factual_nodes || []).length + (snapshot.concept_nodes || []).length;

  return (
    <div style={{ display: 'flex', height: '100%', minHeight: 0 }}>
      <GraphFilters
        edgeFilt={edgeFilt} setEdgeFilt={setEdgeFilt}
        confMin={confMin} setConfMin={setConfMin}
      />

      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
        <div style={{ padding: '14px 24px', borderBottom: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          <div style={{ fontSize: 13, fontWeight: 500 }}>Knowledge graph</div>
          <span className="mono-sm mono text-tertiary">
            {totalNodes.toLocaleString()} nodes · {visibleEdges.length.toLocaleString()} edges
          </span>
          <div style={{ flex: 1 }}/>
          <div className="segmented">
            <button className={view === 'db'   ? 'active' : ''} onClick={() => handleTabClick('db')}>Database</button>
            <button className={view === 'docs' ? 'active' : ''} onClick={() => handleTabClick('docs')}>Docs</button>
          </div>
          <button className="btn sm" onClick={() => setLayoutVersion(v => v + 1)} title="Re-run layout">
            <Icons.Refresh size={12}/> Re-layout
          </button>
          <button className="btn sm ghost" onClick={reload} title="Refetch snapshot">Refresh</button>
        </div>

        <div style={{ flex: 1, minHeight: 0, position: 'relative', display: 'flex' }}>
          <GraphCanvas
            layout={layout}
            visibleEdges={visibleEdges}
            selected={selected}
            onSelect={setSelected}
            onBridgeJump={handleBridgeJump}
            view={view}
            focusTarget={focusTarget}
            onFocused={() => setFocusTarget(null)}
            pulseId={pulseId}
            morphNonce={morphNonce}
          />
          {revealPhase && <GraphRevealOverlay phase={revealPhase} name={revealNameRef.current}/>}
        </div>
      </div>

      {selected && (
        <aside style={{ width: 320, borderLeft: '1px solid var(--border-subtle)', flexShrink: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
          <NodeDetail
            node={selected}
            nodeIndex={layout.fullIndex}
            viewNodeIndex={layout.nodeIndex}
            outEdges={layout.outByNode.get(selected.id) || []}
            inEdges={layout.inByNode.get(selected.id) || []}
            bridges={layout.bridges}
            view={view}
            onClose={() => setSelected(null)}
            onSelect={setSelected}
            onBridgeJump={handleBridgeJump}
            openEntity={openEntity}
          />
        </aside>
      )}
    </div>
  );
}

function GraphFilters({ edgeFilt, setEdgeFilt, confMin, setConfMin }) {
  return (
    <aside style={{ width: 220, borderRight: '1px solid var(--border-subtle)', padding: 18, overflowY: 'auto', flexShrink: 0 }}>
      <div className="t-micro text-tertiary" style={{ marginBottom: 8 }}>Edge types</div>
      {[
        ['structure',  'Structure'],
        ['mapping',    'Mapping'],
        ['dependency', 'Dependency'],
        ['relation',   'Relation'],
        ['category',   'Category'],
      ].map(([k, label]) => (
        <FilterCheckbox key={k} checked={!!edgeFilt[k]} label={label}
          swatch={EDGE_COLORS[k]}
          onChange={v => setEdgeFilt({ ...edgeFilt, [k]: v })}/>
      ))}

      <div className="t-micro text-tertiary" style={{ marginTop: 20, marginBottom: 8 }}>
        Confidence ≥ {(confMin * 100).toFixed(0)}%
      </div>
      <input type="range" min="0" max="100" value={Math.round(confMin * 100)}
        onChange={e => setConfMin(Number(e.target.value) / 100)}
        style={{ width: '100%', accentColor: 'var(--accent)' }}/>

      <div className="t-micro text-tertiary" style={{ marginTop: 22, marginBottom: 8 }}>Legend</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 11, color: 'var(--text-secondary)' }}>
        <LegendBlob kind="table"   label="Table"/>
        <LegendBlob kind="column"  label="Column"/>
        <LegendBlob kind="doc"     label="Document"/>
        <LegendBlob kind="chunk"   label="Chunk"/>
        <LegendBlob kind="concept" label="Concept"/>
        <LegendBlob kind="chip"    label="Category value"/>
        <LegendBlob kind="bridge"  label="Bridge (→ other view)"/>
      </div>

      <div className="mono-sm mono text-tertiary" style={{ marginTop: 22, fontSize: 10, lineHeight: '14px' }}>
        {graphApiMockEnabled()
          ? 'mock · edit config.js to go live'
          : `GET ${(window.PARCLE_API_BASE || '/api')}/skill/graph`}
      </div>
    </aside>
  );
}

function LegendBlob({ kind, label }) {
  let swatch = null;
  if (kind === 'table') {
    swatch = (
      <svg width="24" height="14" viewBox="0 0 24 14">
        <rect x="1" y="1" width="22" height="12" rx="3" fill="var(--surface)" stroke={GRAPH_COLORS.table} strokeWidth="1.2"/>
        <path d="M 1 4 Q 1 1 4 1 L 20 1 Q 23 1 23 4 L 23 5 L 1 5 Z" fill={GRAPH_COLORS.table}/>
      </svg>
    );
  } else if (kind === 'column') {
    swatch = <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--surface)', border: `1.4px solid ${GRAPH_COLORS.table}` }}/>;
  } else if (kind === 'doc') {
    swatch = (
      <svg width="24" height="14" viewBox="0 0 24 14">
        <path d="M 1 4 Q 1 1 4 1 L 18 1 L 23 5 L 23 11 Q 23 13 20 13 L 4 13 Q 1 13 1 11 Z" fill="var(--surface)" stroke={GRAPH_COLORS.doc} strokeWidth="1.2"/>
        <path d="M 18 1 L 18 5 L 23 5" fill="none" stroke={GRAPH_COLORS.doc} strokeWidth="1"/>
      </svg>
    );
  } else if (kind === 'chunk') {
    swatch = <span style={{ width: 10, height: 10, borderRadius: 2, background: 'var(--surface)', border: `1.4px solid ${GRAPH_COLORS.doc}` }}/>;
  } else if (kind === 'concept') {
    swatch = <span style={{ width: 14, height: 14, borderRadius: '50%', background: 'var(--surface)', border: '1.5px solid var(--accent)' }}/>;
  } else if (kind === 'chip') {
    swatch = (
      <span style={{ padding: '1px 6px', borderRadius: 8,
        background: 'var(--accent-muted)', color: 'var(--accent-text)',
        fontSize: 9, border: '0.8px solid var(--accent-text)', lineHeight: '12px' }}>val</span>
    );
  } else if (kind === 'bridge') {
    swatch = (
      <svg width="28" height="8" viewBox="0 0 28 8">
        <line x1="1" y1="4" x2="24" y2="4" stroke={GRAPH_COLORS.bridge} strokeWidth="1.4" strokeDasharray="4 2" strokeLinecap="round"/>
        <circle cx="26" cy="4" r="2" fill={GRAPH_COLORS.bridge}/>
      </svg>
    );
  }
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <span style={{ width: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>{swatch}</span>
      <span>{label}</span>
    </div>
  );
}

function FilterCheckbox({ checked, label, swatch, onChange }) {
  return (
    <label style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 2px', fontSize: 12, cursor: 'pointer' }}
      onClick={e => { e.preventDefault(); onChange(!checked); }}>
      <span style={{
        width: 12, height: 12, borderRadius: 3,
        border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-strong)'}`,
        background: checked ? 'var(--accent)' : 'transparent',
        display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
      }}>
        {checked && <Icons.Check size={8} style={{ color: 'var(--on-accent)' }}/>}
      </span>
      {swatch && <span style={{ width: 8, height: 8, borderRadius: 2, background: swatch, flexShrink: 0 }}/>}
      <span style={{ flex: 1 }}>{label}</span>
    </label>
  );
}

// Overlay shown over the graph canvas during the learned-concept reveal: a
// pop-in card naming the concept ("intro"), then a translucent "rebuilding"
// backdrop with a spinner. The morph + highlight phases render nothing here —
// the canvas itself animates.
function GraphRevealOverlay({ phase, name }) {
  if (phase !== 'intro' && phase !== 'reloading') return null;
  const reloading = phase === 'reloading';
  return (
    <div style={{ position: 'absolute', inset: 0, zIndex: 6, pointerEvents: 'none',
      display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      {reloading && <div style={{ position: 'absolute', inset: 0, background: 'var(--bg)', opacity: 0.5 }}/>}
      <div style={{
        position: 'relative', display: 'flex', alignItems: 'center', gap: 12,
        padding: '14px 20px', borderRadius: 14,
        background: 'var(--surface-raised)', border: '1px solid var(--border)',
        boxShadow: '0 12px 34px rgba(0,0,0,0.2)',
        animation: 'parcle-reveal-pop 240ms cubic-bezier(.2,.8,.2,1)',
      }}>
        <span style={{
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          width: 34, height: 34, borderRadius: 10, flexShrink: 0,
          background: 'var(--accent-muted)', color: 'var(--accent-text)',
        }}>
          {reloading
            ? <svg viewBox="0 0 24 24" width="18" height="18" style={{ animation: 'parcle-graph-spin 800ms linear infinite' }}>
                <circle cx="12" cy="12" r="9" fill="none" stroke="var(--border)" strokeWidth="3"/>
                <path d="M12 3a9 9 0 019 9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
              </svg>
            : <Icons.Sparkle size={18}/>}
        </span>
        <div style={{ minWidth: 0 }}>
          <div className="t-micro text-tertiary">{reloading ? 'Rebuilding knowledge graph' : 'Just learned'}</div>
          <div style={{ fontSize: 14, fontWeight: 600, maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
        </div>
      </div>
    </div>
  );
}

function NodeDetail({ node, nodeIndex, viewNodeIndex, outEdges, inEdges, bridges, view, onClose, onSelect, onBridgeJump, openEntity }) {
  const isBridge = node.category === 'concept' && bridges && bridges.has(node.id);
  const isConcept = node.category === 'concept';
  const isTable   = node.category === 'factual' && node.type === 'table';
  const canEdit   = isConcept || isTable;

  const [editing, setEditing] = useFg(false);
  const [pending, setPending] = useFg(false);
  const [errMsg,  setErrMsg]  = useFg(null);

  const runMutation = async (fn, payload) => {
    if (pending) throw new Error('A mutation is already in flight');
    setPending(true);
    setErrMsg(null);
    try {
      const resp = await fn(payload);
      applyMutationResponse(resp);
      return resp;
    } catch (e) {
      setErrMsg(e && e.message ? e.message : String(e));
      throw e;
    } finally {
      setPending(false);
    }
  };

  const onUndoNode = () => {
    runMutation(undoGraph, { target_type: 'node', target_id: node.id })
      .then(() => onClose())  // node disappears from layout — close the panel
      .catch(() => {});
  };

  // Active synonyms paired with the dedup_event index that added them, so
  // the × button can call undoGraph(target_type:"dedup", index) per contract.
  // Tolerates legacy string-only synonyms (mock graph) by skipping the ×.
  // Walk dedup_events from the end so re-added synonyms (undo → re-add)
  // resolve to the most recent active event, not a stale earlier one.
  const findActiveDedupIdx = (events, synName) => {
    for (let i = events.length - 1; i >= 0; i--) {
      const d = events[i];
      if (d.state !== 'undo' && (d.added_synonyms || []).includes(synName)) return i;
    }
    return -1;
  };
  const synRows = isConcept
    ? (node.synonyms || []).map(s => {
        if (typeof s === 'string') return { name: s, dedupIdx: -1, undone: false };
        if (s.state === 'undo') return null;
        const dedupIdx = findActiveDedupIdx(node.dedup_events || [], s.name);
        return { name: s.name, dedupIdx, undone: false };
      }).filter(Boolean)
    : [];

  const onRemoveSyn = (synName, dedupIdx) => {
    if (dedupIdx < 0) return;
    runMutation(undoGraph, { target_type: 'dedup', target_id: node.id, index: dedupIdx })
      .catch(() => {});
  };

  const ConceptEditor = window.ConceptEditor;
  const TableEditor   = window.TableEditor;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
      <div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
        <span className="chip" style={{ fontSize: 10 }}>
          {node.category}{node.type ? ` · ${node.type}` : ''}
        </span>
        {isBridge && <span className="chip" style={{
          background: GRAPH_COLORS.bridge + '22',
          color: GRAPH_COLORS.bridge, border: 0, fontSize: 10,
        }}>bridge</span>}
        {node.isValue && <span className="chip accent" style={{ fontSize: 10 }}>value</span>}
        <div style={{ flex: 1 }}/>
        <button className="btn sm ghost" onClick={onClose}><Icons.X size={12}/></button>
      </div>
      <div style={{ flex: 1, overflowY: 'auto', padding: 18 }}>
        <div className="t-h3" style={{ margin: 0, wordBreak: 'break-word' }}>{node.name}</div>
        <div className="mono-sm mono text-tertiary" style={{ marginTop: 4, wordBreak: 'break-all', fontSize: 10 }}>{node.id}</div>

        {/* Edit / Undo toolbar — Undo is offered on every node type (the
            backend accepts target_type:"node" for any node id). Edit is
            gated to concept / table since those are the only edit surfaces
            the contract exposes. */}
        <div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {canEdit && (
            <button className="btn sm ghost" onClick={() => setEditing(v => !v)} disabled={pending}>
              {editing ? 'Close editor' : 'Edit…'}
            </button>
          )}
          <button className="btn sm ghost" onClick={onUndoNode} disabled={pending}
            style={{ color: 'var(--error)' }}
            title="Soft-undo this node and its incident edges">
            <Icons.Refresh size={11}/> {pending ? 'Undoing…' : 'Undo node'}
          </button>
          {errMsg && (
            <span className="t-small" style={{ color: 'var(--error)' }} title={errMsg}>
              · {errMsg.length > 60 ? errMsg.slice(0, 57) + '…' : errMsg}
            </span>
          )}
        </div>

        {editing && isConcept && ConceptEditor && (
          <div style={{ marginTop: 10 }}>
            <ConceptEditor concept={node} pending={pending} runMutation={runMutation}
              onClose={() => setEditing(false)}/>
          </div>
        )}
        {editing && isTable && TableEditor && (
          <div style={{ marginTop: 10 }}>
            <TableEditor table={node} pending={pending} runMutation={runMutation}
              onClose={() => setEditing(false)}/>
          </div>
        )}

        {isBridge && (
          <button className="btn sm" onClick={() => onBridgeJump(node.id)}
            style={{ width: '100%', marginTop: 14,
              background: GRAPH_COLORS.bridge + '18',
              color: GRAPH_COLORS.bridge,
              border: `1px solid ${GRAPH_COLORS.bridge}`,
              fontWeight: 500 }}>
            {view === 'db' ? 'Jump to Docs view →' : '← Jump to Database view'}
          </button>
        )}

        {isConcept && synRows.length > 0 && (
          <div style={{ marginTop: 12 }}>
            <div className="t-micro text-tertiary" style={{ marginBottom: 4 }}>Synonyms</div>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
              {synRows.map(s => (
                <span key={s.name} className="chip" style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                  {s.name}
                  {s.dedupIdx >= 0 && (
                    <button onClick={() => onRemoveSyn(s.name, s.dedupIdx)} disabled={pending}
                      title="Undo the dedup event that added this synonym"
                      style={{
                        background: 'transparent', border: 'none', cursor: 'pointer',
                        color: 'var(--text-tertiary)', padding: 0, fontSize: 12, lineHeight: 1,
                      }}>×</button>
                  )}
                </span>
              ))}
            </div>
          </div>
        )}

        {node.properties && node.properties.description && (
          <div style={{ marginTop: 12, fontSize: 13, color: 'var(--text-secondary)', lineHeight: '20px' }}>
            {node.properties.description}
          </div>
        )}

        <PropsList properties={node.properties}/>

        <div style={{ marginTop: 16 }}>
          <div className="t-micro text-tertiary" style={{ marginBottom: 6 }}>
            Outgoing · {outEdges.length}
          </div>
          {outEdges.length === 0 ? (
            <span className="text-tertiary t-small">No outgoing edges</span>
          ) : outEdges.map((e, i) => (
            <EdgeRow key={'o' + i} edge={e} otherId={e.target} nodeIndex={nodeIndex} viewNodeIndex={viewNodeIndex} dir="out" onSelect={onSelect}/>
          ))}
        </div>

        <div style={{ marginTop: 16 }}>
          <div className="t-micro text-tertiary" style={{ marginBottom: 6 }}>
            Incoming · {inEdges.length}
          </div>
          {inEdges.length === 0 ? (
            <span className="text-tertiary t-small">No incoming edges</span>
          ) : inEdges.map((e, i) => (
            <EdgeRow key={'i' + i} edge={e} otherId={e.source} nodeIndex={nodeIndex} viewNodeIndex={viewNodeIndex} dir="in" onSelect={onSelect}/>
          ))}
        </div>

        <button className="btn sm" style={{ width: '100%', marginTop: 20 }}
          onClick={() => openEntity && openEntity({ id: node.id, type: node.type || node.category, label: node.name, sub: node.category })}>
          Open full entity <Icons.ArrowR size={11}/>
        </button>
      </div>
    </div>
  );
}

function PropsList({ properties }) {
  const entries = Object.entries(properties || {}).filter(([k, v]) =>
    k !== 'description' && k !== 'columns' &&
    (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
  );
  if (entries.length === 0) return null;
  return (
    <div style={{ marginTop: 12 }}>
      <div className="t-micro text-tertiary" style={{ marginBottom: 4 }}>Properties</div>
      <div style={{ border: '1px solid var(--border-subtle)', borderRadius: 6, overflow: 'hidden' }}>
        {entries.map(([k, v], i) => (
          <div key={k} style={{
            display: 'flex', gap: 8, padding: '6px 10px', fontSize: 12,
            borderTop: i > 0 ? '1px solid var(--border-subtle)' : 0,
          }}>
            <span className="mono-sm mono text-tertiary" style={{ minWidth: 100, flexShrink: 0 }}>{k}</span>
            <span className="mono" style={{ fontSize: 12, wordBreak: 'break-all' }}>{String(v)}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function EdgeRow({ edge, otherId, nodeIndex, viewNodeIndex, dir, onSelect }) {
  const other = nodeIndex && nodeIndex.get(otherId);
  // Only allow selecting targets that exist in the current view. Cross-view
  // edges render as dimmed rows with a hint — switching views is done via
  // the bridge-jump button on the concept instead.
  const inView = !viewNodeIndex || viewNodeIndex.has(otherId);
  const canClick = !!other && inView;

  const [editing, setEditing] = useFg(false);
  const [pending, setPending] = useFg(false);
  const [errMsg,  setErrMsg]  = useFg(null);

  // Confidence is editable on every non-structure edge (per contract).
  const canEditConfidence = edge.type !== 'structure';
  const selector = edgeSelectorFromDTO(edge);

  const runMutation = async (fn, payload) => {
    if (pending) throw new Error('A mutation is already in flight');
    setPending(true);
    setErrMsg(null);
    try {
      const resp = await fn(payload);
      applyMutationResponse(resp);
      return resp;
    } catch (e) {
      setErrMsg(e && e.message ? e.message : String(e));
      throw e;
    } finally {
      setPending(false);
    }
  };

  const onUndoEdge = (e) => {
    e.stopPropagation();
    runMutation(undoGraph, { target_type: 'edge', edge: selector }).catch(() => {});
  };

  const onToggleEdit = (e) => {
    e.stopPropagation();
    setEditing(v => !v);
  };

  const EdgeConfidenceEditor = window.EdgeConfidenceEditor;

  return (
    <div style={{ borderBottom: '1px solid transparent' }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, padding: '6px 0' }}>
        <button onClick={() => canClick && onSelect(other)} disabled={!canClick}
          title={!inView ? 'Not shown in the current view' : ''}
          style={{
            flex: 1, minWidth: 0, display: 'flex', alignItems: 'flex-start', gap: 8,
            textAlign: 'left', background: 'transparent',
            cursor: canClick ? 'pointer' : 'default',
            opacity: inView ? 1 : 0.55,
            padding: 0,
          }}>
          <span style={{ color: 'var(--text-tertiary)', fontSize: 12, minWidth: 18, marginTop: 1 }}>
            {dir === 'out' ? '→' : '←'}
          </span>
          <span style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 12, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {other ? other.name : otherId}
              {!inView && <span className="mono-sm mono text-tertiary" style={{ marginLeft: 6, fontSize: 9 }}>· other view</span>}
            </div>
            <div className="mono-sm mono text-tertiary" style={{ fontSize: 10, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              <span style={{ color: EDGE_COLORS[edge.type] || 'var(--text-tertiary)' }}>{edge.type}</span>
              {edge.relation && <span>· {edge.relation}</span>}
              {edge.value    && <span>· {edge.value}</span>}
              {edge.confidence != null && <span>· {edge.confidence.toFixed(2)}</span>}
            </div>
          </span>
        </button>
        <div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
          {canEditConfidence && (
            <button className="btn sm ghost" onClick={onToggleEdit} disabled={pending}
              title="Edit edge confidence" style={{ padding: '2px 6px', fontSize: 10 }}>
              {editing ? 'Close' : 'Edit'}
            </button>
          )}
          <button className="btn sm ghost" onClick={onUndoEdge} disabled={pending}
            title="Soft-undo this edge"
            style={{ padding: '2px 6px', fontSize: 10, color: 'var(--error)' }}>
            <Icons.Refresh size={10}/>
          </button>
        </div>
      </div>
      {errMsg && (
        <div className="t-small" style={{ color: 'var(--error)', marginLeft: 26 }} title={errMsg}>
          {errMsg.length > 60 ? errMsg.slice(0, 57) + '…' : errMsg}
        </div>
      )}
      {editing && canEditConfidence && EdgeConfidenceEditor && (
        <div style={{ marginLeft: 26 }}>
          <EdgeConfidenceEditor selector={selector} initial={edge.confidence}
            pending={pending} runMutation={runMutation}
            onClose={() => setEditing(false)}/>
        </div>
      )}
    </div>
  );
}
