// ── Combined Library — Books · Theater Plays · Characters/Cast ────────────────
//
// One home for every production. Books live server-side (reader_library),
// plays live client-side (IndexedDB reh-library), characters live client-side
// (IndexedDB character-library). They are joined by their normalized title —
// see prodKey() — so a book and a play with the same title are one production
// and share the character cast. This module owns the s-library section and
// provides the cross-links that open a production in Read Aloud or the Rehearser.

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

// The universal join key across books, plays and characters (mirrors clKey).
function prodKey(title) { return String(title || '').trim().toLowerCase(); }

window._libraryView = (function () {
  try { return localStorage.getItem('ttsvc_library_view') || 'books'; } catch (_) { return 'books'; }
})();

window.navLibraryView = function (view) {
  if (typeof navTo === 'function') navTo('s-library');
  window._libraryView = view;
  try { localStorage.setItem('ttsvc_library_view', view); } catch (_) {}
  document.querySelectorAll('[data-library-view]').forEach(function (el) {
    el.classList.toggle('is-active', el.dataset.libraryView === view);
  });
  document.querySelectorAll('[data-library-panel]').forEach(function (el) {
    el.classList.toggle('is-active', el.dataset.libraryPanel === view);
  });
  libraryRender(view);
};

window.libraryRender = function (view) {
  view = view || window._libraryView || 'books';
  // Reflect the active tab/panel even when entered via the section hook
  document.querySelectorAll('[data-library-view]').forEach(function (el) {
    el.classList.toggle('is-active', el.dataset.libraryView === view);
  });
  document.querySelectorAll('[data-library-panel]').forEach(function (el) {
    el.classList.toggle('is-active', el.dataset.libraryPanel === view);
  });
  if (view === 'books') libraryRenderBooks();
  else if (view === 'plays') libraryRenderPlays();
  else if (view === 'characters') libraryRenderCharacters();
};

// ── Books (server-side reader library) ────────────────────────────────────────

function _libSkeleton(n) {
  return Array.from({ length: n }, () =>
    '<div class="reh-book lib-book-sk" style="pointer-events:none">'
    + '<div class="sk" style="height:14px;width:70%;margin-bottom:8px;border-radius:4px"></div>'
    + '<div class="sk" style="height:10px;width:45%;border-radius:4px;margin-bottom:6px"></div>'
    + '<div class="sk" style="height:6px;width:90%;border-radius:3px"></div>'
    + '</div>'
  ).join('');
}

async function libraryRenderBooks() {
  const list = document.getElementById('lib-books-list');
  if (!list) return;
  list.innerHTML = _libSkeleton(4);
  let all = [];
  try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
  if (!all.length) {
    list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>'
      + '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>';
    return;
  }
  all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
  list.innerHTML = all.map(function (rec) {
    const total = rec.sentenceCount || 0;
    const synthPct = total ? Math.round(((rec.synthCount || 0) / total) * 100) : 0;
    const readPct  = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
    const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
    const cov = libBookCover(rec.title || 'Untitled');
    const coverUrl = LIB_READER_API + '/' + rec.id + '/cover?t=' + new Date(rec.updated || Date.now()).getTime();
    const bg = rec.hasCover
      ? 'style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url(\'' + coverUrl + '\');background-size:cover;background-position:center;color:#fff"'
      : 'style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '"';
    return '<div class="reh-book lib-book" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" ' + bg + ' title="' + escHtml(rec.title || '') + '">'
      + '<div class="reh-book-actions">'
      +   '<button class="reh-book-act lib-act-rehearse" title="Rehearse this title"><span class="mdi mdi-theater"></span></button>'
      +   '<button class="reh-book-act lib-act-del-book" title="Delete book"><span class="mdi mdi-delete-outline"></span></button>'
      + '</div>'
      + '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</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('.lib-book').forEach(function (el) {
    const id = el.dataset.id, title = el.dataset.title;
    el.addEventListener('click', function (e) {
      if (e.target.closest('.reh-book-act')) return;
      // Show the reader on the PDF view immediately and suppress the stale-cast
      // restore so a freshly-opened book never lands in an empty casting panel.
      window._readerStartView = 'main';
      window._readerSuppressCastRestore = true;
      if (typeof navTo === 'function') navTo('s-reader');
      if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
    });
    const reh = el.querySelector('.lib-act-rehearse');
    if (reh) reh.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInRehearser(title); });
    const del = el.querySelector('.lib-act-del-book');
    if (del) del.addEventListener('click', function (e) {
      e.stopPropagation();
      libConfirmDelete(el, 'Delete book?', 'Audio files will be removed.', async function () {
        try { const r = await fetch(LIB_READER_API + '/' + id, { method: 'DELETE' }); if (!r.ok) throw new Error('delete failed'); toast('Book deleted', 'success'); libraryRenderBooks(); }
        catch (err) { toast(err.message || 'Delete failed', 'error'); }
      });
    });
  });
}

// ── Theater Plays (client-side rehearser library) ─────────────────────────────

