// Primus IQ — Session Report Panel (redesigned).
// Right-side panel scoped to a chat session. Claude-inspired: a clean,
// document-like reading/editing surface with quiet chrome.
//
//   • lists the session's reports and analysis runs                — listSessionReports / listSessionAnalysisRuns
//   • awaiting_review = edit the WHOLE report inline (all sections  — listReportSections
//     stacked; Contents rail jumps between them), done = read-only
//   • per-section save                                             — updateReportSection
//   • add section                                                  — addReportSection
//   • AI-assisted revision with an inline accept/reject diff        — suggestReportEdit
//   • whole-report preview (the real PDF renderer)                 — previewReport
//   • approve & render / open + download final PDF                 — approveReportRender / getDownloadUrl

// ── markdown helpers (uses the app's already-loaded marked + DOMPurify) ──────
const _renderMd = (md) => {
  try {
    // Section images use asset:// URLs the browser can't load — and DOMPurify
    // strips the scheme, so after one WYSIWYG save the image reference was
    // silently LOST (figure → caption-only text). Swap each <figure> for a
    // protected chip carrying the original HTML (base64); _htmlToMd restores
    // it verbatim on save.
    const prepared = (md || '').replace(/<figure>[\s\S]*?<\/figure>/gi, (fig) => {
      const cap = (fig.match(/<figcaption>([\s\S]*?)<\/figcaption>/i) || [])[1] || '';
      const alt = (fig.match(/alt="([^"]*)"/i) || [])[1] || '';
      const b64 = btoa(unescape(encodeURIComponent(fig)));
      const label = (alt || cap || 'section image').replace(/</g, '&lt;').slice(0, 90);
      return `<div class="pi-fig-slot" data-fig="${b64}" contenteditable="false">📷 ${label}</div>`;
    });
    return DOMPurify.sanitize(marked.parse(prepared));
  }
  catch (_) { return (md || '').replace(/</g, '&lt;'); }
};
const _inlineMd = (node) => {
  let s = '';
  node.childNodes.forEach((n) => {
    if (n.nodeType === 3) { s += n.textContent; return; }
    if (n.nodeType !== 1) return;
    const t = n.tagName.toLowerCase();
    const inner = _inlineMd(n);
    if (t === 'strong' || t === 'b') s += '**' + inner + '**';
    else if (t === 'em' || t === 'i') s += '*' + inner + '*';
    else if (t === 'code') s += '`' + inner + '`';
    else if (t === 'a') s += '[' + inner + '](' + (n.getAttribute('href') || '') + ')';
    else if (t === 'br') s += '  \n';
    else s += inner;
  });
  return s;
};
const _htmlToMd = (html) => {
  const root = document.createElement('div');
  root.innerHTML = html;
  const out = [];
  root.childNodes.forEach((n) => {
    if (n.nodeType === 3) { const t = n.textContent.trim(); if (t) out.push(t); return; }
    if (n.nodeType !== 1) return;
    const tag = n.tagName.toLowerCase();
    if (/^h[1-6]$/.test(tag)) out.push('#'.repeat(+tag[1]) + ' ' + _inlineMd(n).trim());
    else if (tag === 'ul') n.querySelectorAll(':scope > li').forEach((li) => out.push('- ' + _inlineMd(li).trim()));
    else if (tag === 'ol') { let i = 1; n.querySelectorAll(':scope > li').forEach((li) => out.push((i++) + '. ' + _inlineMd(li).trim())); }
    else if (tag === 'blockquote') out.push('> ' + _inlineMd(n).trim());
    else if (tag === 'hr') out.push('---');
    else if (tag === 'pre') {
      // Preserve the fence INFO-STRING (```stat, ```barchart, ```callout, …) — marked
      // renders it as `class="language-<name>"` on the inner <code>. Dropping it would
      // turn an infographic/callout block into a plain code fence and it would stop
      // rendering in the PDF. Also strip marked's single trailing newline.
      const code = n.querySelector('code');
      const m = ((code && code.className) || '').match(/language-([\w-]+)/);
      const lang = m ? m[1] : '';
      const body = (code ? code.textContent : n.textContent).replace(/\n$/, '');
      out.push('```' + lang + '\n' + body + '\n```');
    }
    else if (tag === 'table') {
      // Reconstruct a GitHub-flavoured markdown table (header + --- separator + rows)
      // so tables survive the WYSIWYG round-trip instead of collapsing to plain text.
      const rows = [];
      const head = Array.from(n.querySelectorAll('thead th, thead td')).map((c) => _inlineMd(c).trim());
      if (head.length) {
        rows.push('| ' + head.join(' | ') + ' |');
        rows.push('| ' + head.map(() => '---').join(' | ') + ' |');
      }
      Array.from(n.querySelectorAll('tbody tr')).forEach((tr) => {
        const cells = Array.from(tr.querySelectorAll('td, th')).map((c) => _inlineMd(c).trim());
        if (cells.length) rows.push('| ' + cells.join(' | ') + ' |');
      });
      if (rows.length) out.push(rows.join('\n'));
    }
    else if (tag === 'div' && n.classList && n.classList.contains('pi-fig-slot')) {
      // Restore the protected <figure> (see _renderMd) byte-identical.
      try {
        const fig = decodeURIComponent(escape(atob(n.getAttribute('data-fig') || '')));
        if (fig) out.push(fig);
      } catch (_) { /* mangled placeholder — drop rather than emit garbage */ }
    }
    else { const t = _inlineMd(n).trim(); if (t) out.push(t); }
  });
  return out.join('\n\n');
};

const _statusChip = (st) => ({
  awaiting_plan_review: { label: 'Plan Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' },
  awaiting_review: { label: 'Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' },
  ready_for_review:{ label: 'Review', bg: 'var(--maroon-10)', fg: 'var(--maroon)' },
  render_queued:   { label: 'Rendering', bg: 'var(--bg-soft)', fg: 'var(--mute)' },
  rendering:       { label: 'Rendering', bg: 'var(--bg-soft)', fg: 'var(--mute)' },
  done:            { label: 'Final', bg: 'rgba(46,160,64,0.14)', fg: '#1f6f2f' },
  published:       { label: 'Published', bg: 'rgba(46,160,64,0.14)', fg: '#1f6f2f' },
  running:         { label: 'Generating', bg: 'var(--bg-soft)', fg: 'var(--mute)' },
  queued:          { label: 'Generating', bg: 'var(--bg-soft)', fg: 'var(--mute)' },
  failed:          { label: 'Failed', bg: 'rgba(200,60,60,0.14)', fg: '#9a3b3b' },
}[st] || { label: st, bg: 'var(--bg-soft)', fg: 'var(--mute)' });

