// 4 · 质检合成（/qc）—— 轨道形式，做 3 项 QC 复选 + 扩展参数 + 一键合成
// /remix 点 OK 后跳到 /qc?job=xxx，本页接着完成轨道质检与合成。
//
// 自动化：用户什么都不勾、什么都不改，直接点「合成」也能跑（默认全部认定 OK + 文档默认参数）。

const _SUB_POSITION_OPTIONS = [
  { value: 2, label: "底部居中（默认）" },
  { value: 5, label: "屏幕居中" },
  { value: 8, label: "顶部居中" },
  { value: 1, label: "底部左对齐" },
  { value: 3, label: "底部右对齐" },
];
const _SUB_COLOR_OPTIONS = [
  { value: "yellow_bg_black", label: "黄底黑字（默认）" },
  { value: "white_bg_black", label: "白底黑字" },
  { value: "white_no_bg", label: "白字无底" },
  { value: "black_no_bg", label: "黑字无底" },
];

// 单 chunk QC 复选（前端态，落 localStorage 不打后端）
const _qcStorageKey = (jobId) => `qc_marks_v1_${jobId}`;

const QcChunkChecks = ({ chunkId, marks, onChange }) => {
  const m = marks[chunkId] || { clip: null, tts: null, bgm: null };
  // null = 未审 / true = OK / false = NG
  const cycle = (cur) => cur === null ? true : cur === true ? false : null;
  const cls = (v) => v === true ? "var(--green)" : v === false ? "var(--pink)" : "var(--fg-3)";
  const lbl = (v) => v === true ? "✓" : v === false ? "✗" : "?";
  return (
    <div className="row gap-3" style={{ alignItems: "center" }}>
      {[
        { k: "clip", t: "分镜" },
        { k: "tts",  t: "口播" },
        { k: "bgm",  t: "BGM" },
      ].map(it => (
        <button key={it.k} onClick={() => onChange(chunkId, it.k, cycle(m[it.k]))}
                title={`${it.t}: ${m[it.k] === true ? "OK" : m[it.k] === false ? "NG" : "未审"} (点击切换)`}
                style={{
                  fontFamily: "inherit", border: "1px solid var(--line-1)",
                  background: "var(--bg-1)", color: cls(m[it.k]),
                  borderRadius: 3, padding: "1px 5px", fontSize: 9.5, cursor: "pointer",
                  fontWeight: 600,
                }}>
          {lbl(m[it.k])}{it.t}
        </button>
      ))}
    </div>
  );
};