async function libraryRenderPlays() {
  const list = document.getElementById('lib-plays-list');
  if (!list) return;
  list.innerHTML = _libSkeleton(3);
  let all = [];
  try { all = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; } catch (_) { all = []; }
  if (!all.length) {
    list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-theater"></span><p>No theater plays yet.</p>'
      + '<p class="lib-empty-hint">Open <b>Script Rehearsal</b> → Import / Export to add a script, or cast a book as an audiobook.</p></div>';
    return;
  }
  all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
  list.innerHTML = all.map(function (rec) {
    const speakers = Object.keys(rec.cast || {});
    const total = (typeof parseScript === 'function') ? parseScript(rec.script || '').filter(function (l) { return l.type === 'dialog'; }).length : 0;
    const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
    const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
    const cov = libBookCover(rec.title || 'Untitled');
    const avatars = speakers.slice(0, 5).map(function (sp) {
      const c = rec.cast[sp] || {};
      return '<span style="background:' + (c.color || '#888') + '">' + (sp[0] || '?').toUpperCase() + '</span>';
    }).join('');
    return '<div class="reh-book lib-play" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '" title="' + escHtml(rec.title || '') + '">'
      + '<div class="reh-book-actions">'
      +   '<button class="reh-book-act lib-act-readaloud" title="Read aloud"><span class="mdi mdi-book-open-page-variant-outline"></span></button>'
      +   '<button class="reh-book-act lib-act-del-play" title="Delete rehearsal"><span class="mdi mdi-delete-outline"></span></button>'
      + '</div>'
      + '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
      + '<div class="reh-book-meta">'
      +   '<div class="reh-book-avatars">' + avatars + '</div>'
      +   total + ' lines · ' + speakers.length + ' cast'
      +   '<div class="reh-book-progress"><div style="width:' + pct + '%"></div></div>'
      +   '<div style="margin-top:4px;opacity:.8">' + pct + '% · ' + date + '</div>'
      + '</div></div>';
  }).join('');

  list.querySelectorAll('.lib-play').forEach(function (el) {
    const id = parseInt(el.dataset.id, 10), title = el.dataset.title;
    el.addEventListener('click', function (e) {
      if (e.target.closest('.reh-book-act')) return;
      openPlayInRehearser(id);
    });
    const ra = el.querySelector('.lib-act-readaloud');
    if (ra) ra.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInReader(title); });
    const del = el.querySelector('.lib-act-del-play');
    if (del) del.addEventListener('click', function (e) {
      e.stopPropagation();
      libConfirmDelete(el, 'Delete rehearsal?', 'This cannot be undone.', async function () {
        try { if (typeof rehDbDelete === 'function') await rehDbDelete(id); toast('Rehearsal deleted', 'success'); libraryRenderPlays(); }
        catch (err) { toast(err.message || 'Delete failed', 'error'); }
      });
    });
  });
}

// ── Cross-links between Read Aloud and the Rehearser ──────────────────────────

async function openPlayInRehearser(id) {
  try {
    const db = await rehDbOpen();
    const rec = await new Promise(function (res, rej) {
      const r = db.transaction(REH_STORE, 'readonly').objectStore(REH_STORE).get(id);
      r.onsuccess = function (e) { res(e.target.result); }; r.onerror = function (e) { rej(e.target.error); };
    });
    if (rec && typeof loadRecord === 'function') loadRecord(rec);
  } catch (err) { toast('Could not open rehearsal', 'error'); }
}

// Open a production in the Rehearser: reuse an existing play of the same title,
// else seed one from the book's source text.
async function productionOpenInRehearser(title) {
  const key = prodKey(title);
  try {
    const plays = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : [];
    const match = plays.find(function (p) { return prodKey(p.title) === key; });
    if (match) { openPlayInRehearser(match.id); return; }
  } catch (_) {}
  // No play yet — try to seed from the matching book's source.
  let books = [];
  try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
  const book = books.find(function (b) { return prodKey(b.title) === key; });
  if (book && book.kind !== 'pdf') {
    try {
      const sr = await fetch(LIB_READER_API + '/' + book.id + '/source');
      const text = sr.ok ? await sr.text() : '';
      if (text && typeof audiobookOpenInRehearser === 'function') { audiobookOpenInRehearser(text, title, []); return; }
    } catch (_) {}
  }
  if (book && book.kind === 'pdf') {
    if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(book.id);
    toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal', 'info');
    return;
  }
  toast('No source to rehearse for this title yet', 'error');
}

// Open a production in Read Aloud: reuse the matching saved book.
async function productionOpenInReader(title) {
  const key = prodKey(title);
  let books = [];
  try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
  const book = books.find(function (b) { return prodKey(b.title) === key; });
  if (book && typeof readerOpenLibraryDoc === 'function') { readerOpenLibraryDoc(book.id); return; }
  if (typeof navTo === 'function') navTo('s-reader');
  toast('No audiobook for this title yet — import its source in Read Aloud', 'info');
}

// ── Shared cast — the character roster a production resolves against ──────────
//
// Returns name(lowercased) → { name, voice, gender, soul, tags } gathered from
// the character library by origin book OR tag membership. The rehearser and the
// audiobook caster both fill empty speaker slots from this, so a character cast
// once is reused everywhere the same production is opened.
async function castForProduction(title) {
  const out = {};
  if (typeof clGetAllByTagOrBook !== 'function') return out;
  let recs = [];
  try { recs = await clGetAllByTagOrBook(title); } catch (_) { recs = []; }
  recs.forEach(function (r) {
    const name = (r.name || '').trim();
    if (!name) return;
    const voice = (r.voice && r.voice.id) ? r.voice.id : (typeof r.voice === 'string' ? r.voice : '');
    out[name.toLowerCase()] = {
      name: name,
      voice: voice || '',
      gender: (r.sheet && r.sheet.gender) || '',
      soul: (r.sheet && (r.sheet.voice_pattern || r.sheet.motivation)) || '',
      tags: r.tags || '',
    };
  });
  return out;
}
window.castForProduction = castForProduction;

// Persist cast voice choices back to the shared roster. Guard: only updates
// characters that ALREADY exist for this production (by name) — never creates
// records, so throwaway scripts and narrators don't pollute the library.
async function castWriteBack(title, castMap) {
  if (!title || !castMap || typeof clGetAllByTagOrBook !== 'function' || typeof clPut !== 'function') return;
  let recs = [];
  try { recs = await clGetAllByTagOrBook(title); } catch (_) { return; }
  if (!recs.length) return;
  const byName = {};
  recs.forEach(function (r) { byName[(r.name || '').trim().toLowerCase()] = r; });
  let n = 0;
  for (const sp of Object.keys(castMap)) {
    if (String(sp).includes('NARRATOR')) continue;
    const slot = castMap[sp] || {};
    const voice = slot.voice;
    if (!voice || voice === 'me') continue;
    const rec = byName[String(sp).trim().toLowerCase()];
    if (!rec) continue;
    const curId = (rec.voice && rec.voice.id) ? rec.voice.id : (typeof rec.voice === 'string' ? rec.voice : '');
    if (curId === voice) continue;
    rec.voice = { id: voice };
    rec.updated = new Date();
    try { await clPut(rec); n++; } catch (_) {}
  }
  return n;
}
window.castWriteBack = castWriteBack;

// ── Shared helpers ────────────────────────────────────────────────────────────

