// pipeline-screen.jsx — the streaming/processing view.
// 4 stages with real-time-feeling log streams, individual + overall progress.

const TXT_PIPELINE = {
  vi: {
    building: "Đang dựng",
    overallProgress: "Tiến trình tổng thể",
    stageAComplete: "Giai đoạn A hoàn tất. Vui lòng xem lại các nhịp phân cảnh, chỉnh sửa mô tả hình ảnh/từ khóa tìm kiếm và tạo lại tài nguyên nếu cần trước khi tiếp tục.",
    saveScene: "Lưu phân cảnh",
    saving: "Đang lưu...",
    regenerate: "Tạo lại",
    regenerating: "Đang tạo lại...",
    continueBtn: "Tiếp tục tạo video",
    bulkRegen: "Tạo lại tài nguyên lỗi",
    startPhaseB: "Bắt đầu giai đoạn B",
    filters: "Bộ lọc",
    searchCaptions: "Tìm kiếm lời thoại...",
    allScenes: "Tất cả phân cảnh",
    unsavedEditsOnly: "Thay đổi chưa lưu",
    hasUnsavedEdits: "Có chỉnh sửa chưa lưu",
    allMedia: "Tất cả phương tiện",
    unsplashQueries: "Từ khóa Unsplash",
    aiImagePrompts: "Yêu cầu ảnh AI",
    videoQueries: "Từ khóa Video",
    showAllDetails: "Hiện tất cả chi tiết",
    collapseAll: "Thu gọn tất cả",
    sceneTitle: "Cảnh",
    captionLocked: "Lời thoại (Thuyết minh - đã khóa)",
    visualDesc: "Mô tả hình ảnh (Yêu cầu cho Agent 1)",
    mediaQueries: "Từ khóa tìm kiếm phương tiện",
    noMedia: "Không có yêu cầu phương tiện nào cho cảnh này",
    saveEditsBtn: "Lưu chỉnh sửa",
    queryLabel: "Từ khóa",
    promptLabel: "Lời thoại",
    typeLabel: "Loại",
    previewTitle: "Xem trước",
    jobTitle: "Thông tin Job",
    jobId: "Mã Job",
    status: "Trạng thái",
    style: "Phong cách",
    language: "Ngôn ngữ",
    aspect: "Tỷ lệ",
    words: "Số từ",
    failed: "Thất bại",
    closeBtn: "Đóng (Esc)",
    livePreview: "trực tiếp · {stage}",
    doneStatus: "xong",
    activeStatus: "đang chạy",
    pendingStatus: "chờ",
    secLabel: "giây",
    audios: "♪ Âm thanh",
    searches: "⊕ Tìm kiếm",
    aiPhotos: "✦ Ảnh AI",
    videos: "▶ Video",
    beatsLabel: "nhịp_{num}",
    sceneNumLabel: "CẢNH {num}",
  },
  en: {
    building: "Building",
    overallProgress: "Overall Progress",
    stageAComplete: "Stage A complete. Please review the storyboard beats, edit visual prompts/queries, and regenerate any assets before continuing.",
    saveScene: "Save Scene",
    saving: "Saving...",
    regenerate: "Regenerate",
    regenerating: "Regenerating...",
    continueBtn: "Continue Video Generation",
    bulkRegen: "Bulk Regenerate Failed",
    startPhaseB: "Start Phase B",
    filters: "Filters",
    searchCaptions: "Search captions...",
    allScenes: "All Scenes",
    unsavedEditsOnly: "Unsaved changes",
    hasUnsavedEdits: "Has unsaved edits",
    allMedia: "All media",
    unsplashQueries: "Unsplash queries",
    aiImagePrompts: "AI Image prompts",
    videoQueries: "Video queries",
    showAllDetails: "Show all details",
    collapseAll: "Collapse all",
    sceneTitle: "Scene",
    captionLocked: "Caption (Voiceover - locked)",
    visualDesc: "Visual Description (Agent 1 prompt)",
    mediaQueries: "Media Search Queries",
    noMedia: "No media requests for this scene",
    saveEditsBtn: "Save edits",
    queryLabel: "Query",
    promptLabel: "Prompt",
    typeLabel: "Type",
    previewTitle: "Preview",
    jobTitle: "Job",
    jobId: "Job ID",
    status: "Status",
    style: "Style",
    language: "Language",
    aspect: "Aspect",
    words: "Words",
    failed: "Failed",
    closeBtn: "Close (Esc)",
    livePreview: "live · {stage}",
    doneStatus: "done",
    activeStatus: "active",
    pendingStatus: "pending",
    secLabel: "s",
    audios: "♪ Audio",
    searches: "⊕ Search",
    aiPhotos: "✦ AI photo",
    videos: "▶ Video",
    beatsLabel: "beat_{num}",
    sceneNumLabel: "SCN {num}",
  }
};

const STAGE_TRANSLATIONS = {
  vi: {
    analyze: { name: "Phân tích kịch bản", sub: "Phân tích giọng điệu, nhịp truyện, thực thể" },
    visual: { name: "Thiết kế hình ảnh", sub: "Tìm kiếm cảnh phim, tạo hình ảnh minh họa" },
    post: { name: "Hậu kỳ", sub: "Cắt ghép, hiệu ứng âm thanh, đồ họa chuyển động" },
    render: { name: "Xuất video & Tối ưu hóa", sub: "Mã hóa bản gốc 4K + bản web 1080p" },
  },
  en: {
    analyze: { name: "Script Analysis", sub: "Parsing tone, beats, named entities" },
    visual: { name: "Visual Design", sub: "Sourcing footage, generating fills" },
    post: { name: "Post-Production", sub: "Cuts, SFX, mix, motion graphics" },
    render: { name: "Render & Optimize", sub: "Encode 4K master + 1080p web" },
  }
};

const STAGES = [
  {
    id: "analyze",
    name: "Script Analysis",
    sub: "Parsing tone, beats, named entities",
    icon: "doc",
    duration: 4200,
    logs: [
      "tokenizer: loaded en_core_web_trf",
      "split into 14 narrative beats",
      "detected tone: \"contemplative · documentary\"",
      "mood vector: { wonder: 0.82, melancholy: 0.41 }",
      "extracted entities: Voyager 1, Voyager 2, Cape Canaveral, NASA, 1977",
      "key questions identified: 3",
      "estimated runtime: 1m 12s @ 1.00× pacing",
      "scene plan emitted → 11 scenes",
    ],
  },
  {
    id: "visual",
    name: "Visual Design",
    sub: "Sourcing footage, generating fills",
    icon: "frame",
    duration: 7000,
    logs: [
      "querying stock: pexels, pond5, internal cache",
      "matched scene 01: \"cape canaveral 1977 launch\" (4 hits)",
      "matched scene 02: \"voyager spacecraft assembly\" (2 hits)",
      "matched scene 03: \"golden record etching macro\" (0 hits) — generating",
      "generating scene 03 with sdxl-turbo @ 1920×1080",
      "matched scene 04: \"cape canaveral aerial\" (6 hits)",
      "matched scene 05: \"voyager probe trajectory animation\"",
      "generating scene 06 fill: \"deep space, distant sun\"",
      "generating scene 09 fill: \"radio dish, slow rotation\"",
      "matched scene 10: \"interstellar medium plot\"",
      "11 / 11 scenes resolved",
      "color-matching across scenes → palette: warm/desaturated",
    ],
  },
  {
    id: "post",
    name: "Post-Production",
    sub: "Cuts, SFX, mix, motion graphics",
    icon: "wave",
    duration: 5800,
    logs: [
      "ffmpeg: trimming 11 clips to scene durations",
      "applying LUT: doc_warm_v3.cube",
      "subtitle pass: 64px Arial, white / black outline",
      "highlighting word-emphasis on 41 keywords",
      "sfx layer: \"low_rumble_bed_05.wav\" → -22 LUFS",
      "sfx layer: \"radio_static_short_02.wav\" @ 00:00:47",
      "music: \"distant_signal_120bpm.flac\" → ducked under VO",
      "motion graphics: lower-third title cards × 2",
      "ken-burns on still scenes 03, 06, 09",
      "loudness normalize → -14 LUFS integrated",
    ],
  },
  {
    id: "render",
    name: "Render & Optimize",
    sub: "Encode 4K master + 1080p web",
    icon: "play",
    duration: 4400,
    logs: [
      "render queue: h264_nvenc, 1080p, crf 18",
      "frame 0000 / 1728",
      "frame 0288 / 1728  ·  16.7 %",
      "frame 0576 / 1728  ·  33.3 %",
      "frame 0864 / 1728  ·  50.0 %",
      "frame 1152 / 1728  ·  66.7 %",
      "frame 1440 / 1728  ·  83.3 %",
      "frame 1728 / 1728  ·  100 %  ✓",
      "writing master 4K (hevc) in background",
      "thumbnail extracted @ 00:00:12",
      "ready: skillsvideo_voyager_1080p.mp4 · 42.8 MB",
    ],
  },
];

const STAGE_ICONS = {
  doc: <><path d="M4 2h6l2 2v10H4z" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M6 6h4 M6 8h4 M6 10h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></>,
  frame: <><rect x="2.5" y="3.5" width="11" height="9" rx="1" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M7 6.5 L10 8 L7 9.5 Z" fill="currentColor"/></>,
  wave: <><path d="M2 8 L4 8 L4 5 L6 5 L6 11 L8 11 L8 3 L10 3 L10 9 L12 9 L12 6 L14 6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></>,
  play: <><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.4"/><path d="M6.5 5.5 L11 8 L6.5 10.5 Z" fill="currentColor"/></>,
};

