// ── Script Rehearser ──────────────────────────────────────────────────────

// ── Constants ─────────────────────────────────────────────────────────────
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];

const REH_EMOTIONS = [
  { value: '',                                        emoji: '😐', label: 'Neutral'    },
  { value: 'happy, cheerful and upbeat',              emoji: '😊', label: 'Happy'      },
  { value: 'sad, melancholy, somber',                 emoji: '😢', label: 'Sad'        },
  { value: 'angry, forceful, aggressive',             emoji: '😠', label: 'Angry'      },
  { value: 'whisper, hushed and intimate',            emoji: '🤫', label: 'Whisper'    },
  { value: 'excited, enthusiastic, energetic',        emoji: '🤩', label: 'Excited'    },
  { value: 'scared, nervous, trembling voice',        emoji: '😨', label: 'Scared'     },
  { value: 'sarcastic, dry, ironic delivery',         emoji: '😏', label: 'Sarcastic'  },
  { value: 'dramatic, theatrical, intense',           emoji: '🎭', label: 'Dramatic'   },
  { value: 'gentle, warm, tender',                    emoji: '🥰', label: 'Gentle'     },
  { value: 'confused, uncertain, hesitant',           emoji: '😕', label: 'Confused'   },
  { value: 'bored, flat, disinterested',              emoji: '😑', label: 'Bored'      },
  { value: 'surprised, shocked, astonished',          emoji: '😲', label: 'Surprised'  },
  { value: 'confident, authoritative, bold',          emoji: '💪', label: 'Confident'  },
  { value: 'mysterious, dark, ominous',               emoji: '🌑', label: 'Mysterious' },
  { value: 'romantic, loving, passionate',            emoji: '❤️',  label: 'Romantic'   },
  { value: 'playful, teasing, mischievous',           emoji: '😈', label: 'Playful'    },
  { value: 'calm, composed, measured',                emoji: '🧘', label: 'Calm'       },
  { value: 'commanding, authoritative, military',     emoji: '⚔️',  label: 'Commanding' },
  { value: 'grieving, tearful, broken',               emoji: '😭', label: 'Grieving'   },
];

// Load custom emotions from localStorage
let rehCustomEmotions = [];
try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {}

function getEmotionInfo(value) {
  if (!value) return { emoji: '', label: 'Pick tone' };
  const all = [...REH_EMOTIONS, ...rehCustomEmotions];
  const found = all.find(e => e.value === value);
  if (found) return { emoji: found.emoji, label: found.label };
  return { emoji: '✨', label: value.length > 14 ? value.slice(0, 13) + '…' : value };
}

// ── Markdown helpers ───────────────────────────────────────────────────────

function renderMarkdownInline(text) {
  // Escape HTML first, then apply inline markdown
  let s = escHtml(text);
  s = s.replace(/\*\*([^*\n]+?)\*\*/g,  '<strong>$1</strong>');
  s = s.replace(/\*([^*\n]+?)\*/g,       '<em>$1</em>');
  s = s.replace(/__([^_\n]+?)__/g,       '<u>$1</u>');
  s = s.replace(/~~([^~\n]+?)~~/g,       '<del>$1</del>');
  s = s.replace(/==([^=\n]+?)==/g,       '<mark class="reh-hl">$1</mark>');
  return s;
}

function stripMarkdown(text) {
  return text
    .replace(/\*\*([^*\n]+?)\*\*/g, '$1')
    .replace(/\*([^*\n]+?)\*/g,      '$1')
    .replace(/__([^_\n]+?)__/g,      '$1')
    .replace(/~~([^~\n]+?)~~/g,      '$1')
    .replace(/==([^=\n]+?)==/g,      '$1');
}

// ── State ─────────────────────────────────────────────────────────────────
const rehState = {
  lines:    [],
  cast:     {},
  lineIndex: 0,
  clips:    [],
  voices:   [],
  backend:  '',
  playing:  false,
  repeat:   false,
  savedId:  null,
  synthCache: new Map(),
  staleLines: new Set(),   // lines that were cached but had their tone changed
  synthCancelled: false,
  synthRunning: false,
  skipDescriptions: true,
  narratorVoice: '',
  practiceStart: null,
  practiceEnd:   null,
  bulkMode:   false,        // bulk-edit (line selection) mode on/off
  bulkSel:    new Set(),    // indices of currently selected lines
  bulkAnchor: null,         // last clicked index for Shift+click range selection
  showHidden: false,        // reveal hidden lines (so they can be restored)
  recStream: null, recAudioCtx: null, recAnalyser: null,
  recSourceNode: null, recGainNode: null, recDestStream: null,
  recMeterRaf: null, recWaveRing: null, mediaRec: null,
  recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
};
window.rehState = rehState;

// ── Library — IndexedDB ────────────────────────────────────────────────────

const REH_DB_NAME = 'reh-library';
const REH_STORE   = 'rehearsals';

function rehDbOpen() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(REH_DB_NAME, 1);
    req.onupgradeneeded = e => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(REH_STORE)) {
        const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
        store.createIndex('updated', 'updated', { unique: false });
      }
    };
    req.onsuccess = e => resolve(e.target.result);
    req.onerror   = e => reject(e.target.error);
  });
}

async function rehDbOp(mode, fn) {
  const db = await rehDbOpen();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(REH_STORE, mode);
    const store = tx.objectStore(REH_STORE);
    const req = fn(store);
    req.onsuccess = e => resolve(e.target.result);
    req.onerror   = e => reject(e.target.error);
  });
}

async function rehDbAdd(record)  { return rehDbOp('readwrite', s => s.add(record)); }
async function rehDbPut(record)  { return rehDbOp('readwrite', s => s.put(record)); }
async function rehDbDelete(id)   { return rehDbOp('readwrite', s => s.delete(id)); }

async function rehDbGetAll() {
  const db = await rehDbOpen();
  return new Promise((resolve, reject) => {
    const tx  = db.transaction(REH_STORE, 'readonly');
    const req = tx.objectStore(REH_STORE).getAll();
    req.onsuccess = e => resolve(e.target.result || []);
    req.onerror   = e => reject(e.target.error);
  });
}

// ── Serialization ─────────────────────────────────────────────────────────

async function clipsToJson(clips) {
  return Promise.all(clips.map(async c => {
    if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
    const ab  = await c.blob.arrayBuffer();
    const u8  = new Uint8Array(ab);
    let bin   = '';
    const CHUNK = 8192;
    for (let i = 0; i < u8.length; i += CHUNK)
      bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
    return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
  }));
}

function clipsFromJson(clips) {
  return clips.map(c => {
    if (!c.b64) return c;
    const bin = atob(c.b64);
    const u8  = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
    return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
  });
}

function rehCurrentRecord() {
  const cast = {};
  Object.entries(rehState.cast).forEach(([sp, c]) => {
    cast[sp] = {
      voice: c.voice, color: c.color, instruct: c.instruct || '',
      lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '',
      ignored: !!c.ignored, hidden: !!c.hidden,
    };
  });
  const emotions = {};
  const notes    = {};
  const ignored  = {};
  const hidden   = {};
  rehState.lines.forEach((l, i) => {
    if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion;
    if (l.type === 'dialog' && l.note)    notes[i]    = l.note;
    if (l.ignored) ignored[i] = true;
    if (l.hidden)  hidden[i]  = true;
  });
  return {
    title:         $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
    // Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the
    // raw textarea before the script has been parsed into lines.
    script:        rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''),
    cast, emotions, notes, ignored, hidden,
    backend:       rehState.backend,
    narratorVoice: rehState.narratorVoice,
    lineIndex:     rehState.lineIndex,
    clips:         rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
    updated:       new Date(),
  };
}

// Reconstruct script text from current parsed lines (preserves inline edits)
function linesToScriptText() {
  return rehState.lines.map(line => {
    switch (line.type) {
      case 'act':
      case 'transition':  return '\n' + line.text + '\n';
      case 'scene':       return '\n' + line.text + '\n';
      case 'action':      return '\n' + line.text + '\n';
      case 'dialog':      return '\n' + line.speaker + '\n' + line.text + '\n';
      case 'direction':
        return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
      case 'pagebreak':   return line.page ? `\n\f${line.page}\n` : '\n\f\n';
      default:            return '';
    }
  }).join('').trim();
}

async function saveToLibrary() {
  const rec = rehCurrentRecord();
  if (rehState.savedId) {
    rec.id = rehState.savedId;
    if (!rec.created) rec.created = new Date();
    await rehDbPut(rec);
  } else {
    rec.created = new Date();
    const newId = await rehDbAdd(rec);
    rehState.savedId = newId;
  }
  toast('Saved to library', 'success');
  rehState._keepSavedIdOnce = true;
  await renderLibraryList();
}

// Auto-save a freshly imported/pasted script as its own library entry so it shows up
// in the Library and can be reopened. Returns the new id (or null on failure).
async function rehAutoSaveImport(title) {
  const text = ($('reh-script-text')?.value || '').trim();
  if (!text) return null;
  if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title;
  const rec = {
    title:   $('reh-script-title')?.value.trim() || title || 'Untitled',
    script:  text,
    cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {},
    backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '',
    lineIndex: 0, clips: [], created: new Date(), updated: new Date(),
  };
  try {
    const id = await rehDbAdd(rec);
    rehState.savedId = id;
    rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast
    await renderLibraryList();
    return id;
  } catch (e) { return null; }
}

async function exportToFile() {
  const rec   = rehCurrentRecord();
  rec.version = 1;
  rec.created = rec.created || new Date();
  rec.clips   = await clipsToJson(rec.clips);
  const json  = JSON.stringify(rec, null, 2);
  const blob  = new Blob([json], { type: 'application/json' });
  const a     = document.createElement('a');
  const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
  a.href = URL.createObjectURL(blob);
  a.download = title + '.reh';
  a.click();
}

function exportFountain() {
  const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled';
  let out = `Title: ${title}\n\n`;
  rehState.lines.forEach(line => {
    const t = stripMarkdown(line.text);
    switch (line.type) {
      case 'act':        out += '\n' + t + '\n'; break;
      case 'scene':      out += '\n' + t + '\n'; break;
      case 'action':     out += '\n' + t + '\n'; break;
      case 'transition': out += '\n' + t + '\n'; break;
      case 'dialog':     out += '\n' + line.speaker + '\n' + t + '\n'; break;
      case 'direction':
        out += (line.speaker ? '' : '\n') + t + '\n'; break;
    }
  });
  const blob = new Blob([out.trim()], { type: 'text/plain' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain';
  a.click();
  toast('Exported as .fountain', 'success');
}

// ── FDX (Final Draft XML) ──────────────────────────────────────────────────

function exportFDX() {
  const title = $('reh-script-title')?.value.trim() || 'Untitled';
  const x = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  let xml = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n`;
  xml += `<FinalDraft DocumentType="Script" Template="No" Version="1">\n<Content>\n`;
  rehState.lines.forEach(line => {
    const t = x(stripMarkdown(line.text));
    switch (line.type) {
      case 'act':
      case 'scene':      xml += `  <Paragraph Type="Scene Heading"><Text>${t}</Text></Paragraph>\n`; break;
      case 'action':     xml += `  <Paragraph Type="Action"><Text>${t}</Text></Paragraph>\n`; break;
      case 'transition': xml += `  <Paragraph Type="Transition"><Text>${t}</Text></Paragraph>\n`; break;
      case 'direction':  xml += `  <Paragraph Type="Parenthetical"><Text>${t}</Text></Paragraph>\n`; break;
      case 'dialog':
        xml += `  <Paragraph Type="Character"><Text>${x(line.speaker)}</Text></Paragraph>\n`;
        xml += `  <Paragraph Type="Dialogue"><Text>${t}</Text></Paragraph>\n`;
        break;
    }
  });
  xml += `</Content>\n</FinalDraft>`;
  const blob = new Blob([xml], { type: 'text/xml' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx';
  a.click();
  toast('Exported as .fdx (Final Draft XML)', 'success');
}

async function importFDX(file) {
  const text = await file.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(text, 'text/xml');
  const paras = doc.querySelectorAll('Paragraph');
  let result = '';
  paras.forEach(para => {
    const type = para.getAttribute('Type') || '';
    const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join('');
    if (!t.trim()) return;
    switch (type) {
      case 'Scene Heading': result += '\n' + t + '\n'; break;
      case 'Action':        result += '\n' + t + '\n'; break;
      case 'Character':     result += '\n' + t + '\n'; break;
      case 'Dialogue':      result += t + '\n'; break;
      case 'Parenthetical': result += t + '\n'; break;
      case 'Transition':    result += '\n' + t + '\n'; break;
    }
  });
  return result.trim();
}

// OSF (Open Screenplay Format — application-agnostic XML)
function exportOSF() {
  const title = $('reh-script-title')?.value.trim() || 'Untitled';
  const x = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<osf:script xmlns:osf="http://www.openscreenplayformat.org/ns/1.0">\n`;
  xml += `  <osf:title>${x(title)}</osf:title>\n<osf:content>\n`;
  rehState.lines.forEach(line => {
    const t = x(stripMarkdown(line.text));
    switch (line.type) {
      case 'act':
      case 'scene':      xml += `    <osf:scene-heading>${t}</osf:scene-heading>\n`; break;
      case 'action':     xml += `    <osf:action>${t}</osf:action>\n`; break;
      case 'transition': xml += `    <osf:transition>${t}</osf:transition>\n`; break;
      case 'direction':  xml += `    <osf:parenthetical speaker="${x(line.speaker)}">${t}</osf:parenthetical>\n`; break;
      case 'dialog':
        xml += `    <osf:dialogue>\n      <osf:character>${x(line.speaker)}</osf:character>\n      <osf:text>${t}</osf:text>\n    </osf:dialogue>\n`;
        break;
    }
  });
  xml += `</osf:content>\n</osf:script>`;
  const blob = new Blob([xml], { type: 'text/xml' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf';
  a.click();
  toast('Exported as .osf (Open Screenplay Format)', 'success');
}

// ── PDF import (via pdf.js from CDN) ──────────────────────────────────────

async function loadPdfJs() {
  if (window.pdfjsLib) return;
  await new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = '/static/js/pdf/pdf.min.js';
    s.onload = resolve; s.onerror = reject;
    document.head.appendChild(s);
  });
  pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
}

// Extract a screenplay from a PDF, reconstructing element types from the
// horizontal indentation that screenplays use (the layout *is* the meaning):
//   action/scene  ~ left margin            character  ~ deeply indented (caps)
//   dialogue      ~ medium indent          parenthetical ~ indented "(...)"
//   transition    ~ right-aligned
async function importPDFScript(file) {
  toast('Loading PDF reader…', 'success');
  await loadPdfJs();

  const ab  = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: ab }).promise;

  // First pass: collect every line {x, right, text} per page
  const allLinesByPage = [];
  const allLines = [];  // flat list for margin/width detection
  for (let p = 1; p <= pdf.numPages; p++) {
    const page     = await pdf.getPage(p);
    const content  = await page.getTextContent();
    const viewport = page.getViewport({ scale: 1 });
    const pageW    = viewport.width;

    // Group items into visual rows by Y (PDF Y is bottom-up)
    const rows = new Map();
    content.items.forEach(item => {
      if (!item.str) return;
      const y = Math.round(item.transform[5] / 3) * 3;
      if (!rows.has(y)) rows.set(y, []);
      rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str });
    });

    const pageLines = [];
    [...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => {
      items.sort((a, b) => a.x - b.x);
      const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim();
      if (!text) return;
      const x     = items[0].x;
      const last  = items[items.length - 1];
      const right = last.x + (last.w || 0);
      pageLines.push({ x, right, pageW, text });
      allLines.push({ x, right, pageW, text });
    });
    allLinesByPage.push(pageLines);
  }
  if (!allLines.length) return '';

  // Detect the left margin (the most common / smallest x of body lines)
  const leftMargin = Math.min(...allLines.map(l => l.x));
  const pageW = allLines[0].pageW || 612;

  // Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks
  const isNoise = t => {
    const s = t.trim();
    if (/^\d{1,4}\.?$/.test(s)) return true;                 // bare page/scene number
    if (/^\(?CONTINUED\)?:?$/i.test(s)) return true;
    if (/^\d+\.$/.test(s)) return true;
    if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true;
    return false;
  };

  const out = [];
  let prevBlank = true;
  let prevWasAction = false; // track consecutive action lines so we don't split paragraphs
  const push = (line, blankBefore) => {
    if (blankBefore && !prevBlank) { out.push(''); }
    out.push(line);
    prevBlank = false;
  };
  const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } };
  const nonAction = () => { prevWasAction = false; };

  for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
    // Emit a page-break marker between PDF pages so the rehearser can paginate exactly
    if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); }

    const pageLns = allLinesByPage[pageIdx] || [];
    for (const ln of pageLns) {
    const t = ln.text.trim();
    if (!t || isNoise(t)) continue;

    const indent = (ln.x - leftMargin) / pageW;       // fraction of page width
    const rightFrac = ln.right / pageW;
    const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t);
    const wordCount = t.split(/\s+/).length;

    // Scene heading (slug line)
    if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) {
      blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction();
      continue;
    }
    // Act / scene number headings (theatre)
    if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) {
      blank(); push(t.toUpperCase()); blank(); nonAction();
      continue;
    }
    // Transition (right-aligned, caps, ends with TO: / IN / OUT)
    if (isCaps && (rightFrac > 0.72 || indent > 0.45) &&
        /(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) {
      blank(); push(t); blank(); nonAction();
      continue;
    }
    // Parenthetical
    if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) {
      push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction();
      continue;
    }
    // Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.))
    const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim();
    if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 &&
        /^[A-Z0-9 .'#\-]+$/.test(nameCore)) {
      blank(); push(t.toUpperCase()); prevBlank = false; nonAction();    // name; dialog follows next line
      continue;
    }
    // Dialogue: medium indent (under a character)
    if (indent > 0.10 && indent < 0.34) {
      push(t, false); nonAction();
      continue;
    }
    // Default: action / description at the left margin.
    // Consecutive action lines join into one paragraph (no blank between them);
    // only start a new paragraph when coming from a non-action line.
    if (prevWasAction) {
      out.push(t); // continue same paragraph
    } else {
      blank(); push(t); // new paragraph
    }
    prevWasAction = true;
    } // end per-page line loop
  } // end page loop

  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}

async function importFromFile(file) {
  try {
    const text = await file.text();
    const data = JSON.parse(text);
    data.clips   = clipsFromJson(data.clips || []);
    data.created = data.created ? new Date(data.created) : new Date();
    data.updated = new Date();
    delete data.id;
    const newId = await rehDbAdd(data);
    toast('Imported: ' + (data.title || 'Untitled'), 'success');
    await renderLibraryList();
    loadRecord({ ...data, id: newId });
  } catch(e) { toast('Import failed: ' + e.message, 'error'); }
}