// Deterministic two-tone cover from a title (mirrors bookCover in rehearser.js,
// kept local so the Library renders even if that module loads later).
function libBookCover(title) {
  let h = 0; const s = String(title || 'Untitled');
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
  return { c1: 'hsl(' + h + ',55%,42%)', c2: 'hsl(' + ((h + 40) % 360) + ',58%,30%)' };
}

function libConfirmDelete(cardEl, heading, sub, onConfirm) {
  if (cardEl.querySelector('.reh-book-del-confirm')) return;
  const o = document.createElement('div');
  o.className = 'reh-book-del-confirm';
  o.style.cssText = 'position:absolute;inset:0;background:rgba(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';
  o.innerHTML = '<div style="font-weight:600;margin-bottom:8px;font-size:14px">' + heading + '</div>'
    + '<div style="font-size:11px;opacity:.8;margin-bottom:12px;line-height:1.4">' + sub + '</div>'
    + '<div style="display:flex;gap:8px">'
    +   '<button class="btn-secondary btn-sm" data-lib-cancel style="background:rgba(255,255,255,.15);border:none;color:#fff;padding:6px 12px">Cancel</button>'
    +   '<button class="btn-primary btn-sm" data-lib-ok style="background:var(--red,#ef4444);border:none;color:#fff;padding:6px 12px">Delete</button>'
    + '</div>';
  o.addEventListener('click', function (e) { e.stopPropagation(); });
  o.querySelector('[data-lib-cancel]').addEventListener('click', function (e) { e.stopPropagation(); o.remove(); });
  o.querySelector('[data-lib-ok]').addEventListener('click', async function (e) {
    e.stopPropagation();
    o.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px"></span>';
    await onConfirm();
  });
  cardEl.appendChild(o);
}

// ── Characters / Cast ─────────────────────────────────────────────────────────

async function libraryRenderCharacters() {
  const container = document.getElementById('lib-chars-list');
  if (!container) return;
  container.innerHTML = '<div class="lib-chars-loading"><span class="mdi mdi-loading mdi-spin"></span> Loading characters…</div>';

  let all = [];
  try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (_) { all = []; }

  if (!all.length) {
    container.innerHTML = '<div class="lib-chars-toolbar">'
      + '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
      + '</div>'
      + '<div class="lib-empty">'
      + '<span class="mdi mdi-account-box-multiple-outline"></span>'
      + '<p>No characters yet.</p>'
      + '<p class="lib-empty-hint">Open a book in <b>Read Aloud</b>, cast it as an audiobook, then click <b>Cast Characters</b> to generate character sheets — or import an existing cast from SillyTavern.</p>'
      + '</div>';
    container.querySelector('#lib-chars-import').addEventListener('click', function () {
      if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
    });
    return;
  }

  // Group by book (production)
  const byBook = {};
  all.forEach(function (rec) {
    const bk = rec.book || 'Unsorted';
    if (!byBook[bk]) byBook[bk] = [];
    byBook[bk].push(rec);
  });

  container.innerHTML = '';

  // Global toolbar — import a cast from SillyTavern into a new/unsorted production
  const bar = document.createElement('div');
  bar.className = 'lib-chars-toolbar';
  bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>';
  bar.querySelector('#lib-chars-import').addEventListener('click', function () {
    if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
  });
  container.appendChild(bar);

  Object.keys(byBook).sort().forEach(function (book) {
    const chars = byBook[book].sort(function (a, b) {
      const tierOrder = { main: 0, supporting: 1, minor: 2 };
      const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
      const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
      return ta - tb || (a.name || '').localeCompare(b.name || '');
    });

    const cov = libBookCover(book);
    const prod = document.createElement('div');
    prod.className = 'lib-chars-production';
    prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
      + '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
      + '<div class="lib-chars-prod-actions">'
      +   '<button class="btn-secondary btn-sm lib-chars-cast-btn" data-book="' + escHtml(book) + '" title="Re-run character sheet generation"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button>'
      +   '<button class="btn-secondary btn-sm lib-chars-reh-btn"  data-book="' + escHtml(book) + '" title="Open in Script Rehearsal"><span class="mdi mdi-theater"></span> Rehearse</button>'
      +   '<button class="btn-secondary btn-sm lib-chars-read-btn" data-book="' + escHtml(book) + '" title="Open in Read Aloud"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</button>'
      +   '<button class="btn-secondary btn-sm lib-chars-imp-btn"  data-book="' + escHtml(book) + '" title="Import SillyTavern cards into this production"><span class="mdi mdi-import"></span></button>'
      + '</div></div>'
      + '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
      + chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
      + '</div>';

    // Action buttons
    prod.querySelector('.lib-chars-cast-btn').addEventListener('click', function () {
      if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
      toast('Open the book in Read Aloud then click Cast Characters', 'info');
    });
    prod.querySelector('.lib-chars-reh-btn').addEventListener('click', function () {
      if (typeof productionOpenInRehearser === 'function') productionOpenInRehearser(book);
    });
    prod.querySelector('.lib-chars-read-btn').addEventListener('click', function () {
      if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
    });
    prod.querySelector('.lib-chars-imp-btn').addEventListener('click', function () {
      if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); });
    });

    // Wire voice selectors and auto-assign buttons
    prod.querySelectorAll('.lib-char-card').forEach(function (card) {
      const charId = card.dataset.charId;
      const rec = all.find(function (r) { return r.id === charId; });
      if (!rec) return;

      // Click on card body → open detail page (not if clicking a button or avatar)
      card.addEventListener('click', function (e) {
        if (e.target.closest('button, .lib-char-avatar, .lib-voice-picker-popup')) return;
        _charDetailPage(rec, chars);
      });

      // Avatar click → upload profile picture
      card.querySelector('.lib-char-avatar')?.addEventListener('click', function (e) {
        e.stopPropagation();
        const inp = document.createElement('input');
        inp.type = 'file'; inp.accept = 'image/*';
        inp.onchange = async function () {
          const file = inp.files[0]; if (!file) return;
          const fr = new FileReader();
          fr.onload = async function (ev) {
            if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
            toast('Profile picture saved', 'success');
            libraryRenderCharacters();
          };
          fr.readAsDataURL(file);
        };
        inp.click();
      });

      card.querySelector('.lib-char-pick-voice')?.addEventListener('click', function (e) {
        e.stopPropagation();
        _openVoicePicker(card, rec, function () { libraryRenderCharacters(); });
      });

      card.querySelector('.lib-char-auto-voice')?.addEventListener('click', async function (e) {
        e.stopPropagation();
        await _autoAssignVoice(rec);
        libraryRenderCharacters();
      });

      card.querySelector('.lib-char-export')?.addEventListener('click', function (e) {
        e.stopPropagation();
        if (typeof stExportRecord === 'function') stExportRecord(rec);
      });

      card.querySelector('.lib-char-online-voice')?.addEventListener('click', function (e) {
        e.stopPropagation();
        _charSearchOnline(rec);
      });

      card.querySelector('.lib-char-gen-voice')?.addEventListener('click', function (e) {
        e.stopPropagation();
        _charDesignVoice(rec);
      });
    });

    container.appendChild(prod);
  });
}

