// ── Read Aloud — PDF/TXT document reader with live word highlighting ────────
//
// Imports a PDF (rendered to canvas via pdf.js with a highlight overlay on the
// real text) or a plain-text file, then reads it sentence-by-sentence with a
// chosen voice/backend/speed while highlighting the word being spoken.
//
// Per-sentence synthesis state is colour-coded: red = not synthesised,
// yellow = synthesising, green = ready (cached), blue = currently reading.
// PDF view supports zoom (fit-width / fit-height / two-page / ±). Reading
// position is remembered so the same document resumes where it left off.
//
// Reuses existing globals: loadPdfJs() + computeWordTimings() (rehearser.js),
// fetchTtsPreviewBlob() (tts-preview.js), ttsBackendOptions()/availableTtsBackends()
// (settings.js), and $ / escHtml / toast (utils.js).

const readerState = {
  mode:    null,        // 'pdf' | 'text'
  title:   '',
  pdfDoc:  null,
  pages:   [],          // [{ pageDiv, overlay, page, base, rendered, renderTask }]
  sentences: [],        // [{ text, words:[{page,x,top,w,h} | {el}], status, _stat }]
  idx:     0,
  readingIdx: -1,       // the single unit currently shown as "reading" (avoids full scans)
  playing: false,
  speed:   1,
  scale:   1,
  zoomMode:'fit-width', // 'fit-width' | 'fit-height' | 'two' | 'custom'
  audioCtx: null,
  currentSource: null,
  raf:      null,
  blobCache: new Map(), // idx -> Blob
  bufCache:  new Map(), // idx -> AudioBuffer
  gainCache: new Map(), // idx -> normalisation gain
  unitsByPage: new Map(),// page -> [unit indices] for lazy status-box creation
  baseSentences: [],    // raw per-sentence units (before chunk grouping)
  chunkMode: 'sentence',// 'sentence' | 'paragraph' | 'page' — synthesis chunk size
  normalize: true,      // even out loudness between chunks on playback
  io:       null,       // IntersectionObserver for lazy page raster
  _seq:     0,          // import generation guard
  synthRunning: false,
  synthCancel:  false,
  selecting: false,     // click-to-select-range mode (stays on until Done/Esc)
  selStart:  null,
  selEnd:    null,
  selAnchored: false,   // true after the first click, waiting for the end click
  fileBlob:  null,      // original imported file (Blob) — kept so PDFs can be re-rendered
  docText:   '',        // original text (for text-mode docs)
  savedId:   null,      // server library doc id when this doc came from / was saved to the library
  savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch)
  sourceUploaded: false,    // the source document has been uploaded to the server
};
window.readerState = readerState;

const READER_RESUME_KEY = 'reader-resume';
const READER_FMT = 'mp3';   // synthesise + store as MP3 (compact; decodeAudioData plays it)
const READER_BUF_WINDOW = 3;   // keep decoded PCM only for ±N units around the playhead
const READER_CANVAS_MARGIN = 2200; // px; raster canvases beyond this from view are released

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

// ── Section enter / leave hooks (wired from nav.js) ─────────────────────────

window.readerOnShow = async function () {
  const sel = $('reader-backend-select');
  if (sel && (!sel.value || sel.options.length <= 1)) {
    // Backends may not have loaded yet (or loaded before this section existed) —
    // fetch them on demand so the dropdown fills instead of staying on "Checking…".
    if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
        typeof refreshTtsBackendAvailability === 'function') {
      try { await refreshTtsBackendAvailability(); } catch (_) {}
    }
    if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
      sel.innerHTML = ttsBackendOptions(sel.value);
      sel.disabled = false;
    }
  }
  readerUpdateBackendHint();
  readerRenderLibrary();
};

window.readerStop = function () { readerSaveResume(); readerPersistProgress(); readerStopPlayback(); };

// ── Backend + voice pickers ─────────────────────────────────────────────────

async function readerFetchVoices() {
  const btn = $('reader-fetch-voices-btn');
  const backend = $('reader-backend-select')?.value;
  if (!backend) { toast('No available TTS backend', 'error'); return; }
  if (btn) btn.disabled = true;
  try {
    const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
    const ids = (Array.isArray(raw) ? raw : []).map(v => (typeof v === 'string' ? v : (v.id || v.name || v.voice_id))).filter(Boolean);
    const sel = $('reader-voice-select'); if (!sel) return;
    const prev = sel.value;
    sel.innerHTML = '<option value="">— select a voice —</option>';
    ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
    if (prev && ids.includes(prev)) sel.value = prev;
    toast('Fetched ' + ids.length + ' voices', 'success');
  } catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); }
  finally { if (btn) btn.disabled = false; }
}

// Guidance for steady long-form narration (addresses voice drift between chunks):
// some backends re-sample timbre per request; flag those and suggest remedies.
function readerUpdateBackendHint() {
  const el = $('reader-backend-hint'); if (!el) return;
  const id = $('reader-backend-select')?.value || '';
  const b = (typeof backendById === 'function') ? backendById(id) : null;
  const steady = id && ((b && b.style_aware) || /design|custom|kokoro|magpie/i.test(id));
  el.innerHTML = steady
    ? '<span class="mdi mdi-check-circle-outline"></span> Good for long-form narration. For maximum steadiness, increase “Voice consistency”.'
    : '<span class="mdi mdi-information-outline"></span> Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise “Voice consistency” to paragraph/page, set a fixed <b>Seed</b> with a low <b>Temperature</b> (e.g. 0.3), keep “Normalise loudness” on, or pick a style-aware backend.';
  el.classList.toggle('reader-hint-warn', !steady);
}

// ── Import ──────────────────────────────────────────────────────────────────

async function readerImportFile(file) {
  if (!file) return;
  const name = (file.name || '').toLowerCase();
  readerResetDoc();
  readerState.title = file.name.replace(/\.[^.]+$/, '');
  readerState.fileBlob = file;
  const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
  try {
    if (name.endsWith('.pdf')) {
      toast('Reading PDF…', 'success');
      await readerLoadPdf(file);
    } else {
      const text = await file.text();
      readerState.docText = text;
      readerLoadText(text);
    }
    if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return; }
    if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
    $('reader-transport').hidden = false;
    $('reader-dropzone').hidden = true;
    $('reader-toolbar').hidden = readerState.mode !== 'pdf';
    $('reader-synthbar').hidden = false;
    const pr = $('reader-page-range');
    if (pr) {
      pr.hidden = readerState.mode !== 'pdf';
      if (readerState.mode === 'pdf') {
        const n = readerState.pages.length;
        const pf = $('reader-page-from'), pt = $('reader-page-to');
        if (pf) { pf.max = n; pf.value = 1; }
        if (pt) { pt.max = n; pt.value = n; }
      }
    }
    readerUpdateScopeLabel();
    const resumed = readerLoadResume();
    readerUpdateProgress();
    readerHighlightSentence(readerState.sentences[readerState.idx]);
    toast(resumed
      ? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
      : 'Loaded · ' + readerState.sentences.length + ' sentences — press play', 'success');
  } catch (e) { toast('Import failed: ' + (e.message || e), 'error'); }
}

function readerResetDoc() {
  readerStopPlayback();
  readerState._seq++;
  readerState.mode = null;
  readerState.pdfDoc = null;
  readerState.pages = [];
  readerState.sentences = [];
  readerState.idx = 0;
  readerState.readingIdx = -1;
  readerState.scale = 1;
  readerState.zoomMode = 'fit-width';
  readerState.blobCache.clear();
  readerState.bufCache.clear();
  readerState.gainCache.clear();
  readerState.unitsByPage = new Map();
  readerState.baseSentences = [];
  readerState.fileBlob = null;
  readerState.docText = '';
  readerState.savedId = null;
  readerState.savedAudioIdx = new Set();
  readerState.sourceUploaded = false;
  if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
  readerState.synthRunning = false;
  readerState.synthCancel = false;
  readerSetSelecting(false);
  readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
  const sb = $('reader-synthbar'); if (sb) sb.hidden = true;
  const sp = $('reader-synth-prog'); if (sp) sp.hidden = true;
  const si = $('reader-sel-info'); if (si) si.hidden = true;
  const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting'); }
}