function loadRecord(rec) {
  if ($('reh-script-text'))  $('reh-script-text').value  = rec.script || '';
  if ($('reh-script-title')) $('reh-script-title').value = rec.title  || '';
  rehState.savedId       = rec.id || null;
  rehState._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it
  rehState.backend       = rec.backend || '';
  rehState.lineIndex     = rec.lineIndex || 0;
  rehState.clips         = (rec.clips || []).map(c => ({ ...c }));
  rehState.synthCache.clear(); rehDecodedBuffers.clear();
  rehState.narratorVoice = rec.narratorVoice || '';
  const lines = parseScript(rec.script || '');
  if (rec.emotions) {
    lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; });
  }
  if (rec.notes) {
    lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; });
  }
  if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; });
  if (rec.hidden)  lines.forEach((l, i) => { if (rec.hidden[i])  l.hidden  = true; });
  rehState.lines = lines;
  rehState.cast  = {};
  const detected = detectCharacters(lines);
  Object.entries(detected).forEach(([sp, def]) => {
    const saved = rec.cast?.[sp];
    rehState.cast[sp] = {
      voice:    saved?.voice ?? def.voice,
      color:    saved?.color ?? def.color,
      instruct: saved?.instruct || '',
      lang:     saved?.lang || '', gender: saved?.gender || '',
      tags:     saved?.tags || '', soul: saved?.soul || '',
      ignored:  !!saved?.ignored, hidden: !!saved?.hidden,
      voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null,
    };
  });
  renderCastList();
  refreshRehBackends().then(() => {
    if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
    if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
  });
  showPhase(2);
}

// ── Library UI ─────────────────────────────────────────────────────────────

// Deterministic cover gradient from the title string
function bookCover(title) {
  let h = 0;
  for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) % 360;
  const h2 = (h + 40) % 360;
  return { c1: `hsl(${h}, 55%, 42%)`, c2: `hsl(${h2}, 58%, 30%)` };
}

let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf';

async function renderLibraryList() {
  const list = $('reh-library-list'); if (!list) return;
  let all;
  try { all = await rehDbGetAll(); } catch(e) { all = []; }
  all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));

  list.classList.toggle('list-view', rehLibView === 'list');
  const vt = $('reh-lib-view-toggle');
  if (vt) vt.innerHTML = `<span class="mdi mdi-${rehLibView === 'list' ? 'view-grid' : 'view-list'}"></span>`;

  if (!all.length) {
    list.innerHTML = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below or drop a file to start.</p></div>';
    return;
  }

  list.innerHTML = all.map(rec => {
    const speakers  = Object.keys(rec.cast || {});
    const meCount   = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
    const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
    const total     = parseScript(rec.script || '').filter(l => l.type === 'dialog').length;
    const pct       = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
    const date      = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
    const isCurrent = rec.id === rehState.savedId;
    const title     = rec.title || 'Untitled';
    const { c1, c2 } = bookCover(title);
    const avatars   = speakers.slice(0, 5).map(sp => {
      const c = rec.cast[sp];
      return `<span style="background:${c.color}">${sp[0].toUpperCase()}</span>`;
    }).join('');
    return `<div class="reh-book" data-id="${rec.id}" style="--bk1:${c1};--bk2:${c2}" title="${escHtml(title)}">
      ${isCurrent ? '<span class="reh-book-active-tag">active</span>' : ''}
      <div class="reh-book-actions">
        <button class="reh-book-act reh-lib-export" data-id="${rec.id}" title="Export .reh"><span class="mdi mdi-export"></span></button>
        <button class="reh-book-act reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
      </div>
      <div class="reh-book-title">${escHtml(title)}</div>
      <div class="reh-book-meta">
        <div class="reh-book-avatars">${avatars}</div>
        ${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
        <div class="reh-book-progress"><div style="width:${pct}%"></div></div>
        <div style="margin-top:4px;opacity:.8">${pct}% · ${date}</div>
      </div>
    </div>`;
  }).join('');

  // Click book → open
  list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => {
    if (e.target.closest('.reh-book-act')) return;
    const id = parseInt(book.dataset.id);
    const db = await rehDbOpen();
    const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
    loadRecord(rec);
  }));

  list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
    e.stopPropagation();
    const id = parseInt(btn.dataset.id);
    const db = await rehDbOpen();
    const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
    rec.version = 1;
    rec.clips = await clipsToJson(rec.clips || []);
    const json = JSON.stringify(rec, null, 2);
    const a = document.createElement('a');
    const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
    a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
    a.download = t + '.reh';
    a.click();
  }));

  list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
    e.stopPropagation();
    
    const bookEl = btn.closest('.reh-book');
    if (bookEl.querySelector('.reh-book-del-confirm')) return;
    
    const confirmOverlay = document.createElement('div');
    confirmOverlay.className = 'reh-book-del-confirm';
    confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
    confirmOverlay.innerHTML = `
      <div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
      <div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
      <div style="display:flex; gap:8px;">
        <button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
        <button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
      </div>
    `;
    
    confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
    
    confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
      ce.stopPropagation();
      confirmOverlay.remove();
    });
    
    confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
      ce.stopPropagation();
      await rehDbDelete(parseInt(btn.dataset.id));
      if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
      renderLibraryList();
    });
    
    bookEl.appendChild(confirmOverlay);
  }));
}

$('reh-lib-view-toggle')?.addEventListener('click', () => {
  rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf';
  localStorage.setItem('reh-lib-view', rehLibView);
  renderLibraryList();
});

// ── Helpers ────────────────────────────────────────────────────────────────

function getVoiceData(voiceId) {
  return (window._voices || []).find(v => v.id === voiceId) || null;
}

function _rehVoiceVisibleId(voiceId, include = '') {
  if (!voiceId) return false;
  if (include && voiceId === include) return true;
  const v = getVoiceData(voiceId);
  return !v || v.enabled !== false;
}

function voiceAvatarHtml(voiceId, color, size = 32) {
  const v = getVoiceData(voiceId);
  const s = size + 'px';
  const radius = Math.round(size / 2);
  if (v?.has_picture) {
    return `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
  }
  const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
  if (ic) {
    return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${ic}" style="font-size:${Math.round(size * 0.56)}px"></span></span>`;
  }
  const initial = (voiceId || '?')[0].toUpperCase();
  return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
}

// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──

// ── Phase navigation ───────────────────────────────────────────────────────

function showPhase(n) {
  // Hide the import/export panel when switching to a numbered phase
  const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
  for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
  syncPhaseTabs(n);
  if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
}

window.showRehImpEx = function () {
  stopPlay(); stopRehMic();
  for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
  const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
  // Show export note based on state
  const note = $('reh-impex-export-note');
  if (note) note.textContent = rehState.lines.length
    ? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
    : 'No active session — load a script first to export.';
};

// Keep the sub-tab bar in sync: highlight current, enable reachable phases
function syncPhaseTabs(n) {
  const hasScript = rehState.lines.length > 0;
  const hasClips  = rehState.clips.length > 0;
  document.querySelectorAll('.reh-subtab').forEach(tab => {
    const p = parseInt(tab.dataset.phase);
    tab.classList.toggle('active', p === n);
    // Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
    let enabled = p === 1
      || (p === 2 && hasScript)
      || (p === 3 && hasScript)
      || (p === 4 && (hasClips || n === 4));
    tab.disabled = !enabled;
  });
}

document.querySelectorAll('.reh-subtab').forEach(tab => {
  tab.addEventListener('click', () => {
    if (tab.disabled) return;
    const p = parseInt(tab.dataset.phase);
    // Leaving the stage: stop playback/mic so audio doesn't keep running
    if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
    if (p === 1) renderLibraryList();
    if (p === 3) {
      // (Re)build the stage if we have a cast/script
      if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
    }
    if (p === 4) { renderSummary(); }
    showPhase(p);
  });
});

// ── Phase 1 ────────────────────────────────────────────────────────────────

$('reh-file-input')?.addEventListener('change', function () {
  const f = this.files?.[0]; if (!f) return;
  const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
  const r = new FileReader();
  r.onload = async e => {
    $('reh-script-text').value = e.target.result;
    await rehAutoSaveImport(guess);
    toast('Script loaded & saved to your library — click Parse & cast', 'success');
  };
  r.readAsText(f); this.value = '';
});

$('reh-pdf-input')?.addEventListener('change', async function () {
  const f = this.files?.[0]; if (!f) return;
  this.value = '';
  try {
    const text = await importPDFScript(f);
    if ($('reh-script-text')) $('reh-script-text').value = text;
    const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
    await rehAutoSaveImport(guess);
    toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
  } catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
});

$('reh-fdx-input')?.addEventListener('change', async function () {
  const f = this.files?.[0]; if (!f) return;
  this.value = '';
  try {
    const text = await importFDX(f);
    if ($('reh-script-text')) $('reh-script-text').value = text;
    if (!$('reh-script-title')?.value.trim()) {
      const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
      if ($('reh-script-title')) $('reh-script-title').value = guess;
    }
    toast('FDX imported — click Parse & cast', 'success');
  } catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
});

// ── Unified import router (used by file pickers + drag & drop) ──────────────
async function rehImportAnyFile(f) {
  if (!f) return;
  const name = f.name.toLowerCase();
  const setTitle = strip => {
    if (!$('reh-script-title')?.value.trim()) {
      const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
      if ($('reh-script-title')) $('reh-script-title').value = guess;
    }
  };
  try {
    if (name.endsWith('.reh') || name.endsWith('.json')) {
      await importFromFile(f);                       // full session → loads & jumps to cast
      return;
    }
    if (name.endsWith('.pdf')) {
      toast('Reading PDF…', 'success');
      const text = await importPDFScript(f);
      if ($('reh-script-text')) $('reh-script-text').value = text;
      setTitle(/\.pdf$/i);
      await rehAutoSaveImport();
      toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
      return;
    }
    if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
      const text = await importFDX(f);
      if ($('reh-script-text')) $('reh-script-text').value = text;
      setTitle(/\.(fdx|osf|xml)$/i);
      await rehAutoSaveImport();
      toast('Script imported & saved to your library — click Parse & cast', 'success');
      return;
    }
    // .txt / .md / .fountain / anything else → plain text
    const text = await f.text();
    if ($('reh-script-text')) $('reh-script-text').value = text;
    setTitle(/\.(txt|md|fountain)$/i);
    await rehAutoSaveImport();
    toast('Script loaded & saved to your library — click Parse & cast', 'success');
  } catch(e) { toast('Import failed: ' + e.message, 'error'); }
}

// Drag & drop over the dropzone AND the whole Phase-1 panel
(function setupRehDropzone() {
  const dz    = $('reh-dropzone');
  const phase = $('reh-phase-1');
  if (!dz || !phase) return;
  let depth = 0;

  const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');

  phase.addEventListener('dragenter', e => {
    if (!isFileDrag(e)) return;
    e.preventDefault(); depth++; dz.classList.add('dragover');
    dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  });
  phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
  phase.addEventListener('dragleave', e => {
    if (!isFileDrag(e)) return;
    depth = Math.max(0, depth - 1);
    if (depth === 0) dz.classList.remove('dragover');
  });
  phase.addEventListener('drop', async e => {
    if (!isFileDrag(e)) return;
    e.preventDefault(); depth = 0; dz.classList.remove('dragover');
    const f = e.dataTransfer.files?.[0];
    await rehImportAnyFile(f);
  });
})();

$('reh-parse-btn')?.addEventListener('click', () => {
  const text = $('reh-script-text')?.value.trim();
  if (!text) { toast('Paste or upload a script first', 'error'); return; }
  rehState.lines = parseScript(text);
  if (!rehState.lines.filter(l => l.type === 'dialog').length) {
    toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
  }
  // Preserve cast for returning characters
  const detected = detectCharacters(rehState.lines);
  Object.entries(detected).forEach(([sp]) => {
    if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
  });
  rehState.cast      = detected;
  // Keep the imported/loaded library entry on the first parse so casting updates it
  // instead of orphaning it; otherwise start a fresh entry.
  if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
  else rehState.savedId = null;
  rehState.clips     = [];
  rehState.lineIndex = 0;
  rehState.synthCache.clear(); rehDecodedBuffers.clear();
  renderCastList();
  showPhase(2);
  refreshRehBackends();
});

// ── Phase 2 ────────────────────────────────────────────────────────────────

function castAvatarHtml(sp) {
  const c = rehState.cast[sp]; if (!c) return '';
  if (c.voice && c.voice !== 'me') {
    const vd = getVoiceData(c.voice);
    if (vd?.has_picture) return `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
    const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
    if (ic) return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}"><span class="mdi ${ic}" style="font-size:20px"></span></span>`;
  }
  const initial = sp[0].toUpperCase();
  return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
}

// ── Online voice picker: audition a sample, browse alternatives, agree/switch ──
let _rehAudEl = null, _rehAudBtn = null;
function _rehStopAudition() {
  if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
  if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
  _rehAudBtn = null;
}
function _rehAudition(url, btn) {
  if (!url) { toast('No preview audio for this voice', 'error'); return; }
  if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
  _rehStopAudition();
  if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
  _rehAudEl.src = url;
  _rehAudEl.play().then(() => {
    _rehAudBtn = btn; btn.classList.add('playing');
    const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
  }).catch(() => toast('Could not play preview', 'error'));
}

const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)

// One row of the voice picker. Plays via sample URL (online) or TTS (local library voice).
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
  const play = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
  const right = isCurrent
    ? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
    : `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
  return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
    <div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
    ${right}</div>`;
}

// Render the "matched from fish.audio" picker: current voice + alternatives + change tools
function castOnlinePanelHtml(sp, c) {
  const o = c.online;
  if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';

  // Current assigned voice — an online candidate (has a sample) or a plain library voice
  const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
  let curRow;
  if (curCand) {
    curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
  } else if (c.voice) {
    const vd = getVoiceData(c.voice);
    curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
  } else {
    curRow = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
  }

  // Alternatives = the other matched candidates (not the current one)
  const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
  const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
    name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
    playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
  })).join('');

  return `<div class="reh-cc-online">
    <div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try &amp; change
      ${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
    <div class="reh-vo-list">${curRow}</div>
    ${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
    <div class="reh-vo-tools">
      <button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
      <button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
    </div>
    <div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
      <input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
      <div class="reh-vo-results reh-vo-local-results"></div>
    </div>
    <div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
      <div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
      <div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
    </div>
  </div>`;
}

// Preview a local library voice by synthesizing a short sample with the chosen backend
async function _rehPreviewLocal(voiceId, btn) {
  const backend = $('reh-backend-select')?.value;
  if (!backend) { toast('Select a TTS backend first', 'error'); return; }
  if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
  _rehStopAudition();
  const icon = btn.querySelector('.mdi');
  btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
  try {
    const blob = await fetchTtsPreviewBlob(voiceId, _REH_PREVIEW_TEXT, 'wav', '', backend);
    if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
    _rehAudEl.src = URL.createObjectURL(blob);
    await _rehAudEl.play();
    btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
    if (icon) icon.className = 'mdi mdi-stop';
  } catch (e) {
    btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
    toast('Preview failed: ' + (e.message || e), 'error');
  }
}

// Pick a representative short line from a character's own dialogue (narrator: from
// the action/scene lines it narrates). Prefers a self-contained ~20–110 char sentence.
function _rehPickOneLiner(sp) {
  const isNarr = sp === REH_NARRATOR_KEY;
  const pool = rehState.lines
    .filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp))
    .map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' }))
    .filter(l => l.text.length >= 4);
  if (!pool.length) return null;
  const scored = pool.map(l => {
    const len = l.text.length;
    let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0);
    if (/[.!?]["']?$/.test(l.text)) score += 1;          // complete sentence
    score -= Math.abs(len - 60) / 120;                    // prefer ~60 chars
    return { l, score };
  }).sort((a, b) => b.score - a.score);
  const best = scored[0].l;
  return { text: best.text.slice(0, 220), emotion: best.emotion };
}

// Synthesize and play a one-liner from this character's lines, in their assigned voice.
async function _rehPreviewCastLine(sp, btn) {
  const c = rehState.cast[sp];
  if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; }
  const backend = $('reh-backend-select')?.value;
  if (!backend) { toast('Select a TTS backend first', 'error'); return; }
  if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
  _rehStopAudition();
  const pick = _rehPickOneLiner(sp);
  if (!pick) { toast('No lines to preview yet', 'error'); return; }
  const icon = btn.querySelector('.mdi');
  btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
  try {
    const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion), backend);
    if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
    _rehAudEl.src = URL.createObjectURL(blob);
    await _rehAudEl.play();
    btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
    if (icon) icon.className = 'mdi mdi-stop';
  } catch (e) {
    btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
    toast('Preview failed: ' + (e.message || e), 'error');
  }
}

// Assign an existing library voice to a character (keeps the picker open)
function _rehAssignLocal(sp, voiceId) {
  const c = rehState.cast[sp]; if (!c) return;
  _rehStopAudition();
  c.voice = voiceId; c.voiceData = getVoiceData(voiceId);
  if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId);
  renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
}