function _charHue(name) {
  return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
}

function _charAlignHtml(sh) {
  const score = sh.moral_alignment_score;
  if (score == null) return '';
  const pct = Math.max(0, Math.min(100, score));
  const arc = sh.arc_direction || 'neutral';
  const arrowMap = {
    'good-to-bad': { ch: '↘', color: '#ff7043', tip: 'Arc: Descends toward evil' },
    'bad-to-good': { ch: '↗', color: '#66bb6a', tip: 'Arc: Redeems toward good' },
    'complex':     { ch: '↕', color: '#ab47bc', tip: 'Arc: Complex / unpredictable' },
    'stable-good': { ch: '→', color: '#66bb6a', tip: 'Arc: Stable good' },
    'stable-bad':  { ch: '→', color: '#888',    tip: 'Arc: Stable evil' },
    'neutral':     { ch: '→', color: '#aaa',    tip: 'Arc: Neutral' },
  };
  const a = arrowMap[arc] || arrowMap['neutral'];
  const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Morally ambiguous';
  return '<div class="lib-char-align">'
    + '<span class="lib-char-align-evil" title="Evil">●</span>'
    + '<div class="lib-char-align-bar" title="' + label + ' (' + pct + '/100)">'
    +   '<div class="lib-char-align-dot" style="left:' + pct + '%"></div>'
    + '</div>'
    + '<span class="lib-char-align-good" title="Good">●</span>'
    + '<span class="lib-char-align-arrow" style="color:' + a.color + '" title="' + a.tip + '">' + a.ch + '</span>'
    + '</div>';
}

function _libStr(v) {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (Array.isArray(v)) return v.filter(Boolean).join(', ');
  return JSON.stringify(v);
}

function _charRelsHtml(rec, allChars) {
  if (!allChars || allChars.length < 2) return '';
  const relText = _libStr(rec.sheet?.relationships).toLowerCase();
  if (!relText) return '';
  const hits = allChars
    .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
    .map(function (c) {
      const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
      return { c: c, n: (relText.match(re) || []).length };
    })
    .filter(function (x) { return x.n > 0; })
    .sort(function (a, b) { return b.n - a.n; })
    .slice(0, 5);
  if (!hits.length) return '';
  return '<div class="lib-char-rels">'
    + hits.map(function (x) {
        const h = _charHue(x.c.name);
        return '<span class="lib-char-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
          + escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
      }).join('')
    + '</div>';
}

