/* Processing view — v2.29.0 redesign.
 *
 * Two-column work area:
 *   • main column — analysis pipeline stepper, essay preview, live activity log
 *   • right pane — per-source rows with status pills + inline Upload affordance,
 *                  plus a "Sources needing help" cluster
 *
 * Sticky footer carries the safe-to-leave reassurance, the notify-on-complete
 * affordance, and Pause / Cancel run controls.
 *
 * Polling: GET /argus/api/status/:id every 2s.
 *   Shape (post-v2.29.0 — older origins gracefully degrade):
 *     {
 *       state, stage, progress,
 *       started_at, assignment_name, file_name,
 *       refs: [{ id, idx, label, first_author, year, doi, mentions,
 *                status, verification_status, engagement_depth, resolution, via }],
 *       control: 'running' | 'paused' | 'cancelling',
 *       notify:  { email, channel } | null,
 *       queue_position?, queue_eta_seconds?, friendly_reason?
 *     }
 *
 * Same external contract as before — { jobId, onDone, onFail }.
 */

const { useState: usePS, useEffect: usePE, useRef: usePR, useMemo: usePM } = React;

const STAGES = [
  { key: "ingest_essay",        label: "Essay uploaded",     short: "uploaded" },
  { key: "extract_text",        label: "Text extracted",     short: "extracted" },
  { key: "extract_references",  label: "References detected", short: "detected" },
  { key: "resolve_sources",     label: "Resolving sources",  short: "resolving" },
  { key: "verify_citations",    label: "Analysing claims",   short: "analysing" },
  { key: "compile_report",      label: "Compiling report",   short: "compiling" },
];

/* Map a backend stage key to one of the six display stages above. The
   pipeline has several finer-grained stages (detect_style, align_pages,
   etc.); we fold them into the nearest display step so the stepper
   doesn't show invisible micro-progress. */
const STAGE_FOLD = {
  ingest_essay:        "ingest_essay",
  extract_text:        "extract_text",
  extract_references:  "extract_references",
  detect_style:        "extract_references",
  detect_discipline:   "extract_references",
  resolve_sources:     "resolve_sources",
  download_sources:    "resolve_sources",
  extract_source_text: "resolve_sources",
  align_pages:         "resolve_sources",
  grade_essay:         "verify_citations",
  verify_citations:    "verify_citations",
  find_unattributed:   "verify_citations",
  consolidate:         "compile_report",
  compile_report:      "compile_report",
};

const SOURCE_TABS = [
  { key: "all",     label: "All"     },
  { key: "resolved", label: "Resolved" },
  { key: "needs",   label: "Needs attention" },
  { key: "pending", label: "Pending" },
];

const STATUS_TONE = {
  resolved:        { tone: "good", label: "Resolved" },
  analysing:       { tone: "accent", label: "Analysing claims" },
  needs_attention: { tone: "warn", label: "Needs attention" },
  pending:         { tone: "ink-4", label: "Pending" },
};

/* ────────────────────────────────────────────────────────────────── */

