const { useEffect, useMemo, useRef, useState } = React;
const Api = window.SubtitleAPI;
const Srt = window.SubtitleSRT;
const LIBRARY_KEY = "frontend-subtitle:audio-library:v1";
const SELECTED_TRACK_KEY = "frontend-subtitle:selected-track:v1";
const TRANSLATION_LANGUAGES = [
  ["Korean", "한국어"],
  ["English", "영어"],
  ["Japanese", "일본어"],
  ["Chinese", "중국어"],
  ["Cantonese", "광둥어"],
  ["French", "프랑스어"],
  ["German", "독일어"],
  ["Italian", "이탈리아어"],
  ["Portuguese", "포르투갈어"],
  ["Russian", "러시아어"],
  ["Spanish", "스페인어"],
  ["Arabic", "아랍어"],
  ["Czech", "체코어"],
  ["Danish", "덴마크어"],
  ["Dutch", "네덜란드어"],
  ["Filipino", "필리핀어"],
  ["Finnish", "핀란드어"],
  ["Greek", "그리스어"],
  ["Hindi", "힌디어"],
  ["Hungarian", "헝가리어"],
  ["Indonesian", "인도네시아어"],
  ["Malay", "말레이어"],
  ["Persian", "페르시아어"],
  ["Polish", "폴란드어"],
  ["Romanian", "루마니아어"],
  ["Swedish", "스웨덴어"],
  ["Thai", "태국어"],
  ["Turkish", "터키어"],
  ["Vietnamese", "베트남어"],
];

const Icon = {
  Upload: ({ size = 18 }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>,
  Spark: ({ size = 16 }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.6 7.4L22 12l-7.4 2.6L12 22l-2.6-7.4L2 12l7.4-2.6L12 2z"/></svg>,
  Play: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>,
  Pause: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="4" height="12" rx="1"/><rect x="9" y="2" width="4" height="12" rx="1"/></svg>,
  Download: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M8 2v8"/><path d="M4.5 7.5L8 11l3.5-3.5"/><path d="M2 13.5h12"/></svg>,
  Plus: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M8 3v10M3 8h10"/></svg>,
  Trash: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 4h11"/><path d="M6.5 2.5h3L10.5 4h-5z"/><path d="M5 6l.5 7h5L11 6"/></svg>,
  ArrowUp: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M8 13V3M4 7l4-4 4 4"/></svg>,
  ArrowDown: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v10M4 9l4 4 4-4"/></svg>,
  Check: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8.5l3 3 7-7"/></svg>,
  SkipBack: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor"><rect x="2.5" y="3" width="2" height="10" rx="0.5"/><path d="M13 3.2v9.6L5.5 8z"/></svg>,
  SkipForward: ({ size = 14 }) => <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor"><rect x="11.5" y="3" width="2" height="10" rx="0.5"/><path d="M3 3.2v9.6L10.5 8z"/></svg>,
};

function fmtDuration(seconds) {
  if (!Number.isFinite(seconds)) return "0:00";
  const minutes = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60).toString().padStart(2, "0");
  return `${minutes}:${secs}`;
}

function formatBytes(bytes) {
  if (!bytes) return "0 MB";
  return `${(bytes / 1048576).toFixed(2)} MB`;
}

function formatClock(ms) {
  const safe = Math.max(0, Math.round(Number(ms) || 0));
  const minutes = Math.floor(safe / 60000);
  const seconds = Math.floor((safe % 60000) / 1000);
  const millis = safe % 1000;
  return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}

function formatJobTime(value) {
  if (!value) return "-";
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return String(value);
  return date.toLocaleTimeString("ko-KR", { hour12: false });
}

function readFileAsText(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (event) => resolve(String(event.target?.result || ""));
    reader.onerror = () => reject(new Error("파일을 읽을 수 없습니다."));
    reader.readAsText(file);
  });
}

function loadLibrary() {
  try {
    const parsed = JSON.parse(localStorage.getItem(LIBRARY_KEY) || "[]");
    if (!Array.isArray(parsed)) return [];
    return parsed
      .filter((item) => item?.id && item?.audioUrl)
      .map((item) => ({
        ...item,
        audioUrl: Api.normalizeStorageUrl ? Api.normalizeStorageUrl(item.audioUrl) : item.audioUrl,
      }));
  } catch {
    return [];
  }
}

function saveLibrary(items) {
  localStorage.setItem(LIBRARY_KEY, JSON.stringify(items));
}