// Build sentence list from a flat array of word objects (each carries geometry).
function readerBuildSentences(words) {
  const sentences = [];
  let cur = null;
  const endRe = /[.!?]["'”’)\]]?$/;
  const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
  for (const w of words) {
    if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null };
    cur.words.push(w);
    cur.text += (cur.text ? ' ' : '') + w.text;
    const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
    if ((isEnd && cur.words.length >= 2) || cur.words.length >= 45) {
      sentences.push(cur); cur = null;
    }
  }
  if (cur && cur.words.length) sentences.push(cur);
  return sentences;
}

// Group base sentences into synthesis units. Larger units = one continuous
// generation = steadier voice (fewer seams where timbre/prosody can drift).
//  • sentence  — one unit per sentence (most responsive)
//  • paragraph — merge sentences within a paragraph (text) / page (PDF), ~500 chars
//  • page      — merge a whole PDF page (or ~2000-char blocks of text)
function readerGroupUnits(base, mode) {
  if (mode === 'sentence' || !base.length) {
    return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null }));
  }
  const maxChars = mode === 'page' ? 2000 : 500;
  const isPdf = readerState.mode === 'pdf';
  const keyOf = s => isPdf ? (s.words[0]?.page ?? 0) : (s.words[0]?.para ?? 0);
  const units = [];
  let cur = null;
  for (const s of base) {
    const k = keyOf(s);
    // boundary break: paragraph mode breaks on para/page change; page mode breaks
    // only on PDF page change (text page mode flows by size alone)
    const boundary = cur && (
      (mode === 'paragraph' && k !== cur._key) ||
      (mode === 'page' && isPdf && k !== cur._key)
    );
    const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
    if (boundary || tooLong) { units.push(cur); cur = null; }
    if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k };
    cur.text += (cur.text ? ' ' : '') + s.text;
    cur.words.push(...s.words);
  }
  if (cur) units.push(cur);
  return units;
}

// ── PDF import: build scale-1 geometry; render lazily at current scale ───────

async function readerLoadPdf(file) {
  await loadPdfJs();
  const seq = readerState._seq;
  const ab = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
  if (seq !== readerState._seq) return;
  readerState.mode = 'pdf';
  readerState.pdfDoc = pdf;
  const doc = $('reader-doc');
  const nPages = pdf.numPages;
  const progress = readerShowParseProgress(nPages);

  // Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
  const page1 = await pdf.getPage(1);
  if (seq !== readerState._seq) { progress.done(); return; }
  const base1 = page1.getViewport({ scale: 1 });

  // Render empty skeleton pages instantly so the user can scroll
  for (let p = 1; p <= nPages; p++) {
    const pageDiv = document.createElement('div');
    pageDiv.className = 'reader-page';
    // set initial size so scrollbar reflects full document size instantly
    pageDiv.style.width = base1.width + 'px';
    pageDiv.style.height = base1.height + 'px';
    const overlay = document.createElement('div');
    overlay.className = 'reader-overlay';
    pageDiv.appendChild(overlay);
    doc.appendChild(pageDiv);
    // Use base1 as placeholder geometry until actual page is parsed
    readerState.pages.push({ pageDiv, overlay, page: null, base: base1, rendered: false, renderTask: null });
  }

  // Apply zoom and rasterize immediately so the user sees the book!
  readerApplyZoom('fit-width');
  readerSetupLazyRaster();

  // Sentences/units are built incrementally per page (page-local) and appended.
  // We yield on every page to keep the browser buttery smooth for scrolling.
  for (let p = 1; p <= nPages; p++) {
    // If page1 was already fetched, reuse it
    const page = p === 1 ? page1 : await pdf.getPage(p);
    if (seq !== readerState._seq) { progress.done(); return; }
    
    const base = page.getViewport({ scale: 1 });   // actual geometry reference
    const pageIdx = p - 1;
    const pgState = readerState.pages[pageIdx];
    
    // Update the skeleton with actual page data
    pgState.page = page;
    pgState.base = base;
    // We do NOT change pageDiv.style.width here, it will be updated by readerApplyZoom / readerRenderPage

    // Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
    const content = await page.getTextContent();
    if (seq !== readerState._seq) return;
    const pageWords = [];
    for (const item of content.items) {
      const str = (item.str || '');
      if (!str.trim()) continue;
      const m = pdfjsLib.Util.transform(base.transform, item.transform);
      const h = Math.hypot(m[2], m[3]) || Math.hypot(m[0], m[1]) || 12;
      const x = m[4];
      const top = m[5] - h;
      const w = (item.width || 0);
      const tokens = str.split(/(\s+)/);                 // keep spaces for width proportion
      const totalLen = str.length || 1;
      let offX = x;
      for (const tok of tokens) {
        const tw = (tok.length / totalLen) * w;
        if (tok.trim()) pageWords.push({ page: pageIdx, x: offX, top, w: Math.max(tw, 2), h, text: tok });
        offX += tw;
      }
    }
    // Build + append this page's sentences and units
    const pageBase = readerBuildSentences(pageWords);
    const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode);
    const startUnit = readerState.sentences.length;
    readerState.baseSentences.push(...pageBase);
    readerState.sentences.push(...pageUnits);
    const idxs = [];
    for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
    readerState.unitsByPage.set(pageIdx, idxs);

    // Yield to the event loop EVERY page so scrolling remains 60fps
    progress.update(p);
    await new Promise(r => setTimeout(r, 0));
  }
  progress.done();
}

// Lightweight floating parse-progress line shown in the document pane for big PDFs.
function readerShowParseProgress(total) {
  const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
  const el = document.createElement('div');
  el.className = 'reader-parsing';
  el.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:16px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
  el.style.cssText = 'position:sticky; top:12px; z-index:100; margin:0 auto -42px; background:var(--accent); color:#fff; padding:6px 14px; border-radius:20px; font-size:12px; display:inline-flex; align-items:center; gap:8px; box-shadow:0 4px 12px rgba(0,0,0,.25); font-weight:600;';
  doc.insertBefore(el, doc.firstChild);
  const label = el.querySelector('span:last-child');
  return {
    update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
    done() { el.remove(); },
  };
}

// ── Zoom ─────────────────────────────────────────────────────────────────────

function readerComputeScale(mode) {
  const doc = $('reader-doc');
  if (!doc || !readerState.pages.length) return 1;

  let maxW = 0, maxH = 0;
  for (const p of readerState.pages) {
    if (p.base) {
      if (p.base.width > maxW) maxW = p.base.width;
      if (p.base.height > maxH) maxH = p.base.height;
    }
  }
  if (!maxW) return 1;

  const padW = 36, gap = 16;
  const availW = (doc.clientWidth || 900) - padW;
  const availH = (doc.clientHeight || 600) - 36;
  if (mode === 'fit-width')  return Math.max(0.2, availW / maxW);
  if (mode === 'fit-height') return Math.max(0.2, availH / maxH);
  if (mode === 'two')        return Math.max(0.2, (availW - gap) / 2 / maxW);
  return readerState.scale;
}