function ViewProcessing({ jobId, onDone, onFail }) {
  const [status, setStatus]   = usePS({ state: "queued", stage: null, progress: {}, refs: [] });
  const [elapsed, setElapsed] = usePS(0);
  const [tab, setTab]         = usePS("all");
  const [activity, setActivity] = usePS([]);   // synthesised event log
  const [activityOpen, setActivityOpen] = usePS(false);
  const [essayText, setEssayText] = usePS("");
  const [busy, setBusy]       = usePS({});     // per-action busy flags
  const [notifySent, setNotifySent] = usePS(false);
  const start = usePR(Date.now());
  const lastStages = usePR({});

  /* ── poll loop ──────────────────────────────────────────────── */
  usePE(() => {
    let cancel = false;
    let timer;
    const tick = async () => {
      const r = await Api.status(jobId);
      if (cancel) return;
      if (!r.ok) {
        timer = setTimeout(tick, 4000);
        return;
      }
      const s = r.data || {};
      setStatus(s);
      pushActivityFromStages(s.stages || {}, s);
      if (s.state === "failed") {
        onFail(jobId, s.friendly_reason || "Pipeline halted before a report could be compiled.");
        return;
      }
      if (s.state === "completed") {
        // Opportunistic notify dispatch — only fires when an email is
        // registered; backend short-circuits otherwise.
        if (s.notify && s.notify.email && !notifySent) {
          setNotifySent(true);
          Api.dispatchNotify(jobId).catch(() => {});
        }
        // Hand off to the workspace.
        onDone(jobId);
        return;
      }
      timer = setTimeout(tick, 2000);
    };
    tick();
    const iv = setInterval(
      () => setElapsed(Math.floor((Date.now() - start.current) / 1000)),
      1000,
    );
    return () => { cancel = true; clearTimeout(timer); clearInterval(iv); };
  }, [jobId]);

  /* ── essay preview — fetched once extract_text completes ────── */
  usePE(() => {
    if (essayText) return;
    const stages = status.stages || {};
    if ((stages.extract_text || {}).status !== "completed") return;
    let cancel = false;
    (async () => {
      try {
        const r = await Api.report(jobId);
        if (cancel) return;
        if (r.ok && r.data && typeof r.data.essay_text === "string") {
          setEssayText(r.data.essay_text);
        }
      } catch {/* not yet available; pipeline hasn't published essay text */}
    })();
    return () => { cancel = true; };
  }, [status, essayText, jobId]);

  /* ── synthesise an event log from stage state transitions ───── */
  function pushActivityFromStages(stages, snap) {
    setActivity((prev) => {
      const out = prev.slice();
      const seen = new Set(prev.map((e) => e.key));
      for (const st of STAGES) {
        const row = stages[st.key];
        if (!row) continue;
        const seenKey = `${st.key}:${row.status}`;
        if (seen.has(seenKey)) continue;
        if (row.status === "running" || row.status === "completed") {
          out.push({
            key: seenKey,
            stage: st.key,
            label: row.status === "completed" ? `${st.label}` : `${st.label}…`,
            time: timeOnly(row.completed_at || row.started_at || new Date().toISOString()),
            tone: row.status === "completed" ? "good" : "accent",
          });
        }
      }
      // also fold in compile_report finalisation and verify activity
      const refsFound = snap?.progress?.refs_found || 0;
      if (refsFound > 0 && !seen.has(`refs_found:${refsFound}`)) {
        out.push({
          key: `refs_found:${refsFound}`,
          stage: "extract_references",
          label: `${refsFound} references detected`,
          time: timeOnly(new Date().toISOString()),
          tone: "good",
        });
      }
      return out.slice(-50);
    });
    lastStages.current = stages;
  }

  /* ── controls ───────────────────────────────────────────────── */
  const control = status.control || "running";
  const paused  = control === "paused";
  const cancelling = control === "cancelling";

  async function onPauseToggle() {
    setBusy((b) => ({ ...b, control: true }));
    const r = paused ? await Api.resumeJob(jobId) : await Api.pauseJob(jobId);
    setBusy((b) => ({ ...b, control: false }));
    if (r.ok) {
      setStatus((s) => ({ ...s, control: paused ? "running" : "paused" }));
    }
  }

  async function onCancel() {
    if (!window.confirm("Cancel this analysis? Sources already retrieved stay cached.")) return;
    setBusy((b) => ({ ...b, cancel: true }));
    const r = await Api.cancelJob(jobId);
    setBusy((b) => ({ ...b, cancel: false }));
    if (r.ok) {
      setStatus((s) => ({ ...s, control: "cancelling" }));
    }
  }

  async function onNotify() {
    // Ask the browser for permission, register the intent (so any
    // device polling the job can email-deliver it), and store an
    // intent locally for the in-tab Notification API trigger.
    if (window.Notification && Notification.permission === "default") {
      try { await Notification.requestPermission(); } catch {/* ignored */}
    }
    setBusy((b) => ({ ...b, notify: true }));
    // We don't yet know an email — backend accepts empty string and
    // treats the intent as "browser only". A future ProfileSettings
    // value could fill this in.
    const r = await Api.notifyJob(jobId, { email: "", channel: "browser" });
    setBusy((b) => ({ ...b, notify: false }));
    if (r.ok) {
      setStatus((s) => ({ ...s, notify: { email: "", channel: "browser" } }));
    }
  }

  /* ── header values ──────────────────────────────────────────── */
  const filename = status.file_name || status.assignment_name || jobId.slice(0, 8);
  const refsFound = status.progress?.refs_found || 0;
  const totalSources = (status.refs || []).length || refsFound;

  /* ── stepper computation ────────────────────────────────────── */
  const foldedStage = STAGE_FOLD[status.stage] || status.stage;
  const currentStepIdx = Math.max(0, STAGES.findIndex((s) => s.key === foldedStage));

  /* ── source filter ──────────────────────────────────────────── */
  const refs = Array.isArray(status.refs) ? status.refs : [];
  const counts = usePM(() => ({
    all:      refs.length,
    resolved: refs.filter((r) => r.status === "resolved").length,
    needs:    refs.filter((r) => r.status === "needs_attention").length,
    pending:  refs.filter((r) => r.status === "pending" || r.status === "analysing").length,
  }), [refs]);

  const filteredRefs = refs.filter((r) => {
    if (tab === "all")     return true;
    if (tab === "resolved") return r.status === "resolved";
    if (tab === "needs")   return r.status === "needs_attention";
    if (tab === "pending") return r.status === "pending" || r.status === "analysing";
    return true;
  });
  const needsHelp = refs.filter((r) => r.status === "needs_attention");

  return (
    <div className="proc-shell">
      {/* ── HEADER ─────────────────────────────────────────────── */}
      <header className="proc-head">
        <div className="proc-head-main">
          <h1 className="proc-title">
            {status.assignment_name
              ? <>{status.assignment_name} <span className="proc-title-sep">—</span> <span className="proc-title-file">{filename}</span></>
              : <span className="proc-title-file">{filename}</span>}
          </h1>
          <p className="proc-sub">
            Submitted {formatSubmittedAt(status.started_at)}
            {status.progress?.words ? <> · {status.progress.words.toLocaleString()} words</> : null}
            {refsFound > 0 ? <> · {refsFound} references detected</> : null}
          </p>
        </div>
        <div className="proc-head-side">
          <span className="proc-state-pill">
            <span className="proc-state-dot"/>
            {cancelling ? "Cancelling…" : paused ? "Paused" : "Analysis in progress"}
          </span>
          <button className="btn ghost sm" onClick={() => onDone(jobId)}>
            Continue in background
          </button>
        </div>
      </header>

      {/* ── BANNER ─────────────────────────────────────────────── */}
      <section className="proc-banner">
        <div>
          <p className="proc-banner-title">
            Argus is verifying the bibliography and analysing citation claims.
          </p>
          <p className="proc-banner-body">
            You can leave this page — we’ll continue in the background and notify you when the report is ready.
          </p>
        </div>
        <div className="proc-banner-actions">
          {status.notify
            ? <span className="proc-notify-on"><Icon.Bell size={12}/> You’ll be notified</span>
            : <button className="btn ghost sm" onClick={onNotify} disabled={busy.notify}>
                <Icon.Bell size={12}/> Notify me
              </button>}
        </div>
      </section>

      {/* ── BODY: two columns ──────────────────────────────────── */}
      <div className="proc-body">
        <div className="proc-main">

          {/* ── PIPELINE STEPPER ─────────────────────────────── */}
          <section className="card">
            <div className="card-head">
              <span className="card-title">Analysis pipeline</span>
              <span className="card-sub mono">
                Elapsed {fmtElapsed(elapsed)}
                {currentStepIdx < STAGES.length - 1
                  ? <> · Est. remaining {estRemaining(elapsed, currentStepIdx)}</>
                  : null}
              </span>
            </div>
            <ol className="proc-stepper">
              {STAGES.map((st, i) => {
                const state = i < currentStepIdx ? "done"
                            : i === currentStepIdx ? (paused ? "paused" : "running")
                            : "pending";
                return (
                  <li key={st.key} className={`step is-${state}`}>
                    <span className="step-marker">
                      {state === "done" && <Icon.Check size={10}/>}
                      {state === "running" && <span className="step-pulse"/>}
                    </span>
                    <span className="step-label">{st.label}</span>
                    <span className="step-meta">
                      {state === "done"     ? "Completed" :
                       state === "running"  ? (paused ? "Paused" : "In progress") :
                                              "Pending"}
                    </span>
                  </li>
                );
              })}
            </ol>
          </section>

          {/* ── ESSAY PREVIEW ────────────────────────────────── */}
          <section className="card">
            <div className="card-head">
              <span className="card-title">Essay preview</span>
              <span className="card-sub mono">
                {essayText ? "Text extracted" : "Extracting…"}
                {status.progress?.words ? ` · ${status.progress.words.toLocaleString()} words` : ""}
              </span>
            </div>
            <div className="proc-essay">
              {essayText
                ? essayText.split(/\n\n+/).slice(0, 4).map((p, i) =>
                    <p key={i} className="proc-essay-p">{p.slice(0, 600)}{p.length > 600 ? "…" : ""}</p>)
                : <p className="proc-essay-skeleton">The essay text will appear here once the extractor finishes.</p>}
            </div>
          </section>

          {/* ── FINDINGS READY (early enter) ─────────────────── */}
          <FindingsReadyCard
              verified={status.progress?.refs_verified || 0}
              total={refsFound}
              state={status.state}
              onReview={() => onDone(jobId)}/>

          {/* ── LIVE ACTIVITY LOG (collapsible) ──────────────── */}
          <section className="card">
            <button className="card-head card-head-toggle"
                    onClick={() => setActivityOpen((o) => !o)}
                    aria-expanded={activityOpen}>
              <span className="card-title">Live activity</span>
              <span className="card-sub mono">
                {activity.length} event{activity.length === 1 ? "" : "s"}
                <span className="card-caret" aria-hidden>
                  {activityOpen ? "▾" : "▸"}
                </span>
              </span>
            </button>
            {activityOpen && (
              <ul className="proc-activity">
                {activity.slice().reverse().map((e) => (
                  <li key={e.key}>
                    <span className={`activity-dot tone-${e.tone}`}/>
                    <span className="activity-time mono">{e.time}</span>
                    <span className="activity-label">{e.label}</span>
                  </li>
                ))}
                {!activity.length && (
                  <li className="activity-empty">Pipeline events will appear here as they happen.</li>
                )}
              </ul>
            )}
          </section>

        </div>

        {/* ── RIGHT PANE: SOURCES ──────────────────────────────── */}
        <aside className="proc-right">
          <section className="card">
            <div className="card-head">
              <span className="card-title">Sources {totalSources ? `(${totalSources})` : ""}</span>
            </div>
            <div className="proc-tabs">
              {SOURCE_TABS.map((t) => (
                <button key={t.key}
                        className={`proc-tab ${tab === t.key ? "is-active" : ""}`}
                        onClick={() => setTab(t.key)}>
                  {t.label}
                  {(t.key === "all" || counts[t.key] > 0) && (
                    <span className="proc-tab-count">{counts[t.key]}</span>
                  )}
                </button>
              ))}
            </div>
            <ul className="proc-sources">
              {filteredRefs.length === 0 && (
                <li className="proc-source-empty">
                  {refs.length === 0
                    ? "Bibliography hasn’t been parsed yet — sources will appear as soon as references are detected."
                    : "Nothing in this filter right now."}
                </li>
              )}
              {filteredRefs.map((r) => (
                <SourceRow key={r.id} cite={r} jobId={jobId}
                           onSupplemented={onSupplemented} onRetry={onRetryRef}/>
              ))}
            </ul>
          </section>

          {needsHelp.length > 0 && (
            <section className="card needs-help">
              <div className="card-head">
                <span className="card-title">Sources needing help ({needsHelp.length})</span>
                <span className="card-sub">Argus couldn’t retrieve these. Uploading a PDF rescues each one.</span>
              </div>
              <ul className="proc-sources is-help">
                {needsHelp.map((r) => (
                  <SourceRow key={r.id} cite={r} jobId={jobId} compact
                             onSupplemented={onSupplemented} onRetry={onRetryRef}/>
                ))}
              </ul>
            </section>
          )}
        </aside>
      </div>

      {/* ── FOOTER ─────────────────────────────────────────────── */}
      <footer className="proc-foot">
        <div className="proc-foot-left">
          <span className="proc-foot-item">
            <Icon.Check size={12}/>
            <strong>Safe to leave</strong>
            <span className="ink-4">We’ll continue analysing in the right panel.</span>
          </span>
          <span className="proc-foot-item">
            <Icon.Bell size={12}/>
            {status.notify
              ? <><strong>You’ll be notified</strong> <span className="ink-4">when the report is ready.</span></>
              : <><strong>You’ll be notified</strong> <span className="ink-4">enable from above.</span></>}
          </span>
        </div>
        <div className="proc-foot-right">
          <span className="proc-foot-label mono">Control run</span>
          <button className="btn ghost sm" onClick={onPauseToggle}
                  disabled={cancelling || busy.control}>
            {paused
              ? <><Icon.Refresh size={11}/> Resume analysis</>
              : <><Icon.Pause size={11}/> Pause analysis</>}
          </button>
          <button className="proc-foot-cancel" onClick={onCancel}
                  disabled={cancelling || busy.cancel}>
            {cancelling ? "Cancelling…" : "Cancel run"}
          </button>
        </div>
      </footer>
    </div>
  );

  function onSupplemented(refIdx) {
    // Locally optimistic: bump the ref's status. The next poll will
    // reconcile against the real verification outcome.
    setStatus((s) => {
      const refs = Array.isArray(s.refs) ? s.refs.slice() : [];
      for (const r of refs) {
        if (Number(r.idx) === Number(refIdx)) r.status = "analysing";
      }
      return { ...s, refs };
    });
  }

  function onRetryRef(refIdx) {
    // Re-enqueued through the companion lane. Show the row as pending
    // again until the next status tick reconciles.
    setStatus((s) => {
      const refs = Array.isArray(s.refs) ? s.refs.slice() : [];
      for (const r of refs) {
        if (Number(r.idx) === Number(refIdx)) r.status = "pending";
      }
      return { ...s, refs };
    });
  }
}

