// 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;
  const createPortal = typeof ReactDOM !== 'undefined' && ReactDOM.createPortal
    ? ReactDOM.createPortal.bind(ReactDOM)
    : null;

  // ─── 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);
  }

  /** MM:SS.mmm → 7 digits [mm×2, ss×2, mmm×3] */
  function leMsToDigitArray(ms) {
    if (ms == null || isNaN(ms)) return [0, 0, 0, 0, 0, 0, 0];
    const t = leFmtTime(Math.max(0, Math.round(ms)));
    const only = t.replace(/\D/g, '').slice(0, 7);
    const pad = (only + '0000000').slice(0, 7);
    return pad.split('').map(c => parseInt(c, 10) || 0);
  }

  function leDigitArrayToMs(digits) {
    const d = digits.length >= 7 ? digits : [...digits, ...Array(7 - digits.length).fill(0)];
    const mm = d[0] * 10 + d[1];
    const ss = d[2] * 10 + d[3];
    const mmm = d[4] * 100 + d[5] * 10 + d[6];
    return mm * 60000 + ss * 1000 + mmm;
  }

  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}`;
  }

  const LE_NARROW_SM_MQ = '(max-width: 640px)';
  function useLeNarrowSm() {
    const [narrow, setNarrow] = useState(() =>
      typeof window !== 'undefined' && window.matchMedia(LE_NARROW_SM_MQ).matches
    );
    useEffect(() => {
      if (typeof window === 'undefined') return undefined;
      const mq = window.matchMedia(LE_NARROW_SM_MQ);
      const on = () => setNarrow(mq.matches);
      on();
      mq.addEventListener('change', on);
      return () => mq.removeEventListener('change', on);
    }, []);
    return narrow;
  }

  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;
  }

  /** 모든 word를 문서 순서대로 모아 1 Section → 1 Paragraph → 1 Sentence → 1 Line 구조로 만든다. 코멘트 라인 등 word가 없는 노드는 사라진다. */
  function leConsolidateTimelineToSingleLine(doc) {
    const base = leClone(doc);
    const root = base.root;
    const words = [];
    leWalk(root, (n) => {
      if (n.type === 'word') words.push(leClone(n));
    });
    if (!words.length) return null;

    const sections = leFlattenByType(root, 'section');
    const sectionLabel = sections[0]?.label || base.media?.title || root.label || '노래';
    const startMs = Math.min(...words.map(w => w.start_ms ?? 0));
    const endMs = Math.max(...words.map(w => w.end_ms ?? w.start_ms ?? 0));

    const sectionId = leNewId('section', root.id);
    const parId = leNewId('paragraph', sectionId);
    const senId = leNewId('sentence', parId);
    const lineId = leNewId('line', senId);

    words.forEach((w, i) => {
      w.id = leNewId('word', lineId);
      w.seq = i + 1;
    });

    const lineText = words.map(w => w.text).filter(Boolean).join(' ').trim();
    const line = {
      id: lineId,
      type: 'line',
      line_no: 1,
      start_ms: startMs,
      end_ms: endMs,
      text: lineText,
      children: words,
    };

    const sentence = {
      id: senId,
      type: 'sentence',
      start_ms: startMs,
      end_ms: endMs,
      text: lineText,
      children: [line],
    };

    const paragraph = {
      id: parId,
      type: 'paragraph',
      label: 'A',
      start_ms: startMs,
      end_ms: endMs,
      text: lineText,
      children: [sentence],
    };

    const section = {
      id: sectionId,
      type: 'section',
      kind: 'verse',
      label: sectionLabel,
      start_ms: startMs,
      end_ms: endMs,
      text: lineText,
      children: [paragraph],
    };

    root.children = [section];
    root.start_ms = startMs;
    root.end_ms = endMs;
    root.text = lineText;

    return leNormalizeHierarchy(base);
  }

  // ─── 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 'merge':         return <svg {...c}><path d="M12 5l-4 3 4 3M4 5l4 3-4 3"/></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>;
      case 'copy-clipboard':
        return (
          <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" style={s}>
            <path d="M4.941 6.6c.518 0 .941.405.941.9v11.7h9.412c.518 0 .941.405.941.9s-.423.9-.94.9H5.881c-.517 0-.96-.18-1.327-.53A1.7 1.7 0 0 1 4 19.2V7.5c0-.495.424-.9.941-.9"/>
            <path fillRule="evenodd" d="M18.118 3q.775 0 1.329.53.552.528.553 1.27v10.8q0 .742-.553 1.27-.553.53-1.33.53h-8.47q-.776 0-1.329-.53a1.7 1.7 0 0 1-.553-1.27V4.8q0-.742.553-1.27Q8.87 3 9.648 3zm-8.47 12.6h8.47V4.8h-8.47z" clipRule="evenodd"/>
          </svg>
        );
      default: return null;
    }
  }

  // ─── TimestampTouchPicker (narrow view: full-screen / sheet) ──

  /**
   * @param {object} props
   * @param {boolean} props.open
   * @param {() => void} props.onClose
   * @param {boolean} props.useFullScreen
   * @param {string} props.fieldLabel
   * @param {number} props.initialPrimaryMs
   * @param {number} [props.min]
   * @param {number} [props.max]
   * @param {{ role: 'start'|'end', peerMs: number, initialStartMs: number, initialEndMs: number, touchOpenAs: 'start'|'end', finalizePair: (s: number, e: number) => [number, number], resolveBoth: (draft: number) => [number, number], onCommitBoth: (s: number, e: number) => void } | null} props.pair
   * @param {(ms: number) => void} props.onCommitSingle
   */
  function TimestampTouchPicker({ open, onClose, useFullScreen, fieldLabel, initialPrimaryMs, min, max, pair, onCommitSingle }) {
    const [digits, setDigits] = useState(() => leMsToDigitArray(initialPrimaryMs));
    const [focusIdx, setFocusIdx] = useState(0);
    const [draftStart, setDraftStart] = useState(0);
    const [draftEnd, setDraftEnd] = useState(0);
    const [editingSide, setEditingSide] = useState(/** @type {'start'|'end'} */ ('start'));

    useEffect(() => {
      if (!open) return;
      if (pair) {
        setDraftStart(pair.initialStartMs);
        setDraftEnd(pair.initialEndMs);
        const side = pair.touchOpenAs;
        setEditingSide(side);
        setDigits(leMsToDigitArray(side === 'start' ? pair.initialStartMs : pair.initialEndMs));
      } else {
        setDigits(leMsToDigitArray(initialPrimaryMs));
      }
      setFocusIdx(0);
    }, [open]);

    useEffect(() => {
      if (!open) return;
      const prev = document.body.style.overflow;
      document.body.style.overflow = 'hidden';
      return () => { document.body.style.overflow = prev; };
    }, [open]);

    const draftMs = leDigitArrayToMs(digits);
    const previewPair = useMemo(() => {
      if (!pair) return null;
      return pair.finalizePair(draftStart, draftEnd);
    }, [pair, draftStart, draftEnd]);

    const switchEditingSide = (side) => {
      if (!pair || side === editingSide) return;
      setEditingSide(side);
      setDigits(leMsToDigitArray(side === 'start' ? draftStart : draftEnd));
      setFocusIdx(0);
    };

    const applyDigit = (n) => {
      const next = digits.slice();
      next[focusIdx] = n;
      const ms = leDigitArrayToMs(next);
      if (pair) {
        if (editingSide === 'start') setDraftStart(ms);
        else setDraftEnd(ms);
      }
      setDigits(next);
      setFocusIdx(i => Math.min(6, i + 1));
    };

    const runClose = () => {
      onClose();
    };

    useEffect(() => {
      if (!open) return;
      const onKey = (ev) => {
        if (ev.key === 'Escape') {
          ev.preventDefault();
          onClose();
        }
      };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [open, onClose]);

    const commitResolved = (s, e) => {
      if (pair) pair.onCommitBoth(s, e);
      else onCommitSingle(s);
      runClose();
    };

    const onPrimaryConfirm = () => {
      if (!pair) {
        let ms = draftMs;
        if (min != null) ms = Math.max(min, ms);
        if (max != null) ms = Math.min(max, ms);
        onCommitSingle(ms);
        runClose();
        return;
      }
      const [s, e] = pair.finalizePair(draftStart, draftEnd);
      commitResolved(s, e);
    };

    if (!open || !createPortal) return null;

    const shellClass = 'ts-touchpick-root' + (useFullScreen ? ' ts-touchpick-root--fullscreen' : ' ts-touchpick-root--sheet');

    const previewStart = pair && previewPair ? previewPair[0] : draftMs;
    const previewEnd = pair && previewPair ? previewPair[1] : draftMs;

    const ui = (
      <div className={shellClass} role="dialog" aria-modal="true" aria-labelledby="ts-touchpick-title">
        <div className="ts-touchpick-backdrop" onClick={runClose}/>
        <div className="ts-touchpick-panel">
          <div className="ts-touchpick-header">
            <span id="ts-touchpick-title" className="ts-touchpick-title">
              {pair ? '구간 시간 입력' : (fieldLabel ? `${fieldLabel} · ` : '') + '시간 입력'}
            </span>
            <button type="button" className="ts-touchpick-close" onClick={runClose} aria-label="닫기">
              <LEIcon name="x" size={18}/>
            </button>
          </div>

          {pair && (
            <div className="ts-touchpick-preview-row">
              {editingSide === 'start' ? (
                <button
                  type="button"
                  aria-pressed="true"
                  className="ts-touchpick-preview-item ts-touchpick-preview-item--editing"
                  onClick={() => switchEditingSide('start')}
                >
                  <span className="ts-touchpick-preview-k">시작 · 편집</span>
                  <span className="ts-touchpick-preview-v mono">{leFmtTime(previewStart)}</span>
                </button>
              ) : (
                <button
                  type="button"
                  aria-pressed="false"
                  className="ts-touchpick-preview-item"
                  onClick={() => switchEditingSide('start')}
                >
                  <span className="ts-touchpick-preview-k">시작</span>
                  <span className="ts-touchpick-preview-v mono">{leFmtTime(previewStart)}</span>
                </button>
              )}
              {editingSide === 'end' ? (
                <button
                  type="button"
                  aria-pressed="true"
                  className="ts-touchpick-preview-item ts-touchpick-preview-item--editing"
                  onClick={() => switchEditingSide('end')}
                >
                  <span className="ts-touchpick-preview-k">끝 · 편집</span>
                  <span className="ts-touchpick-preview-v mono">{leFmtTime(previewEnd)}</span>
                </button>
              ) : (
                <button
                  type="button"
                  aria-pressed="false"
                  className="ts-touchpick-preview-item"
                  onClick={() => switchEditingSide('end')}
                >
                  <span className="ts-touchpick-preview-k">끝</span>
                  <span className="ts-touchpick-preview-v mono">{leFmtTime(previewEnd)}</span>
                </button>
              )}
            </div>
          )}

          <div className="ts-touchpick-digit-strip" role="group" aria-label="MM:SS.mmm">
            {[0, 1].map(i => (
              <button
                key={'d' + i}
                type="button"
                className={'ts-touchpick-digit' + (focusIdx === i ? ' ts-touchpick-digit--focus' : '')}
                onClick={() => setFocusIdx(i)}
              >{digits[i]}</button>
            ))}
            <span className="ts-touchpick-sep">:</span>
            {[2, 3].map(i => (
              <button
                key={'d' + i}
                type="button"
                className={'ts-touchpick-digit' + (focusIdx === i ? ' ts-touchpick-digit--focus' : '')}
                onClick={() => setFocusIdx(i)}
              >{digits[i]}</button>
            ))}
            <span className="ts-touchpick-sep">.</span>
            {[4, 5, 6].map(i => (
              <button
                key={'d' + i}
                type="button"
                className={'ts-touchpick-digit' + (focusIdx === i ? ' ts-touchpick-digit--focus' : '')}
                onClick={() => setFocusIdx(i)}
              >{digits[i]}</button>
            ))}
          </div>

          <div className="ts-touchpick-keypad">
            {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
              <button key={n} type="button" className="ts-touchpick-key" onClick={() => applyDigit(n)}>{n}</button>
            ))}
            <button type="button" className="ts-touchpick-key ts-touchpick-key--wide0" onClick={() => applyDigit(0)}>0</button>
          </div>

          <div className="ts-touchpick-actions">
            <button type="button" className="ts-touchpick-btn ts-touchpick-btn--ghost" onClick={runClose}>취소</button>
            <button type="button" className="ts-touchpick-btn ts-touchpick-btn--primary" onClick={onPrimaryConfirm}>
              {pair ? '시작·끝 저장' : '확인'}
            </button>
          </div>
        </div>
      </div>
    );

    return createPortal(ui, document.body);
  }

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

  function TimestampInput({ value, onChange, min, max, fieldLabel, pair }) {
    const [text, setText] = useState(leFmtTime(value));
    const [focused, setFocused] = useState(false);
    const [isNarrow, setIsNarrow] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches);
    const [pickerOpen, setPickerOpen] = useState(false);

    useEffect(() => {
      const mq = window.matchMedia('(max-width: 640px)');
      const fn = () => setIsNarrow(mq.matches);
      mq.addEventListener('change', fn);
      return () => mq.removeEventListener('change', fn);
    }, []);

    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;
      const raw = value + delta * mult;
      if (pair) {
        const [s, er] = pair.resolveBoth(raw);
        pair.onCommitBoth(s, er);
      } else {
        commit(String(raw));
      }
    };

    const touchTrigger = isNarrow && createPortal;

    return (
      <>
        {touchTrigger ? (
          <button
            type="button"
            className="ts-input mono ts-input-touch"
            onClick={() => setPickerOpen(true)}
          >{leFmtTime(value)}</button>
        ) : (
          <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 type="button" className="ts-step-btn" onClick={e => step(1, e)} title="Step up (shift=100ms, alt=1ms)">▲</button>
          <button type="button" className="ts-step-btn" onClick={e => step(-1, e)} title="Step down">▼</button>
        </div>
        {touchTrigger && (
          <TimestampTouchPicker
            open={pickerOpen}
            onClose={() => setPickerOpen(false)}
            useFullScreen={isNarrow}
            fieldLabel={fieldLabel || ''}
            initialPrimaryMs={value}
            min={min}
            max={max}
            pair={pair ? {
              ...pair,
              initialStartMs: pair.role === 'start' ? value : pair.peerMs,
              initialEndMs: pair.role === 'end' ? value : pair.peerMs,
              touchOpenAs: pair.role,
            } : null}
            onCommitSingle={(ms) => {
              let clamped = ms;
              if (min != null) clamped = Math.max(min, clamped);
              if (max != null) clamped = Math.min(max, clamped);
              onChange(clamped);
            }}
          />
        )}
      </>
    );
  }

  // ─── 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)}
        onDoubleClick={(e) => {
          if (!hasChildren) return;
          e.preventDefault();
          onToggle(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 [wordSearch, setWordSearch] = useState('');
    const [matchIx, setMatchIx] = useState(-1);

    const wordMatches = useMemo(() => {
      const q = wordSearch.trim().toLowerCase();
      if (!q) return [];
      const words = leFlattenByType(root, 'word');
      words.sort((a, b) => (Number(a.start_ms) || 0) - (Number(b.start_ms) || 0));
      return words.filter((w) => String(w.text || '').toLowerCase().includes(q));
    }, [root, wordSearch]);

    useEffect(() => {
      setMatchIx(-1);
    }, [wordSearch]);

    const goPrevWordMatch = () => {
      if (!wordMatches.length) return;
      const n = matchIx < 0 ? wordMatches.length - 1 : (matchIx - 1 + wordMatches.length) % wordMatches.length;
      setMatchIx(n);
      onSelect(wordMatches[n].id);
    };

    const goNextWordMatch = () => {
      if (!wordMatches.length) return;
      const n = matchIx < 0 ? 0 : (matchIx + 1) % wordMatches.length;
      setMatchIx(n);
      onSelect(wordMatches[n].id);
    };

    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');

    const matchLabel = wordMatches.length
      ? (matchIx < 0 ? `—/${wordMatches.length}` : `${matchIx + 1}/${wordMatches.length}`)
      : '';

    return (
      <div className="panel panel-tree">
        <div className="panel-header panel-tree-header">
          <div className="panel-tree-header-top">
            <span>Hierarchy</span>
            <div className="panel-header-actions">
              <button type="button" className="panel-header-btn" title="Expand all" onClick={onExpandAll}><LEIcon name="chevron-down" size={10}/></button>
              <button type="button" className="panel-header-btn" title="Collapse all" onClick={onCollapseAll}><LEIcon name="chevron-right" size={10}/></button>
            </div>
          </div>
          <div className="panel-tree-search">
            <input
              type="search"
              className="panel-tree-search-input"
              placeholder="Word 검색…"
              value={wordSearch}
              onChange={(e) => setWordSearch(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault();
                  if (e.shiftKey) goPrevWordMatch();
                  else goNextWordMatch();
                }
              }}
              aria-label="Word 텍스트 검색"
            />
            {wordSearch.trim() && (
              <span className="panel-tree-search-count mono" title="일치하는 word 개수">{wordMatches.length}</span>
            )}
            <button
              type="button"
              className="panel-tree-search-nav"
              disabled={!wordMatches.length}
              title="이전 일치 (Shift+Enter)"
              onClick={goPrevWordMatch}
            >
              ◀
            </button>
            <button
              type="button"
              className="panel-tree-search-nav"
              disabled={!wordMatches.length}
              title="다음 일치 (Enter)"
              onClick={goNextWordMatch}
            >
              ▶
            </button>
            {!!matchLabel && (
              <span className="panel-tree-search-pos mono" title="현재 일치 순번">{matchLabel}</span>
            )}
          </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 || 'Untitled 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 }) {
    const narrowSm = useLeNarrowSm();
    const rowTitle = onSelect
      ? (narrowSm
        ? '가사·시각은 표시만 됩니다. 행을 선택해 상세에서 편집하세요.'
        : '빈 영역을 클릭하면 이 word를 편집합니다.')
      : undefined;
    return (
      <div
        className={'word-row' + (playing ? ' playing' : '') + (onSelect ? ' selectable' : '') + (narrowSm ? ' word-row--read-sm' : '')}
        onClick={onSelect || undefined}
        title={rowTitle}
      >
        <div
          className={'seq mono' + (onSeekPlay ? ' seq-playable' : '')}
          onClick={onSeekPlay ? (e) => { e.stopPropagation(); onSeekPlay(); } : undefined}
          onPointerDown={onSeekPlay ? (e) => { e.stopPropagation(); } : 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>
        {narrowSm ? (
          <span className="text-label">{word.text || '\u00a0'}</span>
        ) : (
          <input
            className="text-input"
            value={word.text}
            onClick={e => e.stopPropagation()}
            onChange={e => onChange({ ...word, text: e.target.value })}
          />
        )}
        {narrowSm ? (
          <>
            <span className="ts-label-small mono">{leFmtTime(word.start_ms)}</span>
            <span className="ts-label-small mono">{leFmtTime(word.end_ms)}</span>
          </>
        ) : (
          <>
            <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
          type="button"
          className="del-btn"
          onClick={e => { e.stopPropagation(); onDelete(); }}
          onPointerDown={e => { e.stopPropagation(); }}
          title="Delete word"
        >
          <LEIcon name="trash" size={12}/>
        </button>
      </div>
    );
  }

  function WordInsertGap({ onAddWord, onSplit, onMerge, 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, flexWrap: 'wrap', justifyContent: 'center' }}>
          <button className="insert-gap-btn" onClick={e => { e.stopPropagation(); onAddWord(); }}>
            <LEIcon name="plus" size={10}/> Insert word
          </button>
          {onMerge && (
            <button
              type="button"
              className="insert-gap-btn"
              title="경계 앞·뒤 word를 하나로 합칩니다. 잘못 나뉜 음절·단어를 복구할 때 사용합니다."
              onClick={e => { e.stopPropagation(); onMerge(); }}
            >
              <LEIcon name="merge" size={10}/> Merge words
            </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 detail-section--hide-sm">
        <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, onApplyWordBoundaryDrag }) {
    const trackRef = useRef(null);
    const wordsRef = useRef(words);
    wordsRef.current = words;
    /** 드래그 중 commit으로 timeline 범위가 바뀌므로, 마우스→ms 변환은 매 move마다 최신 lineStart/lineEnd를 쓴다 */
    const lineRangeRef = useRef({ lineStart, lineEnd });
    lineRangeRef.current = { lineStart, lineEnd };
    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 onMove = (ev) => {
        if (!trackRef.current) return;
        const { lineStart: rs, lineEnd: re } = lineRangeRef.current;
        const rDur = Math.max(1, re - rs);
        const rect = trackRef.current.getBoundingClientRect();
        const frac = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));
        const ms = Math.round(rs + frac * rDur);
        const ws = wordsRef.current;
        const word = ws[wordIdx];
        if (!word) return;
        const prev = ws[wordIdx - 1];
        const next = ws[wordIdx + 1];
        if (onApplyWordBoundaryDrag) {
          onApplyWordBoundaryDrag(wordIdx, handle, ms);
        } else if (handle === 'start') {
          const clamped = Math.max(prev ? prev.end_ms + 1 : rs, 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 : re));
          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, onMergeWordsAt, onSelectWord, onSeekPlay, onForceApplyTiming,
    onMovePrevLine, onMoveNextLine, onMovePrevSentence, onMoveNextSentence,
    canMovePrevLine, canMoveNextLine, canMovePrevSentence, canMoveNextSentence,
    onApplyWordBoundary,
  }) {
    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 timelineStart = words.length
      ? Math.min(normalizedDraftStart, ...words.map(w => w.start_ms))
      : normalizedDraftStart;
    const timelineEnd = words.length
      ? Math.max(normalizedDraftEnd, ...words.map(w => w.end_ms))
      : normalizedDraftEnd;
    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 });
      }
    };

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

    const finalizeLinePair = (sIn, eIn) => {
      let s = Math.max(0, Math.round(sIn));
      let e = Math.round(eIn);
      if (s > e - minLineDiff) e = s + minLineDiff;
      if (e < s + minLineDiff) s = Math.max(0, e - minLineDiff);
      e = Math.max(s + minLineDiff, e);
      return [s, e];
    };

    return (
      <>
        {isComment && (
          <div className="detail-section detail-section--ld-actions">
            <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 detail-section--ld-actions">
            <div className="detail-section-title">
              <span>Actions</span>
              <span className="hint hint--hide-sm">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 detail-section--ld-timing">
          <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}
                fieldLabel={isComment ? 'At' : 'Start'}
                onChange={v => {
                  if (isComment) {
                    onUpdate({ ...node, start_ms: v, end_ms: v, children: [] });
                  } else {
                    handleDraftStart(v);
                  }
                }}
                pair={!isComment ? {
                  role: 'start',
                  peerMs: normalizedDraftEnd,
                  finalizePair: finalizeLinePair,
                  resolveBoth: (draft) => {
                    let s = Math.max(0, Math.round(draft));
                    let e = normalizedDraftEnd;
                    if (s > e - minLineDiff) e = s + minLineDiff;
                    return [s, e];
                  },
                  onCommitBoth: (s, e) => commitLineDraftPair(s, e),
                } : undefined}
              />
            </div>
            {!isComment && (
              <div className="ts-editor">
                <span className="ts-editor-label">End</span>
                <TimestampInput
                  value={normalizedDraftEnd}
                  fieldLabel="End"
                  onChange={v => handleDraftEnd(v)}
                  pair={{
                    role: 'end',
                    peerMs: normalizedDraftStart,
                    finalizePair: finalizeLinePair,
                    resolveBoth: (draft) => {
                      let s = normalizedDraftStart;
                      let e = Math.round(draft);
                      if (e < s + minLineDiff) s = e - minLineDiff;
                      s = Math.max(0, s);
                      e = Math.max(e, s + minLineDiff);
                      return [s, e];
                    },
                    onCommitBoth: (s, e) => commitLineDraftPair(s, e),
                  }}
                />
              </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 className="le-line-timing-actions" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 12 }}>
                <button
                  type="button"
                  className="le-btn"
                  title="Playhead → Start"
                  onClick={() => handleDraftStart(currentMs)}
                >
                  <LEIcon name="skip-back" size={11}/>
                  <span className="le-btn-label-sm">Playhead → Start</span>
                </button>
                <button
                  type="button"
                  className="le-btn"
                  title="Playhead → End"
                  onClick={() => handleDraftEnd(currentMs)}
                >
                  <LEIcon name="skip-fwd" size={11}/>
                  <span className="le-btn-label-sm">Playhead → End</span>
                </button>
                <button
                  type="button"
                  className="le-btn"
                  title="Preview"
                  onClick={() => onSeekPlay && onSeekPlay(normalizedDraftStart)}
                >
                  <LEIcon name="play" size={11}/>
                  <span className="le-btn-label-sm">Preview</span>
                </button>
                <button
                  type="button"
                  className="le-btn"
                  title="Reset"
                  onClick={() => { setDraftStart(node.start_ms); setDraftEnd(node.end_ms); }}
                >
                  <LEIcon name="undo" size={11}/>
                  <span className="le-btn-label-sm">Reset</span>
                </button>
                <button
                  type="button"
                  className="le-btn"
                  title="Apply Evenly"
                  onClick={() => onForceApplyTiming(normalizedDraftStart, normalizedDraftEnd)}
                >
                  <LEIcon name="check" size={11}/>
                  <span className="le-btn-label-sm">Apply Evenly</span>
                </button>
              </div>
              <WordTimeline
                words={words}
                lineStart={timelineStart}
                lineEnd={timelineEnd}
                currentMs={currentMs}
                onUpdateWord={onUpdate}
                onApplyWordBoundaryDrag={onApplyWordBoundary
                  ? (idx, handle, ms) => onApplyWordBoundary(idx, handle, ms, normalizedDraftStart, normalizedDraftEnd)
                  : undefined}
              />
              <div className="hint hint--hide-sm" style={{ marginTop: 10 }}>
                재생하면서 playhead를 시작과 끝에 맞춘 뒤 적용하면, 이 line의 모든 word timestamp를 균등 배분해 다시 씁니다.
              </div>
            </>
          )}
        </div>

        <div className="detail-section">
          <div className="detail-section-title">
            <span>{isComment ? 'Comment' : 'Words (' + words.length + ')'}</span>
            {!isComment && (
              <span className="hint hint--hide-sm" style={{ maxWidth: '70%', textAlign: 'right', lineHeight: 1.45 }}>
                문서 되돌리기(Ctrl+Z / ⌘Z)는 word 입력란 포커스가 없을 때 동작합니다. 입력 중에는 해당 칸의 텍스트만 브라우저가 되돌립니다.
              </span>
            )}
          </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)}
                        onMerge={() => onMergeWordsAt(i)}
                        onSelectWord={() => onSelectWord(words[i + 1].id)}
                      />
                    )
                    : <WordInsertGap onAddWord={() => onAddWordAt(i + 1)} onSelectWord={() => onSelectWord(w.id)}/>
                  }
                </React.Fragment>
              ))}
            </div>
          )}
        </div>

        <div className="detail-section detail-section--hide-sm">
          <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;

    const finalizeWordPair = (sIn, eIn) => {
      let s = Math.round(sIn);
      let e = Math.round(eIn);
      const minGap = 1;
      if (e < s + minGap) e = s + minGap;
      if (s > e - minGap) s = e - minGap;
      const minS = prev ? prev.start_ms : 0;
      const maxE = next ? next.end_ms : Math.max(node.end_ms + 60000, e, s + minGap);
      for (let i = 0; i < 8; i++) {
        e = Math.min(maxE, Math.max(e, s + minGap));
        s = Math.max(minS, Math.min(s, e - minGap));
      }
      return [s, e];
    };

    return (
      <>
        <div className="detail-section detail-section--wd-actions">
          <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 detail-section--wd-text">
          <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 detail-section--wd-timing">
          <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}
                fieldLabel="Start"
                min={prev ? prev.start_ms : 0} max={node.end_ms - 1}
                onChange={v => onUpdate({ ...node, start_ms: v })}
                pair={{
                  role: 'start',
                  peerMs: node.end_ms,
                  finalizePair: finalizeWordPair,
                  resolveBoth: (draftStart) => {
                    const minGap = 1;
                    let s = Math.round(draftStart);
                    let e = Math.round(node.end_ms);
                    if (s > e - minGap) e = s + minGap;
                    const minS = prev ? prev.start_ms : 0;
                    const maxE = next ? next.end_ms : Math.max(node.end_ms + 60000, e, s + minGap);
                    for (let i = 0; i < 8; i++) {
                      e = Math.min(maxE, Math.max(e, s + minGap));
                      s = Math.max(minS, Math.min(s, e - minGap));
                    }
                    return [s, e];
                  },
                  onCommitBoth: (s, e) => onUpdate({ ...node, start_ms: s, end_ms: e }),
                }}
              />
            </div>
            <div className="ts-editor">
              <span className="ts-editor-label">End</span>
              <TimestampInput
                value={node.end_ms}
                fieldLabel="End"
                min={node.start_ms + 1}
                max={next ? next.end_ms : node.end_ms + 10000}
                onChange={v => onUpdate({ ...node, end_ms: v })}
                pair={{
                  role: 'end',
                  peerMs: node.start_ms,
                  finalizePair: finalizeWordPair,
                  resolveBoth: (draftEnd) => {
                    const minGap = 1;
                    let s = Math.round(node.start_ms);
                    let e = Math.round(draftEnd);
                    if (e < s + minGap) s = e - minGap;
                    const minS = prev ? prev.start_ms : 0;
                    const maxE = next ? next.end_ms : Math.max(node.end_ms + 60000, e, s + minGap);
                    for (let i = 0; i < 8; i++) {
                      e = Math.min(maxE, Math.max(e, s + minGap));
                      s = Math.max(minS, Math.min(s, e - minGap));
                    }
                    return [s, e];
                  },
                  onCommitBoth: (s, e) => onUpdate({ ...node, start_ms: s, end_ms: e }),
                }}
              />
            </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>
    );
  }

  /** Text preview 패널과 동일한 계층으로 평문 추출 (복사용). 문장 사이 \\n\\n, 문단(Paragraph) 사이 \\n\\n\\n */
  function leStructuredPreviewPlainText(node) {
    if (!node) return '';

    const sentenceLines = (sentence) => {
      const lines = (sentence.children || [])
        .filter((l) => !leIsCommentLine(l))
        .map((l) => (l.text || '').trim())
        .filter(Boolean);
      return lines.join('\n');
    };

    if (node.type === 'song') {
      const paragraphs = [];
      for (const sec of node.children || []) {
        for (const para of sec.children || []) {
          const parts = [];
          for (const sent of para.children || []) {
            const block = sentenceLines(sent);
            if (block) parts.push(block);
          }
          if (parts.length) paragraphs.push(parts.join('\n\n'));
        }
      }
      return paragraphs.join('\n\n\n');
    }

    if (node.type === 'section') {
      const paragraphs = [];
      for (const para of node.children || []) {
        const parts = [];
        for (const sent of para.children || []) {
          const block = sentenceLines(sent);
          if (block) parts.push(block);
        }
        if (parts.length) paragraphs.push(parts.join('\n\n'));
      }
      return paragraphs.join('\n\n\n');
    }

    if (node.type === 'paragraph') {
      const parts = [];
      for (const sent of node.children || []) {
        const block = sentenceLines(sent);
        if (block) parts.push(block);
      }
      return parts.join('\n\n');
    }

    if (node.type === 'sentence') {
      return sentenceLines(node);
    }

    if (node.type === 'line') {
      return leIsCommentLine(node) ? '' : String(node.text || '');
    }

    if (node.type === 'word') {
      return String(node.text || '');
    }

    return String(node.text || '');
  }

  async function leCopyPlainText(text) {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.setAttribute('readonly', '');
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    document.body.appendChild(ta);
    ta.select();
    try {
      return document.execCommand('copy');
    } finally {
      document.body.removeChild(ta);
    }
  }

  function leCollectSentenceTexts(node) {
    if (!node) return [];

    const sentenceToText = (sentence) => {
      const lines = (sentence.children || [])
        .filter((line) => !leIsCommentLine(line))
        .map((line) => String(line.text || '').trim())
        .filter(Boolean);
      return lines.join('\n').trim();
    };

    const sentences = [];
    leWalk(node, (n) => {
      if (n.type === 'sentence') {
        const text = sentenceToText(n);
        if (text) sentences.push(text);
      }
    });

    if (sentences.length) return sentences;

    if (node.type === 'line' && !leIsCommentLine(node)) {
      const lineText = String(node.text || '').trim();
      return lineText ? [lineText] : [];
    }
    if (node.type === 'word') {
      const wordText = String(node.text || '').trim();
      return wordText ? [wordText] : [];
    }
    return [];
  }

  function DetailTextPreview({ node, onCopyToast, songTitle }) {
    const renderNode = (n) => {
      if (!n) return null;
      if (n.type === 'song') {
        return (n.children || []).map(section => (
          <div key={section.id} className="preview-section">
            {renderNode(section)}
          </div>
        ));
      }
      if (n.type === 'section') {
        return (n.children || []).map(paragraph => (
          <div key={paragraph.id} className="preview-paragraph">
            {renderNode(paragraph)}
          </div>
        ));
      }
      if (n.type === 'paragraph') {
        return (n.children || []).map(sentence => (
          <div key={sentence.id} className="preview-sentence">
            {renderNode(sentence)}
          </div>
        ));
      }
      if (n.type === 'sentence') {
        return (n.children || [])
          .filter(line => !leIsCommentLine(line))
          .map(line => (
            <div key={line.id} className="preview-line">
              {line.text || ''}
            </div>
          ));
      }
      if (n.type === 'line') {
        return leIsCommentLine(n) ? null : <div className="preview-line">{n.text || ''}</div>;
      }
      if (n.type === 'word') return n.text || '';
      return n.text || '';
    };

    const content = renderNode(node);
    const isEmpty = content == null || content === '' || (Array.isArray(content) && content.length === 0);

    const handleCopyPreview = async () => {
      const plain = leStructuredPreviewPlainText(node).trim();
      if (!plain) {
        onCopyToast?.('복사할 미리보기 텍스트가 없습니다.');
        return;
      }
      try {
        await leCopyPlainText(plain);
        onCopyToast?.('미리보기 텍스트를 클립보드에 복사했습니다.');
      } catch (e) {
        onCopyToast?.(`복사 실패: ${e.message || e}`);
      }
    };

    const handleCopyPreviewJson = async () => {
      const sentences = leCollectSentenceTexts(node);
      if (!sentences.length) {
        onCopyToast?.('복사할 sentence 데이터가 없습니다.');
        return;
      }

      const payload = {
        topic: String(songTitle || '노래').trim() || '노래',
        sentences,
      };

      try {
        await leCopyPlainText(JSON.stringify(payload, null, 2));
        onCopyToast?.('JSON 데이터를 클립보드에 복사했습니다.');
      } catch (e) {
        onCopyToast?.(`복사 실패: ${e.message || e}`);
      }
    };

    return (
      <div className="detail-text-preview-wrap">
        <div className="detail-section-title">
          <span>Text preview</span>
          <button
            type="button"
            className="detail-section-title-action"
            onClick={handleCopyPreviewJson}
            title="노래 제목과 sentence 배열을 JSON으로 클립보드에 복사"
            aria-label="JSON 복사"
          >
            JSON 복사
          </button>
        </div>
        <button
          type="button"
          className="detail-text-preview-copy"
          onClick={handleCopyPreview}
          title="미리보기 전체 텍스트를 클립보드에 복사"
          aria-label="클립보드에 복사"
        >
          <LEIcon name="copy-clipboard" size={15}/>
        </button>
        <div className="detail-text-preview structured">
          {isEmpty ? '미리보기 텍스트가 없습니다.' : content}
        </div>
      </div>
    );
  }

  function ContainerDetail({
    node, onSelect, onInsertGroup, onRemoveGroup, onDeleteChild, onAddChild, onAddCommentLine, currentMs,
    onMovePrevParagraph, onMoveNextParagraph, canMovePrevParagraph, canMoveNextParagraph,
    showToast, songTitle,
  }) {
    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 detail-section--cd-timing">
          <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 detail-section--cd-add">
            <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 detail-section--cd-actions">
            <div className="detail-section-title">
              <span>Actions</span>
              <span className="hint hint--hide-sm">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 hint--hide-sm">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 detail-section--hide-sm">
          <DetailTextPreview node={node} onCopyToast={showToast} songTitle={songTitle}/>
        </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);
    useWakeLock(playing);
    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);
    };

    /**
     * WordTimeline 핸들 드래그 전용 업데이트.
     * 줄이면 인접 단어의 반대 경계가 따라오고, 막힌 방향으로 확장하면 기존 duration을 유지한 채 이웃 단어들을 밀어낸다.
     */
    const applyWordBoundaryDrag = (lineId, wordIdx, handle, targetMs, lineDraftStart, lineDraftEnd) => {
      const next = leClone(doc);
      const found = leFindNode(next.root, lineId);
      if (!found || found.node.type !== 'line' || leIsCommentLine(found.node)) return;
      const ws = found.node.children || [];
      if (wordIdx < 0 || wordIdx >= ws.length) return;

      const cap = next.media?.duration_ms;
      const songCap = cap != null && Number.isFinite(cap) ? cap : Number.POSITIVE_INFINITY;
      const durationOf = (word) => Math.max(1, Math.round((word.end_ms ?? 0) - (word.start_ms ?? 0)));
      const w = ws[wordIdx];

      if (handle === 'start') {
        const prevW = ws[wordIdx - 1];
        const lineMin = Math.max(0, Math.min(lineDraftStart ?? 0, ws[0]?.start_ms ?? lineDraftStart ?? 0));
        const minStartForIndex = lineMin + wordIdx * 2;
        let start = Math.round(targetMs);
        start = Math.max(minStartForIndex, Math.min(start, w.end_ms - 1));

        if (!prevW) {
          start = Math.max(lineMin, Math.min(start, w.end_ms - 1));
          if (start === w.start_ms) return;
          w.start_ms = start;
          leRecalc(next.root);
          commit(next);
          return;
        }

        w.start_ms = start;
        if (prevW.end_ms < start - 1) {
          prevW.end_ms = Math.max(prevW.start_ms + 1, start - 1);
        }

        for (let j = wordIdx - 1; j >= 0; j -= 1) {
          const cur = ws[j];
          const right = ws[j + 1];
          const duration = durationOf(cur);
          const maxEnd = right.start_ms - 1;
          const minStart = lineMin + j * 2;
          const nextStart = Math.max(minStart, Math.min(cur.start_ms, maxEnd - 1));
          cur.end_ms = maxEnd;
          cur.start_ms = Math.max(minStart, Math.min(nextStart, cur.end_ms - 1));
          if (cur.end_ms - cur.start_ms > duration) {
            cur.start_ms = Math.max(minStart, cur.end_ms - duration);
          }
        }

        leRecalc(next.root);
        commit(next);
        return;
      }

      const nextW = ws[wordIdx + 1];
      const tailSpan = ws
        .slice(wordIdx + 1)
        .reduce((sum, word) => sum + durationOf(word) + 1, 0);
      let end = Math.round(targetMs);
      const maxEnd = songCap === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : songCap - tailSpan;
      end = Math.max(w.start_ms + 1, Math.min(end, maxEnd));

      if (!nextW) {
        end = Math.min(end, lineDraftEnd, songCap);
        if (end === w.end_ms) return;
        w.end_ms = end;
        leRecalc(next.root);
        commit(next);
        return;
      }

      w.end_ms = end;
      if (nextW.start_ms > end + 1) {
        nextW.start_ms = Math.min(nextW.end_ms - 1, end + 1);
      }

      for (let j = wordIdx + 1; j < ws.length; j++) {
        const prev = ws[j - 1];
        const cur = ws[j];
        const duration = durationOf(cur);
        const minStart = prev.end_ms + 1;
        if (cur.start_ms < minStart) {
          cur.start_ms = minStart;
          cur.end_ms = cur.start_ms + duration;
        }
      }

      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 mergeWordsAt = (lineId, leftWordIdx) => {
      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 (leftWordIdx < 0 || leftWordIdx >= words.length - 1) return;
      const a = words[leftWordIdx];
      const b = words[leftWordIdx + 1];
      const merged = {
        ...a,
        text: String(a.text || '') + String(b.text || ''),
        start_ms: a.start_ms,
        end_ms: b.end_ms,
        confidence: (a.confidence != null || b.confidence != null)
          ? Math.min(a.confidence ?? 1, b.confidence ?? 1)
          : a.confidence,
      };
      line.children.splice(leftWordIdx, 2, merged);
      leResequenceLine(line);
      leRecalc(next.root);
      commit(next, 'Words merged');
    };

    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 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 consolidateToOneLine = () => {
      const confirmMsg =
        '「한 LINE으로 통합」을 실행하면 섹션·문단·문장·라인으로 나눈 구조가 모두 사라지고, 모든 단어가 하나의 라인 안으로만 모입니다.\n\n' +
        '코멘트 라인 등 단어가 붙어 있지 않은 줄은 제거됩니다. 지금까지의 구간 편집·동기화 결과는 이 구조상 사라집니다.\n\n' +
        '실행 직후에는 Undo(Ctrl+Z / ⌘Z)로 직전 상태로 되돌릴 수 있습니다.\n\n계속하시겠습니까?';
      if (!window.confirm(confirmMsg)) return;

      const normalized = leConsolidateTimelineToSingleLine(doc);
      if (!normalized) {
        showToast('통합할 단어가 없습니다.');
        return;
      }

      const next = normalized;
      leReassignIds(next.root);
      leNormalizeLineGaps(next.root);
      leRecalc(next.root);

      const sec = next.root.children?.[0];
      const par = sec?.children?.[0];
      const sen = par?.children?.[0];
      const loneLine = sen?.children?.[0];
      const firstWord = loneLine?.children?.[0];
      const expandIds = [next.root.id, sec?.id, par?.id, sen?.id, loneLine?.id].filter(Boolean);

      setSelectedId(firstWord?.id ?? loneLine?.id ?? next.root.id);
      setExpanded(new Set(expandIds));

      commit(next, '한 LINE으로 통합됨');
    };

    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)}
            onMergeWordsAt={(leftIdx) => mergeWordsAt(selected.id, leftIdx)}
            onSelectWord={setSelectedId}
            onSeekPlay={seekPlay}
            onForceApplyTiming={(startMs, endMs) => forceApplyLineTiming(selected.id, startMs, endMs)}
            onApplyWordBoundary={(idx, handle, ms, lineDraftStart, lineDraftEnd) => applyWordBoundaryDrag(selected.id, idx, handle, ms, lineDraftStart, lineDraftEnd)}
            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}
          showToast={showToast}
          songTitle={doc.media?.title || song.title || '노래'}
        />
      );
    };

    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(), { dirty });
    };

    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>
            <span className="topbar-brand-label">가사 편집기</span>
          </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 topbar-btn--hide-label-sm"
            onClick={undo}
            disabled={!history.length}
            title="단축키: Ctrl+Z / ⌘Z. word·timestamp 입력란에 포커스가 있으면 문서가 아니라 해당 입력만 되돌립니다."
          >
            <LEIcon name="undo" size={11}/>
            <span className="topbar-btn-label">Undo</span>
          </button>
          <button
            className="topbar-btn topbar-btn--hide-label-sm"
            onClick={redo}
            disabled={!future.length}
            title="단축키: Ctrl+Y / Ctrl+Shift+Z / ⌘⇧Z"
          >
            <LEIcon name="redo" size={11}/>
            <span className="topbar-btn-label">Redo</span>
          </button>
          <button
            className="topbar-btn topbar-btn--hide-label-sm"
            onClick={fullRecalc}
            title="Re-sync all parent timestamps"
          >
            <LEIcon name="zap" size={11}/>
            <span className="topbar-btn-label">Parent sync</span>
          </button>
          <button
            type="button"
            className="topbar-btn topbar-btn--hide-label-sm topbar-btn--show-at-2k"
            onClick={consolidateToOneLine}
            title="모든 단어를 하나의 라인으로 모아 1 Section → 1 Paragraph → 1 Sentence → 1 Line 구조로 초기화합니다. 기존 구간·코멘트 라인은 사라집니다."
          >
            <LEIcon name="merge" size={11}/>
            <span className="topbar-btn-label">한 LINE으로 통합</span>
          </button>
          <button
            className="topbar-btn topbar-btn--hide-on-sm"
            onClick={downloadCurrentTimeline}
            title="현재 타임라인 JSON 다운로드"
          >
            <LEIcon name="download" size={11}/>
            <span className="topbar-btn-label">Download</span>
          </button>
          <button
            className="topbar-btn primary topbar-btn--hide-label-sm"
            onClick={handleSaveAndExit}
            title="저장하고 편집기 닫기"
          >
            <LEIcon name="save" size={11}/>
            <span className="topbar-btn-label">저장 & 닫기</span>
          </button>
          <button
            className="topbar-btn danger topbar-btn--hide-label-sm"
            onClick={onExit}
            title="Discard changes and exit"
          >
            <LEIcon name="x" size={11}/>
            <span className="topbar-btn-label">닫기</span>
          </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 });
})();