// Render filtered library voices inside a card's "From library" panel
function _rehRenderLocalResults(card, sp, q) {
  const box = card.querySelector('.reh-vo-local-results'); if (!box) return;
  const cur = rehState.cast[sp]?.voice;
  const ql = (q || '').trim().toLowerCase();
  const matches = rehState.voices.filter(v => {
    if (!_rehVoiceVisibleId(v, cur)) return false;
    if (v === cur) return false;
    if (!ql) return true;
    const vd = getVoiceData(v);
    return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
  }).slice(0, 40);
  if (!matches.length) { box.innerHTML = `<div class="reh-vo-hint">No library voices match.</div>`; return; }
  box.innerHTML = matches.map(v => {
    const vd = getVoiceData(v);
    return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` });
  }).join('');
}

// Search fish.audio and render results inside a card's "Search online" panel
async function _rehSearchOnline(card, sp, q) {
  const box = card.querySelector('.reh-vo-search-results'); if (!box) return;
  q = (q || '').trim();
  if (!q) { box.innerHTML = `<div class="reh-vo-hint">Enter a search term first.</div>`; return; }
  box.innerHTML = `<div class="reh-vo-hint"><span class="reh-imsdb-spinner"></span> Searching fish.audio…</div>`;
  const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
  let items = [];
  try {
    const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score'];
    items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || [];
  } catch (_) {}
  const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({
    title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
    language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
  }));
  _rehSearchCache[sp] = cands;
  if (!cands.length) { box.innerHTML = `<div class="reh-vo-hint">No playable voices found for “${escHtml(q)}”.</div>`; return; }
  box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
    name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
    playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
  })).join('');
}

// Import a freshly searched fish.audio result, assign it, and fold it into candidates
async function _rehUseSearchResult(sp, idx, btn) {
  const c = rehState.cast[sp]; if (!c) return;
  const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
  _rehStopAudition();
  const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
  try {
    const vid = await _rehImportFishCandidate(sp, cand);
    cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
    if (!c.online) c.online = { candidates: [], picked: 0 };
    c.online.candidates.push(cand);
    c.online.picked = c.online.candidates.length - 1;
    if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
    renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
    toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
  } catch (err) {
    btn.disabled = false; btn.innerHTML = orig;
    toast('Import failed: ' + err.message, 'error');
  }
}

// Remember fish imports this session so the same voice isn't downloaded twice
const _rehFishImported = {};

// Import one fish.audio candidate into the library and return its voice_id.
// Reuses an already-imported voice (this session OR already in the library) so the
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
async function _rehImportFishCandidate(sp, cand) {
  const key = (cand.sample_audio || cand.title || '').toLowerCase();
  if (key && _rehFishImported[key]) {
    const id = _rehFishImported[key];
    if (!rehState.voices.includes(id)) rehState.voices.push(id);
    return id;
  }
  const title = (cand.title || '').toLowerCase().trim();
  const existing = title && (window._voices || []).find(v =>
    (v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
  if (existing) {
    if (key) _rehFishImported[key] = existing.id;
    if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
    return existing.id;
  }
  const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
  const vid = `${lang2}_${(cand.title || sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
  const d = await fetch('/api/quick-import-voice', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
  }).then(r => r.json());
  if (!d.voice_id) throw new Error('import returned no id');
  if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
  if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
  if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
  if (key) _rehFishImported[key] = d.voice_id;
  return d.voice_id;
}

// "Disagree" → switch this character to one of the presented alternatives
async function _rehUseCandidate(sp, idx, btn) {
  const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
  const cand = o.candidates[idx]; if (!cand) return;
  _rehStopAudition();
  if (cand.voice_id) { // already imported earlier — just reassign
    c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
    renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
    return;
  }
  const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
  try {
    const vid = await _rehImportFishCandidate(sp, cand);
    cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
    if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
    renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
    toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
  } catch (err) {
    btn.disabled = false; btn.innerHTML = orig;
    toast('Import failed: ' + err.message, 'error');
  }
}

const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names

function _ensureNarrator() {
  if (!rehState.cast[REH_NARRATOR_KEY]) {
    rehState.cast[REH_NARRATOR_KEY] = {
      voice: rehState.narratorVoice || '',
      color: '#6b7280',
      instruct: '',
      voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
      _isNarrator: true,
    };
  }
  // Bidirectional sync between the narrator cast row and the global narratorVoice.
  // The cast row is the source of truth (auto-design assigns the voice there), but
  // playback/synth read rehState.narratorVoice — keep them aligned both ways.
  const n = rehState.cast[REH_NARRATOR_KEY];
  if (n.voice) {
    rehState.narratorVoice = n.voice;
    if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
  } else if (rehState.narratorVoice) {
    n.voice = rehState.narratorVoice;
    n.voiceData = getVoiceData(rehState.narratorVoice);
  }
}

const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];

// Apply a mutation to every dialog line spoken by a character
function _castApplyToLines(sp, fn) {
  rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
}

function _safeDomId(value) {
  let out = '';
  const s = String(value || 'voice');
  for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
  return out || 'voice';
}

function renderCastList() {
  const list = $('reh-cast-list'); if (!list) return;
  // Make sure the voice library is loaded so every picker (incl. the narrator) has
  // voices to choose from even when the Rehearser was opened directly.
  if (!(window._voices || []).length && typeof loadVoiceLibrary === 'function' && !rehState._castLibFetch) {
    rehState._castLibFetch = true;
    loadVoiceLibrary().then(() => renderCastList()).catch(() => {});
  }
  _ensureNarrator();
  const narr = REH_NARRATOR_KEY;
  const lineCount  = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
  const allOthers  = Object.keys(rehState.cast).filter(s => s !== narr);
  const others     = _castSortFilter(allOthers, lineCount);   // narrator always stays pinned on top
  const speakers   = [narr, ...others];
  const scriptTitle = $('reh-script-title')?.value.trim() || '';

  list.innerHTML = speakers.map(sp => {
    const c = rehState.cast[sp];
    const isNarr = sp === narr;
    const isMe   = c.voice === 'me';
    const label  = isNarr ? 'Narrator' : sp;
    const n = lineCount(sp);
    const sub = isNarr ? 'scene headings &amp; descriptions' : `${n} line${n!==1?'s':''}${scriptTitle ? ` · ${escHtml(scriptTitle)}` : ''}`;
    const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : '');
    const langSel = REH_CAST_LANGS.map(l => `<option${(c.lang||'')===l?' selected':''}>${l}</option>`).join('');
    const genSel  = REH_CAST_GENDERS.map(([v,t]) => `<option value="${v}"${(c.gender||'')===v?' selected':''}>${t}</option>`).join('');
    const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
    const voiceSel = `<select id="${pickerId}" class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
        <option value="">— ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} —</option>
        ${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
      </select>`;

    const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
    return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
      <div class="reh-cc-head">
        <div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
        <div class="reh-cc-titles">
          <div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
          <div class="reh-cc-sub">${sub}</div>
          <div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
          ${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
        </div>
      </div>
      ${!isNarr ? `<div class="reh-cc-actions">
        <label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
        <span class="reh-cc-actions-spacer"></span>
        <button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this character’s lines" aria-label="Ignore this character’s lines"><span class="mdi mdi-eye-off-outline"></span></button>
        <button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this character’s lines from the script" aria-label="Hide this character’s lines"><span class="mdi mdi-minus-circle-outline"></span></button>
        <button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
      </div>` : ''}
      <div class="reh-cc-body"${isMe?' style="display:none"':''}>
        <div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
        <div class="reh-cc-extra">
          ${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
          ${!isNarr ? `<div class="reh-cc-grid">
            <div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
            <div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
            <div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
          </div>` : ''}
          <div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
            <input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
          ${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
            <summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
              <button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
            </summary>
            <textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
          </details>` : ''}
        </div>
      </div>
    </div>`;
  }).join('');

  // Cast count (reflects active filter) + keep the chosen view (card / list)
  const countEl = $('reh-cast-count');
  if (countEl) {
    const shown = others.length, total = allOthers.length;
    countEl.textContent = shown === total
      ? `${total} ${total === 1 ? 'character' : 'characters'} + narrator`
      : `${shown} of ${total} characters`;
  }
  _wireCastControls();
  applyCastView();

  // ── Wiring ────────────────────────────────────────────────────────────────
  const card = el => el.closest('.reh-cast-card');
  const spOf = el => card(el).dataset.speaker;

  if (window.VoicePicker) {
    list.querySelectorAll('.reh-voice-sel[id]').forEach(sel => {
      const cur = sel.value;
      VoicePicker.upgrade(sel.id);
      if (cur) VoicePicker.setValue(sel.id, cur);
    });
  }

  list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
    const sp = spOf(this);
    rehState.cast[sp].voice = this.checked ? 'me' : (card(this).querySelector('.reh-voice-sel')?.value || '');
    rehState.cast[sp].voiceData = this.checked ? null : getVoiceData(rehState.cast[sp].voice);
    renderCastList();
  }));
  list.querySelectorAll('.reh-voice-sel').forEach(sel => sel.addEventListener('change', function () {
    const sp = this.dataset.speaker;
    rehState.cast[sp].voice = this.value;
    rehState.cast[sp].voiceData = getVoiceData(this.value);
    delete rehState.cast[sp].online; // manual override → drop the online-match picker context
    if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = this.value;
    renderCastList();
  }));
  list.querySelectorAll('.reh-cast-instruct').forEach(inp => inp.addEventListener('input', function () {
    rehState.cast[spOf(this)].instruct = this.value;
  }));
  list.querySelectorAll('.reh-cc-lang').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].lang = this.value; }));
  list.querySelectorAll('.reh-cc-gender').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].gender = this.value; }));
  list.querySelectorAll('.reh-cc-tags').forEach(i => i.addEventListener('input', function () { rehState.cast[spOf(this)].tags = this.value; }));
  list.querySelectorAll('.reh-cc-soul-text').forEach(t => t.addEventListener('input', function () { rehState.cast[spOf(this)].soul = this.value; }));
  list.querySelectorAll('.reh-cc-develop').forEach(b => b.addEventListener('click', function (e) { e.preventDefault(); _castDevelop(spOf(this), this); }));
  list.querySelectorAll('.reh-cc-iconbtn').forEach(b => b.addEventListener('click', function () {
    const sp = spOf(this), act = this.dataset.act, c = rehState.cast[sp];
    if (act === 'ignore') { c.ignored = !c.ignored; _castApplyToLines(sp, l => l.ignored = c.ignored); renderCastList(); }
    else if (act === 'hide') { c.hidden = !c.hidden; _castApplyToLines(sp, l => l.hidden = c.hidden); renderCastList(); }
    else if (act === 'delete') { _castDeleteCharacter(sp); }
  }));

  // Online voice picker — delegated so dynamically-injected result rows work too.
  // `list` persists across re-renders, so attach these handlers only once.
  if (!list._voDelegated) {
   list._voDelegated = true;
   list.addEventListener('click', e => {
    const sampleBtn = e.target.closest('.reh-cc-sample-btn');
    if (sampleBtn) { e.preventDefault(); _rehPreviewCastLine(sampleBtn.dataset.speaker, sampleBtn); return; }

    const playBtn = e.target.closest('.reh-vo-play');
    if (playBtn) { e.preventDefault(); if (playBtn.dataset.voice) _rehPreviewLocal(playBtn.dataset.voice, playBtn); else _rehAudition(playBtn.dataset.url, playBtn); return; }

    const toggle = e.target.closest('.reh-vo-toggle');
    if (toggle) {
      e.preventDefault();
      const alts = toggle.closest('.reh-cc-online')?.querySelector('.reh-vo-alts');
      if (alts) { alts.hidden = !alts.hidden; toggle.textContent = toggle.textContent.replace(/[▾▴]\s*$/, '') + (alts.hidden ? '▾' : '▴'); }
      return;
    }

    const tool = e.target.closest('.reh-vo-tool');
    if (tool) {
      e.preventDefault();
      const panel = tool.closest('.reh-cc-online');
      const want = tool.dataset.tool;
      const localP = panel.querySelector('.reh-vo-local-panel');
      const searchP = panel.querySelector('.reh-vo-search-panel');
      const showLocal = want === 'local' && (localP?.hidden ?? true);
      const showSearch = want === 'search' && (searchP?.hidden ?? true);
      if (localP) localP.hidden = !showLocal;
      if (searchP) searchP.hidden = !showSearch;
      panel.querySelectorAll('.reh-vo-tool').forEach(t => t.classList.toggle('active', (t.dataset.tool === 'local' && showLocal) || (t.dataset.tool === 'search' && showSearch)));
      if (showLocal) { const card = e.target.closest('.reh-cast-card'); _rehRenderLocalResults(card, spOf(tool), ''); card.querySelector('.reh-vo-local-input')?.focus(); }
      if (showSearch) panel.querySelector('.reh-vo-search-input')?.focus();
      return;
    }

    const goBtn = e.target.closest('.reh-vo-search-go');
    if (goBtn) { e.preventDefault(); const card = e.target.closest('.reh-cast-card'); _rehSearchOnline(card, spOf(goBtn), card.querySelector('.reh-vo-search-input')?.value); return; }

    const use = e.target.closest('.reh-vo-use');
    if (use) {
      e.preventDefault();
      const sp = spOf(use), act = use.dataset.act;
      if (act === 'local') _rehAssignLocal(sp, use.dataset.voice);
      else if (act === 'search') _rehUseSearchResult(sp, parseInt(use.dataset.idx, 10), use);
      else _rehUseCandidate(sp, parseInt(use.dataset.idx, 10), use); // 'alt'
    }
  });
  list.addEventListener('input', e => {
    const li = e.target.closest('.reh-vo-local-input');
    if (li) _rehRenderLocalResults(e.target.closest('.reh-cast-card'), spOf(li), li.value);
  });
  list.addEventListener('keydown', e => {
    const si = e.target.closest('.reh-vo-search-input');
    if (si && e.key === 'Enter') { e.preventDefault(); _rehSearchOnline(e.target.closest('.reh-cast-card'), spOf(si), si.value); }
   });
  }
}

// Cast card / list view toggle
function applyCastView() {
  const list = $('reh-cast-list'); if (!list) return;
  const view = rehState.castView === 'list' ? 'list' : 'card';
  list.classList.toggle('reh-cast-view-card', view === 'card');
  list.classList.toggle('reh-cast-view-list', view === 'list');
  document.querySelectorAll('#reh-cast-view-toggle .reh-view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
}
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }

// Cast sort + filter state (narrator excluded — it's pinned on top by renderCastList)
rehState.castFilter = { search: '', gender: '', lang: '' };
try { rehState.castSort = JSON.parse(localStorage.getItem('reh-cast-sort')) || { by: 'name', dir: 'asc' }; }
catch (_) { rehState.castSort = { by: 'name', dir: 'asc' }; }

// Wire the cast toolbar (view toggle + sort/filter). Called from renderCastList so it
// always binds once the section is mounted; guarded to attach only once.
function _wireCastControls() {
  const toggle = $('reh-cast-view-toggle');
  if (!toggle || toggle._wired) return;
  toggle._wired = true;
  toggle.querySelectorAll('.reh-view-btn').forEach(b => b.addEventListener('click', () => {
    rehState.castView = b.dataset.view;
    try { localStorage.setItem('reh-cast-view', b.dataset.view); } catch (_) {}
    applyCastView();
  }));
  const search = $('reh-cast-search'), fg = $('reh-cast-filter-gender'), fl = $('reh-cast-filter-lang');
  const sortSel = $('reh-cast-sort'), dirBtn = $('reh-cast-sort-dir');
  const setDirIcon = () => { if (dirBtn) { dirBtn.dataset.dir = rehState.castSort.dir; dirBtn.querySelector('.mdi').className = 'mdi mdi-sort-' + (rehState.castSort.dir === 'desc' ? 'descending' : 'ascending'); } };
  const persist = () => { try { localStorage.setItem('reh-cast-sort', JSON.stringify(rehState.castSort)); } catch (_) {} };
  if (sortSel) sortSel.value = rehState.castSort.by;
  setDirIcon();
  search?.addEventListener('input', () => { rehState.castFilter.search = search.value.trim(); renderCastList(); search.focus(); });
  fg?.addEventListener('change', () => { rehState.castFilter.gender = fg.value; renderCastList(); });
  fl?.addEventListener('change', () => { rehState.castFilter.lang = fl.value; renderCastList(); });
  sortSel?.addEventListener('change', () => {
    rehState.castSort.by = sortSel.value;
    rehState.castSort.dir = sortSel.value === 'lines' ? 'desc' : 'asc';   // lines → most first
    setDirIcon(); persist(); renderCastList();
  });
  dirBtn?.addEventListener('click', () => {
    rehState.castSort.dir = rehState.castSort.dir === 'desc' ? 'asc' : 'desc';
    setDirIcon(); persist(); renderCastList();
  });
}

function _castSortFilter(speakers, lineCount) {
  const f = rehState.castFilter || {}, s = rehState.castSort || { by: 'name', dir: 'asc' };
  const out = speakers.filter(sp => {
    const c = rehState.cast[sp] || {};
    if (f.search) { const q = f.search.toLowerCase(); if (!sp.toLowerCase().includes(q) && !(c.tags || '').toLowerCase().includes(q)) return false; }
    if (f.gender && (c.gender || '') !== f.gender) return false;
    if (f.lang && (c.lang || '') !== f.lang) return false;
    return true;
  });
  const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
  const cmp = {
    name:   byName,
    gender: (a, b) => (rehState.cast[a].gender || '~').localeCompare(rehState.cast[b].gender || '~') || byName(a, b),
    lang:   (a, b) => (rehState.cast[a].lang || '~').localeCompare(rehState.cast[b].lang || '~') || byName(a, b),
    lines:  (a, b) => (lineCount(a) - lineCount(b)) || byName(a, b),
    tag:    (a, b) => (rehState.cast[a].tags || '~').localeCompare(rehState.cast[b].tags || '~') || byName(a, b),
  }[s.by] || byName;
  out.sort(cmp);
  if (s.dir === 'desc') out.reverse();
  return out;
}

// Remove a character and all of their dialog lines (with index-state remap)
function _castDeleteCharacter(sp) {
  const n = rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
  if (!confirm(`Delete “${sp}” and their ${n} line${n!==1?'s':''}? This cannot be undone.`)) return;
  for (let i = rehState.lines.length - 1; i >= 0; i--) {
    if (rehState.lines[i].speaker === sp && rehState.lines[i].type === 'dialog') {
      rehState.lines.splice(i, 1);
      _reindexLineState(i);
    }
  }
  delete rehState.cast[sp];
  renderCastList();
  if (rehState.lines.length) buildScriptPage();
  toast(`Removed ${sp}`, 'success');
}

// Ask the LLM to flesh out one character → fills the soul brief + design prompt
async function _castDevelop(sp, btn) {
  const script = $('reh-script-text')?.value.trim() || linesToScriptText();
  if (!script) { toast('Load a script first', 'error'); return; }
  const c = rehState.cast[sp];
  const orig = btn.innerHTML;
  btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Developing…';
  try {
    const r = await fetch('/api/analyze-characters', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        script, names: [sp],
        llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
        model: $('reh-llm-model')?.value || '',
        language: c.lang || $('reh-design-lang')?.value || 'English',
      }),
    });
    if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
    const info = (await r.json()).characters?.[0] || {};
    if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
    if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; }
    renderCastList();
    toast(`Developed ${sp}`, 'success');
  } catch (e) {
    btn.disabled = false; btn.innerHTML = orig;
    toast('Develop failed: ' + e.message, 'error');
  }
}

async function refreshRehBackends() {
  rehInitLlmField();
  const sel = $('reh-backend-select'); if (!sel) return;
  let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
  // If the global list hasn't been probed yet, trigger the probe now
  if (!backends.length && typeof refreshTtsBackendAvailability === 'function') {
    await refreshTtsBackendAvailability().catch(() => {});
    backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
  }
  // Last resort: show all known backends (even unavailable ones) so the user can try
  if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) {
    backends = _ttsBackends;
  }
  const prev = sel.value || rehState.backend;
  sel.innerHTML = backends.length
    ? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
    : '<option value="">No backend available</option>';
  if (prev && [...sel.options].some(o => o.value === prev)) {
    // Restore a previously chosen backend
    sel.value = prev;
  } else {
    // Default backend preference. fishspeech is best — it clones each voice's saved WAV
    // (consistent identity) AND honours per-line emotion markers (tone), so a character
    // sounds the same across the rehearsal while still reacting to tone. voice_clone is the
    // fallback for consistency (weak tone); voice_design gives tone but drifts each line.
    const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design'];
    const pick = preferred.find(id => [...sel.options].some(o => o.value === id));
    if (pick) sel.value = pick;
  }
  rehState.backend = sel.value || '';   // keep state in sync with the (possibly auto-picked) value
  _checkToneStyleSupport();
}