function computeAssetStats(scenes) {
  if (!scenes || scenes.length === 0) return null;
  const audio = { done: 0, total: 0 };
  const unsplash = { done: 0, total: 0 };
  const ai = { done: 0, total: 0 };
  const video = { done: 0, total: 0 };
  for (let i = 0; i < scenes.length; i++) {
    const scene = scenes[i];
    audio.total += 1;
    if (scene.local_audio) audio.done += 1;
    let imgIdx = 0;
    let vidIdx = 0;
    for (const req of (scene.media_requests || [])) {
      if (req.type === "unsplash") {
        unsplash.total += 1;
        if (scene.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) unsplash.done += 1;
        imgIdx++;
      } else if (req.type === "ai" || req.type === "gemini") {
        ai.total += 1;
        if (scene.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) ai.done += 1;
        imgIdx++;
      } else if (req.type === "video") {
        video.total += 1;
        if (scene.local_videos?.includes(`scene_${i}_vid_${vidIdx}.mp4`)) video.done += 1;
        vidIdx++;
      }
    }
  }
  return { audio, unsplash, ai, video };
}

function AssetCounters({ scenes, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  const stats = computeAssetStats(scenes);
  if (!stats) return null;
  const items = [
    { key: "audio",    label: t.audios,      ...stats.audio    },
    { key: "unsplash", label: t.searches,    ...stats.unsplash },
    { key: "ai",       label: t.aiPhotos,    ...stats.ai       },
    { key: "video",    label: t.videos,      ...stats.video    },
  ].filter(x => x.total > 0);
  if (items.length === 0) return null;
  return (
    <div style={{ display: "flex", gap: 18, flexWrap: "wrap", paddingTop: 2 }}>
      {items.map(({ key, label, done, total }) => {
        const complete = done === total;
        const inProgress = done > 0 && !complete;
        const dotColor = complete ? "var(--ok)" : inProgress ? "var(--accent)" : "var(--fg-4)";
        const numColor = complete ? "var(--ok)" : inProgress ? "var(--accent)" : "var(--fg-2)";
        return (
          <div key={key} style={{ display: "flex", alignItems: "center", gap: 5 }} className="mono">
            <span style={{ width: 6, height: 6, borderRadius: 999, background: dotColor, display: "inline-block", flexShrink: 0 }}/>
            <span style={{ fontSize: 11, color: "var(--fg-3)" }}>{label}</span>
            <span style={{ fontSize: 12, fontWeight: 600, color: numColor }}>{done}</span>
            <span style={{ fontSize: 11, color: "var(--fg-4)" }}>/ {total}</span>
          </div>
        );
      })}
    </div>
  );
}

function fmtTime(ms) {
  const s = Math.floor(ms / 1000);
  const m = Math.floor(s / 60);
  const r = s % 60;
  const cs = Math.floor((ms % 1000) / 10);
  return `${String(m).padStart(2,"0")}:${String(r).padStart(2,"0")}.${String(cs).padStart(2,"0")}`;
}

// Map BE status string → index in STAGES (0..3) + completion flag
const STATUS_MAP = {
  PENDING:               { stageIdx: 0, done: false },
  GENERATING_SCRIPT:     { stageIdx: 0, done: false },
  GENERATING_ASSETS:     { stageIdx: 1, done: false },
  AWAITING_REVIEW:       { stageIdx: 1, done: true  }, // assets stage finished, paused
  REGENERATING_MEDIA:    { stageIdx: 1, done: true  },
  GENERATING_CODE:       { stageIdx: 2, done: false },
  RENDERING:             { stageIdx: 3, done: false },
  GENERATING_SUBTITLES:  { stageIdx: 3, done: false },
  COMPLETED:             { stageIdx: 3, done: true  },
  FAILED:                { stageIdx: 0, done: false, failed: true },
};

function PipelineScreen({ onComplete, onError, state, jobId, uiLang = "vi" }) {
  const startRef = React.useRef(performance.now());
  const [now, setNow] = React.useState(0);
  const [job, setJob] = React.useState({ status: "PENDING", progress: 0, error: null, video_url: null });
  const completedRef = React.useRef(false);
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;

  // wall-clock animation tick for log streams / preview animations
  React.useEffect(() => {
    let raf;
    const tick = () => {
      setNow(performance.now() - startRef.current);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  // poll backend status every 1.5s
  React.useEffect(() => {
    if (!jobId) return;
    let cancelled = false;
    const poll = async () => {
      try {
        const res = await fetch(`/api/status/${jobId}`);
        if (!res.ok) throw new Error(`status ${res.status}`);
        const data = await res.json();
        if (cancelled) return;
        setJob(data);
        if (data.status === "COMPLETED" && !completedRef.current) {
          completedRef.current = true;
          const url = data.video_url || `/files/${jobId}/output.mp4`;
          setTimeout(() => onComplete && onComplete(url), 600);
        } else if (data.status === "FAILED") {
          onError && onError(data.error || "Job failed");
        }
      } catch (e) {
        if (!cancelled) onError && onError(String(e.message || e));
      }
    };
    poll();
    const id = setInterval(poll, 1500);
    return () => { cancelled = true; clearInterval(id); };
  }, [jobId, onComplete, onError]);

  const mapped = STATUS_MAP[job.status] || STATUS_MAP.PENDING;
  const activeIdx = mapped.stageIdx;
  const stageDone = mapped.done;

  // compute per-stage progress: stages < activeIdx are done, == activeIdx is active, > activeIdx pending
  const stageInfo = STAGES.map((s, i) => {
    const isDone = i < activeIdx || (i === activeIdx && stageDone);
    const isActive = i === activeIdx && !stageDone;
    let p = 0;
    if (isDone) p = 1;
    else if (isActive) {
      // animate log lines based on wall-clock time spent in this stage; cap at 0.9 until next status arrives
      const elapsed = (now / 1000) % 12;
      p = Math.min(0.9, elapsed / 12 + (job.progress || 0) * 0.1);
    }
    const stageTrans = STAGE_TRANSLATIONS[uiLang]?.[s.id] || {};
    return { 
      ...s, 
      name: stageTrans.name || s.name,
      sub: stageTrans.sub || s.sub,
      p, 
      active: isActive, 
      done: isDone 
    };
  });

  const overallP = stageDone ? 1 : (activeIdx + (stageInfo[activeIdx]?.p || 0)) / STAGES.length;
  const totalDuration = STAGES.reduce((a, s) => a + s.duration, 0);

  const inReview = job.status === "AWAITING_REVIEW" || job.status === "REGENERATING_MEDIA" || job.status === "GENERATING_ASSETS";

  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: inReview ? "1fr" : "1fr 420px",
      gap: 18,
      padding: "0 28px 28px",
      maxWidth: inReview ? 1800 : 1600,
      margin: "0 auto",
    }}>
      {/* main column */}
      <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
        {job.status === "FAILED" && (
          <div style={{
            background: "var(--bg-1)",
            border: "1px solid var(--err)",
            borderRadius: "var(--radius-lg)",
            padding: "12px 16px",
            color: "var(--err)",
            fontSize: 13,
          }}>
            <strong className="mono" style={{ letterSpacing: "0.08em", textTransform: "uppercase", fontSize: 11 }}>{t.failed}</strong>
            <div style={{ marginTop: 4, color: "var(--fg-1)" }}>{job.error || "Unknown error"}</div>
          </div>
        )}

        {/* overall progress strip */}
        <div style={{
          background: "var(--bg-1)",
          border: "1px solid var(--line)",
          borderRadius: "var(--radius-lg)",
          padding: "14px 18px",
          display: "flex", flexDirection: "column", gap: 10,
        }}>
          <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
            <div style={{ display: "flex", alignItems: "baseline", gap: 12 }}>
              <span className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--fg-3)" }}>{t.building}</span>
              <span style={{ fontSize: 18, fontWeight: 500, color: "var(--fg-0)" }}>
                skillsvideo_<span style={{ color: "var(--accent)" }}>voyager</span>_1080p.mp4
              </span>
            </div>
            <div style={{ display: "flex", gap: 18, fontSize: 12, color: "var(--fg-2)" }} className="mono tabular">
              <span>{fmtTime(now)} <span style={{ color: "var(--fg-4)" }}>/ {fmtTime(totalDuration)}</span></span>
              <span style={{ color: "var(--accent)" }}>{Math.round(overallP * 100)}%</span>
            </div>
          </div>
          <div style={{ position: "relative", height: 4, background: "var(--bg-3)", borderRadius: 999, overflow: "hidden" }}>
            <div style={{
              width: `${overallP * 100}%`,
              height: "100%",
              background: "var(--accent)",
              boxShadow: "0 0 12px var(--accent)",
              transition: "width 0.05s linear",
            }}/>
            {stageInfo.slice(0, -1).map((s, i) => (
              <div key={i} style={{
                position: "absolute", top: -2, bottom: -2,
                left: `${(s.end / totalDuration) * 100}%`,
                width: 1,
                background: "var(--bg-0)",
              }}/>
            ))}
          </div>
          {job.scenes && job.scenes.length > 0 && <AssetCounters scenes={job.scenes} uiLang={uiLang}/>}
        </div>

        {/* stages OR review */}
        {(job.status === "AWAITING_REVIEW" || job.status === "REGENERATING_MEDIA" || job.status === "GENERATING_ASSETS") ? (
          <ReviewScreen
            job={job}
            jobId={jobId}
            onJobUpdate={setJob}
            isRegenerating={job.status === "REGENERATING_MEDIA"}
            uiLang={uiLang}
          />
        ) : (
          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
            {stageInfo.map((s, i) => <StageCard key={s.id} stage={s} index={i+1} uiLang={uiLang}/>)}
          </div>
        )}
      </div>

      {/* preview + meta (hidden during review for max real estate) */}
      {!inReview && (
        <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
          <PreviewPane stageInfo={stageInfo} now={now} job={job} uiLang={uiLang} />
          <MetaPanel state={state} jobId={jobId} jobStatus={job.status} uiLang={uiLang}/>
        </div>
      )}
    </div>
  );
}