/* ─────────── findings-ready card ─────────────────────────────
 * Early-enter affordance — once verify_citations has produced at
 * least one verdict, surface a "review X of Y" card so the user can
 * jump to the workspace without waiting for the full report.
 */
function FindingsReadyCard({ verified, total, state, onReview }) {
  if (!total || state === "completed") return null;
  if (verified === 0) return null;
  return (
    <section className="card findings-ready" onClick={onReview} role="button" tabIndex={0}
             onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onReview()}>
      <div className="findings-ready-main">
        <div>
          <div className="findings-ready-eyebrow mono">Findings becoming available</div>
          <div className="findings-ready-headline">
            <strong>{verified}</strong> of {total} findings ready for review
          </div>
          <div className="findings-ready-sub">
            You can start reading now — the rest will appear as Argus finishes them.
          </div>
        </div>
        <div className="findings-ready-cta mono">
          Review now <span aria-hidden>→</span>
        </div>
      </div>
      <div className="findings-ready-bar">
        <span style={{width: `${Math.min(100, Math.round((verified / total) * 100))}%`}}/>
      </div>
    </section>
  );
}

/* ─────────── per-source row ───────────────────────────────────
 * Live source card during processing. Surfaces:
 *   - label + status pill
 *   - mentions + source-level + retrieval confidence
 *   - in-flight progress (analysing claims …)
 *   - action set keyed off status:
 *       needs_attention → Upload source · Retry
 *       resolved        → Open · Replace
 *       analysing       → (passive — just the progress bar)
 *       pending         → (passive — waiting in queue)
 */