// Keep the rehearser select in sync whenever the global backend list refreshes
(window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => {
  const sel = $('reh-backend-select'); if (!sel) return;
  const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
  if (!backends.length) return;
  const prev = sel.value || rehState.backend;
  sel.innerHTML = backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('');
  if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
  else if (sel.options.length) sel.value = sel.options[0].value;
  rehState.backend = sel.value || '';
});

// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
function _rehAllVoiceIds(include) {
  const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
  (window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
  if (include) ids.add(include);
  return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}

function populateNarratorSelect() {
  const sel = $('reh-narrator-voice'); if (!sel) return;
  const cur = rehState.narratorVoice || sel.value;
  sel.innerHTML = '<option value="">— none (skip or pause) —</option>' +
    _rehAllVoiceIds(cur).map(v => `<option value="${escHtml(v)}"${v===cur?' selected':''}>${escHtml(v)}</option>`).join('');
}

$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
  const backend = $('reh-backend-select')?.value;
  if (!backend) { toast('Select a backend first', 'error'); return; }
  $('reh-fetch-voices-btn').disabled = true;
  try {
    if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
      await loadVoiceLibrary().catch(() => {});
    }
    const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
    rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
      .filter(id => _rehVoiceVisibleId(id));
    renderCastList();
    populateNarratorSelect();
    toast('Fetched ' + rehState.voices.length + ' voices', 'success');
  } catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
  finally { $('reh-fetch-voices-btn').disabled = false; }
});

$('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; });
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));

// ── TTS instruct builder — emotion goes first as a directive ─────────────────
// voice_clone backends treat instruct as an IDENTITY description; putting the
// emotion first and using directive language ("Speak in a … manner") makes the
// model prioritise it over the static voice description, which otherwise wins.
function _buildInstruct(voiceProfile, emotion) {
  const p = (voiceProfile || '').trim();
  const e = (emotion     || '').trim();
  if (!e && !p) return '';
  if (!e) return p;
  if (!p) return `Speak in a ${e} manner.`;
  return `Speak in a ${e} manner. ${p}`;
}

// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
// input text (the OpenAI-style `instruct` field is ignored). For fish backends we
// therefore prepend the tone as a [tag] so per-line tones actually take effect.
function _rehBackendIsFish() {
  let id = '';
  try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; }
  catch (_) { id = rehState.backend || ''; }
  return /fish/i.test(id);
}
function _rehInlineTone(text, emotion) {
  const e = (emotion || '').trim();
  if (!e || !_rehBackendIsFish()) return text;
  if (/^\s*\[/.test(text)) return text;          // already carries an inline [tag]
  return `[${e.toLowerCase()}] ${text}`;
}

// ── Auto-design voices with LLM + Design a Voice ────────────────────────────

const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' };

function rehDefaultLlmUrl() {
  try {
    if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url)
      return _appSettings.llm_url;
  } catch(_) {}
  return 'http://localhost:11434/v1';
}

// Collect all LLM endpoints visible in the Language Models section
function rehCollectLlmEndpoints() {
  const seen = new Set();
  const results = [];
  const add = (url, label) => {
    if (!url) return;
    url = url.trim();
    if (!url || seen.has(url)) return;
    seen.add(url);
    results.push({ url, label: label || url });
  };
  // 1. Active LLM from settings
  add(rehDefaultLlmUrl(), 'Active LLM');
  // 2. All URL inputs in the Language Models section (llm-local-url-inp)
  document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => {
    const v = inp.value?.trim();
    const def = inp.dataset.llmLocalDefault;
    const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || '';
    const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]');
    const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key;
    add(v || def, name);
  });
  // 3. The dc-url-inp fields with LLM role
  document.querySelectorAll('.dc-url-inp').forEach(inp => {
    const card = inp.closest('[class*="llm-local-card"]');
    if (!card) return;
    const name = card.querySelector('.llm-local-name')?.textContent?.trim() || '';
    add(inp.value?.trim() || inp.dataset.dcDefault, name);
  });
  // 4. Always include common defaults as suggestions
  const DEFAULTS = [
    ['http://localhost:11434/v1', 'Ollama'],
    ['http://localhost:8000/v1',  'vLLM'],
    ['http://localhost:1234/v1',  'LM Studio'],
    ['http://localhost:28080/v1', 'llama-swap'],
    ['http://localhost:14000/v1', 'LiteLLM'],
  ];
  DEFAULTS.forEach(([u, l]) => add(u, l));
  return results;
}

// Prefill LLM URL + datalist when Phase 2 first shows voices
function rehInitLlmField() {
  const u = $('reh-llm-url');
  if (!u) return;
  if (!u.value) u.value = rehDefaultLlmUrl();
  // Populate datalist with all known LLM endpoints
  const dl = $('reh-llm-url-list');
  if (dl) {
    dl.innerHTML = rehCollectLlmEndpoints()
      .map(e => `<option value="${e.url.replace(/"/g, '&quot;')}">${e.label}</option>`)
      .join('');
  }
}

$('reh-llm-refresh')?.addEventListener('click', async () => {
  const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
  const sel = $('reh-llm-model'); if (!sel) return;
  sel.innerHTML = '<option value="">Loading…</option>';
  try {
    const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
    const d = await r.json();
    const models = d.models || [];
    sel.innerHTML = '<option value="">— default —</option>' +
      models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
    // Default to the global Active LLM model when available (still overridable here)
    const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : '';
    if (want && models.includes(want)) sel.value = want;
    toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error');
  } catch(e) {
    sel.innerHTML = '<option value="">— default —</option>';
    toast('Could not list models: ' + e.message, 'error');
  }
});

// Avatar icon keys for designed voices — male / female / neutral / robot / animal.
// Picked from the LLM description (robot/animal cues) then gender.
const REH_AVATAR_ICONS = {
  male:    'mdi-face-man',
  female:  'mdi-face-woman',
  neutral: 'mdi-account',
  robot:   'mdi-robot-outline',
  animal:  'mdi-paw',
};
function _pickVoiceAvatar(gender, desc, speaker) {
  const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase();
  if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot';
  if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal';
  if (gender === 'M') return 'male';
  if (gender === 'F') return 'female';
  return 'neutral';
}

let rehDesignCancelled = false;

$('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; });

$('reh-autodesign-btn')?.addEventListener('click', async () => {
  const backend = $('reh-backend-select')?.value;
  if (!backend) { toast('Select a TTS backend first', 'error'); return; }

  const speakers = Object.keys(rehState.cast);
  if (!speakers.length) { toast('No characters to design for', 'error'); return; }

  const script  = $('reh-script-text')?.value.trim() || linesToScriptText();
  const llmUrl  = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
  const llmModel= $('reh-llm-model')?.value || '';
  const language= $('reh-design-lang')?.value || 'English';
  const langCode= REH_LANG_CODE[language] || 'EN';
  const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
  const tag = scriptTitle.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';

  const btn = $('reh-autodesign-btn');
  const prog = $('reh-autodesign-progress');
  const fill = $('reh-autodesign-fill');
  const label= $('reh-autodesign-label');
  btn.disabled = true;
  rehDesignCancelled = false;
  if (prog) prog.hidden = false;
  const setProg = (d, t, msg) => {
    if (fill)  fill.style.width = (t ? (d/t)*100 : 0) + '%';
    if (label) label.textContent = msg || `${d} / ${t}`;
  };
  setProg(0, speakers.length, 'Analyzing script with LLM…');

  // 1) Ask the LLM to describe each character
  let characters;
  try {
    const r = await fetch('/api/analyze-characters', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }),
    });
    if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
    const d = await r.json();
    characters = d.characters || [];
  } catch(e) {
    toast('Character analysis failed: ' + e.message, 'error');
    btn.disabled = false; if (prog) prog.hidden = true;
    return;
  }

  // Map analysis back to speakers (case-insensitive match)
  const byName = {};
  characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });

  // 2) For each character, design + save a voice
  let done = 0;
  const designOnly = speakers.filter(sp => rehState.cast[sp].voice !== 'me');
  for (const sp of designOnly) {
    if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
    const info = byName[sp.toUpperCase().trim()] || {};
    const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
    const desc = info.description ||
      `A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
    const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;

    const safeName = sp.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
    const voiceId  = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
    rehMarkCastDesigning(sp, 'designing', null, {
      gender, language, voiceId, desc,
      age: info.age || '',
      step: 'Generating voice audio…',
    });
    setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`);

    try {
      // Generate the voice audio
      const dr = await fetch('/api/voice-design', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }),
      });
      if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); }
      const dd = await dr.json();

      // Save it to the voice library, tagged with script name + Rehearser
      const sr = await fetch('/api/save', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }),
      });
      if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
      const saved = await sr.json();

      // Tag metadata — display name = character, script as a tag, origin 'designed',
      // grouped by play title, plus an auto-picked gender/type avatar icon.
      const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
      if (typeof saveMeta === 'function') {
        await saveMeta(saved.voice_id, {
          gender,
          name:   charName,
          avatar: _pickVoiceAvatar(gender, desc, sp),
          origin: 'designed',
          group:  `Rehearser: ${scriptTitle}`,
          note: `Rehearser · ${scriptTitle} · ${charName} — ${desc.slice(0, 180)}`,
          transcript: stripMarkdown(sampleLine).slice(0, 300),
          tag: scriptTitle,
        }).catch(()=>{});
      }

      // Assign to cast + remember the LLM's research as the style note & character soul
      rehState.cast[sp].voice    = saved.voice_id;
      rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc;
      rehState.cast[sp].soul     = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · ');
      rehState.cast[sp].voiceData = null;
      if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id);
      rehMarkCastDesigning(sp, 'done');
    } catch(e) {
      rehMarkCastDesigning(sp, 'err', e.message);
    }
    done++;
    setProg(done, designOnly.length);
  }

  // Refresh the broader voice library so avatars/pictures resolve
  if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{});

  renderCastList();
  populateNarratorSelect();
  if (prog) prog.hidden = true;
  btn.disabled = false;
  if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
});

// ── Match from library — LLM picks the best EXISTING voice for each character ──
// ── Character research → per-character note ──────────────────────────────────
// Store the LLM's reading of the play as each character's "soul" note (+ gender / style).
function rehWriteCharacterNote(sp, info) {
  const c = rehState.cast[sp]; if (!c || !info) return;
  if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
  const bits = [];
  if (info.age) bits.push(`Age: ${info.age}`);
  if (info.description) bits.push(info.description);
  const note = bits.join(' · ');
  if (note && !c.soul) c.soul = note;                       // fills the Character-soul / LLM-brief note
  if (info.description && !c.instruct) c.instruct = info.description;
}

// Ask the LLM to read the script + cast, then annotate every character with a note.
// Returns a name→info map (used by the online/design flows that need gender too).
async function rehResearchCast(speakers) {
  const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY);
  if (!names.length) return {};
  let chars = [];
  try {
    const r = await fetch('/api/analyze-characters', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        script: $('reh-script-text')?.value.trim() || linesToScriptText(), names,
        llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
        model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English',
      }),
    });
    if (r.ok) chars = (await r.json()).characters || [];
  } catch (_) {}
  const byName = {};
  chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
  speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));
  return byName;
}

$('reh-matchlib-btn')?.addEventListener('click', async () => {
  const btn = $('reh-matchlib-btn');
  const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me');
  if (!speakers.length) { toast('No characters to match', 'error'); return; }
  let lib = (window._voices || []).filter(v => v.enabled !== false);
  if (!lib.length) {
    // Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
    try { lib = (await fetch('/api/voices').then(r => r.json())).filter(v => v.enabled !== false); } catch (_) {}
  }
  if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }

  const candidates = lib.map(v => ({
    id: v.id, gender: v.gender || '', language: v.lang || '',
    tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
  }));
  const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);
  const byDisplay = {};
  speakers.forEach(sp => { byDisplay[nameFor(sp).toUpperCase().trim()] = sp; });

  const orig = btn.innerHTML;
  btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Matching…';
  try {
    const r = await fetch('/api/match-characters-voices', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        script: $('reh-script-text')?.value.trim() || linesToScriptText(),
        names: speakers.map(nameFor),
        voices: candidates,
        llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
        model: $('reh-llm-model')?.value || '',
        language: $('reh-design-lang')?.value || 'English',
      }),
    });
    if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
    const assignments = (await r.json()).assignments || [];
    const validIds = new Set(candidates.map(c => c.id));
    let n = 0;
    assignments.forEach(a => {
      const sp = byDisplay[String(a.name || '').toUpperCase().trim()];
      if (sp && a.voice_id && validIds.has(a.voice_id)) {
        rehState.cast[sp].voice = a.voice_id;
        rehState.cast[sp].voiceData = getVoiceData(a.voice_id);
        if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id);
        if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id;
        n++;
      }
    });
    // Research the play + cast and drop a note on each character
    btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Researching characters…';
    await rehResearchCast(speakers);
    renderCastList();
    populateNarratorSelect();
    toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error');
  } catch (e) {
    toast('Match failed: ' + e.message, 'error');
  } finally {
    btn.disabled = false; btn.innerHTML = orig;
  }
});

