// Lyric Editor — full-screen dark pro tool for editing lyric_timeline JSON
// Wrapped in IIFE to avoid scope conflicts with the main app.

(function () {
  const { useState, useEffect, useMemo, useRef } = React;

  // ─── Utilities ────────────────────────────────────────────────

  function leFmtTime(ms) {
    if (ms == null || isNaN(ms)) return '--:--.---';
    const neg = ms < 0;
    ms = Math.abs(Math.round(ms));
    const mm = Math.floor(ms / 60000);
    const ss = Math.floor((ms % 60000) / 1000);
    const mmm = ms % 1000;
    return (neg ? '-' : '') +
      String(mm).padStart(2, '0') + ':' +
      String(ss).padStart(2, '0') + '.' +
      String(mmm).padStart(3, '0');
  }

  function leFmtTimeShort(ms) {
    if (ms == null || isNaN(ms)) return '--:--';
    ms = Math.max(0, Math.round(ms));
    const mm = Math.floor(ms / 60000);
    const ss = Math.floor((ms % 60000) / 1000);
    return String(mm).padStart(2, '0') + ':' + String(ss).padStart(2, '0');
  }

  function leParseTime(str) {
    if (typeof str === 'number') return str;
    if (!str) return 0;
    str = String(str).trim();
    if (/^\d+$/.test(str)) return parseInt(str, 10);
    const m = str.match(/^(?:(\d+):)?(\d+)(?:\.(\d+))?$/);
    if (!m) return null;
    const mm = parseInt(m[1] || '0', 10);
    const ss = parseInt(m[2] || '0', 10);
    let ms = m[3] || '0';
    ms = (ms + '000').slice(0, 3);
    return mm * 60000 + ss * 1000 + parseInt(ms, 10);
  }

  function leClone(o) { return JSON.parse(JSON.stringify(o)); }

  function leWalk(node, cb, chain = []) {
    cb(node, chain);
    if (node.children) {
      const nextChain = [...chain, node];
      node.children.forEach(c => leWalk(c, cb, nextChain));
    }
  }

  function leFindNode(root, id) {
    let result = null;
    leWalk(root, (n, chain) => {
      if (n.id === id) result = { node: n, chain };
    });
    return result;
  }

  function leIsCommentLine(node) {
    return !!(node && node.type === 'line' && node.kind === 'comment');
  }

  function leNodeHasPlayableContent(node) {
    if (!node) return false;
    if (node.type === 'word') return true;
    if (node.type === 'line') return !leIsCommentLine(node) && !!(node.children && node.children.length);
    return (node.children || []).some(leNodeHasPlayableContent);
  }

  function leNodeCanSeek(node) {
    return !!(node && !leIsCommentLine(node) && Number.isFinite(node.start_ms));
  }

  function leRecalc(node) {
    if (node.type === 'word') return node;
    if (node.type === 'line') {
      if (leIsCommentLine(node)) {
        node.children = [];
        node.text = node.text || '';
        node.end_ms = Math.max(node.start_ms || 0, node.end_ms || node.start_ms || 0);
        return node;
      }
      if (!node.children || node.children.length === 0) return node;
      node.children.forEach(leRecalc);
      node.start_ms = Math.min(...node.children.map(c => c.start_ms));
      node.end_ms = Math.max(...node.children.map(c => c.end_ms));
      node.text = node.children.map(c => c.text).filter(Boolean).join(' ').trim();
      return node;
    }
    if (!node.children || node.children.length === 0) return node;
    node.children.forEach(leRecalc);

    const timingChildren = node.type === 'sentence'
      ? node.children.filter(c => !leIsCommentLine(c))
      : node.children;
    const effectiveTimingChildren = timingChildren.length ? timingChildren : node.children;
    node.start_ms = Math.min(...effectiveTimingChildren.map(c => c.start_ms));
    node.end_ms = Math.max(...effectiveTimingChildren.map(c => c.end_ms));

    if (node.type === 'sentence') {
      node.text = node.children
        .filter(c => !leIsCommentLine(c))
        .map(c => c.text)
        .filter(Boolean)
        .join(' ')
        .trim();
    } else if (node.type === 'paragraph' || node.type === 'section' || node.type === 'song') {
      node.text = node.children
        .map(c => c.text)
        .filter(Boolean)
        .join('\n');
    }
    return node;
  }

  function leResequenceLine(line) {
    if (line.type !== 'line' || !line.children) return;
    line.children.forEach((w, i) => { w.seq = i + 1; });
  }

  function leRelabelLineNos(sentence) {
    if (sentence.type !== 'sentence' || !sentence.children) return;
    sentence.children.forEach((l, i) => { l.line_no = i + 1; });
  }

  let _leIdCounter = Date.now();
  function leNewId(type, parentId) {
    const suffix = (++_leIdCounter).toString(36);
    return parentId ? `${parentId}_${type[0]}${suffix}` : `${type}_${suffix}`;
  }

  function leFlattenByType(root, type) {
    const out = [];
    leWalk(root, (n) => { if (n.type === type) out.push(n); });
    return out;
  }

  function leFindCommentAnchorMs(lines, commentIndex, fallbackMs = 0) {
    for (let i = commentIndex + 1; i < lines.length; i += 1) {
      if (lines[i].type === 'line' && !leIsCommentLine(lines[i])) return lines[i].start_ms;
    }
    for (let i = commentIndex - 1; i >= 0; i -= 1) {
      if (lines[i].type === 'line' && !leIsCommentLine(lines[i])) return lines[i].end_ms;
    }
    return fallbackMs;
  }

  function leSyncCommentLineAnchor(sentence, line, fallbackMs = 0) {
    const lines = sentence.children || [];
    const idx = lines.findIndex(child => child.id === line.id);
    const anchorMs = leFindCommentAnchorMs(lines, idx, fallbackMs);
    line.start_ms = anchorMs;
    line.end_ms = anchorMs;
  }

  function leNormalizeSentenceLines(sentence, fallbackMs = 0) {
    if (!sentence || sentence.type !== 'sentence') return;
    const seen = new Set();
    sentence.children = (sentence.children || []).filter((child) => {
      if (!child || child.type !== 'line' || !child.id || seen.has(child.id)) return false;
      seen.add(child.id);
      return true;
    });
    sentence.children.forEach((child) => {
      if (leIsCommentLine(child)) leSyncCommentLineAnchor(sentence, child, fallbackMs);
    });
    leRelabelLineNos(sentence);
  }

  // Convert song.cues [{start(s), end(s), text}] → lyric_timeline JSON
  function cuesToTimeline(song) {
    const cues = song.cues || [];
    let counter = 0;
    const nid = (t) => `${t}_${String(++counter).padStart(4, '0')}`;

    if (cues.length === 0) {
      const root = { id: nid('song'), type: 'song', label: song.title || '노래', start_ms: 0, end_ms: (song.duration || 60) * 1000, text: '', children: [] };
      return { schema: 'lyric-timeline/v1', media: { title: song.title || '노래', artist: '', language: 'ko', duration_ms: Math.round((song.duration || 60) * 1000), audio_file: song.filename || '' }, root };
    }

    const sentences = cues.map((cue) => {
      const startMs = Math.round(cue.start * 1000);
      const endMs   = Math.round(cue.end * 1000);
      const rawWords = (cue.text || '').trim().split(/\s+/).filter(Boolean);
      const totalWords = rawWords.length || 1;
      const wDur = (endMs - startMs) / totalWords;

      const words = rawWords.map((text, i) => ({
        id: nid('word'),
        type: 'word',
        seq: i + 1,
        text,
        start_ms: Math.round(startMs + i * wDur),
        end_ms:   Math.round(startMs + (i + 1) * wDur),
        confidence: 0.95,
      }));

      const line = {
        id: nid('line'), type: 'line', line_no: 1,
        start_ms: startMs, end_ms: endMs,
        text: cue.text, children: words,
      };

      return {
        id: nid('sentence'), type: 'sentence',
        start_ms: startMs, end_ms: endMs,
        text: cue.text, children: [line],
      };
    });

    const paragraph = {
      id: nid('paragraph'), type: 'paragraph', label: 'A',
      start_ms: sentences[0].start_ms,
      end_ms: sentences[sentences.length - 1].end_ms,
      text: sentences.map(s => s.text).join(' '),
      children: sentences,
    };

    const section = {
      id: nid('section'), type: 'section', kind: 'verse', label: song.title || '노래',
      start_ms: paragraph.start_ms, end_ms: paragraph.end_ms,
      text: paragraph.text, children: [paragraph],
    };

    const root = {
      id: nid('song'), type: 'song', label: song.title || '노래',
      start_ms: section.start_ms, end_ms: section.end_ms,
      text: section.text, children: [section],
    };

    return {
      schema: 'lyric-timeline/v1',
      media: {
        title: song.title || '노래', artist: '',
        language: 'ko',
        duration_ms: Math.round((song.duration || 0) * 1000),
        audio_file: song.filename || '',
      },
      segmentation: {
        section_source: 'manual', paragraph_source: 'manual',
        sentence_source: 'asr', line_source: 'subtitle_layout', word_source: 'asr',
      },
      root,
    };
  }

  function leWrapChildren(parent, wrapperTypes) {
    let children = parent.children || [];
    wrapperTypes.forEach((type) => {
      const wrapper = {
        id: leNewId(type, parent.id),
        type,
        start_ms: children[0]?.start_ms ?? parent.start_ms ?? 0,
        end_ms: children[children.length - 1]?.end_ms ?? parent.end_ms ?? (parent.start_ms ?? 0),
        text: '',
        children,
      };
      if (type === 'section') Object.assign(wrapper, { kind: 'verse', label: parent.label || 'Section 1' });
      if (type === 'paragraph') Object.assign(wrapper, { label: 'A' });
      if (type === 'line') Object.assign(wrapper, { line_no: 1 });
      children = [wrapper];
    });
    parent.children = children;
  }

  function leDeduplicateIds(root) {
    const seen = new Set();
    let counter = Date.now();
    leWalk(root, (node) => {
      if (!node.id) return;
      if (seen.has(node.id)) {
        node.id = node.id.replace(/_\d+$/, '') + '_x' + (++counter).toString(36);
      } else {
        seen.add(node.id);
      }
    });
  }

  function leNormalizeLineGaps(root) {
    leWalk(root, (node) => {
      if (node.type !== 'line' || leIsCommentLine(node)) return;
      const words = node.children || [];
      for (let i = 0; i < words.length - 1; i++) {
        if (words[i].end_ms < words[i + 1].start_ms) {
          words[i].end_ms = words[i + 1].start_ms;
        } else if (words[i].end_ms > words[i + 1].start_ms) {
          words[i + 1].start_ms = words[i].end_ms;
        }
      }
    });
  }

  function leReassignIds(root) {
    const ABBR = { song: 'sng', section: 'sec', paragraph: 'par', sentence: 'sen', line: 'ln', word: 'w' };
    const idMap = new Map();
    function walk(node, parentNewId, localIdx) {
      const abbr = ABBR[node.type] || node.type;
      const newId = parentNewId ? `${parentNewId}_${abbr}${localIdx}` : `${abbr}${localIdx}`;
      idMap.set(node.id, newId);
      node.id = newId;
      if (node.children) node.children.forEach((child, i) => walk(child, newId, i));
    }
    walk(root, null, 0);
    return idMap;
  }

  function leNormalizeHierarchy(doc) {
    const next = leClone(doc);
    const root = next.root;
    leDeduplicateIds(root);
    const rootChildType = root.children?.[0]?.type;
    if (rootChildType === 'sentence') leWrapChildren(root, ['paragraph', 'section']);
    else if (rootChildType === 'paragraph') leWrapChildren(root, ['section']);
    else if (rootChildType === 'line') leWrapChildren(root, ['sentence', 'paragraph', 'section']);

    leWalk(root, (node) => {
      const childType = node.children?.[0]?.type;
      if (node.type === 'section' && childType === 'sentence') leWrapChildren(node, ['paragraph']);
      else if (node.type === 'section' && childType === 'line') leWrapChildren(node, ['sentence', 'paragraph']);
      else if (node.type === 'paragraph' && childType === 'line') leWrapChildren(node, ['sentence']);
      else if (node.type === 'sentence' && childType === 'word') leWrapChildren(node, ['line']);
    });

    leWalk(root, (node) => {
      if (node.type === 'sentence') leRelabelLineNos(node);
      if (node.type === 'line') leResequenceLine(node);
    });
    leRecalc(root);
    return next;
  }

  // ─── Icons ────────────────────────────────────────────────────

  function LEIcon({ name, size = 14 }) {
    const s = { width: size, height: size, display: 'inline-block', verticalAlign: 'middle' };
    const c = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', style: s };
    switch (name) {
      case 'chevron-right': return <svg {...c}><path d="M6 3l5 5-5 5"/></svg>;
      case 'chevron-down':  return <svg {...c}><path d="M3 6l5 5 5-5"/></svg>;
      case 'chevron-up':    return <svg {...c}><path d="M3 10l5-5 5 5"/></svg>;
      case 'play':          return <svg {...c} fill="currentColor" stroke="none"><path d="M4 3v10l9-5z"/></svg>;
      case 'pause':         return <svg {...c} fill="currentColor" stroke="none"><rect x="4" y="3" width="3" height="10"/><rect x="9" y="3" width="3" height="10"/></svg>;
      case 'skip-back':     return <svg {...c} fill="currentColor" stroke="none"><rect x="3" y="3" width="2" height="10"/><path d="M13 3v10L6 8z"/></svg>;
      case 'skip-fwd':      return <svg {...c} fill="currentColor" stroke="none"><rect x="11" y="3" width="2" height="10"/><path d="M3 3v10l7-5z"/></svg>;
      case 'plus':          return <svg {...c}><path d="M8 3v10M3 8h10"/></svg>;
      case 'trash':         return <svg {...c}><path d="M3 4h10M6 4V3h4v1M5 4l1 9h4l1-9"/></svg>;
      case 'undo':          return <svg {...c}><path d="M4 6h6a3 3 0 010 6H7M4 6l2-2M4 6l2 2"/></svg>;
      case 'redo':          return <svg {...c}><path d="M12 6H6a3 3 0 000 6h3M12 6l-2-2M12 6l-2 2"/></svg>;
      case 'check':         return <svg {...c}><path d="M3 8l3 3 7-7"/></svg>;
      case 'split':         return <svg {...c}><path d="M3 8h10M8 3v10"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/></svg>;
      case 'download':      return <svg {...c}><path d="M8 3v8M4 8l4 4 4-4M3 13h10"/></svg>;
      case 'music':         return <svg {...c}><path d="M6 12a2 2 0 11-2-2 2 2 0 012 2zM6 12V4l7-2v8"/><path d="M13 10a2 2 0 11-2-2 2 2 0 012 2z"/></svg>;
      case 'zap':           return <svg {...c} fill="currentColor" stroke="none"><path d="M9 1L3 9h4l-1 6 6-8H8z"/></svg>;
      case 'lock':          return <svg {...c}><rect x="3" y="7" width="10" height="7" rx="1"/><path d="M5 7V5a3 3 0 016 0v2"/></svg>;
      case 'x':             return <svg {...c}><path d="M3 3l10 10M13 3L3 13"/></svg>;
      case 'save':          return <svg {...c}><path d="M3 3h8l2 2v8H3zM5 3v4h6V3M5 13v-4h6v4"/></svg>;
      default: return null;
    }
  }

  // ─── TimestampInput ───────────────────────────────────────────

  function TimestampInput({ value, onChange, min, max }) {
    const [text, setText] = useState(leFmtTime(value));
    const [focused, setFocused] = useState(false);

    useEffect(() => {
      if (!focused) setText(leFmtTime(value));
    }, [value, focused]);

    const commit = (v) => {
      const parsed = leParseTime(v);
      if (parsed == null) { setText(leFmtTime(value)); return; }
      let clamped = parsed;
      if (min != null) clamped = Math.max(min, clamped);
      if (max != null) clamped = Math.min(max, clamped);
      onChange(clamped);
      setText(leFmtTime(clamped));
    };

    const step = (delta, e) => {
      const mult = e.shiftKey ? 100 : e.altKey ? 1 : 10;
      commit(String(value + delta * mult));
    };

    return (
      <>
        <input
          className="ts-input mono"
          value={text}
          onChange={e => setText(e.target.value)}
          onFocus={() => setFocused(true)}
          onBlur={() => { setFocused(false); commit(text); }}
          onKeyDown={e => {
            if (e.key === 'Enter') e.target.blur();
            else if (e.key === 'Escape') { setText(leFmtTime(value)); e.target.blur(); }
            else if (e.key === 'ArrowUp') { e.preventDefault(); step(1, e); }
            else if (e.key === 'ArrowDown') { e.preventDefault(); step(-1, e); }
          }}
        />
        <div className="ts-stepper">
          <button className="ts-step-btn" onClick={e => step(1, e)} title="Step up (shift=100ms, alt=1ms)">▲</button>
          <button className="ts-step-btn" onClick={e => step(-1, e)} title="Step down">▼</button>
        </div>
      </>
    );
  }

  // ─── RangeDragEditor ─────────────────────────────────────────

  function RangeDragEditor({ start, end, min, max, playhead, onStart, onEnd }) {
    const ref = useRef(null);
    const [dragging, setDragging] = useState(null);

    const pxToMs = (px) => {
      const rect = ref.current.getBoundingClientRect();
      const frac = Math.max(0, Math.min(1, (px - rect.left) / rect.width));
      return Math.round(min + frac * (max - min));
    };

    const startDrag = (which) => (e) => {
      e.preventDefault();
      setDragging(which);
      const onMove = (ev) => {
        const ms = pxToMs(ev.clientX);
        if (which === 'start') onStart(Math.min(ms, end - 50));
        else onEnd(Math.max(ms, start + 50));
      };
      const onUp = () => {
        setDragging(null);
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
    };

    const pct = (ms) => ((ms - min) / (max - min)) * 100;

    return (
      <div className="ts-range" ref={ref}>
        <div className="ts-range-track"/>
        <div className="ts-range-fill" style={{ left: pct(start) + '%', width: (pct(end) - pct(start)) + '%' }}/>
        <div
          className={'ts-range-handle left' + (dragging === 'start' ? ' dragging' : '')}
          style={{ left: 'calc(' + pct(start) + '% - 3px)' }}
          onMouseDown={startDrag('start')}
        >
          <div className="ts-range-label" style={{ left: '50%' }}>{leFmtTime(start)}</div>
        </div>
        <div
          className={'ts-range-handle right' + (dragging === 'end' ? ' dragging' : '')}
          style={{ left: 'calc(' + pct(end) + '% - 3px)' }}
          onMouseDown={startDrag('end')}
        >
          <div className="ts-range-label" style={{ left: '50%' }}>{leFmtTime(end)}</div>
        </div>
        {playhead != null && playhead >= min && playhead <= max && (
          <div className="ts-range-playhead" style={{ left: pct(playhead) + '%' }}/>
        )}
      </div>
    );
  }

  // ─── InsertGap ───────────────────────────────────────────────

  function InsertGap({ label, onClick }) {
    return (
      <div className="insert-gap" onClick={onClick}>
        <div className="insert-gap-line"/>
        <button className="insert-gap-btn">
          <LEIcon name="plus" size={10}/> {label}
        </button>
      </div>
    );
  }

  // ─── Tree Panel ──────────────────────────────────────────────

  const TYPE_LABEL = { song: 'SONG', section: 'SEC', paragraph: 'PAR', sentence: 'SEN', line: 'LN', word: 'W' };

  function TreeRow({ node, depth, expanded, onToggle, selected, onSelect, onSeek, playing }) {
    const hasChildren = node.children && node.children.length > 0;
    const iconClass = 'tree-icon ' + node.type;
    const kindKey = (node.kind || '').replace(/[^a-z]/gi, '').toLowerCase();

    let label = '';
    let meta = '';
    if (node.type === 'song') { label = node.label || 'Song'; meta = `${(node.children || []).length} sections`; }
    else if (node.type === 'section') label = node.label || node.kind || 'Section';
    else if (node.type === 'paragraph') { label = `Paragraph ${node.label || ''}`.trim(); meta = `${(node.children || []).length} sent.`; }
    else if (node.type === 'sentence') { const t = node.text || ''; label = t.length > 40 ? t.slice(0, 40) + '…' : t; }
    else if (node.type === 'line') label = leIsCommentLine(node) ? `Comment · ${node.text || '메모'}` : (node.text || '');
    else if (node.type === 'word') label = node.text || '';
    const typeLabel = leIsCommentLine(node) ? 'CMT' : TYPE_LABEL[node.type];
    const canSeek = leNodeCanSeek(node);

    return (
      <div
        className={'tree-row' + (selected ? ' selected' : '') + (playing ? ' playing' : '')}
        style={{ paddingLeft: 8 + depth * 14 }}
        onClick={() => onSelect(node.id)}
      >
        <button
          className={'tree-chevron ' + (hasChildren ? (expanded ? 'open' : '') : 'leaf')}
          onClick={e => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
        >
          <LEIcon name="chevron-right" size={10}/>
        </button>
        <div className={iconClass}>{typeLabel}</div>
        {node.type === 'section' && (
          <div className={'le-pill section-' + kindKey} style={{ marginRight: 4 }}>{node.kind}</div>
        )}
        <div
          className="tree-label"
          onClick={e => {
            e.stopPropagation();
            onSelect(node.id);
            if (canSeek) onSeek(node.start_ms);
          }}
        >
          {label}
          {meta && <span className="meta">· {meta}</span>}
        </div>
        <div className="tree-time mono">{leFmtTimeShort(node.start_ms)}</div>
      </div>
    );
  }

  function TreePanel({ root, selectedId, onSelect, onSeek, expanded, onToggle, playingIds, onExpandAll, onCollapseAll }) {
    const rows = [];
    const render = (node, depth, path = '0') => {
      rows.push(
        <TreeRow
          key={`${path}:${node.id}`} node={node} depth={depth}
          expanded={expanded.has(node.id)}
          onToggle={onToggle}
          selected={selectedId === node.id}
          playing={playingIds.has(node.id)}
          onSelect={onSelect}
          onSeek={onSeek}
        />
      );
      if (node.children && expanded.has(node.id)) {
        node.children.forEach((c, index) => render(c, depth + 1, `${path}.${index}`));
      }
    };
    render(root, 0, '0');

    return (
      <div className="panel">
        <div className="panel-header">
          <span>Hierarchy</span>
          <div className="panel-header-actions">
            <button className="panel-header-btn" title="Expand all" onClick={onExpandAll}><LEIcon name="chevron-down" size={10}/></button>
            <button className="panel-header-btn" title="Collapse all" onClick={onCollapseAll}><LEIcon name="chevron-right" size={10}/></button>
          </div>
        </div>
        <div className="panel-body">
          <div className="tree">{rows}</div>
        </div>
      </div>
    );
  }

  // ─── Detail Head ─────────────────────────────────────────────

  function Crumbs({ chain, node, onSelect }) {
    const all = [...chain, node];
    return (
      <div className="detail-crumbs">
        {all.map((n, i) => (
          <React.Fragment key={n.id}>
            {i > 0 && <span className="sep">/</span>}
            <span
              className={'crumb' + (i === all.length - 1 ? ' current' : '')}
              onClick={() => onSelect(n.id)}
            >
              {n.type === 'song' ? (n.label || 'Song')
                : n.type === 'section' ? (n.label || n.kind)
                : n.type === 'paragraph' ? `Paragraph ${n.label || ''}`
                : n.type === 'sentence' ? 'Sentence'
                : n.type === 'line' ? (leIsCommentLine(n) ? 'Comment line' : `Line ${n.line_no || ''}`)
                : n.text}
            </span>
          </React.Fragment>
        ))}
      </div>
    );
  }

  function DetailHead({ node, chain, onSelect }) {
    const titleText = node.type === 'song' ? (node.label || 'Untitled song')
      : node.type === 'section' ? (node.label || node.kind)
      : node.type === 'paragraph' ? `Paragraph ${node.label || ''}`
      : node.type === 'sentence' ? 'Sentence'
      : node.type === 'line' ? (leIsCommentLine(node) ? 'Comment line' : `Line ${node.line_no || ''}`)
      : node.text;

    return (
      <div className="detail-head">
        <Crumbs chain={chain} node={node} onSelect={onSelect}/>
        <div className="detail-title">
          <span className="type-tag">{node.type.toUpperCase()}</span>
          <span>{titleText}</span>
          <span className="id-chip mono">{node.id}</span>
        </div>
        <div className="detail-meta">
          <div className="item"><span className="k">Start</span><span className="v">{leFmtTime(node.start_ms)}</span></div>
          <div className="item"><span className="k">End</span><span className="v">{leFmtTime(node.end_ms)}</span></div>
          <div className="item"><span className="k">Duration</span><span className="v">{leFmtTime(node.end_ms - node.start_ms)}</span></div>
          {node.children && <div className="item"><span className="k">Children</span><span className="v">{node.children.length}</span></div>}
        </div>
      </div>
    );
  }

  // ─── Word Row ────────────────────────────────────────────────

  function WordRow({ word, onChange, onDelete, onSeekPlay, onSelect, playing }) {
    return (
      <div
        className={'word-row' + (playing ? ' playing' : '') + (onSelect ? ' selectable' : '')}
        onClick={onSelect || undefined}
        title={onSelect ? '빈 영역을 클릭하면 이 word를 편집합니다.' : undefined}
      >
        <div
          className={'seq mono' + (onSeekPlay ? ' seq-playable' : '')}
          onClick={onSeekPlay ? (e) => { e.stopPropagation(); onSeekPlay(); } : undefined}
          title={onSeekPlay ? `${word.start_ms}ms부터 재생` : undefined}
        >
          {playing
            ? <LEIcon name="play" size={9}/>
            : onSeekPlay
              ? <span className="seq-num">{word.seq}</span>
              : word.seq
          }
        </div>
        <input
          className="text-input"
          value={word.text}
          onClick={e => e.stopPropagation()}
          onChange={e => onChange({ ...word, text: e.target.value })}
        />
        <input
          className="ts-input-small mono"
          defaultValue={leFmtTime(word.start_ms)}
          onClick={e => e.stopPropagation()}
          onBlur={e => {
            const v = leParseTime(e.target.value);
            if (v != null) onChange({ ...word, start_ms: v });
            else e.target.value = leFmtTime(word.start_ms);
          }}
          key={'s' + word.start_ms}
        />
        <input
          className="ts-input-small mono"
          defaultValue={leFmtTime(word.end_ms)}
          onClick={e => e.stopPropagation()}
          onBlur={e => {
            const v = leParseTime(e.target.value);
            if (v != null) onChange({ ...word, end_ms: v });
            else e.target.value = leFmtTime(word.end_ms);
          }}
          key={'e' + word.end_ms}
        />
        <div className={'conf mono' + ((word.confidence || 1) < 0.9 ? ' low' : '')}>
          {word.confidence != null ? word.confidence.toFixed(2) : '—'}
        </div>
        <button className="del-btn" onClick={e => { e.stopPropagation(); onDelete(); }} title="Delete word">
          <LEIcon name="trash" size={12}/>
        </button>
      </div>
    );
  }

  function WordInsertGap({ onAddWord, onSplit, onSelectWord }) {
    return (
      <div
        className={'insert-gap' + (onSelectWord ? ' selectable' : '')}
        onClick={onSelectWord || undefined}
        title={onSelectWord ? '빈칸을 클릭하면 가까운 word를 편집합니다.' : undefined}
      >
        <div className="insert-gap-line"/>
        <div style={{ position: 'relative', zIndex: 1, margin: '0 auto', display: 'flex', gap: 4 }}>
          <button className="insert-gap-btn" onClick={e => { e.stopPropagation(); onAddWord(); }}>
            <LEIcon name="plus" size={10}/> Insert word
          </button>
          {onSplit && (
            <button className="insert-gap-btn" onClick={e => { e.stopPropagation(); onSplit(); }}>
              <LEIcon name="split" size={10}/> Split line
            </button>
          )}
        </div>
      </div>
    );
  }

  function LineNavigation({ prevLine, nextLine, prevSentence, nextSentence, onSelect }) {
    return (
      <div className="detail-section">
        <div className="detail-section-title">Navigate</div>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          <button className="le-btn" onClick={() => prevLine && onSelect(prevLine.id)} disabled={!prevLine}>
            <LEIcon name="skip-back" size={11}/> Go prev line
          </button>
          <button className="le-btn" onClick={() => nextLine && onSelect(nextLine.id)} disabled={!nextLine}>
            <LEIcon name="skip-fwd" size={11}/> Go next line
          </button>
          <button className="le-btn" onClick={() => prevSentence && onSelect(prevSentence.id)} disabled={!prevSentence}>
            <LEIcon name="skip-back" size={11}/> Go prev sentence
          </button>
          <button className="le-btn" onClick={() => nextSentence && onSelect(nextSentence.id)} disabled={!nextSentence}>
            <LEIcon name="skip-fwd" size={11}/> Go next sentence
          </button>
        </div>
      </div>
    );
  }

  // ─── Line Detail ─────────────────────────────────────────────

  const WT_COLORS = [
    { fg: 'var(--cyan)',   bg: 'rgba(77,208,225,0.13)'  },
    { fg: 'var(--amber)',  bg: 'rgba(245,166,35,0.13)'  },
    { fg: 'var(--blue)',   bg: 'rgba(93,143,245,0.13)'  },
    { fg: 'var(--green)',  bg: 'rgba(107,208,136,0.13)' },
    { fg: 'var(--purple)', bg: 'rgba(157,123,219,0.13)' },
    { fg: 'var(--pink)',   bg: 'rgba(227,126,176,0.13)' },
  ];

  function WordTimeline({ words, lineStart, lineEnd, currentMs, onUpdateWord }) {
    const trackRef = useRef(null);
    const wordsRef = useRef(words);
    wordsRef.current = words;
    const [dragging, setDragging] = useState(null);

    const totalMs = Math.max(1, lineEnd - lineStart);
    const pct = (ms) => Math.max(0, Math.min(100, ((ms - lineStart) / totalMs) * 100));

    const startDrag = (wordIdx, handle) => (e) => {
      e.preventDefault();
      e.stopPropagation();
      setDragging({ wordIdx, handle });
      const rStart = lineStart, rEnd = lineEnd, rDur = totalMs;

      const onMove = (ev) => {
        if (!trackRef.current) return;
        const rect = trackRef.current.getBoundingClientRect();
        const frac = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));
        const ms = Math.round(rStart + frac * rDur);
        const ws = wordsRef.current;
        const word = ws[wordIdx];
        if (!word) return;
        const prev = ws[wordIdx - 1];
        const next = ws[wordIdx + 1];
        if (handle === 'start') {
          const clamped = Math.max(prev ? prev.end_ms + 1 : rStart, Math.min(ms, word.end_ms - 1));
          if (clamped !== word.start_ms) onUpdateWord({ ...word, start_ms: clamped });
        } else {
          const clamped = Math.max(word.start_ms + 1, Math.min(ms, next ? next.start_ms - 1 : rEnd));
          if (clamped !== word.end_ms) onUpdateWord({ ...word, end_ms: clamped });
        }
      };
      const onUp = () => {
        setDragging(null);
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
    };

    if (!words.length) return null;

    return (
      <div className="word-timeline">
        <div className="wt-track" ref={trackRef}>
          <div className="wt-grid"/>
          {words.map((word, i) => {
            const { fg, bg } = WT_COLORS[i % WT_COLORS.length];
            const lp = pct(word.start_ms);
            const wp = Math.max(pct(word.end_ms) - lp, 0.3);
            const isThis = dragging?.wordIdx === i;
            return (
              <div
                key={word.id}
                className={'wt-bar' + (isThis ? ' dragging' : '')}
                style={{ left: lp + '%', width: wp + '%', '--wc': fg, '--wcb': bg }}
              >
                <div className="wt-num">{word.seq}</div>
                <div
                  className={'wt-handle wt-hl' + (isThis && dragging.handle === 'start' ? ' active' : '')}
                  onMouseDown={startDrag(i, 'start')}
                />
                <div className="wt-label">{word.text}</div>
                <div
                  className={'wt-handle wt-hr' + (isThis && dragging.handle === 'end' ? ' active' : '')}
                  onMouseDown={startDrag(i, 'end')}
                />
              </div>
            );
          })}
          {currentMs != null && currentMs >= lineStart && currentMs <= lineEnd && (
            <div className="wt-playhead" style={{ left: pct(currentMs) + '%' }}/>
          )}
        </div>
        <div className="wt-axis">
          <span>{leFmtTime(lineStart)}</span>
          <span>{leFmtTime(lineEnd)}</span>
        </div>
      </div>
    );
  }

  function LineDetail({
    node, currentMs, onUpdate, onAddWordAt, onDeleteWord, onSplitLineAt, onSelectWord, onSeekPlay, onForceApplyTiming,
    onMovePrevLine, onMoveNextLine, onMovePrevSentence, onMoveNextSentence,
    canMovePrevLine, canMoveNextLine, canMovePrevSentence, canMoveNextSentence,
  }) {
    const isComment = leIsCommentLine(node);
    const words = node.children || [];
    const minLineDiff = Math.max(1, words.length - 1);
    const [draftStart, setDraftStart] = useState(node.start_ms);
    const [draftEnd, setDraftEnd] = useState(node.end_ms);

    useEffect(() => {
      setDraftStart(node.start_ms);
      setDraftEnd(node.end_ms);
    }, [node.id, node.start_ms, node.end_ms]);

    const normalizedDraftStart = Math.max(0, Math.round(draftStart));
    const normalizedDraftEnd = Math.max(Math.round(draftEnd), normalizedDraftStart + minLineDiff);
    const draftRangeMin = Math.max(0, Math.min(node.start_ms, normalizedDraftStart, currentMs || 0) - 2000);
    const draftRangeMax = Math.max(node.end_ms, normalizedDraftEnd, currentMs || 0) + 2000;

    const handleDraftStart = (v) => {
      const newStart = Math.max(0, Math.min(v, normalizedDraftEnd - minLineDiff));
      setDraftStart(newStart);
      if (words.length > 0) {
        const fw = words[0];
        const newFwStart = Math.min(newStart, fw.end_ms - 1);
        if (newFwStart !== fw.start_ms) onUpdate({ ...fw, start_ms: newFwStart });
      }
    };
    const handleDraftEnd = (v) => {
      const newEnd = Math.max(v, normalizedDraftStart + minLineDiff);
      setDraftEnd(newEnd);
      if (words.length > 0) {
        const lw = words[words.length - 1];
        const newLwEnd = Math.max(newEnd, lw.start_ms + 1);
        if (newLwEnd !== lw.end_ms) onUpdate({ ...lw, end_ms: newLwEnd });
      }
    };

    return (
      <>
        {isComment && (
          <div className="detail-section">
            <div className="detail-section-title">
              <span>Actions</span>
              <span className="hint">Reposition this comment line</span>
            </div>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              <button className="le-btn" onClick={onMovePrevLine} disabled={!canMovePrevLine}>
                <LEIcon name="chevron-up" size={11}/> Move prev line
              </button>
              <button className="le-btn" onClick={onMoveNextLine} disabled={!canMoveNextLine}>
                <LEIcon name="chevron-down" size={11}/> Move next line
              </button>
              <button className="le-btn" onClick={onMovePrevSentence} disabled={!canMovePrevSentence}>
                <LEIcon name="skip-back" size={11}/> Move prev sentence
              </button>
              <button className="le-btn" onClick={onMoveNextSentence} disabled={!canMoveNextSentence}>
                <LEIcon name="skip-fwd" size={11}/> Move next sentence
              </button>
            </div>
          </div>
        )}
        {!isComment && (
          <div className="detail-section">
            <div className="detail-section-title">
              <span>Actions</span>
              <span className="hint">Move this line between sentences</span>
            </div>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              <button className="le-btn" onClick={onMovePrevSentence} disabled={!canMovePrevSentence}>
                <LEIcon name="skip-back" size={11}/> Move prev sentence
              </button>
              <button className="le-btn" onClick={onMoveNextSentence} disabled={!canMoveNextSentence}>
                <LEIcon name="skip-fwd" size={11}/> Move next sentence
              </button>
            </div>
          </div>
        )}
        <div className="detail-section">
          <div className="detail-section-title">{isComment ? 'Comment anchor' : 'Timing draft'}</div>
          <div className="ts-pair">
            <div className="ts-editor">
              <span className="ts-editor-label">{isComment ? 'At' : 'Start'}</span>
              <TimestampInput
                value={isComment ? node.start_ms : normalizedDraftStart}
                onChange={v => {
                  if (isComment) {
                    onUpdate({ ...node, start_ms: v, end_ms: v, children: [] });
                  } else {
                    handleDraftStart(v);
                  }
                }}
              />
            </div>
            {!isComment && (
              <div className="ts-editor">
                <span className="ts-editor-label">End</span>
                <TimestampInput
                  value={normalizedDraftEnd}
                  onChange={v => handleDraftEnd(v)}
                />
              </div>
            )}
          </div>
          <div className="ts-duration">
            <span>{isComment ? 'Playback' : 'Duration'}</span>
            <span className="v">{isComment ? 'Disabled' : leFmtTime(normalizedDraftEnd - normalizedDraftStart)}</span>
          </div>
          {!isComment && words.length > 0 && (
            <>
              <RangeDragEditor
                start={normalizedDraftStart} end={normalizedDraftEnd}
                min={draftRangeMin} max={draftRangeMax}
                playhead={currentMs}
                onStart={v => handleDraftStart(v)}
                onEnd={v => handleDraftEnd(v)}
              />
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 12 }}>
                <button className="le-btn" onClick={() => handleDraftStart(currentMs)}>
                  <LEIcon name="skip-back" size={11}/> Playhead → Start
                </button>
                <button className="le-btn" onClick={() => handleDraftEnd(currentMs)}>
                  <LEIcon name="skip-fwd" size={11}/> Playhead → End
                </button>
                <button className="le-btn" onClick={() => onSeekPlay && onSeekPlay(normalizedDraftStart)}>
                  <LEIcon name="play" size={11}/> Preview
                </button>
                <button className="le-btn" onClick={() => { setDraftStart(node.start_ms); setDraftEnd(node.end_ms); }}>
                  <LEIcon name="undo" size={11}/> Reset
                </button>
                <button className="le-btn" onClick={() => onForceApplyTiming(normalizedDraftStart, normalizedDraftEnd)}>
                  <LEIcon name="check" size={11}/> Apply Evenly
                </button>
              </div>
              <WordTimeline
                words={words}
                lineStart={normalizedDraftStart}
                lineEnd={normalizedDraftEnd}
                currentMs={currentMs}
                onUpdateWord={onUpdate}
              />
              <div className="hint" style={{ marginTop: 10 }}>
                재생하면서 playhead를 시작과 끝에 맞춘 뒤 적용하면, 이 line의 모든 word timestamp를 균등 배분해 다시 씁니다.
              </div>
            </>
          )}
        </div>

        <div className="detail-section">
          <div className="detail-section-title">{isComment ? 'Comment' : 'Words (' + words.length + ')'}</div>
          {isComment ? (
            <>
              <textarea
                className="detail-text-input"
                value={node.text || ''}
                onChange={e => onUpdate({ ...node, text: e.target.value, children: [], end_ms: node.start_ms })}
                placeholder="이 line은 재생되지 않는 메모 전용입니다."
                style={{ minHeight: 140, resize: 'vertical', lineHeight: 1.6, fontFamily: 'var(--font-mono)' }}
              />
              <div className="hint" style={{ marginTop: 10 }}>
                주석 line은 플레이어 이동과 재생에서 제외되고, 가사 cue 텍스트에도 포함되지 않습니다.
              </div>
            </>
          ) : (
            <div className="word-list">
              <WordInsertGap onAddWord={() => onAddWordAt(0)} onSelectWord={words[0] ? () => onSelectWord(words[0].id) : null}/>
              {words.map((w, i) => (
                <React.Fragment key={w.id}>
                  <WordRow
                    word={w}
                    playing={currentMs >= w.start_ms && currentMs <= w.end_ms}
                    onChange={onUpdate}
                    onDelete={() => onDeleteWord(w.id)}
                    onSelect={() => onSelectWord(w.id)}
                    onSeekPlay={onSeekPlay ? () => onSeekPlay(w.start_ms) : null}
                  />
                  {i < words.length - 1
                    ? (
                      <WordInsertGap
                        onAddWord={() => onAddWordAt(i + 1)}
                        onSplit={() => onSplitLineAt(i + 1)}
                        onSelectWord={() => onSelectWord(words[i + 1].id)}
                      />
                    )
                    : <WordInsertGap onAddWord={() => onAddWordAt(i + 1)} onSelectWord={() => onSelectWord(w.id)}/>
                  }
                </React.Fragment>
              ))}
            </div>
          )}
        </div>

        <div className="detail-section">
          <div className="detail-section-title">Text preview</div>
          <div className="detail-text-preview">{node.text}</div>
        </div>
      </>
    );
  }

  // ─── Word Detail ─────────────────────────────────────────────

  function WordDetail({ node, currentMs, siblings, onUpdate, onAddBefore, onAddAfter, onDelete, onMovePrev, onMoveNext, canMovePrev, canMoveNext }) {
    const idx = siblings.findIndex(w => w.id === node.id);
    const prev = siblings[idx - 1];
    const next = siblings[idx + 1];
    const rangeMin = prev ? prev.start_ms : Math.max(0, node.start_ms - 2000);
    const rangeMax = next ? next.end_ms : node.end_ms + 2000;

    return (
      <>
        <div className="detail-section">
          <div className="detail-section-title">
            <span>Actions</span>
            <span className="hint">Add or remove this word</span>
          </div>
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            <button className="le-btn" onClick={onAddBefore}><LEIcon name="plus" size={11}/> Add before</button>
            <button className="le-btn" onClick={onAddAfter}><LEIcon name="plus" size={11}/> Add after</button>
            <button className="le-btn" onClick={onMovePrev} disabled={!canMovePrev}><LEIcon name="chevron-up" size={11}/> Prev line</button>
            <button className="le-btn" onClick={onMoveNext} disabled={!canMoveNext}><LEIcon name="chevron-down" size={11}/> Next line</button>
            <div style={{ flex: 1 }}/>
            <button className="le-btn" style={{ color: 'var(--red)', borderColor: '#4a2a30' }} onClick={onDelete}>
              <LEIcon name="trash" size={11}/> Delete
            </button>
          </div>
        </div>
        <div className="detail-section">
          <div className="detail-section-title">Text</div>
          <input
            className="detail-text-input"
            value={node.text}
            onChange={e => onUpdate({ ...node, text: e.target.value })}
          />
        </div>
        <div className="detail-section">
          <div className="detail-section-title">Timing</div>
          <div className="ts-pair">
            <div className="ts-editor">
              <span className="ts-editor-label">Start</span>
              <TimestampInput
                value={node.start_ms}
                min={prev ? prev.start_ms : 0} max={node.end_ms - 1}
                onChange={v => onUpdate({ ...node, start_ms: v })}
              />
            </div>
            <div className="ts-editor">
              <span className="ts-editor-label">End</span>
              <TimestampInput
                value={node.end_ms}
                min={node.start_ms + 1}
                max={next ? next.end_ms : node.end_ms + 10000}
                onChange={v => onUpdate({ ...node, end_ms: v })}
              />
            </div>
          </div>
          <div className="ts-duration">
            <span>Duration</span>
            <span className="v">{leFmtTime(node.end_ms - node.start_ms)}</span>
          </div>
          <RangeDragEditor
            start={node.start_ms} end={node.end_ms}
            min={rangeMin} max={rangeMax}
            playhead={currentMs}
            onStart={v => onUpdate({ ...node, start_ms: Math.max(prev ? prev.start_ms : 0, Math.min(v, node.end_ms - 1)) })}
            onEnd={v => onUpdate({ ...node, end_ms: Math.min(next ? next.end_ms : v, Math.max(v, node.start_ms + 1)) })}
          />
        </div>
        <div className="detail-section">
          <div className="detail-section-title">Metadata</div>
          <div className="detail-meta" style={{ marginTop: 0 }}>
            <div className="item"><span className="k">Seq</span><span className="v">{node.seq}</span></div>
            <div className="item"><span className="k">Confidence</span><span className="v">{node.confidence != null ? node.confidence.toFixed(3) : '—'}</span></div>
          </div>
        </div>
      </>
    );
  }

  // ─── Container Detail ────────────────────────────────────────

  function ChildRow({ child, idx, onSelect, onDelete, currentMs }) {
    const playing = !leIsCommentLine(child) && currentMs >= child.start_ms && currentMs <= child.end_ms;
    let text = '';
    if (child.type === 'section') text = child.label + (child.kind ? ' · ' + child.kind : '');
    else if (child.type === 'paragraph') text = `Paragraph ${child.label || ''} — ` + (child.text || '').slice(0, 60);
    else if (child.type === 'sentence') text = child.text;
    else if (child.type === 'line') text = leIsCommentLine(child) ? `Comment  ${child.text || '메모'}` : `L${child.line_no || ''}  ${child.text}`;
    else text = child.text;

    return (
      <div className="child-row" onClick={onSelect} style={playing ? { borderColor: 'var(--amber)' } : undefined}>
        <div className="idx mono">{idx + 1}</div>
        <div className="text">{text}</div>
        <div className="ts mono">{leFmtTime(child.start_ms)}</div>
        <div className="ts mono">{leFmtTime(child.end_ms)}</div>
        <button className="del-btn" onClick={e => { e.stopPropagation(); onDelete(); }} title="Delete">
          <LEIcon name="trash" size={12}/>
        </button>
      </div>
    );
  }

  function ContainerDetail({
    node, onSelect, onInsertGroup, onRemoveGroup, onDeleteChild, onAddChild, onAddCommentLine, currentMs,
    onMovePrevParagraph, onMoveNextParagraph, canMovePrevParagraph, canMoveNextParagraph,
  }) {
    const children = node.children || [];
    const childType = children[0]?.type;
    const insertLabel = (
      childType === 'line' ? 'sentence break'
      : childType === 'sentence' ? 'paragraph break'
      : childType === 'paragraph' ? 'section break'
      : null
    );
    const addActions = [];
    if (node.type === 'song') addActions.push({ key: 'section', label: 'Add section', onClick: onAddChild });
    else if (node.type === 'section') addActions.push({ key: 'paragraph', label: 'Add paragraph', onClick: onAddChild });
    else if (node.type === 'paragraph') addActions.push({ key: 'sentence', label: 'Add sentence', onClick: onAddChild });
    else if (node.type === 'sentence') {
      addActions.push({ key: 'line', label: 'Add line', onClick: onAddChild });
      addActions.push({ key: 'comment', label: 'Add comment line', onClick: onAddCommentLine });
    }

    return (
      <>
        <div className="detail-section">
          <div className="detail-section-title">Timing (computed from children)</div>
          <div className="ts-pair">
            <div className="ts-editor" style={{ opacity: 0.6 }}>
              <span className="ts-editor-label">Start</span>
              <input className="ts-input mono" value={leFmtTime(node.start_ms)} disabled/>
            </div>
            <div className="ts-editor" style={{ opacity: 0.6 }}>
              <span className="ts-editor-label">End</span>
              <input className="ts-input mono" value={leFmtTime(node.end_ms)} disabled/>
            </div>
          </div>
          <div className="ts-duration">
            <span><LEIcon name="lock" size={10}/> Derived — edit children to change</span>
            <span className="v">{leFmtTime(node.end_ms - node.start_ms)}</span>
          </div>
        </div>

        {addActions.length > 0 && (
          <div className="detail-section">
            <div className="detail-section-title">Add</div>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {addActions.map((action) => (
                <button key={action.key} className="le-btn" onClick={action.onClick}>
                  <LEIcon name="plus" size={11}/> {action.label}
                </button>
              ))}
            </div>
          </div>
        )}

        {node.type === 'sentence' && (
          <div className="detail-section">
            <div className="detail-section-title">
              <span>Actions</span>
              <span className="hint">Move this sentence between paragraphs</span>
            </div>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              <button className="le-btn" onClick={onMovePrevParagraph} disabled={!canMovePrevParagraph}>
                <LEIcon name="skip-back" size={11}/> Move prev paragraph
              </button>
              <button className="le-btn" onClick={onMoveNextParagraph} disabled={!canMoveNextParagraph}>
                <LEIcon name="skip-fwd" size={11}/> Move next paragraph
              </button>
            </div>
          </div>
        )}

        {node.type === 'section' && (
          <div className="detail-section">
            <div className="detail-section-title">Section</div>
            <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
              <span style={{ fontSize: 11, color: 'var(--fg-2)' }}>Kind</span>
              <div className={'le-pill section-' + (node.kind || '').replace(/[^a-z]/g, '')}>{node.kind}</div>
            </div>
          </div>
        )}

        <div className="detail-section">
          <div className="detail-section-title">
            <span>{childType ? (childType.charAt(0).toUpperCase() + childType.slice(1) + 's') : 'Children'} ({children.length})</span>
            {insertLabel && <span className="hint">Hover between to insert {insertLabel}</span>}
          </div>
          {children.length === 0 ? (
            <div className="detail-text-preview">아직 추가된 항목이 없습니다.</div>
          ) : (
            <div className="child-list">
              {children.map((c, i) => (
                <React.Fragment key={c.id}>
                  <ChildRow child={c} idx={i} onSelect={() => onSelect(c.id)} onDelete={() => onDeleteChild(c.id)} currentMs={currentMs}/>
                  {i < children.length - 1 && insertLabel && (
                    <InsertGap label={'Insert ' + insertLabel} onClick={() => onInsertGroup(i + 1)}/>
                  )}
                </React.Fragment>
              ))}
            </div>
          )}
        </div>

        {(node.type === 'sentence' || node.type === 'paragraph' || node.type === 'section') && onRemoveGroup && (
          <div className="detail-section">
            <div className="detail-section-title">Danger zone</div>
            <button className="le-btn" style={{ color: 'var(--red)', borderColor: '#4a2a30' }} onClick={onRemoveGroup}>
              <LEIcon name="trash" size={11}/> Ungroup (merge into previous {node.type})
            </button>
          </div>
        )}

        <div className="detail-section">
          <div className="detail-section-title">Text preview</div>
          <div className="detail-text-preview">{node.text}</div>
        </div>
      </>
    );
  }

  // ─── Transport ───────────────────────────────────────────────

  function LETransport({ root, playing, currentMs, duration, hasAudio, onPlayToggle, onSeek, onStep }) {
    const scrubRef = useRef(null);

    const bars = useMemo(() => {
      const N = 200;
      const arr = [];
      for (let i = 0; i < N; i++) {
        const t = (i / N) * duration;
        let amp = 0.15;
        leWalk(root, (n) => {
          if (n.type === 'word' && t >= n.start_ms && t <= n.end_ms) {
            amp = Math.max(amp, 0.55 + Math.sin(i * 0.7) * 0.2 + (n.text.length % 4) * 0.08);
          } else if (n.type === 'section' && t >= n.start_ms && t <= n.end_ms) {
            amp = Math.max(amp, n.kind === 'chorus' ? 0.8 + Math.sin(i * 1.3) * 0.15 : 0.35 + Math.sin(i * 0.9) * 0.1);
          }
        });
        arr.push(Math.min(1, amp));
      }
      return arr;
    }, [root, duration]);

    const sections = useMemo(() => (root.children || []).filter(s => s.type === 'section'), [root]);

    const handleClick = (e) => {
      const rect = scrubRef.current.getBoundingClientRect();
      const frac = (e.clientX - rect.left) / rect.width;
      onSeek(Math.max(0, Math.min(duration, frac * duration)));
    };

    const playheadPct = duration > 0 ? (currentMs / duration) * 100 : 0;

    return (
      <div className="le-transport">
        <div className="transport-controls">
          <button className="transport-btn" onClick={() => onStep(-5000)} title="Back 5s"><LEIcon name="skip-back" size={14}/></button>
          <button className="transport-btn play" onClick={onPlayToggle} title={playing ? 'Pause' : 'Play'}>
            <LEIcon name={playing ? 'pause' : 'play'} size={16}/>
          </button>
          <button className="transport-btn" onClick={() => onStep(5000)} title="Forward 5s"><LEIcon name="skip-fwd" size={14}/></button>
        </div>

        <div className="transport-scrub" ref={scrubRef} onClick={handleClick}>
          <div className="scrub-wave">
            {bars.map((a, i) => (
              <div key={i}
                className={'scrub-wave-bar' + (((i / bars.length) * duration) <= currentMs ? ' active' : '')}
                style={{ height: (a * 80 + 8) + '%' }}
              />
            ))}
          </div>
          <div className="scrub-markers">
            {sections.map(s => {
              const left = duration > 0 ? (s.start_ms / duration) * 100 : 0;
              return (
                <React.Fragment key={s.id}>
                  <div className="scrub-marker" style={{ left: left + '%' }}/>
                  <div className="scrub-marker-label" style={{ left: `calc(${left}% + 4px)` }}>{s.label}</div>
                </React.Fragment>
              );
            })}
          </div>
          <div className="scrub-playhead" style={{ left: playheadPct + '%' }}/>
        </div>

        <div className="transport-time mono">
          <div>{leFmtTime(currentMs)}</div>
          <div className="total">/ {leFmtTime(duration)}</div>
          <div className={'transport-audio-badge' + (hasAudio ? ' has-audio' : ' no-audio')}
               title={hasAudio ? '실제 음원 재생 중' : '음원 없음 — 업로드 단계에서 파일을 올려주세요'}>
            {hasAudio ? '♪' : '○'}
          </div>
        </div>
      </div>
    );
  }

  // ─── Main LyricEditor Component ───────────────────────────────

  function LyricEditor({ song, onSave, onExit }) {
    const [doc, setDoc] = useState(() => {
      if (song.lyricTimeline) return leNormalizeHierarchy(song.lyricTimeline);
      if (song.cues && song.cues.length > 0) return leNormalizeHierarchy(cuesToTimeline(song));
      return leNormalizeHierarchy(cuesToTimeline({ ...song, cues: [] }));
    });

    const [selectedId, setSelectedId] = useState(() => doc.root.id);
    const [expanded, setExpanded] = useState(() => {
      const s = new Set([doc.root.id]);
      (doc.root.children || []).forEach(c => s.add(c.id));
      return s;
    });
    const [playing, setPlaying] = useState(false);
    const [currentMs, setCurrentMs] = useState(0);
    const [dirty, setDirty] = useState(false);
    const [toast, setToast] = useState(null);
    const [history, setHistory] = useState([]);
    const [future, setFuture] = useState([]);

    const duration = doc.media?.duration_ms || doc.root.end_ms || 60000;
    const audioRef = useRef(null);
    const audioUrl = useMemo(() => (window.songAudioUrls || new Map()).get(song.id) || null, [song.id]);

    // ── 실제 오디오: timeupdate → currentMs 동기화
    useEffect(() => {
      const audio = audioRef.current;
      if (!audio || !audioUrl) return;
      const onTime   = () => setCurrentMs(Math.round(audio.currentTime * 1000));
      const onEnded  = () => setPlaying(false);
      audio.addEventListener('timeupdate', onTime);
      audio.addEventListener('ended', onEnded);
      return () => {
        audio.removeEventListener('timeupdate', onTime);
        audio.removeEventListener('ended', onEnded);
      };
    }, [audioUrl]);

    // ── 실제 오디오: playing 상태 → play/pause
    useEffect(() => {
      const audio = audioRef.current;
      if (!audio || !audioUrl) return;
      if (playing) {
        const p = audio.play();
        if (p && p.catch) p.catch(() => setPlaying(false));
      } else {
        audio.pause();
      }
    }, [playing, audioUrl]);

    // ── 음원이 사라지면 재생 상태 해제
    useEffect(() => {
      if (!audioUrl && playing) setPlaying(false);
    }, [playing, audioUrl]);

    // seek + 오디오 sync
    const seekTo = (ms) => {
      const clamped = Math.max(0, Math.min(duration, Math.round(ms)));
      setCurrentMs(clamped);
      const audio = audioRef.current;
      if (audio && audioUrl) audio.currentTime = clamped / 1000;
    };

    const ensureAudioForPlayback = () => {
      if (audioUrl) return true;
      showToast('mp3 파일이 없습니다. 먼저 음원을 업로드하세요.');
      return false;
    };

    const togglePlayback = () => {
      if (playing) {
        setPlaying(false);
        return;
      }
      if (!ensureAudioForPlayback()) return;
      setPlaying(true);
    };

    // seek 후 즉시 재생
    const seekPlay = (ms) => {
      if (!ensureAudioForPlayback()) return;
      seekTo(ms);
      setPlaying(true);
    };

    const { node: selected, chain } = useMemo(() => {
      const r = leFindNode(doc.root, selectedId);
      return r || { node: doc.root, chain: [] };
    }, [doc, selectedId]);

    const playingIds = useMemo(() => {
      const s = new Set();
      leWalk(doc.root, (n) => {
        if (leNodeHasPlayableContent(n) && currentMs >= n.start_ms && currentMs <= n.end_ms) s.add(n.id);
      });
      return s;
    }, [doc, currentMs]);

    const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(null), 1800); };

    const buildSavedTimeline = () => {
      const next = leNormalizeHierarchy(doc);
      leRecalc(next.root);
      return next;
    };

    const buildSavedSong = () => {
      const lyricTimeline = buildSavedTimeline();
      const cues = window.SongfilmAPI.flattenTimelineToCues(lyricTimeline);
      return { ...song, lyricTimeline, cues };
    };

    const commit = (newDoc, message) => {
      setHistory(h => [...h.slice(-30), doc]);
      setFuture([]);
      setDoc(newDoc);
      setDirty(true);
      if (message) showToast(message);
    };

    const undo = () => {
      if (!history.length) return;
      setFuture(f => [doc, ...f]);
      setDoc(history[history.length - 1]);
      setHistory(h => h.slice(0, -1));
      showToast('Undone');
    };

    const redo = () => {
      if (!future.length) return;
      setHistory(h => [...h, doc]);
      setDoc(future[0]);
      setFuture(f => f.slice(1));
      showToast('Redone');
    };

    const updateNode = (updated) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, updated.id);
      if (!found) return;
      const original = { ...found.node };
      Object.assign(found.node, updated);
      if (found.node.type === 'word') {
        const line = found.chain[found.chain.length - 1];
        const idx = line?.children?.findIndex(w => w.id === found.node.id) ?? -1;
        const prevWord = idx > 0 ? line.children[idx - 1] : null;
        const nextWord = idx >= 0 && idx < line.children.length - 1 ? line.children[idx + 1] : null;

        let startMs = found.node.start_ms;
        let endMs = found.node.end_ms;
        if (prevWord) startMs = Math.max(prevWord.start_ms + 1, startMs);
        if (nextWord) endMs = Math.min(nextWord.end_ms - 1, endMs);
        if (endMs <= startMs) {
          if (prevWord && updated.start_ms !== original.start_ms) startMs = Math.max(prevWord.start_ms + 1, endMs - 1);
          else endMs = startMs + 1;
        }

        found.node.start_ms = startMs;
        found.node.end_ms = endMs;

        if (prevWord && updated.start_ms != null && updated.start_ms !== original.start_ms) {
          prevWord.end_ms = Math.max(prevWord.start_ms, startMs - 1);
        }
        if (nextWord && updated.end_ms != null && updated.end_ms !== original.end_ms) {
          nextWord.start_ms = Math.min(nextWord.end_ms, endMs + 1);
        }
      }
      leRecalc(next.root);
      commit(next);
    };

    const deleteWord = (wordId) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, wordId);
      if (!found || found.node.type !== 'word') return;
      const line = found.chain[found.chain.length - 1];
      const wordIdx = line.children.findIndex(w => w.id === wordId);
      const deletedWord = line.children[wordIdx];
      if (wordIdx === 0 && line.children.length > 1) {
        line.children[1].start_ms = deletedWord.start_ms;
      } else if (wordIdx > 0) {
        line.children[wordIdx - 1].end_ms = deletedWord.end_ms;
      }
      line.children = line.children.filter(w => w.id !== wordId);
      if (line.children.length === 0) {
        const sentence = found.chain[found.chain.length - 2];
        if (sentence) {
          sentence.children = sentence.children.filter(l => l.id !== line.id);
          leRelabelLineNos(sentence);
        }
      } else {
        leResequenceLine(line);
      }
      leRecalc(next.root);
      commit(next, 'Word deleted');
    };

    const addWordAt = (lineId, atIdx) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found || found.node.type !== 'line') return;
      const line = found.node;
      const prevW = line.children[atIdx - 1];
      const nextW = line.children[atIdx];
      const startMs = prevW ? prevW.end_ms + 1 : line.start_ms;
      const rawEnd = nextW ? nextW.start_ms - 1 : startMs + 500;
      const endMs = Math.max(startMs + 50, Math.min(rawEnd, startMs + 500));
      const newWord = {
        id: leNewId('word', lineId), type: 'word', seq: 0,
        text: 'new', start_ms: startMs, end_ms: endMs, confidence: 1.0,
      };
      line.children.splice(atIdx, 0, newWord);
      leResequenceLine(line);
      leRecalc(next.root);
      commit(next, 'Word inserted');
      setSelectedId(newWord.id);
    };

    const addWordAdjacent = (wordId, where) => {
      const found = leFindNode(doc.root, wordId);
      if (!found || found.node.type !== 'word') return;
      const line = found.chain[found.chain.length - 1];
      const idx = line.children.findIndex(w => w.id === wordId);
      addWordAt(line.id, where === 'before' ? idx : idx + 1);
    };

    const moveWordToAdjacentLine = (wordId, direction) => {
      const next = leClone(doc);
      const allLines = leFlattenByType(next.root, 'line').filter(l => !leIsCommentLine(l));
      let currentLine = null, wordIdx = -1, lineIdx = -1;
      for (let li = 0; li < allLines.length; li++) {
        const wi = allLines[li].children.findIndex(w => w.id === wordId);
        if (wi >= 0) { currentLine = allLines[li]; wordIdx = wi; lineIdx = li; break; }
      }
      if (!currentLine) return;
      const targetLine = direction === 'prev' ? allLines[lineIdx - 1] : allLines[lineIdx + 1];
      if (!targetLine) { showToast(`No ${direction === 'prev' ? 'previous' : 'next'} line`); return; }
      const movedWords = direction === 'prev'
        ? currentLine.children.splice(0, wordIdx + 1)
        : currentLine.children.splice(wordIdx);
      if (!movedWords.length) return;
      if (direction === 'prev') targetLine.children.push(...movedWords);
      else targetLine.children.unshift(...movedWords);
      if (currentLine.children.length === 0) {
        const found = leFindNode(next.root, currentLine.id);
        const sentence = found.chain[found.chain.length - 1];
        if (sentence) { sentence.children = sentence.children.filter(l => l.id !== currentLine.id); leRelabelLineNos(sentence); }
      } else {
        leResequenceLine(currentLine);
      }
      leResequenceLine(targetLine);
      const tFound = leFindNode(next.root, targetLine.id);
      if (tFound) leRelabelLineNos(tFound.chain[tFound.chain.length - 1]);
      leRecalc(next.root);
      commit(next, `${movedWords.length} word${movedWords.length > 1 ? 's' : ''} moved to ${direction === 'prev' ? 'previous' : 'next'} line`);
      setSelectedId(wordId);
    };

    const canMovePrev = useMemo(() => {
      if (!selected || selected.type !== 'word') return false;
      const lines = leFlattenByType(doc.root, 'line').filter(l => !leIsCommentLine(l));
      for (let li = 0; li < lines.length; li++) {
        if (lines[li].children.some(w => w.id === selected.id)) return li > 0;
      }
      return false;
    }, [selected, doc]);

    const canMoveNext = useMemo(() => {
      if (!selected || selected.type !== 'word') return false;
      const lines = leFlattenByType(doc.root, 'line').filter(l => !leIsCommentLine(l));
      for (let li = 0; li < lines.length; li++) {
        if (lines[li].children.some(w => w.id === selected.id)) return li < lines.length - 1;
      }
      return false;
    }, [selected, doc]);

    const splitLineAt = (lineId, atIdx) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found) return;
      const line = found.node;
      const sentence = found.chain[found.chain.length - 1];
      if (atIdx <= 0 || atIdx >= line.children.length) return;
      const leftWords = line.children.slice(0, atIdx);
      const rightWords = line.children.slice(atIdx);
      line.children = leftWords;
      leResequenceLine(line);
      const newLine = {
        id: leNewId('line', sentence.id), type: 'line',
        line_no: (line.line_no || 1) + 1,
        children: rightWords, text: '', start_ms: 0, end_ms: 0,
      };
      leResequenceLine(newLine);
      const idx = sentence.children.findIndex(l => l.id === line.id);
      sentence.children.splice(idx + 1, 0, newLine);
      leRelabelLineNos(sentence);
      leRecalc(next.root);
      commit(next, 'Line split');
    };

    const forceApplyLineTiming = (lineId, startMs, endMs) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found || found.node.type !== 'line' || leIsCommentLine(found.node)) return;
      const line = found.node;
      const words = line.children || [];
      if (!words.length) {
        showToast('이 line에는 아직 word가 없습니다.');
        return;
      }

      const start = Math.max(0, Math.round(startMs));
      const minDiff = Math.max(1, words.length - 1);
      const requestedEnd = Math.round(endMs);
      const end = Math.max(requestedEnd, start + minDiff);
      const totalCoveredMs = end - start + 1;
      const baseDuration = Math.floor(totalCoveredMs / words.length);
      const remainder = totalCoveredMs % words.length;

      let cursor = start;
      words.forEach((word, index) => {
        const durationMs = baseDuration + (index < remainder ? 1 : 0);
        word.start_ms = cursor;
        word.end_ms = index === words.length - 1 ? end : cursor + durationMs - 1;
        cursor = word.end_ms + 1;
      });

      leResequenceLine(line);
      leRecalc(next.root);
      commit(next, requestedEnd < start + minDiff ? 'Line timing redistributed (range extended)' : 'Line timing redistributed');
    };

    const moveCommentLine = (lineId, direction) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found || found.node.type !== 'line' || !leIsCommentLine(found.node)) return;

      const line = found.node;
      const currentSentence = found.chain[found.chain.length - 1];
      if (!currentSentence || currentSentence.type !== 'sentence') return;

      const sentences = leFlattenByType(next.root, 'sentence');
      const sentenceIndex = sentences.findIndex(sentence => sentence.id === currentSentence.id);
      const lineIndex = currentSentence.children.findIndex(child => child.id === line.id);
      if (lineIndex < 0) return;

      let targetSentence = currentSentence;
      let targetIndex = lineIndex;
      const [movedLine] = currentSentence.children.splice(lineIndex, 1);

      if (direction === 'prev-line') {
        if (lineIndex <= 0) {
          currentSentence.children.splice(lineIndex, 0, movedLine);
          return;
        }
        targetIndex = lineIndex - 1;
        currentSentence.children.splice(targetIndex, 0, movedLine);
      } else if (direction === 'next-line') {
        if (lineIndex >= currentSentence.children.length) {
          currentSentence.children.splice(lineIndex, 0, movedLine);
          return;
        }
        targetIndex = lineIndex + 1;
        currentSentence.children.splice(targetIndex, 0, movedLine);
      } else if (direction === 'prev-sentence') {
        if (sentenceIndex <= 0) {
          currentSentence.children.splice(lineIndex, 0, movedLine);
          return;
        }
        targetSentence = sentences[sentenceIndex - 1];
        targetSentence.children = targetSentence.children || [];
        targetIndex = targetSentence.children.length;
        targetSentence.children.splice(targetIndex, 0, movedLine);
      } else if (direction === 'next-sentence') {
        if (sentenceIndex < 0 || sentenceIndex >= sentences.length - 1) {
          currentSentence.children.splice(lineIndex, 0, movedLine);
          return;
        }
        targetSentence = sentences[sentenceIndex + 1];
        targetSentence.children = targetSentence.children || [];
        targetIndex = 0;
        targetSentence.children.splice(targetIndex, 0, movedLine);
      } else {
        currentSentence.children.splice(lineIndex, 0, movedLine);
        return;
      }

      leNormalizeSentenceLines(currentSentence, currentSentence.start_ms || 0);
      if (targetSentence.id !== currentSentence.id) {
        leNormalizeSentenceLines(targetSentence, targetSentence.start_ms || currentSentence.start_ms || 0);
      }
      leRecalc(next.root);
      commit(next, 'Comment line moved');
      setSelectedId(movedLine.id);
      setExpanded(e => new Set([...e, currentSentence.id, targetSentence.id, movedLine.id]));
    };

    const moveLineToAdjacentSentence = (lineId, direction) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found || found.node.type !== 'line') return;

      const line = found.node;
      const currentSentence = found.chain[found.chain.length - 1];
      if (!currentSentence || currentSentence.type !== 'sentence') return;

      const sentences = leFlattenByType(next.root, 'sentence');
      const sentenceIndex = sentences.findIndex(sentence => sentence.id === currentSentence.id);
      const lineIndex = currentSentence.children.findIndex(child => child.id === line.id);
      if (sentenceIndex < 0 || lineIndex < 0) return;

      const isComment = leIsCommentLine(line);
      if (!isComment) {
        const lyricLines = currentSentence.children.filter(child => child.type === 'line' && !leIsCommentLine(child));
        const lyricIndex = lyricLines.findIndex(child => child.id === line.id);
        if (direction === 'prev' && lyricIndex !== 0) return;
        if (direction === 'next' && lyricIndex !== lyricLines.length - 1) return;
      }

      const targetSentence = direction === 'prev'
        ? sentences[sentenceIndex - 1]
        : sentences[sentenceIndex + 1];
      if (!targetSentence) return;

      const [movedLine] = currentSentence.children.splice(lineIndex, 1);
      targetSentence.children = targetSentence.children || [];

      let insertIndex = targetSentence.children.length;
      if (direction === 'next') {
        if (isComment) {
          insertIndex = 0;
        } else {
          const firstLyricIndex = targetSentence.children.findIndex(child => child.type === 'line' && !leIsCommentLine(child));
          insertIndex = firstLyricIndex >= 0 ? firstLyricIndex : targetSentence.children.length;
        }
      }
      targetSentence.children.splice(insertIndex, 0, movedLine);

      leNormalizeSentenceLines(currentSentence, currentSentence.start_ms || 0);
      leNormalizeSentenceLines(targetSentence, targetSentence.start_ms || currentSentence.start_ms || 0);
      leRecalc(next.root);
      commit(next, isComment ? 'Comment line moved to adjacent sentence' : 'Line moved to adjacent sentence');
      setSelectedId(movedLine.id);
      setExpanded(e => new Set([...e, currentSentence.id, targetSentence.id, movedLine.id]));
    };

    const moveSentenceToAdjacentParagraph = (sentenceId, direction) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, sentenceId);
      if (!found || found.node.type !== 'sentence') return;

      const sentence = found.node;
      const currentParagraph = found.chain[found.chain.length - 1];
      if (!currentParagraph || currentParagraph.type !== 'paragraph') return;

      const paragraphs = leFlattenByType(next.root, 'paragraph');
      const paragraphIndex = paragraphs.findIndex(paragraph => paragraph.id === currentParagraph.id);
      const sentenceSiblings = currentParagraph.children.filter(child => child.type === 'sentence');
      const sentenceIndex = sentenceSiblings.findIndex(child => child.id === sentence.id);
      if (paragraphIndex < 0 || sentenceIndex < 0) return;

      let targetParagraph = null;
      if (direction === 'prev') {
        if (sentenceIndex !== 0) return;
        targetParagraph = paragraphs[paragraphIndex - 1];
      } else if (direction === 'next') {
        if (sentenceIndex !== sentenceSiblings.length - 1) return;
        targetParagraph = paragraphs[paragraphIndex + 1];
      }
      if (!targetParagraph) return;

      const currentIndex = currentParagraph.children.findIndex(child => child.id === sentence.id);
      const [movedSentence] = currentParagraph.children.splice(currentIndex, 1);
      targetParagraph.children = targetParagraph.children || [];
      if (direction === 'prev') targetParagraph.children.push(movedSentence);
      else targetParagraph.children.unshift(movedSentence);

      leRecalc(next.root);
      commit(next, 'Sentence moved to adjacent paragraph');
      setSelectedId(movedSentence.id);
      setExpanded(e => new Set([...e, currentParagraph.id, targetParagraph.id, movedSentence.id]));
    };

    const appendChild = (parentId, { comment = false } = {}) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, parentId);
      if (!found) return;
      const parent = found.node;
      parent.children = parent.children || [];

      const baseStart = Number.isFinite(parent.start_ms) ? parent.start_ms : 0;
      const baseEnd = Number.isFinite(parent.end_ms) ? Math.max(parent.end_ms, baseStart + 1000) : baseStart + 4000;
      const makeRange = () => ({ start_ms: baseStart, end_ms: baseEnd });
      let newChild = null;

      if (parent.type === 'song') {
        newChild = {
          id: leNewId('section', parentId),
          type: 'section',
          kind: 'verse',
          label: `Section ${parent.children.length + 1}`,
          text: '',
          children: [],
          ...makeRange(),
        };
      } else if (parent.type === 'section') {
        const paragraphCount = parent.children.filter(c => c.type === 'paragraph').length;
        newChild = {
          id: leNewId('paragraph', parentId),
          type: 'paragraph',
          label: String.fromCharCode(65 + (paragraphCount % 26)),
          text: '',
          children: [],
          ...makeRange(),
        };
      } else if (parent.type === 'paragraph') {
        newChild = {
          id: leNewId('sentence', parentId),
          type: 'sentence',
          text: '',
          children: [],
          ...makeRange(),
        };
      } else if (parent.type === 'sentence') {
        const lastChild = parent.children[parent.children.length - 1];
        const anchorMs = lastChild ? lastChild.end_ms : baseStart;
        newChild = {
          id: leNewId('line', parentId),
          type: 'line',
          line_no: parent.children.length + 1,
          ...(comment ? { kind: 'comment' } : {}),
          text: '',
          children: [],
          start_ms: anchorMs,
          end_ms: anchorMs,
        };
      }
      if (!newChild) return;

      if (parent.type === 'sentence' && comment) {
        parent.children.unshift(newChild);
        leNormalizeSentenceLines(parent, parent.start_ms || baseStart);
      } else {
        parent.children.push(newChild);
      }
      if (parent.type === 'sentence' && !comment) leRelabelLineNos(parent);
      leRecalc(next.root);
      commit(next, comment ? 'Comment line added' : `New ${newChild.type} added`);
      setSelectedId(newChild.id);
      setExpanded(e => new Set([...e, parent.id, newChild.id]));
    };

    const insertGroupAt = (parentId, childIdx) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, parentId);
      if (!found) return;
      const parent = found.node;
      const grandparent = found.chain[found.chain.length - 1];
      if (!grandparent) return;
      if (childIdx <= 0 || childIdx >= parent.children.length) return;
      const newType = parent.type;
      const moved = parent.children.splice(childIdx);
      const newNode = {
        id: leNewId(newType, grandparent.id), type: newType,
        children: moved, start_ms: 0, end_ms: 0, text: '',
        ...(newType === 'paragraph' ? { label: String.fromCharCode(65 + (grandparent.children.length % 26)) }
          : newType === 'section' ? { kind: 'verse', label: 'New Section' }
          : {}),
      };
      if (newType === 'sentence') moved.forEach((l, i) => { l.line_no = i + 1; });
      const pIdx = grandparent.children.findIndex(c => c.id === parent.id);
      grandparent.children.splice(pIdx + 1, 0, newNode);
      leRecalc(next.root);
      commit(next, `New ${newType} created`);
      setSelectedId(newNode.id);
      setExpanded(e => new Set([...e, newNode.id]));
    };

    const ungroup = (nodeId) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, nodeId);
      if (!found) return;
      const node = found.node;
      const parent = found.chain[found.chain.length - 1];
      if (!parent) return;
      const idx = parent.children.findIndex(c => c.id === node.id);
      if (idx <= 0) { showToast('Cannot ungroup the first item'); return; }
      const prev = parent.children[idx - 1];
      prev.children = [...(prev.children || []), ...(node.children || [])];
      parent.children.splice(idx, 1);
      if (prev.type === 'sentence') leRelabelLineNos(prev);
      leRecalc(next.root);
      commit(next, 'Ungrouped');
      setSelectedId(prev.id);
    };

    const deleteChild = (parentId, childId) => {
      const next = leClone(doc);
      const pFound = leFindNode(next.root, parentId);
      if (!pFound) return;
      const parent = pFound.node;
      const child = parent.children.find(c => c.id === childId);
      if (!child) return;
      if (child.children && child.children.length && parent.children.length > 1) {
        const idx = parent.children.findIndex(c => c.id === childId);
        if (idx > 0) {
          const prev = parent.children[idx - 1];
          prev.children = [...(prev.children || []), ...child.children];
          parent.children.splice(idx, 1);
        } else {
          const nextSib = parent.children[1];
          nextSib.children = [...child.children, ...(nextSib.children || [])];
          parent.children.splice(idx, 1);
        }
      } else {
        parent.children = parent.children.filter(c => c.id !== childId);
      }
      if (parent.type === 'sentence') leRelabelLineNos(parent);
      leRecalc(next.root);
      commit(next, 'Deleted');
      setSelectedId(parent.id);
    };

    const fullRecalc = () => {
      const next = leClone(doc);
      const idMap = leReassignIds(next.root);
      leNormalizeLineGaps(next.root);
      leRecalc(next.root);
      setSelectedId(prev => idMap.get(prev) ?? prev);
      setExpanded(prev => {
        const updated = new Set();
        prev.forEach(id => updated.add(idMap.get(id) ?? id));
        return updated;
      });
      commit(next, 'IDs re-assigned & timestamps re-synced');
    };

    const toggleExpand = (id) => {
      setExpanded(e => { const n = new Set(e); if (n.has(id)) n.delete(id); else n.add(id); return n; });
    };
    const expandAll = () => { const s = new Set(); leWalk(doc.root, n => { if (n.children) s.add(n.id); }); setExpanded(s); };
    const collapseAll = () => setExpanded(new Set([doc.root.id]));

    // Auto-expand ancestors on select
    useEffect(() => {
      const r = leFindNode(doc.root, selectedId);
      if (!r) return;
      setExpanded(e => { const n = new Set(e); r.chain.forEach(c => n.add(c.id)); return n; });
    }, [selectedId]);

    // Keyboard shortcuts
    useEffect(() => {
      const handler = (e) => {
        const ae = document.activeElement;
        if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return;
        if (e.code === 'Space') { e.preventDefault(); togglePlayback(); }
        else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
        else if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redo(); }
        else if (e.key === 'ArrowLeft') seekTo(currentMs - 1000);
        else if (e.key === 'ArrowRight') seekTo(currentMs + 1000);
      };
      window.addEventListener('keydown', handler);
      return () => window.removeEventListener('keydown', handler);
    }, [history, future, doc, duration, currentMs, playing, audioUrl]);

    const selectedCanSeek = useMemo(() => leNodeCanSeek(selected), [selected]);
    const sentenceNodes = useMemo(() => leFlattenByType(doc.root, 'sentence'), [doc]);
    const paragraphNodes = useMemo(() => leFlattenByType(doc.root, 'paragraph'), [doc]);
    const selectedSentence = useMemo(() => {
      if (!selected) return null;
      if (selected.type === 'sentence') return selected;
      return [...chain].reverse().find(n => n.type === 'sentence') || null;
    }, [selected, chain]);
    const selectedParagraph = useMemo(() => {
      if (!selected) return null;
      if (selected.type === 'paragraph') return selected;
      return [...chain].reverse().find(n => n.type === 'paragraph') || null;
    }, [selected, chain]);
    const lineSiblings = useMemo(() => {
      if (selected?.type !== 'line' || !selectedSentence?.children) return [];
      return selectedSentence.children.filter(child => child.type === 'line');
    }, [selected, selectedSentence]);
    const lyricLineSiblings = useMemo(() => {
      if (selected?.type !== 'line' || leIsCommentLine(selected) || !selectedSentence?.children) return [];
      return selectedSentence.children.filter(child => child.type === 'line' && !leIsCommentLine(child));
    }, [selected, selectedSentence]);
    const selectedLineIndex = useMemo(() => {
      if (selected?.type !== 'line') return -1;
      return lineSiblings.findIndex(line => line.id === selected.id);
    }, [selected, lineSiblings]);
    const selectedLyricLineIndex = useMemo(() => {
      if (selected?.type !== 'line' || leIsCommentLine(selected)) return -1;
      return lyricLineSiblings.findIndex(line => line.id === selected.id);
    }, [selected, lyricLineSiblings]);
    const selectedSentenceIndex = useMemo(() => {
      if (!selectedSentence) return -1;
      return sentenceNodes.findIndex(sentence => sentence.id === selectedSentence.id);
    }, [sentenceNodes, selectedSentence]);
    const sentenceSiblings = useMemo(() => {
      if (!selectedSentence || !selectedParagraph?.children) return [];
      return selectedParagraph.children.filter(child => child.type === 'sentence');
    }, [selectedSentence, selectedParagraph]);
    const selectedSentenceIndexInParagraph = useMemo(() => {
      if (!selectedSentence) return -1;
      return sentenceSiblings.findIndex(sentence => sentence.id === selectedSentence.id);
    }, [selectedSentence, sentenceSiblings]);
    const selectedParagraphIndex = useMemo(() => {
      if (!selectedParagraph) return -1;
      return paragraphNodes.findIndex(paragraph => paragraph.id === selectedParagraph.id);
    }, [paragraphNodes, selectedParagraph]);
    const prevLineTarget = selectedLineIndex > 0 ? lineSiblings[selectedLineIndex - 1] : null;
    const nextLineTarget = selectedLineIndex >= 0 && selectedLineIndex < lineSiblings.length - 1 ? lineSiblings[selectedLineIndex + 1] : null;
    const prevSentenceTarget = selectedSentenceIndex > 0 ? sentenceNodes[selectedSentenceIndex - 1] : null;
    const nextSentenceTarget = selectedSentenceIndex >= 0 && selectedSentenceIndex < sentenceNodes.length - 1 ? sentenceNodes[selectedSentenceIndex + 1] : null;
    const prevParagraphTarget = selectedParagraphIndex > 0 ? paragraphNodes[selectedParagraphIndex - 1] : null;
    const nextParagraphTarget = selectedParagraphIndex >= 0 && selectedParagraphIndex < paragraphNodes.length - 1 ? paragraphNodes[selectedParagraphIndex + 1] : null;
    const canMoveLinePrevSentence = selected?.type === 'line' && (
      leIsCommentLine(selected)
        ? !!prevSentenceTarget
        : selectedLyricLineIndex === 0 && !!prevSentenceTarget
    );
    const canMoveLineNextSentence = selected?.type === 'line' && (
      leIsCommentLine(selected)
        ? !!nextSentenceTarget
        : selectedLyricLineIndex >= 0 && selectedLyricLineIndex === lyricLineSiblings.length - 1 && !!nextSentenceTarget
    );
    const canMoveSentencePrevParagraph = selected?.type === 'sentence'
      && selectedSentenceIndexInParagraph === 0
      && !!prevParagraphTarget;
    const canMoveSentenceNextParagraph = selected?.type === 'sentence'
      && selectedSentenceIndexInParagraph >= 0
      && selectedSentenceIndexInParagraph === sentenceSiblings.length - 1
      && !!nextParagraphTarget;

    const wordSiblings = useMemo(() => {
      if (!selected || selected.type !== 'word') return [];
      const parent = chain[chain.length - 1];
      return parent?.children || [];
    }, [selected, chain]);

    const renderDetail = () => {
      if (!selected) return <div className="le-empty">Select something to edit</div>;
      if (selected.type === 'word') {
        return (
          <WordDetail
            node={selected} siblings={wordSiblings} currentMs={currentMs}
            onUpdate={updateNode}
            onAddBefore={() => addWordAdjacent(selected.id, 'before')}
            onAddAfter={() => addWordAdjacent(selected.id, 'after')}
            onDelete={() => deleteWord(selected.id)}
            onMovePrev={() => moveWordToAdjacentLine(selected.id, 'prev')}
            onMoveNext={() => moveWordToAdjacentLine(selected.id, 'next')}
            canMovePrev={canMovePrev} canMoveNext={canMoveNext}
          />
        );
      }
      if (selected.type === 'line') {
        return (
          <LineDetail
            node={selected} currentMs={currentMs}
            onUpdate={updateNode}
            onAddWordAt={(atIdx) => addWordAt(selected.id, atIdx)}
            onDeleteWord={deleteWord}
            onSplitLineAt={(atIdx) => splitLineAt(selected.id, atIdx)}
            onSelectWord={setSelectedId}
            onSeekPlay={seekPlay}
            onForceApplyTiming={(startMs, endMs) => forceApplyLineTiming(selected.id, startMs, endMs)}
            onMovePrevLine={() => moveCommentLine(selected.id, 'prev-line')}
            onMoveNextLine={() => moveCommentLine(selected.id, 'next-line')}
            onMovePrevSentence={() => moveLineToAdjacentSentence(selected.id, 'prev')}
            onMoveNextSentence={() => moveLineToAdjacentSentence(selected.id, 'next')}
            canMovePrevLine={!!prevLineTarget}
            canMoveNextLine={!!nextLineTarget}
            canMovePrevSentence={canMoveLinePrevSentence}
            canMoveNextSentence={canMoveLineNextSentence}
          />
        );
      }
      return (
        <ContainerDetail
          node={selected} currentMs={currentMs}
          onSelect={setSelectedId}
          onDeleteChild={(childId) => deleteChild(selected.id, childId)}
          onInsertGroup={(atIdx) => insertGroupAt(selected.id, atIdx)}
          onAddChild={() => appendChild(selected.id)}
          onAddCommentLine={selected.type === 'sentence' ? () => appendChild(selected.id, { comment: true }) : null}
          onMovePrevParagraph={selected.type === 'sentence' ? () => moveSentenceToAdjacentParagraph(selected.id, 'prev') : null}
          onMoveNextParagraph={selected.type === 'sentence' ? () => moveSentenceToAdjacentParagraph(selected.id, 'next') : null}
          canMovePrevParagraph={selected.type === 'sentence' ? canMoveSentencePrevParagraph : false}
          canMoveNextParagraph={selected.type === 'sentence' ? canMoveSentenceNextParagraph : false}
          onRemoveGroup={selected.type !== 'song' ? () => ungroup(selected.id) : null}
        />
      );
    };

    const downloadCurrentTimeline = () => {
      const lyricTimeline = buildSavedTimeline();
      const filename = `${(doc.media?.title || song.title || 'song').replace(/[^\w가-힣\- ]/g, '_')}.lyric-timeline.json`;
      const blob = new Blob([JSON.stringify(lyricTimeline, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
      showToast(`"${filename}" 저장됨`);
    };

    const handleSaveAndExit = () => {
      onSave(buildSavedSong());
    };

    return (
      <div className="le-root">
        {audioUrl && <audio ref={audioRef} src={audioUrl} preload="auto" style={{display:'none'}}/>}
        {/* Topbar */}
        <div className="topbar">
          <div className="topbar-brand">
            <div className="topbar-brand-mark">L</div>
            가사 편집기
          </div>
          <div className="topbar-song">
            <LEIcon name="music" size={12}/>
            <span className="title">{doc.media?.title || '노래'}</span>
            {doc.media?.artist && <><span className="sep">·</span><span className="artist">{doc.media.artist}</span></>}
            <span className="sep">·</span>
            <span className="mono" style={{ color: 'var(--fg-2)', fontSize: 11 }}>{leFmtTimeShort(duration)}</span>
          </div>
          <div className="topbar-spacer"/>
          <div className={'topbar-status' + (dirty ? ' dirty' : '')}>
            <div className="dot"/>
            {dirty ? 'Unsaved' : 'Saved'}
          </div>
          <button className="topbar-btn" onClick={undo} disabled={!history.length}>
            <LEIcon name="undo" size={11}/> Undo
          </button>
          <button className="topbar-btn" onClick={redo} disabled={!future.length}>
            <LEIcon name="redo" size={11}/> Redo
          </button>
          <button className="topbar-btn" onClick={fullRecalc} title="Re-sync all parent timestamps">
            <LEIcon name="zap" size={11}/> Re-sync
          </button>
          <button className="topbar-btn" onClick={downloadCurrentTimeline}>
            <LEIcon name="download" size={11}/> Download
          </button>
          <button className="topbar-btn primary" onClick={handleSaveAndExit}>
            <LEIcon name="save" size={11}/> 저장 & 닫기
          </button>
          <button className="topbar-btn danger" onClick={onExit} title="Discard changes and exit">
            <LEIcon name="x" size={11}/> 닫기
          </button>
        </div>

        {/* Body */}
        <div className="le-body">
          <TreePanel
            root={doc.root}
            selectedId={selectedId}
            onSelect={setSelectedId}
            onSeek={seekTo}
            expanded={expanded}
            onToggle={toggleExpand}
            playingIds={playingIds}
            onExpandAll={expandAll}
            onCollapseAll={collapseAll}
          />
          <div className="panel detail">
            <div className="panel-header">
              <span>{selected?.type || '—'} details</span>
              <div className="panel-header-actions">
                <button className="panel-header-btn" title="Jump to start" onClick={() => selectedCanSeek && selected && seekTo(selected.start_ms)} disabled={!selectedCanSeek}>
                  <LEIcon name="skip-back" size={10}/>
                </button>
                <button className="panel-header-btn" title="Play from here (해당 위치부터 재생)" onClick={() => selectedCanSeek && selected && seekPlay(selected.start_ms)} disabled={!selectedCanSeek}>
                  <LEIcon name="play" size={10}/>
                </button>
              </div>
            </div>
            <div className="panel-body">
              <DetailHead node={selected} chain={chain} onSelect={setSelectedId}/>
              {selected?.type === 'line' && (
                <LineNavigation
                  prevLine={prevLineTarget}
                  nextLine={nextLineTarget}
                  prevSentence={prevSentenceTarget}
                  nextSentence={nextSentenceTarget}
                  onSelect={setSelectedId}
                />
              )}
              {renderDetail()}
            </div>
          </div>
        </div>

        {/* Transport */}
        <LETransport
          root={doc.root} playing={playing} currentMs={currentMs} duration={duration}
          hasAudio={!!audioUrl}
          onPlayToggle={togglePlayback}
          onSeek={seekTo}
          onStep={(d) => seekTo(currentMs + d)}
        />

        {toast && (
          <div className="le-toast">
            <LEIcon name="check" size={12}/> {toast}
          </div>
        )}
      </div>
    );
  }

  Object.assign(window, { LyricEditor });
})();