function SourceRow({ cite, jobId, compact, onSupplemented, onRetry }) {
  const tone = STATUS_TONE[cite.status] || STATUS_TONE.pending;
  const labelText = cite.label || cite.first_author || "Reference";
  const showUpload = cite.status === "needs_attention";
  const showRetry  = cite.status === "needs_attention";
  const showProgress = cite.status === "analysing";
  const showReplace = cite.status === "resolved" && !compact;
  const showOpen    = cite.status === "resolved" && !compact && cite.via;
  const helpReason  = compact ? helpReasonFor(cite) : null;
  const confidence  = retrievalConfidence(cite);

  return (
    <li className={`proc-source ${compact ? "is-compact" : ""}`}>
      <div className="proc-source-head">
        <span className="proc-source-label">{labelText}</span>
        <span className={`pill tone-${tone.tone}`}>{tone.label}</span>
      </div>
      {!compact && (
        <div className="proc-source-meta mono">
          {cite.mentions > 0 && <>Mentions: {cite.mentions}</>}
          {cite.via && <> · Source level: {sourceLevelLabel(cite)}</>}
          {confidence && <> · <span className={`confidence tone-${confidence.tone}`}>{confidence.label}</span></>}
        </div>
      )}
      {compact && helpReason && (
        <div className="proc-source-meta">
          <span className="proc-source-issue">{helpReason}</span>
        </div>
      )}
      {showProgress && (
        <div className="proc-source-progress">
          <span className="proc-source-progress-label">Analysing claims…</span>
          <span className="proc-source-progress-bar"><span/></span>
        </div>
      )}
      {(showUpload || showRetry || showReplace || showOpen) && (
        <div className="proc-source-actions">
          {showUpload && (
            <UploadAffordance jobId={jobId} refIdx={cite.idx} reason={uploadReason(cite)}
                              compact={compact}
                              onUploaded={() => onSupplemented && onSupplemented(cite.idx)}/>
          )}
          {showRetry && Number.isFinite(Number(cite.idx)) && (
            <RetrySourceButton jobId={jobId} refIdx={cite.idx}
                               onRetried={() => onRetry && onRetry(cite.idx)}/>
          )}
          {showReplace && (
            <UploadAffordance jobId={jobId} refIdx={cite.idx}
                              label="Replace source"
                              variant="ghost"
                              reason={null}
                              compact={true}
                              onUploaded={() => onSupplemented && onSupplemented(cite.idx)}/>
          )}
          {showOpen && (
            <span className="upload-reason ink-4">via {cite.via}</span>
          )}
        </div>
      )}
    </li>
  );
}

