/* EditMode — the "Edit" tab: pick a creator format, drop ONE raw clip, REVIEW the
   AI's edit plan (live preview + editable captions), then render.
   State machine: compose → planning → review → rendering → result | error.
   APIs: /api/formats, /api/upload-clip, /api/edit-plan, /api/edit-render,
   /api/edit-rerender, /api/events/:id, /api/video/:id. */
(() => {
const { useState: useS, useEffect: useE, useRef: useRf } = React;

const PLAN_STAGES = [
  { id: 'normalize',     label: 'Prepping your clip' },
  { id: 'transcribe',    label: 'Listening to every word' },
  { id: 'transliterate', label: 'Romanizing Hindi words', conditional: true },
  { id: 'editboard',     label: 'Planning the edit' },
];

function sseFollow(id, onEvent) {
  return new Promise((resolve, reject) => {
    const es = new EventSource('/api/events/' + id);
    es.onmessage = (e) => {
      const { stage, payload } = JSON.parse(e.data);
      if (stage === 'done') { es.close(); resolve(payload); return; }
      if (stage === 'error') { es.close(); reject(new Error(payload.message || 'failed')); return; }
      try { onEvent && onEvent(stage, payload); } catch {}
    };
    es.onerror = () => { es.close(); reject(new Error('connection lost')); };
  });
}

const mmss = (ms) => { const s = Math.max(0, Math.round(ms / 1000)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };

// Persist the Edit session so a refresh / navigation / accidental reset doesn't
// throw away a finished reel or the caption edits. The rendered MP4 lives on the
// server (see /api/video/:id); here we only keep the handle (jobId) + the board.
const EDIT_SESSION_KEY = 'kinetic.edit.v1';
const loadEditSession = () => { try { return JSON.parse(localStorage.getItem(EDIT_SESSION_KEY) || 'null'); } catch { return null; } };
const saveEditSession = (s) => { try { s ? localStorage.setItem(EDIT_SESSION_KEY, JSON.stringify(s)) : localStorage.removeItem(EDIT_SESSION_KEY); } catch {} };

// Network-resilient fetch. The Railway box goes cold when idle (a wake takes
// several seconds) and each edit holds it for ~70s, so a request issued at a bad
// moment gets its connection dropped ("Load failed" in Safari) or a 5xx. Retry
// transient failures with backoff instead of failing on the first hiccup. A 4xx
// (rate limit, too-large, bad input) is a real answer — return it, don't retry.
async function fetchRetry(url, opts = {}, { tries = 5, base = 900 } = {}) {
  let lastErr;
  for (let i = 0; i < tries; i++) {
    try {
      const r = await fetch(url, opts);
      if (r.status >= 500) throw new Error('server ' + r.status); // cold-start / restart → retry
      return r;
    } catch (e) {
      lastErr = e;
      if (i < tries - 1) await new Promise((res) => setTimeout(res, base * (i + 1)));
    }
  }
  throw lastErr;
}

// Upload with a REAL progress % — fetch() can't report upload progress, XHR can.
// Same cold-start resilience as fetchRetry (retry a dropped connection / 5xx, but
// NOT a 4xx like "too large" / "busy"). onProgress gets a 0..1 fraction.
function xhrUpload(url, file, { onProgress, tries = 3 } = {}) {
  return new Promise((resolve, reject) => {
    let attempt = 0;
    const go = () => {
      attempt++;
      if (onProgress) onProgress(0);
      const xhr = new XMLHttpRequest();
      xhr.open('POST', url);
      xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total); };
      const retry = () => { if (attempt < tries) setTimeout(go, 800 * attempt); else reject(new Error('network')); };
      xhr.onload = () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          if (onProgress) onProgress(1);
          try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
        } else if (xhr.status >= 500) { retry(); }        // cold-start / restart → retry
        else { let m = 'server ' + xhr.status; try { const j = JSON.parse(xhr.responseText); if (j.error) m = j.error; } catch {} reject(new Error(m)); }
      };
      xhr.onerror = retry;
      xhr.ontimeout = retry;
      xhr.send(file);
    };
    go();
  });
}
const pageText = (board, c) => board.words.slice(c.from, c.to + 1).map(w => w.w).join(' ');

/* ── editPageText: apply edited text to one caption page ─────────────────────
   Same word count → swap words, timings untouched (the common mishear fix).
   Different count → redistribute the page's FIXED [start,end] span evenly and
   shift every later index (pages/emph/kw/moments/graphics) by the delta.
   Page time boundaries never move, so global timing order stays sane; the
   server re-validates the whole board before rendering anyway. */
function editPageText(board, pageIdx, text) {
  const tokens = String(text).trim().split(/\s+/).filter(Boolean);
  if (!tokens.length) return board;
  const b = JSON.parse(JSON.stringify(board));
  const page = b.captions[pageIdx];
  const oldN = page.to - page.from + 1, n = tokens.length;
  if (n === oldN) {
    tokens.forEach((tk, i) => { b.words[page.from + i].w = tk; });
    return b;
  }
  const start = b.words[page.from].s, end = b.words[page.to].e;
  const span = Math.max(1, end - start);
  const newWords = tokens.map((tk, i) => ({
    w: tk,
    s: Math.round(start + (span * i) / n),
    e: Math.round(start + (span * (i + 1)) / n),
  }));
  b.words.splice(page.from, oldN, ...newWords);
  const delta = n - oldN, oldTo = page.to, newTo = page.from + n - 1;
  b.captions.forEach((c, i) => {
    if (i < pageIdx) return;
    if (i === pageIdx) {
      c.to = newTo;
      c.emph = (c.emph || []).map(x => Math.min(newTo, x)).filter((x, j, a) => a.indexOf(x) === j).slice(0, 2);
      const kw = {}; Object.entries(c.kw || {}).forEach(([k, v]) => { kw[Math.min(newTo, +k)] = v; }); c.kw = kw;
    } else {
      c.from += delta; c.to += delta;
      c.emph = (c.emph || []).map(x => x + delta);
      const kw = {}; Object.entries(c.kw || {}).forEach(([k, v]) => { kw[+k + delta] = v; }); c.kw = kw;
    }
  });
  (b.moments || []).forEach(m => {
    if (m.atWord > oldTo) m.atWord += delta;
    else if (m.atWord >= page.from) m.atWord = Math.min(m.atWord, newTo);
  });
  (b.graphics || []).forEach(g => {
    if (g.fromWord > oldTo) g.fromWord += delta; else if (g.fromWord >= page.from) g.fromWord = Math.min(g.fromWord, newTo);
    if (g.toWord > oldTo) g.toWord += delta; else if (g.toWord >= page.from) g.toWord = Math.min(g.toWord, newTo);
    if (g.toWord < g.fromWord) g.toWord = g.fromWord;
  });
  return b;
}

/* ── addCaptionAfter: INSERT a brand-new caption page ────────────────────────
   The escape hatch for words STT never transcribed (mumble/cross-talk/accent) —
   there's no word to edit, so we synthesize timed words for the typed text and
   drop them into the time gap after page `pageIdx` (-1 = before the first page).
   Everything downstream (page indices, emph, kw, moments, graphics) shifts by
   the inserted word count, exactly like editPageText. If the gap is too tight we
   carve a little room from the neighbouring words; the server re-validates and
   re-pages from `words` before rendering, so timings can only get saner.
   startMs (optional) = the user's chosen start time, clamped inside the gap —
   this is what the timeline tap / slider feeds in. */
