// Library + Reader for WakeUpYourSoul.
//
// Library:
//   - Renders a grid of book cards from data in window.__langData[lang]
//   - Each card: cover (initial-based monogram), title, author, subtitle
//   - Click a card → expands inline to show summary + structure + actions
//   - Search filters by title/author/subtitle, sort by title or author
//
// Reader:
//   - Opens when user clicks "Read book" on a card with a markdown file
//   - Fetches /books/<lang>/<id>.md, splits into chapters, renders TOC + content
//   - Prev/next chapter, click TOC entry to jump, back-button returns to library
//
// All text in the reader (chapter content) is markdown rendered to HTML by a
// tiny built-in converter — no external dependency.

(function () {
  // Read the lang bundle once on demand.
  function L() { return (window.__langData && window.__langData[(window.i18n && window.i18n.getLang()) || "en"]) || {}; }
  function t(key, fallback) { return L()[key] != null ? L()[key] : (fallback != null ? fallback : key); }

  // ===== Book discovery =====================================================
  // Each book lives under window.__langData[lang] as book.<id>.<field>.
  // We discover the set of book ids by scanning for keys matching book.X.title.

  function listBooks() {
    // The English bundle is the canonical master list. Other languages may
    // have fewer translated books — for those we show the EN metadata and
    // tag the card as `available: false` so the UI can offer the EN fallback.
    const cur = L();
    const en = (window.__langData && window.__langData.en) || {};
    const ids = new Set();
    const collectIds = (bundle) => {
      for (const k of Object.keys(bundle)) {
        const m = /^book\.([^.]+)\.title$/.exec(k);
        if (m) ids.add(m[1]);
      }
    };
    collectIds(en);
    collectIds(cur);

    const pick = (id, field) => {
      const k = `book.${id}.${field}`;
      return cur[k] != null ? cur[k] : en[k];
    };

    return [...ids].map((id) => ({
      id,
      title: pick(id, "title") || id,
      author: pick(id, "author") || "",
      subtitle: pick(id, "subtitle") || "",
      summary: pick(id, "summary") || "",
      structure: pick(id, "structure") || [],
      stats: pick(id, "stats") || null,
      pdf: pick(id, "pdf") || null,
      available: cur[`book.${id}.title`] != null,
    }));
  }

  // Expose for the home-page Stumble feature.
  window.__listBooks = listBooks;

  // ===== Library view =======================================================

  // Strip leading articles in any of our languages so sort/monogram lookups
  // don't all cluster under "T", "D", "L", etc.
  function stripArticle(title) {
    let s = (title || "").trim();
    s = s.replace(/^(the|a|an|den|det|de|la|le|les|l['’]|el|los|las|un|une|des)\s+/i, "");
    return s.trim();
  }
  function monogram(title) {
    return (stripArticle(title)[0] || "?").toUpperCase();
  }

  // ===== Sorting ============================================================
  // Each option: { id, labelKey, compare(a, b) }. The grid always pushes
  // untranslated books to the bottom regardless of the active option, so the
  // available set leads.
  const SORT_OPTS = [
    { id: "title",        labelKey: "page.library.sort_title",
      compare: (a, b) => stripArticle(a.title).localeCompare(stripArticle(b.title)) },
    { id: "title_full",   labelKey: "page.library.sort_title_full",
      compare: (a, b) => a.title.localeCompare(b.title) },
    { id: "cover",        labelKey: "page.library.sort_cover",
      compare: (a, b) => {
        const ma = monogram(a.title), mb = monogram(b.title);
        if (ma !== mb) return ma.localeCompare(mb);
        return stripArticle(a.title).localeCompare(stripArticle(b.title));
      } },
    { id: "author",       labelKey: "page.library.sort_author",
      compare: (a, b) => (a.author || "").localeCompare(b.author || "") },
    { id: "words_desc",   labelKey: "page.library.sort_words_desc",
      compare: (a, b) => (b.stats?.words || 0) - (a.stats?.words || 0) },
    { id: "words_asc",    labelKey: "page.library.sort_words_asc",
      compare: (a, b) => (a.stats?.words || 0) - (b.stats?.words || 0) },
    { id: "chapters_desc",labelKey: "page.library.sort_chapters_desc",
      compare: (a, b) => (b.stats?.chapters || 0) - (a.stats?.chapters || 0) },
    { id: "chapters_asc", labelKey: "page.library.sort_chapters_asc",
      compare: (a, b) => (a.stats?.chapters || 0) - (b.stats?.chapters || 0) },
    { id: "parts_desc",   labelKey: "page.library.sort_parts_desc",
      compare: (a, b) => (b.stats?.parts || 0) - (a.stats?.parts || 0) },
    { id: "parts_asc",    labelKey: "page.library.sort_parts_asc",
      compare: (a, b) => (a.stats?.parts || 0) - (b.stats?.parts || 0) },
    { id: "avg_desc",     labelKey: "page.library.sort_avg_desc",
      compare: (a, b) => (b.stats?.avg_words || 0) - (a.stats?.avg_words || 0) },
    { id: "avg_asc",      labelKey: "page.library.sort_avg_asc",
      compare: (a, b) => (a.stats?.avg_words || 0) - (b.stats?.avg_words || 0) },
    { id: "reading_desc", labelKey: "page.library.sort_reading_desc",
      compare: (a, b) => (b.stats?.reading_minutes || 0) - (a.stats?.reading_minutes || 0) },
    { id: "reading_asc",  labelKey: "page.library.sort_reading_asc",
      compare: (a, b) => (a.stats?.reading_minutes || 0) - (b.stats?.reading_minutes || 0) },
    { id: "random",       labelKey: "page.library.sort_random",
      // Generate a fresh random key per book per call so repeat sorts shuffle.
      compare: () => Math.random() - 0.5 },
  ];

  function populateSortSelect() {
    const sel = document.getElementById("library-sort-select");
    if (!sel) return;
    const prev = sel.value || "title";
    sel.innerHTML = SORT_OPTS.map((o) =>
      '<option value="' + o.id + '">' + escapeHTML(t(o.labelKey, o.id)) + '</option>'
    ).join("");
    // Restore selection if it still exists, else default.
    sel.value = SORT_OPTS.some((o) => o.id === prev) ? prev : "title";
  }

  function cardHTML(b) {
    const sub = b.subtitle ? `<div class="lib-card-sub">${escapeHTML(b.subtitle)}</div>` : "";
    const naBadge = b.available
      ? ""
      : '<span class="lib-card-badge">' + escapeHTML(t("page.library.not_translated", "Not yet translated")) + '</span>';
    return (
      '<article class="lib-card' + (b.available ? "" : " lib-card-na") + '" data-book-id="' + escapeHTML(b.id) + '" tabindex="0">' +
        '<div class="lib-card-cover">' + escapeHTML(monogram(b.title)) + '</div>' +
        '<div class="lib-card-body">' +
          '<h3 class="lib-card-title">' + escapeHTML(b.title) + '</h3>' +
          '<div class="lib-card-author">' + t("page.library.by", "by") + ' ' + escapeHTML(b.author) + '</div>' +
          sub +
          naBadge +
        '</div>' +
      '</article>'
    );
  }

  function statsHTML(stats) {
    if (!stats || !stats.chapters) return "";
    const cell = (num, lbl) =>
      '<div class="lib-stat">' +
        '<div class="lib-stat-num">' + escapeHTML(String(num)) + '</div>' +
        '<div class="lib-stat-lbl">' + escapeHTML(lbl) + '</div>' +
      '</div>';
    const readMin = stats.reading_minutes || 0;
    const readStr = readMin >= 60
      ? Math.floor(readMin / 60) + "h " + (readMin % 60) + "m"
      : readMin + " min";
    return (
      '<div class="lib-stats">' +
        cell(stats.parts, t("page.library.stats_parts", "Parts")) +
        cell(stats.chapters, t("page.library.stats_chapters", "Chapters")) +
        cell(stats.words.toLocaleString(), t("page.library.stats_words", "Words")) +
        cell(stats.avg_words.toLocaleString(), t("page.library.stats_avg_chapter", "Avg / chapter")) +
        cell(readStr, t("page.library.stats_reading_time", "Reading time")) +
      '</div>'
    );
  }

  function structureItemHTML(s) {
    // Backward-compatible: older bundles stored structure as a plain string
    // (just the part heading). New bundles store an object with stats and an
    // optional per-part summary parsed from the book's `## Structure` block.
    if (typeof s === "string") {
      return '<li>' + escapeHTML(s) + '</li>';
    }
    const ch = s.chapters || 0;
    const w = s.words || 0;
    const meta =
      '<span class="lib-struct-meta">' +
        escapeHTML(String(ch)) + ' · ' +
        escapeHTML(w.toLocaleString()) + ' ' + escapeHTML(t("page.library.stats_words", "Words")) +
      '</span>';
    const headSpan = '<span class="lib-struct-h">' + escapeHTML(s.heading || "") + '</span>';
    if (s.summary) {
      const sumHTML = escapeHTML(s.summary).replace(/\n\n+/g, '<br/><br/>');
      return (
        '<li class="lib-struct-row">' +
          '<details>' +
            '<summary>' + headSpan + meta + '</summary>' +
            '<p class="lib-struct-sum">' + sumHTML + '</p>' +
          '</details>' +
        '</li>'
      );
    }
    return '<li class="lib-struct-row">' + headSpan + meta + '</li>';
  }

  function detailHTML(b) {
    const structure = (b.structure || []).map(structureItemHTML).join("");
    const pdfBtn = b.pdf
      ? '<a class="btn-pdf" href="books/pdf/' + encodeURIComponent(b.pdf) + '" target="_blank" rel="noopener">' + t("page.library.download_pdf", "Download PDF") + '</a>'
      : '';
    const readLabel = b.available
      ? t("page.library.read_button", "Read book")
      : t("page.library.read_in_english", "Read in English");
    const readBtn = canRead(b.id)
      ? '<button class="btn-read" data-action="read" data-book-id="' + escapeHTML(b.id) + '">' + readLabel + '</button>'
      : '<span class="btn-disabled">' + t("page.library.no_pdf", "Reader coming soon") + '</span>';
    const notice = b.available
      ? ""
      : '<div class="lib-detail-notice">' + escapeHTML(t("page.library.fallback_notice", "This book is not yet translated. The English original is available below.")) + '</div>';
    return (
      '<div class="lib-detail">' +
        notice +
        '<div class="lib-detail-summary">' + escapeHTML(b.summary).replace(/\n\n+/g, '<br/><br/>') + '</div>' +
        statsHTML(b.stats) +
        (structure ? '<details class="lib-detail-struct"><summary>' + escapeHTML(t("page.library.structure", "Structure")) + '</summary><ul>' + structure + '</ul></details>' : '') +
        '<div class="lib-detail-actions">' + readBtn + ' ' + pdfBtn + '</div>' +
      '</div>'
    );
  }

  function escapeHTML(s) {
    return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
  }

  // We can render the reader if a .md file exists for the current language.
  // We optimistically assume yes for any book with a title in current lang —
  // missing files surface as "couldn't load" in the reader, which is fine.
  function canRead(/* id */) { return true; }

  function renderLibrary() {
    const grid = document.getElementById("library-grid");
    if (!grid) return;
    // Remember which card was open (and scroll position) so a re-render
    // on langchange or search doesn't collapse the user's current selection.
    const prevOpenId = grid.querySelector(".lib-card.open")?.dataset.bookId || null;
    const prevScroll = window.scrollY;

    const search = (document.getElementById("library-search")?.value || "").trim().toLowerCase();
    const sortBy = document.getElementById("library-sort-select")?.value || "title";
    let books = listBooks();
    if (search) {
      books = books.filter((b) =>
        b.title.toLowerCase().includes(search) ||
        b.author.toLowerCase().includes(search) ||
        b.subtitle.toLowerCase().includes(search)
      );
    }
    const opt = SORT_OPTS.find((o) => o.id === sortBy) || SORT_OPTS[0];
    books.sort((a, b) => {
      // Untranslated books drop to the bottom regardless of sort field, so
      // the available-in-current-lang set leads the grid.
      if (a.available !== b.available) return a.available ? -1 : 1;
      return opt.compare(a, b);
    });
    const empty = document.getElementById("library-empty");
    if (books.length === 0) {
      grid.innerHTML = "";
      if (empty) empty.hidden = false;
    } else {
      if (empty) empty.hidden = true;
      grid.innerHTML = books.map(cardHTML).join("");
    }

    if (prevOpenId) {
      const card = grid.querySelector('.lib-card[data-book-id="' + prevOpenId.replace(/"/g, '\\"') + '"]');
      const b = books.find((x) => x.id === prevOpenId);
      if (card && b) {
        card.insertAdjacentHTML("beforeend", detailHTML(b));
        card.classList.add("open");
        window.scrollTo({ top: prevScroll, behavior: "instant" });
      }
    }
  }

  function onCardClick(ev) {
    const card = ev.target.closest(".lib-card");
    if (!card) return;
    // Don't toggle when clicking action buttons or the Structure disclosure
    // inside the expanded detail — those have their own behavior.
    if (ev.target.closest("[data-action], .btn-pdf, a, button:not(.lib-card), summary, .lib-detail-struct")) return;
    const id = card.dataset.bookId;
    const open = card.classList.contains("open");
    // Collapse any other open card first
    document.querySelectorAll(".lib-card.open").forEach((c) => {
      c.classList.remove("open");
      const det = c.querySelector(".lib-detail");
      if (det) det.remove();
    });
    if (!open) {
      const books = listBooks();
      const b = books.find((x) => x.id === id);
      if (b) {
        card.insertAdjacentHTML("beforeend", detailHTML(b));
        card.classList.add("open");
      }
    }
  }

  function onActionClick(ev) {
    const btn = ev.target.closest("[data-action='read']");
    if (!btn) return;
    ev.stopPropagation();
    openReader(btn.dataset.bookId);
  }

  // ===== Reader =============================================================
  // State for the currently open book. `loadedLang` is the language whose
  // markdown actually loaded (may differ from the UI language if we fell
  // back to English because the book isn't translated yet).
  let reader = { id: null, chapters: [], index: 0, loadedLang: null };
  let scrollSpy = null;  // IntersectionObserver for TOC tracking
  let lineSpy = null;    // IntersectionObserver for lazy line numbering
  let resizeTimer = null;

  function loadBookScript(lang, bookId) {
    // Loads books/<lang>/<id>.js as a <script> tag. This works offline
    // (file://) where fetch() is blocked by CORS. Each script populates
    // window.__bookCache[lang][id] with the raw markdown.
    return new Promise((resolve) => {
      window.__bookCache = window.__bookCache || {};
      if (window.__bookCache[lang] && window.__bookCache[lang][bookId]) {
        return resolve(window.__bookCache[lang][bookId]);
      }
      const s = document.createElement("script");
      s.src = "books/" + lang + "/" + encodeURIComponent(bookId) + ".js";
      s.async = true;
      s.onload = () => resolve(window.__bookCache[lang] && window.__bookCache[lang][bookId]);
      s.onerror = () => resolve(null);
      document.head.appendChild(s);
    });
  }

  async function openReader(bookId, chapterIdx) {
    const lang = (window.i18n && window.i18n.getLang()) || "en";
    let text = await loadBookScript(lang, bookId);
    let loadedLang = lang;
    // Fall back to English if the language version is missing.
    if (!text && lang !== "en") {
      text = await loadBookScript("en", bookId);
      loadedLang = "en";
    }
    if (!text) {
      console.warn("Could not load book", bookId, "in", lang);
      alert(t("page.library.read_failed", "Could not load this book."));
      return;
    }

    const chapters = splitChapters(text);
    if (chapters.length === 0) return;
    // chapterIdx is the index of `## Chapter ...` headings as the extractor
    // counts them — splitChapters() also produces an "About" pseudo-chapter
    // at index 0, so we offset by 1 to align.
    let startIdx = 0;
    if (typeof chapterIdx === "number" && chapterIdx >= 0) {
      startIdx = Math.min(chapterIdx + 1, chapters.length - 1);
    }
    reader = { id: bookId, chapters, index: startIdx, loadedLang };
    document.body.dataset.view = "reader";
    document.querySelectorAll(".view").forEach((s) => {
      s.classList.toggle("active", s.dataset.view === "reader");
    });
    renderReader();
    // After render: scroll the chosen chapter into view.
    const target = document.getElementById("chap-" + startIdx);
    if (target) target.scrollIntoView({ behavior: "instant", block: "start" });
    else window.scrollTo({ top: 0, behavior: "instant" });
  }

  // Exposed for other widgets (Word Wall) to open a book at a specific chapter.
  window.__openBookAt = openReader;

  function closeReader() {
    // Return to library view
    document.querySelectorAll(".nav-cat").forEach((b) => {
      if (b.dataset.cat === "library") b.click();
    });
  }

  // Split a book's markdown into chapters using `## Chapter N — Title` / `# PART …` lines.
  // Each chapter is { heading, body }. We also collect the meta sections (Summary,
  // Structure, Key Themes, License) into a single "About" chapter at the top.
  function splitChapters(md) {
    const lines = md.split(/\r?\n/);
    const chapters = [];
    let currentHeading = null;
    let currentBody = [];
    const flush = () => {
      if (currentHeading != null) {
        chapters.push({ heading: currentHeading, body: currentBody.join("\n").trim() });
      }
    };
    // Skip the book title (`# Title`) at top — we use it from the cards.
    let i = 0;
    while (i < lines.length && !/^#\s/.test(lines[i])) i++;
    if (i < lines.length) i++; // skip the title line
    // Pull author/subtitle lines (we already have those)
    while (i < lines.length && /^\*\*/.test(lines[i].trim())) i++;
    // First "meta" section: everything until the first `# PART` heading.
    let aboutBody = [];
    while (i < lines.length && !/^#\s+(PART|PARTIE|PARTE|DEL)\b/i.test(lines[i])) {
      aboutBody.push(lines[i]);
      i++;
    }
    const aboutText = aboutBody.join("\n").trim();
    if (aboutText) {
      chapters.push({ heading: t("page.library.about_book", "About this book"), body: aboutText });
    }
    // From here on, look for `# PART …` (part header — purely structural, becomes a divider)
    // and `## Chapter …` (each chapter).
    let currentPart = null;
    while (i < lines.length) {
      const line = lines[i];
      const partM = /^#\s+((?:PART|PARTIE|PARTE|DEL)\s+.+)$/i.exec(line);
      const chapM = /^##\s+(Chapter\s+.+|Kapitel\s+.+|Chapitre\s+.+|Capítulo\s+.+)$/i.exec(line);
      if (partM) {
        flush();
        currentPart = partM[1].trim();
        currentHeading = null;
        currentBody = [];
        i++;
        continue;
      }
      if (chapM) {
        flush();
        currentHeading = chapM[1].trim();
        if (currentPart) currentHeading = currentPart + " · " + currentHeading;
        currentBody = [];
        i++;
        continue;
      }
      if (currentHeading != null) {
        currentBody.push(line);
      }
      i++;
    }
    flush();
    return chapters;
  }

  function renderReader() {
    const titleEl = document.getElementById("reader-title");
    const tocEl = document.getElementById("reader-toc-list");
    const contentEl = document.getElementById("reader-content");
    if (!titleEl || !tocEl || !contentEl) return;
    const data = L();
    titleEl.textContent = data[`book.${reader.id}.title`] || reader.id;

    // TOC — one entry per chapter, anchored to a section in the content
    tocEl.innerHTML = reader.chapters.map((c, idx) =>
      '<a href="#chap-' + idx + '" class="reader-toc-item" data-idx="' + idx + '">' +
        escapeHTML(c.heading) + '</a>'
    ).join("");

    const uiLang = (window.i18n && window.i18n.getLang()) || "en";
    const fallbackBanner = (reader.loadedLang && reader.loadedLang !== uiLang)
      ? '<div class="reader-fallback">' +
          escapeHTML(t("page.library.fallback_banner", "Not yet translated — showing the English original.")) +
        '</div>'
      : '';

    // Content — all chapters concatenated into one continuous scroll
    contentEl.innerHTML = fallbackBanner + reader.chapters.map((c, idx) =>
      '<section class="reader-chapter" id="chap-' + idx + '" data-idx="' + idx + '">' +
        '<h2 class="reader-chapter-h">' + escapeHTML(c.heading) + '</h2>' +
        renderMarkdown(c.body) +
      '</section>'
    ).join("");

    contentEl.scrollTop = 0;
    setActiveChapter(0);
    setupScrollSpy(contentEl);
    setupLineNumbering(contentEl);
  }

  // ---- Per-line numbering --------------------------------------------------
  // Each visual line gets a number in the chapter's left gutter. We compute
  // line positions via Range.getClientRects() (one rect per wrapped line) and
  // place absolutely-positioned spans inside each chapter. Lazy by chapter
  // so opening a 150-chapter book doesn't pay the full cost up front.

  function numberChapter(chapter) {
    if (chapter.dataset.numbered === "1") return;
    chapter.querySelectorAll(".line-num").forEach((n) => n.remove());

    const chapterRect = chapter.getBoundingClientRect();
    const blocks = chapter.querySelectorAll(
      "p, ul > li, ol > li, h2:not(.reader-chapter-h), h3, h4, h5, blockquote > p, blockquote"
    );
    const frag = document.createDocumentFragment();
    let lineNo = 1;
    let lastTop = -9999;

    blocks.forEach((block) => {
      // Skip blocks that hold a nested numbered child (avoid double-counting blockquote + its <p>)
      if (block.tagName === "BLOCKQUOTE" && block.querySelector("p")) return;
      const cs = getComputedStyle(block);
      const fontSize = cs.fontSize;
      const fontFamily = cs.fontFamily;
      const fontStyle = cs.fontStyle;
      let lineHeight = cs.lineHeight;
      // "normal" doesn't transfer reliably between elements with different
      // fonts — pin it to a px value derived from the block's own font.
      if (!lineHeight || lineHeight === "normal") {
        lineHeight = (parseFloat(fontSize) * 1.2) + "px";
      }

      const range = document.createRange();
      range.selectNodeContents(block);
      const rects = range.getClientRects();
      lastTop = -9999;  // restart per-block so identical tops in different blocks both register

      for (const r of rects) {
        if (r.height < 4) continue;                   // skip zero-height rects
        if (Math.abs(r.top - lastTop) < 4) continue;  // coalesce inline-rect splits on same line
        lastTop = r.top;

        const span = document.createElement("span");
        span.className = "line-num";
        span.textContent = lineNo++;
        span.style.top = (r.top - chapterRect.top) + "px";
        span.style.fontSize = fontSize;
        span.style.lineHeight = lineHeight;
        span.style.fontFamily = fontFamily;   // ← match font so ascents line up
        span.style.fontStyle = fontStyle;     // (em / italic blocks)
        frag.appendChild(span);
      }
    });

    chapter.appendChild(frag);
    chapter.dataset.numbered = "1";
  }

  function clearChapter(chapter) {
    chapter.querySelectorAll(".line-num").forEach((n) => n.remove());
    delete chapter.dataset.numbered;
  }

  function setupLineNumbering(scrollRoot) {
    if (lineSpy) lineSpy.disconnect();
    if (!("IntersectionObserver" in window)) {
      // Old browser: number everything once.
      scrollRoot.querySelectorAll(".reader-chapter").forEach(numberChapter);
      return;
    }
    // Number any chapter within ~2 viewports of the visible area; clear when
    // it's been off-screen long enough.
    lineSpy = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          if (e.isIntersecting) numberChapter(e.target);
          else clearChapter(e.target);
        }
      },
      {
        root: scrollRoot,
        rootMargin: "200% 0px 200% 0px",
        threshold: 0,
      }
    );
    scrollRoot.querySelectorAll(".reader-chapter").forEach((c) => lineSpy.observe(c));
  }

  function reflowLineNumbers() {
    // After a viewport change, every chapter's wrap points may have changed.
    // Clear all flags so the observer renumbers visible chapters fresh.
    document.querySelectorAll(".reader-chapter").forEach(clearChapter);
    const contentEl = document.getElementById("reader-content");
    if (contentEl && document.querySelector(".view-reader.active")) {
      setupLineNumbering(contentEl);
    }
  }

  window.addEventListener("resize", () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(reflowLineNumbers, 200);
  });

  function setActiveChapter(idx) {
    reader.index = idx;
    const items = document.querySelectorAll(".reader-toc-item");
    items.forEach((a) => {
      const on = parseInt(a.dataset.idx, 10) === idx;
      a.classList.toggle("active", on);
      // Center the active item in the TOC panel so the reader can see
      // both upcoming chapters (below) and where they've been (above).
      if (on) {
        try { a.scrollIntoView({ block: "center", behavior: "smooth" }); }
        catch (_) { a.scrollIntoView({ block: "nearest" }); }
      }
    });
  }

  function setupScrollSpy(scrollRoot) {
    if (scrollSpy) scrollSpy.disconnect();
    if (!("IntersectionObserver" in window)) return;
    const sections = scrollRoot.querySelectorAll(".reader-chapter");
    // The "active" zone is the top third of the viewport — a chapter becomes
    // current when its body crosses that line.
    scrollSpy = new IntersectionObserver(
      (entries) => {
        // A chapter is "active" while it crosses the middle band of the
        // reader viewport. That way the highlight tracks what you're
        // actually reading (not the top edge), and the TOC stays centered
        // on the current chapter with future and past in view.
        let best = null;
        for (const e of entries) {
          if (!e.isIntersecting) continue;
          if (!best || e.boundingClientRect.top < best.boundingClientRect.top) {
            best = e;
          }
        }
        if (best) {
          const idx = parseInt(best.target.dataset.idx, 10);
          if (!isNaN(idx) && idx !== reader.index) setActiveChapter(idx);
        }
      },
      {
        root: scrollRoot,
        // Active zone is the middle ~20% of the viewport (from 40% to 60%
        // top-to-bottom). A chapter activates when its body crosses there.
        rootMargin: "-40% 0px -40% 0px",
        threshold: 0,
      }
    );
    sections.forEach((s) => scrollSpy.observe(s));
  }

  // Tiny markdown renderer. Handles what our books use:
  //   ### subheading
  //   **bold**, *italic*
  //   paragraphs separated by blank lines
  //   lists (- item)
  //   blockquotes (> ...)
  // Anything fancier we don't need.
  function renderMarkdown(md) {
    const lines = md.split(/\r?\n/);
    const out = [];
    let inList = false;
    let inQuote = false;
    let para = [];

    const flushPara = () => {
      if (para.length === 0) return;
      out.push("<p>" + inline(para.join(" ").trim()) + "</p>");
      para = [];
    };
    const closeList = () => { if (inList) { out.push("</ul>"); inList = false; } };
    const closeQuote = () => { if (inQuote) { out.push("</blockquote>"); inQuote = false; } };

    for (const raw of lines) {
      const line = raw.trimEnd();
      if (line === "") {
        flushPara();
        closeList();
        closeQuote();
        continue;
      }
      const h = /^(#{1,6})\s+(.+)$/.exec(line);
      if (h) {
        flushPara(); closeList(); closeQuote();
        const lvl = Math.min(h[1].length + 1, 6);
        out.push("<h" + lvl + ">" + inline(h[2]) + "</h" + lvl + ">");
        continue;
      }
      const li = /^[-*]\s+(.+)$/.exec(line);
      if (li) {
        flushPara(); closeQuote();
        if (!inList) { out.push("<ul>"); inList = true; }
        out.push("<li>" + inline(li[1]) + "</li>");
        continue;
      }
      const bq = /^>\s?(.*)$/.exec(line);
      if (bq) {
        flushPara(); closeList();
        if (!inQuote) { out.push("<blockquote>"); inQuote = true; }
        out.push("<p>" + inline(bq[1]) + "</p>");
        continue;
      }
      closeList(); closeQuote();
      para.push(line);
    }
    flushPara(); closeList(); closeQuote();
    return out.join("\n");
  }

  function inline(s) {
    return escapeHTML(s)
      .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
      .replace(/\*([^*]+)\*/g, "<em>$1</em>");
  }

  // ===== Wiring =============================================================

  function init() {
    const grid = document.getElementById("library-grid");
    if (!grid) return;

    document.getElementById("library-grid").addEventListener("click", onCardClick);
    document.getElementById("library-grid").addEventListener("click", onActionClick);
    document.getElementById("library-search")?.addEventListener("input", renderLibrary);
    document.getElementById("library-sort-select")?.addEventListener("change", renderLibrary);

    document.getElementById("reader-back")?.addEventListener("click", closeReader);
    document.getElementById("reader-toc-toggle")?.addEventListener("click", () => {
      document.querySelector(".reader-shell")?.classList.toggle("toc-open");
    });
    document.getElementById("reader-toc-list")?.addEventListener("click", (e) => {
      const a = e.target.closest("[data-idx]");
      if (!a) return;
      e.preventDefault();
      const idx = parseInt(a.dataset.idx, 10) || 0;
      const target = document.getElementById("chap-" + idx);
      if (target) {
        target.scrollIntoView({ behavior: "smooth", block: "start" });
        setActiveChapter(idx);
      }
      // Close mobile TOC drawer after click
      document.querySelector(".reader-shell")?.classList.remove("toc-open");
    });

    populateSortSelect();
    renderLibrary();
    renderAtlasBookSlots();
    renderDictBookSlots();
    window.addEventListener("langchange", () => {
      populateSortSelect();
      renderLibrary();
      renderAtlasBookSlots();
      renderDictBookSlots();
      reloadReaderForLang();
    });
  }

  // ===== Dictionary → Library bridge =======================================
  // Dictionary entries declare `<span class="dict-books-slot" data-books-for="<term-id>">`.
  // We look up `dict.books.<term-id>` in the current lang bundle (array of
  // book ids) and fill the slot with compact "Read more" links.
  function renderDictBookSlots() {
    const slots = document.querySelectorAll(".dict-books-slot[data-books-for]");
    if (!slots.length) return;
    const data = L();
    const all = listBooks();
    const byId = Object.create(null);
    for (const b of all) byId[b.id] = b;
    slots.forEach((slot) => {
      const tid = slot.dataset.booksFor;
      const ids = data[`dict.books.${tid}`];
      if (!Array.isArray(ids) || ids.length === 0) {
        slot.innerHTML = "";
        return;
      }
      const readMore = t("page.dictionary.read_more", "Read more:");
      const links = ids.map((id) => {
        const b = byId[id];
        if (!b) return "";
        return (
          '<a class="dict-book-link" data-action="atlas-read" ' +
          'data-book-id="' + escapeHTML(b.id) + '" tabindex="0">' +
            escapeHTML(b.title) +
          '</a>'
        );
      }).filter(Boolean).join('<span class="dict-book-sep"> · </span>');
      if (!links) {
        slot.innerHTML = "";
        return;
      }
      slot.innerHTML =
        '<span class="dict-book-label">' + escapeHTML(readMore) + '</span> ' + links;
    });
  }

  // ===== Atlas → Library bridge ============================================
  // Atlas pages declare `<div class="atlas-books" data-books="id1,id2,id3">`
  // — we fill those with compact book cards drawn from the library data.
  // Clicking a card opens the reader directly.
  function renderAtlasBookSlots() {
    const slots = document.querySelectorAll(".atlas-books[data-books]");
    if (!slots.length) return;
    const all = listBooks();
    const byId = Object.create(null);
    for (const b of all) byId[b.id] = b;
    slots.forEach((slot) => {
      const ids = (slot.dataset.books || "").split(",").map((s) => s.trim()).filter(Boolean);
      slot.innerHTML = ids.map((id) => {
        const b = byId[id];
        if (!b) return "";
        const sub = b.subtitle
          ? '<div class="atlas-book-sub">' + escapeHTML(b.subtitle) + '</div>'
          : '';
        return (
          '<a class="atlas-book" data-action="atlas-read" data-book-id="' + escapeHTML(b.id) + '" tabindex="0">' +
            '<div class="atlas-book-cover">' + escapeHTML(monogram(b.title)) + '</div>' +
            '<div class="atlas-book-body">' +
              '<h4 class="atlas-book-title">' + escapeHTML(b.title) + '</h4>' +
              sub +
            '</div>' +
          '</a>'
        );
      }).join("");
    });
    if (!document.body.dataset.atlasBound) {
      document.body.addEventListener("click", (ev) => {
        const a = ev.target.closest("[data-action='atlas-read']");
        if (!a) return;
        ev.preventDefault();
        openReader(a.dataset.bookId);
      });
      document.body.dataset.atlasBound = "1";
    }
  }

  // When the user switches language while inside the reader, reload the book
  // in the new language and try to land on the same chapter index. Chapters
  // line up 1:1 across translations (same Part/Chapter structure), so the
  // current index is a safe anchor.
  async function reloadReaderForLang() {
    if (!reader.id) return;
    if (document.body.dataset.view !== "reader") return;
    const lang = (window.i18n && window.i18n.getLang()) || "en";
    let text = await loadBookScript(lang, reader.id);
    let loadedLang = lang;
    if (!text && lang !== "en") {
      text = await loadBookScript("en", reader.id);
      loadedLang = "en";
    }
    if (!text) return;
    const chapters = splitChapters(text);
    if (chapters.length === 0) return;
    const prevIdx = Math.min(reader.index, chapters.length - 1);
    reader = { id: reader.id, chapters, index: prevIdx, loadedLang };
    renderReader();
    const target = document.getElementById("chap-" + prevIdx);
    if (target) target.scrollIntoView({ behavior: "instant", block: "start" });
  }

  // =========================================================================
  // READER MEMORY — save/restore per-book reading position.
  // =========================================================================

  const READER_STATE_KEY = "wuys-reader-state";
  const READER_PREFS_KEY = "wuys-reader-prefs";

  function loadReaderState() {
    try { return JSON.parse(localStorage.getItem(READER_STATE_KEY) || "{}"); }
    catch (e) { return {}; }
  }
  function saveReaderState(state) {
    try { localStorage.setItem(READER_STATE_KEY, JSON.stringify(state)); }
    catch (e) {}
  }
  function rememberPosition(bookId, chapterIdx, scrollTop) {
    if (!bookId) return;
    const state = loadReaderState();
    state[bookId] = {
      chapter: chapterIdx,
      scroll: Math.round(scrollTop || 0),
      ts: Date.now(),
    };
    saveReaderState(state);
  }
  // Throttled scroll save — fires at most every 800ms while scrolling.
  let _saveScrollTimer = null;
  function scheduleSavePosition() {
    if (_saveScrollTimer) return;
    _saveScrollTimer = setTimeout(() => {
      _saveScrollTimer = null;
      if (!reader.id) return;
      const contentEl = document.getElementById("reader-content");
      if (!contentEl) return;
      rememberPosition(reader.id, reader.index, contentEl.scrollTop);
    }, 800);
  }
  window.__listSavedReaderState = loadReaderState;  // exposed for the home page

  // =========================================================================
  // READER PREFS — text size, theme, font, line height (global, not per book).
  // =========================================================================

  const DEFAULT_PREFS = {
    size: "medium",
    theme: "wuys",
    font: "serif",
    line: "normal",
    customBg: "#d4b896",
    customText: "#3a2e22",
  };
  function loadPrefs() {
    try {
      const raw = JSON.parse(localStorage.getItem(READER_PREFS_KEY) || "{}");
      const merged = Object.assign({}, DEFAULT_PREFS, raw);
      // Migration: old saved values may be "dark" or "sepia" (removed); map to "wuys".
      if (merged.theme === "dark" || merged.theme === "sepia") merged.theme = "wuys";
      return merged;
    } catch (e) { return Object.assign({}, DEFAULT_PREFS); }
  }
  function savePrefs(prefs) {
    try { localStorage.setItem(READER_PREFS_KEY, JSON.stringify(prefs)); }
    catch (e) {}
  }
  function applyPrefs(prefs) {
    const shellEl = document.querySelector(".reader-shell");
    const contentEl = document.getElementById("reader-content");
    if (!contentEl || !shellEl) return;
    shellEl.dataset.theme = prefs.theme;
    contentEl.dataset.size = prefs.size;
    contentEl.dataset.font = prefs.font;
    contentEl.dataset.line = prefs.line;

    // Custom theme: drive CSS variables on the shell. The custom-theme CSS
    // rules consume these variables.
    shellEl.style.setProperty("--r-custom-bg", prefs.customBg);
    shellEl.style.setProperty("--r-custom-text", prefs.customText);

    // Reveal the colour-picker panel only when "custom" is the active theme.
    const customPanel = document.getElementById("reader-custom-panel");
    if (customPanel) customPanel.hidden = prefs.theme !== "custom";
    const bgInput = document.getElementById("reader-custom-bg");
    const txInput = document.getElementById("reader-custom-text");
    if (bgInput && bgInput.value !== prefs.customBg) bgInput.value = prefs.customBg;
    if (txInput && txInput.value !== prefs.customText) txInput.value = prefs.customText;

    document.querySelectorAll(".reader-setting-buttons[data-setting]").forEach(group => {
      const key = group.dataset.setting;
      const target = prefs[key];
      group.querySelectorAll("button").forEach(btn => {
        btn.classList.toggle("active", btn.dataset.value === target);
      });
    });
  }
  function bindPrefsUI() {
    document.querySelectorAll(".reader-setting-buttons[data-setting] button").forEach(btn => {
      btn.addEventListener("click", () => {
        const key = btn.parentElement.dataset.setting;
        const value = btn.dataset.value;
        const prefs = loadPrefs();
        prefs[key] = value;
        savePrefs(prefs);
        applyPrefs(prefs);
        // Layout changes can shift line wrapping — re-flow line numbers.
        if (key === "size" || key === "font" || key === "line") {
          setTimeout(() => {
            if (typeof reflowLineNumbers === "function") reflowLineNumbers();
          }, 60);
        }
      });
    });
    // Custom-theme color pickers — apply live as the user drags.
    ["reader-custom-bg", "reader-custom-text"].forEach(id => {
      const inp = document.getElementById(id);
      if (!inp) return;
      inp.addEventListener("input", () => {
        const prefs = loadPrefs();
        prefs.theme = "custom";
        if (id === "reader-custom-bg") prefs.customBg = inp.value;
        else prefs.customText = inp.value;
        savePrefs(prefs);
        applyPrefs(prefs);
      });
    });
    document.getElementById("reader-settings-toggle")?.addEventListener("click", () => {
      const panel = document.getElementById("reader-settings-panel");
      if (!panel) return;
      const btn = document.getElementById("reader-settings-toggle");
      const open = panel.hidden;
      panel.hidden = !open;
      btn?.classList.toggle("active", open);
      // Close the search panel if we're opening settings.
      if (open) document.getElementById("reader-search-panel")?.setAttribute("hidden", "");
      document.getElementById("reader-search-toggle")?.classList.remove("active");
    });
  }

  // =========================================================================
  // BOOK SEARCH — full-text find with match navigation + highlight.
  // =========================================================================

  let searchHits = [];      // [{el, idx}, ...] — el = the highlight span
  let searchCurrent = -1;

  function clearHighlights() {
    document.querySelectorAll(".reader-search-hit").forEach(el => {
      const parent = el.parentNode;
      if (!parent) return;
      parent.replaceChild(document.createTextNode(el.textContent), el);
      parent.normalize();
    });
    searchHits = [];
    searchCurrent = -1;
    const countEl = document.getElementById("reader-search-count");
    if (countEl) countEl.textContent = "";
  }

  function highlightInTextNode(textNode, term) {
    if (!term) return [];
    const text = textNode.nodeValue;
    const lower = text.toLowerCase();
    const needle = term.toLowerCase();
    if (!needle || lower.indexOf(needle) === -1) return [];

    const frag = document.createDocumentFragment();
    const created = [];
    let lastIdx = 0;
    let idx;
    while ((idx = lower.indexOf(needle, lastIdx)) !== -1) {
      if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
      const span = document.createElement("span");
      span.className = "reader-search-hit";
      span.textContent = text.slice(idx, idx + needle.length);
      frag.appendChild(span);
      created.push(span);
      lastIdx = idx + needle.length;
    }
    if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
    textNode.parentNode.replaceChild(frag, textNode);
    return created;
  }

  function runSearch(term) {
    clearHighlights();
    if (!term || term.length < 2) return;
    const content = document.getElementById("reader-content");
    if (!content) return;

    // Walk text nodes (skip script/style/line-num gutters).
    const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        const p = n.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        if (p.classList && (p.classList.contains("line-num") || p.classList.contains("reader-search-hit"))) {
          return NodeFilter.FILTER_REJECT;
        }
        if (!n.nodeValue || !n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });
    const targets = [];
    let node;
    while ((node = walker.nextNode())) targets.push(node);

    targets.forEach(tn => {
      const hits = highlightInTextNode(tn, term);
      hits.forEach(h => searchHits.push(h));
    });

    const countEl = document.getElementById("reader-search-count");
    if (countEl) {
      countEl.textContent = searchHits.length
        ? `1 / ${searchHits.length}`
        : t("page.library.search_no_match", "No matches");
    }
    if (searchHits.length) jumpToHit(0);
  }

  function jumpToHit(idx) {
    if (!searchHits.length) return;
    idx = (idx + searchHits.length) % searchHits.length;
    if (searchCurrent >= 0 && searchHits[searchCurrent]) {
      searchHits[searchCurrent].classList.remove("current");
    }
    searchCurrent = idx;
    const el = searchHits[idx];
    el.classList.add("current");
    el.scrollIntoView({ block: "center", behavior: "smooth" });
    const countEl = document.getElementById("reader-search-count");
    if (countEl) countEl.textContent = `${idx + 1} / ${searchHits.length}`;
  }

  function bindSearchUI() {
    const toggle = document.getElementById("reader-search-toggle");
    const panel = document.getElementById("reader-search-panel");
    const input = document.getElementById("reader-search-input");
    const closeBtn = document.getElementById("reader-search-close");
    const prevBtn = document.getElementById("reader-search-prev");
    const nextBtn = document.getElementById("reader-search-next");
    if (!toggle || !panel || !input) return;

    toggle.addEventListener("click", () => {
      const willOpen = panel.hidden;
      panel.hidden = !willOpen;
      toggle.classList.toggle("active", willOpen);
      // Close settings panel if opening search.
      if (willOpen) {
        document.getElementById("reader-settings-panel")?.setAttribute("hidden", "");
        document.getElementById("reader-settings-toggle")?.classList.remove("active");
        setTimeout(() => input.focus(), 40);
      } else {
        clearHighlights();
      }
    });
    closeBtn?.addEventListener("click", () => {
      panel.hidden = true;
      toggle.classList.remove("active");
      input.value = "";
      clearHighlights();
    });

    let debounce = null;
    input.addEventListener("input", () => {
      clearTimeout(debounce);
      debounce = setTimeout(() => runSearch(input.value.trim()), 180);
    });
    input.addEventListener("keydown", (ev) => {
      if (ev.key === "Enter") {
        ev.preventDefault();
        if (searchHits.length) jumpToHit(searchCurrent + (ev.shiftKey ? -1 : 1));
      } else if (ev.key === "Escape") {
        panel.hidden = true;
        toggle.classList.remove("active");
        clearHighlights();
      }
    });
    prevBtn?.addEventListener("click", () => jumpToHit(searchCurrent - 1));
    nextBtn?.addEventListener("click", () => jumpToHit(searchCurrent + 1));
  }

  // =========================================================================
  // Wire memory + prefs + search into the existing reader lifecycle.
  // =========================================================================

  // Patch setActiveChapter so chapter changes also save reader position.
  if (typeof setActiveChapter === "function") {
    const _orig = setActiveChapter;
    setActiveChapter = function (idx) {
      _orig(idx);
      if (reader.id) {
        const contentEl = document.getElementById("reader-content");
        rememberPosition(reader.id, idx, contentEl ? contentEl.scrollTop : 0);
      }
    };
  }

  // Hook scroll-based position saving once when reader becomes available.
  function bindReaderScroll() {
    const contentEl = document.getElementById("reader-content");
    if (!contentEl || contentEl.dataset.scrollBound) return;
    contentEl.dataset.scrollBound = "1";
    contentEl.addEventListener("scroll", scheduleSavePosition, { passive: true });
  }

  // Extend openReader to consult saved state when no explicit chapter is given.
  if (typeof openReader === "function") {
    const _origOpen = openReader;
    openReader = async function (bookId, chapterIdx) {
      let startIdx = chapterIdx;
      let restoreScroll = null;
      if (typeof chapterIdx !== "number") {
        const saved = loadReaderState()[bookId];
        if (saved) {
          // saved.chapter is the chapter index INSIDE chapters[] (already offset
          // by 1 from the source-side numbering since the About is index 0).
          // Pass undefined to _origOpen so it doesn't double-offset, then we
          // adjust below.
          restoreScroll = saved.scroll;
        }
      }
      await _origOpen(bookId, startIdx);
      // Apply saved state if any.
      if (typeof startIdx !== "number") {
        const saved = loadReaderState()[bookId];
        if (saved && reader.chapters && reader.chapters.length) {
          const tgtIdx = Math.min(Math.max(saved.chapter || 0, 0), reader.chapters.length - 1);
          if (tgtIdx !== reader.index) {
            setActiveChapter(tgtIdx);
            const target = document.getElementById("chap-" + tgtIdx);
            if (target) target.scrollIntoView({ behavior: "instant", block: "start" });
          }
          if (restoreScroll != null) {
            const contentEl = document.getElementById("reader-content");
            if (contentEl) setTimeout(() => { contentEl.scrollTop = restoreScroll; }, 30);
          }
        }
      }
      applyPrefs(loadPrefs());
      bindReaderScroll();
      // Make the new instance the source for the public open API.
    };
    window.__openBookAt = openReader;
  }

  function bindOutsideClickToClose() {
    document.addEventListener("click", (ev) => {
      const settingsPanel = document.getElementById("reader-settings-panel");
      const searchPanel = document.getElementById("reader-search-panel");
      if (settingsPanel && !settingsPanel.hidden) {
        if (!ev.target.closest("#reader-settings-panel") && !ev.target.closest("#reader-settings-toggle")) {
          settingsPanel.hidden = true;
          document.getElementById("reader-settings-toggle")?.classList.remove("active");
        }
      }
      if (searchPanel && !searchPanel.hidden) {
        if (!ev.target.closest("#reader-search-panel") && !ev.target.closest("#reader-search-toggle")) {
          searchPanel.hidden = true;
          document.getElementById("reader-search-toggle")?.classList.remove("active");
          clearHighlights();
        }
      }
    });
  }

  function initReaderToolsUI() {
    bindPrefsUI();
    bindSearchUI();
    bindOutsideClickToClose();
    applyPrefs(loadPrefs());
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () => { init(); initReaderToolsUI(); });
  } else {
    init();
    initReaderToolsUI();
  }
})();