function readerApplyZoom(mode) {
  if (!readerState.pages.length) return;
  if (mode) readerState.zoomMode = mode;
  if (mode && mode !== 'custom') readerState.scale = readerComputeScale(mode);
  const scale = readerState.scale;
  const doc = $('reader-doc');
  doc.classList.toggle('two-page', readerState.zoomMode === 'two');

  readerState.pages.forEach(pg => {
    pg.pageDiv.style.width  = (pg.base.width  * scale) + 'px';
    pg.pageDiv.style.height = (pg.base.height * scale) + 'px';
    const old = pg.pageDiv.querySelector('canvas.reader-canvas');
    if (old) old.remove();
    if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
    pg.rendered = false;
  });
  // reposition only the status boxes that actually exist (lazy/visible pages)
  readerState.pages.forEach(pg => pg.overlay.querySelectorAll('.reader-stat').forEach(el => {
    const i = parseInt(el.dataset.si); if (!isNaN(i)) readerPaintStatus(i);
  }));
  if (readerState.idx < readerState.sentences.length) readerHighlightSentence(readerState.sentences[readerState.idx]);
  const pct = $('reader-zoom-pct'); if (pct) pct.textContent = Math.round(scale * 100) + '%';
  readerRenderVisible();
}

function readerSetupLazyRaster() {
  if (readerState.io) readerState.io.disconnect();
  readerState.io = new IntersectionObserver(entries => {
    entries.forEach(en => {
      if (!en.isIntersecting) return;
      const idx = readerState.pages.findIndex(p => p.pageDiv === en.target);
      if (idx >= 0) readerRenderPage(idx);
    });
  }, { root: $('reader-doc'), rootMargin: '600px 0px' });
  readerState.pages.forEach(p => readerState.io.observe(p.pageDiv));
}

function readerRenderVisible() {
  const doc = $('reader-doc'); if (!doc) return;
  const dr = doc.getBoundingClientRect();
  readerState.pages.forEach((pg, i) => {
    const r = pg.pageDiv.getBoundingClientRect();
    if (r.bottom > dr.top - 800 && r.top < dr.bottom + 800) readerRenderPage(i);
  });
  readerEvictCanvases(dr);
}

// Release canvas bitmaps for pages far outside the viewport so a long book's
// raster memory stays bounded; they re-render when scrolled back into view.
function readerEvictCanvases(dr) {
  const doc = $('reader-doc'); if (!doc) return;
  dr = dr || doc.getBoundingClientRect();
  readerState.pages.forEach(pg => {
    if (!pg.rendered) return;
    const r = pg.pageDiv.getBoundingClientRect();
    if (r.bottom < dr.top - READER_CANVAS_MARGIN || r.top > dr.bottom + READER_CANVAS_MARGIN) {
      if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
      const c = pg.pageDiv.querySelector('canvas.reader-canvas');
      if (c) c.remove();
      pg.rendered = false;   // allow re-raster when it returns to view
    }
  });
}

async function readerRenderPage(idx) {
  const pg = readerState.pages[idx];
  if (!pg || pg.rendered) return;
  pg.rendered = true; // claim immediately to avoid double render
  const scale = readerState.scale;
  const viewport = pg.page.getViewport({ scale });
  const canvas = document.createElement('canvas');
  canvas.className = 'reader-canvas';
  canvas.width = Math.floor(viewport.width);
  canvas.height = Math.floor(viewport.height);
  pg.pageDiv.insertBefore(canvas, pg.overlay);
  readerCreatePageStatus(idx);   // create this page's status boxes lazily (cheap for big books)
  try {
    pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport });
    await pg.renderTask.promise;
  } catch (_) { pg.rendered = false; canvas.remove(); }
  finally { pg.renderTask = null; }
}

// ── Text import: render word spans, highlight via classes ────────────────────

function readerLoadText(text) {
  readerState.mode = 'text';
  readerState.docText = text;   // keep the source so it can be saved to the library
  const doc = $('reader-doc');
  const pane = document.createElement('div');
  pane.className = 'reader-text';
  doc.appendChild(pane);

  const words = [];
  const paras = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
  paras.forEach((para, pi) => {
    const pEl = document.createElement('p');
    pEl.className = 'reader-text-para';
    para.split('\n').forEach((line, li, arr) => {
      line.trim().split(/\s+/).forEach(tok => {
        if (!tok) return;
        const span = document.createElement('span');
        span.className = 'reader-word';
        span.textContent = tok;
        pEl.appendChild(span);
        pEl.appendChild(document.createTextNode(' '));
        words.push({ el: span, text: tok, page: 0, para: pi });
      });
      if (li < arr.length - 1) pEl.appendChild(document.createElement('br'));
    });
    pane.appendChild(pEl);
  });
  readerState.baseSentences = readerBuildSentences(words);
  readerState.sentences = readerGroupUnits(readerState.baseSentences, readerState.chunkMode);
  pane.addEventListener('click', e => {
    const span = e.target.closest('.reader-word'); if (!span) return;
    const si = readerState.sentences.findIndex(s => s.words.some(w => w.el === span));
    if (si < 0) return;
    if (readerState.selecting) readerPickSentence(si);
    else readerJumpTo(si);
  });
}

// ── Per-sentence synthesis-state visualization ───────────────────────────────

function readerSentBBox(sentence) {
  const pageIdx = sentence.words[0]?.page ?? 0;
  const on = sentence.words.filter(w => w.page === pageIdx);
  return {
    page: pageIdx,
    x: Math.min(...on.map(w => w.x)),
    top: Math.min(...on.map(w => w.top)),
    w: Math.max(...on.map(w => w.x + w.w)) - Math.min(...on.map(w => w.x)),
    h: Math.max(...on.map(w => w.top + w.h)) - Math.min(...on.map(w => w.top)),
  };
}

// Index units by their page so status boxes can be created lazily per page
// (creating thousands of overlay divs up front would jank big books).
function readerBuildUnitIndex() {
  readerState.unitsByPage = new Map();
  if (readerState.mode !== 'pdf') return;
  readerState.sentences.forEach((s, i) => {
    const pg = s.words[0]?.page ?? 0;
    if (!readerState.unitsByPage.has(pg)) readerState.unitsByPage.set(pg, []);
    readerState.unitsByPage.get(pg).push(i);
  });
}

// Create + paint the status boxes for one page (called when the page rasters).
function readerCreatePageStatus(pageIdx) {
  if (readerState.mode !== 'pdf') return;
  const pg = readerState.pages[pageIdx]; if (!pg) return;
  const idxs = readerState.unitsByPage?.get(pageIdx) || [];
  for (const i of idxs) {
    const s = readerState.sentences[i];
    if (!s || s._stat) continue;
    const el = document.createElement('div');
    el.className = 'reader-stat';
    el.dataset.si = i;
    pg.overlay.appendChild(el);
    s._stat = el;
    readerPaintStatus(i);
  }
}

// effective colour: reading wins, else cached/synth/pending
function readerEffectiveStatus(s) { return s.status; }

function readerSetStatus(idx, status) {
  const s = readerState.sentences[idx]; if (!s) return;
  s.status = status;
  readerPaintStatus(idx);
}

function readerPaintStatus(idx) {
  const s = readerState.sentences[idx]; if (!s) return;
  const eff = readerEffectiveStatus(s);
  if (readerState.mode === 'pdf') {
    if (!s._stat) return;
    const b = readerSentBBox(s);
    const scale = readerState.scale;
    Object.assign(s._stat.style, { left: (b.x * scale) + 'px', top: (b.top * scale) + 'px', width: (b.w * scale) + 'px', height: (b.h * scale) + 'px' });
    s._stat.className = 'reader-stat reader-stat-' + eff;
  } else {
    s.words.forEach(w => {
      if (!w.el) return;
      w.el.classList.remove('stat-pending', 'stat-synth', 'stat-ready', 'stat-reading');
      w.el.classList.add('stat-' + eff);
    });
  }
}

// ── Active reading highlight (word + scroll) ─────────────────────────────────

let _readerWordBox = null;

function readerEnsureBox() {
  if (!_readerWordBox) { _readerWordBox = document.createElement('div'); _readerWordBox.className = 'reader-hl-word'; }
}

