// Learning Stream — full page + topbar live ticker
// Derives events from the `/skill/graph` snapshot — no SSE required
// (api_contract_graph.md, "完整 graph snapshot 即可还原历史").
// Coverage gap is intentionally not derived; protocol has no field for it.

const { useState: useLs, useEffect: useLsE, useRef: useLsR, useMemo: useLsM } = React;

// Range filter → ms horizon used by both the page header chips and the
// counter sidebar. Sparkline buckets always use 24h regardless of range.
const LEARNING_RANGE_MS = {
  today: 24 * 3600e3,
  '7d':  7 * 24 * 3600e3,
  '30d': 30 * 24 * 3600e3,
  '6mo': 182 * 24 * 3600e3,
};

// Recency buckets shown above each group on the page. Order matters.
const LEARNING_GROUPS = [
  { label: 'Last 5 minutes', test: d => d < 5 * 60e3 },
  { label: 'Last hour',      test: d => d >= 5 * 60e3 && d < 60 * 60e3 },
  { label: 'Today',          test: d => d >= 60 * 60e3 && d < 24 * 3600e3 },
  { label: 'This week',      test: d => d >= 24 * 3600e3 && d < 7 * 24 * 3600e3 },
  { label: 'Older',          test: d => d >= 7 * 24 * 3600e3 },
];