/* ─────────── retry button — re-enqueues a companion-lane fetch ─ */
function RetrySourceButton({ jobId, refIdx, onRetried }) {
  const [busy, setBusy] = usePS(false);
  const [done, setDone] = usePS(false);
  async function go() {
    setBusy(true);
    let r = { ok: false };
    if (Api.retryClientFetch) {
      r = await Api.retryClientFetch(jobId, refIdx);
    }
    setBusy(false);
    if (r.ok || r.error === "already_queued") {
      setDone(true);
      if (onRetried) onRetried();
      setTimeout(() => setDone(false), 3000);
    }
  }
  return (
    <button className="btn ghost sm" onClick={go} disabled={busy || done}>
      <Icon.Refresh size={11}/> {busy ? "Retrying…" : done ? "Queued" : "Retry"}
    </button>
  );
}

/* ─────────── upload affordance (shared) ────────────────────────
 *
 * Used by:
 *   • the processing view's per-source rows
 *   • the workspace Inspector for any unverified pillState
 *   • the workspace source-text pane's NoExcerptState
 *
 * Lifecycle:
 *   pick file → POST /supplement → backend returns 202 + supplement_job_id
 *   → poll /supplement-status/:sid until state==completed | failed
 *   → on completion, call onUploaded so the parent can refresh.
 */