function readerClearHighlights() {
  if (readerState.mode === 'text') {
    document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
  } else if (_readerWordBox) {
    _readerWordBox.hidden = true;
  }
}

// Scroll ONLY the #reader-doc pane (never the window) so the active text stays
// inside the visible frame and not under the app header. Keeps the target at
// ~38% from the top when it would otherwise be clipped top or bottom.
function readerEnsureVisible(el) {
  const doc = $('reader-doc'); if (!doc || !el) return;
  const dr = doc.getBoundingClientRect();
  const er = el.getBoundingClientRect();
  if (er.top < dr.top + 70 || er.bottom > dr.bottom - 50) {
    const delta = (er.top - dr.top) - doc.clientHeight * 0.38;
    doc.scrollTo({ top: doc.scrollTop + delta, behavior: 'smooth' });
  }
}

function readerHighlightSentence(sentence) {
  if (!sentence) return;
  if (readerState.mode === 'text') readerEnsureVisible(sentence.words[0]?.el);
  else if (sentence._stat) readerEnsureVisible(sentence._stat);
}

function readerHighlightWord(sentence, wi) {
  const w = sentence.words[wi]; if (!w) return;
  if (readerState.mode === 'text') {
    document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
    if (w.el) { w.el.classList.add('is-word'); readerEnsureVisible(w.el); }
    return;
  }
  readerEnsureBox();
  const pg = readerState.pages[w.page]; if (!pg) return;
  const scale = readerState.scale;
  if (_readerWordBox.parentNode !== pg.overlay) pg.overlay.appendChild(_readerWordBox);
  Object.assign(_readerWordBox.style, { left: (w.x * scale) + 'px', top: (w.top * scale) + 'px', width: (w.w * scale) + 'px', height: (w.h * scale) + 'px' });
  _readerWordBox.hidden = false;
  readerEnsureVisible(_readerWordBox);
}

// ── Scope: select range / page range / all + batch synthesis ─────────────────

// Select mode STAYS ON until the user explicitly finishes (Done button / Esc).
// Step 1: click a start sentence (the "anchor"). Step 2: click the end sentence.
// The anchor pulses while waiting; after a range is set, the next click starts a
// fresh anchor so you can keep refining without leaving the mode.
function readerSetSelecting(on) {
  readerState.selecting = !!on;
  readerState.selAnchored = false;
  const doc = $('reader-doc'); if (doc) doc.classList.toggle('reader-selecting', readerState.selecting);
  const btn = $('reader-select-toggle'); if (btn) btn.classList.toggle('active', readerState.selecting);
  const lbl = $('reader-select-label'); if (lbl) lbl.textContent = readerState.selecting ? 'Done' : 'Select range';
  const ic = $('reader-select-icon'); if (ic) ic.className = 'mdi ' + (readerState.selecting ? 'mdi-check' : 'mdi-cursor-default-click-outline');
  readerSelHint(readerState.selecting ? 'Click the <b>start</b> sentence…' : '');
  if (!readerState.selecting) readerPaintSelection();  // drop the transient anchor styling
}

function readerSelHint(html) {
  const h = $('reader-sel-hint'); if (!h) return;
  h.innerHTML = html; h.hidden = !html;
}

function readerPickSentence(si) {
  if (!readerState.selAnchored) {
    // start a fresh range
    readerState.selStart = si; readerState.selEnd = si; readerState.selAnchored = true;
    readerSelHint('Start at sentence <b>' + (si + 1) + '</b> — now click the <b>end</b> sentence');
  } else {
    readerState.selEnd = si; readerState.selAnchored = false;
    const r = readerSelRange();
    readerSelHint('Selected <b>' + (r[1] - r[0] + 1) + '</b> sentences — click a new start, or <b>Done</b>');
  }
  readerPaintSelection();
  readerUpdateScopeLabel();
}

function readerClearSelection() {
  readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
  readerPaintSelection();
  readerUpdateScopeLabel();
  if (readerState.selecting) readerSelHint('Click the <b>start</b> sentence…');
}

function readerSelRange() {
  if (readerState.selStart === null || readerState.selEnd === null) return null;
  return [Math.min(readerState.selStart, readerState.selEnd), Math.max(readerState.selStart, readerState.selEnd)];
}

function readerPaintSelection() {
  const range = readerSelRange();
  const inSel = i => range && i >= range[0] && i <= range[1];
  // While waiting for the second click, the lone start sentence is the "anchor".
  const anchorI = (readerState.selAnchored && range && range[0] === range[1]) ? range[0] : -1;
  readerState.sentences.forEach((s, i) => {
    const sel = inSel(i) && i !== anchorI;
    const anchor = i === anchorI;
    if (readerState.mode === 'pdf') {
      if (s._stat) { s._stat.classList.toggle('sel', sel); s._stat.classList.toggle('sel-anchor', anchor); }
    } else {
      s.words.forEach(w => { if (w.el) { w.el.classList.toggle('in-sel', sel); w.el.classList.toggle('sel-anchor', anchor); } });
    }
  });
}

// Indices to act on: an active selection wins, else PDF page range, else everything.
function readerScopeIndices() {
  const all = readerState.sentences.map((_, i) => i);
  const range = readerSelRange();
  if (range) return all.filter(i => i >= range[0] && i <= range[1]);
  if (readerState.mode === 'pdf') {
    const n = readerState.pages.length;
    let from = parseInt($('reader-page-from')?.value) || 1;
    let to   = parseInt($('reader-page-to')?.value) || n;
    from = Math.max(1, Math.min(from, n));
    to   = Math.max(from, Math.min(to, n));
    return all.filter(i => { const p = (readerState.sentences[i].words[0]?.page ?? 0) + 1; return p >= from && p <= to; });
  }
  return all;
}

function readerUpdateScopeLabel() {
  const scopeEl = $('reader-synth-scope');
  const range = readerSelRange();
  if (scopeEl) {
    if (range) scopeEl.textContent = 'selection';
    else if (readerState.mode === 'pdf') scopeEl.textContent = 'pages';
    else scopeEl.textContent = 'all';
  }
  const info = $('reader-sel-info'), txt = $('reader-sel-text');
  if (info) info.hidden = !range;
  if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '–' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')';
}

// Synthesise the given sentence indices (skips already-cached). Returns count done.
async function readerSynthIndices(targets) {
  targets = targets.filter(i => !readerState.blobCache.has(i));
  if (!targets.length || readerState.synthRunning) return 0;
  const voice = $('reader-voice-select')?.value;
  const backend = $('reader-backend-select')?.value;
  if (!voice) { toast('Pick a voice first', 'error'); return 0; }
  if (!backend) { toast('No TTS backend selected', 'error'); return 0; }
  const instruct = $('reader-instruct')?.value.trim() || '';

  readerState.synthRunning = true; readerState.synthCancel = false;
  const prog = $('reader-synth-prog'); if (prog) prog.hidden = false;
  const total = targets.length; let done = 0;
  const update = () => {
    const f = $('reader-synth-fill'); if (f) f.style.width = (done / total * 100) + '%';
    const l = $('reader-synth-label'); if (l) l.textContent = done + ' / ' + total;
  };
  update();

  const queue = targets.slice();
  const worker = async () => {
    while (queue.length && !readerState.synthCancel) {
      const i = queue.shift();
      if (readerState.blobCache.has(i)) { done++; update(); continue; }
      if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
      try {
        const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
        readerState.blobCache.set(i, blob);
        if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
      } catch (_) {
        if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'pending');
      }
      done++; update();
    }
  };
  const N = Math.min(2, targets.length);   // bounded concurrency — don't overload the engine
  await Promise.all(Array.from({ length: N }, worker));

  readerState.synthRunning = false;
  if (prog) prog.hidden = true;
  return done;
}