function _charCardHtml(rec, allChars) {
  const sh = rec.sheet || {};
  const hue = _charHue(rec.name);
  const hue2 = (hue + 40) % 360;
  const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
  const voiceLabel = voiceId ? escHtml(voiceId) : '<span style="opacity:.45">Keine Stimme</span>';
  const tier = String(sh.tier || '').toLowerCase();
  const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
    : tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
  const gender = String(sh.gender || '').toLowerCase();
  const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
  const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 140);

  const avatarInner = rec.image
    ? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
    : escHtml((rec.name || '?')[0].toUpperCase());

  return '<div class="lib-char-card" data-char-id="' + escHtml(rec.id) + '">'
    + '<div class="lib-char-card-banner" style="--ch1:hsl(' + hue + ',52%,35%);--ch2:hsl(' + hue2 + ',56%,26%)">'
    +   '<div class="lib-char-avatar" data-char-id="' + escHtml(rec.id) + '" title="Bild hochladen">' + avatarInner + '</div>'
    +   '<button class="lib-char-export" title="Als SillyTavern-Karte exportieren (.json)"><span class="mdi mdi-export-variant"></span></button>'
    + '</div>'
    + '<div class="lib-char-body">'
    +   '<div class="lib-char-name">' + tierBadge + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:11px;opacity:.5"></span></div>'
    +   (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
    +   (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
    +   _charAlignHtml(sh)
    +   _charRelsHtml(rec, allChars)
    +   '<div class="lib-char-divider"></div>'
    +   '<div class="lib-char-voice-row">'
    +     '<span class="lib-char-voice-label">' + voiceLabel + '</span>'
    +     '<button class="lib-char-pick-voice btn-sm">Auswahl</button>'
    +     '<button class="lib-char-auto-voice btn-sm">Auto</button>'
    +   '</div>'
    + '</div></div>';
}

// ── Character detail modal ────────────────────────────────────────────────────

function _lcdSourcesHtml(sources) {
  const list = Array.isArray(sources) ? sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
  if (!list.length) return '';
  return '<div class="lcd-sources">'
    + '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text</div>'
    + '<div class="lcd-source-list">'
    + list.map(function (s) {
        const page = s.page != null ? 'Seite ' + s.page : '';
        const hint = _libStr(s.line_hint || s.hint || '');
        return '<div class="lcd-source-item">'
          + (page || hint ? '<div class="lcd-source-page">' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
          + (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
          + '</div>';
      }).join('')
    + '</div></div>';
}

function _lcdField(label, value, multiline) {
  const v = _libStr(value);
  if (!v) return '';
  return '<div class="lcd-field">'
    + '<div class="lcd-field-label">' + label + '</div>'
    + '<div class="lcd-field-value">' + (multiline ? escHtml(v) : escHtml(v)) + '</div>'
    + '</div>';
}

function _lcdSection(icon, label, fields) {
  const body = fields.join('');
  if (!body) return '';
  return '<div class="lcd-section">'
    + '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
    + body
    + '</div>';
}

function _lcdSectionFull(icon, label, fields) {
  const body = fields.join('');
  if (!body) return '';
  return '<div class="lcd-section-full">'
    + '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
    + body
    + '</div>';
}

function _lcdFieldEdit(label, value, sheetKey) {
  const v = _libStr(value);
  return '<div class="lcd-field">'
    + (label ? '<div class="lcd-field-label">' + escHtml(label) + '</div>' : '')
    + '<div class="lcd-field-value lcd-field-editable" contenteditable="true" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(v) + '</div>'
    + '</div>';
}

function _jumpToReaderPage(pageNum) {
  // Always navigate to the reader section first
  if (typeof navTo === 'function') navTo('s-reader');
  setTimeout(function () {
    // Try page-div scroll (PDF reader)
    const pages = window.readerState?.pages;
    if (pages && pages.length >= pageNum) {
      const pg = pages[pageNum - 1];
      if (pg?.pageDiv) { pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
    }
    // Try sentence-index jump — find first sentence on or after target page (0-indexed internally)
    const sentences = window.readerState?.sentences;
    if (sentences && sentences.length) {
      const target0 = pageNum - 1;
      const idx = sentences.findIndex(function (s) {
        return (s.words || []).some(function (w) { return (w.page ?? w.para ?? 0) >= target0; });
      });
      if (idx >= 0 && typeof readerJumpTo === 'function') { readerJumpTo(idx); return; }
    }
    toast('Öffne das Buch in „Vorlesen" und klicke nochmal auf die Quelle', 'info');
  }, 300);
}

// ── Character detail PAGE (full-page with inline editing, replaces the modal) ─

async function _charDetailPage(rec, allChars) {
  const container = document.getElementById('lib-chars-list');
  if (!container) return;
  window._libDetailRec = rec;

  const sh = rec.sheet || {};
  const hue = _charHue(rec.name);
  const hue2 = (hue + 40) % 360;
  const tier = String(sh.tier || '').toLowerCase();
  const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
  const gender = _libStr(sh.gender);
  const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female'
    : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
  const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
  const score = sh.moral_alignment_score;
  const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
  const arcMap = {
    'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
    'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
    'complex':     { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
    'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
    'stable-bad':  { ch: '→', label: 'Stabil böse', color: '#888' },
    'neutral':     { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
  };
  const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral'];

  const avatarHtml = rec.image
    ? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
    : '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';

  const alignHtml = pct != null ? (
    '<div class="lcd-align-section">'
    + '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
    + '<div class="lcd-align-bar-wrap">'
    +   '<span class="lcd-align-label">Böse</span>'
    +   '<input type="range" class="lcd-align-slider" min="0" max="100" value="' + pct + '">'
    +   '<span class="lcd-align-label">Gut</span>'
    +   '<span class="lcd-align-slider-val">' + pct + '/100</span>'
    + '</div>'
    + '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label
    +   (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)')
    + '</div>'
    + (_libStr(sh.alignment) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
    + '</div>'
  ) : '';

  const relText = _libStr(sh.relationships).toLowerCase();
  const relHits = (allChars || [])
    .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
    .map(function (c) {
      const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
      return { c: c, n: (relText.match(re) || []).length };
    })
    .filter(function (x) { return x.n > 0; })
    .sort(function (a, b) { return b.n - a.n; })
    .slice(0, 8);
  const relDotsHtml = relHits.length ? (
    '<div class="lcd-rels-dots">'
    + relHits.map(function (x) {
        const h = _charHue(x.c.name);
        return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
          + escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
      }).join('')
    + '</div>'
  ) : '';

  const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
  const sourcesHtml = sourcesList.length ? (
    '<div class="lcd-sources">'
    + '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text'
    +   '<span class="lcd-sources-hint"> · Klicken zum Springen</span></div>'
    + '<div class="lcd-source-list">'
    + sourcesList.map(function (s) {
        const page = s.page != null ? 'Seite ' + s.page : '';
        const hint = _libStr(s.line_hint || s.hint || '');
        return '<div class="lcd-source-item lcd-source-clickable" data-page="' + (s.page != null ? s.page : '') + '">'
          + (page || hint ? '<div class="lcd-source-page"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
          + (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
          + '</div>';
      }).join('')
    + '</div></div>'
  ) : '';

  const sidebarChars = (allChars || []).slice().sort(function (a, b) {
    return (b.sheet?.sources?.length || 0) - (a.sheet?.sources?.length || 0);
  });
  const sidebarHtml = sidebarChars.map(function (c) {
    const h = _charHue(c.name);
    const count = (c.sheet?.sources || []).length;
    return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
      + '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
      + '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
      + (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
      + '</div>';
  }).join('');

  container.innerHTML = '';
  const pg = document.createElement('div');
  pg.className = 'lib-char-page';
  pg.innerHTML =
    '<div class="lib-cpg-main">'
    + '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
    + '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
    +   avatarHtml
    +   '<div class="lcd-header-body">'
    +     '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
    +     '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
    +     '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
    +     '<div class="lcd-tier-gender">'
    +       (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
    +       (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
    +     '</div>'
    +   '</div>'
    + '</div>'
    + '<div class="lcd-body">'
    +   '<div class="lcd-voice-top">'
    +     '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
    +     '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
    +     '<button class="lcd-pick-voice">Auswählen</button>'
    +     '<button class="lcd-auto-voice">Automatisch</button>'
    +     '<button class="lcd-online-voice">Online suchen</button>'
    +     '<button class="lcd-gen-voice">Generieren</button>'
    +   '</div>'
    +   alignHtml
    +   '<div class="lcd-sections">'
    +   _lcdSection('mdi-account-outline', 'Erscheinung', [
          _lcdFieldEdit('Körperlich', sh.physical, 'physical'),
          _lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'),
        ])
    +   _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
          _lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms'),
          _lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'),
        ])
    +   _lcdSection('mdi-book-open-outline', 'Geschichte', [
          _lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory'),
          _lcdFieldEdit('Motivation', sh.motivation, 'motivation'),
          _lcdFieldEdit('Ängste', sh.fears, 'fears'),
        ])
    +   _lcdSection('mdi-sword', 'Fähigkeiten', [
          _lcdFieldEdit('Fertigkeiten', sh.skills, 'skills'),
          _lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities'),
          _lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'),
          _lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'),
        ])
    +   _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
          _lcdFieldEdit('', sh.relationships, 'relationships'),
          relDotsHtml,
        ])
    +   _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
          _lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'),
          _lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition'),
        ])
    +   _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
          _lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
          _lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
        ])
    +   '</div>'
    +   sourcesHtml
    +   (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
    + '</div>'
    + '</div>'
    + '<div class="lib-cpg-sidebar">'
    +   '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
    +   sidebarHtml
    + '</div>';

  container.appendChild(pg);

  pg.querySelector('.lib-cpg-back').addEventListener('click', function () { libraryRenderCharacters(); });

  pg.querySelector('.lcd-avatar-upload').addEventListener('click', function () {
    const inp = document.createElement('input');
    inp.type = 'file'; inp.accept = 'image/*';
    inp.onchange = async function () {
      const file = inp.files[0]; if (!file) return;
      const fr = new FileReader();
      fr.onload = async function (ev) {
        if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
        toast('Profilbild gespeichert', 'success');
        rec.image = ev.target.result;
        const av = pg.querySelector('.lcd-avatar-upload');
        if (av) av.innerHTML = '<img src="' + ev.target.result + '" alt="' + escHtml(rec.name) + '">';
      };
      fr.readAsDataURL(file);
    };
    inp.click();
  });

  pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
    item.addEventListener('click', async function () {
      const target = (allChars || []).find(function (c) { return c.id === item.dataset.charId; });
      if (target) _charDetailPage(target, allChars);
    });
  });

  pg.querySelectorAll('.lcd-source-clickable').forEach(function (item) {
    item.addEventListener('click', function () {
      const n = parseInt(item.dataset.page, 10);
      if (!isNaN(n)) _jumpToReaderPage(n);
    });
  });

  pg.querySelector('.lcd-pick-voice')?.addEventListener('click', async function () {
    _openVoicePicker(pg.querySelector('.lcd-voice-top'), rec, async function () {
      const all = await clGetAll().catch(() => allChars);
      const up = all.find(function (r) { return r.id === rec.id; }) || rec;
      _charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
    });
  });
  pg.querySelector('.lcd-auto-voice')?.addEventListener('click', async function () {
    await _autoAssignVoice(rec);
    const all = await clGetAll().catch(() => allChars);
    const up = all.find(function (r) { return r.id === rec.id; }) || rec;
    _charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
  });
  pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
  pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); });

  const slider = pg.querySelector('.lcd-align-slider');
  const sliderVal = pg.querySelector('.lcd-align-slider-val');
  const arcEl = pg.querySelector('.lcd-align-arc');
  if (slider) {
    slider.addEventListener('input', async function () {
      const val = parseInt(slider.value, 10);
      if (sliderVal) sliderVal.textContent = val + '/100';
      if (arcEl) arcEl.textContent = arcInfo.ch + ' ' + arcInfo.label
        + (val >= 70 ? ' · Rechtschaffen (' + val + '/100)' : val <= 30 ? ' · Böse (' + val + '/100)' : ' · Moralisch ambivalent (' + val + '/100)');
      arcEl && (arcEl.style.color = arcInfo.color);
      rec.sheet.moral_alignment_score = val;
      rec.updated = new Date();
      if (typeof clPut === 'function') await clPut(rec);
    });
  }

  let _saveTimer = null;
  function _schedSave(key, value, isRecKey) {
    clearTimeout(_saveTimer);
    _saveTimer = setTimeout(async function () {
      if (isRecKey) { rec[key] = value; }
      else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; }
      rec.updated = new Date();
      if (typeof clPut === 'function') await clPut(rec);
    }, 900);
  }
  pg.querySelectorAll('[contenteditable][data-sheet-key]').forEach(function (el) {
    el.addEventListener('input', function () { _schedSave(el.dataset.sheetKey, el.textContent.trim(), false); });
  });
  pg.querySelectorAll('[contenteditable][data-rec-key]').forEach(function (el) {
    el.addEventListener('input', function () { _schedSave(el.dataset.recKey, el.textContent.trim(), true); });
  });
}