function StageCard({ stage, index, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  const logRef = React.useRef(null);
  const visibleLogs = stage.logs.slice(0, Math.floor(stage.p * stage.logs.length) + (stage.active ? 1 : 0)).filter(Boolean);

  React.useEffect(() => {
    if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
  }, [visibleLogs.length]);

  const status = stage.done ? t.doneStatus : stage.active ? t.activeStatus : t.pendingStatus;

  return (
    <div style={{
      background: "var(--bg-1)",
      border: `1px solid ${stage.active ? "var(--accent-line)" : "var(--line)"}`,
      borderRadius: "var(--radius-lg)",
      overflow: "hidden",
      opacity: stage.done && !stage.active ? 0.7 : 1,
      transition: "opacity .3s, border-color .2s",
    }}>
      <header style={{
        display: "flex", alignItems: "center", gap: 12,
        padding: "10px 14px",
        background: stage.active ? "linear-gradient(90deg, var(--accent-soft), transparent 60%)" : "transparent",
      }}>
        <div style={{
          display: "flex", alignItems: "center", justifyContent: "center",
          width: 28, height: 28, borderRadius: 6,
          background: stage.done ? "var(--ok)" : stage.active ? "var(--accent)" : "var(--bg-3)",
          color: stage.done || stage.active ? "var(--accent-fg)" : "var(--fg-2)",
        }}>
          {stage.done ? (
            <svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 8.5 L6.5 12 L13 4.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
          ) : stage.active ? (
            <Spinner/>
          ) : (
            <span className="mono" style={{ fontSize: 11, fontWeight: 600 }}>{index}</span>
          )}
        </div>
        <div style={{ flex: 1, display: "flex", alignItems: "baseline", gap: 12 }}>
          <span style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-0)" }}>{stage.name}</span>
          <span style={{ fontSize: 12, color: "var(--fg-3)" }}>{stage.sub}</span>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
          {stage.active && (
            <span className="mono" style={{ fontSize: 11, color: "var(--accent)" }}>
              {Math.round(stage.p * 100)}%
            </span>
          )}
          <span className="mono" style={{
            fontSize: 10, letterSpacing: "0.1em", textTransform: "uppercase",
            color: stage.done ? "var(--ok)" : stage.active ? "var(--accent)" : "var(--fg-4)",
          }}>{status}</span>
        </div>
      </header>

      {/* progress strip */}
      <div style={{ height: 2, background: "var(--bg-2)", overflow: "hidden" }}>
        <div style={{
          width: `${stage.p * 100}%`,
          height: "100%",
          background: stage.done ? "var(--ok)" : "var(--accent)",
          transition: "width 0.05s linear",
        }}/>
      </div>

      {/* log stream */}
      {(stage.active || stage.done) && visibleLogs.length > 0 && (
        <div ref={logRef} style={{
          maxHeight: stage.active ? 124 : 0,
          overflowY: "auto",
          padding: stage.active ? "8px 14px" : 0,
          background: "var(--bg-0)",
          transition: "max-height .25s, padding .25s",
          fontFamily: "var(--font-mono)",
          fontSize: 11.5,
          color: "var(--fg-2)",
          lineHeight: 1.6,
        }}>
          {visibleLogs.map((line, i) => (
            <div key={i} style={{
              display: "flex", gap: 12,
              color: i === visibleLogs.length - 1 && stage.active ? "var(--fg-0)" : "var(--fg-2)",
              animation: i === visibleLogs.length - 1 ? "fadeIn .15s ease-out" : "none",
            }}>
              <span style={{ color: "var(--fg-4)", flexShrink: 0 }}>
                {String(i).padStart(3, "0")}
              </span>
              <span style={{ flex: 1 }}>{line}{i === visibleLogs.length - 1 && stage.active && <span style={{ marginLeft: 4, animation: "blink 1s step-end infinite" }}>▍</span>}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function Spinner() {
  return (
    <svg width="14" height="14" viewBox="0 0 16 16" style={{ animation: "spin 1.1s linear infinite" }}>
      <style>{`@keyframes spin { to { transform: rotate(360deg) } } svg { transform-origin: center }`}</style>
      <circle cx="8" cy="8" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.6" opacity="0.25"/>
      <path d="M8 2.5 a5.5 5.5 0 0 1 5.5 5.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
    </svg>
  );
}

function PreviewPane({ stageInfo, now, job, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  // pick the active stage's visual treatment
  const active = stageInfo.find(s => s.active) || stageInfo[stageInfo.length - 1];
  return (
    <section style={{
      background: "var(--bg-1)",
      border: "1px solid var(--line)",
      borderRadius: "var(--radius-lg)",
      overflow: "hidden",
    }}>
      <header style={{
        padding: "10px 14px",
        borderBottom: "1px solid var(--line)",
        display: "flex", alignItems: "center", justifyContent: "space-between",
      }}>
        <span className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--fg-3)" }}>{t.previewTitle}</span>
        <span className="mono" style={{ fontSize: 11, color: "var(--fg-2)" }}>{t.livePreview.replace("{stage}", active?.name?.toLowerCase())}</span>
      </header>
      <div style={{ aspectRatio: "16 / 9", position: "relative", background: "var(--bg-0)", overflow: "hidden" }}>
        {/* scene mockup that morphs by stage */}
        {active?.id === "analyze" && <AnalyzeViz now={now} job={job} uiLang={uiLang}/>}
        {active?.id === "visual" && <VisualViz now={now} job={job} uiLang={uiLang}/>}
        {active?.id === "post" && <PostViz now={now} job={job} uiLang={uiLang}/>}
        {(active?.id === "render" || stageInfo[3].done) && <RenderViz now={now} done={stageInfo[3].done} job={job} uiLang={uiLang}/>}

        {/* scan line */}
        <div style={{
          position: "absolute", left: 0, right: 0, top: 0, height: 60,
          background: "linear-gradient(180deg, transparent, var(--accent-soft) 50%, transparent)",
          animation: "scan 2.4s linear infinite",
          pointerEvents: "none",
        }}/>
      </div>
    </section>
  );
}

function AnalyzeViz({ now, job, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  const scenes = job?.scenes || [];
  const beats = scenes.length > 0 
    ? scenes.map(s => s.caption || s.visual_description || (uiLang === "vi" ? "Phân cảnh..." : "Scene..."))
    : (uiLang === "vi" 
       ? ["Năm 1977.", "NASA phóng tàu.", "Voyager 1 & 2.", "Đĩa ghi vàng.", "Thư trong chai.", "47 năm sau.", "Vẫn đang truyền tín hiệu."]
       : ["Year 1977.", "NASA launches.", "Voyager 1 & 2.", "Golden record.", "Bottle messages.", "Forty-seven years later.", "Still transmitting."]);
  return (
    <div style={{
      position: "absolute", inset: 0,
      padding: 16,
      fontFamily: "var(--font-mono)",
      fontSize: 10.5,
      color: "var(--fg-2)",
      lineHeight: 1.8,
      background: "linear-gradient(180deg, var(--bg-1), var(--bg-0))",
      overflow: "hidden",
    }}>
      {beats.map((b, i) => (
        <div key={i} style={{
          display: "flex", gap: 10,
          opacity: now > 200 + i * 350 ? 1 : 0.15,
          transition: "opacity .25s",
        }}>
          <span style={{ color: "var(--fg-4)" }}>{t.beatsLabel.replace("{num}", String(i + 1).padStart(2, "0"))}</span>
          <span style={{ color: now > 200 + i * 350 ? "var(--fg-0)" : "var(--fg-3)" }}>{b}</span>
          {now > 400 + i * 350 && <span style={{ color: "var(--accent)" }}>✓</span>}
        </div>
      ))}
    </div>
  );
}

function VisualViz({ now, job, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  // grid of "scene thumbnails" filling in
  const scenes = job?.scenes || Array.from({ length: 11 });
  return (
    <div style={{
      position: "absolute", inset: 0,
      display: "grid",
      gridTemplateColumns: "repeat(4, 1fr)",
      gridTemplateRows: "repeat(3, 1fr)",
      gap: 2,
      background: "var(--bg-1)",
    }}>
      {scenes.map((s, i) => {
        const appearAt = 4200 + i * 240;  // stage starts at 4200ms
        const visible = now > appearAt || job?.status !== "GENERATING_ASSETS";
        const hueShift = (i * 31) % 360;
        
        let imageUrl = null;
        if (s?.local_images?.length > 0) {
            imageUrl = `/files/${job.job_id}/public/${s.local_images[0]}`;
        } else if (s?.local_image) {
            imageUrl = `/files/${job.job_id}/public/${s.local_image}`;
        }
        
        let videoUrl = null;
        if (s?.local_videos?.length > 0) {
            videoUrl = `/files/${job.job_id}/public/${s.local_videos[0]}`;
        }

        return (
          <div key={i} style={{
            position: "relative",
            background: visible && !imageUrl && !videoUrl
              ? `linear-gradient(135deg, var(--bg-2), var(--bg-1))`
              : "var(--bg-0)",
            transition: "background .2s",
            display: "flex", alignItems: "center", justifyContent: "center",
            overflow: "hidden",
          }}>
            {imageUrl && <img src={imageUrl} style={{width: '100%', height: '100%', objectFit: 'cover', position: 'absolute'}} />}
            {videoUrl && <video src={videoUrl} autoPlay loop muted style={{width: '100%', height: '100%', objectFit: 'cover', position: 'absolute'}} />}

            {visible && !imageUrl && !videoUrl && (
              <span className="mono" style={{
                fontSize: 9, color: "rgba(255,255,255,0.7)",
                letterSpacing: "0.06em",
                padding: "2px 4px",
                background: "rgba(0,0,0,0.4)",
                borderRadius: 2,
                position: "relative",
                zIndex: 1,
              }}>{t.sceneNumLabel.replace("{num}", String(i + 1).padStart(2, "0"))}</span>
            )}
            {!visible && !imageUrl && !videoUrl && (
              <div style={{ width: "60%", height: 1, background: "var(--fg-4)", animation: "pulse 0.8s ease-in-out infinite" }}/>
            )}
          </div>
        );
      })}
    </div>
  );
}

function PostViz({ now, job, uiLang = "vi" }) {
  // waveform + subtitle overlay simulation
  const bars = 80;
  return (
    <div style={{
      position: "absolute", inset: 0,
      background: "linear-gradient(180deg, var(--bg-1), var(--bg-0))",
      display: "flex", flexDirection: "column",
      padding: 20,
    }}>
      <div style={{ flex: 1, position: "relative" }}>
        {/* subtitle preview */}
        <div style={{
          position: "absolute", bottom: 8, left: "50%", transform: "translateX(-50%)",
          padding: "4px 10px",
          background: "rgba(0,0,0,0.7)",
          borderRadius: 3,
          color: "white",
          fontWeight: 700,
          fontSize: 14,
          fontFamily: "Arial, sans-serif",
          whiteSpace: "nowrap",
        }}>
          {uiLang === "vi" ? (
            <>chúng vẫn đang <span style={{ background: "#FFD60A", color: "#0a0a0a", padding: "1px 4px", borderRadius: 2 }}>truyền tín hiệu</span></>
          ) : (
            <>they are <span style={{ background: "#FFD60A", color: "#0a0a0a", padding: "1px 4px", borderRadius: 2 }}>still transmitting</span></>
          )}
        </div>
      </div>
      {/* waveform */}
      <div style={{ display: "flex", alignItems: "center", gap: 1.5, height: 56 }}>
        {Array.from({ length: bars }).map((_, i) => {
          const phase = (i / bars) * Math.PI * 4 + now * 0.003;
          const h = (Math.sin(phase) * 0.4 + Math.sin(phase * 1.7) * 0.3 + 0.5) * 50 + 4;
          const isActive = i < (now / 12000) * bars;
          return (
            <div key={i} style={{
              flex: 1,
              height: h,
              background: isActive ? "var(--accent)" : "var(--fg-4)",
              borderRadius: 1,
            }}/>
          );
        })}
      </div>
    </div>
  );
}

function RenderViz({ now, done, job, uiLang = "vi" }) {
  return (
    <div style={{
      position: "absolute", inset: 0,
      background: `
        radial-gradient(ellipse at 30% 40%, var(--bg-3) 0%, transparent 50%),
        radial-gradient(ellipse at 70% 60%, var(--bg-2) 0%, transparent 60%),
        linear-gradient(180deg, var(--bg-1), var(--bg-0))
      `,
      display: "flex", alignItems: "center", justifyContent: "center",
      overflow: "hidden",
    }}>
      <svg viewBox="0 0 320 180" style={{ width: "100%", height: "100%", position: "absolute" }}>
        <circle cx="240" cy="48" r="36" fill="var(--fg-4)" opacity="0.4"/>
        <circle cx="60" cy="30" r="0.8" fill="var(--fg-1)" opacity="0.4"/>
        <circle cx="120" cy="22" r="0.5" fill="var(--fg-1)" opacity="0.3"/>
        <circle cx="180" cy="38" r="0.6" fill="var(--fg-1)" opacity="0.4"/>
        <g transform="translate(80, 100)" fill="var(--fg-4)" opacity="0.5">
          <rect x="-2" y="-4" width="4" height="8"/>
          <rect x="-12" y="-2" width="8" height="4"/>
          <rect x="4" y="-2" width="8" height="4"/>
        </g>
      </svg>
      <div style={{ position: "absolute", bottom: 14, left: "50%", transform: "translateX(-50%)",
        padding: "5px 12px", background: "rgba(0,0,0,0.78)", color: "white",
        fontFamily: "Arial, sans-serif", fontWeight: 700, fontSize: 16, borderRadius: 3, whiteSpace: "nowrap" }}>
        {uiLang === "vi" ? (
          <>Tàu Voyager 1 và Voyager 2 vẫn đang <span style={{ background: "#FFD60A", color: "#0a0a0a", padding: "1px 5px", borderRadius: 2 }}>truyền tín hiệu</span>.</>
        ) : (
          <>Voyager 1 and Voyager 2 are <span style={{ background: "#FFD60A", color: "#0a0a0a", padding: "1px 5px", borderRadius: 2 }}>still transmitting</span>.</>
        )}
      </div>
      {done && (
        <div style={{ position: "absolute", top: 12, right: 12,
          padding: "4px 8px", background: "var(--ok)", color: "var(--accent-fg)",
          fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
          letterSpacing: "0.1em", borderRadius: 3 }}>{uiLang === "vi" ? "SẴN SÀNG" : "READY"}</div>
      )}
    </div>
  );
}

function MetaPanel({ state, jobId, jobStatus, uiLang = "vi" }) {
  const t = TXT_PIPELINE[uiLang] || TXT_PIPELINE.vi;
  const wordCount = state.source.trim() ? state.source.trim().split(/\s+/).length : 320;
  const stylePreset = STYLE_PRESETS.find(s => s.id === state.settings.style) || {};
  const styleName = translateStylePreset(stylePreset, uiLang).name || state.settings.style;
  return (
    <section style={{
      background: "var(--bg-1)",
      border: "1px solid var(--line)",
      borderRadius: "var(--radius-lg)",
      padding: 16,
      display: "flex", flexDirection: "column", gap: 14,
    }}>
      <div className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--fg-3)" }}>{t.jobTitle}</div>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, fontSize: 12.5 }}>
        {[
          [t.jobId, jobId ? jobId.slice(0, 8) : "—"],
          [t.status, jobStatus || "—"],
          [t.style, styleName],
          [t.language, LANGUAGES.find(l => l.code === state.settings.language)?.label || state.settings.language],
          [t.aspect, state.settings.aspect],
          [t.words, wordCount],
        ].map(([k, v]) => (
          <div key={k}>
            <div style={{ fontSize: 10.5, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: "0.08em" }} className="mono">{k}</div>
            <div style={{ color: "var(--fg-0)" }}>{v}</div>
          </div>
        ))}
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────────────────
// MediaPreviewModal — lightbox for full-size image / playable video preview.
// ─────────────────────────────────────────────────────────────────────────
function MediaPreviewModal({ media, onClose, uiLang }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  if (!media) return null;
  const { url, type, title, query } = media;
  const isVideo = type === "video";

  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, zIndex: 100,
        background: "rgba(0,0,0,0.88)",
        display: "flex", alignItems: "center", justifyContent: "center",
        padding: 40,
        cursor: "zoom-out",
      }}>
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          maxWidth: "92vw",
          maxHeight: "92vh",
          display: "flex", flexDirection: "column", gap: 12,
          alignItems: "center",
          cursor: "default",
        }}>
        {isVideo ? (
          <video src={url} controls autoPlay loop style={{
            maxWidth: "92vw", maxHeight: "78vh",
            borderRadius: 8, boxShadow: "0 20px 60px rgba(0,0,0,0.6)",
            background: "#000",
          }}/>
        ) : (
          <img src={url} alt={query || ""} style={{
            maxWidth: "92vw", maxHeight: "78vh",
            objectFit: "contain",
            borderRadius: 8, boxShadow: "0 20px 60px rgba(0,0,0,0.6)",
          }}/>
        )}
        <div style={{
          display: "flex", flexDirection: "column", alignItems: "center", gap: 4,
          color: "var(--fg-0)", fontSize: 13, maxWidth: "80vw", textAlign: "center",
        }}>
          <div className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--fg-3)" }}>
            {title} · {type}
          </div>
          {query && <div style={{ color: "var(--fg-1)" }}>{query}</div>}
        </div>
        <button onClick={onClose} style={{
          padding: "8px 16px",
          background: "var(--bg-2)",
          border: "1px solid var(--line)",
          borderRadius: "var(--radius)",
          color: "var(--fg-0)",
          fontSize: 12.5,
          marginTop: 4,
        }}>{uiLang === "en" ? "Close (Esc)" : "Đóng (Esc)"}</button>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────
// ReviewScreen — shown when status === AWAITING_REVIEW. Lets user inspect
// every scene, edit visual_description and media queries, regenerate any
// single media item, then click Continue to start the code+render phase.
// Caption (voiceover) is locked because TTS audio was already generated.
// ─────────────────────────────────────────────────────────────────────────
function ReviewScreen({ job, jobId, onJobUpdate, isRegenerating, uiLang }) {
  const [preview, setPreview] = React.useState(null); // {url, type, title, query}
  const scenes = job.scenes || [];
  // Local edit buffer keyed by scene index. Holds pending visual_description + media query strings.
  const [edits, setEdits] = React.useState({});
  const [savingScene, setSavingScene] = React.useState(null);
  const [regenKey, setRegenKey] = React.useState(null); // `${sceneIdx}:${mediaIdx}`
  const [bulkRegenBusy, setBulkRegenBusy] = React.useState(false);
  const [continueLoading, setContinueLoading] = React.useState(false);
  // Bust image/video URL cache after regenerate
  const [bustToken, setBustToken] = React.useState({});
  // Which scene indices are expanded. Default: all collapsed when many scenes, otherwise all expanded.
  const initialExpanded = React.useMemo(
    () => new Set(scenes.length <= 6 ? scenes.map((_, i) => i) : []),
    // run once on mount
    [],  // eslint-disable-line react-hooks/exhaustive-deps
  );
  const [expanded, setExpanded] = React.useState(initialExpanded);
  // Filters
  const [filterText, setFilterText] = React.useState("");
  const [onlyUnsaved, setOnlyUnsaved] = React.useState(false);
  const [onlyType, setOnlyType] = React.useState("all"); // all|ai|video|unsplash
  const [activeScene, setActiveScene] = React.useState(0);

  // Per-scene refs so the rail can scroll-into-view
  const sceneRefs = React.useRef({});
  const scrollContainerRef = React.useRef(null);

  const getEdit = (i, field, fallback) => {
    const e = edits[i];
    if (e && field in e) return e[field];
    return fallback;
  };

  const setEdit = (i, field, value) => {
    setEdits((prev) => ({ ...prev, [i]: { ...(prev[i] || {}), [field]: value } }));
  };

  const setMediaQueryEdit = (i, m, value) => {
    setEdits((prev) => {
      const cur = prev[i] || {};
      const queries = { ...(cur.queries || {}) };
      queries[m] = value;
      return { ...prev, [i]: { ...cur, queries } };
    });
  };

  const getMediaQuery = (i, m, fallback) => {
    const e = edits[i];
    if (e && e.queries && m in e.queries) return e.queries[m];
    return fallback;
  };

  const sceneDirty = (i) => {
    const scene = scenes[i];
    const e = edits[i];
    if (!e) return false;
    if ("visual_description" in e && e.visual_description !== (scene.visual_description || "")) return true;
    if (e.queries) {
      const orig = scene.media_requests || [];
      for (const m of Object.keys(e.queries)) {
        if (e.queries[m] !== (orig[m]?.query || "")) return true;
      }
    }
    return false;
  };

  const saveScene = async (i) => {
    const scene = scenes[i];
    setSavingScene(i);
    try {
      const e = edits[i] || {};
      const body = {};
      if ("visual_description" in e) body.visual_description = e.visual_description;
      if (e.queries) {
        body.media_requests = (scene.media_requests || []).map((req, m) => ({
          type: req.type,
          query: m in e.queries ? e.queries[m] : req.query,
        }));
      }
      const res = await fetch(`/api/jobs/${jobId}/scenes/${i}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error(`save failed: ${res.status}`);
      // Reflect the change locally so other UI stays in sync until next poll
      const data = await res.json();
      onJobUpdate((j) => {
        const next = [...(j.scenes || [])];
        next[i] = data.scene;
        return { ...j, scenes: next };
      });
      // Clear edit buffer for this scene
      setEdits((prev) => { const n = { ...prev }; delete n[i]; return n; });
    } catch (err) {
      alert((uiLang === "en" ? "Save failed: " : "Lưu thất bại: ") + (err.message || err));
    } finally {
      setSavingScene(null);
    }
  };

  const regenerateMedia = async (i, m) => {
    const key = `${i}:${m}`;
    setRegenKey(key);
    try {
      // Auto-save the new query first (if dirty for this media)
      const e = edits[i];
      if (e && e.queries && m in e.queries) {
        await saveScene(i);
      }
      const res = await fetch(`/api/jobs/${jobId}/scenes/${i}/media/${m}/regenerate`, { method: "POST" });
      if (!res.ok) {
        const detail = await res.json().catch(() => ({ detail: res.statusText }));
        throw new Error(detail.detail || `regen failed: ${res.status}`);
      }
      const data = await res.json();
      onJobUpdate((j) => {
        const next = [...(j.scenes || [])];
        next[i] = data.scene;
        return { ...j, scenes: next };
      });
      setBustToken((prev) => ({ ...prev, [data.filename]: Date.now() }));
    } catch (err) {
      alert((uiLang === "en" ? "Regenerate failed: " : "Tạo lại thất bại: ") + (err.message || err));
    } finally {
      setRegenKey(null);
    }
  };

  const uploadMedia = async (i, m, fileObj) => {
    if (!fileObj) return;
    const key = `${i}:${m}`;
    setRegenKey(key);
    try {
      const fd = new FormData();
      fd.append("file", fileObj);
      const res = await fetch(`/api/jobs/${jobId}/scenes/${i}/media/${m}/upload`, {
        method: "POST",
        body: fd,
      });
      if (!res.ok) {
        const detail = await res.json().catch(() => ({ detail: res.statusText }));
        throw new Error(detail.detail || `upload failed: ${res.status}`);
      }
      const data = await res.json();
      onJobUpdate((j) => {
        const next = [...(j.scenes || [])];
        next[i] = data.scene;
        return { ...j, scenes: next };
      });
      setBustToken((prev) => ({ ...prev, [data.filename]: Date.now() }));
    } catch (err) {
      alert((uiLang === "en" ? "Upload failed: " : "Tải lên thất bại: ") + (err.message || err));
    } finally {
      setRegenKey(null);
    }
  };

  // Count missing image/video assets (file-presence based — same logic as
  // header counters). Audio isn't regenerable through this endpoint, so we
  // don't include it here.
  const failedCount = React.useMemo(() => {
    let n = 0;
    for (let i = 0; i < scenes.length; i++) {
      const s = scenes[i];
      let imgIdx = 0;
      let vidIdx = 0;
      for (const req of (s?.media_requests || [])) {
        if (req.type === "unsplash" || req.type === "ai" || req.type === "gemini" || req.type === "local") {
          if (!s.local_images?.includes(`scene_${i}_img_${imgIdx}.jpg`)) n++;
          imgIdx++;
        } else if (req.type === "video") {
          if (!s.local_videos?.includes(`scene_${i}_vid_${vidIdx}.mp4`)) n++;
          vidIdx++;
        }
      }
    }
    return n;
  }, [scenes]);

  const regenerateAllFailed = async () => {
    if (bulkRegenBusy || failedCount === 0) return;
    setBulkRegenBusy(true);
    try {
      const res = await fetch(`/api/jobs/${jobId}/media/regenerate-failed`, { method: "POST" });
      if (!res.ok) {
        const d = await res.json().catch(() => ({ detail: res.statusText }));
        throw new Error(d.detail || `bulk regen failed: ${res.status}`);
      }
      const data = await res.json();
      onJobUpdate((j) => ({ ...j, scenes: data.scenes }));
      const ts = Date.now();
      const bust = {};
      for (const it of data.items || []) {
        if (it.status === "ok" && it.filename) bust[it.filename] = ts;
      }
      if (Object.keys(bust).length > 0) {
        setBustToken((prev) => ({ ...prev, ...bust }));
      }
      alert(uiLang === "en" 
        ? `Regenerated ${data.succeeded}/${data.total} OK${data.failed ? `, ${data.failed} failed` : ""}`
        : `Đã tạo lại ${data.succeeded}/${data.total} thành công${data.failed ? `, ${data.failed} thất bại` : ""}`);
    } catch (err) {
      alert((uiLang === "en" ? "Bulk regenerate failed: " : "Tạo lại tất cả thất bại: ") + (err.message || err));
    } finally {
      setBulkRegenBusy(false);
    }
  };

  const toggleExpand = (i) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(i)) next.delete(i); else next.add(i);
      return next;
    });
  };
  const expandAll = () => setExpanded(new Set(scenes.map((_, i) => i)));
  const collapseAll = () => setExpanded(new Set());

  const sceneMatchesFilter = (scene, i) => {
    if (onlyUnsaved && !sceneDirty(i)) return false;
    if (onlyType !== "all") {
      const types = (scene.media_requests || []).map(r => r.type);
      if (onlyType === "ai" && !types.some(t => t === "ai" || t === "gemini")) return false;
      if (onlyType === "video" && !types.includes("video")) return false;
      if (onlyType === "unsplash" && !types.includes("unsplash")) return false;
    }
    if (filterText.trim()) {
      const q = filterText.trim().toLowerCase();
      const hay = `${scene.caption || ""} ${scene.visual_description || ""} ${(scene.media_requests || []).map(r => r.query).join(" ")}`.toLowerCase();
      if (!hay.includes(q)) return false;
    }
    return true;
  };

  const visibleIndices = scenes.map((_, i) => i).filter((i) => sceneMatchesFilter(scenes[i], i));

  const jumpTo = (i) => {
    setExpanded((prev) => new Set(prev).add(i));
    const el = sceneRefs.current[i];
    if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
  };

  // Scroll-spy: track which scene is most visible in the scroll container
  React.useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
        if (visible.length > 0) {
          const idx = Number(visible[0].target.dataset.sceneIdx);
          if (!Number.isNaN(idx)) setActiveScene(idx);
        }
      },
      { root: container, rootMargin: "-30% 0px -50% 0px", threshold: [0, 0.25, 0.5, 1] },
    );
    Object.values(sceneRefs.current).forEach((el) => el && observer.observe(el));
    return () => observer.disconnect();
  }, [scenes.length, visibleIndices.length]);

  const dirtyCount = scenes.filter((_, i) => sceneDirty(i)).length;

  const continueJob = async () => {
    // Block continue if anything is unsaved
    const dirtyIdx = scenes.map((_, i) => i).filter(sceneDirty);
    if (dirtyIdx.length > 0) {
      if (!confirm(uiLang === "en" 
        ? `There are ${dirtyIdx.length} unsaved scenes. Continuing will discard these edits. OK?`
        : `Có ${dirtyIdx.length} phân cảnh chưa lưu. Tiếp tục sẽ bỏ qua thay đổi đó. OK?`)) return;
    }
    setContinueLoading(true);
    try {
      const res = await fetch(`/api/jobs/${jobId}/continue`, { method: "POST" });
      if (!res.ok) throw new Error(`continue failed: ${res.status}`);
    } catch (err) {
      alert((uiLang === "en" ? "Failed to continue: " : "Không tiếp tục được: ") + (err.message || err));
      setContinueLoading(false);
    }
  };

  return (
    <div style={{
      background: "var(--bg-1)",
      border: "1px solid var(--accent-line)",
      borderRadius: "var(--radius-lg)",
      overflow: "hidden",
      display: "flex", flexDirection: "column",
    }}>
      <header style={{
        padding: "14px 18px",
        borderBottom: "1px solid var(--line)",
        background: "linear-gradient(90deg, var(--accent-soft), transparent 70%)",
        display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
      }}>
        <div>
          <div className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--accent)" }}>
            {job.status === "GENERATING_ASSETS" 
              ? (uiLang === "en" ? "Generating assets" : "Đang tải tài nguyên") 
              : (uiLang === "en" ? "Awaiting review" : "Chờ duyệt")} · {scenes.length} {uiLang === "en" ? "scenes" : "phân cảnh"}
            {dirtyCount > 0 && <span style={{ color: "var(--warn)", marginLeft: 8 }}>· {dirtyCount} {uiLang === "en" ? "unsaved" : "chưa lưu"}</span>}
          </div>
          <div style={{ fontSize: 14, color: "var(--fg-1)", marginTop: 4 }}>
            {uiLang === "en" 
              ? "Review and edit media. Voiceover has been generated so caption cannot be changed." 
              : "Kiểm tra và chỉnh sửa phương tiện. Lời thoại đã được tạo nên không thể thay đổi."}
          </div>
          {/* Show a subtle note when audio is ready but images/video still loading */}
          {job.status === "GENERATING_ASSETS" && job.audio_ready && (
            <div style={{ fontSize: 11.5, color: "var(--fg-3)", marginTop: 6, display: "flex", alignItems: "center", gap: 6 }}>
              <svg width="10" height="10" viewBox="0 0 12 12" style={{ animation: "spin 1.1s linear infinite", flexShrink: 0 }}>
                <circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.3"/>
                <path d="M6 1.5 a4.5 4.5 0 0 1 4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
              </svg>
              {uiLang === "en" ? "Images/videos are still loading — you can continue or wait" : "Ảnh/video vẫn đang tải — có thể tiếp tục hoặc chờ thêm"}
            </div>
          )}
        </div>
        {(() => {
          // Disabled only when audio hasn't finished yet
          const waitingForAudio = job.status === "GENERATING_ASSETS" && !job.audio_ready;
          const isDisabled = continueLoading || isRegenerating || waitingForAudio;
          let label = uiLang === "en" ? "Continue to render →" : "Tiếp tục xuất video →";
          if (continueLoading) {
            label = uiLang === "en" ? "Continuing…" : "Đang tiếp tục…";
          } else if (waitingForAudio) {
            label = uiLang === "en" ? "Generating audio…" : "Đang tạo lời thoại…";
          } else if (job.status === "GENERATING_ASSETS" && job.audio_ready) {
            label = uiLang === "en" ? "Continue to render →" : "Tiếp tục xuất video →";
          }
          return (
            <button
              onClick={continueJob}
              disabled={isDisabled}
              style={{
                padding: "9px 18px",
                background: "var(--accent)",
                color: "var(--accent-fg)",
                borderRadius: "var(--radius)",
                fontWeight: 600,
                fontSize: 13,
                display: "flex", alignItems: "center", gap: 8,
                opacity: isDisabled ? 0.5 : 1,
                cursor: isDisabled ? "not-allowed" : "pointer",
                whiteSpace: "nowrap",
              }}>
              {label}
            </button>
          );
        })()}
      </header>

      {/* Filter bar */}
      <div style={{
        display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
        padding: "10px 18px",
        borderBottom: "1px solid var(--line)",
        background: "var(--bg-0)",
      }}>
        <input
          type="text"
          value={filterText}
          onChange={(e) => setFilterText(e.target.value)}
          placeholder={uiLang === "en" ? "Search caption, visual, or query…" : "Tìm kiếm lời thoại, hình ảnh hoặc từ khóa..."}
          style={{
            flex: "1 1 240px",
            minWidth: 200,
            padding: "8px 12px",
            background: "var(--bg-2)",
            border: "1px solid var(--line)",
            borderRadius: "var(--radius)",
            fontSize: 13,
          }}
        />
        <FilterChip active={onlyUnsaved} onClick={() => setOnlyUnsaved((v) => !v)}>
          {uiLang === "en" ? "Only unsaved" : "Chưa lưu"}
        </FilterChip>
        <FilterChip active={onlyType === "ai"} onClick={() => setOnlyType((v) => v === "ai" ? "all" : "ai")}>
          {uiLang === "en" ? "AI image" : "Ảnh AI"}
        </FilterChip>
        <FilterChip active={onlyType === "unsplash"} onClick={() => setOnlyType((v) => v === "unsplash" ? "all" : "unsplash")}>
          {uiLang === "en" ? "Unsplash" : "Unsplash"}
        </FilterChip>
        <FilterChip active={onlyType === "video"} onClick={() => setOnlyType((v) => v === "video" ? "all" : "video")}>
          {uiLang === "en" ? "Video" : "Video"}
        </FilterChip>
        <div style={{ flex: 1 }}/>
        <span className="mono" style={{ fontSize: 11, color: "var(--fg-3)" }}>
          {visibleIndices.length}/{scenes.length} {uiLang === "en" ? "shown" : "đang hiển thị"}
        </span>
        <button onClick={expandAll} style={chipBtnStyle(false)}>
          {uiLang === "en" ? "Expand all" : "Mở rộng tất cả"}
        </button>
        <button onClick={collapseAll} style={chipBtnStyle(false)}>
          {uiLang === "en" ? "Collapse all" : "Thu gọn tất cả"}
        </button>
        <button
          onClick={regenerateAllFailed}
          disabled={bulkRegenBusy || failedCount === 0}
          style={{
            ...chipBtnStyle(failedCount > 0),
            opacity: (bulkRegenBusy || failedCount === 0) ? 0.55 : 1,
            cursor: (bulkRegenBusy || failedCount === 0) ? "not-allowed" : "pointer",
          }}
          title={failedCount === 0 
            ? (uiLang === "en" ? "No failed assets" : "Không có tài nguyên lỗi") 
            : (uiLang === "en" ? `Regen ${failedCount} scene(s) with failed media` : `Tạo lại ${failedCount} phân cảnh lỗi`)}>
          {bulkRegenBusy 
            ? (uiLang === "en" ? "Regenerating…" : "Đang tạo lại...") 
            : `↻ ${uiLang === "en" ? "Regen failed" : "Tạo lại lỗi"} (${failedCount})`}
        </button>
      </div>

      {/* Body: rail (left) + scrollable scene column (right) */}
      <div style={{
        display: "grid",
        gridTemplateColumns: "220px 1fr",
        height: "calc(100vh - 280px)",
        minHeight: 480,
      }}>
        {/* RAIL */}
        <aside style={{
          borderRight: "1px solid var(--line)",
          background: "var(--bg-0)",
          overflowY: "auto",
          padding: "10px 8px",
        }}>
          <div className="mono" style={{
            fontSize: 10, color: "var(--fg-4)", letterSpacing: "0.1em",
            textTransform: "uppercase", padding: "4px 8px 8px",
          }}>{uiLang === "en" ? "Timeline" : "Dòng thời gian"}</div>
          {visibleIndices.map((i) => {
            const scene = scenes[i];
            const dirty = sceneDirty(i);
            const isActive = activeScene === i;
            // first media thumbnail (if any)
            const firstReq = (scene.media_requests || [])[0];
            let thumbUrl = null;
            if (firstReq) {
              const fname = firstReq.type === "video"
                ? `scene_${i}_vid_0.mp4`
                : `scene_${i}_img_0.jpg`;
              const isReady = firstReq.type === "video"
                ? scene.local_videos?.includes(fname)
                : scene.local_images?.includes(fname);
              if (isReady) {
                const bust = bustToken[fname] || "";
                thumbUrl = `/files/${jobId}/public/${fname}${bust ? `?t=${bust}` : ""}`;
              }
            }
            const snippet = (scene.caption || scene.visual_description || "").slice(0, 48);
            return (
              <button
                key={i}
                onClick={() => jumpTo(i)}
                style={{
                  display: "grid",
                  gridTemplateColumns: "auto 56px 1fr auto",
                  gap: 8,
                  alignItems: "center",
                  width: "100%",
                  padding: "6px 8px",
                  marginBottom: 2,
                  background: isActive ? "var(--bg-3)" : "transparent",
                  border: `1px solid ${isActive ? "var(--accent-line)" : "transparent"}`,
                  borderRadius: "var(--radius)",
                  cursor: "pointer",
                  textAlign: "left",
                  transition: "background .1s",
                }}>
                <span className="mono" style={{ fontSize: 10, color: scene.media_failed ? "var(--err)" : "var(--fg-3)", width: 22 }}>
                  {String(i + 1).padStart(2, "0")}
                </span>
                <div style={{
                  width: 56, height: 32,
                  background: "var(--bg-3)",
                  borderRadius: 3, overflow: "hidden",
                  flexShrink: 0,
                  position: "relative",
                  display: "flex", alignItems: "center", justifyContent: "center",
                }}>
                  {thumbUrl && firstReq?.type !== "video" && (
                    <img src={thumbUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", position: "absolute" }}/>
                  )}
                  {thumbUrl && firstReq?.type === "video" && (
                    <video src={thumbUrl} muted preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", position: "absolute" }}/>
                  )}
                  {/* Placeholder when no media ready yet */}
                  {!thumbUrl && (() => {
                    const hasVideoReq = (scene.media_requests || []).some(r => r.type === "video");
                    const hasAnyReq = (scene.media_requests || []).length > 0;
                    return (
                      <div style={{
                        position: "absolute", inset: 0,
                        display: "flex", flexDirection: "column",
                        alignItems: "center", justifyContent: "center", gap: 2,
                        background: hasVideoReq
                          ? "linear-gradient(135deg, #1a1a2e, #16213e)"
                          : "linear-gradient(135deg, var(--bg-2), var(--bg-3))",
                      }}>
                        {hasVideoReq ? (
                          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" style={{ opacity: 0.5 }}>
                            <rect x="1" y="3" width="14" height="10" rx="1.5" stroke="var(--fg-3)" strokeWidth="1.2"/>
                            <rect x="1" y="5" width="2.5" height="2" fill="var(--fg-3)" opacity="0.6"/>
                            <rect x="1" y="9" width="2.5" height="2" fill="var(--fg-3)" opacity="0.6"/>
                            <rect x="12.5" y="5" width="2.5" height="2" fill="var(--fg-3)" opacity="0.6"/>
                            <rect x="12.5" y="9" width="2.5" height="2" fill="var(--fg-3)" opacity="0.6"/>
                            <path d="M6.5 6.2 L10.5 8 L6.5 9.8 Z" fill="var(--fg-3)" opacity="0.7"/>
                          </svg>
                        ) : hasAnyReq ? (
                          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" style={{ opacity: 0.5 }}>
                            <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="var(--fg-3)" strokeWidth="1.2"/>
                            <circle cx="5.5" cy="6" r="1.5" fill="var(--fg-3)" opacity="0.6"/>
                            <path d="M1.5 10.5 L5 7.5 L7.5 10 L10.5 7 L14.5 11.5" stroke="var(--fg-3)" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" opacity="0.6"/>
                          </svg>
                        ) : (
                          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" style={{ opacity: 0.4 }}>
                            <rect x="2" y="2" width="12" height="12" rx="1.5" stroke="var(--fg-3)" strokeWidth="1.2"/>
                            <path d="M4.5 5.5 H11.5 M4.5 8 H11.5 M4.5 10.5 H8.5" stroke="var(--fg-3)" strokeWidth="1.2" strokeLinecap="round"/>
                          </svg>
                        )}
                        <span style={{
                          fontSize: 7, color: "var(--fg-4)",
                          letterSpacing: "0.06em", textTransform: "uppercase",
                          fontFamily: "var(--font-mono)",
                        }}>
                          {hasVideoReq ? "video" : hasAnyReq ? "image" : "text"}
                        </span>
                      </div>
                    );
                  })()}
                  {/* FAIL badge — shown when backend flagged media_failed */}
                  {scene.media_failed && (
                    <div style={{
                      position: "absolute", inset: 0,
                      display: "flex", alignItems: "center", justifyContent: "center",
                      background: "rgba(180,30,30,0.72)",
                      backdropFilter: "blur(1px)",
                    }}>
                      <span style={{
                        fontSize: 8, fontWeight: 700,
                        color: "#fff",
                        letterSpacing: "0.12em",
                        textTransform: "uppercase",
                        fontFamily: "var(--font-mono)",
                        textShadow: "0 1px 3px rgba(0,0,0,0.6)",
                      }}>FAIL</span>
                    </div>
                  )}
                </div>
                <span style={{
                  fontSize: 11, color: isActive ? "var(--fg-0)" : "var(--fg-2)",
                  overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
                }}>{snippet || "—"}</span>
                {dirty && <span title="Unsaved" style={{
                  width: 6, height: 6, borderRadius: 999,
                  background: "var(--warn)", flexShrink: 0,
                }}/>}
              </button>
            );
          })}
          {visibleIndices.length === 0 && (
            <div style={{ padding: 12, fontSize: 12, color: "var(--fg-3)" }}>
              {uiLang === "en" ? "No scenes match filter." : "Không có phân cảnh nào khớp."}
            </div>
          )}
        </aside>

        {/* MAIN scenes column */}
        <div
          ref={scrollContainerRef}
          style={{
            overflowY: "auto",
            padding: 20,
            display: "flex", flexDirection: "column", gap: 16,
          }}>
          {visibleIndices.length === 0 ? (
            <div style={{
              padding: 40, textAlign: "center",
              color: "var(--fg-3)", fontSize: 13,
              border: "1px dashed var(--line)", borderRadius: "var(--radius-lg)",
            }}>
              {uiLang === "en" ? "No scenes match the current filter." : "Không có phân cảnh nào khớp với bộ lọc hiện tại."}
            </div>
          ) : visibleIndices.map((i) => {
            const scene = scenes[i];
            return (
              <div
                key={i}
                ref={(el) => { sceneRefs.current[i] = el; }}
                data-scene-idx={i}>
                <SceneReviewCard
                  sceneIdx={i}
                  scene={scene}
                  jobId={jobId}
                  visualDescription={getEdit(i, "visual_description", scene.visual_description || "")}
                  onVisualChange={(v) => setEdit(i, "visual_description", v)}
                  getMediaQuery={(m) => getMediaQuery(i, m, scene.media_requests?.[m]?.query || "")}
                  onMediaQueryChange={(m, v) => setMediaQueryEdit(i, m, v)}
                  onSave={() => saveScene(i)}
                  onRegenerateMedia={(m) => regenerateMedia(i, m)}
                  onUploadMedia={(m, f) => uploadMedia(i, m, f)}
                  onPreviewMedia={setPreview}
                  isSaving={savingScene === i}
                  regenKey={regenKey}
                  isDirty={sceneDirty(i)}
                  bustToken={bustToken}
                  isExpanded={expanded.has(i)}
                  onToggleExpand={() => toggleExpand(i)}
                  uiLang={uiLang}
                />
              </div>
            );
          })}
        </div>
      </div>

      {preview && <MediaPreviewModal media={preview} onClose={() => setPreview(null)} uiLang={uiLang}/>}
    </div>
  );
}

function FilterChip({ active, onClick, children }) {
  return (
    <button onClick={onClick} style={chipBtnStyle(active)}>{children}</button>
  );
}

function chipBtnStyle(active) {
  return {
    padding: "6px 10px",
    background: active ? "var(--accent-soft)" : "var(--bg-2)",
    border: `1px solid ${active ? "var(--accent-line)" : "var(--line)"}`,
    borderRadius: "var(--radius)",
    fontSize: 12,
    color: active ? "var(--accent)" : "var(--fg-2)",
    cursor: "pointer",
    whiteSpace: "nowrap",
  };
}

// Chip showing the template Agent 1 picked for a scene. Hover → animated preview.
// Reuses TemplatePreview / TEMPLATE_TRANSLATIONS defined globally in input-screen.jsx.
function ScenePickedTemplate({ templateId, uiLang }) {
  const [hover, setHover] = React.useState(false);
  if (!templateId) {
    return (
      <span className="mono" style={{
        fontSize: 10.5, padding: "2px 7px", borderRadius: 4, flexShrink: 0,
        background: "var(--bg-2)", border: "1px solid var(--line)", color: "var(--fg-3)",
      }}>
        {uiLang === "en" ? "Custom" : "Tùy chỉnh"}
      </span>
    );
  }
  const tr = (typeof TEMPLATE_TRANSLATIONS !== "undefined"
    && TEMPLATE_TRANSLATIONS[uiLang] && TEMPLATE_TRANSLATIONS[uiLang][templateId]) || null;
  const name = tr ? tr.name : templateId;
  const canPreview = typeof TemplatePreview !== "undefined";
  return (
    <span
      onClick={(e) => e.stopPropagation()}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{
        position: "relative", flexShrink: 0,
        fontSize: 10.5, padding: "2px 8px", borderRadius: 4,
        background: "var(--accent-soft)", border: "1px solid var(--accent-line)",
        color: "var(--accent)", fontFamily: "var(--font-mono)",
        display: "inline-flex", alignItems: "center", gap: 4,
        cursor: canPreview ? "help" : "default",
      }}>
      ▦ {name}
      {hover && canPreview && (
        <div style={{
          position: "absolute", top: "calc(100% + 8px)", left: 0, zIndex: 80, padding: 8,
          background: "var(--bg-1)", border: "1px solid var(--line)",
          borderRadius: "var(--radius)", boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
          pointerEvents: "none",
        }}>
          <TemplatePreview id={templateId} />
        </div>
      )}
    </span>
  );
}

function SceneReviewCard({
  sceneIdx, scene, jobId,
  visualDescription, onVisualChange,
  getMediaQuery, onMediaQueryChange,
  onSave, onRegenerateMedia, onUploadMedia, onPreviewMedia,
  isSaving, regenKey, isDirty, bustToken,
  isExpanded, onToggleExpand,
  uiLang,
}) {
  const mediaList = scene.media_requests || [];
  const caption = scene.caption || "";
  const duration = scene.duration_seconds;
  const hasAudio = !!scene.local_audio;

  const mediaFilename = (m) => {
    const req = mediaList[m];
    if (!req) return null;
    if (req.type === "video") {
      const videoPos = mediaList.slice(0, m).filter(r => r.type === "video").length;
      const expectedName = `scene_${sceneIdx}_vid_${videoPos}.mp4`;
      return scene.local_videos?.includes(expectedName) ? expectedName : null;
    }
    const imagePos = mediaList.slice(0, m).filter(r => ["unsplash", "ai", "gemini", "local"].includes(r.type)).length;
    const expectedName = `scene_${sceneIdx}_img_${imagePos}.jpg`;
    return scene.local_images?.includes(expectedName) ? expectedName : null;
  };

  return (
    <div style={{
      background: "var(--bg-0)",
      border: `1px solid ${isDirty ? "var(--accent-line)" : "var(--line)"}`,
      borderRadius: "var(--radius-lg)",
      padding: isExpanded ? 20 : 12,
      display: "flex", flexDirection: "column", gap: isExpanded ? 16 : 0,
      transition: "padding .15s",
    }}>
      {/* Header row — click to toggle expand */}
      <div
        onClick={onToggleExpand}
        style={{
          display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
          cursor: "pointer", userSelect: "none",
        }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14, flex: 1, minWidth: 0 }}>
          <span style={{
            display: "inline-block",
            width: 14,
            color: "var(--fg-3)",
            transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
            transition: "transform .15s",
            fontSize: 10,
          }}>▶</span>
          <span className="mono" style={{
            fontSize: 13, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--fg-0)",
          }}>
            {uiLang === "en" ? "Scene" : "Phân cảnh"} {String(sceneIdx + 1).padStart(2, "0")}
          </span>
          {duration != null && (
            <span className="mono" style={{ fontSize: 12, color: "var(--fg-2)" }}>
              {duration.toFixed(2)}s
            </span>
          )}
          <ScenePickedTemplate templateId={scene.template} uiLang={uiLang} />
          {hasAudio && (
            <span className="mono" style={{ fontSize: 12, color: "var(--ok)" }}>
              ♪
            </span>
          )}
          {!isExpanded && (
            <span style={{
              fontSize: 13, color: "var(--fg-2)",
              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
              flex: 1, minWidth: 0,
            }}>{caption.slice(0, 120) || <em style={{ color: "var(--fg-4)" }}>{uiLang === "en" ? "(no caption)" : "(không có lời thoại)"}</em>}</span>
          )}
          {!isExpanded && mediaList.length > 0 && (
            <span className="mono" style={{ fontSize: 11, color: "var(--fg-3)", flexShrink: 0 }}>
              {mediaList.length} {uiLang === "en" ? "media" : "phương tiện"}
            </span>
          )}
        </div>
        <button
          onClick={(e) => { e.stopPropagation(); onSave(); }}
          disabled={!isDirty || isSaving}
          style={{
            padding: "6px 14px",
            background: isDirty ? "var(--bg-3)" : "transparent",
            border: `1px solid ${isDirty ? "var(--accent-line)" : "var(--line)"}`,
            borderRadius: "var(--radius)",
            fontSize: 12.5,
            fontWeight: 500,
            color: isDirty ? "var(--accent)" : "var(--fg-3)",
            cursor: (isDirty && !isSaving) ? "pointer" : "default",
            flexShrink: 0,
          }}>
          {isSaving 
            ? (uiLang === "en" ? "Saving…" : "Đang lưu...") 
            : isDirty 
              ? (uiLang === "en" ? "Save" : "Lưu") 
              : (uiLang === "en" ? "Saved" : "Đã lưu")}
        </button>
      </div>

      {isExpanded && (<>
      {/* Caption (read-only — TTS already generated) */}
      <div>
        <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--fg-3)", marginBottom: 6 }} className="mono">
          <span style={{ textTransform: "uppercase", letterSpacing: "0.08em" }}>
            {uiLang === "en" ? "Caption (voiceover, locked)" : "Lời thoại (Thuyết minh, đã khóa)"}
          </span>
          <span title={uiLang === "en" ? "Voiceover generated, cannot be edited" : "Lời thoại đã được tạo, không thể chỉnh sửa"}>🔒</span>
        </label>
        <div style={{
          padding: "12px 14px",
          background: "var(--bg-2)",
          border: "1px solid var(--line)",
          borderRadius: "var(--radius)",
          fontSize: 15,
          lineHeight: 1.55,
          color: "var(--fg-0)",
          whiteSpace: "pre-wrap",
        }}>{caption || <span style={{ color: "var(--fg-4)" }}>{uiLang === "en" ? "(no caption)" : "(không có lời thoại)"}</span>}</div>
        {hasAudio && (
          <div style={{
            marginTop: 8,
            display: "flex",
            alignItems: "center",
            gap: 10,
            padding: "8px 12px",
            background: "var(--bg-1)",
            border: "1px solid var(--line-soft)",
            borderRadius: "var(--radius)",
          }}>
            <span className="mono" style={{
              fontSize: 11,
              letterSpacing: "0.08em",
              textTransform: "uppercase",
              color: "var(--fg-3)",
              flexShrink: 0,
            }}>♪ TTS</span>
            <audio
              controls
              preload="none"
              src={`/files/${jobId}/public/${scene.local_audio}`}
              style={{ flex: 1, height: 32 }}
            />
          </div>
        )}
      </div>

      {/* Visual description */}
      <div>
        <label style={{ display: "block", fontSize: 12, color: "var(--fg-3)", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.08em" }} className="mono">
          {uiLang === "en" ? "Visual description" : "Mô tả hình ảnh"}
        </label>
        <textarea
          value={visualDescription}
          onChange={(e) => onVisualChange(e.target.value)}
          rows={3}
          style={{
            width: "100%",
            padding: "12px 14px",
            background: "var(--bg-2)",
            border: "1px solid var(--line)",
            borderRadius: "var(--radius)",
            fontSize: 14,
            lineHeight: 1.55,
            resize: "vertical",
            fontFamily: "inherit",
          }}
        />
      </div>

      {/* Media grid */}
      {mediaList.length > 0 && (
        <div>
          <label style={{ display: "block", fontSize: 12, color: "var(--fg-3)", marginBottom: 8, textTransform: "uppercase", letterSpacing: "0.08em" }} className="mono">
            {uiLang === "en" 
              ? `Media (${mediaList.length}) — click to preview` 
              : `Phương tiện (${mediaList.length}) — nhấp để xem trước`}
          </label>
          <div style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
            gap: 12,
          }}>
            {mediaList.map((req, m) => {
              const fname = mediaFilename(m);
              const bust = bustToken[fname] || "";
              const url = fname ? `/files/${jobId}/public/${fname}${bust ? `?t=${bust}` : ""}` : null;
              const isVideo = req.type === "video";
              const key = `${sceneIdx}:${m}`;
              const regenerating = regenKey === key;
              const previewObj = url ? {
                url, type: req.type, title: `Scene ${sceneIdx + 1} · #${m + 1}`, query: getMediaQuery(m),
              } : null;
              return (
                <div key={m} style={{
                  display: "flex", flexDirection: "column", gap: 8,
                  padding: 10,
                  background: "var(--bg-1)",
                  border: "1px solid var(--line-soft)",
                  borderRadius: "var(--radius)",
                }}>
                  {/* big thumb — click to open lightbox */}
                  <button
                    onClick={() => previewObj && onPreviewMedia(previewObj)}
                    disabled={!previewObj}
                    style={{
                      position: "relative",
                      width: "100%",
                      aspectRatio: "16 / 9",
                      background: "var(--bg-3)",
                      borderRadius: 6,
                      overflow: "hidden",
                      cursor: previewObj ? "zoom-in" : "default",
                      padding: 0,
                      border: "1px solid var(--line)",
                    }}>
                    {url && !isVideo && (
                      <img
                        key={url}
                        src={url}
                        style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
                        alt=""
                      />
                    )}
                    {url && isVideo && (
                      <video
                        key={url}
                        src={url}
                        muted
                        playsInline
                        preload="metadata"
                        style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
                      />
                    )}
                    {isVideo && (
                      <div style={{
                        position: "absolute", top: 8, left: 8,
                        padding: "2px 6px",
                        background: "rgba(0,0,0,0.7)",
                        color: "white",
                        fontSize: 10,
                        fontFamily: "var(--font-mono)",
                        letterSpacing: "0.06em",
                        borderRadius: 3,
                      }}>VIDEO</div>
                    )}
                    {regenerating && (
                      <div style={{
                        position: "absolute", inset: 0,
                        background: "rgba(0,0,0,0.7)",
                        display: "flex", flexDirection: "column",
                        alignItems: "center", justifyContent: "center",
                        gap: 8, color: "var(--accent)",
                      }}>
                        <Spinner/>
                        <span className="mono" style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase" }}>
                          {uiLang === "en" ? "Regenerating…" : "Đang tạo lại..."}
                        </span>
                      </div>
                    )}
                  </button>

                  {/* meta */}
                  <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
                    <span className="mono" style={{ fontSize: 10.5, color: "var(--fg-3)", letterSpacing: "0.08em", textTransform: "uppercase" }}>
                      #{m + 1} · {req.type}
                    </span>
                  </div>

                  {/* query input */}
                  <input
                    type="text"
                    value={getMediaQuery(m)}
                    onChange={(e) => onMediaQueryChange(m, e.target.value)}
                    disabled={regenerating}
                    placeholder={uiLang === "en" ? "Search query…" : "Từ khóa tìm kiếm..."}
                    style={{
                      padding: "8px 10px",
                      background: "var(--bg-2)",
                      border: "1px solid var(--line)",
                      borderRadius: 4,
                      fontSize: 13,
                      width: "100%",
                    }}
                  />

                  {/* regenerate */}
                  <button
                    onClick={() => onRegenerateMedia(m)}
                    disabled={regenerating || isSaving}
                    style={{
                      padding: "8px 12px",
                      background: regenerating ? "var(--bg-2)" : "var(--bg-3)",
                      border: "1px solid var(--accent-line)",
                      borderRadius: "var(--radius)",
                      fontSize: 12.5,
                      fontWeight: 500,
                      color: regenerating ? "var(--fg-3)" : "var(--accent)",
                      cursor: regenerating ? "wait" : "pointer",
                      whiteSpace: "nowrap",
                    }}>
                    {regenerating
                      ? (uiLang === "en" ? "Regenerating…" : "Đang tạo lại...")
                      : `↻ ${uiLang === "en" ? "Regenerate" : "Tạo lại"}`}
                  </button>

                  {/* upload your own image (image slots only) */}
                  {!isVideo && (
                    <label
                      style={{
                        padding: "8px 12px",
                        background: "var(--bg-2)",
                        border: "1px dashed var(--line)",
                        borderRadius: "var(--radius)",
                        fontSize: 12.5,
                        fontWeight: 500,
                        color: regenerating ? "var(--fg-3)" : "var(--fg-1)",
                        cursor: regenerating ? "wait" : "pointer",
                        textAlign: "center",
                        whiteSpace: "nowrap",
                      }}>
                      {`⤓ ${uiLang === "en" ? "Upload image" : "Tải ảnh lên"}`}
                      <input
                        type="file"
                        accept="image/jpeg,image/png,image/webp,image/gif"
                        disabled={regenerating || isSaving}
                        style={{ display: "none" }}
                        onChange={(e) => {
                          const f = e.target.files?.[0];
                          e.target.value = "";  // allow re-uploading the same file
                          if (f) onUploadMedia(m, f);
                        }}
                      />
                    </label>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
      </>)}
    </div>
  );
}

Object.assign(window, { PipelineScreen, STAGES, ReviewScreen });