function addCaptionAfter(board, pageIdx, text, startMs) {
  const tokens = String(text).trim().split(/\s+/).filter(Boolean);
  if (!tokens.length) return board;
  const b = JSON.parse(JSON.stringify(board));
  const DEFAULT_DUR = 1500, MIN_DUR = 600;

  const clipEnd = Math.round((b.source?.durationSec || 0) * 1000) || (b.words.length ? b.words[b.words.length - 1].e : 0);
  const insertAt = pageIdx < 0 ? 0 : b.captions[pageIdx].to + 1; // word index the new words occupy
  const prevEnd = pageIdx < 0 ? 0 : b.words[b.captions[pageIdx].to].e;
  const nextPage = b.captions[pageIdx + 1];
  const nextStart = nextPage ? b.words[nextPage.from].s : (clipEnd || prevEnd + DEFAULT_DUR);

  let s = prevEnd, e;
  if (nextStart - prevEnd >= MIN_DUR) {
    // real room in the gap — honor the user's chosen start (clamped inside it)
    if (Number.isFinite(startMs)) s = Math.max(prevEnd, Math.min(Math.round(startMs), nextStart - MIN_DUR));
    e = Math.min(nextStart, s + DEFAULT_DUR);
  } else {
    e = Math.min(nextStart, s + DEFAULT_DUR);
    // gap too small — carve from the previous word's tail, then the next word's head
    if (pageIdx >= 0) {
      const lw = b.words[b.captions[pageIdx].to];
      const steal = Math.min(MIN_DUR - (e - s), Math.max(0, (lw.e - lw.s) - 120));
      lw.e -= steal; s -= steal;
    }
    if (e - s < MIN_DUR && nextPage) {
      const nw = b.words[nextPage.from];
      const steal = Math.min(MIN_DUR - (e - s), Math.max(0, (nw.e - nw.s) - 120));
      nw.s += steal; e += steal;
    }
    if (e <= s) e = s + MIN_DUR;
  }

  const span = Math.max(1, e - s), n = tokens.length;
  const newWords = tokens.map((tk, i) => ({ w: tk, s: Math.round(s + span * i / n), e: Math.round(s + span * (i + 1) / n) }));
  b.words.splice(insertAt, 0, ...newWords);

  const bump = (idx) => (idx >= insertAt ? idx + n : idx);
  b.captions.forEach(c => {
    c.from = bump(c.from); c.to = bump(c.to);
    c.emph = (c.emph || []).map(bump);
    const kw = {}; Object.entries(c.kw || {}).forEach(([k, v]) => { kw[bump(+k)] = v; }); c.kw = kw;
  });
  (b.moments || []).forEach(m => { m.atWord = bump(m.atWord); });
  (b.graphics || []).forEach(g => { g.fromWord = bump(g.fromWord); g.toWord = bump(g.toWord); });

  const maxId = b.captions.reduce((mx, c) => { const m = /^c(\d+)$/.exec(c.id || ''); return m ? Math.max(mx, +m[1]) : mx; }, 0);
  b.captions.splice(pageIdx + 1, 0, { id: 'c' + (maxId + 1), from: insertAt, to: insertAt + n - 1, emph: [], kw: {}, emoji: null });
  return b;
}

/* ── timeline board mutations (all times in MS; return a new board) ───────────
   These give the review timeline the design's drag/trim/delete/add on the
   existing edit-board (words + caption pages + moments + graphics). Captions
   move/trim by shifting/scaling their words' timings; moments/graphics anchor
   to word indices, so we remap them to the word nearest the new time. */
const _clone = (x) => JSON.parse(JSON.stringify(x));
const timeToWordIdx = (board, ms) => {
  const w = board.words; if (!w.length) return 0;
  let best = 0, bd = Infinity;
  for (let i = 0; i < w.length; i++) { const d = Math.abs((w[i].s + w[i].e) / 2 - ms); if (d < bd) { bd = d; best = i; } }
  return best;
};
// caption: MOVE = shift the page's words by delta (keeps per-word timing)
function moveCaption(board, capIdx, newStartMs) {
  const b = _clone(board); const c = b.captions[capIdx]; if (!c) return board;
  const delta = Math.round(newStartMs) - b.words[c.from].s;
  for (let i = c.from; i <= c.to; i++) { b.words[i].s += delta; b.words[i].e += delta; }
  return b;
}
// caption: TRIM = scale the page's words into [newS,newE]
function trimCaption(board, capIdx, newS, newE) {
  const b = _clone(board); const c = b.captions[capIdx]; if (!c) return board;
  const s0 = b.words[c.from].s, e0 = b.words[c.to].e, span0 = Math.max(1, e0 - s0), span1 = Math.max(200, Math.round(newE - newS));
  for (let i = c.from; i <= c.to; i++) { const ws = b.words[i].s, we = b.words[i].e; b.words[i].s = Math.round(newS + (ws - s0) / span0 * span1); b.words[i].e = Math.round(newS + (we - s0) / span0 * span1); }
  return b;
}
// caption: DELETE = drop the page + its words (render re-pages the rest); anchors on removed words are dropped
function removeCaption(board, capIdx) {
  const b = _clone(board); const c = b.captions[capIdx]; if (!c) return board;
  const from = c.from, to = c.to, n = to - from + 1;
  b.words.splice(from, n);
  const remap = (idx) => (idx < from ? idx : idx > to ? idx - n : -1);
  b.captions.splice(capIdx, 1);
  b.captions = b.captions.map((x) => {
    let nf = remap(x.from), nt = remap(x.to);
    if (nf < 0) nf = Math.max(0, Math.min(from, b.words.length - 1)); if (nt < 0) nt = nf;
    const kw = {}; Object.entries(x.kw || {}).forEach(([k, v]) => { const r = remap(+k); if (r >= 0) kw[r] = v; });
    return { ...x, from: Math.max(0, nf), to: Math.max(nf, nt), emph: (x.emph || []).map(remap).filter((i) => i >= 0), kw };
  });
  b.moments = (b.moments || []).filter((m) => remap(m.atWord) >= 0).map((m) => ({ ...m, atWord: remap(m.atWord) }));
  b.graphics = (b.graphics || []).filter((g) => remap(g.fromWord) >= 0 && remap(g.toWord) >= 0).map((g) => ({ ...g, fromWord: remap(g.fromWord), toWord: remap(g.toWord) }));
  return b;
}
// punch-in (atWord + holdWords): move keeps the hold count; trim recomputes it from the new end
function retimeMoment(board, id, newSMs, newEMs) {
  const b = _clone(board); const m = (b.moments || []).find((x) => x.id === id); if (!m) return board;
  const w = b.words;
  if (newSMs != null) m.atWord = Math.max(0, Math.min(w.length - 2, timeToWordIdx(b, newSMs)));
  if (newEMs != null) m.holdWords = Math.max(1, timeToWordIdx(b, newEMs) - m.atWord);
  m.holdWords = Math.max(1, Math.min(w.length - 1 - m.atWord, m.holdWords));
  b.moments.sort((x, y) => x.atWord - y.atWord);
  return b;
}
function moveMoment(board, id, newSMs) { return retimeMoment(board, id, newSMs, null); }
// graphic (fromWord..toWord)
function retimeGraphic(board, id, newSMs, newEMs) {
  const b = _clone(board); const g = (b.graphics || []).find((x) => x.id === id); if (!g) return board;
  const w = b.words;
  if (newSMs != null) g.fromWord = Math.max(0, Math.min(w.length - 1, timeToWordIdx(b, newSMs)));
  if (newEMs != null) g.toWord = Math.max(g.fromWord, Math.min(w.length - 1, timeToWordIdx(b, newEMs)));
  if (g.toWord < g.fromWord) g.toWord = g.fromWord;
  b.graphics.sort((x, y) => x.fromWord - y.fromWord);
  return b;
}
function moveGraphic(board, id, newSMs) {
  const b = _clone(board); const g = (b.graphics || []).find((x) => x.id === id); if (!g) return board;
  const w = b.words, span = g.toWord - g.fromWord;
  g.fromWord = Math.max(0, Math.min(w.length - 1 - span, timeToWordIdx(b, newSMs)));
  g.toWord = g.fromWord + span;
  b.graphics.sort((x, y) => x.fromWord - y.fromWord);
  return b;
}
// add at playhead
function addMomentAt(board, ms) {
  const b = _clone(board); const a = timeToWordIdx(b, ms);
  const maxId = (b.moments || []).reduce((mx, m) => { const n = /^m(\d+)$/.exec(m.id || ''); return n ? Math.max(mx, +n[1]) : mx; }, 0);
  b.moments = [...(b.moments || []), { id: 'm' + (maxId + 1), type: 'punchIn', atWord: a, holdWords: Math.max(1, Math.min(3, b.words.length - 1 - a)) }].sort((x, y) => x.atWord - y.atWord);
  return b;
}
function addGraphicAt(board, ms) {
  const b = _clone(board); const a = timeToWordIdx(b, ms), to = Math.min(b.words.length - 1, a + 3);
  const maxId = (b.graphics || []).reduce((mx, g) => { const n = /^g(\d+)$/.exec(g.id || ''); return n ? Math.max(mx, +n[1]) : mx; }, 0);
  b.graphics = [...(b.graphics || []), { id: 'g' + (maxId + 1), type: 'bignum', fromWord: a, toWord: to, title: 'Label', data: { value: 100, suffix: '%', title: 'Label' } }].sort((x, y) => x.fromWord - y.fromWord);
  return b;
}