window._charDetailPage = _charDetailPage;

function _charDetailModal(rec, allChars) {
  const sh = rec.sheet || {};
  const cov = libBookCover(rec.name);
  const hue = _charHue(rec.name);
  const tier = String(sh.tier || '').toLowerCase();
  const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
  const gender = _libStr(sh.gender);
  const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
  const score = sh.moral_alignment_score;
  const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
  const arc = sh.arc_direction || 'neutral';
  const arcMap = {
    'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
    'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
    'complex':     { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
    'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
    'stable-bad':  { ch: '→', label: 'Stabil böse', color: '#888' },
    'neutral':     { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
  };
  const arcInfo = arcMap[arc] || arcMap['neutral'];
  const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';

  const avatarHtml = rec.image
    ? '<div class="lcd-avatar"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
    : '<div class="lcd-avatar" style="background:hsl(' + hue + ',55%,38%)">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';

  const alignHtml = pct != null ? (
    '<div class="lcd-align-section">'
    + '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
    + '<div class="lcd-align-bar-wrap">'
    +   '<span class="lcd-align-label">Böse</span>'
    +   '<div class="lcd-align-bar"><div class="lcd-align-dot" style="left:' + pct + '%"></div></div>'
    +   '<span class="lcd-align-label">Gut</span>'
    + '</div>'
    + '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '</div>'
    + (_libStr(sh.arc_note) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.arc_note)) + '</div>' : '')
    + (_libStr(sh.alignment) ? '<div class="lcd-arc-note" style="margin-top:6px">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
    + '</div>'
  ) : '';

  // Relationship dots (same logic as card)
  const relText = _libStr(sh.relationships).toLowerCase();
  const relHits = (allChars || [])
    .filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
    .map(function (c) {
      const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
      return { c: c, n: (relText.match(re) || []).length };
    })
    .filter(function (x) { return x.n > 0; })
    .sort(function (a, b) { return b.n - a.n; })
    .slice(0, 8);

  const relDotsHtml = relHits.length ? (
    '<div class="lcd-rels-dots">'
    + relHits.map(function (x) {
        const h = _charHue(x.c.name);
        return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
          + escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
      }).join('')
    + '</div>'
  ) : '';

  const ov = document.createElement('div');
  ov.className = 'lib-char-detail-ov';
  ov.innerHTML = '<div class="lib-char-detail-box">'
    + '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + ((hue+40)%360) + ',58%,25%)">'
    +   avatarHtml
    +   '<div class="lcd-header-body">'
    +     '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
    +     (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
    +     (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
    +     '<div class="lcd-tier-gender">'
    +       (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
    +       (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
    +     '</div>'
    +   '</div>'
    +   '<div class="lcd-header-btns">'
    +     '<button class="lcd-edit-btn"><span class="mdi mdi-pencil-outline"></span> Bearbeiten</button>'
    +     '<button class="lcd-close-btn"><span class="mdi mdi-close"></span></button>'
    +   '</div>'
    + '</div>'
    + '<div class="lcd-body">'
    +   '<div class="lcd-voice-top">'
    +     '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
    +     '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
    +     '<button class="lcd-pick-voice">Auswählen</button>'
    +     '<button class="lcd-auto-voice">Automatisch</button>'
    +     '<button class="lcd-online-voice">Online suchen</button>'
    +     '<button class="lcd-gen-voice">Generieren</button>'
    +   '</div>'
    +   alignHtml
    +   '<div class="lcd-sections">'
    +   _lcdSection('mdi-account-outline', 'Erscheinung', [
          _lcdField('Körperlich', sh.physical, true),
          _lcdField('Kleidung & Aussehen', sh.clothing, true),
        ])
    +   _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
          _lcdField('Eigenheiten & Verhalten', sh.mannerisms, true),
          _lcdField('Stimme & Sprache', sh.voice_pattern, true),
        ])
    +   _lcdSection('mdi-book-open-outline', 'Geschichte', [
          _lcdField('Hintergrund & Herkunft', sh.backstory, true),
          _lcdField('Motivation', sh.motivation, true),
          _lcdField('Ängste', sh.fears, true),
        ])
    +   _lcdSection('mdi-sword', 'Fähigkeiten', [
          _lcdField('Fertigkeiten', sh.skills, true),
          _lcdField('Besondere Fähigkeiten', sh.capabilities, true),
          _lcdField('Stärkstes Attribut', sh.attribute_high, false),
          _lcdField('Schwächstes Attribut', sh.attribute_low, false),
        ])
    +   _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
          _lcdField('', sh.relationships, true),
          relDotsHtml,
        ])
    +   _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
          _lcdField('Konfliktstil', sh.conflict_style, true),
          _lcdField('Siegbedingung', sh.win_condition, true),
        ])
    +   _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
          _lcdField('Dunkles Geheimnis / fataler Fehler', sh.secret, true),
          _lcdField('Charakterentwicklung', sh.arc_note, true),
        ])
    +   '</div>'
    +   _lcdSourcesHtml(sh.sources)
    +   (rec.analysis ? '<div class="lcd-analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
    + '</div>'
    + '</div>';

  document.body.appendChild(ov);

  const close = function () { ov.remove(); };
  ov.querySelector('.lcd-close-btn').addEventListener('click', close);
  ov.addEventListener('click', function (e) { if (e.target === ov) close(); });

  ov.querySelector('.lcd-edit-btn').addEventListener('click', function () {
    close();
    if (typeof clEdit === 'function') clEdit(rec.id);
  });

  // Voice buttons inside detail modal
  const box = ov.querySelector('.lib-char-detail-box');
  ov.querySelector('.lcd-pick-voice').addEventListener('click', function (e) {
    e.stopPropagation();
    _openVoicePicker(box, rec, function () { close(); libraryRenderCharacters(); });
  });
  ov.querySelector('.lcd-auto-voice').addEventListener('click', async function (e) {
    e.stopPropagation();
    await _autoAssignVoice(rec);
    close();
    libraryRenderCharacters();
  });
  ov.querySelector('.lcd-online-voice').addEventListener('click', function (e) {
    e.stopPropagation(); _charSearchOnline(rec);
  });
  ov.querySelector('.lcd-gen-voice').addEventListener('click', function (e) {
    e.stopPropagation(); _charDesignVoice(rec);
  });

  // Avatar click to upload image
  ov.querySelector('.lcd-avatar').addEventListener('click', function () {
    const inp = document.createElement('input');
    inp.type = 'file'; inp.accept = 'image/*';
    inp.onchange = async function () {
      const file = inp.files[0]; if (!file) return;
      const fr = new FileReader();
      fr.onload = async function (ev) {
        if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
        toast('Profile picture saved', 'success');
        close();
        libraryRenderCharacters();
      };
      fr.readAsDataURL(file);
    };
    inp.click();
  });
}