async function readerSynthAll() {
  const targets = readerScopeIndices().filter(i => !readerState.blobCache.has(i));
  if (!targets.length) { toast('Selected range is already synthesised', 'success'); return; }
  const done = await readerSynthIndices(targets);
  if (done || !readerState.synthCancel) toast(readerState.synthCancel ? 'Synthesis cancelled (' + done + ' done)' : 'Synthesised ' + done + ' sentences', readerState.synthCancel ? 'error' : 'success');
}

// ── Export as MP3 (per page, or per sentence) with meaningful filenames ──────

function readerSafeName(s) {
  return (s || 'audio').replace(/[\/\\:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60) || 'audio';
}
function readerPad(n) { return String(n).padStart(2, '0'); }
function readerPageOf(i) { return readerState.mode === 'pdf' ? (readerState.sentences[i].words[0]?.page ?? 0) + 1 : 1; }

function readerDownload(blob, filename) {
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
const _readerDelay = ms => new Promise(r => setTimeout(r, ms));

async function readerExport(mode) {
  if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
  const indices = readerScopeIndices();
  const missing = indices.filter(i => !readerState.blobCache.has(i));
  if (missing.length) {
    toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
    await readerSynthIndices(missing);
    if (readerState.synthCancel) { toast('Export cancelled', 'error'); return; }
  }
  const ready = indices.filter(i => readerState.blobCache.has(i));
  if (!ready.length) { toast('Nothing to export', 'error'); return; }
  const title = readerSafeName(readerState.title);

  if (mode === 'sentence') {
    const perPage = {};
    for (const i of ready) {
      const pg = readerPageOf(i);
      perPage[pg] = (perPage[pg] || 0) + 1;
      const name = readerState.mode === 'pdf'
        ? `${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`
        : `${title} - ${readerPad(perPage[pg])}.mp3`;
      readerDownload(readerState.blobCache.get(i), name);
      await _readerDelay(350);
    }
    toast('Exported ' + ready.length + ' MP3 files', 'success');
    return;
  }

  // mode === 'page' → one combined MP3 per page (text mode → one file for the doc)
  const byPage = new Map();
  ready.forEach(i => { const pg = readerPageOf(i); if (!byPage.has(pg)) byPage.set(pg, []); byPage.get(pg).push(i); });
  let files = 0;
  for (const [pg, idxs] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
    const blob = new Blob(idxs.map(i => readerState.blobCache.get(i)), { type: 'audio/mpeg' });
    const name = readerState.mode === 'pdf' ? `${title} - p${readerPad(pg)}.mp3` : `${title}.mp3`;
    readerDownload(blob, name);
    files++;
    await _readerDelay(400);
  }
  toast('Exported ' + files + (readerState.mode === 'pdf' ? ' page MP3 file(s)' : ' MP3'), 'success');
}

// ── Chunk mode (voice consistency) ───────────────────────────────────────────

function readerRechunk(mode) {
  readerState.chunkMode = mode;
  if (!readerState.baseSentences.length) return;
  readerStopPlayback();
  // drop synthesised audio (chunk text changed) and old per-unit status boxes
  readerState.blobCache.clear(); readerState.bufCache.clear(); readerState.gainCache.clear();
  readerState.savedAudioIdx = new Set();   // chunking changed → previously-saved audio no longer aligns
  readerState.pages.forEach(p => p.overlay.querySelectorAll('.reader-stat').forEach(e => e.remove()));
  readerClearSelection();
  readerState.sentences = readerGroupUnits(readerState.baseSentences, mode);
  readerBuildUnitIndex();
  // recreate status boxes only for pages already rendered (rest come on raster)
  readerState.pages.forEach((p, i) => { if (p.rendered) readerCreatePageStatus(i); });
  if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
  readerState.idx = 0;
  readerUpdateScopeLabel();
  readerUpdateProgress();
  readerHighlightSentence(readerState.sentences[0]);
  toast('Voice consistency: ' + mode + ' — audio cleared, re-synthesise', 'success');
}

// ── Generation params (seed / temperature) + loudness normalisation ──────────

function readerGenParams() {
  const out = {};
  const seed = $('reader-seed')?.value.trim();
  const temp = $('reader-temp')?.value.trim();
  const nspd = $('reader-tts-speed')?.value.trim();
  if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
  if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
  if (nspd !== '' && nspd != null && !isNaN(+nspd) && parseFloat(nspd) !== 1) out.speed = parseFloat(nspd);
  return Object.keys(out).length ? out : null;
}

// RMS-based gain so each chunk plays at a similar perceived loudness (clamped,
// peak-limited to avoid clipping). Cached per unit.
function readerComputeGain(idx, buf) {
  if (readerState.gainCache.has(idx)) return readerState.gainCache.get(idx);
  const ch = buf.getChannelData(0);
  const step = Math.max(1, Math.floor(ch.length / 8000));
  let sum = 0, n = 0, peak = 1e-6;
  for (let i = 0; i < ch.length; i += step) { const v = ch[i]; sum += v * v; n++; if (Math.abs(v) > peak) peak = Math.abs(v); }
  const rms = Math.sqrt(sum / Math.max(1, n)) || 0.0001;
  let gain = 0.10 / rms;                 // target RMS ≈ 0.10
  gain = Math.max(0.5, Math.min(gain, 4));
  gain = Math.min(gain, 0.99 / peak);    // never clip
  readerState.gainCache.set(idx, gain);
  return gain;
}

// ── Playback ─────────────────────────────────────────────────────────────────

// Fetch a chunk's audio that's already persisted on the server (synced device).
async function readerFetchServerAudio(idx) {
  if (!readerState.savedId || !readerState.savedAudioIdx.has(idx)) return null;
  try {
    const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);
    if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; }
  } catch (_) {}
  return null;
}

async function readerGetBuffer(idx) {
  if (readerState.bufCache.has(idx)) return readerState.bufCache.get(idx);
  let blob = readerState.blobCache.get(idx) || await readerFetchServerAudio(idx);
  if (!blob) {
    const voice = $('reader-voice-select')?.value;
    const backend = $('reader-backend-select')?.value;
    if (!voice) throw new Error('Pick a voice first');
    if (!backend) throw new Error('No TTS backend selected');
    const instruct = $('reader-instruct')?.value.trim() || '';
    if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth');
    blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
    readerState.blobCache.set(idx, blob);
    if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
  }
  const buf = await readerCtx().decodeAudioData(await blob.arrayBuffer());
  readerState.bufCache.set(idx, buf);
  return buf;
}

// Keep decoded PCM (large) only for a small window around the playhead; the
// compact MP3 blobs stay cached so re-entry just re-decodes (cheap).
function readerEvictBuffers(center) {
  if (readerState.bufCache.size <= READER_BUF_WINDOW * 2 + 1) return;
  for (const i of readerState.bufCache.keys()) {
    if (Math.abs(i - center) > READER_BUF_WINDOW) readerState.bufCache.delete(i);
  }
}

// Fetch the next chunk's audio AND pre-decode it so playback starts gaplessly.
async function readerPrefetch(idx) {
  if (idx < 0 || idx >= readerState.sentences.length) return;
  if (!readerState.blobCache.has(idx)) {
    // prefer audio already persisted on the server, else synthesise
    if (!(await readerFetchServerAudio(idx))) {
      const voice = $('reader-voice-select')?.value;
      const backend = $('reader-backend-select')?.value;
      if (!voice || !backend) return;
      const instruct = $('reader-instruct')?.value.trim() || '';
      if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth');
      try {
        const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
        readerState.blobCache.set(idx, b);
        if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
      } catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; }
    }
  }
  // pre-decode (only matters while it's the immediate look-ahead within the window)
  if (!readerState.bufCache.has(idx) && Math.abs(idx - readerState.idx) <= READER_BUF_WINDOW) {
    try { readerState.bufCache.set(idx, await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer())); }
    catch (_) {}
  }
}

