// Admin ASR Correction tab — manage deterministic typo correction rules
// (wrong→correct, global / per-show), preview match count before saving, and
// trigger a backfill (dry-run preview → confirm → run). Part of EQ2a.
const AdminAsrCorrectionTab = ({ lang }) => {
  const t = lang === 'zh';
  const [rows, setRows] = React.useState(null);
  const [shows, setShows] = React.useState([]);
  const [error, setError] = React.useState(null);
  const [toast, setToast] = React.useState(null);

  // new-rule form
  const [wrong, setWrong] = React.useState('');
  const [correct, setCorrect] = React.useState('');
  const [scope, setScope] = React.useState('show');
  const [showId, setShowId] = React.useState('');
  const [note, setNote] = React.useState('');
  const [adding, setAdding] = React.useState(false);
  const [matchCount, setMatchCount] = React.useState(null);

  // backfill
  const [backfillScope, setBackfillScope] = React.useState(''); // '' = all shows
  const [preview, setPreview] = React.useState(null);
  const [previewing, setPreviewing] = React.useState(false);
  const [backfilling, setBackfilling] = React.useState(false);

  // EQ2e F6: detect over a show's existing episodes (dry-run estimate → confirm)
  const [detectScope, setDetectScope] = React.useState(''); // show_id (required)
  const [detectEstimate, setDetectEstimate] = React.useState(null);
  const [detectEstimating, setDetectEstimating] = React.useState(false);
  const [detectStarting, setDetectStarting] = React.useState(false);

  // EQ2e F8: a single active background job (detect or apply) we poll + can cancel
  const [activeJob, setActiveJob] = React.useState(null); // { taskId, label, status }
  const [cancelling, setCancelling] = React.useState(false);

  // EQ2e F8: batch restore existing episodes back to original ASR text
  const [restoreScope, setRestoreScope] = React.useState(''); // '' = all shows
  const [restoring, setRestoring] = React.useState(false);

  // EQ2e F-approve: per-candidate "also apply to existing episodes" checkbox
  const [applyToExisting, setApplyToExisting] = React.useState({});

  const showToast = (msg, kind = 'success') => {
    setToast({ msg, kind });
    setTimeout(() => setToast(null), 3500);
  };

  const reload = React.useCallback(async () => {
    setError(null);
    try {
      const res = await apiFetch('/admin/asr-corrections');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setRows(await res.json());
    } catch (err) {
      setError(err.message || String(err));
    }
  }, []);

  React.useEffect(() => { reload(); }, [reload]);

  React.useEffect(() => {
    (async () => {
      try {
        const res = await apiFetch('/shows');
        if (res.ok) setShows(await res.json());
      } catch { /* non-fatal */ }
    })();
  }, []);

  const showTitle = (id) => {
    const s = shows.find((x) => x.id === id);
    return s ? s.title : id;
  };

  // ── match-count preview (over-broad rule guard) ──
  React.useEffect(() => {
    if (!wrong.trim() || (scope === 'show' && !showId)) {
      setMatchCount(null);
      return;
    }
    let cancelled = false;
    const tid = setTimeout(async () => {
      try {
        const qs = new URLSearchParams({ wrong: wrong.trim(), scope });
        if (scope === 'show' && showId) qs.set('show_id', showId);
        const res = await apiFetch(`/admin/asr-corrections/match-count?${qs}`);
        if (res.ok && !cancelled) setMatchCount((await res.json()).match_count);
      } catch { /* preview is best-effort */ }
    }, 400);
    return () => { cancelled = true; clearTimeout(tid); };
  }, [wrong, scope, showId]);

  const handleAdd = async (e) => {
    e?.preventDefault?.();
    if (!wrong.trim() || adding) return;
    if (scope === 'show' && !showId) {
      showToast(t ? '請選擇節目' : 'Please choose a show', 'warn');
      return;
    }
    setAdding(true);
    try {
      const body = {
        wrong: wrong.trim(),
        correct: correct,
        scope,
        show_id: scope === 'show' ? showId : null,
        note: note.trim() || null,
      };
      const res = await apiFetch('/admin/asr-corrections', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (res.status === 409) {
        showToast(t ? '規則已存在' : 'Rule already exists', 'warn');
        return;
      }
      if (res.status === 422) {
        showToast(t ? '欄位有誤（綁節目需選節目）' : 'Invalid fields', 'warn');
        return;
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setWrong(''); setCorrect(''); setNote(''); setMatchCount(null);
      showToast(
        t ? '已新增（既有逐字稿需按下方「批次回填」才會更新）'
          : 'Added (existing transcripts need a manual backfill below)',
      );
      await reload();
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setAdding(false);
    }
  };

  const handleToggle = async (r) => {
    try {
      const res = await apiFetch(`/admin/asr-corrections/${r.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ enabled: !r.enabled }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      await reload();
    } catch (err) {
      showToast(err.message || String(err), 'error');
    }
  };

  const handleDelete = async (r) => {
    if (!confirm(t ? `刪除「${r.wrong}→${r.correct}」？` : `Delete "${r.wrong}→${r.correct}"?`)) return;
    try {
      const res = await apiFetch(`/admin/asr-corrections/${r.id}`, { method: 'DELETE' });
      if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
      showToast(t ? '已刪除' : 'Deleted');
      await reload();
    } catch (err) {
      showToast(err.message || String(err), 'error');
    }
  };

  // ── EQ2b: LLM candidate review (approve / reject) ──
  // EQ2c F3: per-candidate edited `correct` value (keyed by candidate id),
  // pre-filled lazily from the candidate's detected value.
  const [reviewingId, setReviewingId] = React.useState(null);
  const [editedCorrect, setEditedCorrect] = React.useState({});

  const handleReview = async (c, action) => {
    if (reviewingId) return;
    setReviewingId(c.id);
    try {
      const opts = { method: 'POST' };
      const wantApply = action === 'approve' && !!applyToExisting[c.id];
      if (action === 'approve') {
        // Send the (possibly edited) correct value so admin fixes apply.
        // EQ2e F-approve: apply_to_existing also re-runs the rule over old episodes.
        const edited = editedCorrect[c.id];
        const finalCorrect = (edited === undefined ? c.correct : edited).trim();
        opts.headers = { 'Content-Type': 'application/json' };
        opts.body = JSON.stringify({ correct: finalCorrect, apply_to_existing: wantApply });
      }
      const res = await apiFetch(`/admin/asr-corrections/${c.id}/${action}`, opts);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = action === 'approve' ? await res.json().catch(() => ({})) : {};
      // EQ2e: if approval kicked off a rule-application job, track it.
      if (wantApply && data.task_id) {
        setActiveJob({ taskId: data.task_id, label: t ? `套用「${c.wrong}→${c.correct}」` : `Apply "${c.wrong}→${c.correct}"`, status: null });
      }
      showToast(
        action === 'approve'
          ? (wantApply
              ? (t ? '已核准，套用既有集作業已啟動' : 'Approved — applying to existing episodes')
              : (t ? '已核准，已跨集生效' : 'Approved — now active across episodes'))
          : (t ? '已駁回' : 'Rejected'),
      );
      await reload();
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setReviewingId(null);
    }
  };

  const handlePreview = async () => {
    if (previewing) return;
    setPreviewing(true);
    setPreview(null);
    try {
      const body = { dry_run: true };
      if (backfillScope) body.show_id = backfillScope;
      const res = await apiFetch('/admin/asr-corrections/backfill', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setPreview(await res.json());
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setPreviewing(false);
    }
  };

  const handleConfirmBackfill = async () => {
    if (backfilling) return;
    setBackfilling(true);
    try {
      const body = { dry_run: false };
      if (backfillScope) body.show_id = backfillScope;
      const res = await apiFetch('/admin/asr-corrections/backfill', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      // EQ2e F8: track the apply job so the operator sees progress + can cancel.
      if (data.task_id) {
        setActiveJob({ taskId: data.task_id, label: t ? '套用原有集數' : 'Apply to existing episodes', status: null });
      }
      showToast(
        t ? `套用已在背景啟動（任務 ${data.task_id?.slice(0, 8) || ''}）`
          : `Apply started in background (task ${data.task_id?.slice(0, 8) || ''})`,
      );
      setPreview(null);
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setBackfilling(false);
    }
  };

  // ── EQ2e F6: detect over a show's existing episodes ──
  const handleDetectEstimate = async () => {
    if (detectEstimating || !detectScope) return;
    setDetectEstimating(true);
    setDetectEstimate(null);
    try {
      const res = await apiFetch('/admin/asr-corrections/detect-existing', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ show_id: detectScope, dry_run: true }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setDetectEstimate(await res.json());
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setDetectEstimating(false);
    }
  };

  const handleConfirmDetect = async () => {
    if (detectStarting || !detectScope) return;
    setDetectStarting(true);
    try {
      const res = await apiFetch('/admin/asr-corrections/detect-existing', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ show_id: detectScope, dry_run: false }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      if (data.task_id) {
        setActiveJob({ taskId: data.task_id, label: t ? '偵測原有集數' : 'Detect existing episodes', status: null });
      }
      showToast(t ? '偵測作業已在背景啟動' : 'Detection started in background');
      setDetectEstimate(null);
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setDetectStarting(false);
    }
  };

  // ── EQ2e F8: poll the active job's status (current/total + failures) ──
  React.useEffect(() => {
    const taskId = activeJob?.taskId;
    if (!taskId) return;
    let cancelled = false;
    const poll = async () => {
      try {
        const res = await apiFetch(`/admin/asr-corrections/backfill-status/${taskId}`);
        if (!res.ok || cancelled) return;
        const s = await res.json();
        if (cancelled) return;
        setActiveJob((j) => (j && j.taskId === taskId ? { ...j, status: s } : j));
        if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(s.state)) {
          clearInterval(iv);
          reload(); // a finished detect job may have produced new pending candidates
        }
      } catch { /* keep polling through transient errors */ }
    };
    poll();
    const iv = setInterval(poll, 2000);
    return () => { cancelled = true; clearInterval(iv); };
  }, [activeJob?.taskId, reload]);

  const handleCancelJob = async () => {
    const taskId = activeJob?.taskId;
    if (!taskId || cancelling) return;
    setCancelling(true);
    try {
      const res = await apiFetch(`/admin/asr-corrections/backfill-cancel/${taskId}`, { method: 'POST' });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      showToast(t ? '已送出取消（已完成部分會保留）' : 'Cancel requested (completed work is kept)');
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setCancelling(false);
    }
  };

  // ── EQ2e F8: batch restore existing episodes to original ASR text ──
  const handleBatchRestore = async () => {
    if (restoring) return;
    const scopeLabel = restoreScope ? showTitle(restoreScope) : (t ? '全部節目' : 'all shows');
    if (!confirm(
      t ? `將「${scopeLabel}」所有曾被校正的既有集還原回原始 ASR 文字，確定？`
        : `Restore every previously-corrected episode in "${scopeLabel}" back to original ASR text. Continue?`,
    )) return;
    setRestoring(true);
    try {
      const body = {};
      if (restoreScope) body.show_id = restoreScope;
      const res = await apiFetch('/admin/asr-corrections/batch-restore', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      showToast(
        t ? `已還原 ${data.affected_transcripts} 集（${data.affected_chunks} 個片段索引）`
          : `Restored ${data.affected_transcripts} episode(s) (${data.affected_chunks} chunk index(es))`,
      );
      await reload();
    } catch (err) {
      showToast(err.message || String(err), 'error');
    } finally {
      setRestoring(false);
    }
  };

  // EQ2b: pending LLM candidates are derived from the full list and shown in a
  // dedicated review section; the main rules table excludes them.
  const candidates = (rows || []).filter(
    (r) => r.source === 'llm' && r.status === 'pending',
  );
  const ruleRows = (rows || []).filter(
    (r) => !(r.source === 'llm' && r.status === 'pending'),
  );
  const totalCount = ruleRows.length;

  return (
    <div style={{ maxWidth: 920 }}>
      <p style={{ color: TOKEN.textSecondary, fontSize: 13, marginTop: 0, lineHeight: 1.6 }}>
        {t
          ? 'ASR 校正字典：把 Whisper 聽錯的詞（例「咪有企」）對應到正確的詞（「滅火器」）。整詞精確比對。新轉錄會自動套用；既有逐字稿需按下方「批次回填」才會更新（連搜尋一起修）。'
          : 'ASR correction dictionary: map a mis-transcribed term (e.g. "咪有企") to the correct one ("滅火器"). Whole-term literal match. New transcripts apply it automatically; existing transcripts need the backfill below (which also fixes search).'}
      </p>

      {/* ── new rule form ── */}
      <form onSubmit={handleAdd} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 6 }}>
        <Input value={wrong} onChange={(e) => setWrong(e.target.value)}
          placeholder={t ? '錯字，例：咪有企' : 'Wrong, e.g. 咪有企'} style={{ width: 180 }} />
        <span style={{ color: TOKEN.textMuted }}>→</span>
        <Input value={correct} onChange={(e) => setCorrect(e.target.value)}
          placeholder={t ? '正字，例：滅火器' : 'Correct, e.g. 滅火器'} style={{ width: 180 }} />
        <select value={scope} onChange={(e) => setScope(e.target.value)}
          style={{ background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '8px 10px', fontSize: 14 }}>
          <option value="show">{t ? '綁節目' : 'Show'}</option>
          <option value="global">{t ? '全站' : 'Global'}</option>
        </select>
        {scope === 'show' && (
          <select value={showId} onChange={(e) => setShowId(e.target.value)}
            style={{ background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '8px 10px', fontSize: 14, maxWidth: 200 }}>
            <option value="">{t ? '選節目…' : 'Choose show…'}</option>
            {shows.map((s) => <option key={s.id} value={s.id}>{s.title}</option>)}
          </select>
        )}
        <Input value={note} onChange={(e) => setNote(e.target.value)}
          placeholder={t ? '備註（選填）' : 'Note (optional)'} style={{ width: 160 }} />
        <Btn type="submit" variant="primary" disabled={!wrong.trim() || adding}>
          {adding ? (t ? '新增中…' : 'Adding…') : (t ? '新增' : 'Add')}
        </Btn>
      </form>

      {/* match-count preview — over-broad rule guard */}
      <div style={{ minHeight: 20, marginBottom: 16, fontSize: 12, color: matchCount > 50 ? '#fbbf24' : TOKEN.textMuted }}>
        {matchCount !== null && (
          t ? `目前命中 ${matchCount} 段逐字稿${matchCount > 50 ? '（範圍偏大，請確認不會誤傷）' : ''}`
            : `Currently matches ${matchCount} segment(s)${matchCount > 50 ? ' (broad — double-check for false hits)' : ''}`
        )}
      </div>

      {error && <div style={{ color: '#f87171', fontSize: 13, marginBottom: 12 }}>{error}</div>}

      {/* ── EQ2b: pending LLM candidate review ── */}
      {candidates.length > 0 && (
        <div style={{ marginBottom: 24, padding: 16, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.accent}`, borderRadius: 8 }}>
          <div style={{ fontWeight: 600, color: TOKEN.text, marginBottom: 4 }}>
            {t ? `待審核候選（${candidates.length}）` : `Pending candidates (${candidates.length})`}
          </div>
          <p style={{ color: TOKEN.textSecondary, fontSize: 12, marginTop: 0, lineHeight: 1.6 }}>
            {t
              ? 'LLM 在轉錄時偵測到的疑似同音錯字。核准後會跨集生效並進入校正字典；駁回則不再提醒。（候選不影響既有資料，需核准才生效。）'
              : 'Suspected homophone typos the LLM detected during transcription. Approve to activate across episodes and add to the dictionary; reject to dismiss. (Candidates do not affect existing data until approved.)'}
          </p>
          <div style={{ overflowX: 'auto' }}>
          <table style={{ width: '100%', minWidth: 420, borderCollapse: 'collapse', fontSize: 14 }}>
            <thead>
              <tr style={{ borderBottom: `1px solid ${TOKEN.surfaceBorder}`, color: TOKEN.textSecondary, textAlign: 'left' }}>
                <th style={{ padding: '8px', fontWeight: 600 }}>{t ? '錯字 → 正字' : 'Wrong → Correct'}</th>
                <th style={{ padding: '8px', fontWeight: 600 }}>{t ? '節目' : 'Show'}</th>
                <th style={{ padding: '8px', fontWeight: 600 }}></th>
              </tr>
            </thead>
            <tbody>
              {candidates.map((c) => (
                <tr key={c.id} style={{ borderBottom: `1px solid ${TOKEN.surfaceBorder}`, color: TOKEN.text }}>
                  <td style={{ padding: '8px', fontFamily: 'ui-monospace, monospace' }}>
                    <span>{c.wrong}</span> <span style={{ color: TOKEN.textMuted }}>→</span>{' '}
                    {/* EQ2c F3: correct is editable; admin can fix a near-miss before approving */}
                    <input
                      value={editedCorrect[c.id] === undefined ? c.correct : editedCorrect[c.id]}
                      onChange={(e) => setEditedCorrect((m) => ({ ...m, [c.id]: e.target.value }))}
                      disabled={reviewingId === c.id}
                      style={{
                        width: 130, fontFamily: 'ui-monospace, monospace', fontSize: 14,
                        background: TOKEN.surfaceRaised, color: TOKEN.text,
                        border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '4px 8px',
                      }}
                    />
                  </td>
                  <td style={{ padding: '8px' }}>
                    <Badge variant="default">{showTitle(c.show_id)}</Badge>
                  </td>
                  <td style={{ padding: '8px', textAlign: 'right', whiteSpace: 'nowrap' }}>
                    {/* EQ2e F-approve: also apply the rule to existing episodes on approve */}
                    <label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: TOKEN.textSecondary, marginRight: 8, cursor: 'pointer' }}>
                      <input
                        type="checkbox"
                        checked={!!applyToExisting[c.id]}
                        disabled={reviewingId === c.id}
                        onChange={(e) => setApplyToExisting((m) => ({ ...m, [c.id]: e.target.checked }))}
                      />
                      {t ? '同時套用既有集' : 'Apply to existing'}
                    </label>
                    <Btn variant="primary" size="sm" disabled={reviewingId === c.id} onClick={() => handleReview(c, 'approve')}>
                      {t ? '核准' : 'Approve'}
                    </Btn>
                    <Btn variant="ghost" size="sm" disabled={reviewingId === c.id} onClick={() => handleReview(c, 'reject')}>
                      {t ? '駁回' : 'Reject'}
                    </Btn>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          </div>
        </div>
      )}

      {/* ── EQ2e F6: detect over a show's existing episodes ── */}
      <div style={{ marginBottom: 20, padding: 16, background: TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8 }}>
        <div style={{ fontWeight: 600, color: TOKEN.text, marginBottom: 6 }}>
          {t ? '偵測原有集數' : 'Detect over existing episodes'}
        </div>
        <p style={{ color: TOKEN.textSecondary, fontSize: 12, marginTop: 0, lineHeight: 1.6 }}>
          {t
            ? '對一個節目「偵測功能上線前的舊集」逐集跑 LLM 同音字偵測，產生待審核候選。只產候選、不改任何逐字稿；會先估成本，你確認後才執行。'
            : 'Run LLM homophone detection over a show\'s older episodes to produce pending candidates. It only creates candidates (never changes transcripts); it estimates cost first and runs only after you confirm.'}
        </p>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <select value={detectScope} onChange={(e) => { setDetectScope(e.target.value); setDetectEstimate(null); }}
            style={{ background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '8px 10px', fontSize: 14, maxWidth: 220 }}>
            <option value="">{t ? '選節目…' : 'Choose show…'}</option>
            {shows.map((s) => <option key={s.id} value={s.id}>{s.title}</option>)}
          </select>
          <Btn variant="secondary" onClick={handleDetectEstimate} disabled={detectEstimating || !detectScope}>
            {detectEstimating ? (t ? '估算中…' : 'Estimating…') : (t ? '估算成本' : 'Estimate cost')}
          </Btn>
        </div>

        {detectEstimate && (
          <div style={{ marginTop: 12, padding: 14, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8 }}>
            <div style={{ color: TOKEN.text, fontSize: 13, lineHeight: 1.7 }}>
              {t
                ? `將偵測 ${detectEstimate.episode_count} 集、估 ${detectEstimate.estimated_input_tokens.toLocaleString()} input tokens，預估成本約 $${detectEstimate.estimated_cost_usd}。`
                : `Will detect ${detectEstimate.episode_count} episode(s), est. ${detectEstimate.estimated_input_tokens.toLocaleString()} input tokens, ~$${detectEstimate.estimated_cost_usd}.`}
            </div>
            <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
              <Btn variant="primary" onClick={handleConfirmDetect} disabled={detectStarting || detectEstimate.episode_count === 0}>
                {detectStarting ? (t ? '啟動中…' : 'Starting…') : (t ? '確認偵測' : 'Confirm & detect')}
              </Btn>
              <Btn variant="ghost" onClick={() => setDetectEstimate(null)}>{t ? '取消' : 'Cancel'}</Btn>
            </div>
            {detectEstimate.episode_count === 0 && (
              <div style={{ color: TOKEN.textMuted, fontSize: 12, marginTop: 6 }}>
                {t ? '這個節目沒有可偵測的逐字稿。' : 'This show has no transcripts to detect.'}
              </div>
            )}
          </div>
        )}
      </div>

      {/* ── EQ2e F8: active background job (progress / failures / cancel) ── */}
      {activeJob && (
        <div style={{ marginBottom: 20, padding: 16, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.accent}`, borderRadius: 8 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
            <div style={{ fontWeight: 600, color: TOKEN.text }}>
              {activeJob.label}
              {activeJob.status?.state && (
                <span style={{ marginLeft: 8 }}>
                  <Badge variant={
                    activeJob.status.state === 'SUCCESS' ? 'success'
                      : activeJob.status.state === 'FAILURE' ? 'danger'
                      : activeJob.status.state === 'REVOKED' ? 'warning' : 'default'
                  }>{activeJob.status.state}</Badge>
                </span>
              )}
            </div>
            <div style={{ display: 'flex', gap: 8 }}>
              {!['SUCCESS', 'FAILURE', 'REVOKED', 'UNKNOWN'].includes(activeJob.status?.state) && (
                <Btn variant="danger" size="sm" onClick={handleCancelJob} disabled={cancelling}>
                  {cancelling ? (t ? '取消中…' : 'Cancelling…') : (t ? '取消作業' : 'Cancel')}
                </Btn>
              )}
              <Btn variant="ghost" size="sm" onClick={() => setActiveJob(null)}>
                {t ? '關閉' : 'Dismiss'}
              </Btn>
            </div>
          </div>
          {(() => {
            const st = activeJob.status;
            const total = st?.total || 0;
            const current = st?.current || 0;
            const pct = total > 0 ? Math.round((current / total) * 100) : 0;
            return (
              <div>
                <div style={{ height: 8, background: TOKEN.bg, borderRadius: 4, overflow: 'hidden' }}>
                  <div style={{ height: '100%', width: `${pct}%`, background: TOKEN.accent, transition: 'width 0.4s' }} />
                </div>
                <div style={{ color: TOKEN.textSecondary, fontSize: 12, marginTop: 6 }}>
                  {st?.message || (t ? '查詢中…' : 'Polling…')}
                  {total > 0 ? `（${current}/${total}）` : ''}
                </div>
                {st?.failed_chunk_ids?.length > 0 && (
                  <div style={{ color: '#fbbf24', fontSize: 12, marginTop: 6, wordBreak: 'break-all' }}>
                    {t ? `失敗 ${st.failed_chunk_ids.length} 筆：` : `${st.failed_chunk_ids.length} failed: `}
                    {st.failed_chunk_ids.slice(0, 10).join(', ')}
                    {st.failed_chunk_ids.length > 10 ? '…' : ''}
                  </div>
                )}
              </div>
            );
          })()}
        </div>
      )}

      {/* ── rules table ── */}
      {rows === null ? (
        <div style={{ color: TOKEN.textMuted, fontSize: 13 }}>{t ? '載入中…' : 'Loading…'}</div>
      ) : ruleRows.length === 0 ? (
        <div style={{ color: TOKEN.textMuted, fontSize: 13 }}>{t ? '尚無規則' : 'No rules yet'}</div>
      ) : (
        <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', minWidth: 560, borderCollapse: 'collapse', fontSize: 14 }}>
          <thead>
            <tr style={{ borderBottom: `1px solid ${TOKEN.surfaceBorder}`, color: TOKEN.textSecondary, textAlign: 'left' }}>
              <th style={{ padding: '10px 8px', fontWeight: 600 }}>{t ? '錯字 → 正字' : 'Wrong → Correct'}</th>
              <th style={{ padding: '10px 8px', fontWeight: 600 }}>{t ? '範圍' : 'Scope'}</th>
              <th style={{ padding: '10px 8px', fontWeight: 600 }}>{t ? '狀態' : 'Status'}</th>
              <th style={{ padding: '10px 8px', fontWeight: 600 }}></th>
            </tr>
          </thead>
          <tbody>
            {ruleRows.map((r) => (
              <tr key={r.id} style={{ borderBottom: `1px solid ${TOKEN.surfaceBorder}`, color: TOKEN.text }}>
                <td style={{ padding: '10px 8px', fontFamily: 'ui-monospace, monospace' }}>
                  {r.wrong} <span style={{ color: TOKEN.textMuted }}>→</span> {r.correct}
                  {r.note ? <span style={{ color: TOKEN.textMuted, fontSize: 12 }}>　{r.note}</span> : null}
                </td>
                <td style={{ padding: '10px 8px' }}>
                  {r.scope === 'global'
                    ? <Badge variant="warning">{t ? '全站' : 'Global'}</Badge>
                    : <Badge variant="default">{showTitle(r.show_id)}</Badge>}
                </td>
                <td style={{ padding: '10px 8px' }}>
                  <Badge variant={r.enabled ? 'success' : 'muted'}>
                    {r.enabled ? (t ? '啟用' : 'Enabled') : (t ? '停用' : 'Disabled')}
                  </Badge>
                </td>
                <td style={{ padding: '10px 8px', textAlign: 'right', whiteSpace: 'nowrap' }}>
                  <Btn variant="ghost" size="sm" onClick={() => handleToggle(r)}>
                    {r.enabled ? (t ? '停用' : 'Disable') : (t ? '啟用' : 'Enable')}
                  </Btn>
                  <Btn variant="danger" size="sm" onClick={() => handleDelete(r)}>
                    {t ? '刪除' : 'Delete'}
                  </Btn>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        </div>
      )}

      {/* ── backfill ── */}
      <div style={{ marginTop: 28, paddingTop: 20, borderTop: `1px solid ${TOKEN.surfaceBorder}` }}>
        <div style={{ fontWeight: 600, color: TOKEN.text, marginBottom: 6 }}>
          {t ? '批次回填既有逐字稿' : 'Backfill existing transcripts'}
        </div>
        <p style={{ color: TOKEN.textSecondary, fontSize: 12, marginTop: 0, lineHeight: 1.6 }}>
          {t
            ? '新增規則後，既有逐字稿不會自動更新。回填會修正既有逐字稿並重算受影響片段的搜尋索引。會先估算範圍與成本，你確認後才執行。'
            : 'Newly added rules do not update existing transcripts automatically. Backfill corrects existing transcripts and recomputes the search index for affected chunks. It previews scope + cost first; it runs only after you confirm.'}
        </p>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <select value={backfillScope} onChange={(e) => { setBackfillScope(e.target.value); setPreview(null); }}
            style={{ background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '8px 10px', fontSize: 14, maxWidth: 220 }}>
            <option value="">{t ? '全部節目' : 'All shows'}</option>
            {shows.map((s) => <option key={s.id} value={s.id}>{s.title}</option>)}
          </select>
          <Btn variant="secondary" onClick={handlePreview} disabled={previewing}>
            {previewing ? (t ? '估算中…' : 'Estimating…') : (t ? '估算範圍' : 'Preview impact')}
          </Btn>
        </div>

        {preview && (
          <div style={{ marginTop: 12, padding: 14, background: TOKEN.surfaceRaised, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8 }}>
            <div style={{ color: TOKEN.text, fontSize: 13, lineHeight: 1.7 }}>
              {t
                ? `將更新 ${preview.affected_segments} 段、重算 ${preview.affected_chunks} 個片段索引，預估成本約 $${preview.estimated_cost_usd}（embedding）。`
                : `Will update ${preview.affected_segments} segment(s), recompute ${preview.affected_chunks} chunk index(es), est. ~$${preview.estimated_cost_usd} (embedding).`}
            </div>
            <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
              <Btn variant="primary" onClick={handleConfirmBackfill} disabled={backfilling || preview.affected_chunks === 0}>
                {backfilling ? (t ? '執行中…' : 'Running…') : (t ? '確認執行' : 'Confirm & run')}
              </Btn>
              <Btn variant="ghost" onClick={() => setPreview(null)}>{t ? '取消' : 'Cancel'}</Btn>
            </div>
            {preview.affected_chunks === 0 && (
              <div style={{ color: TOKEN.textMuted, fontSize: 12, marginTop: 6 }}>
                {t ? '沒有需要更新的內容。' : 'Nothing to update.'}
              </div>
            )}
          </div>
        )}
      </div>

      {/* ── EQ2e F8: batch restore existing episodes ── */}
      <div style={{ marginTop: 24, paddingTop: 20, borderTop: `1px solid ${TOKEN.surfaceBorder}` }}>
        <div style={{ fontWeight: 600, color: TOKEN.text, marginBottom: 6 }}>
          {t ? '批次還原既有集' : 'Batch restore existing episodes'}
        </div>
        <p style={{ color: TOKEN.textSecondary, fontSize: 12, marginTop: 0, lineHeight: 1.6 }}>
          {t
            ? '把曾被校正的既有集還原回原始 ASR 文字（含搜尋索引）。注意：會還原該範圍內「所有曾被校正的集」，不限定某一次套用作業。'
            : 'Revert previously-corrected episodes back to original ASR text (and search index). Note: this reverts ALL corrected episodes in scope, not just one apply run.'}
        </p>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <select value={restoreScope} onChange={(e) => setRestoreScope(e.target.value)}
            style={{ background: TOKEN.surfaceRaised, color: TOKEN.text, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 6, padding: '8px 10px', fontSize: 14, maxWidth: 220 }}>
            <option value="">{t ? '全部節目' : 'All shows'}</option>
            {shows.map((s) => <option key={s.id} value={s.id}>{s.title}</option>)}
          </select>
          <Btn variant="danger" onClick={handleBatchRestore} disabled={restoring}>
            {restoring ? (t ? '還原中…' : 'Restoring…') : (t ? '批次還原' : 'Batch restore')}
          </Btn>
        </div>
      </div>

      <div style={{ marginTop: 18, color: TOKEN.textMuted, fontSize: 12 }}>
        {t ? `共 ${totalCount} 條規則` : `${totalCount} rule(s) total`}
      </div>

      {toast && (
        <div style={{
          position: 'fixed', bottom: 24, right: 24,
          background: toast.kind === 'error' ? '#ef4444' : toast.kind === 'warn' ? '#f59e0b' : TOKEN.accent,
          color: '#fff', padding: '10px 16px', borderRadius: 6, fontSize: 13, boxShadow: '0 4px 12px rgba(0,0,0,0.4)', maxWidth: 360,
        }}>
          {toast.msg}
        </div>
      )}
    </div>
  );
};