// 12 buckets over the last 24h, oldest → newest.
function buildLearningSpark(events, type) {
  const buckets = new Array(12).fill(0);
  const horizon = 24 * 3600e3;
  const now = Date.now();
  const bucketSize = horizon / 12;
  events.forEach(e => {
    if (type !== 'all' && e.type !== type) return;
    const t = e.at ? new Date(e.at).getTime() : NaN;
    if (isNaN(t)) return;
    const ago = now - t;
    if (ago < 0 || ago > horizon) return;
    const idx = Math.min(11, Math.floor((horizon - ago) / bucketSize));
    buckets[idx]++;
  });
  // Sparkline component requires non-flat min/max; nudge a flat array so
  // the line still renders.
  if (buckets.every(v => v === 0)) return [0, 0.0001, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
  return buckets;
}

// ---------- TICKER (topbar) ----------

function LearningTicker({ setRoute }) {
  const [snapshot, setSnapshot] = useLs(null);
  const [idx, setIdx] = useLs(0);
  const [pulse, setPulse] = useLs(0);

  useLsE(() => {
    let cancelled = false;
    fetchGraphSnapshotCached()
      .then(s => { if (!cancelled) setSnapshot(s); })
      .catch(() => { /* silent — ticker is non-critical */ });
    // Per-turn upserts from /skill/ask responses re-publish the cached
    // snapshot — keep the ticker's marquee in sync with new learnings.
    const unsub = typeof subscribeGraphSnapshot === 'function'
      ? subscribeGraphSnapshot(s => { if (!cancelled) setSnapshot(s); })
      : null;
    return () => { cancelled = true; if (unsub) unsub(); };
  }, []);

  const events = useLsM(() => deriveLearningEvents(snapshot), [snapshot]);

  useLsE(() => {
    if (events.length === 0) return;
    const t = setInterval(() => {
      setIdx(i => (i + 1) % events.length);
      setPulse(p => p + 1);
    }, 4200);
    return () => clearInterval(t);
  }, [events.length]);

  const row = events.length > 0 ? events[idx % events.length] : null;
  const tLabel = row ? formatRelativeTime(row.at) : '';

  return (
    <button onClick={() => setRoute('learning')} style={{
      display: 'flex', alignItems: 'center', gap: 10,
      height: 30, padding: '0 10px 0 8px',
      maxWidth: 380, minWidth: 260,
      border: '1px solid var(--border-subtle)', borderRadius: 7,
      background: 'var(--surface)',
      position: 'relative',
      overflow: 'hidden',
    }} title="Open learning stream">
      {/* Pulse dot */}
      <span style={{ position: 'relative', display: 'inline-flex', width: 8, height: 8, flexShrink: 0 }}>
        <span style={{
          position: 'absolute', inset: -3, borderRadius: '50%',
          background: 'var(--success)', opacity: 0.25,
          animation: 'parclePulseLoop 2s ease-out infinite',
        }}/>
        <span style={{
          position: 'absolute', inset: 0, borderRadius: '50%',
          background: 'var(--success)',
        }}/>
      </span>
      <span className="t-micro text-tertiary" style={{ fontSize: 10, flexShrink: 0 }}>LEARNING</span>
      <div key={pulse} style={{
        flex: 1, minWidth: 0, fontSize: 12, fontWeight: 500,
        color: 'var(--text-primary)',
        whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        textAlign: 'left',
        animation: 'tickerSlide 420ms cubic-bezier(.2,.8,.2,1)',
      }}>
        {row ? row.title : 'Idle — no learnings yet'}
      </div>
      {tLabel && (
        <span className="mono-sm mono text-tertiary" style={{ fontSize: 10, flexShrink: 0 }}>{tLabel}</span>
      )}
    </button>
  );
}

// ---------- GLOBAL "LEARNED NEW CONCEPT" TOAST ----------
// App-level notification (mounted regardless of route). Fires when a turn
// learns brand-new concept(s) — see graph_api.subscribeConceptsLearned. Auto-
// dismisses after 30s; hovering pauses the countdown and a shorter 5s window
// starts on leave. Clicking reveals the concept(s) in the knowledge graph.
function LearnedConceptToast({ onReveal }) {
  const [learned, setLearned] = useLs([]);   // [{id, name}], accumulated while visible
  const timerRef = useLsR(null);
  const hoverRef = useLsR(false);
  const TOAST_MS = 30000, RESUME_MS = 5000;

  useLsE(() => {
    if (typeof subscribeConceptsLearned !== 'function') return;
    const clearTimer = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } };
    const startTimer = (ms) => { clearTimer(); timerRef.current = setTimeout(() => { setLearned([]); timerRef.current = null; }, ms); };
    const unsub = subscribeConceptsLearned(concepts => {
      if (!concepts || !concepts.length) return;
      setLearned(prev => {
        const seen = new Set(prev.map(c => c.id));
        const merged = prev.slice();
        concepts.forEach(c => {
          if (c && c.id && !seen.has(c.id)) { seen.add(c.id); merged.push({ id: c.id, name: c.name || c.id }); }
        });
        return merged;
      });
      if (!hoverRef.current) startTimer(TOAST_MS);  // (re)arm the long window
    });
    return () => { unsub(); clearTimer(); };
  }, []);

  if (learned.length === 0) return null;

  const count = learned.length;
  const detail = count === 1 ? learned[0].name : `${count} new concepts`;
  const dismiss = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } setLearned([]); };
  const reveal = () => {
    if (typeof onReveal === 'function') {
      onReveal({ ids: learned.map(c => c.id), name: learned[0].name, count, token: Date.now() });
    }
    dismiss();
  };
  const onEnter = () => { hoverRef.current = true; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } };
  const onLeave = () => {
    hoverRef.current = false;
    // Guard on length too: a click fires before mouseleave, so dismiss() may
    // have already emptied the toast — don't arm an orphaned timer.
    if (learned.length > 0 && !timerRef.current) {
      timerRef.current = setTimeout(() => { setLearned([]); timerRef.current = null; }, RESUME_MS);
    }
  };

  return (
    <button onClick={reveal} onMouseEnter={onEnter} onMouseLeave={onLeave}
      title="Reveal in knowledge graph"
      style={{
        position: 'fixed', right: 20, top: 60, zIndex: 80,
        display: 'inline-flex', alignItems: 'center', gap: 12,
        padding: '14px 16px', borderRadius: 14, maxWidth: 360, textAlign: 'left',
        background: 'var(--surface-raised)', color: 'var(--text-primary)',
        border: '1px solid var(--border)', cursor: 'pointer',
        boxShadow: '0 10px 30px rgba(0,0,0,0.16)',
        animation: 'parcle-toast-in 200ms ease-out',
      }}>
      <span style={{
        position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        width: 36, height: 36, borderRadius: 10, flexShrink: 0,
        background: 'var(--accent-muted)', color: 'var(--accent-text)',
      }}>
        <span style={{
          position: 'absolute', inset: 0, borderRadius: 10,
          border: '1px solid var(--accent)', opacity: 0.4,
          animation: 'parclePulseLoop 2s ease-out infinite',
        }}/>
        <Icons.Sparkle size={18}/>
      </span>
      <span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
        <span style={{ fontSize: 14, fontWeight: 600 }}>Learned new concept</span>
        <span style={{ fontSize: 12.5, color: 'var(--text-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{detail}</span>
      </span>
      <Icons.ArrowR size={15} style={{ color: 'var(--text-tertiary)', flexShrink: 0, marginLeft: 4 }}/>
    </button>
  );
}

// ---------- FULL PAGE ----------

function LearningPage() {
  const [filter, setFilter] = useLs('all');
  const [range, setRange] = useLs('30d');
  const [snapshot, setSnapshot] = useLs(null);
  const [loadState, setLoadState] = useLs('loading');  // loading | ready | error
  const [loadError, setLoadError] = useLs(null);

  useLsE(() => {
    let cancelled = false;
    setLoadState('loading');
    fetchGraphSnapshotCached()
      .then(s => { if (!cancelled) { setSnapshot(s); setLoadState('ready'); } })
      .catch(err => {
        if (cancelled) return;
        setLoadError(err && err.message ? err.message : String(err));
        setLoadState('error');
      });
    // Per-turn upserts from /skill/ask republish the cached snapshot.
    // Subscribe so the page reflects new learnings without a manual reload.
    const unsub = typeof subscribeGraphSnapshot === 'function'
      ? subscribeGraphSnapshot(s => { if (!cancelled) { setSnapshot(s); setLoadState('ready'); } })
      : null;
    return () => { cancelled = true; if (unsub) unsub(); };
  }, []);

  const events = useLsM(() => deriveLearningEvents(snapshot), [snapshot]);

  // Range filter — applied to derived events.
  const inRange = useLsM(() => {
    const horizon = LEARNING_RANGE_MS[range] || LEARNING_RANGE_MS.today;
    const now = Date.now();
    return events.filter(e => {
      const t = e.at ? new Date(e.at).getTime() : NaN;
      if (isNaN(t)) return false;
      return now - t <= horizon;
    });
  }, [events, range]);

  // Type filter (sidebar selection). The special `needs_review` value is a
  // status-based pseudo-filter — counts events of any type whose status is
  // `needs_review`, so the review-queue button surfaces every actionable item.
  const filtered = useLsM(
    () => inRange.filter(r => {
      if (filter === 'all')          return true;
      if (filter === 'needs_review') return r.status === 'needs_review';
      return r.type === filter;
    }),
    [inRange, filter]
  );

  // Group by recency bucket for the page body.
  const groups = useLsM(() => {
    const now = Date.now();
    return LEARNING_GROUPS.map(g => ({
      label: g.label,
      rows: filtered.filter(r => {
        const t = r.at ? new Date(r.at).getTime() : NaN;
        if (isNaN(t)) return false;
        return g.test(now - t);
      }),
    })).filter(g => g.rows.length > 0);
  }, [filtered]);

  // Counters reflect the in-range, real event totals (gap is protocol-less).
  const counters = useLsM(() => {
    const c = { entity: 0, relation: 0, rerank: 0, dedupe: 0, schema: 0, gap: 0 };
    inRange.forEach(e => { if (c[e.type] != null) c[e.type]++; });
    c.all = inRange.length;
    return c;
  }, [inRange]);

  // Needs-review queue size (low-confidence relations / reranks awaiting attention).
  const needsReviewCount = useLsM(
    () => inRange.filter(e => e.status === 'needs_review').length,
    [inRange]
  );

  // Memoize sparkline buckets — each is an O(N) scan and there are 4 of them.
  const sparkData = useLsM(() => ({
    entity:   buildLearningSpark(inRange, 'entity'),
    relation: buildLearningSpark(inRange, 'relation'),
    dedupe:   buildLearningSpark(inRange, 'dedupe'),
    rerank:   buildLearningSpark(inRange, 'rerank'),
  }), [inRange]);

  return (
    <div style={{ display: 'flex', height: '100%' }}>
      {/* Sidebar */}
      <aside style={{ width: 240, borderRight: '1px solid var(--border-subtle)', padding: '20px 14px', overflowY: 'auto', flexShrink: 0 }}>
        <div className="t-micro text-tertiary" style={{ marginBottom: 8 }}>Type</div>
        {[
          ['all',      'All learnings',    counters.all],
          ['entity',   'New entities',     counters.entity],
          ['relation', 'Relationships',    counters.relation],
          ['dedupe',   'De-duplications',  counters.dedupe],
          ['rerank',   'Re-rankings',      counters.rerank],
          ['gap',      'Coverage gaps',    counters.gap],
          ['schema',   'Schema inferences',counters.schema],
        ].map(([id, label, count]) => (
          <button key={id} onClick={() => setFilter(id)} style={{
            display: 'flex', alignItems: 'center', gap: 10, width: '100%',
            padding: '7px 10px', borderRadius: 6,
            background: filter === id ? 'var(--selected)' : 'transparent',
            color: filter === id ? 'var(--text-primary)' : 'var(--text-secondary)',
            fontSize: 13, fontWeight: filter === id ? 500 : 400,
            marginBottom: 1, textAlign: 'left',
          }}>
            <span style={{ flex: 1 }}>{label}</span>
            <span className="mono-sm mono text-tertiary" style={{ fontSize: 11 }}>{count.toLocaleString()}</span>
          </button>
        ))}

        <div className="t-micro text-tertiary" style={{ marginTop: 24, marginBottom: 10 }}>Review queue</div>
        <div style={{ padding: 12, background: 'var(--warning-bg)', borderRadius: 8, border: '1px solid transparent' }}>
          <div style={{ fontSize: 12, fontWeight: 500, color: 'var(--warning)' }}>
            {needsReviewCount} need{needsReviewCount === 1 ? 's' : ''} review
          </div>
          <div className="t-small text-secondary" style={{ marginTop: 4 }}>
            Lower-confidence inferences waiting for human approval.
          </div>
          <button className="btn sm" style={{ marginTop: 8, width: '100%' }} onClick={() => setFilter('needs_review')}>
            Open queue
          </button>
        </div>
      </aside>

      {/* Main */}
      <div style={{ flex: 1, overflowY: 'auto' }}>
        <div style={{ padding: '24px 32px', maxWidth: 1020 }}>
          <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
            <div>
              <h1 className="t-h1" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 10 }}>
                Learning stream
                <span style={{
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                  padding: '3px 10px', borderRadius: 999,
                  background: 'var(--success-bg)', color: 'var(--success)',
                  fontSize: 11, fontWeight: 500,
                }}>
                  <span style={{
                    width: 6, height: 6, borderRadius: '50%', background: 'var(--success)',
                    animation: 'parclePulseLoop 2s ease-out infinite',
                  }}/>
                  Live
                </span>
              </h1>
              <div className="text-secondary t-body mt-4">
                Everything Parcle figured out about your organization — derived from the current graph snapshot.
              </div>
            </div>
            <div className="segmented">
              {['today', '7d', '30d', '6mo'].map(r => (
                <button key={r} className={range === r ? 'active' : ''} onClick={() => setRange(r)}>
                  {r === 'today' ? 'Today' : r === '7d' ? '7 days' : r === '30d' ? '30 days' : '6 months'}
                </button>
              ))}
            </div>
          </div>

          {/* Rolling counter strip */}
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginTop: 20 }}>
            <CounterTile label="Entities discovered"    value={counters.entity}   spark={sparkData.entity}/>
            <CounterTile label="Relationships inferred" value={counters.relation} spark={sparkData.relation}/>
            <CounterTile label="De-duplications"        value={counters.dedupe}   spark={sparkData.dedupe}/>
            <CounterTile label="Re-rankings"            value={counters.rerank}   spark={sparkData.rerank} accent/>
          </div>

          {/* Stream, grouped */}
          <div style={{ marginTop: 24 }}>
            {loadState === 'loading' && (
              <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-tertiary)' }}>
                Loading graph snapshot…
              </div>
            )}
            {loadState === 'error' && (
              <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-tertiary)' }}>
                <div style={{ marginBottom: 6, color: 'var(--warning)' }}>Could not load graph snapshot</div>
                <div className="t-small">{loadError}</div>
                <div className="t-small" style={{ marginTop: 8 }}>
                  The first build may not have finished yet — try again in a moment.
                </div>
              </div>
            )}
            {loadState === 'ready' && groups.length === 0 && (
              <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-tertiary)' }}>
                {events.length === 0
                  ? 'No learning events in this snapshot yet.'
                  : 'No events in this view.'}
              </div>
            )}
            {loadState === 'ready' && groups.map(g => (
              <div key={g.label} style={{ marginBottom: 28 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
                  <span className="t-micro text-secondary">{g.label}</span>
                  <span style={{ flex: 1, height: 1, background: 'var(--border-subtle)' }}/>
                  <span className="mono-sm mono text-tertiary">{g.rows.length}</span>
                </div>
                <div className="card" style={{ padding: 0 }}>
                  {g.rows.map((r, i) => <LearningRow key={r.id} row={r} first={i === 0}/>)}
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

function CounterTile({ label, value, spark, accent }) {
  const [display, setDisplay] = useLs(value);
  useLsE(() => {
    if (value === display) return;
    // Step size scales with the gap so the morph completes in ~24 frames
    // regardless of magnitude — avoids 30-second crawls on large jumps.
    const d = value - display;
    const stride = Math.max(1, Math.floor(Math.abs(d) / 24));
    const dir = Math.sign(d);
    const t = setInterval(() => {
      setDisplay(prev => {
        const remaining = value - prev;
        if (remaining === 0) { clearInterval(t); return prev; }
        const stepDelta = Math.abs(remaining) <= stride ? remaining : dir * stride;
        return prev + stepDelta;
      });
    }, 40);
    return () => clearInterval(t);
  }, [value]);

  return (
    <div className="card" style={{ padding: 14 }}>
      <div className="t-micro text-tertiary">{label}</div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginTop: 4 }}>
        <span style={{
          fontSize: 24, fontWeight: 600, letterSpacing: '-0.01em',
          color: accent ? 'var(--accent-text)' : 'var(--text-primary)',
          fontVariantNumeric: 'tabular-nums',
        }}>{display.toLocaleString()}</span>
      </div>
      <div style={{ marginTop: 8 }}>
        <Sparkline data={spark} color={accent ? 'var(--accent)' : 'var(--text-secondary)'} height={24}/>
      </div>
    </div>
  );
}

function LearningRow({ row, first }) {
  const [pending, setPending] = useLs(false);
  const [errMsg, setErrMsg] = useLs(null);
  const [editing, setEditing] = useLs(false);

  const isUndone = row.undo_state === 'undo';
  const canUndo  = !!row.undo_payload;
  const canEdit  = !!row.edit_kind;

  // Status chip — Undone overrides everything; otherwise reuse the
  // confidence-derived status the deriver already attached to the row.
  const statusMeta = isUndone
    ? { chip: 'error',   text: 'Undone' }
    : ({
        auto:         { chip: 'accent',  text: 'Auto-applied' },
        applied:      { chip: 'success', text: 'Applied' },
        needs_review: { chip: 'warning', text: 'Needs review' },
      }[row.status] || { chip: 'accent', text: 'Auto-applied' });

  const tLabel = formatRelativeTime(row.at);

  // Returns the merged response on success; rethrows on failure so callers
  // (sub-editors) can keep the editor open until the user cancels manually.
  // The error message is captured in errMsg for inline display either way.
  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 onUndo    = () => runMutation(undoGraph,    row.undo_payload).catch(() => {});
  const onRecover = () => runMutation(recoverGraph, row.undo_payload).catch(() => {});

  return (
    <div style={{
      display: 'flex', alignItems: 'flex-start', gap: 14,
      padding: '16px 20px',
      borderTop: first ? 'none' : '1px solid var(--border-subtle)',
      opacity: isUndone ? 0.72 : 1,
    }}>
      <div style={{ flexShrink: 0 }}>
        <LearningGlyph type={row.type}/>
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{
            fontSize: 13, fontWeight: 500,
            textDecoration: isUndone ? 'line-through' : 'none',
            color: isUndone ? 'var(--text-tertiary)' : 'var(--text-primary)',
          }}>{row.title}</span>
          <span className={`chip ${statusMeta.chip}`} style={{ fontSize: 10 }}>{statusMeta.text}</span>
          <span className="mono-sm mono text-tertiary" style={{ fontSize: 10 }}>
            · conf {Number(row.conf || 0).toFixed(2)} · {row.evidence} {row.evidence === 1 ? 'piece' : 'pieces'} of evidence
          </span>
        </div>
        <div className="t-small text-secondary" style={{ marginTop: 4, whiteSpace: 'pre-wrap' }}>{row.body}</div>

        {/* Actions */}
        <div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
          {isUndone && canUndo && (
            <button className="btn sm" onClick={onRecover} disabled={pending}>
              <Icons.Refresh size={11}/> {pending ? 'Recovering…' : 'Recover'}
            </button>
          )}
          {!isUndone && canUndo && (
            <button className="btn sm ghost" onClick={onUndo} disabled={pending}>
              <Icons.Refresh size={11}/> {pending ? 'Undoing…' : 'Undo'}
            </button>
          )}
          {!isUndone && canEdit && (
            <button className="btn sm ghost" onClick={() => setEditing(true)} disabled={pending}>Edit…</button>
          )}
          {errMsg && (
            <span className="t-small" style={{ color: 'var(--error)' }} title={errMsg}>
              · {errMsg.length > 60 ? errMsg.slice(0, 57) + '…' : errMsg}
            </span>
          )}
        </div>

        {editing && canEdit && (
          <LearningRowEditor row={row} pending={pending}
            runMutation={runMutation}
            onClose={() => setEditing(false)}/>
        )}
      </div>
      <div style={{ flexShrink: 0, textAlign: 'right' }}>
        <div className="mono-sm mono text-tertiary">{tLabel}</div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────
// LearningRowEditor — dispatch on row.edit_kind. Re-resolves the live target
// from the cached snapshot on every re-render so concurrent mutations (e.g.
// removing a synonym while editing the description) reflect immediately.
// ─────────────────────────────────────────────────────────────────────────
function LearningRowEditor({ row, pending, runMutation, onClose }) {
  const [snapshot, setSnap] = useLs(null);
  useLsE(() => {
    let cancelled = false;
    fetchGraphSnapshotCached().then(s => { if (!cancelled) setSnap(s); }).catch(() => {});
    const unsub = typeof subscribeGraphSnapshot === 'function'
      ? subscribeGraphSnapshot(s => { if (!cancelled) setSnap(s); })
      : null;
    return () => { cancelled = true; if (unsub) unsub(); };
  }, []);

  if (!snapshot) return <EditorShell pending={false} onClose={onClose}><span className="text-secondary t-small">Loading…</span></EditorShell>;

  if (row.edit_kind === 'concept') {
    const concept = (snapshot.concept_nodes || []).find(n => n.id === row.edit_target_id);
    if (!concept) return <EditorShell pending={false} onClose={onClose}><span className="text-secondary t-small">Concept no longer in snapshot.</span></EditorShell>;
    return <ConceptEditor concept={concept} pending={pending} runMutation={runMutation} onClose={onClose}/>;
  }
  if (row.edit_kind === 'table') {
    const table = (snapshot.factual_nodes || []).find(n => n.id === row.edit_target_id);
    if (!table) return <EditorShell pending={false} onClose={onClose}><span className="text-secondary t-small">Table no longer in snapshot.</span></EditorShell>;
    return <TableEditor table={table} pending={pending} runMutation={runMutation} onClose={onClose}/>;
  }
  if (row.edit_kind === 'edge_confidence') {
    return <EdgeConfidenceEditor selector={row.edit_edge} initial={row.edit_current_confidence}
      pending={pending} runMutation={runMutation} onClose={onClose}/>;
  }
  return null;
}

function EditorShell({ pending, onSave, onClose, children }) {
  return (
    <div style={{
      marginTop: 10, padding: 12, background: 'var(--bg)', borderRadius: 6,
      border: '1px solid var(--border-subtle)',
    }}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{children}</div>
      <div style={{ display: 'flex', gap: 6, marginTop: 12, justifyContent: 'flex-end' }}>
        <button className="btn sm ghost" onClick={onClose} disabled={pending}>Cancel</button>
        {onSave && (
          <button className="btn sm accent" onClick={onSave} disabled={pending}>
            {pending ? 'Saving…' : 'Save'}
          </button>
        )}
      </div>
    </div>
  );
}

function EditorField({ label, hint, children }) {
  return (
    <div>
      <div className="t-micro text-tertiary" style={{ marginBottom: 4 }}>{label}</div>
      {children}
      {hint && <div className="t-small text-tertiary" style={{ marginTop: 4 }}>{hint}</div>}
    </div>
  );
}

const _EDITOR_INPUT_STYLE = {
  width: '100%', padding: '6px 8px', fontSize: 12,
  border: '1px solid var(--border)', borderRadius: 4,
  background: 'var(--surface)', color: 'var(--text-primary)',
  fontFamily: 'inherit',
};

function ConceptEditor({ concept, pending, runMutation, onClose }) {
  const props = concept.properties || {};
  const [name, setName]       = useLs(concept.name || '');
  const [desc, setDesc]       = useLs(props.description || '');
  const [formula, setFormula] = useLs(props.formula || '');
  const [newSyn, setNewSyn]   = useLs('');

  // Active synonyms paired with the dedup_event index that added them, so
  // the × button can call undoGraph(target_type:"dedup", index) per contract.
  // Walk dedup_events from the end so re-added synonyms resolve to the most
  // recent active event. Legacy string-only synonyms (no dedup metadata)
  // surface with dedupIdx=-1 — × is disabled in that branch.
  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 dedupEvents = concept.dedup_events || [];
  const synRows = (concept.synonyms || [])
    .map(s => {
      if (typeof s === 'string')   return { name: s, dedupIdx: -1 };
      if (s.state === 'undo')      return null;
      return { name: s.name, dedupIdx: findActiveDedupIdx(dedupEvents, s.name) };
    })
    .filter(Boolean);

  const onRemoveSyn = async (synName, dedupIdx) => {
    if (dedupIdx < 0) return;  // no matching dedup event — × disabled in UI anyway
    try {
      await runMutation(undoGraph, {
        target_type: 'dedup',
        target_id: concept.id,
        index: dedupIdx,
      });
    } catch (e) { /* error surfaced by parent runMutation */ }
  };

  const onSave = async () => {
    const patch = {};
    if (name !== (concept.name || ''))   patch.name = name;
    if (desc !== (props.description || ''))                  patch['properties.description'] = desc;
    if (formula !== (props.formula || ''))                   patch['properties.formula']     = formula;
    const trimmed = newSyn.trim();
    if (trimmed) {
      // Contract: synonyms[] must contain all current active synonyms plus
      // any additions; deletions of active items are rejected. Send full set.
      const activeNames = synRows.map(s => s.name);
      if (!activeNames.includes(trimmed)) patch.synonyms = [...activeNames, trimmed];
    }
    if (Object.keys(patch).length === 0) { onClose(); return; }
    try {
      await runMutation(editGraph, { target_type: 'concept', target_id: concept.id, patch });
      onClose();
    } catch (e) { /* keep editor open; error rendered by parent */ }
  };

  return (
    <EditorShell pending={pending} onSave={onSave} onClose={onClose}>
      <EditorField label="Name">
        <input value={name} onChange={e => setName(e.target.value)} disabled={pending} style={_EDITOR_INPUT_STYLE}/>
      </EditorField>
      <EditorField label="Description">
        <textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={pending} rows={3}
          style={{ ..._EDITOR_INPUT_STYLE, resize: 'vertical' }}/>
      </EditorField>
      <EditorField label="Formula">
        <input value={formula} onChange={e => setFormula(e.target.value)} disabled={pending} style={_EDITOR_INPUT_STYLE}/>
      </EditorField>
      <EditorField label="Synonyms"
        hint="Active synonyms can only be removed by undoing the dedup event that added them.">
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
          {synRows.length === 0 && <span className="text-tertiary t-small">— none —</span>}
          {synRows.map(s => (
            <span key={s.name} className="chip" style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
              {s.name}
              <button onClick={() => onRemoveSyn(s.name, s.dedupIdx)}
                disabled={pending || s.dedupIdx < 0}
                title={s.dedupIdx < 0 ? 'No dedup event to undo for this synonym' : 'Undo the dedup event that added this synonym'}
                style={{
                  background: 'transparent', border: 'none', cursor: s.dedupIdx >= 0 ? 'pointer' : 'not-allowed',
                  color: 'var(--text-tertiary)', padding: 0, fontSize: 12, lineHeight: 1,
                }}>×</button>
            </span>
          ))}
        </div>
        <input value={newSyn} onChange={e => setNewSyn(e.target.value)}
          placeholder="add synonym" disabled={pending} style={_EDITOR_INPUT_STYLE}/>
      </EditorField>
    </EditorShell>
  );
}

function TableEditor({ table, pending, runMutation, onClose }) {
  const props = table.properties || {};
  const [desc, setDesc] = useLs(props.description || '');
  const [colState, setColState] = useLs(() => {
    const init = {};
    (props.columns || []).forEach(c => {
      init[c.name] = {
        description:       c.description || '',
        value_description: c.value_description || '',
      };
    });
    return init;
  });

  const updateCol = (colName, field, val) => {
    setColState(prev => ({ ...prev, [colName]: { ...(prev[colName] || {}), [field]: val } }));
  };

  const onSave = async () => {
    const patch = {};
    if (desc !== (props.description || '')) patch['properties.description'] = desc;
    (props.columns || []).forEach(c => {
      const cur  = colState[c.name] || {};
      const orig = { description: c.description || '', value_description: c.value_description || '' };
      if ((cur.description || '')       !== orig.description)       patch[`properties.columns.${c.name}.description`]       = cur.description || '';
      if ((cur.value_description || '') !== orig.value_description) patch[`properties.columns.${c.name}.value_description`] = cur.value_description || '';
    });
    if (Object.keys(patch).length === 0) { onClose(); return; }
    try {
      await runMutation(editGraph, { target_type: 'table', target_id: table.id, patch });
      onClose();
    } catch (e) { /* keep editor open */ }
  };

  return (
    <EditorShell pending={pending} onSave={onSave} onClose={onClose}>
      <EditorField label="Table description">
        <textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={pending} rows={2}
          style={{ ..._EDITOR_INPUT_STYLE, resize: 'vertical' }}/>
      </EditorField>
      <div className="t-micro text-tertiary">Columns</div>
      {(props.columns || []).map(c => (
        <div key={c.name} style={{
          padding: 8, border: '1px solid var(--border-subtle)', borderRadius: 4,
          background: 'var(--surface)',
        }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 6 }}>
            <span style={{ fontWeight: 500, fontSize: 12 }}>{c.name}</span>
            <span className="mono-sm mono text-tertiary" style={{ fontSize: 10 }}>{c.data_type}</span>
          </div>
          <input value={(colState[c.name] || {}).description || ''}
            onChange={e => updateCol(c.name, 'description', e.target.value)}
            placeholder="description" disabled={pending}
            style={{ ..._EDITOR_INPUT_STYLE, marginBottom: 4 }}/>
          <input value={(colState[c.name] || {}).value_description || ''}
            onChange={e => updateCol(c.name, 'value_description', e.target.value)}
            placeholder="value description" disabled={pending}
            style={_EDITOR_INPUT_STYLE}/>
        </div>
      ))}
    </EditorShell>
  );
}

function EdgeConfidenceEditor({ selector, initial, pending, runMutation, onClose }) {
  const [conf, setConf] = useLs(initial != null ? Number(initial) : 0.5);

  const onSave = async () => {
    try {
      await runMutation(editGraph, {
        target_type: 'edge',
        edge: selector,
        patch: { confidence: conf },
      });
      onClose();
    } catch (e) { /* keep editor open */ }
  };

  return (
    <EditorShell pending={pending} onSave={onSave} onClose={onClose}>
      <EditorField label={`Confidence ${conf.toFixed(2)}`}>
        <input type="range" min="0" max="1" step="0.01" value={conf}
          onChange={e => setConf(Number(e.target.value))}
          disabled={pending} style={{ width: '100%', accentColor: 'var(--accent)' }}/>
      </EditorField>
    </EditorShell>
  );
}

Object.assign(window, {
  LearningTicker, LearningPage, LearnedConceptToast,
  // Re-used by fabric.jsx NodeDetail / EdgeRow inline edit panels.
  ConceptEditor, TableEditor, EdgeConfidenceEditor,
});