const ReviewPanel = ({ sessionId, focusRunId, onClose, onOpenArtifact }) => {
  const [reports, setReports]       = React.useState([]);
  const [activeRun, setActiveRun]   = React.useState(null);
  const [sections, setSections]     = React.useState([]);
  const [reviewData, setReviewData] = React.useState(null);
  const [dirtyFiles, setDirtyFiles] = React.useState([]);     // filenames with unsaved edits
  const [focusedFile, setFocusedFile] = React.useState(null); // Contents-rail highlight
  const [saving, setSaving]         = React.useState(false);
  const [savedAt, setSavedAt]       = React.useState(null);
  const [error, setError]           = React.useState(null);

  // in-flight flags for the analysis review actions (publish / approve / reject).
  // Each shows a spinner INSIDE its button while the 202 request is pending, and
  // guards against a double-click firing the request twice.
  const [publishBusy, setPublishBusy] = React.useState(null);   // null | 'publish' | 'download'
  const [approveBusy, setApproveBusy] = React.useState(false);
  const [rejectBusy, setRejectBusy]   = React.useState(false);

  // layout — default to ~half the viewport (a document-like workspace), clamped so
  // the chat always keeps ≥360px. The user can still drag it narrower/wider.
  const [panelW, setPanelW] = React.useState(() => {
    const vw = (typeof window !== 'undefined' && window.innerWidth) || 1440;
    return Math.round(Math.max(420, Math.min(980, vw - 360, vw * 0.5)));
  });
  const [expanded, setExpanded]   = React.useState(false);
  const [dragging, setDragging]   = React.useState(false);
  const [tocOpen, setTocOpen]     = React.useState(true);
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
  const [docView, setDocView]     = React.useState('edit');   // editable view: 'edit' (WYSIWYG) | 'preview' (server render)

  // inline AI
  const [pill, setPill]           = React.useState(null);   // {left,top,bLeft,bTop,text,filename}
  const [aiOpen, setAiOpen]       = React.useState(false);
  const [aiInstruction, setAiInstruction] = React.useState('');
  const [aiBusy, setAiBusy]       = React.useState(false);
  const [suggestion, setSuggestion] = React.useState(null); // {revised, parts, filename}
  const [previewKey, setPreviewKey] = React.useState(0);

  const scrollRef = React.useRef(null);
  const docRefs   = React.useRef({});   // filename -> that section's contentEditable element

  const runId    = activeRun && activeRun.run_id;
  const isAnalysis = activeRun && activeRun.kind === 'analysis';
  const editable = activeRun && activeRun.status === 'awaiting_review';
  const isDone   = activeRun && (activeRun.status === 'done' || activeRun.status === 'published');
  const hasSections = activeRun && ['awaiting_review', 'render_queued', 'rendering', 'done'].includes(activeRun.status);
  const dirty = dirtyFiles.length > 0;
  const markDirty = (fn) => setDirtyFiles((prev) => (prev.includes(fn) ? prev : [...prev, fn]));

  // ── load the session's reports ──────────────────────────────────────────
  React.useEffect(() => {
    if (!sessionId && !focusRunId) return;
    setError(null);
    // NOTE: do NOT reset reports/activeRun to empty here. Blanking before the
    // refetch resolves made the whole pane flash empty on every run/notification
    // switch. The .then below always replaces both, so the previous run stays
    // visible until the new list arrives (fetch-then-swap, no flicker).
    const pick = (list) => {
      if (focusRunId) { const f = list.find((r) => r.run_id === focusRunId); if (f) return f; }
      return list.find((r) => ['awaiting_plan_review', 'awaiting_review', 'ready_for_review'].includes(r.status)) || list[0] || null;
    };
    const listP = sessionId
      ? Promise.all([
          PrimusAPI.listSessionReports(sessionId).then((res) => (res && res.reports) || []),
          PrimusAPI.listSessionAnalysisRuns(sessionId).then((res) => (res && res.runs) || []),
        ]).then(([reportRuns, analysisRuns]) => [...reportRuns, ...analysisRuns])
      : Promise.resolve([]);
    listP
      .then(async (list) => {
        if (focusRunId && !list.some((r) => r.run_id === focusRunId)) {
          try {
            let meta = null;
            try { meta = await PrimusAPI.getReport(focusRunId); }
            catch (_) { meta = await PrimusAPI.getAnalysisRun(focusRunId); }
            if (meta && meta.run_id) list = [meta, ...list];
          } catch (_) { /* focused run not resolvable — leave it out */ }
        }
        setReports(list);
        setActiveRun(pick(list));
      })
      .catch((e) => setError(e.message || "Could not load this session's reports."));
  }, [sessionId, focusRunId]);

  // ── load ALL sections when the active report changes ─────────────────────
  React.useEffect(() => {
    // Reset only the report-scratch state (sections, edit buffers, inline-AI).
    // Do NOT null reviewData up front — that made the AnalysisReviewCard blank
    // then repopulate on every active-run change. For analysis runs we
    // fetch-then-swap below so the card updates in place; only clear it when
    // the active run is not an analysis run (so no stale card lingers).
    setSections([]); setDirtyFiles([]); setSavedAt(null); setFocusedFile(null);
    setSuggestion(null); setAiInstruction(''); setPill(null); setAiOpen(false);
    docRefs.current = {};
    if (!activeRun) { setReviewData(null); return; }
    if (activeRun.kind === 'analysis') {
      PrimusAPI.getAnalysisRunReview(activeRun.run_id)
        .then((data) => setReviewData(data || null))
        .catch((e) => { setReviewData(null); setError(e.message || 'Could not load the analysis run.'); });
      return;
    }
    setReviewData(null);
    if (!hasSections) return;
    PrimusAPI.listReportSections(activeRun.run_id)
      .then((secs) => { setSections(secs || []); if (secs && secs.length) setFocusedFile(secs[0].filename); })
      .catch((e) => setError(e.message || 'Could not load the report.'));
  }, [activeRun && activeRun.run_id]);

  // ── inject each section's rendered HTML into its block, once per run ──────
  // Blocks stay mounted (Preview just hides them), so this never clobbers live
  // edits: a block already tagged for this run is skipped. New/added blocks and
  // a fresh run get (re)injected.
  React.useEffect(() => {
    if (!editable || !sections.length) return;
    sections.forEach((s) => {
      const el = docRefs.current[s.filename];
      if (el && el.getAttribute('data-loaded') !== String(runId)) {
        el.innerHTML = _renderMd(s.content);
        el.setAttribute('data-loaded', String(runId));
      }
    });
  }, [editable, sections, runId, docView]);

  // ── resize + expand geometry are pure CSS on THIS element only ──────────
  const startResize = (e) => {
    e.preventDefault();
    const sx = e.clientX, sw = panelW;
    setDragging(true); setPill(null);
    const maxW = Math.max(760, (window.innerWidth || 1440) - 360);   // always leave ≥360 for the chat
    const move = (ev) => setPanelW(Math.max(380, Math.min(maxW, sw + (sx - ev.clientX))));
    const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); setDragging(false); };
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  };

  // Contents rail → scroll that section into view (in edit mode).
  const scrollToSection = (fn) => {
    setFocusedFile(fn); setDocView('edit');
    requestAnimationFrame(() => {
      const el = docRefs.current[fn];
      const wrap = el && el.closest('[data-file]');
      if (wrap && typeof wrap.scrollIntoView === 'function') wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
    });
  };

  const switchReport = (r) => {
    if (r.run_id === runId) { setDropdownOpen(false); return; }
    if (dirty && !window.confirm('Discard unsaved edits?')) return;
    setActiveRun(r); setDropdownOpen(false);
  };

  // ── inline selection → Ask AI (scoped to the section block it lands in) ──
  const onDocMouseDown = () => { if (pill || aiOpen) { setPill(null); setAiOpen(false); setSuggestion(null); setAiInstruction(''); } };
  const onDocSelect = () => {
    if (!editable) return;
    const sel = window.getSelection();
    if (!sel || sel.isCollapsed || !sel.rangeCount) return;
    const text = sel.toString().trim();
    if (text.length < 2) return;
    const range = sel.getRangeAt(0);
    const anchor = range.commonAncestorContainer;
    const anchorEl = anchor.nodeType === 1 ? anchor : anchor.parentElement;
    const wrap = anchorEl && anchorEl.closest('[data-file]');
    if (!wrap || !scrollRef.current || !scrollRef.current.contains(wrap)) return;
    const fn = wrap.getAttribute('data-file');
    const rect = range.getBoundingClientRect();
    const pr = scrollRef.current.getBoundingClientRect();
    const cw = scrollRef.current.clientWidth, bw = Math.min(380, cw - 32);
    const rawLeft = rect.left - pr.left + rect.width / 2;
    const bLeft = Math.max(bw / 2 + 12, Math.min(cw - bw / 2 - 12, rawLeft));
    const bTop = Math.max(8, Math.min(rect.top - pr.top + 6, scrollRef.current.clientHeight - 230));
    setPill({
      left: Math.max(20, Math.min(cw - 20, rawLeft + scrollRef.current.scrollLeft)),
      top: rect.top - pr.top + scrollRef.current.scrollTop - 8,
      bLeft, bTop, text, filename: fn,
    });
    setAiOpen(false); setSuggestion(null); setAiInstruction('');
  };
  const onScrollDoc = () => { if (pill && !aiOpen) setPill(null); };

  const askAI = async (instrArg) => {
    const instruction = (typeof instrArg === 'string' ? instrArg : aiInstruction).trim();
    if (!pill || !pill.text) return;
    const fn = pill.filename;
    setAiBusy(true); setError(null);
    try {
      const res = await PrimusAPI.suggestReportEdit(runId, fn, instruction || 'Improve this passage', pill.text);
      const revised = (res && res.revised_section) || '';
      const el = docRefs.current[fn];
      const curMd = el ? _htmlToMd(el.innerHTML) : '';
      const parts = window.Diff ? window.Diff.diffWords(curMd, revised) : [{ value: revised }];
      setSuggestion({ revised, parts, filename: fn });
    } catch (e) { setError(e.message || 'Suggestion failed.'); }
    finally { setAiBusy(false); }
  };
  const acceptSuggestion = () => {
    if (!suggestion) return;
    const el = docRefs.current[suggestion.filename];
    if (el) { el.innerHTML = _renderMd(suggestion.revised); markDirty(suggestion.filename); }   // whole-section replace (matches backend)
    setPill(null); setAiOpen(false); setSuggestion(null); setAiInstruction('');
    const sel = window.getSelection(); if (sel) sel.removeAllRanges();
  };
  const rejectSuggestion = () => setSuggestion(null);
  const closeAi = () => { setAiOpen(false); setPill(null); setSuggestion(null); setAiInstruction(''); setAiBusy(false); };

  // ── save (all edited sections) / add section ─────────────────────────────
  const save = async () => {
    if (!dirty || saving) return;
    setSaving(true); setError(null);
    try {
      const updated = {};
      for (const fn of dirtyFiles) {
        const el = docRefs.current[fn];
        if (!el) continue;
        const md = _htmlToMd(el.innerHTML);
        await PrimusAPI.updateReportSection(runId, fn, md);
        updated[fn] = md;
      }
      setSections((prev) => prev.map((s) => (updated[s.filename] != null ? { ...s, content: updated[s.filename] } : s)));
      setDirtyFiles([]); setSavedAt(Date.now());
      setPreviewKey((k) => k + 1);
    } catch (e) { setError(e.message || 'Save failed.'); }
    finally { setSaving(false); }
  };
  const addSection = async () => {
    const t = window.prompt('New section title');
    if (!t || !t.trim()) return;
    try {
      const s = await PrimusAPI.addReportSection(runId, t.trim());
      setSections((prev) => [...prev, s]);
      setPreviewKey((k) => k + 1);
      setFocusedFile(s.filename);
      requestAnimationFrame(() => {
        const el = docRefs.current[s.filename];
        const wrap = el && el.closest('[data-file]');
        if (wrap && typeof wrap.scrollIntoView === 'function') wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    } catch (e) { setError((e && e.message) || 'Could not add section.'); }
  };

  const closePanel = () => { setExpanded(false); onClose && onClose(); };

  const downloadArtifact = async () => {
    if (!activeRun || !activeRun.artifact_id) return;
    try {
      const res = await PrimusAPI.getDownloadUrl(activeRun.artifact_id);
      const url = res && (res.download_url || res.url);
      if (url) window.open(url, '_blank', 'noopener');
    } catch (e) { setError((e && e.message) || 'Could not get the download link.'); }
  };

  const publishAnalysis = async ({ download = false } = {}) => {
    if (!activeRun || activeRun.kind !== 'analysis' || publishBusy) return;
    setPublishBusy(download ? 'download' : 'publish'); setError(null);
    try {
      const next = await PrimusAPI.publishAnalysisRun(runId);
      setActiveRun(next);
      const review = await PrimusAPI.getAnalysisRunReview(runId);
      setReviewData(review || null);
      // "Publish and Download" — after the publish succeeds, immediately
      // fetch a signed URL and open it. Reuses the same endpoint used by
      // the notification's Download button.
      if (download && next && next.artifact_id) {
        try {
          const res = await PrimusAPI.getDownloadUrl(next.artifact_id);
          const url = res && (res.download_url || res.url);
          if (url) window.open(url, '_blank', 'noopener');
        } catch (_) { /* silent — publish already succeeded */ }
      }
    } catch (e) {
      setError((e && e.message) || 'Could not publish the cleaned dataset.');
    } finally {
      setPublishBusy(null);
    }
  };

  const [planFeedbackOpen, setPlanFeedbackOpen] = React.useState(false);
  const [planFeedback, setPlanFeedback] = React.useState('');

  const approvePlan = async () => {
    if (!activeRun || activeRun.kind !== 'analysis' || approveBusy) return;
    setApproveBusy(true); setError(null);
    try {
      const next = await PrimusAPI.approveAnalysisPlan(runId);
      setActiveRun(next);
      const review = await PrimusAPI.getAnalysisRunReview(runId);
      setReviewData(review || null);
    } catch (e) {
      setError((e && e.message) || 'Could not approve the cleaning plan.');
    } finally {
      setApproveBusy(false);
    }
  };

  const rejectPlan = async () => {
    if (!activeRun || activeRun.kind !== 'analysis' || !planFeedback.trim() || rejectBusy) return;
    setRejectBusy(true); setError(null);
    try {
      const next = await PrimusAPI.rejectAnalysisPlan(runId, planFeedback.trim());
      setActiveRun(next);
      const review = await PrimusAPI.getAnalysisRunReview(runId);
      setReviewData(review || null);
      setPlanFeedbackOpen(false); setPlanFeedback('');
    } catch (e) {
      setError((e && e.message) || 'Could not reject the cleaning plan.');
    } finally {
      setRejectBusy(false);
    }
  };

  // ── styles ──────────────────────────────────────────────────────────────
  const asideStyle = expanded
    ? { position: 'fixed', top: 20, right: 20, bottom: 20, left: 20, width: 'auto', zIndex: 1000,
        display: 'flex', flexDirection: 'column', background: 'var(--bg-card)',
        border: '1px solid var(--line-2)', borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden' }
    : { position: 'relative', width: panelW, minWidth: panelW, height: '100%',
        display: 'flex', flexDirection: 'column', background: 'var(--bg-card)',
        borderLeft: '1px solid var(--line)', overflow: 'hidden',
        transition: dragging ? 'none' : 'width 0.18s ease' };
  const iconBtn = { width: 32, height: 32, borderRadius: 8, border: 'none', background: 'transparent',
                    cursor: 'pointer', display: 'grid', placeItems: 'center', color: 'var(--mute)' };
  const wideEnough = expanded || panelW >= 460;
  // Contents rail only in EDIT mode — it scrolls between the stacked section blocks.
  // A done report is a single rendered document (iframe), so a jump-list there does
  // nothing; omit it rather than show dead nav.
  const showToc = tocOpen && hasSections && wideEnough && editable;
  const docMaxWidth = expanded ? 760 : '100%';
  // Report-only: ReviewPreview iframes /reports/{run_id}/preview which does
  // not exist for analysis runs. Analysis runs render their preview inline
  // via AnalysisReviewCard (table of preview_rows). Without this guard, a
  // done analysis run showed a red "Preview failed." under its own preview.
  const showPreview = !isAnalysis && (isDone || (editable && docView === 'preview'));

  return (
    <React.Fragment>
      {expanded && (
        <div onClick={() => setExpanded(false)}
             style={{ position: 'fixed', inset: 0, background: 'var(--bg-overlay)', backdropFilter: 'blur(2px)', zIndex: 999, animation: 'fadein 0.2s ease' }} />
      )}

      {/* While dragging, a transparent shield captures ALL mouse events — without it,
          the report iframe swallows mousemove as soon as the cursor crosses it, which
          froze shrink-drags (grow-drags move over regular DOM and worked). */}
      {dragging && (
        <div style={{ position: 'fixed', inset: 0, zIndex: 2000, cursor: 'col-resize' }} />
      )}

      <aside style={asideStyle}>
        {!expanded && (
          <div onMouseDown={startResize} title="Drag to resize"
               style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 8, cursor: 'col-resize', zIndex: 5 }} />
        )}

        {/* Header */}
        <div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 6, padding: '10px 12px 10px 16px', borderBottom: '1px solid var(--line)', flexShrink: 0 }}>
          <button onClick={() => setDropdownOpen((o) => !o)}
                  style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1, background: 'transparent', border: 'none', cursor: 'pointer', padding: '5px 6px', borderRadius: 8, textAlign: 'left', fontFamily: 'inherit' }}>
            <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13.5, fontWeight: 600, color: 'var(--ink)' }}>
              {activeRun ? (activeRun.title || (isAnalysis ? 'Dataset review' : 'Report')) : 'Review items in this chat'}
            </span>
            {activeRun && (
              <span style={{ flexShrink: 0, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.03em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: _statusChip(activeRun.status).bg, color: _statusChip(activeRun.status).fg }}>
                {_statusChip(activeRun.status).label}
              </span>
            )}
            {reports.length > 1 && (
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0, color: 'var(--mute-2)' }}>
                <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            )}
          </button>

          {hasSections && editable && (
            <button onClick={() => setTocOpen((o) => !o)} title="Contents" style={iconBtn}>
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2.5 4h11M2.5 8h11M2.5 12h7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>
            </button>
          )}
          {isDone && activeRun.artifact_id && (
            <button onClick={downloadArtifact} title="Download" style={iconBtn}>
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2.5v7m0 0L5 6.5m3 3l3-3M2.5 12.5h11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </button>
          )}
          <button onClick={() => setExpanded((x) => !x)} title={expanded ? 'Collapse' : 'Expand'} style={iconBtn}>
            {expanded
              ? <svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
              : <svg width="15" height="15" viewBox="0 0 16 16" fill="none"><path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>}
          </button>
          <button onClick={closePanel} title="Close" style={{ ...iconBtn, fontSize: 20, lineHeight: 1 }}>×</button>

          {/* Reports dropdown */}
          {dropdownOpen && reports.length > 0 && (
            <div style={{ position: 'absolute', top: 50, left: 12, width: 340, maxHeight: 360, overflowY: 'auto', background: 'var(--bg-card)', border: '1px solid var(--line-2)', borderRadius: 12, boxShadow: 'var(--shadow-md)', padding: 6, zIndex: 40, animation: 'fadein 0.14s ease' }} className="pi-thin-scroll">
              <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--mute-2)', padding: '8px 10px 6px' }}>
                {reports.length} review item{reports.length > 1 ? 's' : ''} in this chat
              </div>
              {reports.map((r) => {
                const c = _statusChip(r.status); const active = r.run_id === runId;
                return (
                  <button key={r.run_id} onClick={() => switchReport(r)}
                          style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 10, padding: '10px 11px', border: 'none', background: active ? 'var(--bg-soft)' : 'transparent', borderRadius: 9, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
                    <span style={{ width: 26, height: 26, borderRadius: 7, background: active ? 'var(--maroon-10)' : 'var(--bg-soft)', color: active ? 'var(--maroon)' : 'var(--mute)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
                      <svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M4 2h5l3 3v9H4z" stroke="currentColor" strokeWidth="1.4" /><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.4" /></svg>
                    </span>
                    <span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
                    <span style={{ flexShrink: 0, fontSize: 9, fontWeight: 700, letterSpacing: '0.03em', textTransform: 'uppercase', padding: '2px 7px', borderRadius: 999, background: c.bg, color: c.fg }}>{c.label}</span>
                  </button>
                );
              })}
            </div>
          )}
        </div>

        {error && <div style={{ padding: '8px 16px', color: 'var(--maroon)', fontSize: 12 }}>{error}</div>}

        {!activeRun && (
          <div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--mute)', fontSize: 13 }}>
            No review items in this chat yet.
          </div>
        )}

        {activeRun && !hasSections && !isAnalysis && (
          <div style={{ flex: 1, display: 'grid', placeItems: 'center', textAlign: 'center', color: 'var(--mute)', fontSize: 13, padding: 24 }}>
            {activeRun.status === 'failed' ? 'This report failed to generate.' : 'Generating your report… this panel will show it when it\'s ready.'}
          </div>
        )}

        {activeRun && (activeRun.status === 'render_queued' || activeRun.status === 'rendering') && (
          <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 14, color: 'var(--mute)', textAlign: 'center', padding: 40 }}>
            <span style={{ width: 32, height: 32, borderRadius: 999, border: '3px solid var(--line)', borderTopColor: 'var(--maroon)', animation: 'pi-spin 0.8s linear infinite' }} />
            <div><div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)', marginBottom: 4 }}>Rendering the final report</div><div style={{ fontSize: 12.5 }}>You'll be notified when it's ready.</div></div>
          </div>
        )}

        {/* ── Body: Contents rail + document ── */}
        {activeRun && ((editable || isDone) || isAnalysis) && (
          <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
            {showToc && (
              <div className="pi-thin-scroll" style={{ width: 200, flexShrink: 0, borderRight: '1px solid var(--line)', background: 'var(--bg-soft)', overflowY: 'auto', padding: '16px 10px' }}>
                <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--mute-2)', padding: '0 8px 10px' }}>Contents</div>
                {sections.map((s) => {
                  const on = s.filename === focusedFile;
                  return (
                    <button key={s.filename} onClick={() => (isDone ? setFocusedFile(s.filename) : scrollToSection(s.filename))}
                            style={{ width: '100%', textAlign: 'left', display: 'block', padding: '7px 9px', border: 'none', background: on ? 'var(--maroon-10)' : 'transparent', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: on ? 600 : 500, color: on ? 'var(--maroon)' : 'var(--ink-2)', marginBottom: 2, lineHeight: 1.35 }}>
                      {s.title}
                    </button>
                  );
                })}
                {editable && (
                  <button onClick={addSection}
                          style={{ width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 6, padding: '8px 9px', marginTop: 6, border: '1px dashed var(--line-2)', background: 'transparent', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', fontSize: 11.5, color: 'var(--mute)' }}>
                    <span style={{ fontSize: 14, lineHeight: 1 }}>+</span> Add section
                  </button>
                )}
              </div>
            )}

            {/* Document area */}
            <div style={{ flex: 1, minWidth: 0, position: 'relative', display: 'flex', flexDirection: 'column' }}>
              {isAnalysis && reviewData && (
                <AnalysisReviewCard review={reviewData} onDownload={downloadArtifact} />
              )}
              {editable && (
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px 0', flexShrink: 0 }}>
                  {/* Sliding segmented toggle — Preview | Edit (thumb slides between them) */}
                  <div style={{ position: 'relative', display: 'inline-flex', padding: 3, borderRadius: 999,
                                background: 'var(--bg-soft)', border: '1px solid var(--line-2)' }}>
                    <span aria-hidden style={{ position: 'absolute', top: 3, left: 3, width: 74, height: 24, borderRadius: 999,
                                               background: 'var(--bg-card)', boxShadow: 'var(--shadow-xs)',
                                               transform: docView === 'edit' ? 'translateX(74px)' : 'translateX(0)',
                                               transition: 'transform 0.22s cubic-bezier(0.4,0,0.2,1)' }} />
                    {[['preview', 'Preview'], ['edit', 'Edit']].map(([v, label]) => (
                      <button key={v}
                              onClick={() => { setDocView(v); if (v === 'preview') { setPill(null); setAiOpen(false); setSuggestion(null); } }}
                              style={{ position: 'relative', zIndex: 1, width: 74, height: 24, border: 'none', background: 'transparent',
                                       cursor: 'pointer', fontFamily: 'inherit', fontSize: 12,
                                       fontWeight: docView === v ? 600 : 500,
                                       color: docView === v ? 'var(--ink)' : 'var(--mute)', transition: 'color 0.15s' }}>
                        {label}
                      </button>
                    ))}
                  </div>
                  {docView === 'preview' && (
                    <span style={{ fontSize: 10.5, color: 'var(--mute-2)' }}>Rendered · reflects saved edits</span>
                  )}
                </div>
              )}

              {/* Server-rendered full-report preview (done reports + editable Preview tab) */}
              {showPreview && (
                <div style={{ flex: 1, minHeight: 0, padding: 12 }}>
                  <ReviewPreview runId={runId} refreshKey={previewKey} />
                </div>
              )}

              {/* Editable full report — ALL sections stacked. Kept mounted (hidden during
                  Preview) so switching views never blanks or loses unsaved edits. */}
              {editable && (
                <div ref={scrollRef} onScroll={onScrollDoc} className="pi-thin-scroll"
                     style={{ flex: 1, overflow: 'auto', position: 'relative', display: showPreview ? 'none' : 'block' }}>
                  <div style={{ maxWidth: docMaxWidth, margin: '0 auto', padding: '24px 40px 100px' }}>
                    <div style={{ fontSize: 11, color: 'var(--mute-2)', marginBottom: 18, fontWeight: 500 }}>{activeRun.title}</div>
                    {sections.map((s) => (
                      <div key={s.filename} data-file={s.filename} style={{ marginBottom: 30 }}>
                        <div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--mute-2)', marginBottom: 8, paddingBottom: 6, borderBottom: '1px solid var(--line)' }}>
                          {s.title}
                          {dirtyFiles.includes(s.filename) && <span style={{ marginLeft: 8, color: 'var(--maroon)', textTransform: 'none', letterSpacing: 0, fontWeight: 600 }}>• edited</span>}
                        </div>
                        <div
                          ref={(el) => { if (el) docRefs.current[s.filename] = el; }}
                          className="prose"
                          contentEditable
                          suppressContentEditableWarning
                          onMouseDown={onDocMouseDown}
                          onMouseUp={onDocSelect}
                          onKeyUp={onDocSelect}
                          onInput={() => markDirty(s.filename)}
                          style={{ outline: 'none', fontSize: 15, minHeight: 40 }}
                        />
                      </div>
                    ))}
                  </div>

                  {/* Ask AI pill */}
                  {pill && !aiOpen && (
                    <button
                      onMouseDown={(e) => e.preventDefault()}
                      onClick={() => setAiOpen(true)}
                      style={{ position: 'absolute', left: pill.left, top: pill.top, transform: 'translate(-50%,-100%)', zIndex: 15, display: 'inline-flex', alignItems: 'center', gap: 6, height: 32, padding: '0 13px', borderRadius: 999, border: 'none', background: 'var(--maroon)', color: '#fff', fontFamily: 'inherit', fontSize: 12, fontWeight: 600, cursor: 'pointer', boxShadow: '0 6px 18px rgba(122,28,28,0.32)', animation: 'fadein 0.12s ease' }}>
                      <svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M8 2l1.4 3.6L13 7l-3.6 1.4L8 12l-1.4-3.6L3 7l3.6-1.4z" fill="currentColor" /></svg>
                      Ask AI
                    </button>
                  )}
                </div>
              )}

              {/* Inline AI bubble (anchored at the selection) */}
              {editable && aiOpen && pill && (
                <div style={{ position: 'absolute', left: pill.bLeft, top: pill.bTop, transform: 'translateX(-50%)', width: 'min(380px, calc(100% - 32px))', zIndex: 16, background: 'var(--bg-card)', border: '1px solid var(--line-2)', borderRadius: 14, boxShadow: 'var(--shadow-md)', overflow: 'hidden', animation: 'fadein 0.15s ease' }}>
                  <div style={{ padding: '9px 12px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 7 }}>
                    <svg width="13" height="13" viewBox="0 0 16 16" fill="none" style={{ color: 'var(--maroon)' }}><path d="M8 2l1.4 3.6L13 7l-3.6 1.4L8 12l-1.4-3.6L3 7l3.6-1.4z" fill="currentColor" /></svg>
                    <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.02em', color: 'var(--ink)', flex: 1 }}>Ask AI to revise selection</span>
                    <button onClick={closeAi} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--mute-2)', fontSize: 16, lineHeight: 1 }}>×</button>
                  </div>

                  {!suggestion ? (
                    <div style={{ padding: '11px 12px' }}>
                      <div style={{ fontSize: 11, color: 'var(--mute)', marginBottom: 8, lineHeight: 1.45, maxHeight: 52, overflow: 'hidden' }}>
                        “{pill.text.length > 120 ? pill.text.slice(0, 120) + '…' : pill.text}”
                      </div>
                      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 9 }}>
                        {[['Improve writing', 'improve the writing and make it more professional'], ['Make concise', 'make this more concise'], ['More formal', 'make this more formal']].map(([label, instr]) => (
                          <button key={label} onClick={() => askAI(instr)} disabled={aiBusy}
                                  style={{ height: 26, padding: '0 10px', borderRadius: 999, border: '1px solid var(--line-2)', background: 'var(--bg-soft)', color: 'var(--ink-2)', fontFamily: 'inherit', fontSize: 11, fontWeight: 500, cursor: aiBusy ? 'default' : 'pointer', opacity: aiBusy ? 0.5 : 1 }}>
                            {label}
                          </button>
                        ))}
                      </div>
                      <div style={{ display: 'flex', gap: 7, alignItems: 'center' }}>
                        <input value={aiInstruction} onChange={(e) => setAiInstruction(e.target.value)}
                               onKeyDown={(e) => { if (e.key === 'Enter' && !aiBusy) { e.preventDefault(); askAI(); } }}
                               placeholder="Describe your edit…" autoFocus
                               style={{ flex: 1, height: 34, padding: '0 11px', borderRadius: 9, border: '1px solid var(--line-2)', background: 'var(--bg-card)', fontFamily: 'inherit', fontSize: 12.5, color: 'var(--ink)' }} />
                        <button onClick={() => askAI()} disabled={aiBusy}
                                style={{ width: 34, height: 34, borderRadius: 9, border: 'none', background: 'var(--maroon)', color: '#fff', cursor: aiBusy ? 'default' : 'pointer', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
                          {aiBusy
                            ? <span style={{ width: 13, height: 13, borderRadius: 999, border: '2px solid rgba(255,255,255,.4)', borderTopColor: '#fff', display: 'inline-block', animation: 'pi-spin 0.7s linear infinite' }} />
                            : <svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M8 13V4M4 8l4-4 4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>}
                        </button>
                      </div>
                    </div>
                  ) : (
                    <div>
                      <div className="pi-thin-scroll" style={{ maxHeight: 220, overflowY: 'auto', padding: 12, fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, monospace' }}>
                        <SectionDiff parts={suggestion.parts} />
                      </div>
                      <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', padding: '9px 12px', borderTop: '1px solid var(--line)', background: 'var(--bg-soft)' }}>
                        <button className="btn-secondary" onClick={rejectSuggestion} style={{ height: 30 }}>Reject</button>
                        <button className="btn-primary" onClick={acceptSuggestion} style={{ height: 30 }}>Accept</button>
                      </div>
                    </div>
                  )}
                </div>
              )}
            </div>
          </div>
        )}

        {/* Footer */}
        {activeRun && ((editable || isDone) || isAnalysis) && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 16px', borderTop: '1px solid var(--line)', flexShrink: 0 }}>
            {isAnalysis ? (
              activeRun.status === 'awaiting_plan_review' ? (
                <React.Fragment>
                  <span style={{ fontSize: 11.5, color: 'var(--mute-2)' }}>
                    Review the proposed cleaning plan below before any changes are made to your data.
                  </span>
                  <div style={{ flex: 1 }} />
                  {planFeedbackOpen ? (
                    <React.Fragment>
                      <input
                        type="text" value={planFeedback} onChange={(e) => setPlanFeedback(e.target.value)}
                        placeholder="What should change about this plan?"
                        style={{ flex: 1, maxWidth: 320, height: 34, borderRadius: 8, border: '1px solid var(--line-2)', padding: '0 10px', fontSize: 12.5 }}
                      />
                      <button className="btn-secondary" onClick={() => { setPlanFeedbackOpen(false); setPlanFeedback(''); }} disabled={rejectBusy} style={{ height: 34 }}>Cancel</button>
                      <button className="btn-primary" onClick={rejectPlan} disabled={!planFeedback.trim() || rejectBusy} style={{ height: 34, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                        {rejectBusy && <Spinner light />}Send feedback
                      </button>
                    </React.Fragment>
                  ) : (
                    <React.Fragment>
                      <button className="btn-secondary" onClick={() => setPlanFeedbackOpen(true)} disabled={approveBusy} style={{ height: 34 }}>Reject with feedback</button>
                      <button className="btn-primary" onClick={approvePlan} disabled={approveBusy} style={{ height: 34, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                        {approveBusy && <Spinner light />}Approve plan
                      </button>
                    </React.Fragment>
                  )}
                </React.Fragment>
              ) : (
                <React.Fragment>
                  <span style={{ fontSize: 11.5, color: 'var(--mute-2)' }}>
                    {activeRun.artifact_id
                      ? 'Published dataset available'
                      : (activeRun.status === 'awaiting_review' || activeRun.status === 'ready_for_review')
                        ? 'Cleaning complete — review the summary, then publish to save it to your library.'
                        : 'Review the cleaning plan, quality status, and preview before publishing'}
                  </span>
                  <div style={{ flex: 1 }} />
                  {activeRun.artifact_id ? (
                    <button className="btn-primary" onClick={downloadArtifact} style={{ height: 34 }}>
                      Download cleaned dataset
                    </button>
                  ) : (
                    <React.Fragment>
                      <button className="btn-secondary" onClick={() => publishAnalysis({ download: false })} disabled={!!publishBusy} style={{ height: 34, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                        {publishBusy === 'publish' && <Spinner />}Publish
                      </button>
                      <button className="btn-primary" onClick={() => publishAnalysis({ download: true })} disabled={!!publishBusy} style={{ height: 34, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                        {publishBusy === 'download' && <Spinner light />}Publish & Download →
                      </button>
                    </React.Fragment>
                  )}
                </React.Fragment>
              )
            ) : editable ? (
              <React.Fragment>
                <span style={{ fontSize: 11.5, color: 'var(--mute-2)' }}>
                  {saving ? 'Saving…' : dirty ? (dirtyFiles.length + ' section' + (dirtyFiles.length > 1 ? 's' : '') + ' edited') : savedAt ? 'All changes saved' : 'Editing inline'}
                </span>
                <div style={{ flex: 1 }} />
                <button className="btn-secondary" onClick={save} disabled={!dirty || saving} style={{ height: 34 }}>{saving ? 'Saving…' : 'Save'}</button>
                <ApproveRenderButton runId={runId} onClose={closePanel} />
              </React.Fragment>
            ) : (
              <React.Fragment>
                <span style={{ fontSize: 11.5, color: 'var(--mute-2)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                  <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.2 3L13 5" stroke="#2E6F40" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
                  Approved · read-only
                </span>
                <div style={{ flex: 1 }} />
                {activeRun.status === 'done' && activeRun.artifact_id && (
                  <button onClick={() => onOpenArtifact && onOpenArtifact(activeRun.artifact_id)} className="btn-primary" style={{ height: 34 }}>
                    Open final PDF →
                  </button>
                )}
              </React.Fragment>
            )}
          </div>
        )}
      </aside>
    </React.Fragment>
  );
};

// One-line human description for a single cleaning tool's summary dict. Tool
// summaries have no shared shape (dedupe_rows reports rows_before/after,
// cluster_near_duplicates reports clusters_merged, dataprep tools report
// new_columns, ...) — this renders whichever fields a given op actually has,
// rather than assuming one fixed shape (which is what silently produced
// "Rows before: n/a · Rows after: n/a" whenever the LAST-run tool in a chain
// didn't happen to report those two specific keys).
const _describeCleaningOp = (op) => {
  const name = String(op.op || 'cleaning step').replace(/_/g, ' ');
  const bits = [];
  if (op.column) bits.push(`column '${op.column}'`);
  if (Array.isArray(op.columns_changed) && op.columns_changed.length) bits.push(`columns: ${op.columns_changed.join(', ')}`);
  if (Array.isArray(op.dropped_columns) && op.dropped_columns.length) bits.push(`dropped: ${op.dropped_columns.join(', ')}`);
  if (op.clusters_merged != null) bits.push(`${op.clusters_merged} value(s) merged`);
  if (op.removed != null) bits.push(`${op.removed} row(s) removed`);
  if (op.rows_before != null && op.rows_after != null) bits.push(`${op.rows_before} → ${op.rows_after} rows`);
  if (Array.isArray(op.new_columns) && op.new_columns.length) bits.push(`added: ${op.new_columns.join(', ')}`);
  if (Array.isArray(op.operations_applied) && op.operations_applied.length) bits.push(op.operations_applied.join('; '));
  if (op.notes) bits.push(op.notes);
  return bits.length ? `${name} — ${bits.join(', ')}` : name;
};

const AnalysisReviewCard = ({ review, onDownload }) => {
  const profile = review && review.cleaning_profile ? review.cleaning_profile : {};
  const summary = review && review.cleaning_summary ? review.cleaning_summary : {};
  const operations = (review && review.cleaning_operations) || [];
  const strategies = (review && review.cleaning_plan) || [];
  const previewRows = (review && review.preview_rows) || [];
  const previewColumns = (review && review.preview_columns) || [];
  // profile.rows (the ORIGINAL row count, captured once during profiling) and
  // review.row_count (the CURRENT/latest cleaned file's row count, computed
  // server-side for the preview) are always both available regardless of
  // which cleaning tool(s) ran — unlike a single tool's self-reported
  // rows_before/rows_after, which only some tools include.
  const rowsBefore = profile.rows;
  const rowsAfter = review.row_count;

  // The published/downloaded file is named after the SOURCE (`<stem>_cleaned.<ext>`,
  // mirroring the backend's _cleaned_output_basename), so show that SAME name here
  // instead of the internal per-step working file (`cleaned.csv` / `cleaned__Sheet.csv`)
  // — the review card and the library entry then always agree.
  const _outputFileName = (() => {
    const src = (review.source_file_name || '').toString();
    let stem = src.replace(/\.[^.\\/]+$/, '').replace(/\s+/g, '_')
                  .replace(/[^0-9A-Za-z._-]+/g, '').replace(/^[._-]+|[._-]+$/g, '');
    if (!stem) {
      // no source name known — fall back to whatever the run actually produced
      return (review.latest_cleaned_file || '').split('/').pop()
             || summary.cleaned_filename || 'Not published yet';
    }
    const ext = review.input_format === 'xlsx' ? 'xlsx' : 'csv';
    return `${stem}_cleaned.${ext}`;
  })();

  return (
    <div className="pi-thin-scroll" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '22px 24px 28px' }}>
      <div style={{ width: '100%', maxWidth: 860, minWidth: 0, margin: '0 auto', display: 'grid', gap: 16 }}>
        <div style={{
          border: `1px solid ${review.artifact_id ? '#2E6F40' : 'var(--line-2)'}`,
          borderRadius: 14,
          background: review.artifact_id ? 'rgba(46, 111, 64, 0.06)' : 'var(--bg-soft)',
          padding: 18,
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
            <div style={{ fontSize: 12, color: 'var(--mute-2)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 700 }}>Cleaning Review</div>
            {review.artifact_id && (
              <span style={{
                fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
                padding: '3px 8px', borderRadius: 999,
                background: '#2E6F40', color: 'white',
                display: 'inline-flex', alignItems: 'center', gap: 4,
              }}>
                <svg width="10" height="10" viewBox="0 0 16 16" fill="none">
                  <path d="M3 8.5l3.2 3L13 5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
                Published to library
              </span>
            )}
          </div>
          <div style={{ fontSize: 20, fontWeight: 700, color: 'var(--ink)', marginBottom: 6 }}>{review.source_file_name || review.title || 'Dataset cleaning run'}</div>
          {review.question && <div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.5 }}>{review.question}</div>}
          {review.artifact_id ? (
            <div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
              <button className="btn-primary" onClick={onDownload} style={{ height: 34 }}>
                ⬇ Download cleaned dataset
              </button>
              <span style={{ fontSize: 11.5, color: 'var(--mute)' }}>
                {_outputFileName}
              </span>
            </div>
          ) : review.status === 'awaiting_plan_review' ? (
            <div style={{ marginTop: 12, fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.5 }}>
              <strong>Awaiting your approval before any cleaning happens.</strong> Review the
              suggested algorithms below, then <em>Approve plan</em> to let cleaning proceed, or
              <em> Reject with feedback</em> to have it revised.
            </div>
          ) : (review.status === 'awaiting_review' || review.status === 'ready_for_review') ? (
            <div style={{ marginTop: 12, fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.5 }}>
              <strong>Awaiting your approval.</strong> Preview the cleaned rows and cleaning summary below,
              then use the <em>Publish</em> or <em>Publish &amp; Download</em> buttons at the bottom of this
              panel to save the file to your library.
            </div>
          ) : (
            <div style={{ marginTop: 12, fontSize: 12, color: 'var(--mute)' }}>
              Not yet published — the analyst will save the cleaned file to your library when it finishes.
            </div>
          )}
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
          <MetricCard label="Rows" value={String(review.row_count || profile.rows || '0')} />
          <MetricCard label="Duplicates" value={String(profile.duplicate_rows || 0)} />
          <MetricCard label="Null columns" value={String(Object.keys(profile.null_counts || {}).length)} />
          {/* Source-based name (`<source>_cleaned.<ext>`) — matches the published
              library artifact and the Download button, not the internal per-step
              working file (`cleaned.csv` / `cleaned__Sheet.csv`). */}
          <MetricCard label="Output file" value={_outputFileName} />
        </div>

        <div style={{ border: '1px solid var(--line-2)', borderRadius: 14, background: 'var(--bg-card)', padding: 18 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', marginBottom: 10 }}>Suggested cleaning algorithms</div>
          <div style={{ display: 'grid', gap: 10 }}>
            {strategies.length ? strategies.map((item) => (
              <div key={item.id || item.label} style={{ border: '1px solid var(--line)', borderRadius: 12, padding: 12, background: 'var(--bg-soft)' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
                  <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>{item.label}</span>
                  <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '2px 7px', borderRadius: 999, background: item.risk === 'high' ? 'rgba(200,60,60,0.14)' : 'var(--maroon-10)', color: item.risk === 'high' ? '#9a3b3b' : 'var(--maroon)' }}>
                    {item.risk || 'info'}
                  </span>
                </div>
                <div style={{ fontSize: 12.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{item.why}</div>
                <div style={{ fontSize: 11.5, color: 'var(--mute)', marginTop: 6 }}>Algorithm: {item.algorithm}</div>
              </div>
            )) : <div style={{ fontSize: 12.5, color: 'var(--mute)' }}>No strategy summary was saved for this run.</div>}
          </div>
        </div>

        <div style={{ border: '1px solid var(--line-2)', borderRadius: 14, background: 'var(--bg-card)', padding: 18 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', marginBottom: 10 }}>Applied cleaning summary</div>
          <div style={{ fontSize: 12.5, color: 'var(--ink-2)', lineHeight: 1.6 }}>
            Rows before: {rowsBefore == null ? 'n/a' : rowsBefore} · Rows after: {rowsAfter == null ? 'n/a' : rowsAfter}
          </div>
          <div style={{ marginTop: 8, display: 'grid', gap: 6 }}>
            {operations.length ? operations.map((op, idx) => (
              <div key={idx} style={{ fontSize: 12.5, color: 'var(--ink)' }}>• {_describeCleaningOp(op)}</div>
            )) : (
              <div style={{ fontSize: 12.5, color: 'var(--mute)' }}>No cleaning operations recorded yet.</div>
            )}
          </div>
        </div>

        <div style={{ border: '1px solid var(--line-2)', borderRadius: 14, background: 'var(--bg-card)', padding: 18, minWidth: 0 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', marginBottom: 10 }}>Preview of cleaned data</div>
          <div className="pi-thin-scroll" style={{ overflowX: 'auto', maxWidth: '100%' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
              <thead>
                <tr>
                  {previewColumns.map((col) => (
                    <th key={col} style={{ textAlign: 'left', padding: '8px 10px', borderBottom: '1px solid var(--line)', color: 'var(--ink-2)' }}>{col}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {previewRows.map((row, idx) => (
                  <tr key={idx}>
                    {previewColumns.map((col) => (
                      <td key={col} style={{ padding: '8px 10px', borderBottom: '1px solid var(--line)', color: 'var(--ink)' }}>{String(row[col] == null ? '' : row[col])}</td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      </div>
    </div>
  );
};

// Inline button spinner (uses the app-wide `pi-spin` keyframe). `light` for use
// on a filled/maroon button (white text), default for a light/secondary button.
const Spinner = ({ light }) => (
  <span aria-hidden style={{
    width: 13, height: 13, borderRadius: 999, flexShrink: 0, display: 'inline-block',
    border: light ? '2px solid rgba(255,255,255,0.45)' : '2px solid var(--line-2)',
    borderTopColor: light ? '#fff' : 'var(--maroon)',
    animation: 'pi-spin 0.7s linear infinite',
  }} />
);

const MetricCard = ({ label, value }) => (
  <div style={{ border: '1px solid var(--line-2)', borderRadius: 14, background: 'var(--bg-card)', padding: 16 }}>
    <div style={{ fontSize: 11, color: 'var(--mute-2)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 700, marginBottom: 6 }}>{label}</div>
    <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.2 }}>{value}</div>
  </div>
);

// Whole-report WYSIWYG preview — the SAME renderer the PDF uses. Used for
// finished (read-only) reports and the editable Preview tab. `refreshKey` bumps on save/add.
const ReviewPreview = ({ runId, refreshKey }) => {
  const [html, setHtml] = React.useState('');
  const [loading, setLoading] = React.useState(true);
  const frameRef = React.useRef(null);
  React.useEffect(() => {
    if (!runId) { setLoading(false); return; }
    let cancelled = false;
    setLoading(true);
    PrimusAPI.previewReport(runId)
      .then((res) => { if (!cancelled) setHtml((res && res.html) || ''); })
      .catch(() => { if (!cancelled) setHtml('<p style="color:#9a3b3b;font-family:sans-serif">Preview failed.</p>'); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [runId, refreshKey]);

  // srcDoc documents inherit the PARENT's base URL, so the report's internal
  // TOC anchors (href="#section-x") resolve to the app URL and navigate the
  // iframe INTO the app (the "screen blows up"). Intercept: same-document
  // anchors scroll inside the iframe; external links open a new tab.
  const wireLinks = () => {
    const doc = frameRef.current && frameRef.current.contentDocument;
    if (!doc) return;
    doc.addEventListener('click', (e) => {
      const a = e.target && e.target.closest && e.target.closest('a[href]');
      if (!a) return;
      const href = a.getAttribute('href') || '';
      if (href.startsWith('#')) {
        e.preventDefault();
        const id = href.slice(1);
        const target = doc.getElementById(id) ||
          doc.querySelector(`[name="${CSS.escape ? CSS.escape(id) : id}"]`);
        if (target && typeof target.scrollIntoView === 'function') {
          target.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
      } else {
        e.preventDefault();
        window.open(a.href, '_blank', 'noopener');
      }
    });
  };

  return (
    <div style={{ height: '100%', position: 'relative', border: '1px solid var(--line-2)', borderRadius: 8, overflow: 'hidden' }}>
      {loading && <div style={{ position: 'absolute', top: 8, right: 10, fontSize: 11, color: 'var(--mute)' }}>rendering…</div>}
      <iframe ref={frameRef} title="report-preview" srcDoc={html} onLoad={wireLinks}
              style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }} />
    </div>
  );
};

const ApproveRenderButton = ({ runId, onClose }) => {
  const [busy, setBusy] = React.useState(false);
  const approve = async () => {
    if (!window.confirm("Approve and render the final report? You won't be able to edit after this.")) return;
    setBusy(true);
    try {
      await PrimusAPI.approveReportRender(runId);
      window.alert("Rendering started — you'll be notified when the final report is ready.");
      onClose();
    } catch (e) { window.alert((e && e.message) || 'Could not start rendering.'); setBusy(false); }
  };
  return (
    <button className="btn-primary" onClick={approve} disabled={busy} style={{ height: 34 }}>
      {busy ? 'Starting…' : 'Approve & Render →'}
    </button>
  );
};

const SectionDiff = ({ parts }) => (
  <span>
    {(parts || []).map((p, i) => {
      if (p.added)   return <span key={i} style={{ background: 'rgba(64,160,64,0.18)', color: '#1f6f2f' }}>{p.value}</span>;
      if (p.removed) return <span key={i} style={{ background: 'rgba(200,60,60,0.16)', color: '#9a3b3b', textDecoration: 'line-through' }}>{p.value}</span>;
      return <span key={i} style={{ color: 'var(--ink)' }}>{p.value}</span>;
    })}
  </span>
);