// ── Match from online — search fish.audio per character, import & assign ──────
const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' };
$('reh-matchonline-btn')?.addEventListener('click', async () => {
  const btn = $('reh-matchonline-btn');
  const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY);
  if (!speakers.length) { toast('No characters to match', 'error'); return; }
  const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
  const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label');
  const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; };

  const orig = btn.innerHTML;
  btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Analyzing…';
  if (prog) prog.hidden = false;
  rehDesignCancelled = false;
  try {
    // 1) Ask the LLM for each character's gender (drives the online search)
    let chars = [];
    try {
      const ar = await fetch('/api/analyze-characters', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers,
          llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '',
          language: $('reh-design-lang')?.value || 'English' }),
      });
      if (ar.ok) chars = (await ar.json()).characters || [];
    } catch (_) {}
    const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
    const G = { M: 'male', F: 'female', N: 'neutral' };

    let done = 0, n = 0;
    for (let i = 0; i < speakers.length; i++) {
      if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
      const sp = speakers[i];
      setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`);
      const info = byName[sp.toUpperCase().trim()] || {};
      rehWriteCharacterNote(sp, info);   // drop the LLM's character research into a note
      const gender = G[(info.gender || '').toUpperCase().charAt(0)] || '';
      // Search fish.audio: by character name first (catches known/celebrity names), then gender+lang
      const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score'];
      let items = [];
      try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {}
      let pick = items.find(v => v.sample_audio);
      let nameHit = !!pick;   // got a result by searching the character's actual name
      if (!pick) {  // fallback: best by gender+language, offset per character to diversify
        const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean);
        try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {}
        pick = items.find(v => v.sample_audio);
      }
      // Keep the top few playable results so the user can audition & swap them later
      let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({
        title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
        language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
      }));
      // When the name didn't match, the fallback returns the same popular voices for
      // everyone — rotate the pick per character so they don't all sound identical,
      // and skip voices already taken by earlier characters this run.
      if (!nameHit && cands.length > 1) {
        const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean));
        const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)];
        cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
      }
      if (cands.length) {
        try {
          const vid = await _rehImportFishCandidate(sp, cands[0]);
          cands[0].voice_id = vid;
          rehState.cast[sp].voice = vid;
          rehState.cast[sp].voiceData = getVoiceData(vid);
          rehState.cast[sp].online = { candidates: cands, picked: 0 };
          n++;
        } catch (e) { logErr('match-online import ' + sp, e); }
      }
      done++; setProg(done, speakers.length);
    }
    if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
    renderCastList();
    populateNarratorSelect();
    toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error');
  } catch (e) {
    toast('Online match failed: ' + e.message, 'error');
  } finally {
    btn.disabled = false; btn.innerHTML = orig;
    if (prog) prog.hidden = true;
  }
});

// Show per-character design progress card beneath the cast row
function rehMarkCastDesigning(sp, state, msg, info) {
  const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);
  if (!row) return;

  // Inline badge in the cast row header
  let badge = row.querySelector('.reh-cast-design-badge');
  if (!badge) {
    badge = document.createElement('span');
    badge.className = 'reh-cast-design-badge';
    row.querySelector('strong')?.after(badge);
  }
  badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : '');
  badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed';
  if (msg) badge.title = msg;
  row.classList.toggle('reh-cast-designing', state === 'designing');

  // Expanded detail panel beneath the instruct row
  const wrap = row.closest('div');
  let panel = wrap?.querySelector('.reh-cast-design-panel');

  if (state === 'designing' && info) {
    if (!panel) {
      panel = document.createElement('div');
      panel.className = 'reh-cast-design-panel';
      wrap.appendChild(panel);
    }
    const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧';
    const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral';
    panel.innerHTML = `
      <div class="reh-cdp-meta">
        <span class="reh-cdp-chip reh-cdp-gender">${genderIcon} ${escHtml(genderLabel)}</span>
        <span class="reh-cdp-chip reh-cdp-lang"><span class="mdi mdi-translate"></span> ${escHtml(info.language || 'EN')}</span>
        <span class="reh-cdp-chip reh-cdp-name"><span class="mdi mdi-account-outline"></span> ${escHtml(info.voiceId || sp)}</span>
        ${info.age ? `<span class="reh-cdp-chip">${escHtml(info.age)}</span>` : ''}
      </div>
      <div class="reh-cdp-desc">${escHtml(info.desc || '')}</div>
      <div class="reh-cdp-step"><span class="reh-imsdb-spinner"></span> <span class="reh-cdp-step-txt">${escHtml(info.step || 'Designing voice…')}</span></div>`;
  } else if (state === 'done' && panel) {
    // Collapse to a summary line
    const descEl = panel.querySelector('.reh-cdp-desc');
    const metaEl = panel.querySelector('.reh-cdp-meta');
    const stepEl = panel.querySelector('.reh-cdp-step');
    if (stepEl) stepEl.remove();
    if (descEl) {
      // Truncate
      const t = descEl.textContent;
      if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…';
    }
    panel.classList.add('done');
  } else if (state === 'err') {
    if (panel) panel.remove();
  }
}

$('reh-start-btn')?.addEventListener('click', () => {
  const backend = $('reh-backend-select')?.value;
  if (!backend) { toast('Select a backend first', 'error'); return; }
  rehState.backend       = backend;
  rehState.narratorVoice = $('reh-narrator-voice')?.value || '';
  rehState.lineIndex     = 0;
  rehState.clips         = [];
  rehState.playing       = false;
  rehState.synthCache.clear(); rehDecodedBuffers.clear();
  rehState.practiceStart = null;
  rehState.practiceEnd   = null;
  buildScriptPage();
  showPhase(3);
  highlightCurrentLine();
});

// ── Stage display controls (font size + collapsible cast) ───────────────────

const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale';
const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed';

function rehStageScale() {
  const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1');
  return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v));
}
function rehApplyStageFont() {
  // Set on the page wrapper so the scale inherits to BOTH the single A4 page
  // (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode.
  const scale = String(rehStageScale());
  const wrap = document.querySelector('.reh-page-wrap');
  if (wrap) wrap.style.setProperty('--reh-stage-scale', scale);
  const page = $('reh-a4-page');
  if (page) page.style.setProperty('--reh-stage-scale', scale);
}
function rehStageFontStep(delta) {
  const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100));
  localStorage.setItem(REH_STAGE_FONT_KEY, String(next));
  rehApplyStageFont();
}

function rehApplyCastCollapsed() {
  const row = $('reh-cast-row');
  if (!row) return;
  const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
  row.classList.toggle('reh-cast-collapsed', collapsed);
  const btn = $('reh-cast-toggle');
  if (btn) btn.setAttribute('aria-expanded', String(!collapsed));
}
function rehToggleCast() {
  const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
  localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1');
  rehApplyCastCollapsed();
}

$('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1));
$('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1));
$('reh-cast-toggle')?.addEventListener('click', rehToggleCast);

// ── A4 Script page ─────────────────────────────────────────────────────────

function buildScriptPage() {
  const titleEl = $('reh-page-title');
  if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';

  // Cast strip
  const castStrip = $('reh-cast-strip');
  if (castStrip) {
    const names = Object.keys(rehState.cast);
    castStrip.innerHTML = names.map(sp => {
      const c = rehState.cast[sp], isMe = c.voice === 'me';
      return `<div class="reh-strip-char" title="${escHtml(sp)} — ${isMe?'me':(c.voice||'no voice')}">
        ${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
        <span style="font-size:10px;font-weight:700;color:${c.color};max-width:56px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(sp)}</span>
        ${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
      </div>`;
    }).join('');
    const lbl = $('reh-cast-toggle-label');
    if (lbl) lbl.textContent = `Characters (${names.length})`;
  }
  rehApplyStageFont();
  rehApplyCastCollapsed();

  const linesEl = $('reh-script-lines'); if (!linesEl) return;

  linesEl.innerHTML = rehState.lines.map((line, i) => {
    const isCached = rehState.synthCache.has(i);
    const isStale  = rehState.staleLines.has(i);
    const dotCls   = isStale ? 'reh-synth-dot stale' : 'reh-synth-dot';
    const dotTitle = isStale ? 'Tone changed — needs re-synthesis' : 'Pre-synthesized';
    const synthDot = `<span class="${dotCls}" id="reh-syd-${i}" style="${(isCached||isStale)?'':'display:none'}" title="${dotTitle}"></span>`;
    const reSynthBtn = `<button class="reh-resynth-btn" id="reh-rsb-${i}" title="Re-synthesize this line" ${isStale?'':'hidden'}><span class="mdi mdi-refresh"></span></button>`;
    const editBtn  = `<button class="reh-edit-btn" data-index="${i}" title="Edit text (or double-click)"><span class="mdi mdi-pencil-outline"></span></button>`;
    const note     = rehState.lines[i].note || '';
    // noteArea includes the edit button so both icons live in the right gutter
    const noteArea = `<div class="reh-note-area" data-index="${i}">
      ${editBtn}
      <button class="reh-note-btn${note?' has-note':''}" title="${note?'Edit note':'Add note'}"><span class="mdi mdi-note-edit-outline"></span></button>
      <textarea class="reh-note-ta" placeholder="Personal note (not spoken)…" spellcheck="false">${escHtml(note)}</textarea>
    </div>`;

    // Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing
    // them inside bulk mode so they can be selected and restored.
    if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return '';
    const _bSel = rehState.bulkSel.has(i);
    const bulkCheck = rehState.bulkMode
      ? `<span class="reh-bulk-check${_bSel ? ' checked' : ''}" data-bulk="${i}" title="Select line"><span class="mdi ${_bSel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'}"></span></span>`
      : '';
    const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');

    switch (line.type) {
      case 'act':
        return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;

      case 'scene':
        return `<div class="reh-scene${lineFlags}" data-index="${i}">
          ${bulkCheck}<span class="mdi mdi-film" style="flex-shrink:0"></span>
          <span style="flex:1">${escHtml(line.text)}</span>
          ${editBtn} ${synthDot}
        </div>`;

      case 'action':
        return `<div class="reh-action-block${lineFlags}" data-index="${i}">
          ${bulkCheck}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
          ${editBtn} ${synthDot}
        </div>`;

      case 'pagebreak': {
        const pbLabel = line.page ? `— Page ${line.page} —` : '— Page break —';
        return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
      }

      case 'transition':
        return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;

      case 'direction': {
        const indent = line.speaker ? 'text-align:center;' : '';
        return `<div class="reh-direction${lineFlags}" data-index="${i}" style="${indent}">${bulkCheck}<em>${escHtml(line.text)}</em></div>`;
      }

      case 'dialog': {
        const c     = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' };
        const isMe  = c.voice === 'me';
        const emoInfo = getEmotionInfo(line.emotion || '');
        return `<div class="reh-block${lineFlags}${rehState.practiceEnd===i?' reh-range-end':''}${rehState.practiceStart===i?' reh-range-start':''}${isPracticeRange(i)?' reh-in-range':''}" data-index="${i}" data-speaker="${escHtml(line.speaker)}" style="cursor:pointer">
          ${bulkCheck}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
          <div class="reh-block-inner">
            <div class="reh-block-head">
              <button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}</button>
              <span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
              <span class="reh-block-head-spacer"></span>
              ${isMe
                ? '<span class="reh-block-badge reh-badge-me"><span class="mdi mdi-microphone"></span> Me</span>'
                : '<span class="reh-block-badge reh-badge-tts"><span class="mdi mdi-speaker-outline"></span> TTS</span>'
              }
              ${!isMe
                ? `<button class="reh-emo-btn${line.emotion?' has-emotion':''}" data-index="${i}" title="Set speaking tone">
                    ${emoInfo.emoji ? emoInfo.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(emoInfo.label)}</span>
                    <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>
                  </button>`
                : ''
              }
              ${synthDot}
              ${reSynthBtn}
            </div>
            <div class="reh-block-dialog" id="reh-diag-${i}">${renderMarkdownInline(line.text)}</div>
          </div>
          ${noteArea}
        </div>`;
      }

      default: return '';
    }
  }).join('');

  // Emotion picker buttons
  linesEl.querySelectorAll('.reh-emo-btn').forEach(btn => {
    btn.addEventListener('click', e => { e.stopPropagation(); openEmoPicker(parseInt(btn.dataset.index), btn); });
  });

  // Edit buttons
  linesEl.querySelectorAll('.reh-edit-btn').forEach(btn => {
    btn.addEventListener('click', e => { e.stopPropagation(); startInlineEdit(parseInt(btn.dataset.index)); });
  });

  // Double-click on dialog / action text to edit
  linesEl.querySelectorAll('.reh-block-dialog').forEach(el => {
    el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
  });
  linesEl.querySelectorAll('.reh-action-text').forEach(el => {
    el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
  });

  // Avatar button: play/pause a single character line
  linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      const idx = parseInt(btn.dataset.index);
      if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; }
      stopPlay();
      hideRecOverlay();
      rehState.lineIndex = idx;
      highlightCurrentLine();
      startPlay();
    });
  });

  // Gutter button: play from here / set end
  linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      const idx = parseInt(btn.dataset.index);
      if (e.shiftKey) {
        // Set / clear practice end
        rehState.practiceEnd = rehState.practiceEnd === idx ? null : idx;
      } else {
        // Jump to this line and start playing
        stopPlay();
        hideRecOverlay();
        rehState.lineIndex = idx;
        highlightCurrentLine();
        startPlay();
      }
      updatePracticeRange();
    });
  });

  // Click block to jump; in Select mode, click toggles selection and Shift+click selects a range.
  linesEl.querySelectorAll('[data-index]').forEach(el => {
    el.addEventListener('click', e => {
      if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return;
      const idx = parseInt(el.dataset.index);
      if (rehState.bulkMode) {
        _toggleBulkSel(idx, e.shiftKey);
        return;
      }
      stopPlay();
      hideRecOverlay();
      rehState.lineIndex = idx;
      highlightCurrentLine();
    });
  });

  // Re-synth buttons (per line)
  linesEl.querySelectorAll('.reh-resynth-btn').forEach(btn => {
    btn.addEventListener('click', e => {
      e.stopPropagation();
      synthOneLine(parseInt(btn.id.replace('reh-rsb-', '')));
    });
  });

  // Note area: toggle textarea visibility on button click, save on change
  linesEl.querySelectorAll('.reh-note-area').forEach(area => {
    const idx = parseInt(area.dataset.index);
    const btn = area.querySelector('.reh-note-btn');
    const ta  = area.querySelector('.reh-note-ta');
    if (!btn || !ta) return;
    // Show if note has content
    if (ta.value.trim()) ta.classList.add('open');
    btn.addEventListener('click', e => {
      e.stopPropagation();
      ta.classList.toggle('open');
      if (ta.classList.contains('open')) ta.focus();
    });
    ta.addEventListener('input', () => {
      rehState.lines[idx].note = ta.value;
      btn.classList.toggle('has-note', !!ta.value.trim());
    });
    ta.addEventListener('click', e => e.stopPropagation());
  });

  // Bulk-edit: leading checkboxes toggle line selection. The page wrapper also
  // gets the mode class because A4/PDF modes move blocks out of #reh-script-lines.
  linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode);
  document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode);
  if (rehState.bulkMode) {
    linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => {
      cb.addEventListener('click', e => {
        e.stopPropagation();
        _toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey);
      });
    });
  }

  // Apply current pagination mode
  applyPageMode();

  // Warn if backend doesn't support tone/style changes
  _checkToneStyleSupport();
}

// ── Bulk-edit (line selection: ignore / hide / delete) ──────────────────────

function _toggleBulkSel(i, range = false) {
  if (!Number.isFinite(i)) return;
  if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) {
    const a = Math.min(rehState.bulkAnchor, i);
    const b = Math.max(rehState.bulkAnchor, i);
    const indices = [];
    for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n);
    const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n));
    indices.forEach(n => {
      if (deselect) rehState.bulkSel.delete(n);
      else rehState.bulkSel.add(n);
      _refreshBulkLine(n);
    });
    rehState.bulkAnchor = i;
  } else {
    if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i);
    else rehState.bulkSel.add(i);
    rehState.bulkAnchor = i;
    _refreshBulkLine(i);
  }
  _updateBulkCount();
}

function _refreshBulkLine(i) {
  const sel = rehState.bulkSel.has(i);
  document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb => {
    cb.classList.toggle('checked', sel);
    const icon = cb.querySelector('.mdi');
    if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline');
  });
  document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel));
}

function _updateBulkCount() {
  const el = $('reh-bulk-count');
  if (el) el.textContent = `${rehState.bulkSel.size} selected`;
}

function setBulkMode(on) {
  rehState.bulkMode = on;
  if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; }
  const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on;
  const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on);
  _updateBulkCount();
  if (rehState.lines.length) buildScriptPage();
}

// Apply a mutation to all selected lines, then re-render.
function _bulkApply(fn, { keepSelection = false } = {}) {
  if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
  [...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); });
  if (!keepSelection) rehState.bulkSel.clear();
  _updateBulkCount();
  buildScriptPage();
}

function _bulkDelete() {
  if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
  const victims = [...rehState.bulkSel].sort((a, b) => b - a);  // high → low so indices stay valid
  victims.forEach(i => {
    rehState.lines.splice(i, 1);
    _reindexLineState(i);
  });
  rehState.bulkSel.clear();
  // Keep the cursor in range
  if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1);
  _updateBulkCount();
  buildScriptPage();
  highlightCurrentLine();
  toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success');
}

// After deleting line `d`, shift every index-keyed bit of state above it down by one.
function _reindexLineState(d) {
  const shift = (collection, isMap) => {
    const out = isMap ? new Map() : new Set();
    for (const entry of collection) {
      const k = isMap ? entry[0] : entry;
      if (k === d) continue;            // dropped line
      const nk = k > d ? k - 1 : k;
      if (isMap) out.set(nk, entry[1]); else out.add(nk);
    }
    return out;
  };
  rehState.synthCache = shift(rehState.synthCache, true);
  rehState.staleLines = shift(rehState.staleLines, false);
  const sel = shift(rehState.bulkSel, false);
  rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v));
  if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--;
  if (rehState.practiceEnd   != null && rehState.practiceEnd   > d) rehState.practiceEnd--;
  if (rehState.lineIndex > d) rehState.lineIndex--;
}

// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
let _pageMode = localStorage.getItem('reh-page-mode') || 'auto';

function applyPageMode() {
  const wrap = document.querySelector('.reh-page-wrap');
  const a4   = $('reh-a4-page');
  if (!wrap) return;

  if (_pageMode === 'scroll') {
    // Endless scroll — put all blocks back into the a4 div, no paper pages
    wrap.querySelectorAll('.reh-paper').forEach(p => {
      [...p.children].forEach(c => { if (!c.classList.contains('reh-paper-num')) a4?.appendChild(c); });
      p.remove();
    });
    wrap.classList.remove('paginated');
    if (a4) a4.style.display = '';
    // Hide pagebreak visual dividers in scroll mode
    document.querySelectorAll('.reh-block-pagebreak').forEach(el => el.style.display = 'none');
  } else if (_pageMode === 'pdf') {
    // PDF pages — paginate at pagebreak markers
    paginateScript({ respectBreaks: true });
  } else {
    // Auto — paginate by content height
    paginateScript({ respectBreaks: false });
  }
  _syncPageModeBtn();
}

function _syncPageModeBtn() {
  const btn = $('reh-page-mode-btn');
  if (!btn) return;
  const icons  = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' };
  const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' };
  btn.innerHTML = `<span class="mdi ${icons[_pageMode] || icons.auto}"></span> ${labels[_pageMode] || labels.auto}`;
  btn.title = 'Switch view: ' + labels[_pageMode];
}

function cyclePageMode() {
  const idx = PAGE_MODES.indexOf(_pageMode);
  _pageMode = PAGE_MODES[(idx + 1) % PAGE_MODES.length];
  localStorage.setItem('reh-page-mode', _pageMode);
  applyPageMode();
  // Rebuild script page to apply cleanly
  if (rehState.lines.length) buildScriptPage();
}

// ── Pagination: split the continuous script into A4 paper pages ─────────────
function paginateScript({ respectBreaks = false } = {}) {
  const wrap    = document.querySelector('.reh-page-wrap');
  const linesEl = $('reh-script-lines');
  const titleEl = $('reh-page-title');
  if (!wrap || !linesEl) return;

  // Collect the already-wired block elements (moving them keeps listeners)
  const blocks = [...linesEl.children];
  if (!blocks.length) return;

  wrap.classList.add('paginated');
  const a4 = $('reh-a4-page');
  if (a4) a4.style.display = 'none';
  wrap.querySelectorAll('.reh-paper').forEach(p => p.remove());

  // Printable content height inside a page (A4 minus vertical padding)
  const PAGE_CONTENT = 1027;     // 1123 − 56 − 40
  const TITLE_SPACE  = 64;       // page-1 title block allowance

  let pageNum = 0, page = null, used = 0;
  const newPage = () => {
    pageNum++;
    page = document.createElement('div');
    page.className = 'reh-paper';
    const num = document.createElement('div');
    num.className = 'reh-paper-num';
    num.textContent = pageNum + '.';
    page.appendChild(num);
    used = 0;
    if (pageNum === 1 && titleEl) {
      const t = titleEl.cloneNode(true);
      t.style.display = '';
      page.appendChild(t);
      used += TITLE_SPACE;
    }
    wrap.appendChild(page);
  };
  newPage();

  for (const block of blocks) {
    // PDF page-break marker: always start a new page here
    if (respectBreaks && block.classList.contains('reh-block-pagebreak')) {
      newPage();
      continue; // don't move the marker div into the page
    }

    // Measure block height (must be in DOM to measure → append then check)
    page.appendChild(block);
    const cs = getComputedStyle(block);
    const h  = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0);

    // If adding this block overflows the page (and page already has content), move to a new page
    if (!respectBreaks && used + h > PAGE_CONTENT && used > (pageNum === 1 ? TITLE_SPACE : 0)) {
      newPage();
      page.appendChild(block);
    }
    used += h;
  }
}

function isPracticeRange(idx) {
  const s = rehState.practiceStart, e = rehState.practiceEnd;
  if (s === null) return false;
  return idx >= s && (e === null || idx <= e);
}

// ── Inline editing ──────────────────────────────────────────────────────────

function startInlineEdit(idx) {
  const line = rehState.lines[idx]; if (!line) return;
  const dialogEl = document.getElementById('reh-diag-' + idx);
  const actionEl = document.querySelector(`[data-index="${idx}"] .reh-action-text`);
  const sceneEl  = document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`);
  const targetEl = dialogEl || actionEl || sceneEl;
  if (!targetEl || targetEl.tagName === 'TEXTAREA') return;

  const orig = line.text;
  const ta   = document.createElement('textarea');
  ta.value   = orig;
  ta.style.cssText = 'width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;';

  // Formatting toolbar
  const toolbar = document.createElement('div');
  toolbar.className = 'reh-fmt-toolbar';
  toolbar.innerHTML = `
    <button class="reh-fmt-btn" data-b="**" title="Bold"><b>B</b></button>
    <button class="reh-fmt-btn" data-b="*" title="Italic"><i>I</i></button>
    <button class="reh-fmt-btn" data-b="__" title="Underline"><u>U</u></button>
    <button class="reh-fmt-btn" data-b="~~" title="Strikethrough"><del>S</del></button>
    <button class="reh-fmt-btn" data-b="==" title="Highlight" style="background:#fef3c7">H</button>
    <kbd class="reh-fmt-hint">Ctrl+Enter save · Esc cancel</kbd>
  `;
  toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => {
    btn.addEventListener('mousedown', e => {
      e.preventDefault();
      const wrap = btn.dataset.b;
      const s = ta.selectionStart, end = ta.selectionEnd;
      const sel = ta.value.slice(s, end) || 'text';
      ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end);
      ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length);
      ta.focus();
    });
  });

  targetEl.style.display = 'none';
  targetEl.before(toolbar);
  toolbar.after(ta);
  ta.focus(); ta.select();

  const save = () => {
    const newText = ta.value.trim() || orig;
    line.text = newText;
    targetEl.innerHTML = renderMarkdownInline(newText);
    targetEl.style.display = '';
    toolbar.remove(); ta.remove();
    rehState.synthCache.delete(idx);
    const dot = document.getElementById('reh-syd-' + idx);
    if (dot) dot.style.display = 'none';
  };

  ta.addEventListener('keydown', e => {
    if (e.key === 'Escape')                      { ta.value = orig; save(); }
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
  });
  ta.addEventListener('blur', save);
}