function makeTrackFromUpload(file, saved) {
  const now = new Date().toISOString();
  return {
    id: `audio_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
    name: file.name || saved.filename || "audio.mp3",
    size: Number(saved.size || file.size || 0),
    audioUrl: saved.url,
    storageFilename: saved.filename,
    createdAt: now,
    updatedAt: now,
    duration: 0,
    rawSrt: "",
    lines: [],
    selectedLineId: "",
    status: "ready",
  };
}

function persistableLines(lines) {
  return (lines || []).map(({ id, index, startMs, endMs, text, translatedText, translationLanguage }) => ({
    id,
    index,
    startMs,
    endMs,
    text,
    translatedText: translatedText || "",
    translationLanguage: translationLanguage || "",
  }));
}

function ToastHost({ toast }) {
  return toast ? <div className="toast">{toast}</div> : null;
}

function JobQueueDialog({ open, snapshot, loading, error, onClose, onRefresh }) {
  useEffect(() => {
    if (!open) return undefined;
    const handleKey = (event) => {
      if (event.key === "Escape") onClose();
    };
    window.addEventListener("keydown", handleKey);
    return () => window.removeEventListener("keydown", handleKey);
  }, [open, onClose]);

  if (!open) return null;

  const summary = snapshot?.summary || {};
  const jobs = snapshot?.jobs || [];
  const summaryItems = [
    ["queued", "대기"],
    ["running", "진행"],
    ["completed", "완료"],
    ["failed", "실패"],
    ["canceled", "취소"],
  ];

  return (
    <div className="dialog-backdrop" onMouseDown={onClose}>
      <section className="job-dialog" role="dialog" aria-modal="true" aria-labelledby="job-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
        <div className="dialog-head">
          <div>
            <div className="section-kicker">Async Job Queue</div>
            <h2 id="job-dialog-title">qwen-asr-api 작업 현황</h2>
            <p>서버의 `/jobs` 목록을 5초마다 갱신합니다. queue position이 비어 있으면 이미 worker가 잡은 작업입니다.</p>
          </div>
          <div className="dialog-actions">
            <button className="btn" onClick={onRefresh} disabled={loading}>{loading ? "조회 중" : "새로고침"}</button>
            <button className="btn" onClick={onClose}>닫기</button>
          </div>
        </div>

        <div className="job-summary">
          {summaryItems.map(([key, label]) => (
            <div key={key} className={`job-summary-item ${key}`}>
              <span>{label}</span>
              <strong className="mono">{summary[key] ?? 0}</strong>
            </div>
          ))}
        </div>

        {error && <div className="job-error">{error}</div>}

        <div className="job-list">
          {jobs.length === 0 && !loading ? (
            <div className="empty-log">표시할 job이 없습니다.</div>
          ) : jobs.map((job) => {
            const progress = job.progress || {};
            const stage = progress.stage || job.status;
            const total = progress.total_items ?? null;
            const completed = progress.completed_items ?? null;
            const progressText = total == null ? "-" : `${completed ?? 0}/${total} ${progress.unit || ""}`.trim();
            return (
              <article key={job.job_id} className={`job-row-card ${job.status}`}>
                <div className="job-row-head">
                  <span className={`job-status ${job.status}`}>{job.status}</span>
                  <strong>{job.original_filename || job.operation}</strong>
                  <span className="mono">{formatJobTime(job.updated_at)}</span>
                </div>
                <div className="job-meta-grid">
                  <div><span>stage</span><b>{Api.mapStageLabel(stage)}</b></div>
                  <div><span>progress</span><b className="mono">{progressText}</b></div>
                  <div><span>worker</span><b className="mono">{job.worker_index ?? "-"}</b></div>
                  <div><span>queue</span><b className="mono">{job.queue_position ?? "-"}</b></div>
                </div>
                <p>{progress.detail || job.error || "상세 메시지가 없습니다."}</p>
                <code>{job.job_id}</code>
              </article>
            );
          })}
        </div>
      </section>
    </div>
  );
}

function TranslationDialog({ open, lineCount, loading, onClose, onTranslate }) {
  const [language, setLanguage] = useState("Korean");

  useEffect(() => {
    if (!open) return undefined;
    const handleKey = (event) => {
      if (event.key === "Escape" && !loading) onClose();
    };
    window.addEventListener("keydown", handleKey);
    return () => window.removeEventListener("keydown", handleKey);
  }, [open, loading, onClose]);

  if (!open) return null;

  const submit = (event) => {
    event.preventDefault();
    const target = language.trim();
    if (target) onTranslate(target);
  };

  return (
    <div className="dialog-backdrop" onMouseDown={loading ? undefined : onClose}>
      <section className="translate-dialog" role="dialog" aria-modal="true" aria-labelledby="translate-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
        <form onSubmit={submit}>
          <div className="dialog-head">
            <div>
              <div className="section-kicker">Ollama Translation</div>
              <h2 id="translate-dialog-title">어떤 언어로 번역할까요?</h2>
              <p>현재 {lineCount}개 라인을 각 라인의 앞뒤 5줄 문맥과 함께 Ollama에 보내 번역합니다. 결과는 원문 아래에 추가됩니다.</p>
            </div>
            <div className="dialog-actions">
              <button type="button" className="btn" onClick={onClose} disabled={loading}>취소</button>
              <button type="submit" className="btn accent" disabled={loading || !language.trim()}>
                {loading ? "번역 중" : "번역"}
              </button>
            </div>
          </div>
          <div className="translate-body">
            <label className="field-label" htmlFor="translation-language">번역할 언어</label>
            <select
              id="translation-language"
              className="translate-input"
              value={language}
              onChange={(event) => setLanguage(event.target.value)}
              autoFocus
            >
              {TRANSLATION_LANGUAGES.map(([value, label]) => (
                <option key={value} value={value}>{label} ({value})</option>
              ))}
            </select>
            <div className="tiny-note">목록은 서버의 Ollama 번역 API가 지원하는 언어 기준입니다.</div>
          </div>
        </form>
      </section>
    </div>
  );
}

function AudioTransport({ file, audioUrl, lines, currentMs, duration, playing, onPlayToggle, onSeek, onStep }) {
  const scrubRef = useRef(null);
  const totalMs = Math.max(
    Math.round((Number(duration) || 0) * 1000),
    ...lines.map((line) => Math.round(Number(line.endMs) || 0)),
    0,
  );
  const hasAudio = Boolean(audioUrl);
  const progressPct = totalMs > 0 ? Math.min(100, Math.max(0, (currentMs / totalMs) * 100)) : 0;

  const bars = useMemo(() => {
    const count = 160;
    const seedText = file?.name || "subtitle-forge";
    let seed = seedText.split("").reduce((sum, char) => sum + char.charCodeAt(0), 17);
    return Array.from({ length: count }, (_, index) => {
      const t = totalMs > 0 ? (index / count) * totalMs : 0;
      const lineHit = lines.some((line) => t >= line.startMs && t <= line.endMs);
      seed = (seed * 16807) % 2147483647;
      const noise = (seed % 100) / 100;
      const wave = 0.24 + Math.abs(Math.sin(index * 0.41)) * 0.28 + noise * 0.2;
      return Math.min(1, lineHit ? wave + 0.26 : wave);
    });
  }, [file?.name, lines, totalMs]);

  const handleSeek = (event) => {
    if (!scrubRef.current || totalMs <= 0) return;
    const rect = scrubRef.current.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
    onSeek(pct * totalMs);
  };

  return (
    <section className="audio-transport" aria-label="Audio transport">
      <div className="transport-info">
        <div className="section-kicker">Audio Timeline</div>
        <strong>{file?.name || "MP3를 선택하면 여기서 전체 재생을 제어합니다"}</strong>
        <span>{hasAudio ? "라인 재생, pause, scrub 이동이 모두 같은 오디오에 연결됩니다." : "아직 연결된 오디오가 없습니다."}</span>
      </div>
      <div className="transport-controls">
        <button className="transport-btn" onClick={() => onStep(-5000)} disabled={!hasAudio} title="5초 뒤로">
          <Icon.SkipBack/>
        </button>
        <button className="transport-btn play" onClick={onPlayToggle} disabled={!hasAudio} title={playing ? "일시정지" : "재생"}>
          {playing ? <Icon.Pause size={17}/> : <Icon.Play size={17}/>}
        </button>
        <button className="transport-btn" onClick={() => onStep(5000)} disabled={!hasAudio} title="5초 앞으로">
          <Icon.SkipForward/>
        </button>
      </div>
      <div className={`transport-scrub ${hasAudio ? "" : "disabled"}`} ref={scrubRef} onClick={hasAudio ? handleSeek : undefined}>
        <div className="scrub-wave">
          {bars.map((height, index) => {
            const active = totalMs > 0 && ((index / bars.length) * totalMs) <= currentMs;
            return <div key={index} className={`scrub-wave-bar ${active ? "active" : ""}`} style={{ height: `${height * 78 + 8}%` }} />;
          })}
        </div>
        <div className="scrub-playhead" style={{ left: `${progressPct}%` }}/>
      </div>
      <div className="transport-time mono">
        <span>{formatClock(currentMs)}</span>
        <span className="total">/ {formatClock(totalMs)}</span>
        <b className={hasAudio ? "has-audio" : "no-audio"}>{hasAudio ? "LIVE" : "NO AUDIO"}</b>
      </div>
    </section>
  );
}

function StatusRail({ stage, progress, running, lineCount }) {
  const stages = [
    { id: "preparing_audio", label: "오디오 준비" },
    { id: "transcribing", label: "Qwen ASR" },
    { id: "aligning", label: "Forced align" },
    { id: "rendering_result", label: "SRT 렌더" },
  ];
  const stageIndex = stages.findIndex((item) => item.id === stage);

  return (
    <section className="status-card">
      <div className="section-kicker">Pipeline</div>
      <h2>ASR에서 timestamp SRT까지</h2>
      <p>
        MP3 원본을 qwen-asr-api에 보내면 서버가 5분 이하 chunk로 나누고,
        Qwen3-ASR 전사 뒤 Qwen3-ForcedAligner로 line timestamp를 생성합니다.
      </p>
      <div className="stage-list">
        {stages.map((item, index) => {
          const done = stage === "completed" || (stageIndex >= 0 && index < stageIndex);
          const active = running && item.id === stage;
          return (
            <div key={item.id} className={`stage-item ${done ? "done" : ""} ${active ? "active" : ""}`}>
              <span>{done ? <Icon.Check size={12}/> : index + 1}</span>
              <strong>{item.label}</strong>
            </div>
          );
        })}
      </div>
      <div className="progress-wrap">
        <div className="progress-head">
          <span>{running ? "처리 중" : lineCount ? "편집 가능" : "대기"}</span>
          <span className="mono">{progress}%</span>
        </div>
        <div className="progress-track"><div style={{ width: `${progress}%` }}/></div>
      </div>
      <div className="tiny-note">긴 오디오는 프론트엔드에서 자르지 않고 서버가 자동 분리합니다.</div>
    </section>
  );
}

function UploadPanel({ file, duration, running, onFile, onStart, onImportSrt }) {
  const inputRef = useRef(null);
  const srtRef = useRef(null);
  const [dragging, setDragging] = useState(false);

  const handlePicked = (picked) => {
    const nextFile = picked?.[0];
    if (nextFile) onFile(nextFile);
  };

  return (
    <section
      className={`upload-card ${dragging ? "dragging" : ""}`}
      onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
      onDragLeave={() => setDragging(false)}
      onDrop={(event) => {
        event.preventDefault();
        setDragging(false);
        handlePicked(event.dataTransfer.files);
      }}
    >
      <input ref={inputRef} type="file" accept="audio/mpeg,audio/mp3,audio/*" hidden onChange={(event) => handlePicked(event.target.files)} />
      <input
        ref={srtRef}
        type="file"
        accept=".srt,application/x-subrip,text/plain"
        hidden
        onChange={(event) => {
          const selected = event.target.files?.[0];
          event.target.value = "";
          if (selected) onImportSrt(selected);
        }}
      />
      <div className="upload-mark"><Icon.Upload size={30}/></div>
      <div>
        <div className="section-kicker">Audio Source</div>
        <h2>MP3를 올리고 SRT를 생성하세요</h2>
        <p>결과 SRT는 line 단위로만 편집합니다. paragraph, sentence, word 레벨은 새 앱에서 숨깁니다.</p>
      </div>
      <div className="upload-actions">
        <button className="btn primary" onClick={() => inputRef.current?.click()} disabled={running}>
          <Icon.Upload/> MP3 선택
        </button>
        <button className="btn" onClick={() => srtRef.current?.click()} disabled={running}>
          SRT 불러오기
        </button>
      </div>
      {file && (
        <div className="file-chip">
          <div>
            <strong>{file.name}</strong>
            <span>{formatBytes(file.size)} · {duration ? fmtDuration(duration) : "metadata 대기"}</span>
          </div>
          <button className="btn accent" onClick={onStart} disabled={running}>
            <Icon.Spark/> 자막 생성
          </button>
        </div>
      )}
    </section>
  );
}

function LibraryPanel({ tracks, selectedId, running, onSelect, onDelete }) {
  return (
    <section className="library-card">
      <div className="card-head">
        <div>
          <div className="section-kicker">MP3 Library</div>
          <h2>오디오 목록</h2>
        </div>
        <span className="mono">{tracks.length} files</span>
      </div>
      <div className="library-list">
        {tracks.length === 0 ? (
          <div className="empty-log">MP3를 업로드하면 서버 URL과 자막 결과가 여기에 보관됩니다.</div>
        ) : tracks.map((track) => (
          <button
            key={track.id}
            className={`library-item ${track.id === selectedId ? "selected" : ""}`}
            onClick={() => onSelect(track.id)}
            disabled={running}
          >
            <div>
              <strong>{track.name}</strong>
              <span>{formatBytes(track.size)} · {track.lines?.length ? `${track.lines.length} lines` : "SRT 없음"}</span>
            </div>
            <span className={`library-status ${track.status || "ready"}`}>{track.status || "ready"}</span>
            <span
              className="library-delete"
              role="button"
              tabIndex={0}
              onClick={(event) => {
                event.stopPropagation();
                onDelete(track.id);
              }}
              onKeyDown={(event) => {
                if (event.key === "Enter" || event.key === " ") {
                  event.preventDefault();
                  event.stopPropagation();
                  onDelete(track.id);
                }
              }}
              title="목록과 서버 MP3 삭제"
            >
              <Icon.Trash size={12}/>
            </span>
          </button>
        ))}
      </div>
    </section>
  );
}

function LogPanel({ logs }) {
  const ref = useRef(null);
  useEffect(() => {
    if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
  }, [logs]);

  return (
    <section className="log-card">
      <div className="card-head">
        <div>
          <div className="section-kicker">Runtime Log</div>
          <h2>처리 로그</h2>
        </div>
        <span className="mono">{logs.length} lines</span>
      </div>
      <div className="log-box" ref={ref}>
        {logs.length === 0 ? (
          <div className="empty-log">아직 실행 로그가 없습니다.</div>
        ) : logs.map((log) => (
          <div key={log.id} className={`log-line ${log.tag}`}>
            <span className="mono">{log.time}</span>
            <b>{log.tag}</b>
            <span>{log.message}</span>
          </div>
        ))}
      </div>
    </section>
  );
}

function TimeInput({ value, onChange }) {
  const [draft, setDraft] = useState(Srt.formatShort(value));

  useEffect(() => {
    setDraft(Srt.formatShort(value));
  }, [value]);

  const commit = () => {
    const parsed = Srt.parseLooseTime(draft);
    if (parsed == null) {
      setDraft(Srt.formatShort(value));
      return;
    }
    onChange(parsed);
  };

  return (
    <input
      className="time-input mono"
      value={draft}
      onChange={(event) => setDraft(event.target.value)}
      onBlur={commit}
      onKeyDown={(event) => {
        if (event.key === "Enter") event.currentTarget.blur();
        if (event.key === "Escape") setDraft(Srt.formatShort(value));
      }}
    />
  );
}

function EditorPanel({
  lines,
  selectedId,
  audioRef,
  audioUrl,
  currentMs,
  playing,
  rawSrt,
  sourceName,
  onSelect,
  onLineChange,
  onDelete,
  onAddAfter,
  onMove,
  onSetTimeFromPlayhead,
  onDownload,
  onResetEdit,
  onTranslateOpen,
  onToast,
  translating,
}) {
  const selectedLine = lines.find((line) => line.id === selectedId) || lines[0] || null;
  const srtPreview = useMemo(() => Srt.renderSrt(lines), [lines]);

  const seekLine = (line) => {
    if (!audioRef.current || !audioUrl) {
      onToast("먼저 MP3를 업로드하면 line 위치로 재생할 수 있습니다.");
      return;
    }
    const isCurrentLine = currentMs >= line.startMs && currentMs < line.endMs;
    if (playing && isCurrentLine) {
      audioRef.current.pause();
      return;
    }
    audioRef.current.currentTime = line.startMs / 1000;
    audioRef.current.play().catch(() => onToast("브라우저가 재생을 막았습니다. 오디오 컨트롤에서 다시 시도해 주세요."));
  };

  return (
    <section className="editor-shell">
      <div className="editor-toolbar">
        <div>
          <div className="section-kicker">Line Editor</div>
          <h2>{lines.length ? `${lines.length}개 SRT line` : "편집할 SRT가 없습니다"}</h2>
          <p>{sourceName ? sourceName : "MP3를 업로드해 자막을 생성하거나 SRT 파일을 불러오세요."}</p>
        </div>
        <div className="toolbar-actions">
          <span className={`play-badge ${playing ? "playing" : ""}`}>
            {playing ? <Icon.Play size={12}/> : null}
            <span className="mono">{Srt.formatShort(currentMs)}</span>
          </span>
          <button className="btn primary" onClick={onTranslateOpen} disabled={!lines.length || translating}>
            <Icon.Spark/> {translating ? "번역 중" : "번역"}
          </button>
          <button className="btn accent" onClick={onDownload} disabled={!lines.length}>
            <Icon.Download/> SRT 저장
          </button>
          <button className="btn danger" onClick={onResetEdit} disabled={!lines.length && !rawSrt}>
            Reset
          </button>
        </div>
      </div>

      {lines.length === 0 ? (
        <div className="empty-editor">
          <div className="empty-orbit">SRT</div>
          <h3>timestamp가 있는 자막을 기다리는 중입니다</h3>
          <p>qwen-asr-api 결과의 `segments` 또는 `text`를 line 목록으로 바꿔 여기에 표시합니다.</p>
        </div>
      ) : (
        <div className="editor-grid">
          <div className="line-list">
            {lines.map((line, index) => {
              const active = currentMs >= line.startMs && currentMs < line.endMs;
              const selected = selectedLine?.id === line.id;
              return (
                <div key={line.id} className={`line-row ${selected ? "selected" : ""} ${active ? "active" : ""}`} onClick={() => onSelect(line.id)}>
                  <button className="row-play" onClick={(event) => { event.stopPropagation(); seekLine(line); }}>
                    {active && playing ? <Icon.Pause size={12}/> : <Icon.Play size={12}/>}
                  </button>
                  <div className="row-index mono">{index + 1}</div>
                  <div className="row-time mono">{Srt.formatShort(line.startMs)} → {Srt.formatShort(line.endMs)}</div>
                  <div className="row-copy">
                    <div className="row-text">{line.text || <span className="muted">빈 line</span>}</div>
                    {line.translatedText && (
                      <div className="row-translation">
                        <span>{line.translationLanguage || "번역"}</span>
                        <b>{line.translatedText}</b>
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>

          <div className="detail-panel">
            {selectedLine && (
              <>
                <div className="detail-title">
                  <span className="mono">#{lines.findIndex((line) => line.id === selectedLine.id) + 1}</span>
                  <strong>Line 편집</strong>
                </div>
                <label className="field-label">시작 / 끝</label>
                <div className="time-pair">
                  <TimeInput value={selectedLine.startMs} onChange={(startMs) => onLineChange(selectedLine.id, { startMs: Math.min(startMs, selectedLine.endMs - 50) })} />
                  <TimeInput value={selectedLine.endMs} onChange={(endMs) => onLineChange(selectedLine.id, { endMs: Math.max(endMs, selectedLine.startMs + 50) })} />
                </div>
                <div className="time-actions">
                  <button className="btn" onClick={() => onSetTimeFromPlayhead(selectedLine.id, "start")}>playhead → start</button>
                  <button className="btn" onClick={() => onSetTimeFromPlayhead(selectedLine.id, "end")}>playhead → end</button>
                </div>
                <label className="field-label">자막 텍스트</label>
                <textarea
                  className="line-textarea"
                  value={selectedLine.text}
                  onChange={(event) => onLineChange(selectedLine.id, { text: event.target.value })}
                />
                <label className="field-label">번역 텍스트</label>
                <textarea
                  className="line-textarea translation"
                  value={selectedLine.translatedText || ""}
                  placeholder="번역 버튼을 누르면 원문 아래에 추가될 번역이 여기에 표시됩니다."
                  onChange={(event) => onLineChange(selectedLine.id, { translatedText: event.target.value })}
                />
                <div className="line-actions">
                  <button className="btn" onClick={() => onAddAfter(selectedLine.id)}><Icon.Plus/> 아래 추가</button>
                  <button className="btn" onClick={() => onMove(selectedLine.id, -1)}><Icon.ArrowUp/> 위로</button>
                  <button className="btn" onClick={() => onMove(selectedLine.id, 1)}><Icon.ArrowDown/> 아래로</button>
                  <button className="btn danger" onClick={() => onDelete(selectedLine.id)}><Icon.Trash/> 삭제</button>
                </div>
                <div className="preview-card">
                  <div className="section-kicker">SRT Preview</div>
                  <pre>{srtPreview || rawSrt}</pre>
                </div>
              </>
            )}
          </div>
        </div>
      )}
    </section>
  );
}

function App() {
  const audioRef = useRef(null);
  const [library, setLibrary] = useState(loadLibrary);
  const [selectedTrackId, setSelectedTrackId] = useState(() => localStorage.getItem(SELECTED_TRACK_KEY) || "");
  const [sourceName, setSourceName] = useState("");
  const [audioUrl, setAudioUrl] = useState("");
  const [duration, setDuration] = useState(0);
  const [lines, setLines] = useState([]);
  const [rawSrt, setRawSrt] = useState("");
  const [logs, setLogs] = useState([]);
  const [progress, setProgress] = useState(0);
  const [stage, setStage] = useState("");
  const [running, setRunning] = useState(false);
  const [selectedId, setSelectedId] = useState("");
  const [currentMs, setCurrentMs] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [toast, setToast] = useState("");
  const [jobDialogOpen, setJobDialogOpen] = useState(false);
  const [jobSnapshot, setJobSnapshot] = useState(null);
  const [jobsLoading, setJobsLoading] = useState(false);
  const [jobsError, setJobsError] = useState("");
  const [translationDialogOpen, setTranslationDialogOpen] = useState(false);
  const [translating, setTranslating] = useState(false);

  const selectedTrack = library.find((track) => track.id === selectedTrackId) || library[0] || null;
  const currentFile = selectedTrack ? { name: selectedTrack.name, size: selectedTrack.size } : null;
  const busy = running || translating;

  const updateTrack = (trackId, patch) => {
    setLibrary((prev) => prev.map((track) => (
      track.id === trackId
        ? { ...track, ...patch, updatedAt: new Date().toISOString() }
        : track
    )));
  };

  const showToast = (message) => {
    setToast(message);
    window.clearTimeout(showToast._timer);
    showToast._timer = window.setTimeout(() => setToast(""), 2400);
  };

  const addLog = (tag, message) => {
    const now = new Date();
    setLogs((prev) => [...prev, {
      id: `${Date.now()}_${prev.length}`,
      tag,
      message,
      time: `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`,
    }]);
  };

  const loadJobs = async () => {
    setJobsLoading(true);
    setJobsError("");
    try {
      const snapshot = await Api.listJobs({ limit: 20 });
      setJobSnapshot(snapshot);
    } catch (error) {
      setJobsError(error.message || "Job 목록을 불러오지 못했습니다.");
    } finally {
      setJobsLoading(false);
    }
  };

  useEffect(() => {
    const audio = audioRef.current;
    if (!audio) return undefined;
    const onTime = () => setCurrentMs(Math.round(audio.currentTime * 1000));
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    const onEnded = () => setPlaying(false);
    const onMeta = () => {
      const nextDuration = audio.duration || 0;
      setDuration(nextDuration);
      if (selectedTrackId && nextDuration) updateTrack(selectedTrackId, { duration: nextDuration });
    };
    audio.addEventListener("timeupdate", onTime);
    audio.addEventListener("play", onPlay);
    audio.addEventListener("pause", onPause);
    audio.addEventListener("ended", onEnded);
    audio.addEventListener("loadedmetadata", onMeta);
    return () => {
      audio.removeEventListener("timeupdate", onTime);
      audio.removeEventListener("play", onPlay);
      audio.removeEventListener("pause", onPause);
      audio.removeEventListener("ended", onEnded);
      audio.removeEventListener("loadedmetadata", onMeta);
    };
  }, [audioUrl, selectedTrackId]);

  useEffect(() => () => {
    if (audioUrl?.startsWith("blob:")) URL.revokeObjectURL(audioUrl);
  }, [audioUrl]);

  useEffect(() => {
    saveLibrary(library);
    if (selectedTrackId) {
      localStorage.setItem(SELECTED_TRACK_KEY, selectedTrackId);
    } else {
      localStorage.removeItem(SELECTED_TRACK_KEY);
    }
  }, [library, selectedTrackId]);

  useEffect(() => {
    if (!selectedTrack) {
      setSelectedTrackId("");
      setSourceName("");
      setAudioUrl("");
      setDuration(0);
      setLines([]);
      setRawSrt("");
      setSelectedId("");
      setProgress(0);
      setStage("");
      setCurrentMs(0);
      setTranslationDialogOpen(false);
      return;
    }

    if (selectedTrack.id !== selectedTrackId) {
      setSelectedTrackId(selectedTrack.id);
    }
    setSourceName(selectedTrack.name);
    setAudioUrl(selectedTrack.audioUrl || "");
    setDuration(Number(selectedTrack.duration || 0));
    setLines(selectedTrack.lines || []);
    setRawSrt(selectedTrack.rawSrt || "");
    setSelectedId(selectedTrack.selectedLineId || selectedTrack.lines?.[0]?.id || "");
    setProgress(selectedTrack.lines?.length ? 100 : 0);
    setStage(selectedTrack.lines?.length ? "completed" : "");
    setCurrentMs(0);
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current.currentTime = 0;
    }
  }, [selectedTrack?.id]);

  useEffect(() => {
    if (!jobDialogOpen) return undefined;
    loadJobs();
    const timer = window.setInterval(loadJobs, 5000);
    return () => window.clearInterval(timer);
  }, [jobDialogOpen]);

  const handleFile = async (nextFile) => {
    if (!nextFile) return;
    setRunning(true);
    try {
      addLog("work", `MP3 서버 저장 · ${nextFile.name} · ${formatBytes(nextFile.size)}`);
      const saved = await Api.saveAudioFile(nextFile);
      const track = makeTrackFromUpload(nextFile, saved);
      setLibrary((prev) => [track, ...prev]);
      setSelectedTrackId(track.id);
      setSourceName(track.name);
      setAudioUrl(track.audioUrl);
      setDuration(0);
      setProgress(0);
      setStage("");
      setLines([]);
      setRawSrt("");
      setSelectedId("");
      addLog("ok", `MP3 저장 완료 · ${track.storageFilename}`);
      showToast("MP3를 서버에 저장하고 목록에 추가했습니다.");
    } catch (error) {
      addLog("err", error.message || String(error));
      showToast(error.message || "MP3 저장에 실패했습니다.");
    } finally {
      setRunning(false);
    }
  };

  const startTranscribe = async () => {
    if (!selectedTrack) {
      showToast("먼저 MP3 파일을 선택하세요.");
      return;
    }
    setRunning(true);
    setLogs([]);
    setProgress(0);
    setStage("queued");
    updateTrack(selectedTrack.id, { status: "transcribing" });
    try {
      const transcribeFile = await Api.fileFromAudioUrl(selectedTrack.audioUrl, selectedTrack.name);
      const { result } = await Api.transcribeToSrt(transcribeFile, {
        onLog: addLog,
        onProgress: setProgress,
        onStage: ({ stage: nextStage }) => setStage(nextStage),
      });
      const nextLines = result.segments?.length
        ? Srt.linesFromSegments(result.segments)
        : Srt.parseSrt(result.text);
      setRawSrt(result.text);
      setLines(nextLines);
      setSelectedId(nextLines[0]?.id || "");
      updateTrack(selectedTrack.id, {
        status: "completed",
        rawSrt: result.text,
        lines: persistableLines(nextLines),
        selectedLineId: nextLines[0]?.id || "",
        duration: result.duration_seconds || duration,
      });
      showToast(`SRT 생성 완료 · ${nextLines.length} lines`);
    } catch (error) {
      updateTrack(selectedTrack.id, { status: "failed" });
      addLog("err", error.message || String(error));
      showToast(error.message || "자막 생성에 실패했습니다.");
    } finally {
      setRunning(false);
    }
  };

  const importSrt = async (srtFile) => {
    try {
      const text = await readFileAsText(srtFile);
      const parsed = Srt.parseSrt(text);
      if (!parsed.length) throw new Error("SRT line을 찾지 못했습니다.");
      setRawSrt(text);
      setLines(parsed);
      setSelectedId(parsed[0].id);
      setSourceName(srtFile.name);
      setProgress(100);
      if (selectedTrack) {
        updateTrack(selectedTrack.id, {
          status: "completed",
          rawSrt: text,
          lines: persistableLines(parsed),
          selectedLineId: parsed[0].id,
        });
      }
      addLog("ok", `SRT 불러오기 완료 · ${srtFile.name} · ${parsed.length} lines`);
      showToast(`SRT 불러오기 완료 · ${parsed.length} lines`);
    } catch (error) {
      showToast(error.message || "SRT 파일을 읽을 수 없습니다.");
    }
  };

  const updateLine = (id, patch) => {
    setLines((prev) => {
      const next = prev.map((line) => line.id === id ? { ...line, ...patch } : line);
      if (selectedTrack) {
        updateTrack(selectedTrack.id, {
          lines: persistableLines(next),
          rawSrt: Srt.renderSrt(next),
          selectedLineId: selectedId || id,
        });
      }
      return next;
    });
  };

  const deleteLine = (id) => {
    setLines((prev) => {
      const next = prev.filter((line) => line.id !== id).map((line, index) => ({ ...line, index: index + 1 }));
      if (selectedId === id) setSelectedId(next[0]?.id || "");
      if (selectedTrack) {
        updateTrack(selectedTrack.id, {
          lines: persistableLines(next),
          rawSrt: Srt.renderSrt(next),
          selectedLineId: next[0]?.id || "",
        });
      }
      return next;
    });
  };

  const addAfter = (id) => {
    setLines((prev) => {
      const index = prev.findIndex((line) => line.id === id);
      const anchor = prev[index] || prev[prev.length - 1];
      const startMs = anchor ? anchor.endMs + 100 : currentMs;
      const newLine = {
        id: Srt.makeLineId("manual"),
        index: index + 2,
        startMs,
        endMs: startMs + 2000,
        text: "",
        translatedText: "",
        translationLanguage: "",
      };
      const next = [...prev.slice(0, index + 1), newLine, ...prev.slice(index + 1)]
        .map((line, nextIndex) => ({ ...line, index: nextIndex + 1 }));
      setSelectedId(newLine.id);
      if (selectedTrack) {
        updateTrack(selectedTrack.id, {
          lines: persistableLines(next),
          rawSrt: Srt.renderSrt(next),
          selectedLineId: newLine.id,
        });
      }
      return next;
    });
  };

  const moveLine = (id, direction) => {
    setLines((prev) => {
      const index = prev.findIndex((line) => line.id === id);
      const target = index + direction;
      if (index < 0 || target < 0 || target >= prev.length) return prev;
      const next = [...prev];
      [next[index], next[target]] = [next[target], next[index]];
      const resequenced = next.map((line, nextIndex) => ({ ...line, index: nextIndex + 1 }));
      if (selectedTrack) {
        updateTrack(selectedTrack.id, {
          lines: persistableLines(resequenced),
          rawSrt: Srt.renderSrt(resequenced),
          selectedLineId: id,
        });
      }
      return resequenced;
    });
  };

  const setTimeFromPlayhead = (id, which) => {
    const line = lines.find((item) => item.id === id);
    if (!line) return;
    if (which === "start") {
      updateLine(id, { startMs: Math.min(currentMs, line.endMs - 50) });
    } else {
      updateLine(id, { endMs: Math.max(currentMs, line.startMs + 50) });
    }
  };

  const downloadSrt = () => {
    if (!lines.length) return;
    const filename = Srt.buildDownloadName(sourceName || selectedTrack?.name || "subtitle");
    Srt.downloadText(filename, Srt.renderSrt(lines));
    showToast(`${filename} 다운로드 시작`);
  };

  const selectTrack = (trackId) => {
    if (busy) return;
    setSelectedTrackId(trackId);
  };

  const deleteTrack = async (trackId) => {
    const target = library.find((track) => track.id === trackId);
    if (!target) return;
    if (!window.confirm(`${target.name} 항목과 서버 MP3를 삭제할까요?`)) return;
    try {
      await Api.deleteAudioFile(target.storageFilename);
    } catch (error) {
      addLog("err", error.message || String(error));
    }
    setLibrary((prev) => prev.filter((track) => track.id !== trackId));
    if (selectedTrackId === trackId) {
      const next = library.find((track) => track.id !== trackId);
      setSelectedTrackId(next?.id || "");
    }
    showToast("MP3 목록 항목을 삭제했습니다.");
  };

  const resetCurrentEdit = () => {
    if (!selectedTrack) {
      showToast("초기화할 MP3 항목이 없습니다.");
      return;
    }
    if (!lines.length && !rawSrt) {
      showToast("초기화할 자막 편집 내용이 없습니다.");
      return;
    }
    if (!window.confirm(`${selectedTrack.name}의 자막 편집 내용을 초기화할까요? MP3 파일은 유지됩니다.`)) return;

    setLines([]);
    setRawSrt("");
    setSelectedId("");
    setProgress(0);
    setStage("");
    setCurrentMs(0);
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current.currentTime = 0;
    }
    updateTrack(selectedTrack.id, {
      status: "ready",
      rawSrt: "",
      lines: [],
      selectedLineId: "",
    });
    addLog("info", `자막 편집 상태 초기화 · ${selectedTrack.name}`);
    showToast("현재 MP3의 자막 편집 상태를 초기화했습니다.");
  };

  const seekTo = (ms) => {
    const totalMs = Math.max(Math.round((Number(duration) || 0) * 1000), ...lines.map((line) => line.endMs || 0), 0);
    const clamped = Math.max(0, totalMs ? Math.min(totalMs, Math.round(ms)) : Math.round(ms));
    setCurrentMs(clamped);
    if (audioRef.current && audioUrl) {
      audioRef.current.currentTime = clamped / 1000;
    }
  };

  const togglePlayback = () => {
    const audio = audioRef.current;
    if (!audio || !audioUrl) {
      showToast("먼저 MP3 파일을 선택하세요.");
      return;
    }
    if (playing) {
      audio.pause();
      return;
    }
    audio.play().catch(() => showToast("브라우저가 재생을 막았습니다. 오디오 컨트롤에서 다시 시도해 주세요."));
  };

  const translateLines = async (targetLanguage) => {
    if (!selectedTrack || !lines.length) {
      showToast("번역할 SRT line이 없습니다.");
      return;
    }
    setTranslating(true);
    setTranslationDialogOpen(false);
    updateTrack(selectedTrack.id, { status: "translating" });
    addLog("work", `Ollama 번역 시작 · target=${targetLanguage} · context=±5 lines`);
    try {
      const result = await Api.translateSubtitleLines({
        lines,
        targetLanguage,
        contextRadius: 5,
      });
      const translationsByIndex = new Map(
        (result.translations || []).map((item) => [Number(item.index), item.translated_text || ""]),
      );
      const nextLines = lines.map((line, index) => ({
        ...line,
        translatedText: translationsByIndex.get(index + 1) || line.translatedText || "",
        translationLanguage: result.target_language || targetLanguage,
      }));
      const rendered = Srt.renderSrt(nextLines);
      setLines(nextLines);
      setRawSrt(rendered);
      updateTrack(selectedTrack.id, {
        status: "completed",
        rawSrt: rendered,
        lines: persistableLines(nextLines),
        selectedLineId: selectedId || nextLines[0]?.id || "",
      });
      const fallbackNote = result.fallback ? ` · fallback=${result.fallback}` : "";
      addLog("ok", `Ollama 번역 완료 · ${result.lines_translated || nextLines.length} lines · ${result.target_language || targetLanguage}${fallbackNote}`);
      showToast(`번역 완료 · ${result.target_language || targetLanguage}`);
    } catch (error) {
      updateTrack(selectedTrack.id, { status: "failed" });
      addLog("err", error.message || String(error));
      showToast(error.message || "번역에 실패했습니다.");
    } finally {
      setTranslating(false);
    }
  };

  const activeLine = lines.find((line) => currentMs >= line.startMs && currentMs < line.endMs);
  useEffect(() => {
    if (activeLine && !selectedId) setSelectedId(activeLine.id);
  }, [activeLine?.id, selectedId]);

  return (
    <div className="app-root">
      <audio ref={audioRef} src={audioUrl || undefined} preload="metadata" />
      <header className="hero">
        <div className="brand-mark">SF</div>
        <div className="hero-copy">
          <div>
            <div className="eyebrow">frontend-subtitle</div>
            <h1>MP3 자막 파일 생성 & 편집기</h1>
          </div>
          <button className="btn queue-btn" onClick={() => setJobDialogOpen(true)}>
            Job Queue 보기
          </button>
        </div>
      </header>

      <AudioTransport
        file={currentFile}
        audioUrl={audioUrl}
        lines={lines}
        currentMs={currentMs}
        duration={duration}
        playing={playing}
        onPlayToggle={togglePlayback}
        onSeek={seekTo}
        onStep={(deltaMs) => seekTo(currentMs + deltaMs)}
      />

      <main className="workspace">
        <aside className="left-rail">
          <LibraryPanel
            tracks={library}
            selectedId={selectedTrack?.id || ""}
            running={busy}
            onSelect={selectTrack}
            onDelete={deleteTrack}
          />
          <UploadPanel
            file={currentFile}
            duration={duration}
            running={busy}
            onFile={handleFile}
            onStart={startTranscribe}
            onImportSrt={importSrt}
          />
          <StatusRail stage={stage} progress={progress} running={busy} lineCount={lines.length} />
          <LogPanel logs={logs} />
        </aside>

        <EditorPanel
          lines={lines}
          selectedId={selectedId}
          audioRef={audioRef}
          audioUrl={audioUrl}
          currentMs={currentMs}
          playing={playing}
          rawSrt={rawSrt}
          sourceName={sourceName}
          onSelect={(id) => {
            setSelectedId(id);
            if (selectedTrack) updateTrack(selectedTrack.id, { selectedLineId: id });
          }}
          onLineChange={updateLine}
          onDelete={deleteLine}
          onAddAfter={addAfter}
          onMove={moveLine}
          onSetTimeFromPlayhead={setTimeFromPlayhead}
          onDownload={downloadSrt}
          onResetEdit={resetCurrentEdit}
          onTranslateOpen={() => setTranslationDialogOpen(true)}
          onToast={showToast}
          translating={translating}
        />
      </main>
      <JobQueueDialog
        open={jobDialogOpen}
        snapshot={jobSnapshot}
        loading={jobsLoading}
        error={jobsError}
        onClose={() => setJobDialogOpen(false)}
        onRefresh={loadJobs}
      />
      <TranslationDialog
        open={translationDialogOpen}
        lineCount={lines.length}
        loading={translating}
        onClose={() => setTranslationDialogOpen(false)}
        onTranslate={translateLines}
      />
      <ToastHost toast={toast} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