window._charDetailModal = _charDetailModal;

function _openVoicePicker(cardEl, rec, onDone) {
  // Remove any existing picker
  document.querySelectorAll('.lib-voice-picker-popup').forEach(function (p) { p.remove(); });

  const voices = window._voices || [];
  const gender = String(rec.sheet?.gender || '').toLowerCase();
  const genderMatch = gender.startsWith('f') ? 'f' : gender.startsWith('m') ? 'm' : '';

  const popup = document.createElement('div');
  popup.className = 'lib-voice-picker-popup';
  popup.innerHTML = '<div class="lib-vp-search"><input type="search" placeholder="Search voices…" class="lib-vp-input" autocomplete="off"></div>'
    + '<div class="lib-vp-list"></div>';

  function renderList(filter) {
    let list = voices.filter(function (v) { return v.enabled !== false; });
    if (filter) {
      const f = filter.toLowerCase();
      list = list.filter(function (v) { return (v.id || '').toLowerCase().includes(f) || (v.name || '').toLowerCase().includes(f); });
    } else if (genderMatch) {
      list = list.filter(function (v) {
        const vg = String(v.gender || '').toLowerCase();
        return vg.startsWith(genderMatch) || !vg;
      }).concat(list.filter(function (v) {
        const vg = String(v.gender || '').toLowerCase();
        return vg && !vg.startsWith(genderMatch);
      }));
    }
    const ul = popup.querySelector('.lib-vp-list');
    ul.innerHTML = list.slice(0, 60).map(function (v) {
      const sel = v.id === rec.voice;
      return '<div class="lib-vp-item' + (sel ? ' selected' : '') + '" data-vid="' + escHtml(v.id) + '">'
        + escHtml(v.id || v.name || '') + (v.gender ? ' <span style="opacity:.5;font-size:10px">· ' + escHtml(v.gender) + '</span>' : '')
        + '</div>';
    }).join('') + (list.length === 0 ? '<div style="padding:12px;opacity:.5;font-size:12px">No voices found</div>' : '');

    ul.querySelectorAll('.lib-vp-item').forEach(function (item) {
      item.addEventListener('click', async function () {
        const vid = item.dataset.vid;
        await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: vid }));
        popup.remove();
        onDone();
      });
    });
  }

  renderList('');
  popup.querySelector('.lib-vp-input').addEventListener('input', function (e) { renderList(e.target.value); });

  // Position near the card
  cardEl.style.position = 'relative';
  cardEl.appendChild(popup);

  // Close on outside click
  setTimeout(function () {
    function close(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); } }
    document.addEventListener('click', close);
  }, 0);

  popup.querySelector('.lib-vp-input').focus();
}