const QcComposePanel = ({ parentJobId, allMatched, rowsCount, qcMarks }) => {
  const [job, setJob] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState("");
  const [defaults, setDefaults] = React.useState(null);
  const [bgmList, setBgmList] = React.useState([]);
  const [params, setParams] = React.useState({});
  const [showParams, setShowParams] = React.useState(true);
  const [history, setHistory] = React.useState([]);
  const pollerRef = React.useRef(null);

  React.useEffect(() => {
    API.compose.defaults().then(d => { setDefaults(d.defaults); setParams(d.defaults); }).catch(() => {});
    API.compose.bgmList().then(d => setBgmList(d.items || [])).catch(() => {});
  }, []);

  const loadHistory = React.useCallback(async () => {
    try {
      const r = await API.compose.listForParent(parentJobId);
      setHistory(r.items || []);
      const top = (r.items || [])[0];
      if (top && (top.status === "running" || top.status === "pending")) {
        setJob(top);
        if (!pollerRef.current) startPolling(top.compose_id);
      }
    } catch (_) {}
  }, [parentJobId]);

  React.useEffect(() => { loadHistory(); }, [loadHistory]);
  React.useEffect(() => () => { if (pollerRef.current) clearInterval(pollerRef.current); }, []);

  const startPolling = (composeId) => {
    if (pollerRef.current) clearInterval(pollerRef.current);
    pollerRef.current = setInterval(async () => {
      try {
        const next = await API.compose.get(composeId);
        setJob(next);
        if (next.status === "completed" || next.status === "failed") {
          clearInterval(pollerRef.current); pollerRef.current = null;
          setBusy(false);
          loadHistory();
        }
      } catch (_) {}
    }, 1500);
  };

  const submit = async () => {
    setBusy(true); setErr("");
    try {
      const j = await API.compose.start(parentJobId, params);
      setJob(j);
      startPolling(j.compose_id);
    } catch (e) { setErr(String(e.message || e)); setBusy(false); }
  };

  const setP = (k, v) => setParams(p => ({ ...p, [k]: v }));
  const resetP = () => defaults && setParams(defaults);
  const gainToDb = (g) => g > 0 ? (20 * Math.log10(g)).toFixed(1) : "-∞";

  // 自动模式：所有 chunk 都还没审 → 默认按 ok 走（不阻拦）
  const totalMarks = Object.values(qcMarks || {}).reduce((s, m) => s + ["clip","tts","bgm"].filter(k => m[k] !== null).length, 0);
  const ngCount = Object.values(qcMarks || {}).reduce((s, m) => s + ["clip","tts","bgm"].filter(k => m[k] === false).length, 0);
  const isAuto = totalMarks === 0;
  const hasNg = ngCount > 0;

  const isDone = job?.status === "completed";
  const isFail = job?.status === "failed";
  const pct = job?.progress || 0;

  return (
    <div className="card">
      <div className="card-hd">
        <span className="card-title">质检合成（轨道 → ffmpeg 出片）</span>
        <span className="card-sub">
          scene = TTS 时长 · 转场 {params.xfade_dur ?? 1}s · 黄底黑字 · BGM {gainToDb(params.bgm_gain ?? 0.794)}dB
        </span>
        <div className="grow"/>
        {isAuto ? (
          <span className="tag" style={{ fontSize: 9.5, padding: "0 6px", color: "var(--cyan)" }}>
            ⚙ 自动模式（无人工标记）
          </span>
        ) : hasNg ? (
          <span className="tag" style={{ fontSize: 9.5, padding: "0 6px", color: "var(--pink)" }}>
            ⚠ {ngCount} 项 NG
          </span>
        ) : (
          <span className="tag" style={{ fontSize: 9.5, padding: "0 6px", color: "var(--green)" }}>
            ✓ 已质检 {totalMarks} 项
          </span>
        )}
        <button className="btn sm ghost" onClick={() => setShowParams(s => !s)}>
          <Icon name="settings" size={10}/>{showParams ? "隐藏参数" : "参数"}
        </button>
        <button className={`btn sm ${allMatched ? "primary" : ""}`}
                onClick={submit} disabled={!allMatched || busy || hasNg}
                title={hasNg ? "存在 NG 项，请回 /remix 修正" : ""}
                style={{
                  background: !allMatched || hasNg ? "var(--bg-2)" : "var(--purple)",
                  color: !allMatched || hasNg ? "var(--fg-3)" : "var(--bg-0)",
                  cursor: !allMatched || hasNg ? "not-allowed" : "pointer",
                }}>
          <Icon name="play" size={11}/>
          {busy ? "合成中…" : `▶ 合成 ${rowsCount} 镜`}
        </button>
      </div>

      {showParams && defaults && (
        <div className="col" style={{ padding: 12, gap: 10, borderTop: "1px solid var(--line-1)",
                                       background: "var(--bg-1)" }}>
          <div className="row between" style={{ alignItems: "center" }}>
            <span className="kpi-l">合成参数</span>
            <button className="btn sm ghost" onClick={resetP}>
              <Icon name="refresh" size={9}/>恢复默认
            </button>
          </div>

          <span className="kpi-l" style={{ fontSize: 9.5, color: "var(--fg-3)" }}>BGM</span>
          <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 10 }}>
            <div className="col gap-3">
              <span className="kpi-l">BGM 文件</span>
              <select className="input" value={params.bgm_filename || ""}
                      onChange={e => setP("bgm_filename", e.target.value || null)}
                      style={{ fontSize: 11, padding: "4px 6px" }}>
                <option value="">随机</option>
                {bgmList.map(b => (<option key={b.filename} value={b.filename}>{b.filename.slice(0, 36)}</option>))}
              </select>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">BGM 音量 ({gainToDb(params.bgm_gain ?? 0.794)} dB)</span>
              <input type="number" className="input" step="0.05" min="0" max="2"
                     value={params.bgm_gain ?? 0.794}
                     onChange={e => setP("bgm_gain", parseFloat(e.target.value || "0.794"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
          </div>

          <span className="kpi-l" style={{ fontSize: 9.5, color: "var(--fg-3)", marginTop: 4 }}>音量</span>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
            <div className="col gap-3">
              <span className="kpi-l">口播音量 ({gainToDb(params.voice_gain ?? 1)} dB)</span>
              <input type="number" className="input" step="0.05" min="0" max="2.5"
                     value={params.voice_gain ?? 1}
                     onChange={e => setP("voice_gain", parseFloat(e.target.value || "1"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">视频自带音频 (UI 占位 · pipeline 默认静音)</span>
              <input type="number" className="input" step="0.05" min="0" max="2.5"
                     value={params.src_audio_gain ?? 0}
                     onChange={e => setP("src_audio_gain", parseFloat(e.target.value || "0"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">edge-tts 语速 (默认 +10%)</span>
              <input type="text" className="input" value={params.voice_rate ?? "+10%"}
                     onChange={e => setP("voice_rate", e.target.value)}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
          </div>

          <span className="kpi-l" style={{ fontSize: 9.5, color: "var(--fg-3)", marginTop: 4 }}>字幕</span>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: 10 }}>
            <div className="col gap-3">
              <span className="kpi-l">位置</span>
              <select className="input" value={params.sub_alignment ?? 2}
                      onChange={e => setP("sub_alignment", parseInt(e.target.value || "2"))}
                      style={{ fontSize: 11, padding: "4px 6px" }}>
                {_SUB_POSITION_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
              </select>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">颜色</span>
              <select className="input" value={params.sub_color || "yellow_bg_black"}
                      onChange={e => setP("sub_color", e.target.value)}
                      style={{ fontSize: 11, padding: "4px 6px" }}>
                {_SUB_COLOR_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
              </select>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">字号 (默认 13)</span>
              <input type="number" className="input" value={params.sub_font_size ?? 13}
                     min={4} max={48}
                     onChange={e => setP("sub_font_size", parseInt(e.target.value || "13"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">距底 px (默认 640 ≈ 画面 2/3 处)</span>
              <input type="number" className="input" value={params.sub_margin_bottom ?? 640}
                     min={0} max={1800}
                     onChange={e => setP("sub_margin_bottom", parseInt(e.target.value || "640"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
          </div>

          <span className="kpi-l" style={{ fontSize: 9.5, color: "var(--fg-3)", marginTop: 4 }}>转场 / 时长</span>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10 }}>
            <div className="col gap-3">
              <span className="kpi-l">转场重叠 (默认 1.0s)</span>
              <input type="number" className="input" step="0.1" min="0" max="3"
                     value={params.xfade_dur ?? 1}
                     onChange={e => setP("xfade_dur", parseFloat(e.target.value || "1"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">最短 scene (默认 2.0s)</span>
              <input type="number" className="input" step="0.5" min="0" max="15"
                     value={params.min_scene_dur ?? 2}
                     onChange={e => setP("min_scene_dur", parseFloat(e.target.value || "2"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
            <div className="col gap-3">
              <span className="kpi-l">字幕单行字数 (默认 16)</span>
              <input type="number" className="input" value={params.max_chars ?? 16}
                     min={4} max={40}
                     onChange={e => setP("max_chars", parseInt(e.target.value || "16"))}
                     style={{ fontSize: 11, padding: "4px 6px" }}/>
            </div>
          </div>

          <span style={{ fontSize: 10, color: "var(--fg-3)" }}>
            产物 → <span className="mono">AIGC混剪/output/final/月日（N）.mp4</span>
          </span>
        </div>
      )}

      {err && <div style={{ padding: "8px 12px", borderTop: "1px solid var(--line-1)",
                            fontSize: 11, color: "var(--pink)" }}>{err}</div>}

      {job && (
        <div className="col" style={{ padding: 12, gap: 8, borderTop: "1px solid var(--line-1)" }}>
          <div className="row between" style={{ alignItems: "center" }}>
            <span className="mono" style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
              {job.compose_id} · {job.status} · scene {job.scenes_used || "?"} / chunk {job.chunks_count}
            </span>
            <span className="mono" style={{ fontSize: 11, color: isFail ? "var(--pink)" : "var(--purple)" }}>
              {pct}%
            </span>
          </div>
          <div className="bar"><span style={{
            width: `${pct}%`, background: isFail ? "var(--pink)" : "var(--purple)", transition: "width .3s",
          }}/></div>
          <div style={{ fontSize: 11, color: "var(--fg-1)" }}>
            {job.message || (isFail ? (job.error || "失败") : "运行中…")}
          </div>
          {(job.log_tail || []).length > 0 && (
            <pre style={{
              fontSize: 10.5, lineHeight: 1.5, color: "var(--fg-3)",
              background: "var(--bg-1)", padding: 8, borderRadius: 4,
              maxHeight: 100, overflow: "auto", margin: 0, whiteSpace: "pre-wrap",
            }}>{(job.log_tail || []).slice(-6).join("\n")}</pre>
          )}
          {isDone && (
            <div className="col gap-6">
              <span className="kpi-l">最终视频预览</span>
              <video src={API.compose.videoUrl(job.compose_id)} controls
                     style={{ width: "100%", maxHeight: 480, background: "#000", borderRadius: 4 }}/>
              <div className="row gap-8">
                <a className="btn sm" href={API.compose.videoUrl(job.compose_id)} download>
                  <Icon name="ext" size={10}/>下载 mp4
                </a>
                <a className="btn sm ghost" href={API.compose.manifestUrl(job.compose_id)} target="_blank" rel="noreferrer">
                  <Icon name="ext" size={10}/>manifest
                </a>
                <span style={{ fontSize: 10, color: "var(--fg-3)" }}>BGM: {job.bgm_picked}</span>
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
};


const QCPage = () => {
  // 文案二创独立链路（URL 带 ?text_rewrite_job=xxx 时显示进度卡）
  const trJobId = useTextRewriteJobIdFromQuery();

  // URL 接参：/qc?job=<id>
  const initialJob = (() => {
    const u = new URL(window.location.href);
    return u.searchParams.get("job") || null;
  })();

  const [parents, setParents] = React.useState([]);
  const [activeParentId, setActiveParentId] = React.useState(initialJob);
  const [tableData, setTableData] = React.useState(null);
  const [bgmList, setBgmList] = React.useState([]);
  const [qcMarks, setQcMarks] = React.useState({});

  const loadJobs = async () => {
    try {
      const j = await API.jobs.list().catch(() => ({ items: [] }));
      const items = (j.items || []).filter(p => p.has_result && p.status === "completed");
      setParents(items);
      if (!activeParentId && items.length) setActiveParentId(items[0].job_id);
    } catch (_) {}
  };

  const loadTable = React.useCallback(async () => {
    if (!activeParentId) { setTableData(null); return; }
    try {
      const d = await API.replicateTable.get(activeParentId);
      setTableData(d);
    } catch (_) {}
  }, [activeParentId]);

  React.useEffect(() => { loadJobs(); }, []);
  React.useEffect(() => { loadTable(); }, [loadTable]);
  React.useEffect(() => {
    API.compose.bgmList().then(d => setBgmList(d.items || [])).catch(() => {});
  }, []);

  // QC marks 持久化（按 job 分桶 localStorage）
  React.useEffect(() => {
    if (!activeParentId) return;
    try {
      const saved = JSON.parse(localStorage.getItem(_qcStorageKey(activeParentId)) || "{}");
      setQcMarks(saved);
    } catch (_) { setQcMarks({}); }
  }, [activeParentId]);

  const updateQcMark = (chunkId, key, value) => {
    setQcMarks(prev => {
      const next = { ...prev, [chunkId]: { ...(prev[chunkId] || {}), [key]: value } };
      try { localStorage.setItem(_qcStorageKey(activeParentId), JSON.stringify(next)); } catch (_) {}
      return next;
    });
  };

  const rows = tableData?.rows || [];
  // 复刻流水线命中 material_remix 或 seed_library 命中 visual_remix.match 都算"已匹配"
  const allMatched = rows.length > 0 && rows.every(r =>
    (r.material_remix && r.material_remix.segment_url)
    || (r.visual_remix && r.visual_remix.match)
  );

  return (
    <>
      <Topbar
        crumbs={["4 · 质检合成", activeParentId
          ? (parents.find(p => p.job_id === activeParentId)?.title || "选中")
          : "选择视频"]}
        right={<>
          <span className="pill" style={{
            background: "color-mix(in oklch, var(--purple) 14%, var(--bg-1))",
            color: "var(--purple)",
          }}>
            <Icon name="check" size={10}/>轨道 · ffmpeg
          </span>
          <button className="btn ghost" onClick={() => { loadJobs(); loadTable(); }}>
            <Icon name="refresh" size={11}/>刷新
          </button>
        </>}
      />

      <div className="row" style={{
        padding: "8px 14px", borderBottom: "1px solid var(--line-1)",
        background: "var(--bg-1)", gap: 12, alignItems: "center",
      }}>
        <span className="kpi-l">Job</span>
        <select value={activeParentId || ""} onChange={e => {
                  setActiveParentId(e.target.value || null);
                  window.history.pushState({}, "", `/qc?job=${encodeURIComponent(e.target.value || "")}`);
                }}
                className="input" style={{ fontSize: 11.5, padding: "4px 8px", maxWidth: 360 }}>
          <option value="">— 选择已拆解视频 —</option>
          {parents.map(p => (
            <option key={p.job_id} value={p.job_id}>
              {p.title || p.job_id.slice(0, 8)} ({p.shots_count || "?"} 镜)
            </option>
          ))}
        </select>
        <div className="grow"/>
        <span style={{ fontSize: 10.5, color: "var(--fg-3)" }}>
          交互逻辑：每行 3 个按钮（分镜 / 口播 / BGM），点击循环 ?→✓→✗。
          全部空 = 自动直出；任一 ✗ 阻塞合成（请回 /remix 修正）。
        </span>
      </div>

      <div className="col scroll" style={{
        padding: "12px 14px 20px", gap: 12, background: "var(--bg-0)", flex: 1, minHeight: 0,
      }}>
        {/* 文案二创独立链路进度（URL 带 ?text_rewrite_job=xxx 时显示）*/}
        {trJobId && <TextRewriteProgress jobId={trJobId} currentRoute="qc"/>}

        {/* 文案二创"待合成清单"：从 match-table 拉每段二创+素材绑定 */}
        {trJobId && <TextRewriteComposeList jobId={trJobId}/>}

        {!activeParentId && !trJobId && (
          <div className="card col center" style={{ padding: "60px 20px", gap: 10, textAlign: "center" }}>
            <Icon name="check" size={24} style={{ color: "var(--fg-3)" }}/>
            <div style={{ fontSize: 13, color: "var(--fg-1)" }}>请上面选一个 job，或从「3·复刻爆款」点 OK 跳过来</div>
          </div>
        )}

        {activeParentId && tableData && (
          <>
            {/* 横向轨道 + QC 复选叠在 ③ 分镜行上方 */}
            <QcTrackPanel jobId={activeParentId} tableData={tableData}
                          qcMarks={qcMarks} onMark={updateQcMark}
                          allBgm={bgmList}/>
            {/* 合成参数 + 提交 + 进度 + 预览 */}
            <QcComposePanel parentJobId={activeParentId}
                            allMatched={allMatched} rowsCount={rows.length}
                            qcMarks={qcMarks}/>
          </>
        )}
      </div>
    </>
  );
};


// 复用 remix.jsx 的 HorizontalTrackPanel 视觉，但在 ③ 分镜上方插一行 QC 复选
const QcTrackPanel = ({ jobId, tableData, qcMarks, onMark, allBgm }) => {
  const rows = tableData?.rows || [];
  const colWidth = (sec) => Math.round(Math.max(220, Math.min(420, (sec || 3) * 60)));
  const LABEL_W = 96;
  const cellBase = {
    borderRight: "1px solid var(--line-soft)",
    borderBottom: "1px solid var(--line-soft)",
    padding: 6, boxSizing: "border-box", overflow: "hidden",
    background: "var(--bg-0)",
  };
  const labelBase = {
    ...cellBase, background: "var(--bg-1)", color: "var(--fg-2)",
    fontSize: 10.5, fontWeight: 600,
    position: "sticky", left: 0, zIndex: 2,
    width: LABEL_W, minWidth: LABEL_W, maxWidth: LABEL_W,
    display: "flex", alignItems: "center", justifyContent: "center",
  };
  const [bgmFilename, setBgmFilename] = React.useState((allBgm || [])[0]?.filename || null);
  React.useEffect(() => {
    if (!bgmFilename && (allBgm || []).length) setBgmFilename(allBgm[0].filename);
  }, [allBgm]);

  return (
    <div className="card" style={{ overflow: "hidden" }}>
      <div className="card-hd">
        <span className="card-title">质检轨道</span>
        <span className="card-sub">{rows.length} chunk · 列宽 ∝ 时长 · 每列 3 个 QC 按钮（分镜/口播/BGM）</span>
      </div>

      <div style={{ overflowX: "auto", maxHeight: 720 }}>
        {/* QC 复选行（按列） */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: 32 }}>QC</div>
          {rows.map(r => (
            <div key={`qc-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: 32,
              padding: "4px 6px", display: "flex", alignItems: "center",
              background: "color-mix(in oklch, var(--purple) 6%, var(--bg-0))",
            }}>
              <QcChunkChecks chunkId={r.chunk_id} marks={qcMarks} onChange={onMark}/>
            </div>
          ))}
        </div>

        {/* ① 文案 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: 64 }}>① 文案</div>
          {rows.map(r => (
            <div key={`tx-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: 64,
              fontSize: 11, lineHeight: 1.5, color: "var(--fg-0)",
            }}>
              <div className="mono" style={{ fontSize: 9, color: "var(--fg-3)", marginBottom: 2 }}>
                {r.chunk_id} · {r.duration_sec.toFixed(1)}s
              </div>
              {r.rewrite_text || <span style={{ color: "var(--fg-3)" }}>—</span>}
            </div>
          ))}
        </div>

        {/* ② 提示词 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: 56 }}>② 提示词</div>
          {rows.map(r => (
            <div key={`pr-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: 56,
              fontSize: 9.5, lineHeight: 1.4, color: "var(--fg-2)",
            }} title={r.aigc_prompt}>
              {r.aigc_prompt || <span style={{ color: "var(--fg-3)" }}>—</span>}
            </div>
          ))}
        </div>

        {/* ③ 分镜素材 — 优先复刻流水线 kh material_match 命中（material_remix），
            fallback 到 seed_library 直接命中（visual_remix.match）*/}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: 160 }}>③ 分镜</div>
          {rows.map(r => {
            const mr = r.material_remix;  // kh material_match 命中（segment_url + time_start/end）
            const m = r.visual_remix && r.visual_remix.match;  // seed_library 命中
            // 复刻流水线命中优先：用 #t=start,end Media Fragments 让浏览器只播命中段
            let videoSrc = "";
            let kindBadge = "";
            if (mr && mr.segment_url) {
              const t0 = (mr.time_start || 0).toFixed(2);
              const t1 = (mr.time_end || 0).toFixed(2);
              videoSrc = `${mr.segment_url}#t=${t0},${t1}`;
              kindBadge = "复刻命中";
            } else if (m && m.preview_url) {
              videoSrc = m.preview_url;
              kindBadge = "素材库";
            }
            return (
              <div key={`cl-${r.chunk_id}`} style={{
                ...cellBase, width: colWidth(r.duration_sec), height: 160, padding: 4,
                position: "relative",
              }}>
                {videoSrc ? (
                  <>
                    <video src={window.withApiToken(videoSrc)} controls preload="metadata"
                           style={{ width: "100%", height: "100%", background: "#000",
                                    borderRadius: 3, objectFit: "cover" }}/>
                    <span className="mono" style={{
                      position: "absolute", top: 6, left: 6, fontSize: 9,
                      padding: "1px 4px", borderRadius: 2,
                      background: kindBadge === "复刻命中"
                        ? "color-mix(in oklch, var(--cyan) 25%, rgba(0,0,0,.7))"
                        : "rgba(0,0,0,.7)",
                      color: kindBadge === "复刻命中" ? "var(--cyan)" : "var(--fg-2)",
                    }}>{kindBadge}</span>
                  </>
                ) : (
                  <div style={{
                    width: "100%", height: "100%",
                    border: "2px dashed var(--purple)", borderRadius: 4,
                    background: "color-mix(in oklch, var(--purple) 6%, var(--bg-0))",
                    display: "flex", alignItems: "center", justifyContent: "center",
                    color: "var(--purple)", fontSize: 11,
                  }}>
                    未匹配（请回 /remix）
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {/* ④ 口播 */}
        <div style={{ display: "flex", minWidth: "fit-content" }}>
          <div style={{ ...labelBase, height: 48 }}>④ 口播</div>
          {rows.map(r => (
            <div key={`tts-${r.chunk_id}`} style={{
              ...cellBase, width: colWidth(r.duration_sec), height: 48,
              padding: 6, display: "flex", alignItems: "center",
            }}>
              {r.rewrite_text ? (
                <audio src={API.compose.ttsPreviewUrl(r.rewrite_text)} controls preload="none"
                       style={{ width: "100%", height: 26 }}/>
              ) : <span style={{ fontSize: 9.5, color: "var(--fg-3)" }}>无文案</span>}
            </div>
          ))}
        </div>

        {/* ⑤ BGM 全宽 */}
        <div style={{
          display: "flex", minWidth: "fit-content", height: 56, alignItems: "center",
          background: "var(--bg-1)", borderTop: "1px solid var(--line-1)",
        }}>
          <div style={{ ...labelBase, height: 56, background: "var(--bg-1)" }}>⑤ BGM</div>
          <div className="row gap-8" style={{ padding: "0 10px", flex: 1, alignItems: "center" }}>
            <select className="input" value={bgmFilename || ""}
                    onChange={e => setBgmFilename(e.target.value || null)}
                    style={{ fontSize: 10.5, padding: "3px 6px", maxWidth: 260 }}>
              {(allBgm || []).length === 0 && <option value="">（无 BGM）</option>}
              {(allBgm || []).map(b => <option key={b.filename} value={b.filename}>{b.filename.slice(0, 36)}</option>)}
            </select>
            {bgmFilename && (
              <audio src={API.compose.bgmStreamUrl(bgmFilename)} controls preload="none"
                     key={bgmFilename}
                     style={{ flex: 1, height: 28, minWidth: 240 }}/>
            )}
            <span style={{ fontSize: 9.5, color: "var(--fg-3)" }}>BGM 选择仅用于试听 · 合成时见参数面板</span>
          </div>
        </div>
      </div>
    </div>
  );
};

// ─── 文案二创待合成清单（QC 页接收 ?text_rewrite_job= 时显示）─────────────────
//
// 数据源：GET /api/text-rewrite/jobs/{id}/match-table
// 展示：每段「二创口播 + 命中分镜素材 + tts 时长」+ 一个"开始合成"按钮（占位）
const TextRewriteComposeList = ({ jobId }) => {
  const [matchData, setMatchData] = React.useState(null);
  const [err, setErr] = React.useState("");

  React.useEffect(() => {
    let cancelled = false;
    const refresh = async () => {
      try {
        const d = await API.textRewrite.matchTable(jobId);
        if (!cancelled) setMatchData(d);
      } catch (e) {
        if (!cancelled) setErr(String(e.message || e));
      }
    };
    refresh();
    const t = setInterval(refresh, 4000);
    return () => { cancelled = true; clearInterval(t); };
  }, [jobId]);

  if (err) {
    return (
      <div className="card" style={{ padding: 12, fontSize: 11, color: "var(--fg-3)" }}>
        加载文案二创合成清单失败：{err}（任务也许还没跑完）
      </div>
    );
  }
  if (!matchData) {
    return (
      <div className="card" style={{ padding: 12, fontSize: 11, color: "var(--fg-3)" }}>
        加载文案二创合成清单…
      </div>
    );
  }
  const rows = matchData.rows || [];
  const hit = rows.filter(r => r.visual_remix && r.visual_remix.match).length;
  const allReady = rows.length > 0 && hit === rows.length;

  return (
    <div className="card">
      <div className="card-hd">
        <span className="card-title">📦 文案二创合成清单</span>
        <span className="card-sub">
          {rows.length} 段 · 已匹配 {hit}/{rows.length} 段分镜素材
        </span>
        <div className="grow"/>
        <button className="btn primary"
                disabled={!allReady}
                title={allReady ? "" : "请先在复刻爆款页把每段都匹配上分镜素材"}
                style={{
                  background: allReady ? "var(--cyan)" : "var(--bg-2)",
                  color: allReady ? "var(--bg-0)" : "var(--fg-3)",
                  cursor: allReady ? "pointer" : "not-allowed",
                  fontSize: 11, padding: "6px 14px",
                }}
                onClick={() => alert("合成端点尚未对接 text_rewrite job；待后续接入 compose API")}>
          ▶ 开始合成（{rows.length} 段）
        </button>
      </div>
      <div style={{ overflow: "auto", maxHeight: 480 }}>
        <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed" }}>
          <colgroup>
            <col style={{ width: 60 }}/>
            <col style={{ width: "40%" }}/>
            <col style={{ width: "32%" }}/>
            <col style={{ width: "20%" }}/>
          </colgroup>
          <thead>
            <tr>
              {["#", "二创口播", "分镜素材", "TTS 时长"].map((h, i) => (
                <th key={i} style={{
                  padding: "6px 10px", textAlign: "left",
                  fontSize: 10.5, fontWeight: 600, color: "var(--fg-2)",
                  background: "var(--bg-1)", borderBottom: "1px solid var(--line-1)",
                  position: "sticky", top: 0, zIndex: 1,
                }}>{h}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map(r => {
              const m = r.visual_remix && r.visual_remix.match;
              return (
                <tr key={r.chunk_id}>
                  <td style={{ padding: 8, fontSize: 9.5, fontFamily: "var(--mono)",
                               color: "var(--fg-3)", verticalAlign: "top",
                               borderBottom: "1px solid var(--line-soft)" }}>
                    #{r.index + 1}<br/>{r.chunk_id}
                  </td>
                  <td style={{ padding: 8, fontSize: 11, lineHeight: 1.55,
                               color: "var(--fg-0)", verticalAlign: "top",
                               borderBottom: "1px solid var(--line-soft)" }}>
                    {r.rewrite_text || <span style={{ color: "var(--fg-3)" }}>—</span>}
                  </td>
                  <td style={{ padding: 8, verticalAlign: "top",
                               borderBottom: "1px solid var(--line-soft)" }}>
                    {m ? (
                      <div className="col gap-3">
                        <video src={window.withApiToken(m.preview_url)} muted preload="metadata"
                               style={{ width: "100%", maxHeight: 90, borderRadius: 3,
                                        background: "#000" }}/>
                        <span className="mono" style={{ fontSize: 9, color: "var(--fg-3)",
                                                         overflow: "hidden", textOverflow: "ellipsis",
                                                         whiteSpace: "nowrap" }}>
                          {m.name}
                        </span>
                      </div>
                    ) : (
                      <span style={{ fontSize: 10, color: "var(--orange)" }}>
                        ⚠ 未匹配 · 去复刻爆款选片
                      </span>
                    )}
                  </td>
                  <td style={{ padding: 8, verticalAlign: "top", fontSize: 10,
                               fontFamily: "var(--mono)", color: "var(--purple)",
                               borderBottom: "1px solid var(--line-soft)" }}>
                    {r.tts_duration_sec.toFixed(1)}s
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
};


Object.assign(window, { QCPage, QcTrackPanel, QcComposePanel, QcChunkChecks,
                        TextRewriteComposeList });