// Revert the single "reading" unit to its idle colour (no full-array scan).
function readerClearReading() {
  const i = readerState.readingIdx;
  if (i >= 0 && i < readerState.sentences.length && readerState.sentences[i].status === 'reading') {
    readerSetStatus(i, readerState.blobCache.has(i) ? 'ready' : 'pending');
  }
  readerState.readingIdx = -1;
}

async function readerPlayCurrent() {
  if (!readerState.playing) return;
  if (readerState.idx >= readerState.sentences.length) { readerStopPlayback(); readerState.idx = 0; readerUpdateProgress(); return; }
  const idx = readerState.idx;
  const sentence = readerState.sentences[idx];
  readerUpdateProgress();
  readerSaveResume();
  readerState.readingIdx = idx;
  readerSetStatus(idx, 'reading');
  readerHighlightSentence(sentence);

  let buf;
  try { buf = await readerGetBuffer(idx); }
  catch (e) { toast(e.message || String(e), 'error'); readerSetStatus(idx, 'pending'); readerStopPlayback(); return; }
  if (!readerState.playing || readerState.idx !== idx) return;
  readerEvictBuffers(idx);

  const timings = computeWordTimings(sentence.text, buf.duration);
  const ctx = readerCtx();
  readerStopSource();
  const src = ctx.createBufferSource();
  src.buffer = buf;
  src.playbackRate.value = readerState.speed;
  if (readerState.normalize) {
    const g = ctx.createGain();
    g.gain.value = readerComputeGain(idx, buf);
    src.connect(g); g.connect(ctx.destination);
  } else {
    src.connect(ctx.destination);
  }
  readerState.currentSource = src;
  const t0 = ctx.currentTime;

  readerPrefetch(idx + 1);

  src.onended = () => {
    if (readerState.currentSource !== src) return;
    readerState.currentSource = null;
    if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
    readerSetStatus(idx, 'ready');   // synthesised → green
    if (readerState.readingIdx === idx) readerState.readingIdx = -1;
    if (!readerState.playing) return;
    readerState.idx++;
    readerPlayCurrent();
  };
  src.start(0);

  const tick = () => {
    if (readerState.currentSource !== src) return;
    const elapsed = (ctx.currentTime - t0) * readerState.speed;
    let active = 0;
    for (let i = timings.length - 1; i >= 0; i--) { if (elapsed >= timings[i].start) { active = i; break; } }
    readerHighlightWord(sentence, Math.min(active, sentence.words.length - 1));
    readerState.raf = requestAnimationFrame(tick);
  };
  readerState.raf = requestAnimationFrame(tick);
}

function readerStopSource() {
  if (readerState.currentSource) { try { readerState.currentSource.onended = null; readerState.currentSource.stop(0); } catch (_) {} readerState.currentSource = null; }
  if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
}

function readerStopPlayback() {
  readerState.playing = false;
  readerStopSource();
  readerClearHighlights();
  // a sentence left mid-read reverts to its cached/uncached colour
  readerClearReading();
  readerUpdatePlayBtn();
  if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', false);
}

function readerPlay() {
  if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
  if (!$('reader-voice-select')?.value) { toast('Pick a voice first', 'error'); return; }
  readerCtx();
  readerState.playing = true;
  readerUpdatePlayBtn();
  if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', true);
  readerPlayCurrent();
}

function readerPause() {
  readerState.playing = false;
  readerStopSource();
  readerSaveResume();
  readerPersistProgress();
  readerClearReading();
  readerUpdatePlayBtn();
}

function readerJumpTo(idx) {
  readerState.idx = Math.max(0, Math.min(idx, readerState.sentences.length - 1));
  readerStopSource();
  readerClearHighlights();
  readerClearReading();
  readerUpdateProgress();
  readerSaveResume();
  if (readerState.playing) readerPlayCurrent();
  else readerHighlightSentence(readerState.sentences[readerState.idx]);
}

function readerUpdatePlayBtn() {
  const btn = $('reader-play'); if (!btn) return;
  const ic = btn.querySelector('.mdi');
  if (ic) ic.className = 'mdi ' + (readerState.playing ? 'mdi-pause' : 'mdi-play');
}

function readerUpdateProgress() {
  const total = readerState.sentences.length;
  const cur = total ? readerState.idx + 1 : 0;
  const lbl = $('reader-progress-label'); if (lbl) lbl.textContent = cur + ' / ' + total;
  const fill = $('reader-progress-fill'); if (fill) fill.style.width = (total ? (readerState.idx / total) * 100 : 0) + '%';
}

// ── Resume (persist last position per document title) ────────────────────────

function readerSaveResume() {
  if (!readerState.title || !readerState.sentences.length) return;
  try { localStorage.setItem(READER_RESUME_KEY, JSON.stringify({ title: readerState.title, idx: readerState.idx, total: readerState.sentences.length })); } catch (_) {}
}

function readerLoadResume() {
  try {
    const r = JSON.parse(localStorage.getItem(READER_RESUME_KEY) || 'null');
    if (r && r.title === readerState.title && r.idx > 0 && r.idx < readerState.sentences.length) {
      readerState.idx = r.idx;
      return true;
    }
  } catch (_) {}
  return false;
}

// ── Library (server-side under the config volume → syncs across devices) ──────
// Each book lives at <config>/reader_library/<id>/ (meta.json + source + audio/).
// Audio is fetched per-unit on demand so opening a book is instant on any device.

const READER_API = '/api/reader/docs';

function readerSyntheticCount() {
  let n = 0;
  for (let i = 0; i < readerState.sentences.length; i++) if (readerState.blobCache.has(i)) n++;
  return n;
}