function UploadAffordance({ jobId, refIdx, reason, onUploaded, compact, label, variant }) {
  const [busy, setBusy]     = usePS(false);
  const [polling, setPolling] = usePS(false);
  const [error, setError]   = usePS(null);
  const [done, setDone]     = usePS(false);
  const inputRef = usePR(null);

  async function pollUntilDone(supplementJobId) {
    if (!supplementJobId || !Api.supplementStatus) {
      setDone(true);
      setPolling(false);
      if (onUploaded) onUploaded({});
      return;
    }
    setPolling(true);
    const started = Date.now();
    for (;;) {
      const r = await Api.supplementStatus(supplementJobId);
      const s = (r.ok && r.data && r.data.state) || "running";
      if (s === "completed") {
        setDone(true); setPolling(false);
        if (onUploaded) onUploaded(r.data);
        return;
      }
      if (s === "failed") {
        setError((r.data && r.data.message) || "Re-verification failed.");
        setPolling(false);
        return;
      }
      if (Date.now() - started > 5 * 60 * 1000) {
        setError("Timed out waiting for re-verification.");
        setPolling(false);
        return;
      }
      await new Promise((res) => setTimeout(res, 2000));
    }
  }

  async function onPick(e) {
    const file = e.target.files && e.target.files[0];
    if (!file || !jobId || !Number.isFinite(Number(refIdx))) {
      setError("Missing job or reference index.");
      return;
    }
    setBusy(true); setError(null);
    const fd = new FormData();
    fd.append("file", file);
    const r = await Api.supplement(jobId, refIdx, fd);
    setBusy(false);
    if (inputRef.current) inputRef.current.value = "";
    if (r.ok && r.status === 202 && r.data && r.data.supplement_job_id) {
      await pollUntilDone(r.data.supplement_job_id);
      return;
    }
    if (r.ok) {
      // Legacy synchronous path (pre-v2.12.12 backend).
      setDone(true);
      if (onUploaded) onUploaded(r.data || {});
      return;
    }
    setError((r.data && r.data.message) || r.error || "Upload failed.");
  }

  if (done) {
    return (
      <div className="upload-ok mono">
        <Icon.Check size={11}/> Source uploaded and re-verified.
      </div>
    );
  }

  const btnClass = `btn ${variant === "ghost" ? "ghost" : "primary"} sm`;
  const idleLabel = label || "Upload source";
  return (
    <div className={`upload-affordance ${compact ? "is-compact" : ""}`}>
      <label className={btnClass}>
        <input ref={inputRef} type="file" accept="application/pdf,.pdf,.epub,.mobi,.azw,.azw3"
               style={{display: "none"}} onChange={onPick} disabled={busy || polling}/>
        <Icon.Upload size={11}/>
        {busy ? "Uploading…" : polling ? "Re-verifying…" : idleLabel}
      </label>
      {reason && !compact && <span className="upload-reason ink-4">{reason}</span>}
      {error && <span className="upload-err">{error}</span>}
    </div>
  );
}