// ── Script editor modal ─────────────────────────────────────────────────────

function openScriptEditorModal() {
  const currentTitle  = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script';
  const currentScript = linesToScriptText() || $('reh-script-text')?.value || '';

  const modal = document.createElement('div');
  modal.className = 'reh-modal-overlay';
  modal.innerHTML = `
    <div class="reh-modal-box" style="max-width:720px;width:95vw;height:80vh">
      <div class="reh-modal-head">
        <h3 style="margin:0;font-size:15px"><span class="mdi mdi-script-text-outline"></span> Edit Script</h3>
        <button class="btn-secondary btn-sm" id="_reh-mc">✕ Close</button>
      </div>
      <div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center">
        <label style="font-size:12px;font-weight:600;flex-shrink:0">Title</label>
        <input type="text" id="_reh-mt" value="${escHtml(currentTitle)}" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:13px;background:var(--surface);color:var(--text)">
      </div>
      <textarea id="_reh-ms" style="flex:1;padding:14px;font-family:'Courier New',monospace;font-size:12.5px;border:none;outline:none;resize:none;background:var(--surface);color:var(--text);height:calc(80vh - 140px);width:100%;box-sizing:border-box;" spellcheck="false">${escHtml(currentScript)}</textarea>
      <div style="padding:10px 14px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
        <button class="btn-secondary" id="_reh-mcan">Cancel</button>
        <button class="btn-primary" id="_reh-mapp"><span class="mdi mdi-auto-fix"></span> Apply changes</button>
      </div>
    </div>
  `;
  document.body.appendChild(modal);

  const close = () => modal.remove();
  modal.querySelector('#_reh-mc').onclick   = close;
  modal.querySelector('#_reh-mcan').onclick = close;
  modal.addEventListener('click', e => { if (e.target === modal) close(); });

  modal.querySelector('#_reh-mapp').onclick = () => {
    const newTitle  = modal.querySelector('#_reh-mt').value.trim() || 'Script';
    const newScript = modal.querySelector('#_reh-ms').value;

    const newLines = parseScript(newScript);
    if (!newLines.filter(l => l.type === 'dialog').length) {
      toast('No dialog found in the edited script', 'error'); return;
    }

    // Merge emotions for unchanged lines
    newLines.forEach(nl => {
      if (nl.type !== 'dialog') return;
      const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text);
      if (old) nl.emotion = old.emotion;
    });

    // Keep cast for matching characters
    const detected = detectCharacters(newLines);
    Object.entries(detected).forEach(([sp]) => {
      if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
    });

    // Update state
    rehState.lines    = newLines;
    rehState.cast     = detected;
    rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1);
    rehState.synthCache.clear(); rehDecodedBuffers.clear();
    rehState.practiceStart = null;
    rehState.practiceEnd   = null;

    if ($('reh-script-title')) $('reh-script-title').value = newTitle;
    if ($('reh-page-title'))   $('reh-page-title').textContent = newTitle;

    buildScriptPage();
    highlightCurrentLine();
    close();
    toast('Script updated', 'success');
  };
}

// ── Practice range ──────────────────────────────────────────────────────────

function updatePracticeRange() {
  const start = rehState.practiceStart;
  const end   = rehState.practiceEnd;

  document.querySelectorAll('.reh-block').forEach(el => {
    const idx = parseInt(el.dataset.index);
    el.classList.toggle('reh-in-range',    start !== null && idx >= start && (end === null || idx <= end));
    el.classList.toggle('reh-range-start', start !== null && idx === start);
    el.classList.toggle('reh-range-end',   end   !== null && idx === end);
    const gb = el.querySelector('.reh-gutter-btn');
    if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : '';
  });

  const info = $('reh-practice-info');
  if (info) {
    const show = end !== null;
    info.classList.toggle('visible', show);
    info.hidden = !show;
    if (show) {
      const f = $('reh-practice-from'), t = $('reh-practice-to');
      if (f) f.textContent = (start !== null ? start : 0) + 1;
      if (t) t.textContent = end + 1;
    }
  }
}

function clearPracticeRange() {
  rehState.practiceStart = null;
  rehState.practiceEnd   = null;
  updatePracticeRange();
}

function setPracticeRangeFromSelection() {
  const picked = [...rehState.bulkSel].sort((a, b) => a - b);
  if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; }
  rehState.practiceStart = picked[0];
  rehState.practiceEnd = picked[picked.length - 1];
  rehState.lineIndex = rehState.practiceStart;
  setBulkMode(false);
  updatePracticeRange();
  highlightCurrentLine();
  toast(`Practice range set: lines ${rehState.practiceStart + 1}–${rehState.practiceEnd + 1}`, 'success');
}

$('reh-practice-clear')?.addEventListener('click', clearPracticeRange);
$('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection);

// ── Emotion picker popover ──────────────────────────────────────────────────

let rehEmoPicker = null;

function openEmoPicker(idx, anchorBtn) {
  closeEmoPicker();
  const line     = rehState.lines[idx];
  const allEmos  = [...REH_EMOTIONS, ...rehCustomEmotions];
  const rect     = anchorBtn.getBoundingClientRect();
  const left     = Math.min(rect.left, window.innerWidth - 265);
  const topBelow = rect.bottom + 4;
  const topAbove = rect.top - 4;
  const spaceBelow = window.innerHeight - topBelow;
  const top = spaceBelow >= 240 ? topBelow : (topAbove - 260);

  const pop = document.createElement('div');
  pop.className = 'reh-emo-popover';
  pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`;
  pop.innerHTML = `<div class="reh-emo-pop-inner">
    <input class="reh-emo-search" placeholder="Search or type custom tone…" autocomplete="off">
    <div class="reh-emo-list"></div>
    <div class="reh-emo-custom-row">
      <button class="btn-secondary btn-sm" id="_emo-custom-apply" style="width:100%;font-size:11px">
        <span class="mdi mdi-plus"></span> Use as custom tone
      </button>
    </div>
  </div>`;
  document.body.appendChild(pop);
  rehEmoPicker = pop;

  const searchEl = pop.querySelector('.reh-emo-search');
  const listEl   = pop.querySelector('.reh-emo-list');
  const applyBtn = pop.querySelector('#_emo-custom-apply');

  function renderList(q = '') {
    const lq = q.toLowerCase();
    const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq));
    listEl.innerHTML = filtered.map(e =>
      `<div class="reh-emo-item${line.emotion===e.value?' selected':''}" data-value="${escHtml(e.value)}">
        <span class="reh-emo-item-emoji">${e.emoji}</span>
        <span class="reh-emo-item-label">${escHtml(e.label)}</span>
        ${line.emotion===e.value?'<span class="mdi mdi-check" style="margin-left:auto;font-size:13px;color:var(--accent)"></span>':''}
      </div>`
    ).join('');
    listEl.querySelectorAll('.reh-emo-item').forEach(item => {
      item.addEventListener('mousedown', e => {
        e.preventDefault();
        selectEmotion(idx, item.dataset.value, anchorBtn);
        closeEmoPicker();
      });
    });
  }

  searchEl.addEventListener('input', () => renderList(searchEl.value));
  searchEl.addEventListener('keydown', e => {
    if (e.key === 'Escape') closeEmoPicker();
    if (e.key === 'Enter') {
      const val = searchEl.value.trim();
      if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
    }
  });

  applyBtn.addEventListener('mousedown', e => {
    e.preventDefault();
    const val = searchEl.value.trim(); if (!val) return;
    const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
    if (!exists) {
      rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
      try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
    }
    selectEmotion(idx, val, anchorBtn);
    closeEmoPicker();
  });

  renderList();
  searchEl.focus();

  setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
}

function _closePickerOnOutside(e) {
  if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
}

function closeEmoPicker() {
  if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
  document.removeEventListener('mousedown', _closePickerOnOutside);
}

function _markSynthDot(idx, state) {
  const dot = document.getElementById('reh-syd-' + idx);
  if (!dot) return;
  dot.className = 'reh-synth-dot'
    + (state === 'stale'       ? ' stale'       : '')
    + (state === 'synthesizing' ? ' synthesizing' : '');
  dot.style.display = state ? '' : 'none';
  dot.title = state === 'stale'        ? 'Tone changed — needs re-synthesis'
            : state === 'synthesizing' ? 'Synthesizing…'
            : 'Pre-synthesized';
  // Also highlight the block row itself while synthesizing
  const block = dot.closest('[data-index]');
  if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
}

function _showReSynthBtn(idx, show) {
  const btn = document.getElementById('reh-rsb-' + idx);
  if (btn) btn.hidden = !show;
}

async function synthOneLine(idx) {
  const line = rehState.lines[idx];
  if (!line || line.type !== 'dialog') return;
  const c = rehState.cast[line.speaker];
  if (!c || !c.voice || c.voice === 'me') return;
  const instruct = _buildInstruct(c.instruct, line.emotion);
  _showReSynthBtn(idx, false);
  _markSynthDot(idx, 'synthesizing');
  try {
    const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
    rehState.synthCache.set(idx, blob);
    rehState.staleLines.delete(idx);
    preDecodeBlob(idx, blob);
    _markSynthDot(idx, 'ok');
    toast('Re-synthesized line ' + (idx + 1), 'success');
  } catch(e) {
    _markSynthDot(idx, null);
    _showReSynthBtn(idx, true);
    toast('Synthesis failed: ' + e.message, 'error');
  }
}

function selectEmotion(idx, value, anchorBtn) {
  rehState.lines[idx].emotion = value;
  if (rehState.synthCache.has(idx)) {
    rehState.synthCache.delete(idx);
    rehState.staleLines.add(idx);
    _markSynthDot(idx, 'stale');
    _updateStaleBatchBtn();
  }
  _showReSynthBtn(idx, true);
  // Update button
  const info = getEmotionInfo(value);
  anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
  anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
  // Show warning if the active backend doesn't reliably support style
  if (value) _checkToneStyleSupport();
}

function _checkToneStyleSupport() {
  const warn = $('reh-tone-warn'); if (!warn) return;
  const txtEl = $('reh-tone-warn-txt');
  const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
  if (!b) { warn.hidden = true; return; }
  const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
  const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);

  // Two opposite engine trade-offs, surfaced so the user can choose knowingly:
  //  • clone backends  → consistent character identity, but weak tone control
  //  • design backends → strong tone, but a fresh persona each call (voices drift)
  if (_rehBackendIsFish()) {
    // Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
    if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent <em>and</em> applies tone. Per-line tones are sent as inline <code>[tags]</code> (e.g. <code>[whisper]</code>, <code>[excited]</code>, <code>[laughing]</code>). You can also type a custom tone like <code>[professional broadcast tone]</code> — S2 supports free-form descriptions. <a href="https://huggingface.co/fishaudio/s2-pro" target="_blank" rel="noopener">Fish-Speech S2 ↗</a>`;
    warn.hidden = false;
  } else if (!b.style_aware && hasTone) {
    const styleAware = all.find(x => x.style_aware);
    const suggest = styleAware ? ` Switch to <strong>${escHtml(styleAware.label)}</strong> for reliable tone — but expect each voice to drift between lines.` : '';
    if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.${suggest}`;
    warn.hidden = false;
  } else if (b.style_aware && !b.uses_wav) {
    const wavBackend = all.find(x => x.uses_wav);
    const suggest = wavBackend ? ` Switch to <strong>${escHtml(wavBackend.label)}</strong> to keep each character’s voice identical throughout.` : '';
    const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? ' Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.' : '';
    if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`;
    warn.hidden = false;
  } else {
    warn.hidden = true;
  }
}

$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });

// ── Transport controls ──────────────────────────────────────────────────────

$('reh-tb-play')?.addEventListener('click', () => {
  if (rehState.playing) pausePlay(); else startPlay();
});

$('reh-tb-stop')?.addEventListener('click', () => {
  stopPlay();
  rehState.lineIndex = rehState.practiceStart ?? 0;
  highlightCurrentLine();
  hideRecOverlay();
});

$('reh-tb-prev')?.addEventListener('click', () => {
  stopPlay();
  rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
  highlightCurrentLine();
  hideRecOverlay();
});

$('reh-tb-next')?.addEventListener('click', () => {
  stopPlay();
  rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
  highlightCurrentLine();
  hideRecOverlay();
});

$('reh-tb-repeat')?.addEventListener('click', () => {
  rehState.repeat = !rehState.repeat;
  $('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
});

$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });

$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);

$('reh-exit-btn')?.addEventListener('click', () => {
  stopPlay(); stopRehMic();
  if (rehState.clips.length) { renderSummary(); showPhase(4); }
  else showPhase(2);
});

// ── Page title inline editing ───────────────────────────────────────────────

$('reh-page-title')?.addEventListener('dblclick', function () {
  this.contentEditable = 'true';
  this.style.outline = '2px solid var(--accent)';
  this.style.borderRadius = '3px';
  this.focus();
  const range = document.createRange();
  range.selectNodeContents(this);
  window.getSelection().removeAllRanges();
  window.getSelection().addRange(range);
});

$('reh-page-title')?.addEventListener('blur', function () {
  if (this.contentEditable === 'true') {
    this.contentEditable = 'false';
    this.style.outline = '';
    const v = this.textContent.trim() || 'Script';
    this.textContent = v;
    if ($('reh-script-title')) $('reh-script-title').value = v;
  }
});

$('reh-page-title')?.addEventListener('keydown', function (e) {
  if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
  if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
});

// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────

let _rehPlayCtx   = null;
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
let rehCurrentSource  = null;        // active AudioBufferSourceNode
let rehWordHighlightRaf = null;

function rehPlayCtx() {
  if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
  return _rehPlayCtx;
}

// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
// blob, so keep only a sliding window around the playhead. Without this, a long
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
const REH_DECODE_WINDOW = 8;
function _rehEvictDecoded(keepIdx) {
  if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
  const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
  for (const k of rehDecodedBuffers.keys()) {
    if (k < lo || k > hi) rehDecodedBuffers.delete(k);
  }
}

async function preDecodeBlob(lineIdx, blob) {
  if (rehDecodedBuffers.has(lineIdx)) return;
  try {
    const ab  = await blob.arrayBuffer();
    const buf = await rehPlayCtx().decodeAudioData(ab);
    rehDecodedBuffers.set(lineIdx, buf);
    _rehEvictDecoded(lineIdx);
  } catch(_) {}
}

function computeWordTimings(text, durationSec) {
  const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
  if (words.length < 2) return [];
  // Distribute proportionally by character length (longer words = more time)
  const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
  let t = 0;
  return words.map(w => {
    const start = t;
    t += (w.length / totalChars) * durationSec;
    return { word: w, start, end: t };
  });
}

function stopAudioSource() {
  if (rehCurrentSource) {
    try { rehCurrentSource.stop(0); } catch(_) {}
    rehCurrentSource = null;
  }
  if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
}