async function _autoAssignVoice(rec) {
  const voices = (window._voices || []).filter(function (v) { return v.enabled !== false; });
  if (!voices.length) { toast('Voice library not loaded', 'error'); return; }

  const gender = String(rec.sheet?.gender || '').toLowerCase().trim();
  // Handle both English (female/male) and German (weiblich/männlich) gender terms
  const isFemale = gender.startsWith('f') || gender.startsWith('w'); // female, weiblich
  const isMale = !isFemale && gender.startsWith('m');                  // male, männlich
  const gMatch = isFemale ? 'f' : isMale ? 'm' : '';
  let pool = gMatch ? voices.filter(function (v) {
    const vg = String(v.gender || '').toLowerCase();
    return gMatch === 'f' ? (vg.startsWith('f') || vg.startsWith('w')) : vg.startsWith('m');
  }) : voices;
  if (!pool.length) pool = voices;

  // Prefer unassigned voices (not already used by another character in same book)
  const usedInBook = new Set();
  try {
    const bookChars = await clGetAllByTagOrBook(rec.book);
    bookChars.forEach(function (r) { if (r.voice && r.id !== rec.id) usedInBook.add(r.voice); });
  } catch (_) {}
  const fresh = pool.filter(function (v) { return !usedInBook.has(v.id); });
  const candidate = (fresh.length ? fresh : pool).sort(function (a, b) { return (b.rating || 0) - (a.rating || 0); })[0];

  if (!candidate) { toast('No matching voice found', 'error'); return; }
  await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: candidate.id }));
  toast('Assigned ' + candidate.id + ' → ' + rec.name, 'success');
}

// Detect the production language from a character's own (book-language) text.
function _charLang(rec) {
  const sh = rec.sheet || {};
  const text = [sh.backstory, sh.voice_pattern, sh.mannerisms, sh.relationships, sh.motivation, sh.archetype]
    .filter(Boolean).join(' ');
  return (typeof detectLang === 'function') ? detectLang(text) : '';
}

// Build a natural-language voice-design prompt from a character sheet.
function _buildVoicePrompt(rec) {
  const sh = rec.sheet || {};
  const g = String(sh.gender || '').toLowerCase();
  const genderWord = g.startsWith('f') ? 'female' : g.startsWith('m') ? 'male' : '';
  const bits = [];
  bits.push('A ' + (genderWord ? genderWord + ' ' : '') + 'voice'
    + (sh.archetype ? ' for ' + sh.archetype.toLowerCase() : '') + '.');
  if (sh.voice_pattern) bits.push(sh.voice_pattern);
  if (sh.mannerisms)    bits.push('Mannerisms: ' + sh.mannerisms);
  if (sh.physical)      bits.push(sh.physical);
  if (sh.alignment)     bits.push('Disposition: ' + sh.alignment);
  return bits.join(' ').slice(0, 600);
}

function _selectLoose(sel, val) {
  if (!sel || !val) return;
  const v = String(val).toLowerCase();
  const opt = [...sel.options].find(function (o) {
    const ov = o.value.toLowerCase(), ot = o.textContent.toLowerCase();
    return ov === v || ot === v || ov.startsWith(v) || ot.startsWith(v) || v.startsWith(ov);
  });
  if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change')); }
}

// Search a matching voice online — opens Get a Voice Online on the Fish.audio
// tab, pre-filled with the character name + detected language.
function _charSearchOnline(rec) {
  if (typeof navTo === 'function') navTo('s-studio');
  const lang = _charLang(rec);
  setTimeout(function () {
    const fishTab = document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');
    if (fishTab) fishTab.click();
    setTimeout(function () {
      const langSel = document.getElementById('fa-lang');
      if (langSel) _selectLoose(langSel, lang);
      const search = document.getElementById('fa-search');
      if (search) {
        search.value = rec.name;
        search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
      }
    }, 120);
  }, 120);
  toast('Searching online voices for ' + rec.name + (lang ? ' (' + lang + ')' : ''), 'info');
}

// Generate a new voice — opens Design a Voice, pre-filled with gender, the
// detected language, a description built from the sheet, and the character name.
function _charDesignVoice(rec) {
  if (typeof navTo === 'function') navTo('s-design');
  const sh = rec.sheet || {};
  const lang = _charLang(rec);
  setTimeout(function () {
    _selectLoose(document.getElementById('design-gender'), sh.gender);
    _selectLoose(document.getElementById('design-language'), lang);
    const instruct = document.getElementById('design-instruct');
    if (instruct) instruct.value = _buildVoicePrompt(rec);
    const nm = document.getElementById('design-preset-name');
    if (nm) nm.value = rec.name;
  }, 140);
  toast('Voice design prepared for ' + rec.name + (lang ? ' · ' + lang : ''), 'info');
}

window._charSearchOnline = _charSearchOnline;
window._charDesignVoice = _charDesignVoice;
window.libraryRenderCharacters = libraryRenderCharacters;
window.libraryRenderBooks = libraryRenderBooks;
window.libraryRenderPlays = libraryRenderPlays;
window.productionOpenInRehearser = productionOpenInRehearser;
window.productionOpenInReader = productionOpenInReader;
window.prodKey = prodKey;