/* ─────────── helpers ─────────────────────────────────────────── */
function fmtElapsed(s) {
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${String(m).padStart(2, "0")}m ${String(r).padStart(2, "0")}s`;
}

function estRemaining(elapsed, idx) {
  // Crude — typical Argus run is 240–360s. Map by step index.
  const remaining = Math.max(60, 360 - elapsed);
  const mins = Math.ceil(remaining / 60);
  return `${Math.max(1, mins - 1)}–${mins + 2} min`;
}

function formatSubmittedAt(iso) {
  if (!iso) return "just now";
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return "just now";
    return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
  } catch { return "just now"; }
}

function timeOnly(iso) {
  if (!iso) return "";
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return "";
    return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
  } catch { return ""; }
}

function sourceLevelLabel(cite) {
  const vs = cite.verification_status;
  if (vs === "verified_full")     return "Available";
  if (vs === "verified_abstract") return "Abstract";
  if (vs === "training_fallback") return "Not retrieved";
  if (vs === "not_resolved")      return "Not found";
  if (vs === "source_mismatch")   return "Wrong work";
  if (vs === "auth_required")     return "Auth-walled";
  if (cite.resolution === "resolved") return "Retrieving";
  if (cite.resolution === "pending")  return "Pending";
  return "—";
}

/* Human-readable problem statement for the "Sources needing help"
   cluster. Mirrors the mockup's two-line row format. */
function helpReasonFor(cite) {
  const vs = cite.verification_status;
  if (vs === "not_resolved")      return "Source not found";
  if (vs === "training_fallback") return "Source text incomplete";
  if (vs === "verified_abstract") return "Only abstract available";
  if (vs === "auth_required")     return "Auth-walled — sign-in needed";
  if (vs === "source_mismatch")   return "Wrong work retrieved";
  if (vs === "attribution_concern") return "Attribution concern flagged";
  return "Needs attention";
}

/* Map the resolution lane (via) into a confidence tier so the user
   can tell a library-confirmed retrieval apart from a rescue-tier
   one. Lane names match argus-backend/argus_sa strategies. */
function retrievalConfidence(cite) {
  if (!cite || cite.status !== "resolved") return null;
  const via = String(cite.via || "").toLowerCase();
  if (!via) return null;
  // High: institutional library, official publisher APIs.
  if (via.includes("library")        ||
      via.includes("elsevier_api")   ||
      via.includes("wiley_tdm")      ||
      via.includes("springer_full")  ||
      via.includes("springer_openaccess")) {
    return { tone: "good", label: "High confidence" };
  }
  // Medium: open-access lanes, DOI resolution, institutional auth.
  if (via.includes("oa_pdf")     || via.includes("unpaywall") ||
      via.includes("publisher_landing") || via.includes("core") ||
      via.includes("doi") || via.includes("s2_oa")) {
    return { tone: "accent", label: "Medium confidence" };
  }
  // Low: rescue lanes (Anna's, Wayback, scidb, last-shot Chrome).
  if (via.includes("annas") || via.includes("wayback") ||
      via.includes("scidb") || via.includes("rescue") ||
      via.includes("libgen")) {
    return { tone: "warn", label: "Rescue lane" };
  }
  return null;
}

function uploadReason(cite) {
  const vs = cite.verification_status;
  if (vs === "not_resolved")    return "Source not found.";
  if (vs === "training_fallback") return "Source not retrieved.";
  if (vs === "verified_abstract") return "Only the abstract is available.";
  if (vs === "auth_required")    return "Auth-walled — sign-in needed.";
  if (vs === "source_mismatch")  return "Retrieved file rejected.";
  return "Argus needs a source for this reference.";
}

/* ─────────── failure view (unchanged contract) ───────────────── */
function ViewFailure({ jobId, message, onRetry, onHome }) {
  return (
    <div className="surface-pad">
      <h1>The run didn't complete.</h1>
      <p className="sub">job · <span className="mono">{(jobId || "").slice(0, 8)}</span></p>
      <div className="proc">
        <p style={{ fontFamily: "var(--font-serif)", color: "var(--ink-2)", fontSize: 14 }}>
          {message || "Something in the pipeline halted before a report could be compiled. The submission has been kept; try resubmitting."}
        </p>
        <div style={{ display: "flex", gap: 10, marginTop: 14 }}>
          <button className="btn ghost" onClick={onHome}>Back to upload</button>
          <button className="btn primary" onClick={onRetry}>Try again</button>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { ViewProcessing, ViewFailure, UploadAffordance });