// Instant playback from pre-decoded buffer + word-level highlight
async function playPreDecoded(lineIdx, blob, text) {
  if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
  _rehEvictDecoded(lineIdx);   // keep the window centred on the playhead
  const buf = rehDecodedBuffers.get(lineIdx);
  if (!buf) { await playAudioBlobFallback(blob); return; }

  const timings = computeWordTimings(text, buf.duration);
  const dialogEl = document.getElementById('reh-diag-' + lineIdx);

  if (dialogEl && timings.length >= 2) {
    dialogEl.innerHTML = timings.map((t, i) =>
      `<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
    ).join(' ');
  }

  return new Promise(resolve => {
    stopAudioSource();
    const ctx = rehPlayCtx();
    const src = ctx.createBufferSource();
    src.buffer = buf;
    src.connect(ctx.destination);
    rehCurrentSource = src;
    const t0 = ctx.currentTime;

    src.onended = () => {
      rehCurrentSource = null;
      if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
      if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
      resolve();
    };

    src.start(0);

    // Pre-decode the line AFTER this one while this is playing
    const nextI = findNextCachedLine(lineIdx + 1);
    if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));

    if (dialogEl && timings.length >= 2) {
      const tick = () => {
        if (rehCurrentSource !== src) return;
        const elapsed = ctx.currentTime - t0;
        let active = 0;
        for (let i = timings.length - 1; i >= 0; i--) {
          if (elapsed >= timings[i].start) { active = i; break; }
        }
        dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
          span.classList.toggle('reh-word-active', i === active);
        });
        rehWordHighlightRaf = requestAnimationFrame(tick);
      };
      rehWordHighlightRaf = requestAnimationFrame(tick);
    }
  });
}

function findNextCachedLine(fromIdx) {
  for (let i = fromIdx; i < rehState.lines.length; i++) {
    if (rehState.lines[i].type === 'dialog' && rehState.synthCache.has(i)) return i;
  }
  return -1;
}

// Fallback when AudioContext decode fails
async function playAudioBlobFallback(blob) {
  const url   = URL.createObjectURL(blob);
  const audio = $('reh-tts-audio'); if (!audio) return;
  audio.src = url; audio.style.display = '';
  await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
  await audio.play().catch(() => {});
  await waitForAudioEnd(audio);
}

// ── Auto-play sequence ──────────────────────────────────────────────────────

function startPlay() {
  rehPlayCtx(); // warm up AudioContext on user gesture
  _ensureNarrator();  // make sure rehState.narratorVoice reflects the narrator cast row
  rehState.playing = true;
  updatePlayBtn();
  playNextLine();
}

function pausePlay() {
  rehState.playing = false;
  updatePlayBtn();
  stopAudioSource();
  const audio = $('reh-tts-audio');
  if (audio && !audio.paused) audio.pause();
  hideStatusBar();
}

function stopPlay() {
  rehState.playing = false;
  updatePlayBtn();
  stopAudioSource();
  const audio = $('reh-tts-audio');
  if (audio) { audio.pause(); audio.src = ''; }
  hideStatusBar();
}

function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }

async function playNextLine() {
  if (!rehState.playing) return;

  // Check practice end boundary
  if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) {
    rehState.playing = false;
    updatePlayBtn();
    if (rehState.repeat) {
      rehState.lineIndex = rehState.practiceStart ?? 0;
      startPlay();
    } else {
      rehState.lineIndex = rehState.practiceStart ?? 0;
      highlightCurrentLine();
    }
    return;
  }

  // Always skip pagebreak markers
  if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') {
    rehState.lineIndex++;
    return playNextLine();
  }

  // Skip lines the user marked ignore / hide during bulk edit
  const _cur = rehState.lines[rehState.lineIndex];
  if (_cur && (_cur.ignored || _cur.hidden)) {
    rehState.lineIndex++;
    return playNextLine();
  }

  // Skip non-dialog lines only when skip mode is on AND no narrator voice is set.
  // When a narrator voice exists, every line with text gets spoken — never skip.
  if (rehState.skipDescriptions && !rehState.narratorVoice) {
    while (
      rehState.lineIndex < rehState.lines.length &&
      rehState.lines[rehState.lineIndex].type !== 'dialog' &&
      rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
      !(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
    ) { rehState.lineIndex++; }
  }

  if (rehState.lineIndex >= rehState.lines.length) {
    rehState.playing = false;
    updatePlayBtn();
    if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
    toast('Script finished', 'success');
    return;
  }

  const line = rehState.lines[rehState.lineIndex];
  highlightCurrentLine();

  // Non-dialog lines
  if (line.type !== 'dialog') {
    // Any line with text can be narrated — direction/transition/scene/act/action all included
    const hasText = !!(line.text || '').trim();
    if (rehState.narratorVoice && hasText) {
      showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
      const cached = rehState.synthCache.get(rehState.lineIndex);
      if (cached) {
        await playPreDecoded(rehState.lineIndex, cached, line.text);
      } else {
        try {
          const blob = await fetchTtsPreviewBlob(rehState.narratorVoice, stripMarkdown(line.text), 'wav', '', rehState.backend);
          if (!rehState.playing) return;
          rehState.synthCache.set(rehState.lineIndex, blob);
          _markSynthDot(rehState.lineIndex, 'ok');
          await playPreDecoded(rehState.lineIndex, blob, line.text);
        } catch(_) { await new Promise(r => setTimeout(r, 200)); }
      }
    } else if (!rehState.skipDescriptions) {
      await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
    }
    if (!rehState.playing) return;
    rehState.lineIndex++;
    playNextLine();
    return;
  }

  // Dialog line
  const cast = rehState.cast[line.speaker] || { voice: '' };

  if (cast.voice === 'me') {
    rehState.playing = false; updatePlayBtn();
    showRecOverlay(line);
    return;
  }

  if (!cast.voice) {
    showStatusBar(line.speaker + ' has no voice — skipping…');
    await new Promise(r => setTimeout(r, 350));
    if (!rehState.playing) return;
    rehState.lineIndex++; playNextLine(); return;
  }

  const profile  = (cast.instruct || '').trim();
  const instruct = _buildInstruct(profile, line.emotion);
  const cleanTxt = stripMarkdown(line.text);

  const cached = rehState.synthCache.get(rehState.lineIndex);
  if (cached) {
    showStatusBar(line.speaker + ' is speaking…');
    await playPreDecoded(rehState.lineIndex, cached, line.text);  // ← instant: pre-decoded PCM
    rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached });
  } else {
    showStatusBar('Synthesizing…');
    try {
      const blob = await fetchTtsPreviewBlob(cast.voice, _rehInlineTone(cleanTxt, line.emotion), 'wav', instruct, rehState.backend);
      if (!rehState.playing) return;
      rehState.synthCache.set(rehState.lineIndex, blob);
      showStatusBar(line.speaker + ' is speaking…');
      await playPreDecoded(rehState.lineIndex, blob, line.text);  // also decodes + pre-fetches next
      rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob });
    } catch(e) {
      showStatusBar('TTS failed: ' + e.message);
      await new Promise(r => setTimeout(r, 1000));
    }
  }

  if (!rehState.playing) return;
  rehState.lineIndex++;
  playNextLine();
}

function waitForAudioEnd(audio) {
  return new Promise(resolve => {
    if (!audio || audio.paused || audio.ended) { resolve(); return; }
    audio.addEventListener('ended', resolve, { once: true });
    audio.addEventListener('pause',  resolve, { once: true });
    audio.addEventListener('error',  resolve, { once: true });
  });
}

function showStatusBar(msg) {
  const bar = $('reh-tts-status-bar'); if (!bar) return;
  bar.hidden = false;
  const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
}

function highlightCurrentLine() {
  const i = rehState.lineIndex, total = rehState.lines.length;
  const prog = $('reh-tb-progress');
  if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
  const lbl = $('reh-tb-label');
  if (lbl) lbl.textContent = `${i + 1} / ${total}`;

  document.querySelectorAll('[data-index]').forEach(el => {
    el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
  });

  const active = document.querySelector(`[data-index="${i}"]`);
  if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
  updatePlayBtn();
}

function updatePlayBtn() {
  const btn = $('reh-tb-play'); if (!btn) return;
  btn.innerHTML = rehState.playing ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
  btn.title     = rehState.playing ? 'Pause' : 'Play all';
}

// ── Synthesize All ─────────────────────────────────────────────────────────

async function synthAll() {
  if (rehState.synthRunning) return;
  if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; }
  _ensureNarrator();  // sync narrator voice from its cast row before synthesizing

  const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => {
    if (line.ignored || line.hidden) return false;  // bulk-edit: never synth ignored/hidden lines
    if (line.type === 'dialog') {
      const c = rehState.cast[line.speaker];
      return c && c.voice && c.voice !== 'me';
    }
    // All non-dialog lines with text are narrated when a narrator voice is set
    return !!(rehState.narratorVoice && (line.text || '').trim());
  });

  if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; }

  rehState.synthRunning = true; rehState.synthCancelled = false;
  const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
  if (synthBar) synthBar.hidden = false;

  const prog = (d, t) => {
    if (fill)  fill.style.width   = (t ? (d/t)*100 : 0) + '%';
    if (label) label.textContent  = `${d} / ${t} synthesized`;
  };
  prog(0, ttsLines.length);

  let done = 0;
  for (const { line, idx } of ttsLines) {
    if (rehState.synthCancelled) break;
    let voice, instruct;
    if (line.type === 'dialog') {
      const c  = rehState.cast[line.speaker];
      voice    = c.voice;
      instruct = _buildInstruct(c.instruct, line.emotion);
    } else {
      voice = rehState.narratorVoice; instruct = '';
    }
    _markSynthDot(idx, 'synthesizing');
    document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    try {
      const blob = await fetchTtsPreviewBlob(voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
      rehState.synthCache.set(idx, blob);
      rehState.staleLines.delete(idx);
      _markSynthDot(idx, 'ok');
      _showReSynthBtn(idx, false);
      preDecodeBlob(idx, blob);  // decode to PCM immediately → zero-latency playback
    } catch(_) { _markSynthDot(idx, null); }
    prog(++done, ttsLines.length);
  }

  if (synthBar) synthBar.hidden = true;
  rehState.synthRunning = false;
  if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
}

function _updateStaleBatchBtn() {
  const btn = $('reh-tb-resynth-stale');
  if (btn) btn.hidden = rehState.staleLines.size === 0;
}

$('reh-tb-synth-all')?.addEventListener('click', () => synthAll());
$('reh-tb-resynth-stale')?.addEventListener('click', async () => {
  if (rehState.synthRunning) return;
  const stale = [...rehState.staleLines];
  if (!stale.length) return;
  rehState.synthRunning = true; rehState.synthCancelled = false;
  const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
  if (synthBar) synthBar.hidden = false;
  let done = 0;
  for (const idx of stale) {
    if (rehState.synthCancelled) break;
    const line = rehState.lines[idx];
    if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
    const c = rehState.cast[line.speaker];
    if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
    const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
    _markSynthDot(idx, 'synthesizing');
    try {
      const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
      rehState.synthCache.set(idx, blob);
      rehState.staleLines.delete(idx);
      preDecodeBlob(idx, blob);
      _markSynthDot(idx, 'ok');
      _showReSynthBtn(idx, false);
    } catch(_) { _markSynthDot(idx, 'stale'); }
    if (fill)  fill.style.width  = ((++done / stale.length) * 100) + '%';
    if (label) label.textContent = `${done} / ${stale.length} synthesized`;
  }
  if (synthBar) synthBar.hidden = true;
  rehState.synthRunning = false;
  _updateStaleBatchBtn();
  if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
});
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });

// ── Recording overlay ───────────────────────────────────────────────────────

function showRecOverlay(line) {
  const overlay = $('reh-rec-overlay'); if (!overlay) return;
  overlay.hidden = false;
  const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' };
  if (cue) cue.innerHTML = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(stripMarkdown(line.text))}</div>`;
  if ($('reh-rec-preview'))     { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
  if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
  if ($('reh-rec-start'))       $('reh-rec-start').disabled = false;
  if ($('reh-rec-stop'))        $('reh-rec-stop').disabled  = true;
  if ($('reh-rec-time'))        $('reh-rec-time').textContent = '0:00';
  rehState.lastRecBlob = null;
}

function hideRecOverlay() {
  const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
  stopRehMic();
}

// ── Mic recording ───────────────────────────────────────────────────────────

function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
  const meter = $('reh-mic-meter'); if (!meter) return;
  if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
  const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
  [...meter.children].forEach((bar,i)=>{
    bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
    if(i<active){bar.classList.add('on');if(db>-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
  });
  const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
}

function rehStartMeter() {
  if (!rehState.recAnalyser) return;
  if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
  const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
  rehState.recWaveRing = new Float32Array(RING);
  const tick=()=>{
    rehState.recAnalyser.getFloatTimeDomainData(data);
    let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
    const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
    rehRenderMeter((db+60)/60,db,peak>0.98);
    if(canvas&&rehState.recWaveRing){
      const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
      for(let i=0;i<ADD;i++) ring[RING-ADD+i]=data[Math.floor(i*data.length/ADD)];
      const ctx=canvas.getContext('2d'),w=canvas.width,h=canvas.height;
      ctx.clearRect(0,0,w,h); ctx.beginPath();
      ctx.strokeStyle=peak>0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
      const mid=h/2;
      for(let i=0;i<RING;i++){const x=(i/RING)*w,y=mid-ring[i]*mid*0.85;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
      ctx.stroke();
    }
    rehState.recMeterRaf=requestAnimationFrame(tick);
  };
  tick();
}

async function startRehMic() {
  if (rehState.recDestStream) return;
  const AudioCtx = window.AudioContext || window.webkitAudioContext;
  rehState.recStream = await requestMicrophoneStream({ raw: true });
  if (AudioCtx) {
    rehState.recAudioCtx   = new AudioCtx();
    rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
    rehState.recGainNode   = rehState.recAudioCtx.createGain();
    rehState.recAnalyser   = rehState.recAudioCtx.createAnalyser();
    rehState.recAnalyser.fftSize = 1024;
    const dest = rehState.recAudioCtx.createMediaStreamDestination();
    rehState.recSourceNode.connect(rehState.recGainNode);
    rehState.recGainNode.connect(rehState.recAnalyser);
    rehState.recGainNode.connect(dest);
    rehState.recDestStream = dest.stream;
    rehStartMeter();
  } else { rehState.recDestStream = rehState.recStream; }
}

function stopRehMic() {
  if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
  rehState.recMeterRaf = null;
  [rehState.recSourceNode,rehState.recGainNode,rehState.recAnalyser].forEach(n=>{try{if(n)n.disconnect();}catch(_){}});
  if (rehState.recStream)     rehState.recStream.getTracks().forEach(t=>t.stop());
  if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
  if (rehState.recAudioCtx)   rehState.recAudioCtx.close().catch(()=>{});
  Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
  rehRenderMeter();
  const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
}

$('reh-rec-start')?.addEventListener('click', async () => {
  try {
    await startRehMic();
    rehState.recChunks=[]; rehState.recSecs=0;
    if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
    if($('reh-rec-start'))$('reh-rec-start').disabled=true;
    if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
    if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
    rehState.recTimer=setInterval(()=>{
      rehState.recSecs++;
      if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
    },1000);
    rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
    rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
    rehState.mediaRec.onstop=()=>{
      clearInterval(rehState.recTimer);
      if($('reh-rec-start'))$('reh-rec-start').disabled=false;
      if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
      const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
      const url=URL.createObjectURL(blob);
      const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
      if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
      rehState.lastRecBlob=blob;
    };
    rehState.mediaRec.start(100);
  } catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
});

$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });

$('reh-rec-keep')?.addEventListener('click', () => {
  if (rehState.lastRecBlob)
    rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
  stopRehMic(); hideRecOverlay();
  rehState.lineIndex++;
  startPlay();
});

$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });

$('reh-skip-line')?.addEventListener('click', () => {
  rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
  stopRehMic(); hideRecOverlay();
  rehState.lineIndex++;
  startPlay();
});

// ── Phase 4: Summary ────────────────────────────────────────────────────────

function renderSummary() {
  const list = $('reh-summary-list'); if (!list) return;
  if (!rehState.clips.length) { list.innerHTML='<p class="note">No clips in this session.</p>'; return; }
  list.innerHTML = rehState.clips.map((clip,idx)=>{
    const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker};
    const c=rehState.cast[clip.speaker]||{color:'#89b4fa'};
    const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped';
    const audioHtml=clip.blob?`<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px;margin-top:4px"></audio>`:'';
    const dlHtml=clip.blob?`<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>`:'';
    return `<div class="reh-summary-row">
      <span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
      <div style="flex:1;min-width:0">
        <div style="font-weight:600;font-size:13px;color:${c.color}">${escHtml(clip.speaker||'—')} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
        <div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(stripMarkdown(line.text))}</div>
        ${audioHtml}
      </div>
      ${dlHtml}
    </div>`;
  }).join('');
}

$('reh-new-session-btn')?.addEventListener('click', () => {
  stopPlay(); stopRehMic();
  rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
  rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice='';
  rehState.practiceStart=null; rehState.practiceEnd=null;
  if($('reh-script-text'))$('reh-script-text').value='';
  if($('reh-script-title'))$('reh-script-title').value='';
  renderLibraryList(); showPhase(1);
});

$('reh-resume-btn')?.addEventListener('click', ()=>{showPhase(3);highlightCurrentLine();});
$('reh-save-session-btn')?.addEventListener('click',  saveToLibrary);
$('reh-cast-save-btn')?.addEventListener('click',     saveToLibrary);
$('reh-export-session-btn')?.addEventListener('click', exportToFile);
$('reh-tb-save')?.addEventListener('click',            saveToLibrary);
$('reh-tb-export')?.addEventListener('click',           exportToFile);

$('reh-lib-import-file')?.addEventListener('change', async function(){
  const f=this.files?.[0]; if(!f)return;
  await importFromFile(f); this.value='';
});

// ── Import/Export panel wiring ─────────────────────────────────────────────
document.querySelectorAll('.reh-impex-file').forEach(inp => {
  inp.addEventListener('change', async function () {
    const f = this.files?.[0]; if (!f) return;
    await importFromFile(f);
    this.value = '';
    // After import, return to Library
    navRehearserPhase(1);
  });
});
$('reh-impex-reh-btn')?.addEventListener('click',     exportToFile);
$('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); });
$('reh-impex-fdx-btn')?.addEventListener('click',     () => { $('reh-fdx-export-btn')?.click(); });
$('reh-impex-osf-btn')?.addEventListener('click',     () => { $('reh-osf-export-btn')?.click(); });

// ── Plain-text + Markdown export ───────────────────────────────────────────
function _downloadText(content, filename) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
  a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
}

function exportAsTxt() {
  const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script';
  _downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt');
}

function exportAsMd() {
  const title = $('reh-script-title')?.value.trim() || 'Script';
  const lines = rehState.lines.map(l => {
    switch (l.type) {
      case 'act':        return `\n# ${l.text}\n`;
      case 'scene':      return `\n## ${l.text}\n`;
      case 'transition': return `\n*${l.text}*\n`;
      case 'direction':  return `*(${l.text})*`;
      case 'action':     return `\n${l.text}\n`;
      case 'dialog':     return `\n**${l.speaker}**\n${l.text}\n`;
      default: return '';
    }
  }).join('\n');
  _downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md');
}