window._editBoardUtils = { editPageText, addCaptionAfter, moveCaption, trimCaption, removeCaption, retimeMoment, retimeGraphic };

const gfxSummary = (g) => {
  const d = g.data || {};
  if (g.type === 'bignum') return `${d.prefix || ''}${d.value ?? ''}${d.suffix || ''} ${d.label || ''}`.trim();
  if (g.type === 'compare') return (d.bars || []).map(b => b.display || b.value).join(' vs ');
  if (g.type === 'checklist') return `${(d.items || []).length} items: ${(d.items || []).slice(0, 2).join(', ')}…`;
  if (g.type === 'quote') return `“${(d.text || '').slice(0, 48)}”`;
  if (g.type === 'progress') return `${d.percent}% ring`;
  return g.type;
};
const GFX_ICON = { bignum: '🔢', compare: '📊', checklist: '✅', quote: '❝', progress: '⭕', timeline: '📅', iconrow: '✨' };

/* ── live in-browser preview: the raw clip + DOM caption overlay synced to
   playback. An approximation of the render (label says so) — but it shows
   pages, karaoke highlight, position, punch-ins and graphic windows live. */
function LivePreview({ clipUrl, board, preview, capPos, onTime, videoRef: extRef }) {
  const _localVid = useRf(null);
  const videoRef = extRef || _localVid;
  const [cur, setCur] = useS({ page: -1, word: -1, zoom: false, gfx: null });

  useE(() => {
    let raf;
    const tick = () => {
      const v = videoRef.current;
      if (v && board) {
        const t = v.currentTime * 1000;
        if (onTime) onTime(v.currentTime);
        const w = board.words;
        const pi = board.captions.findIndex(c => w[c.from].s <= t && t <= w[c.to].e);
        let wi = -1;
        if (pi >= 0) { const c = board.captions[pi]; for (let i = c.from; i <= c.to; i++) if (w[i].s <= t && t <= w[i].e) { wi = i; break; } }
        const zoom = (board.moments || []).some(m => t >= w[m.atWord].s && t <= w[Math.min(w.length - 1, m.atWord + m.holdWords)].e);
        const g = (board.graphics || []).find(g => w[g.fromWord].s <= t && t <= w[g.toWord].e) || null;
        setCur(p => (p.page === pi && p.word === wi && p.zoom === zoom && p.gfx === (g?.id ?? null)) ? p : { page: pi, word: wi, zoom, gfx: g?.id ?? null });
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [board]);

  const W = 270, k = W / 1080; // preview-px per render-px
  const p = preview || {};
  const page = cur.page >= 0 ? board.captions[cur.page] : null;
  const gfx = cur.gfx ? (board.graphics || []).find(g => g.id === cur.gfx) : null;
  const posStyle = capPos === 'top' ? { top: '20%' } : capPos === 'lower' ? { bottom: '12%' } : { top: '68%', transform: 'translateY(-50%)' };
  const baseColor = p.box ? '#1c1c1e' : '#fff';

  return (
    <div className="relative rounded-3xl overflow-hidden ring-1 ring-white/10 bg-black" style={{ width: W, aspectRatio: '9/16' }}>
      <div className="absolute inset-0" style={{ transform: cur.zoom ? `scale(${p.zoomScale || 1.15})` : 'scale(1)', transition: 'transform .25s ease-out', transformOrigin: '50% 38%' }}>
        <video ref={videoRef} src={clipUrl} controls playsInline className="w-full h-full object-cover" />
      </div>
      {gfx && (
        <div className="absolute top-2 left-2 right-2 rounded-xl px-2.5 py-2 text-[10px] font-semibold flex items-center gap-1.5"
          style={{ background: (p.box ? '#ffffffee' : '#0a0a0cee'), color: p.box ? '#1c1c1e' : '#fff' }}>
          <span>{GFX_ICON[gfx.type] || '✨'}</span><span className="truncate">{gfx.type} · {gfxSummary(gfx)}</span>
        </div>
      )}
      {page && (
        <div className="absolute left-2 right-2 text-center pointer-events-none" style={posStyle}>
          <div style={{
            display: 'inline-block', fontFamily: p.fontFamily, fontWeight: p.weight,
            fontSize: Math.max(9, Math.round((p.sizePx || 80) * k)) + 'px', lineHeight: 1.15, letterSpacing: '-0.3px',
            ...(p.box ? { background: 'rgba(255,255,255,0.93)', borderRadius: 8, padding: '4px 8px' } : {}),
            ...(p.stroke ? { WebkitTextStroke: '0.8px #000', textShadow: '0 2px 6px rgba(0,0,0,0.6)' } : { textShadow: '0 1px 4px rgba(0,0,0,0.6)' }),
          }}>
            {board.words.slice(page.from, page.to + 1).map((w, i) => {
              const idx = page.from + i;
              const kwCat = page.kw?.[idx];
              const color = idx === cur.word ? (p.activeWord?.color || '#FFE600')
                : kwCat && p.keywordColors?.[kwCat] ? p.keywordColors[kwCat] : baseColor;
              return <span key={idx} style={{ color, display: 'inline-block', margin: '0 2px', transform: idx === cur.word ? `scale(${p.activeWord?.scale || 1.1})` : 'none' }}>
                {p.case === 'upper' ? w.w.toUpperCase() : w.w}
              </span>;
            })}
            {page.emoji && <span style={{ marginLeft: 3 }}>{page.emoji}</span>}
          </div>
        </div>
      )}
      <div className="absolute bottom-1.5 left-0 right-0 text-center text-[8.5px] text-white/40 pointer-events-none">preview — final render is sharper</div>
    </div>
  );
}

function EditMode({ onOpenPro }) {
  const I = window.Icon;
  const [view, setView] = useS('compose');     // compose | planning | review | rendering | result | error
  const [formats, setFormats] = useS([]);
  const [formatId, setFormatId] = useS('punch');
  const [clip, setClip] = useS(null);          // { clipId, name, sizeMB, durSec, url }
  const [uploading, setUploading] = useS(false);
  const [uploadPct, setUploadPct] = useS(0);
  const [uploadInfo, setUploadInfo] = useS(null); // { name, size } of the clip being uploaded
  const [gen, setGen] = useS({});              // stageId -> 'active' | 'done'
  const [board, setBoard] = useS(null);        // the editable edit plan
  const [capPos, setCapPos] = useS('middle');
  const [pct, setPct] = useS(0);
  const [jobId, setJobId] = useS(null);
  const [resultFormat, setResultFormat] = useS('punch');
  const [swapId, setSwapId] = useS(null);
  const [swapping, setSwapping] = useS(false);
  const [editingPage, setEditingPage] = useS(null); // pageIdx being edited
  const [draft, setDraft] = useS('');
  const [addAfter, setAddAfter] = useS(null);       // pageIdx to insert a NEW caption after (-1 = before first)
  const [addDraft, setAddDraft] = useS('');
  const [addTime, setAddTime] = useS(null);         // chosen start (ms) for the new caption
  const [errMsg, setErrMsg] = useS('');
  const [formatsErr, setFormatsErr] = useS(false);
  const [notice, setNotice] = useS('');
  const fileRef = useRf(null);
  const playheadRef = useRf(null); // timeline playhead — moved directly (no per-frame re-render)
  const playMsRef = useRf(0);      // current preview time (ms) — for snapping to the playhead
  const trackRef = useRf(null);    // the tracks content (px-wide; for drag px→time math)
  const scrollRef = useRf(null);   // horizontal scroll viewport around the tracks
  const previewVidRef = useRf(null); // the preview <video> — so the timeline can scrub it
  const scrubbingRef = useRf(false); // true while dragging the timeline (pauses playhead auto-follow)
  const [pps, setPps] = useS(null); // px/sec zoom; null = "fit the whole clip to the viewport"
  const [viewW, setViewW] = useS(0); // measured width of the scroll viewport (px)
  const [sel, setSel] = useS(null);      // { lane:'captions'|'zoom'|'graphics', id }
  const [drag, setDrag] = useS(null);    // live drag preview { lane, id, kind, s, e } (ms)
  const [guide, setGuide] = useS(null);  // snap guide time (ms) or null
  const [addPick, setAddPick] = useS(false); // add-at-playhead type picker open

  const loadFormats = async () => {
    setFormatsErr(false);
    try {
      const r = await fetchRetry('/api/formats');
      const j = await r.json();
      setFormats(j.formats || []);
    } catch { setFormatsErr(true); }
  };
  useE(() => { loadFormats(); }, []);

  // ── restore a saved session on mount (once) ────────────────────────────────
  useE(() => {
    const s = loadEditSession();
    if (!s) return;
    const restoreEdits = (v, msg) => {
      setFormatId(s.formatId || 'punch'); setCapPos(s.capPos || 'middle');
      if (s.board) setBoard(s.board);
      // blob URL can't survive a refresh/tab-switch — re-point the preview at the server copy
      if (s.clip) setClip({ ...s.clip, url: s.clip.clipId ? ('/api/clip?id=' + encodeURIComponent(s.clip.clipId)) : null });
      setView(v); if (msg) setNotice(msg);
    };
    (async () => {
      if (s.view === 'result' && s.jobId) {
        const ok = await fetch('/api/video/' + s.jobId, { method: 'HEAD' }).then(r => r.ok).catch(() => false);
        if (ok) { setResultFormat(s.resultFormat || s.formatId || 'punch'); restoreEdits('result'); setJobId(s.jobId); return; }
        // the render is gone (backend redeployed without a volume) — keep the edits
        if (s.board) restoreEdits('review', 'Your last reel expired, but your edits are saved — render again when ready.');
        else saveEditSession(null);
        return;
      }
      if (s.view === 'review' && s.board) restoreEdits('review', s.clip ? 'Picked up where you left off. If the preview is blank, re-upload the clip.' : '');
    })();
  }, []);

  // ── save the session whenever meaningful state changes ─────────────────────
  const firstSave = useRf(true);
  useE(() => {
    if (firstSave.current) { firstSave.current = false; return; } // don't clobber a saved session before restore reads it
    if ((view === 'result' && jobId) || (view === 'review' && board)) {
      saveEditSession({ view, formatId, resultFormat, capPos, jobId, board,
        clip: clip ? { clipId: clip.clipId, name: clip.name, sizeMB: clip.sizeMB, durSec: clip.durSec } : null });
    } else if (view === 'compose') {
      saveEditSession(null);
    }
  }, [view, board, jobId, formatId, resultFormat, capPos, clip]);

  // ── track the timeline viewport width so "fit" always matches the real width ──
  useE(() => {
    const el = scrollRef.current; if (!el || typeof ResizeObserver === 'undefined') return;
    const ro = new ResizeObserver(() => setViewW(el.clientWidth));
    ro.observe(el); setViewW(el.clientWidth);
    return () => ro.disconnect();
  }, [view]);

  const fmt = (id) => formats.find(f => f.id === id) || {};

  async function onFile(file) {
    if (!file) return;
    if (!/^video\//.test(file.type) && !/\.(mp4|mov|m4v|webm)$/i.test(file.name)) return alert('Please pick a video file.');
    if (file.size > 300 * 1024 * 1024) return alert('Clip too large (max 300MB). Trim it and try again.');
    const url = URL.createObjectURL(file);
    const durSec = await new Promise((res) => {
      const v = document.createElement('video');
      v.preload = 'metadata';
      v.onloadedmetadata = () => res(v.duration || 0);
      v.onerror = () => res(0);
      v.src = url;
    });
    if (durSec > 91) { URL.revokeObjectURL(url); return alert(`That clip is ${Math.round(durSec)}s — Edit handles up to 90s for now. Trim it and re-upload.`); }
    setUploading(true); setUploadPct(0); setUploadInfo({ name: file.name, size: file.size });
    try {
      const j = await xhrUpload('/api/upload-clip', file, { onProgress: (f) => setUploadPct(Math.round(f * 100)) });
      if (j.error) throw new Error(j.error);
      setClip({ clipId: j.clipId, name: file.name, sizeMB: (file.size / 1048576).toFixed(1), durSec, url });
    } catch (e) {
      URL.revokeObjectURL(url);
      const net = /network|timeout|load failed|failed to fetch|networkerror|server 5/i.test(e.message || '');
      alert(net ? "Couldn't reach the server — it may be waking up. Give it a few seconds and try again." : 'Upload failed: ' + e.message);
    }
    finally { setUploading(false); setUploadInfo(null); }
  }

  const clearClip = () => { if (clip?.url) URL.revokeObjectURL(clip.url); setClip(null); if (fileRef.current) fileRef.current.value = ''; };

  async function planIt() {
    if (!clip) return;
    setErrMsg(''); setGen({}); setView('planning');
    try {
      const r = await fetch('/api/edit-plan', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ clipId: clip.clipId, format: formatId }) });
      const j = await r.json(); if (j.error) throw new Error(j.error);
      const res = await sseFollow(j.id, (stage, payload) => {
        if (payload.status === 'start') setGen(g => ({ ...g, [stage]: 'active' }));
        if (payload.status === 'done') setGen(g => ({ ...g, [stage]: 'done' }));
      });
      setBoard(res.board);
      setCapPos(fmt(formatId).preview?.position || 'middle');
      setView('review');
    } catch (e) { setErrMsg(e.message || 'planning failed'); setView('error'); }
  }

  async function renderIt() {
    if (!board) return;
    if (!clip?.clipId) { setNotice('Your clip is no longer on the server — please re-upload it to render.'); setView('compose'); return; }
    setErrMsg(''); setPct(0); setView('rendering');
    try {
      const r = await fetch('/api/edit-render', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ clipId: clip.clipId, board, format: formatId, captionPos: capPos }) });
      const j = await r.json(); if (j.error) throw new Error(j.error);
      await sseFollow(j.id, (stage, payload) => { if (stage === 'render' && payload.status === 'progress') setPct(payload.pct); });
      setJobId(j.id); setResultFormat(formatId); setSwapId(null); setView('result');
    } catch (e) { setErrMsg(e.message || 'render failed'); setView('error'); }
  }

  async function swapFormat() {
    if (!swapId || swapId === resultFormat || swapping) return;
    setSwapping(true); setPct(0);
    try {
      const r = await fetch('/api/edit-rerender', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jobId, format: swapId }) });
      const j = await r.json(); if (j.error) throw new Error(j.error);
      await sseFollow(j.id, (stage, payload) => { if (stage === 'render' && payload.status === 'progress') setPct(payload.pct); });
      setJobId(j.id); setResultFormat(swapId); setSwapId(null);
    } catch (e) { alert(e.message); }
    finally { setSwapping(false); }
  }

  const commitPageEdit = () => {
    if (editingPage == null) return;
    setBoard(b => editPageText(b, editingPage, draft));
    setEditingPage(null);
  };
  // time window of the add-slot after page idx (-1 = before the first page)
  const slotBounds = (idx) => {
    const clipMs = Math.round((board?.source?.durationSec || 0) * 1000) || (board?.words?.length ? board.words[board.words.length - 1].e : 0);
    const prevEnd = idx < 0 ? 0 : board.words[board.captions[idx].to].e;
    const next = board.captions[idx + 1];
    const nextStart = next ? board.words[next.from].s : clipMs;
    return { prevEnd, nextStart, gap: Math.max(0, nextStart - prevEnd) };
  };
  const beginAdd = (idx, atMs) => {
    setSel(null); setEditingPage(null); setAddAfter(idx); setAddDraft('');
    setAddTime(Number.isFinite(atMs) ? Math.round(atMs) : slotBounds(idx).prevEnd);
  };
  const cancelAdd = () => { setAddAfter(null); setAddDraft(''); setAddTime(null); };
  const commitAdd = () => {
    if (addAfter == null) return;
    const text = addDraft.trim();
    if (text) setBoard(b => addCaptionAfter(b, addAfter, text, addTime));
    cancelAdd();
  };
  const removeMoment = (id) => setBoard(b => ({ ...b, moments: b.moments.filter(m => m.id !== id) }));
  const removeGraphic = (id) => setBoard(b => ({ ...b, graphics: b.graphics.filter(g => g.id !== id) }));

  // ── timeline: view-model, snapping, drag (move/trim), add, delete ──
  const GRID = 250, SNAP_PX = 8; // ms grid + px snap threshold
  const clipMs = () => Math.round((board?.source?.durationSec || 0) * 1000) || (board?.words?.length ? board.words[board.words.length - 1].e : 1);
  const fitPps = () => Math.max(8, (viewW || 320) / Math.max(1, clipMs() / 1000)); // pps that fits the clip
  const PPS = () => pps ?? fitPps();                   // px/sec (null pps → fit)
  const zoomBy = (f) => setPps(p => { const cur = p ?? fitPps(); return Math.max(fitPps(), Math.min(500, cur * f)); });
  const laneChips = (lane) => {
    const w = board.words;
    if (lane === 'captions') return board.captions.map((c, i) => ({ id: c.id, idx: i, s: w[c.from].s, e: w[c.to].e, label: pageText(board, c) }));
    if (lane === 'zoom') return (board.moments || []).map(m => ({ id: m.id, s: w[m.atWord].s, e: w[Math.min(w.length - 1, m.atWord + m.holdWords)].e, label: '🔍 zoom' }));
    if (lane === 'graphics') return (board.graphics || []).map(g => ({ id: g.id, s: w[g.fromWord].s, e: w[g.toWord].e, label: (GFX_ICON[g.type] || '✨') + ' ' + g.type }));
    return [];
  };
  const snapMs = (ms, exceptId) => {
    const p = PPS();
    let best = Math.round(ms / GRID) * GRID, bestD = Math.abs(best - ms) / 1000 * p, gd = null;
    const targets = [playMsRef.current];
    for (const L of ['captions', 'zoom', 'graphics']) for (const ch of laneChips(L)) { if (ch.id === exceptId) continue; targets.push(ch.s, ch.e); }
    for (const t of targets) { const d = Math.abs(t - ms) / 1000 * p; if (d < SNAP_PX && d < bestD) { best = t; bestD = d; gd = t; } }
    setGuide(gd);
    return Math.round(best);
  };
  const startDrag = (e, lane, ch, kind) => {
    e.preventDefault(); e.stopPropagation();
    setSel({ lane, id: ch.id }); setAddAfter(null); // one editor at a time

    const p = PPS(), cm = clipMs(), x0 = e.clientX, s0 = ch.s, e0 = ch.e, dur0 = e0 - s0;
    const move = (ev) => {
      const dms = ((ev.clientX - x0) / p) * 1000;
      let ns = s0, ne = e0;
      if (kind === 'move') { ns = snapMs(Math.max(0, Math.min(cm - dur0, s0 + dms)), ch.id); ne = ns + dur0; }
      else if (kind === 'l') { ns = snapMs(Math.max(0, Math.min(e0 - 200, s0 + dms)), ch.id); ne = e0; }
      else { ne = snapMs(Math.max(s0 + 200, Math.min(cm, e0 + dms)), ch.id); ns = s0; }
      setDrag({ lane, id: ch.id, kind, s: ns, e: ne });
    };
    const up = () => {
      window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
      setGuide(null);
      setDrag(d => { if (d) commitDrag(lane, ch, d); return null; });
    };
    window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
  };
  const commitDrag = (lane, ch, d) => {
    if (Math.abs(d.s - ch.s) < 1 && Math.abs(d.e - ch.e) < 1) return;
    if (lane === 'captions') setBoard(b => d.kind === 'move' ? moveCaption(b, ch.idx, d.s) : trimCaption(b, ch.idx, d.s, d.e));
    else if (lane === 'zoom') setBoard(b => d.kind === 'move' ? moveMoment(b, ch.id, d.s) : retimeMoment(b, ch.id, d.kind === 'l' ? d.s : null, d.kind === 'r' ? d.e : null));
    else setBoard(b => d.kind === 'move' ? moveGraphic(b, ch.id, d.s) : retimeGraphic(b, ch.id, d.kind === 'l' ? d.s : null, d.kind === 'r' ? d.e : null));
  };
  // ── scrub: drag the timeline → seek the preview video (so you SEE the frame) ──
  const seekVideo = (ms) => {
    const t = Math.max(0, Math.min(clipMs(), ms));
    const v = previewVidRef.current;
    if (v) { try { v.pause(); if (isFinite(t)) v.currentTime = t / 1000; } catch {} }
    playMsRef.current = t;
    const el = playheadRef.current; if (el) el.style.left = (t / 1000 * PPS()) + 'px';
  };
  const startScrub = (e) => {
    if (e.target.closest('[data-chip]')) return; // chips handle their own drag
    const sc = scrollRef.current; if (!sc) return;
    e.preventDefault();
    scrubbingRef.current = true;
    const at = (clientX) => { const vr = sc.getBoundingClientRect(); seekVideo(((sc.scrollLeft + (clientX - vr.left)) / PPS()) * 1000); };
    at(e.clientX);
    const move = (ev) => {
      ev.preventDefault();
      const vr = sc.getBoundingClientRect(), edge = ev.clientX - vr.left;
      if (edge > vr.width - 28) sc.scrollLeft = Math.min(sc.scrollWidth, sc.scrollLeft + 12);      // edge-pan when zoomed in
      else if (edge < 28) sc.scrollLeft = Math.max(0, sc.scrollLeft - 12);
      at(ev.clientX);
    };
    const up = () => { scrubbingRef.current = false; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
    window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
  };
  const addAtPlayhead = (kind) => {
    setAddPick(false);
    const ms = playMsRef.current || 0;
    if (kind === 'caption') { let slot = -1; for (let i = 0; i < board.captions.length; i++) if (board.words[board.captions[i].to].e <= ms) slot = i; beginAdd(slot, ms); }
    if (kind === 'punch') setBoard(b => addMomentAt(b, ms));
    if (kind === 'graphic') setBoard(b => addGraphicAt(b, ms));
  };
  const deleteSel = () => {
    if (!sel) return;
    if (sel.lane === 'captions') { const i = board.captions.findIndex(c => c.id === sel.id); if (i >= 0) setBoard(b => removeCaption(b, i)); }
    else if (sel.lane === 'zoom') removeMoment(sel.id);
    else removeGraphic(sel.id);
    setSel(null);
  };

  const download = () => { if (!jobId) return; const a = document.createElement('a'); a.href = '/api/video/' + jobId; a.download = 'kinetic-edit.mp4'; document.body.appendChild(a); a.click(); a.remove(); };
  const reset = () => { clearClip(); setJobId(null); setBoard(null); setView('compose'); };

  /* ---------- compose ---------- */
  if (view === 'compose') return (
    <div className="anim-floatup">
      <div className="mb-6">
        <div className="text-[11px] font-semibold tracking-[0.14em] text-accent uppercase mb-2">Edit mode</div>
        <h1 className="text-[26px] font-bold tracking-tight leading-tight">Your clip, edited like the greats</h1>
        <p className="text-[13.5px] text-muted mt-1.5">Drop one talking-head clip. Kinetic captions it, punches in on the big moments, adds animated graphics — and you review everything before it renders.</p>
      </div>

      <div className="mb-2 text-[13px] font-semibold">1 · Pick a format</div>
      <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
        {formats.map(f => (
          <button key={f.id} onClick={() => setFormatId(f.id)}
            className={`text-left rounded-2xl p-3 border transition-all ${formatId === f.id ? 'border-accent bg-accent/5 ring-1 ring-accent/40' : 'border-border bg-surface hover:border-borders'}`}>
            <div className="h-[72px] rounded-xl overflow-hidden relative ring-1 ring-white/10" style={{ background: f.palette?.bg || '#0a0a0c' }}>
              <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5">
                <div className="flex gap-1">
                  <span className="px-1.5 py-0.5 rounded text-[10px] font-black" style={{ color: f.palette?.text || '#fff' }}>WORDS</span>
                  <span className="px-1.5 py-0.5 rounded text-[10px] font-black" style={{ color: f.palette?.accent || '#FFE600' }}>POP</span>
                </div>
                <div className="flex gap-1 items-center">
                  <span className="w-7 h-1.5 rounded-full" style={{ background: f.palette?.accent, opacity: .9 }} />
                  <span className="w-4 h-1.5 rounded-full" style={{ background: f.palette?.text, opacity: .35 }} />
                  <span className="w-2.5 h-1.5 rounded-full" style={{ background: f.palette?.good || f.palette?.accent, opacity: .7 }} />
                </div>
              </div>
            </div>
            <div className="mt-2.5 text-[13.5px] font-semibold leading-tight">{f.displayName}</div>
            <div className="text-[10px] text-faint mt-0.5">{f.legal}</div>
            <div className="text-[11px] text-muted mt-1 leading-snug">{f.tagline}</div>
          </button>
        ))}
        {!formats.length && !formatsErr && <div className="col-span-2 sm:col-span-3 text-[12px] text-faint p-4">Loading formats…</div>}
        {!formats.length && formatsErr && (
          <div className="col-span-2 sm:col-span-3 text-[12px] text-faint p-4 flex items-center gap-3">
            <span>Couldn't load formats — the server may be waking up.</span>
            <button onClick={loadFormats} className="px-2.5 py-1 rounded-md bg-surface border border-borders text-text hover:bg-surface/70">Retry</button>
          </div>
        )}
      </div>

      <div className="mb-2 text-[13px] font-semibold">2 · Drop your clip</div>
      {uploading ? (
        <div className="w-full rounded-2xl border border-dashed border-accent/40 bg-surface/60 px-5 py-8 flex flex-col items-center gap-3">
          <div className="w-full max-w-[280px] flex items-center justify-between text-[12.5px]">
            <span className="font-semibold text-text truncate pr-2">{uploadPct < 100 ? 'Uploading your clip' : 'Almost there…'}</span>
            <span className="font-mono tnum text-accent shrink-0">{uploadPct}%</span>
          </div>
          <div className="w-full max-w-[280px] h-2 rounded-full bg-hi overflow-hidden">
            <div className="h-full rounded-full bg-accent transition-[width] duration-200 ease-out" style={{ width: Math.max(4, uploadPct) + '%' }} />
          </div>
          <span className="text-[11px] text-faint text-center">
            {uploadInfo ? `${((uploadInfo.size / 1048576) * uploadPct / 100).toFixed(1)} of ${(uploadInfo.size / 1048576).toFixed(1)} MB · ` : ''}keep this tab open — big clips take a bit
          </span>
        </div>
      ) : !clip ? (
        <button onClick={() => fileRef.current?.click()}
          className="w-full rounded-2xl border border-dashed border-borders bg-surface/60 hover:bg-surface transition-colors px-5 py-9 flex flex-col items-center gap-2.5">
          <span className="w-11 h-11 rounded-2xl grid place-items-center bg-hi"><I.Upload size={20} className="text-accent" /></span>
          <span className="text-[14px] font-semibold">Tap to choose a clip</span>
          <span className="text-[11.5px] text-faint">One talking-head video · up to 90s · 9:16 works best</span>
        </button>
      ) : (
        <div className="rounded-2xl border border-border bg-surface p-3.5 flex items-center gap-3.5">
          <video src={clip.url} muted playsInline className="w-[58px] h-[58px] object-cover rounded-xl ring-1 ring-white/10" />
          <div className="flex-1 min-w-0">
            <div className="text-[13.5px] font-semibold truncate">{clip.name}</div>
            <div className="text-[11.5px] text-muted font-mono tnum">{Math.round(clip.durSec)}s · {clip.sizeMB}MB</div>
          </div>
          <button onClick={clearClip} className="w-9 h-9 rounded-full grid place-items-center bg-hi text-muted hover:text-text transition-colors"><I.X size={16} /></button>
        </div>
      )}
      <input ref={fileRef} type="file" accept="video/*" className="hidden" onChange={e => onFile(e.target.files?.[0])} />

      <button onClick={planIt} disabled={!clip || uploading}
        className={`mt-6 w-full rounded-2xl py-3.5 px-5 flex items-center justify-center gap-2.5 text-[15.5px] font-semibold transition-all ${(!clip || uploading) ? 'bg-hi text-faint cursor-not-allowed' : 'bg-accent text-black hover:brightness-105 active:scale-[.99] shadow-[0_8px_30px_-8px_rgba(255,230,0,.4)]'}`}>
        <I.Wand size={17} /> Plan my edit
        <span className="opacity-60 font-normal text-[12px]">· you'll review it first</span>
      </button>
    </div>
  );

  /* ---------- planning ---------- */
  if (view === 'planning') return (
    <div className="anim-floatup">
      <div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.14em] text-accent uppercase mb-1.5">
        <span className="w-1.5 h-1.5 rounded-full bg-accent" style={{ animation: 'kpulse 1.2s infinite' }} /> Planning
      </div>
      <h1 className="text-[22px] font-bold tracking-tight mb-6">Planning your edit in {fmt(formatId).displayName}</h1>
      <div className="space-y-2.5">
        {PLAN_STAGES.filter(s => !s.conditional || gen[s.id]).map(s => {
          const st = gen[s.id] || 'pending';
          return (
            <div key={s.id} className={`flex items-center gap-3 rounded-2xl border px-4 py-3.5 ${st === 'active' ? 'border-accent/40 bg-accent/5' : 'border-border bg-surface'} ${st === 'pending' ? 'opacity-45' : ''}`}>
              <span className={`w-7 h-7 rounded-full grid place-items-center shrink-0 ${st === 'done' ? 'bg-success/15 text-success' : st === 'active' ? 'bg-accent/15 text-accent' : 'bg-hi text-faint'}`}>
                {st === 'done' ? <I.Check size={14} /> : st === 'active'
                  ? <span className="w-3.5 h-3.5 rounded-full border-2 border-accent border-t-transparent" style={{ animation: 'spin 0.8s linear infinite' }} />
                  : <span className="w-1.5 h-1.5 rounded-full bg-faint" />}
              </span>
              <span className="text-[14px] font-medium flex-1">{s.label}</span>
            </div>
          );
        })}
      </div>
      <p className="text-[12px] text-faint mt-5 text-center">No rendering yet — you'll see the plan first.</p>
    </div>
  );

  /* ---------- review ---------- */
  if (view === 'review' && board) {
    const preview = fmt(formatId).preview || {};
    return (
      <div className="anim-floatup" style={{ touchAction: 'manipulation' }}>
        <div className="flex items-start justify-between gap-3 mb-1.5">
          <div className="text-[11px] font-semibold tracking-[0.14em] text-accent uppercase pt-0.5">Review the edit</div>
          <button onClick={() => { if (window.confirm('Start a new reel? Your current edits will be cleared.')) reset(); }}
            className="shrink-0 rounded-lg px-2.5 py-1 text-[11.5px] font-semibold bg-surface border border-border text-muted hover:text-text transition-colors flex items-center gap-1">
            <I.Plus size={12} /> New reel
          </button>
        </div>
        <h1 className="text-[22px] font-bold tracking-tight mb-1.5">Here's the plan — make it yours</h1>
        <p className="text-[12.5px] text-muted mb-5">Play the preview, fix any misheard words, drop what you don't want. Then render.</p>

        {notice && (
          <div className="mb-4 flex items-start gap-2.5 rounded-xl border border-accent/40 bg-accent/5 px-3.5 py-2.5 text-[12.5px]">
            <span className="text-accent shrink-0">✓ saved</span><span className="flex-1 text-muted">{notice}</span>
            <button onClick={() => setNotice('')} className="text-faint hover:text-text shrink-0"><I.X size={13} /></button>
          </div>
        )}

        <div className="flex justify-center mb-5">
          <LivePreview clipUrl={clip?.url} board={board} preview={preview} capPos={capPos} videoRef={previewVidRef}
            onTime={(sec) => {
              playMsRef.current = sec * 1000;
              const el = playheadRef.current; if (!el) return;
              const x = sec * PPS(); el.style.left = x + 'px';
              if (scrubbingRef.current) return; // don't fight the finger while scrubbing
              const sc = scrollRef.current; if (sc) { const vw = sc.clientWidth; if (x < sc.scrollLeft + 24 || x > sc.scrollLeft + vw - 24) sc.scrollLeft = Math.max(0, x - vw / 2); }
            }} />
        </div>

        {/* caption position */}
        <div className="mb-2 text-[13px] font-semibold">Caption position</div>
        <div className="flex p-0.5 rounded-xl bg-surface border border-border mb-5 w-fit">
          {[['top', 'Top'], ['middle', 'Middle'], ['lower', 'Lower third']].map(([id, label]) => (
            <button key={id} onClick={() => setCapPos(id)}
              className={`px-3.5 py-1.5 rounded-[10px] text-[12.5px] font-semibold transition-colors ${capPos === id ? 'bg-accent text-black' : 'text-muted hover:text-text'}`}>{label}</button>
          ))}
        </div>

        {/* ── TIMELINE: lanes · select→inspector · drag-move · trim · delete · add ── */}
        <div className="mb-2 text-[13px] font-semibold">Timeline <span className="text-faint font-normal">— drag the strip to scrub · tap a chip to select · drag edges to trim</span></div>
        {(() => {
          const w = board.words;
          const cm = clipMs();
          const p = PPS();
          const contentW = Math.max(1, (cm / 1000) * p);
          const pxOf = (ms) => (ms / 1000) * p;
          const posOf = (ch) => { const d = drag && drag.id === ch.id ? drag : ch; return { left: pxOf(d.s), width: Math.max(8, pxOf(d.e) - pxOf(d.s)) }; };
          const lanes = [{ key: 'captions', label: 'Captions', color: '#FFE600' }, { key: 'zoom', label: 'Zoom', color: '#6EC7FF' }, { key: 'graphics', label: 'Graphics', color: '#6BE39B' }];
          return (
            <div className="mb-4 rounded-xl bg-surface border border-border p-2.5">
              <div className="flex gap-2">
                {/* fixed lane labels */}
                <div className="w-14 shrink-0 flex flex-col gap-1.5">
                  {lanes.map(L => <div key={L.key} className="h-10 flex items-center gap-1.5 text-[10px] font-semibold text-muted"><span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: L.color }} />{L.label}</div>)}
                </div>
                {/* scrollable, zoomable tracks — drag anywhere to scrub the video */}
                <div ref={scrollRef} className="flex-1 overflow-x-auto overflow-y-hidden" style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}>
                  <div ref={trackRef} onPointerDown={startScrub} className="relative cursor-ew-resize" style={{ width: contentW, touchAction: 'pan-y' }}>
                    <div className="flex flex-col gap-1.5">
                      {lanes.map(L => {
                        const chips = laneChips(L.key);
                        return (
                          <div key={L.key} className="relative h-10 rounded-lg bg-hi">
                            {chips.map(ch => {
                              const isSel = sel?.lane === L.key && sel?.id === ch.id;
                              const pos = posOf(ch);
                              return (
                                <div key={ch.id} data-chip title={ch.label} onPointerDown={e => startDrag(e, L.key, ch, 'move')}
                                  className="absolute top-1 bottom-1 rounded-[5px] flex items-center px-1.5 overflow-hidden cursor-grab select-none touch-none"
                                  style={{ left: pos.left, width: pos.width, background: isSel ? L.color : L.color + 'b3', border: isSel ? '2px solid #fff' : `1px solid ${L.color}`, zIndex: isSel ? 5 : 1 }}>
                                  <span className="truncate text-[9px] font-semibold text-black">{ch.label}</span>
                                  {isSel && <>
                                    <div onPointerDown={e => startDrag(e, L.key, ch, 'l')} className="absolute left-0 top-0 bottom-0 w-2.5 bg-white/90 cursor-ew-resize rounded-l-[5px]" />
                                    <div onPointerDown={e => startDrag(e, L.key, ch, 'r')} className="absolute right-0 top-0 bottom-0 w-2.5 bg-white/90 cursor-ew-resize rounded-r-[5px]" />
                                  </>}
                                </div>
                              );
                            })}
                            {L.key !== 'captions' && !chips.length && <span className="absolute left-2 top-1/2 -translate-y-1/2 text-[9px] text-faint">no {L.label.toLowerCase()}</span>}
                          </div>
                        );
                      })}
                    </div>
                    <div ref={playheadRef} className="absolute top-0 bottom-0 w-[2px] bg-white pointer-events-none z-10" style={{ left: 0 }}>
                      <div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full bg-white shadow" />
                    </div>
                    {guide != null && <div className="absolute top-0 bottom-0 w-px bg-[#6EC7FF] pointer-events-none z-10" style={{ left: pxOf(guide) }} />}
                    {addAfter != null && addTime != null && <div className="absolute top-0 bottom-0 w-[2px] bg-accent pointer-events-none z-10" style={{ left: pxOf(addTime) }} />}
                  </div>
                </div>
              </div>
              {/* footer: total · zoom −/Fit/＋ · add */}
              <div className="flex items-center justify-between mt-2 pl-[64px] gap-2">
                <span className="text-[9px] font-mono tnum text-faint shrink-0">{mmss(cm)}</span>
                <div className="flex items-center gap-1 shrink-0">
                  <button onClick={() => zoomBy(1 / 1.6)} title="Zoom out" className="w-6 h-6 rounded-lg bg-hi text-muted hover:text-text grid place-items-center text-[15px] font-bold leading-none">−</button>
                  <button onClick={() => setPps(null)} title="Fit clip" className={`px-2 h-6 rounded-lg text-[10px] font-semibold ${pps == null ? 'bg-accent text-black' : 'bg-hi text-muted hover:text-text'}`}>Fit</button>
                  <button onClick={() => zoomBy(1.6)} title="Zoom in" className="w-6 h-6 rounded-lg bg-hi text-muted hover:text-text grid place-items-center text-[15px] font-bold leading-none">+</button>
                </div>
                <div className="relative shrink-0">
                  <button onClick={() => setAddPick(v => !v)} className="text-[11px] font-semibold text-accent hover:brightness-110">＋ Add at {mmss(playMsRef.current || 0)}</button>
                  {addPick && (
                    <div className="absolute right-0 bottom-6 z-20 bg-raised border border-border rounded-xl p-1 w-32 shadow-xl">
                      {[['caption', 'Caption'], ['punch', 'Punch-in'], ['graphic', 'Graphic']].map(([k, l]) => (
                        <div key={k} onClick={() => addAtPlayhead(k)} className="px-3 py-2 rounded-lg text-[12px] hover:bg-hi cursor-pointer">{l}</div>
                      ))}
                    </div>
                  )}
                </div>
              </div>
            </div>
          );
        })()}

        {/* ── INSPECTOR (selected element) ── */}
        {sel && (() => {
          const label = { captions: 'Caption', zoom: 'Punch-in', graphics: 'Graphic' }[sel.lane];
          const ch = laneChips(sel.lane).find(c => c.id === sel.id);
          if (!ch) return null;
          const capIdx = sel.lane === 'captions' ? board.captions.findIndex(c => c.id === sel.id) : -1;
          const gfx = sel.lane === 'graphics' ? board.graphics.find(g => g.id === sel.id) : null;
          return (
            <div className="mb-4 rounded-xl bg-surface border border-accent/40 p-3.5">
              <div className="flex items-center justify-between mb-3">
                <div className="flex items-center gap-2">
                  <div className="text-[13px] font-semibold">{label}</div>
                  <span className="text-[10px] font-mono tnum text-faint">{mmss(ch.s)} → {mmss(ch.e)} · {((ch.e - ch.s) / 1000).toFixed(1)}s</span>
                </div>
                <button onClick={() => setSel(null)} className="shrink-0 px-3 py-1 rounded-lg bg-accent text-black text-[12px] font-bold">Done</button>
              </div>
              {capIdx >= 0 && (
                <input key={sel.id + '-' + (board.rev || 0)} autoFocus defaultValue={pageText(board, board.captions[capIdx])}
                  onBlur={e => { const v = e.target.value.replace(/\n/g, ' ').trim(); if (v && v !== pageText(board, board.captions[capIdx])) setBoard(b => editPageText(b, capIdx, v)); }}
                  onKeyDown={e => { if (e.key === 'Enter') { e.currentTarget.blur(); setSel(null); } }}
                  className="w-full bg-hi text-text rounded-lg px-2.5 py-2 text-[16px] outline-none border border-border focus:border-accent/60 mb-3" />
              )}
              {gfx && (
                <div className="flex gap-2 mb-3">
                  {['bignum', 'checklist'].map(t => (
                    <button key={t} onClick={() => setBoard(b => ({ ...b, graphics: b.graphics.map(g => g.id === sel.id ? { ...g, type: t } : g) }))}
                      className={`flex-1 py-1.5 rounded-lg text-[12px] font-semibold capitalize ${gfx.type === t ? 'bg-accent text-black' : 'bg-hi text-muted hover:text-text'}`}>{t}</button>
                  ))}
                </div>
              )}
              <button onClick={deleteSel} className="w-full py-2 rounded-lg border border-alert/50 text-alert text-[13px] font-semibold hover:bg-alert/10">Delete {label.toLowerCase()}</button>
            </div>
          );
        })()}

        {/* inline add-caption (opens when a gap is tapped) */}
        {addAfter != null && (() => {
          const { prevEnd, nextStart, gap } = slotBounds(addAfter);
          const t = addTime ?? prevEnd;
          return (
            <div data-addform className="mb-4 rounded-xl bg-hi border border-accent/50 px-3 py-2 space-y-1.5">
              <div className="flex items-center gap-2.5">
                <span className="text-[10px] font-mono tnum text-accent shrink-0 w-9">{mmss(t)}</span>
                <input autoFocus value={addDraft} onChange={e => setAddDraft(e.target.value)} placeholder="Type the missing caption…"
                  onBlur={e => { if (!(e.relatedTarget && e.relatedTarget.closest('[data-addform]'))) commitAdd(); }}
                  onKeyDown={e => { if (e.key === 'Enter') commitAdd(); if (e.key === 'Escape') cancelAdd(); }}
                  className="flex-1 bg-surface rounded-lg px-2 py-1 text-[16px] outline-none ring-1 ring-accent/50" />
                <button onClick={commitAdd} className="shrink-0 px-2.5 py-1 rounded-lg bg-accent text-black text-[12px] font-bold">Add</button>
              </div>
              {gap >= 2500 && (
                <div className="flex items-center gap-2 pl-[46px]">
                  <input type="range" min={prevEnd} max={Math.max(prevEnd, nextStart - 600)} step={100} value={t} onChange={e => setAddTime(+e.target.value)} className="flex-1 h-1" style={{ accentColor: '#FFE600' }} />
                  <span className="text-[10px] font-mono tnum text-faint shrink-0">shows at {mmss(t)}</span>
                </div>
              )}
            </div>
          );
        })()}

        <button onClick={renderIt}
          className="w-full rounded-2xl py-3.5 px-5 flex items-center justify-center gap-2.5 text-[15.5px] font-semibold bg-accent text-black hover:brightness-105 active:scale-[.99] transition-all shadow-[0_8px_30px_-8px_rgba(255,230,0,.4)]">
          <I.Zap size={17} /> Render this edit
        </button>
      </div>
    );
  }

  /* ---------- rendering ---------- */
  if (view === 'rendering') return (
    <div className="anim-floatup">
      <div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.14em] text-accent uppercase mb-1.5">
        <span className="w-1.5 h-1.5 rounded-full bg-accent" style={{ animation: 'kpulse 1.2s infinite' }} /> Rendering
      </div>
      <h1 className="text-[22px] font-bold tracking-tight mb-6">Cutting your reel in {fmt(formatId).displayName}</h1>
      <div className="flex items-center gap-3 rounded-2xl border border-accent/40 bg-accent/5 px-4 py-3.5">
        <span className="w-7 h-7 rounded-full grid place-items-center shrink-0 bg-accent/15 text-accent">
          <span className="w-3.5 h-3.5 rounded-full border-2 border-accent border-t-transparent" style={{ animation: 'spin 0.8s linear infinite' }} />
        </span>
        <span className="text-[14px] font-medium flex-1">Rendering every frame</span>
        <span className="text-[12px] font-mono tnum text-accent">{pct}%</span>
      </div>
      <p className="text-[12px] text-faint mt-5 text-center">Real footage takes a little while — hang tight.</p>
    </div>
  );

  /* ---------- result ---------- */
  if (view === 'result') return (
    <div className="anim-floatup">
      <div className="flex items-center gap-2 text-[11px] font-semibold tracking-[0.14em] text-success uppercase mb-1.5"><I.Check size={13} /> Edited & ready</div>
      <h1 className="text-[22px] font-bold tracking-tight mb-5">Your clip in {fmt(resultFormat).displayName}</h1>

      <div className="flex justify-center mb-6">
        <div className="w-[min(280px,70vw)] rounded-3xl overflow-hidden ring-1 ring-white/10 relative">
          {swapping && <div className="absolute inset-0 z-10 bg-bg/80 backdrop-blur flex flex-col items-center justify-center gap-2">
            <span className="w-6 h-6 rounded-full border-2 border-accent border-t-transparent" style={{ animation: 'spin 0.8s linear infinite' }} />
            <span className="text-[12px] font-mono tnum text-accent">re-cutting {pct}%</span>
          </div>}
          <video key={jobId} src={'/api/video/' + jobId} controls playsInline className="w-full aspect-[9/16] object-cover bg-black" />
        </div>
      </div>

      <div className="mb-2 text-[13px] font-semibold">Try another format <span className="text-faint font-normal">— free, no AI</span></div>
      <div className="flex gap-2 flex-wrap mb-3">
        {formats.map(f => {
          const isCurrent = f.id === resultFormat, isPicked = f.id === swapId;
          return (
            <button key={f.id} onClick={() => !isCurrent && setSwapId(isPicked ? null : f.id)} disabled={swapping}
              className={`px-3 py-2 rounded-xl text-[12.5px] font-semibold border transition-all ${isCurrent ? 'border-success/40 text-success bg-success/10 cursor-default' : isPicked ? 'border-accent text-accent bg-accent/10' : 'border-border bg-surface text-muted hover:text-text'}`}>
              {f.displayName}{isCurrent && ' ✓'}
            </button>
          );
        })}
      </div>
      {swapId && !swapping && (
        <button onClick={swapFormat} className="w-full mb-4 rounded-2xl py-3 px-5 flex items-center justify-center gap-2 text-[14px] font-semibold bg-accent text-black hover:brightness-105 active:scale-[.99] transition-all">
          <I.Refresh size={15} /> Re-cut in {fmt(swapId).displayName}
        </button>
      )}

      <div className="flex gap-2.5">
        <button onClick={download} className="flex-1 rounded-2xl py-3.5 flex items-center justify-center gap-2 text-[15px] font-semibold bg-text text-black hover:brightness-90 transition-all"><I.Download size={17} /> Download</button>
        <button onClick={() => setView('review')} className="rounded-2xl py-3.5 px-4 flex items-center justify-center gap-2 text-[14px] font-semibold bg-surface border border-border text-muted hover:text-text transition-colors"><I.Edit size={15} /> Review</button>
        <button onClick={reset} className="rounded-2xl py-3.5 px-4 flex items-center justify-center gap-2 text-[14px] font-semibold bg-surface border border-border text-muted hover:text-text transition-colors"><I.Plus size={15} /> New</button>
      </div>
      <p className="text-[11px] text-faint mt-4 text-center">Formats are inspired by creators' public styles — not affiliated with or endorsed by them.</p>
    </div>
  );

  /* ---------- error ---------- */
  return (
    <div className="anim-floatup text-center py-10">
      <div className="w-12 h-12 mx-auto rounded-2xl grid place-items-center bg-alert/10 text-alert mb-4"><I.X size={22} /></div>
      <h2 className="text-[18px] font-bold mb-1.5">That hit a snag</h2>
      <p className="text-[13px] text-muted mb-6 max-w-[380px] mx-auto">{errMsg}</p>
      <div className="flex gap-2.5 justify-center">
        {board
          ? <button onClick={() => setView('review')} className="rounded-2xl py-3 px-6 text-[14px] font-semibold bg-accent text-black">Back to review</button>
          : <button onClick={planIt} className="rounded-2xl py-3 px-6 text-[14px] font-semibold bg-accent text-black">Try again</button>}
        <button onClick={() => setView('compose')} className="rounded-2xl py-3 px-6 text-[14px] font-semibold bg-surface border border-border text-muted">Start over</button>
      </div>
    </div>
  );
}

window.EditMode = EditMode;
})();