async function readerSaveLibrary() {
  if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
  if (readerState.mode === 'pdf' && !readerState.fileBlob) { toast('Original PDF unavailable to save', 'error'); return; }
  const meta = {
    title:    readerState.title || 'Untitled',
    kind:     readerState.mode,
    idx:      readerState.idx,
    voice:    $('reader-voice-select')?.value || '',
    backend:  $('reader-backend-select')?.value || '',
    speed:    readerState.speed,
    instruct: $('reader-instruct')?.value.trim() || '',
    chunkMode: readerState.chunkMode,
    seed:     $('reader-seed')?.value.trim() || '',
    temperature: $('reader-temp')?.value.trim() || '',
    tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
    normalize: readerState.normalize,
    sentenceCount: readerState.sentences.length,
    pageCount: readerState.pages.length,
    synthCount: readerSyntheticCount(),
    updated:  new Date().toISOString(),
  };
  const btn = $('reader-save-lib'); if (btn) btn.disabled = true;
  try {
    if (readerState.savedId) meta.id = readerState.savedId;
    const r = await fetch(READER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
    const fresh = !readerState.savedId;
    readerState.savedId = (await r.json()).id;
    // upload the source document once (it's needed to re-render on another device)
    if (fresh || !readerState.sourceUploaded) {
      const ext = readerState.mode === 'pdf' ? 'pdf' : 'txt';
      const body = readerState.mode === 'pdf' ? readerState.fileBlob : new Blob([readerState.docText || ''], { type: 'text/plain' });
      const sr = await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`, { method: 'PUT', body });
      if (sr.ok) readerState.sourceUploaded = true;
    }
    // upload ONLY chunks not already on the server (incremental)
    let uploaded = 0;
    for (let i = 0; i < readerState.sentences.length; i++) {
      if (readerState.blobCache.has(i) && !readerState.savedAudioIdx.has(i)) {
        const ar = await fetch(`${READER_API}/${readerState.savedId}/audio/${i}`, { method: 'PUT', body: readerState.blobCache.get(i) });
        if (ar.ok) { readerState.savedAudioIdx.add(i); uploaded++; }
      }
    }
    toast('Saved (' + meta.synthCount + ' / ' + meta.sentenceCount + ' synthesised' + (uploaded ? ', +' + uploaded + ' new' : '') + ')', 'success');
    readerRenderLibrary();
  } catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
  finally { if (btn) btn.disabled = false; }
}

// Light update of just the reading position (called on stop/pause/leave)
async function readerPersistProgress() {
  if (!readerState.savedId) return;
  try {
    await fetch(`${READER_API}/${readerState.savedId}/progress`, {
      method: 'PATCH', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }),
    });
  } catch (_) {}
}

async function readerDeleteLibraryDoc(id) {
  try {
    await fetch(`${READER_API}/${id}`, { method: 'DELETE' });
    if (readerState.savedId === id) { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; }
    readerRenderLibrary();
  } catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}

async function readerOpenLibraryDoc(id) {
  let rec;
  try { const r = await fetch(`${READER_API}/${id}`); if (!r.ok) throw new Error(r.statusText); rec = await r.json(); }
  catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
  readerResetDoc();
  readerState.title = rec.title;
  const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = rec.title;
  // restore controls (chunk mode must be set BEFORE the document is grouped)
  readerState.chunkMode = rec.chunkMode || 'sentence';
  if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
  if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
  if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
  if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed;
  readerState.normalize = rec.normalize !== false;
  if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
  if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
  if (rec.voice && $('reader-voice-select'))     readerSetSelectValue('reader-voice-select', rec.voice);
  if (typeof rec.speed === 'number') {
    readerState.speed = rec.speed;
    if ($('reader-speed')) $('reader-speed').value = rec.speed;
    const sl = $('reader-speed-label'); if (sl) sl.textContent = rec.speed.toFixed(2).replace(/0$/, '') + '×';
  }
  if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || '';
  // fetch the source document from the server
  let sourceBlob = null;
  try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {}
  try {
    if (rec.kind === 'pdf') {
      if (!sourceBlob) {
        // PDF upload failed at save time — let user re-supply the file
        const inp = document.createElement('input');
        inp.type = 'file'; inp.accept = '.pdf';
        inp.onchange = async () => {
          const file = inp.files[0]; if (!file) return;
          const put = await fetch(`${READER_API}/${id}/source?ext=pdf`, { method: 'PUT', body: file });
          if (!put.ok) { toast('Re-upload failed', 'error'); return; }
          toast('PDF restored — opening…', 'success');
          readerState.savedId = id; readerState.sourceUploaded = true;
          readerState.fileBlob = file;
          await readerLoadPdf(file);
        };
        inp.click();
        toast('PDF source missing — please re-select the original file', 'error');
        return;
      }
      readerState.fileBlob = sourceBlob;
      toast('Opening book…', 'success');
      await readerLoadPdf(sourceBlob);
    } else {
      const text = sourceBlob ? await sourceBlob.text() : (rec.text || '');
      readerState.docText = text;
      readerLoadText(text);
    }
  } catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
  if (!readerState.sentences.length) { toast('Document had no readable text', 'error'); return; }
  if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
  readerState.savedId = id;
  readerState.sourceUploaded = true;     // already on the server
  readerState.savedAudioIdx = new Set();
  // mark which units already have audio on the server (fetched lazily on play)
  if (rec.sentenceCount === readerState.sentences.length) {
    (rec.audioIdx || []).forEach(i => { if (i < readerState.sentences.length) { readerState.savedAudioIdx.add(i); readerSetStatus(i, 'ready'); } });
  } else if ((rec.audioIdx || []).length) {
    toast('Document changed — re-synthesis needed', 'error');
  }
  readerState.idx = Math.max(0, Math.min(rec.idx || 0, readerState.sentences.length - 1));
  $('reader-transport').hidden = false;
  $('reader-dropzone').hidden = true;
  $('reader-toolbar').hidden = readerState.mode !== 'pdf';
  $('reader-synthbar').hidden = false;
  const pr = $('reader-page-range');
  if (pr) {
    pr.hidden = readerState.mode !== 'pdf';
    if (readerState.mode === 'pdf') {
      const n = readerState.pages.length;
      if ($('reader-page-from')) { $('reader-page-from').max = n; $('reader-page-from').value = 1; }
      if ($('reader-page-to'))   { $('reader-page-to').max = n;   $('reader-page-to').value = n; }
    }
  }
  readerUpdateScopeLabel();
  readerUpdateProgress();
  readerHighlightSentence(readerState.sentences[readerState.idx]);
  toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
  if (typeof window.navReaderView === 'function') window.navReaderView('main');
  else showReaderView('main');
}

function readerSetSelectValue(id, value) {
  const sel = $(id); if (!sel) return;
  if (![...sel.options].some(o => o.value === value)) {
    const o = document.createElement('option'); o.value = o.textContent = value; sel.appendChild(o);
  }
  sel.value = value;
}

async function readerRenderLibrary() {
  const card = $('reader-library-card'), list = $('reader-lib-list');
  if (!card || !list) return;
  let all = [];
  try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
  if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
  list.innerHTML = all.map(rec => {
    const total = rec.sentenceCount || 0;
    const synth = rec.synthCount || 0;
    const readPct  = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
    const synthPct = total ? Math.round((synth / total) * 100) : 0;
    const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
    
    let h = 0;
    const titleStr = rec.title || 'Untitled';
    for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
    const h2 = (h + 40) % 360;
    const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;

    // A timestamp busts the cache if the cover was updated
    const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`;
    const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`;

    return `<div class="reh-book reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
      <div class="reh-book-actions">
        <button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
      </div>
      <div class="reh-book-title">${escHtml(rec.title)}</div>
      <div class="reh-book-meta">
        ${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
        <div class="reh-book-progress" title="${synthPct}% audio"><div style="width:${synthPct}%"></div></div>
        <div style="margin-top:4px;opacity:.8"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span> ${readPct}% read · ${date}</div>
      </div>
    </div>`;
  }).join('');
  
  list.querySelectorAll('.reader-book').forEach(el => {
    el.addEventListener('click', e => {
      if (e.target.closest('.reader-book-del')) return;
      readerOpenLibraryDoc(el.dataset.id);
    });

    const delBtn = el.querySelector('.reader-book-del');
    if (delBtn) {
      delBtn.addEventListener('click', e => {
        e.stopPropagation();
        
        if (el.querySelector('.reader-book-del-confirm')) return;
        
        const confirmOverlay = document.createElement('div');
        confirmOverlay.className = 'reader-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 Book?</div>
          <div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">Audio files will be removed.</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();
          confirmOverlay.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px;"></span>';
          try {
            const r = await fetch(`${READER_API}/${el.dataset.id}`, { method: 'DELETE' });
            if (!r.ok) throw new Error('Failed to delete book');
            toast('Book deleted', 'success');
            readerRenderLibrary();
          } catch (err) {
            toast(err.message, 'error');
            confirmOverlay.remove();
          }
        });
        
        el.appendChild(confirmOverlay);
      });
    }
    
    // Drag & drop for cover image
    el.addEventListener('dragover', e => {
      if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
        e.preventDefault();
        el.style.borderColor = 'var(--accent)';
      }
    });
    el.addEventListener('dragleave', () => { el.style.borderColor = ''; });
    el.addEventListener('drop', async e => {
      if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
      e.preventDefault(); e.stopPropagation();
      el.style.borderColor = '';
      const f = e.dataTransfer.files?.[0];
      if (!f || !f.type.startsWith('image/')) { toast('Please drop an image file', 'error'); return; }
      
      try {
        const id = el.dataset.id;
        const r = await fetch(`${READER_API}/${id}/cover`, { method: 'PUT', body: f });
        if (!r.ok) throw new Error(r.statusText);
        
        // Touch the updated timestamp so the cache buster updates on re-render
        await fetch(`${READER_API}/${id}/progress`, {
          method: 'PATCH', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ updated: new Date().toISOString() }),
        });
        toast('Cover image updated', 'success');
        readerRenderLibrary();
      } catch (err) {
        toast('Failed to upload cover: ' + (err.message || err), 'error');
      }
    });
  });
  
  list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
    e.stopPropagation();
    if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
  }));
}


// ── Wiring ───────────────────────────────────────────────────────────────────

$('reader-file-input')?.addEventListener('change', function () {
  const f = this.files?.[0]; this.value = '';
  readerImportFile(f);
});

$('reader-fetch-voices-btn')?.addEventListener('click', readerFetchVoices);
$('reader-backend-select')?.addEventListener('change', () => {
  const sel = $('reader-voice-select'); if (sel) sel.innerHTML = '<option value="">— fetch voices —</option>';
  readerUpdateBackendHint();
});
$('reader-chunk-mode')?.addEventListener('change', function () { readerRechunk(this.value); });
$('reader-normalize')?.addEventListener('change', function () { readerState.normalize = this.checked; });

$('reader-play')?.addEventListener('click', () => { readerState.playing ? readerPause() : readerPlay(); });
$('reader-stop')?.addEventListener('click', () => { readerStopPlayback(); readerState.idx = 0; readerSaveResume(); readerUpdateProgress(); });
$('reader-prev')?.addEventListener('click', () => readerJumpTo(readerState.idx - 1));
$('reader-next')?.addEventListener('click', () => readerJumpTo(readerState.idx + 1));

$('reader-speed')?.addEventListener('input', function () {
  readerState.speed = parseFloat(this.value) || 1;
  const lbl = $('reader-speed-label'); if (lbl) lbl.textContent = readerState.speed.toFixed(2).replace(/0$/, '') + '×';
  if (readerState.currentSource) readerState.currentSource.playbackRate.value = readerState.speed;
});

// Zoom controls
$('reader-zoom-fitw')?.addEventListener('click', () => readerApplyZoom('fit-width'));
$('reader-zoom-fith')?.addEventListener('click', () => readerApplyZoom('fit-height'));
$('reader-zoom-two') ?.addEventListener('click', () => readerApplyZoom('two'));
$('reader-zoom-in')  ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); });
$('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); });

// ── Document search ──────────────────────────────────────────────────────────
let _readerSearchIdx = -1; // last hit sentence index
function readerSearchGo(term, direction) {
  if (!term) { const st = $('reader-search-status'); if (st) st.textContent = ''; return; }
  const lc = term.toLowerCase();
  const sents = readerState.sentences;
  if (!sents || !sents.length) { const st = $('reader-search-status'); if (st) st.textContent = 'No text loaded'; return; }
  const dir = direction === -1 ? -1 : 1;
  const start = _readerSearchIdx < 0 ? 0 : (_readerSearchIdx + dir + sents.length) % sents.length;
  for (let i = 0; i < sents.length; i++) {
    const idx = (start + i * dir + sents.length * sents.length) % sents.length;
    const s = sents[idx];
    const txt = (s.words || []).map(w => w.text).join(' ').toLowerCase();
    if (txt.includes(lc)) {
      _readerSearchIdx = idx;
      const pageIdx = s.words?.[0]?.page ?? 0;
      const pgDiv = readerState.pages[pageIdx]?.pageDiv;
      if (pgDiv) pgDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
      const st = $('reader-search-status');
      const total = sents.filter(s2 => (s2.words||[]).map(w=>w.text).join(' ').toLowerCase().includes(lc)).length;
      if (st) st.textContent = total ? `p.${pageIdx + 1}` : '';
      return;
    }
  }
  const st = $('reader-search-status');
  if (st) st.textContent = 'Not found';
}

$('reader-search')?.addEventListener('keydown', e => {
  if (e.key === 'Enter') { e.preventDefault(); readerSearchGo(e.target.value.trim(), e.shiftKey ? -1 : 1); }
  if (e.key === 'Escape') { e.target.value = ''; _readerSearchIdx = -1; const st = $('reader-search-status'); if (st) st.textContent = ''; }
});
$('reader-search')?.addEventListener('input', e => {
  _readerSearchIdx = -1;
  if (!e.target.value) { const st = $('reader-search-status'); if (st) st.textContent = ''; }
});

// Library + export
$('reader-save-lib')?.addEventListener('click', readerSaveLibrary);
$('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page'));

// Batch synthesis + range selection
$('reader-synth-all')?.addEventListener('click', readerSynthAll);
$('reader-synth-cancel')?.addEventListener('click', () => { readerState.synthCancel = true; });
$('reader-select-toggle')?.addEventListener('click', () => {
  if (readerState.selecting) readerSetSelecting(false);          // "Done"
  else { readerClearSelection(); readerSetSelecting(true); }
});
$('reader-sel-clear')?.addEventListener('click', readerClearSelection);
// Esc leaves select mode
document.addEventListener('keydown', e => {
  if (e.key === 'Escape' && readerState.selecting) { readerSetSelecting(false); }
});
$('reader-page-from')?.addEventListener('change', readerUpdateScopeLabel);
$('reader-page-to')  ?.addEventListener('change', readerUpdateScopeLabel);
// Click a PDF sentence's status box to set a selection point (in select mode)
$('reader-doc')?.addEventListener('click', e => {
  if (!readerState.selecting || readerState.mode !== 'pdf') return;
  const stat = e.target.closest('.reader-stat'); if (!stat) return;
  const si = parseInt(stat.dataset.si);
  if (!isNaN(si)) readerPickSentence(si);
});

// Re-raster pages that scroll into view (zoom invalidates cached canvases)
$('reader-doc')?.addEventListener('scroll', (function () {
  let t = null;
  return () => { if (t) return; t = setTimeout(() => { t = null; readerRenderVisible(); }, 120); };
})());

// Keep fit-width/height/two correct on window resize
window.addEventListener('resize', (function () {
  let t = null;
  return () => { if (readerState.mode !== 'pdf' || readerState.zoomMode === 'custom') return; clearTimeout(t); t = setTimeout(() => readerApplyZoom(readerState.zoomMode), 200); };
})());

// Drag & drop over the dropzone + the whole reader doc
(function setupReaderDropzone() {
  const targets = ['reader-dropzone', 'reader-doc'].map(id => $(id)).filter(Boolean);
  targets.forEach(el => {
    el.addEventListener('dragover', e => { if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); el.classList.add('dragover'); } });
    el.addEventListener('dragleave', () => el.classList.remove('dragover'));
    el.addEventListener('drop', e => {
      if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
      e.preventDefault(); el.classList.remove('dragover');
      readerImportFile(e.dataTransfer.files?.[0]);
    });
  });
})();

window.showReaderView = function (view) {
  const main = $('reader-main-view');
  const cast = $('reader-audiobook-panel');
  const lib  = $('reader-library-card');
  if (main) main.hidden = (view !== 'main');
  if (cast) {
    cast.hidden = (view !== 'cast');
    if (view !== 'cast') cast.style.display = '';
    if (view === 'cast' && !cast.innerHTML.trim()) {
      cast.innerHTML = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
      cast.className = 'ab-castpanel-inline card';
    }
  }
  if (lib) {
    lib.hidden = (view !== 'library');
    if (view === 'library') {
      lib.classList.remove('card'); // Remove card styling so it takes full width/height naturally
      lib.style.boxShadow = 'none';
      lib.style.border = 'none';
      lib.style.background = 'transparent';
      readerRenderLibrary(); // Re-fetch on open
    }
  }
};

window.readerOnShow = function () {
  if (window._readerStartView) {
    window.showReaderView(window._readerStartView);
    window._readerStartView = null;
  }
};