$('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt);
$('reh-impex-md-btn')?.addEventListener('click',  exportAsMd);

// ── IMSDb / URL import ─────────────────────────────────────────────────────
(function initUrlImport() {
  const inp    = $('reh-impex-url');
  const btn    = $('reh-impex-url-btn');
  const status = $('reh-impex-url-status');
  if (!inp || !btn) return;

  async function fetchUrl() {
    const url = inp.value.trim();
    if (!url) { toast('Paste a URL first', 'error'); return; }
    btn.disabled = true;
    if (status) { status.textContent = 'Fetching…'; status.style.color = ''; }
    try {
      const r = await fetch('/api/fetch-web-script', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url }),
      });
      if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      // Put script into the textarea and set title
      const ta = $('reh-script-text');
      if (ta) ta.value = d.text;
      const titleInp = $('reh-script-title');
      if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
      if (status) {
        status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`;
        status.style.color = 'var(--green)';
      }
      // Navigate to Library phase so user sees the script text area
      navRehearserPhase(1);
      // Scroll the script textarea into view
      setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300);
      toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success');
    } catch(e) {
      if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; }
      toast('Fetch failed: ' + e.message, 'error');
    } finally { btn.disabled = false; }
  }

  btn.addEventListener('click', fetchUrl);
  inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); });
})();

// ── IMSDb library browser (poster grid) ────────────────────────────────────
(function initImsdbBrowser() {
  const modal    = $('reh-imsdb-modal');
  const grid     = $('reh-imsdb-grid');
  const search   = $('reh-imsdb-search');
  const countEl  = $('reh-imsdb-count');
  const closeBtn = $('reh-imsdb-close');
  const coverBtn = $('reh-imsdb-cover-btn');
  const listBtn  = $('reh-imsdb-list-btn');
  const urlInp   = $('reh-imsdb-url');
  const urlBtn   = $('reh-imsdb-url-fetch');
  if (!modal || !grid) return;

  const CACHE_KEY = 'reh-imsdb-cat-v1';
  const CACHE_TTL = 6 * 3600 * 1000;

  let _catalogue = null;
  let _posterObserver = null;
  let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list'

  function _applyView() {
    grid.classList.toggle('list-view', _view === 'list');
    if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover');
    if (listBtn)  listBtn.classList.toggle('active', _view === 'list');
  }

  function _ensurePosterObserver() {
    if (_posterObserver) return _posterObserver;
    _posterObserver = new IntersectionObserver((entries) => {
      entries.forEach(async (ent) => {
        if (!ent.isIntersecting) return;
        const card = ent.target;
        _posterObserver.unobserve(card);
        const title = card.dataset.title;
        try {
          const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json());
          if (d.poster) {
            const img = document.createElement('img');
            img.className = 'reh-imsdb-poster';
            img.loading = 'lazy'; img.src = d.poster; img.alt = title;
            img.onload = () => {
              const wrap = card.querySelector('.reh-imsdb-poster-wrap');
              if (wrap) {
                wrap.querySelector('.reh-imsdb-fallback')?.remove();
                wrap.appendChild(img);
              }
            };
          }
          if (d.year) {
            card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; });
          }
        } catch(_) {}
      });
    }, { root: grid, rootMargin: '300px' });
    return _posterObserver;
  }

  function _bookColor(title) {
    let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0;
    return `hsl(${h % 360}, 45%, 42%)`;
  }

  function renderGrid(items) {
    const obs = _ensurePosterObserver();
    grid.innerHTML = '';
    if (!items.length) { grid.innerHTML = '<div class="reh-imsdb-loading">No matches.</div>'; return; }
    const frag = document.createDocumentFragment();
    items.slice(0, 400).forEach(it => {
      const card = document.createElement('div');
      card.className = 'reh-imsdb-card';
      card.dataset.title = it.title;
      card.dataset.url = it.fetch_url;
      const color1 = _bookColor(it.title);
      const color2 = _bookColor(it.title + '_');
      card.innerHTML = `
        <div class="reh-imsdb-poster-wrap">
          <div class="reh-imsdb-fallback" style="background:linear-gradient(160deg, ${color1}, ${color2})">
            <span class="mdi mdi-movie-roll"></span>
            <span class="reh-imsdb-fallback-title">${escHtml(it.title)}</span>
          </div>
        </div>
        <div class="reh-imsdb-meta">
          <span class="reh-imsdb-title">${escHtml(it.title)}</span>
          <span class="reh-imsdb-year"></span>
        </div>`;
      card.addEventListener('click', () => importImsdb(it));
      frag.appendChild(card);
      obs.observe(card);
    });
    grid.appendChild(frag);
    if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`;
    _applyView();
  }

  async function importAnyWebUrl(url, label = 'script') {
    url = String(url || '').trim();
    if (!url) { toast('Paste a script URL first', 'error'); return; }
    if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; }
    if (urlBtn) urlBtn.disabled = true;
    grid.innerHTML = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching ${escHtml(label)}…</div>`;
    try {
      const r = await fetch('/api/fetch-web-script', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url }),
      });
      if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
      const d = await r.json();
      const ta = $('reh-script-text'); if (ta) ta.value = d.text;
      const ti = $('reh-script-title'); if (ti) ti.value = d.title || label;
      closeModal();
      navRehearserPhase(1);
      setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250);
      toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success');
    } catch(e) {
      grid.innerHTML = `<div class="reh-imsdb-load-error">Fetch failed: ${escHtml(e.message)}</div>`;
      if (_catalogue) renderGrid(_searchFilter(_catalogue));
      toast('Script fetch failed: ' + e.message, 'error');
    } finally {
      if (urlBtn) urlBtn.disabled = false;
    }
  }

  async function importImsdb(it) {
    await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script');
  }

  async function _loadCatalogue() {
    // Try localStorage cache first
    try {
      const raw = localStorage.getItem(CACHE_KEY);
      if (raw) {
        const cached = JSON.parse(raw);
        if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) {
          _catalogue = cached.items;
          return;
        }
      }
    } catch(_) {}
    // Fetch from server
    const d = await fetch('/api/imsdb/list').then(r => r.json());
    _catalogue = d.items || [];
    try {
      localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue }));
    } catch(_) {}
  }

  async function openModal() {
    modal.hidden = false;
    if (urlInp) urlInp.value = '';
    _applyView();
    if (!_catalogue) {
      grid.innerHTML = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
      try {
        await _loadCatalogue();
      } catch(e) {
        grid.innerHTML = `<div class="reh-imsdb-loading">Failed to load: ${escHtml(e.message)}</div>`;
        return;
      }
    }
    renderGrid(_searchFilter(_catalogue));
    search?.focus();
  }

  function closeModal() { modal.hidden = true; }

  function _searchFilter(items) {
    const q = search?.value.trim().toLowerCase() || '';
    return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items;
  }

  let _searchTimer = null;
  search?.addEventListener('input', () => {
    clearTimeout(_searchTimer);
    _searchTimer = setTimeout(() => {
      if (!_catalogue) return;
      renderGrid(_searchFilter(_catalogue));
    }, 150);
  });
  search?.addEventListener('keydown', e => {
    if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) {
      e.preventDefault();
      importAnyWebUrl(search.value.trim(), 'pasted URL');
    }
  });
  urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL'));
  urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } });

  coverBtn?.addEventListener('click', () => {
    _view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
  });
  listBtn?.addEventListener('click', () => {
    _view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
  });

  // Wire all open buttons (Library tab + Import/Export tab)
  [$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => {
    btn?.addEventListener('click', openModal);
  });
  closeBtn?.addEventListener('click', closeModal);
  modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
})();

// ── Train Mode ────────────────────────────────────────────────────────────
// State machine: idle → playing_cue → ready → recording → transcribing → done
// Each "turn" = one set of cue lines + one "me" line the user must speak.

const trainState = {
  seq:      [],       // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}]
  turn:     0,        // current turn index
  phase:    'idle',   // idle | playing_cue | ready | recording | transcribing | done
  cueSource: null,    // AudioBufferSourceNode currently playing
  mediaRec:  null,    // MediaRecorder for train recording
  recChunks: [],
  recRaf:    null,    // rAF id for meter
  recStream: null,    // MediaStream
  recCtx:    null,    // AudioContext for meter
  recAnalyser: null,
  recTimer:  null,
  recSecs:   0,
};

function _trainNormWords(str) {
  return (str || '').toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g, '').trim().split(/\s+/).filter(Boolean);
}

function _trainLcs(a, b) {
  const R = a.length, C = b.length;
  const dp = Array.from({length: R + 1}, () => new Int16Array(C + 1));
  for (let i = 1; i <= R; i++) for (let j = 1; j <= C; j++)
    dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
  let i = R, j = C; const seq = [];
  while (i > 0 && j > 0) {
    if (a[i-1] === b[j-1]) { seq.unshift([i-1, j-1]); i--; j--; }
    else if (dp[i-1][j] >= dp[i][j-1]) i--;
    else j--;
  }
  return seq;
}

function _trainCompare(expected, actual) {
  const ew = _trainNormWords(expected), aw = _trainNormWords(actual);
  if (!ew.length) return { html_exp: '', html_act: '', score: 1 };
  const pairs = _trainLcs(ew, aw);
  const matchedE = new Set(pairs.map(p => p[0]));
  const matchedA = new Set(pairs.map(p => p[1]));
  const expHtml = ew.map((w, i) => matchedE.has(i)
    ? `<span class="tw-ok">${escHtml(w)}</span>`
    : `<span class="tw-miss">${escHtml(w)}</span>`).join(' ');
  const actHtml = aw.map((w, i) => matchedA.has(i)
    ? `<span class="tw-ok">${escHtml(w)}</span>`
    : `<span class="tw-extra">${escHtml(w)}</span>`).join(' ');
  const score = ew.length ? Math.round((matchedE.size / ew.length) * 100) : 100;
  return { html_exp: expHtml, html_act: actHtml, score };
}

function buildTrainSeq() {
  const seq = [];
  const lines = rehState.lines;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (line.type !== 'dialog') continue;
    const cast = rehState.cast[line.speaker];
    if (!cast || cast.voice !== 'me') continue;
    // Gather preceding cue lines (last up to 3 TTS dialog lines going backwards)
    const cueLines = [];
    for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) {
      const prev = lines[j];
      if (prev.type === 'scene' || prev.type === 'act') break;
      if (prev.type !== 'dialog') continue;
      const pc = rehState.cast[prev.speaker];
      if (!pc || pc.voice === 'me') break;
      cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '',
        voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion),
        voiceData: pc.voiceData });
    }
    seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } });
  }
  return seq;
}

function _trainSetState(phase) {
  trainState.phase = phase;
  const stateEl = $('reh-train-rec-state');
  const playBtn   = $('reh-train-play');
  const recBtn    = $('reh-train-record');
  const stopBtn   = $('reh-train-stop-rec');
  const cueState  = $('reh-train-cue-state');
  const map = {
    idle:         { state:'Read the cue above · press ▶ to hear it', play:true,  rec:false, stop:false },
    playing_cue:  { state:'Playing cue…',                            play:false, rec:false, stop:false },
    ready:        { state:'Press 🎤 to record your line',            play:false, rec:true,  stop:false },
    recording:    { state:'Recording…',                              play:false, rec:false, stop:true  },
    transcribing: { state:'Transcribing…',                           play:false, rec:false, stop:false },
    done:         { state:'Done — press ▶ to replay or ⏭ for next', play:true,  rec:true,  stop:false },
  };
  const m = map[phase] || map.idle;
  if (stateEl) stateEl.textContent = m.state;
  if (cueState && phase === 'playing_cue') { cueState.innerHTML = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
  else if (cueState) cueState.innerHTML = '';
  if (playBtn) playBtn.disabled = !m.play;
  if (recBtn)  { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; }
  if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; }
  const timerEl = $('reh-train-rec-timer');
  const metersEl = $('reh-train-meters');
  if (phase === 'recording') {
    if (timerEl) timerEl.hidden = false;
    if (metersEl) metersEl.hidden = false;
  } else if (phase !== 'done') {
    if (timerEl) timerEl.hidden = true;
    if (metersEl) metersEl.hidden = true;
  }
}

async function _trainPlayCueLines(cueLines) {
  _trainSetState('playing_cue');
  for (const cue of cueLines) {
    if (trainState.phase !== 'playing_cue') break;
    let blob = rehState.synthCache.get(cue.index);
    if (!blob) {
      try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, rehState.backend); }
      catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; }
    }
    if (trainState.phase !== 'playing_cue') break;
    await new Promise(resolve => {
      const url = URL.createObjectURL(blob);
      const audio = new Audio(url);
      audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
      audio.onerror = () => { URL.revokeObjectURL(url); resolve(); };
      trainState.cueAudio = audio;
      audio.play().catch(resolve);
    });
  }
  if (trainState.phase === 'playing_cue') _trainSetState('ready');
}

async function _trainTranscribe(blob) {
  _trainSetState('transcribing');
  try {
    const fd = new FormData();
    fd.append('file', blob, 'train_rec.webm');
    fd.append('backend', _appSettings?.stt_preferred_backend || 'configured');
    const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd });
    if (!r.ok) throw new Error(r.statusText);
    const d = await r.json();
    return (d.text || '').trim();
  } catch(e) {
    toast('Transcription failed: ' + e.message, 'error');
    return '';
  }
}

async function trainGoTo(idx) {
  // Stop any in-progress recording or playback
  if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
  _trainStopMic();

  trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1));
  const turn = trainState.seq[trainState.turn];
  if (!turn) return;

  // Update progress
  const progEl = $('reh-train-progress');
  if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`;

  // Render cue card — show ALL cue lines so user can read the full context
  const cueCard    = $('reh-train-cue');
  const cueAvEl    = $('reh-train-cue-avatar');
  const cueSpeaker = $('reh-train-cue-speaker');
  const cueText    = $('reh-train-cue-text');
  if (turn.cueLines.length) {
    const lastCue = turn.cueLines[turn.cueLines.length - 1];
    if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32);
    if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker;
    // Show all cue lines as a readable block
    if (cueText) {
      cueText.innerHTML = turn.cueLines.map(c =>
        `<div class="reh-train-cue-block">
          <span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
          <span class="reh-train-cue-line">${escHtml(c.text)}</span>
        </div>`
      ).join('');
    }
    cueCard?.classList.remove('no-cue');
  } else {
    if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
    if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue';
    if (cueText) cueText.textContent = 'This is the first line — speak when ready.';
    cueCard?.classList.add('no-cue');
  }

  // Render my line card
  const mySpeaker = $('reh-train-my-speaker');
  const myText    = $('reh-train-my-text');
  if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker;
  if (myText) myText.textContent = turn.myLine.text;

  // Hide result from previous turn
  const resultEl = $('reh-train-result');
  if (resultEl) resultEl.hidden = true;

  // Reset audio preview
  const prevAudio = $('reh-train-audio-preview');
  if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; }

  // Show the script text immediately — user reads first, then clicks ▶ to hear the cue
  _trainSetState(turn.cueLines.length ? 'idle' : 'ready');
}

function _trainStartMeter() {
  const meterEl  = $('reh-train-meter');
  const dbEl     = $('reh-train-db');
  const waveEl   = $('reh-train-wave');
  if (!trainState.recAnalyser || !meterEl) return;
  const analyser = trainState.recAnalyser;
  const fft = new Uint8Array(analyser.frequencyBinCount);
  const wCtx = waveEl?.getContext('2d');
  const W = waveEl?.width || 300, H = waveEl?.height || 36;
  const BARS = 18;

  function tick() {
    analyser.getByteFrequencyData(fft);
    const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length;
    const db  = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity;
    if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
    const slots = Array.from({length: BARS}, (_, i) => {
      const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length);
      return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255;
    });
    meterEl.innerHTML = slots.map(v => {
      const h = Math.max(2, Math.round(v * 28));
      const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)';
      return `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
    }).join('');
    if (wCtx) {
      analyser.getByteTimeDomainData(fft);
      wCtx.clearRect(0, 0, W, H);
      wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5;
      fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); });
      wCtx.stroke();
    }
    trainState.recRaf = requestAnimationFrame(tick);
  }
  trainState.recRaf = requestAnimationFrame(tick);
}

function _trainStopMic() {
  if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; }
  if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; }
  if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') {
    try { trainState.mediaRec.stop(); } catch(_) {}
  }
  trainState.mediaRec = null;
  if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; }
  if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; }
  trainState.recAnalyser = null;
  trainState.recChunks = [];
  const timerEl = $('reh-train-rec-timer');
  if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; }
  const metersEl = $('reh-train-meters');
  if (metersEl) metersEl.hidden = true;
}

async function _trainStartRecording() {
  if (trainState.phase !== 'ready') return;
  try {
    const stream = await requestMicrophoneStream({ raw: true });
    trainState.recStream = stream;
    const ctx = new AudioContext();
    trainState.recCtx = ctx;
    const src = ctx.createMediaStreamSource(stream);
    const analyser = ctx.createAnalyser(); analyser.fftSize = 256;
    trainState.recAnalyser = analyser;
    const dst = ctx.createMediaStreamDestination();
    src.connect(analyser); analyser.connect(dst);
    _trainSetState('recording');
    _trainStartMeter();

    const timerEl = $('reh-train-rec-timer');
    if (timerEl) timerEl.hidden = false;
    trainState.recSecs = 0;
    trainState.recTimer = setInterval(() => {
      trainState.recSecs++;
      const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60;
      if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
    }, 1000);

    trainState.recChunks = [];
    const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 });
    trainState.mediaRec = mr;
    mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); };
    mr.onstop = async () => {
      _trainStopMic();
      const blob = new Blob(trainState.recChunks, { type: 'audio/webm' });
      trainState.recChunks = [];
      const url = URL.createObjectURL(blob);
      const prevAudio = $('reh-train-audio-preview');
      if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; }

      // Transcribe
      const transcript = await _trainTranscribe(blob);
      const turn = trainState.seq[trainState.turn];
      const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript);

      const resultEl = $('reh-train-result');
      const expEl    = $('reh-train-expected-text');
      const actEl    = $('reh-train-actual-text');
      const scoreEl  = $('reh-train-score');
      if (expEl)   expEl.innerHTML   = html_exp;
      if (actEl)   actEl.innerHTML   = html_act || '<em style="color:var(--subtext)">Nothing detected</em>';
      if (scoreEl) {
        const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
        scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
      }
      if (resultEl) resultEl.hidden = false;
      _trainSetState('done');
    };
    mr.start();
  } catch(e) {
    toast('Microphone error: ' + e.message, 'error');
    _trainSetState('ready');
  }
}

function _trainStopRecording() {
  if (trainState.mediaRec && trainState.mediaRec.state === 'recording') {
    trainState.mediaRec.stop();
  }
}

function _trainHideStage(hide) {
  // Show/hide the script content area and auxiliary bars — transport bar stays visible always
  const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar'];
  els.forEach(sel => {
    const el = document.querySelector(sel) || document.getElementById(sel.replace('#',''));
    if (el) el.style.display = hide ? 'none' : '';
  });
}

function enterTrainMode() {
  const seq = buildTrainSeq();
  if (!seq.length) {
    toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error');
    return;
  }
  trainState.seq = seq;
  trainState.turn = 0;
  trainState.phase = 'idle';

  _trainHideStage(true);
  const panel = $('reh-train-panel');
  if (panel) panel.hidden = false;

  trainGoTo(0);
}

function exitTrainMode() {
  if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
  _trainStopMic();
  trainState.phase = 'idle';
  trainState.seq = [];

  _trainHideStage(false);
  const panel = $('reh-train-panel');
  if (panel) panel.hidden = true;
}

// ── Bulk-edit button wiring ─────────────────────────────────────────────────
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
$('reh-bulk-all')?.addEventListener('click', () => {
  // Select every line currently visible in the stage
  document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk)));
  rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null;
  document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk)));
  _updateBulkCount();
});
$('reh-bulk-none')?.addEventListener('click', () => {
  const had = [...rehState.bulkSel];
  rehState.bulkSel.clear();
  rehState.bulkAnchor = null;
  had.forEach(_refreshBulkLine);
  _updateBulkCount();
});
$('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; }));
$('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; }));
$('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; }));
$('reh-bulk-delete')?.addEventListener('click', _bulkDelete);
$('reh-bulk-show-hidden')?.addEventListener('change', e => {
  rehState.showHidden = e.target.checked;
  buildScriptPage();
});

// ── Train button wiring ─────────────────────────────────────────────────────
$('reh-page-mode-btn')?.addEventListener('click', cyclePageMode);
$('reh-train-open-btn')?.addEventListener('click', enterTrainMode);
$('reh-train-exit-btn')?.addEventListener('click', exitTrainMode);

$('reh-train-play')?.addEventListener('click', () => {
  const turn = trainState.seq[trainState.turn];
  if (!turn) return;
  if (trainState.phase === 'playing_cue') return;
  if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
  if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
  else _trainSetState('ready');
});

$('reh-train-record')?.addEventListener('click', () => {
  if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording();
});

$('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording);

$('reh-train-prev')?.addEventListener('click', () => {
  if (trainState.turn > 0) trainGoTo(trainState.turn - 1);
});

$('reh-train-next')?.addEventListener('click', () => {
  if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1);
});

$('reh-train-repeat')?.addEventListener('click', () => {
  if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
  _trainStopMic();
  const turn = trainState.seq[trainState.turn];
  if (!turn) return;
  const resultEl = $('reh-train-result');
  if (resultEl) resultEl.hidden = true;
  if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
  else _trainSetState('ready');
});

// ── Init ───────────────────────────────────────────────────────────────────
rehRenderMeter();
_syncPageModeBtn();
renderLibraryList().catch(()=>{});
if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions;
if (window._rehearserStartImpEx) {
  delete window._rehearserStartImpEx;
  showRehImpEx();
} else {
  const _initPhase = window._rehearserStartPhase || 1;
  delete window._rehearserStartPhase;
  showPhase(_initPhase);
}
