// Feed / reader mode. Presents the stories currently on the page one at a
// time in a focused overlay, marking each read as it's shown. Left/right arrow
// keys (or the on-screen buttons) page through them; the full article text is
// whatever Pete captured at ingest (content:encoded or the scraped body),
// fetched on demand from /api/article and cached for the session.
//
// Read state lives in localStorage under pete.read.v1. It's deliberately
// device-local — unlike the small prefs blob, the read set grows unbounded, so
// it isn't mirrored through PetePrefs to the account.
(function () {
  var overlay = document.getElementById("pete-reader");
  if (!overlay) return;

  var READ_KEY = "pete.read.v1";
  var articleEl = overlay.querySelector("[data-reader-article]");
  var scrollEl = overlay.querySelector("[data-reader-scroll]");
  var progressEl = overlay.querySelector("[data-reader-progress]");
  var linkEl = overlay.querySelector("[data-reader-link]");
  var prevBtn = overlay.querySelector("[data-reader-prev]");
  var nextBtn = overlay.querySelector("[data-reader-next]");
  var closeBtn = overlay.querySelector("[data-reader-close]");
  var backdrop = overlay.querySelector("[data-reader-backdrop]");
  var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
  var listenBtn = overlay.querySelector("[data-reader-listen]");
  var shareBtn = overlay.querySelector("[data-reader-share]");
  var typeBtn = overlay.querySelector("[data-reader-type]");
  var typeMenu = overlay.querySelector("[data-reader-typemenu]");
  var voiceRow = overlay.querySelector("[data-reader-voicerow]");
  var voiceSel = overlay.querySelector("[data-reader-voice]");
  var relatedEl = overlay.querySelector("[data-reader-related]");
  var relatedCache = {}; // id -> results array

  // Signed-in users (Authentik) get read + bookmark state synced server-side;
  // window.PETE_USER is non-null for them. Anonymous visitors keep the
  // localStorage-only behaviour and never see the bookmark controls.
  var SIGNED_IN = !!(window.PETE_USER);
  var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories

  // Read-aloud is a signed-in-only perk, backed by server-side Piper voices
  // (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
  // is available to everyone (native sheet where present, clipboard otherwise).
  var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
    window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
  var TTS_OK = SIGNED_IN && !!TTS_CFG;

  var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
  var index = 0;
  var open = false;
  var contentCache = {}; // id -> {content, lede}
  var inflight = null;
  var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)

  // ---- read-state store -----------------------------------------------------
  function loadRead() {
    try {
      var raw = localStorage.getItem(READ_KEY);
      var obj = raw ? JSON.parse(raw) : {};
      return obj && typeof obj === "object" ? obj : {};
    } catch (e) { return {}; }
  }
  function saveRead(set) {
    try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {}
  }
  var readSet = loadRead();

  function isRead(id) { return !!readSet[id]; }
  function setRead(id, on) {
    if (on) readSet[id] = 1; else delete readSet[id];
    saveRead(readSet);
    paintCard(id, on);
    if (SIGNED_IN) postState("/api/read", { id: Number(id), read: !!on });
  }

  // ---- server sync (signed-in only) -----------------------------------------
  function postState(url, body) {
    try {
      fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
        credentials: "same-origin",
        keepalive: true
      }).catch(function () {});
    } catch (e) {}
  }

  function isBookmarked(id) { return !!bookmarkSet[id]; }

  // setBookmark updates memory, paints every matching control, and persists.
  function setBookmark(id, on) {
    if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
    paintBookmark(id, on);
    postState("/api/bookmark", { id: Number(id), on: !!on });
    // On the bookmarks page, an un-bookmark should drop the card immediately.
    if (!on && location.pathname === "/bookmarks") {
      document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
        c.parentNode && c.parentNode.removeChild(c);
      });
    }
  }

  // setBookmarkQuiet applies server-provided state without echoing it back.
  function setBookmarkQuiet(id, on) {
    if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
    paintBookmark(id, on);
  }

  function paintBookmark(id, on) {
    document.querySelectorAll('[data-bookmark-btn][data-story-id="' + cssEsc(id) + '"]').forEach(function (b) {
      applyCardBookmark(b, on);
    });
    if (readerBookmarkBtn && items[index] && String(items[index].id) === String(id)) {
      applyReaderBookmark(on);
    }
  }

  function applyCardBookmark(btn, on) {
    btn.setAttribute("aria-pressed", on ? "true" : "false");
    var svg = btn.querySelector("svg");
    if (on) {
      btn.style.background = "var(--accent)";
      btn.style.color = "#1c1305";
      if (svg) svg.setAttribute("fill", "currentColor");
    } else {
      btn.style.background = "rgba(20,14,6,.62)";
      btn.style.color = "#fff";
      if (svg) svg.setAttribute("fill", "none");
    }
  }

  function applyReaderBookmark(on) {
    if (!readerBookmarkBtn) return;
    readerBookmarkBtn.setAttribute("aria-pressed", on ? "true" : "false");
    readerBookmarkBtn.textContent = on ? "🔖 Saved" : "🔖 Save";
  }

  // initUserState reveals the bookmark controls and pulls the signed-in user's
  // read + bookmark state for the stories on this page, painting them and
  // migrating any device-local reads the account doesn't have yet.
  function initUserState() {
    if (!SIGNED_IN) return;
    document.querySelectorAll("[data-bookmark-btn]").forEach(function (b) {
      b.style.display = "inline-flex";
    });
    var ids = [];
    document.querySelectorAll("[data-story-card]").forEach(function (c) {
      var id = c.getAttribute("data-id");
      if (id) ids.push(id);
    });
    if (!ids.length) return;
    fetch("/api/state?ids=" + encodeURIComponent(ids.join(",")), { credentials: "same-origin" })
      .then(function (r) { return r.ok ? r.json() : null; })
      .then(function (data) {
        if (!data) return;
        var serverRead = Object.create(null);
        (data.read || []).forEach(function (id) {
          serverRead[id] = 1; readSet[id] = 1; paintCard(id, true);
        });
        (data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); });
        // Migrate device-local reads the account doesn't have yet — but only
        // once per device. After the first sync the server is authoritative, so
        // a story the user later marks unread on another device stays unread
        // instead of being perpetually resurrected from this device's stale
        // local set on every page load.
        var MIGRATED_KEY = "pete.readMigrated.v1";
        var migrated = false;
        try { migrated = localStorage.getItem(MIGRATED_KEY) === "1"; } catch (e) {}
        if (!migrated) {
          ids.forEach(function (id) {
            if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
          });
          try { localStorage.setItem(MIGRATED_KEY, "1"); } catch (e) {}
        }
        saveRead(readSet);
      })
      .catch(function () {});
  }
  // Reflect read state onto every matching card on the page (a story can appear
  // in more than one section on the home page).
  function paintCard(id, on) {
    document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
      if (on) c.setAttribute("data-read", "1");
      else c.removeAttribute("data-read");
    });
  }
  function cssEsc(s) {
    return String(s).replace(/["\\]/g, "\\$&");
  }

  // ---- collecting the page's stories ----------------------------------------
  function isVisible(el) {
    return window.getComputedStyle(el).display !== "none";
  }
  function collect() {
    var seen = Object.create(null);
    var out = [];
    document.querySelectorAll("[data-story-card]").forEach(function (c) {
      var id = c.getAttribute("data-id");
      if (!id || seen[id] || !isVisible(c)) return;
      var url = c.getAttribute("data-url") || c.getAttribute("href") || "";
      if (!/^https?:\/\//i.test(url)) url = "";
      seen[id] = true;
      out.push({
        id: id,
        url: url,
        headline: c.getAttribute("data-headline") || "",
        lede: c.getAttribute("data-lede") || "",
        image: c.getAttribute("data-image") || "",
        time: c.getAttribute("data-time") || "",
        source: c.getAttribute("data-source") || "",
        chTitle: c.getAttribute("data-ch-title") || "",
        chEmoji: c.getAttribute("data-ch-emoji") || "",
        chTheme: c.getAttribute("data-ch-theme") || "",
        paywalled: c.getAttribute("data-paywalled") === "1"
      });
    });
    return out;
  }

  // ---- rendering ------------------------------------------------------------
  function escapeHTML(s) {
    return String(s == null ? "" : s)
      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  }
  function safeURL(s) {
    var raw = String(s == null ? "" : s).trim();
    return /^https?:\/\//i.test(raw) ? raw : "";
  }
  // Turn stored plain text (paragraphs separated by blank lines) into escaped
  // <p> blocks. Single newlines inside a paragraph become spaces.
  function paragraphs(text) {
    var blocks = String(text || "").split(/\n{2,}/);
    var html = "";
    for (var i = 0; i < blocks.length; i++) {
      var t = blocks[i].replace(/\s*\n\s*/g, " ").trim();
      if (t) html += "<p>" + escapeHTML(t) + "</p>";
    }
    return html;
  }

  function eyebrow(it) {
    var chip = it.chTheme
      ? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
        (it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
        escapeHTML(it.chTitle) + "</span>"
      : "";
    var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
    var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
    return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
  }

  function renderBody(it, bodyHTML) {
    var hero = it.image
      ? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="' + escapeHTML(it.headline) + '" loading="lazy" decoding="async">'
      : "";
    var href = safeURL(it.url);
    var note = '<p class="pete-reader-note">' +
      (it.paywalled ? "This source is paywalled, so the text above may be partial. " : "") +
      (href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
              escapeHTML(it.source || "original article") + " ↗</a>." : "") +
      "</p>";
    articleEl.innerHTML =
      eyebrow(it) +
      '<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
      hero +
      '<div class="pete-reader-body">' + bodyHTML + "</div>" +
      note;
  }

  function loadingBody(it) {
    bodyReady = false; // read-aloud waits for the real body, not this placeholder
    renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
  }

  function fillContent(it, data) {
    var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
    var html = paragraphs(text);
    if (!html) {
      html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
    }
    renderBody(it, html);
    bodyReady = true;
  }

  function fetchContent(it) {
    if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; }
    loadingBody(it);
    if (inflight) inflight.abort();
    var ctrl = new AbortController();
    inflight = ctrl;
    var reqId = it.id;
    fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" })
      .then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
      .then(function (data) {
        contentCache[reqId] = data;
        if (ctrl !== inflight) return;      // superseded by a newer navigation
        if (items[index] && items[index].id === reqId) fillContent(it, data);
      })
      .catch(function (err) {
        if (err.name === "AbortError") return;
        if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede
      })
      .finally(function () { if (ctrl === inflight) inflight = null; });
  }

  // ---- related ("you might also like") --------------------------------------
  function clearRelated() {
    if (!relatedEl) return;
    relatedEl.innerHTML = "";
    relatedEl.hidden = true;
  }

  function renderRelated(reqId, results) {
    if (!relatedEl) return;
    // Ignore a response that arrived after the user moved on.
    if (!items[index] || String(items[index].id) !== String(reqId)) return;
    if (!results || !results.length) { clearRelated(); return; }
    var html = '<h2 class="pete-reader-related-title">You might also like</h2>' +
      '<div class="pete-reader-related-grid">';
    for (var i = 0; i < results.length; i++) {
      var it = results[i];
      var href = safeURL(it.article_url);
      if (!href) continue;
      var chip = it.channel_theme
        ? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.channel_theme) + '">' +
          (it.channel_emoji ? '<span aria-hidden="true">' + escapeHTML(it.channel_emoji) + "</span>" : "") +
          escapeHTML(it.channel_title) + "</span>"
        : "";
      var thumb = it.thumb_url
        ? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="' + escapeHTML(it.headline || "") + '" loading="lazy" decoding="async">'
        : '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
      html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
        thumb +
        '<div class="pete-reader-related-meta">' +
        '<div class="pete-reader-related-eyebrow">' + chip +
        (it.source ? '<span class="pete-reader-related-source">' + escapeHTML(it.source) + "</span>" : "") +
        "</div>" +
        '<div class="pete-reader-related-headline">' + escapeHTML(it.headline) + "</div>" +
        "</div></a>";
    }
    html += "</div>";
    relatedEl.innerHTML = html;
    relatedEl.hidden = false;
  }

  function fetchRelated(it) {
    if (!relatedEl) return;
    clearRelated();
    var reqId = it.id;
    if (relatedCache[reqId]) { renderRelated(reqId, relatedCache[reqId]); return; }
    fetch("/api/related?id=" + encodeURIComponent(it.id), { credentials: "same-origin" })
      .then(function (r) { return r.ok ? r.json() : null; })
      .then(function (data) {
        var results = (data && data.results) || [];
        relatedCache[reqId] = results;
        renderRelated(reqId, results);
      })
      .catch(function () {});
  }

  // ---- navigation -----------------------------------------------------------
  function show(i) {
    stopSpeak(); // never keep reading a story the user navigated away from
    index = i;
    if (index >= items.length) { renderDone(); return; }
    var it = items[index];
    progressEl.textContent = (index + 1) + " / " + items.length;
    var href = safeURL(it.url);
    if (href) { linkEl.href = href; linkEl.style.display = ""; }
    else linkEl.style.display = "none";
    prevBtn.disabled = index === 0;
    nextBtn.disabled = false;
    nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
    if (scrollEl) scrollEl.scrollTop = 0;
    if (readerBookmarkBtn) {
      readerBookmarkBtn.style.display = SIGNED_IN ? "" : "none";
      applyReaderBookmark(isBookmarked(it.id));
    }
    fetchContent(it);
    fetchRelated(it);
    setRead(it.id, true); // presenting a story marks it read
  }

  function renderDone() {
    stopSpeak();
    clearRelated();
    progressEl.textContent = items.length + " / " + items.length;
    linkEl.style.display = "none";
    prevBtn.disabled = items.length === 0;
    nextBtn.disabled = true;
    nextBtn.textContent = "→";
    if (scrollEl) scrollEl.scrollTop = 0;
    articleEl.innerHTML =
      '<div class="pete-reader-empty">' +
      '<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
      '<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
      '<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
      "Head back to browse more, or press ← to revisit the last one.</p>" +
      "</div>";
  }

  function next() { if (index < items.length) show(index + 1); }
  function prev() { if (index > 0) show(index - 1); }

  function firstUnread() {
    for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i;
    return 0;
  }

  // ---- typography (Aa popover) ----------------------------------------------
  // Per-device reading preferences: text size, sans/serif, default/sepia paper.
  // Kept in localStorage (like the read set) rather than the synced prefs blob,
  // since they're a device-local reading-comfort choice.
  var TYPE_KEY = "pete.reader.type.v1";
  var SIZES = ["s", "m", "l", "xl"];
  var typePrefs = { size: "m", font: "sans", paper: "default" };
  (function loadType() {
    try {
      var raw = JSON.parse(localStorage.getItem(TYPE_KEY) || "{}");
      if (raw && typeof raw === "object") {
        if (SIZES.indexOf(raw.size) >= 0) typePrefs.size = raw.size;
        if (raw.font === "serif" || raw.font === "sans") typePrefs.font = raw.font;
        if (raw.paper === "sepia" || raw.paper === "default") typePrefs.paper = raw.paper;
      }
    } catch (e) {}
  })();
  function saveType() {
    try { localStorage.setItem(TYPE_KEY, JSON.stringify(typePrefs)); } catch (e) {}
  }
  function applyType() {
    if (!scrollEl) return;
    SIZES.forEach(function (s) { scrollEl.classList.remove("is-size-" + s); });
    scrollEl.classList.add("is-size-" + typePrefs.size);
    scrollEl.classList.toggle("is-serif", typePrefs.font === "serif");
    scrollEl.classList.toggle("is-sepia", typePrefs.paper === "sepia");
    paintType();
  }
  function paintType() {
    if (!typeMenu) return;
    typeMenu.querySelectorAll("[data-size]").forEach(function (b) {
      b.classList.toggle("is-active", b.getAttribute("data-size") === typePrefs.size);
    });
    typeMenu.querySelectorAll("[data-font]").forEach(function (b) {
      b.classList.toggle("is-active", b.getAttribute("data-font") === typePrefs.font);
    });
    typeMenu.querySelectorAll("[data-paper]").forEach(function (b) {
      b.classList.toggle("is-active", b.getAttribute("data-paper") === typePrefs.paper);
    });
  }
  function toggleTypeMenu(force) {
    if (!typeMenu || !typeBtn) return;
    var openNow = force != null ? force : typeMenu.hidden;
    typeMenu.hidden = !openNow;
    typeBtn.setAttribute("aria-expanded", openNow ? "true" : "false");
  }

  // ---- share ----------------------------------------------------------------
  var toastEl = null, toastTimer = null;
  function toast(msg) {
    if (!toastEl) {
      toastEl = document.createElement("div");
      toastEl.className = "pete-reader-toast";
      overlay.appendChild(toastEl);
    }
    toastEl.textContent = msg;
    toastEl.classList.add("is-shown");
    if (toastTimer) clearTimeout(toastTimer);
    toastTimer = setTimeout(function () { toastEl.classList.remove("is-shown"); }, 1800);
  }
  function shareCurrent() {
    var it = items[index];
    if (!it) return;
    var url = safeURL(it.url);
    if (!url) { toast("No link to share"); return; }
    var data = { title: it.headline || "Pete", text: it.headline || "", url: url };
    if (navigator.share) {
      navigator.share(data).catch(function () {});
      return;
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(url).then(
        function () { toast("Link copied ✓"); },
        function () { toast("Couldn't copy link"); }
      );
      return;
    }
    window.open(url, "_blank", "noopener");
  }

  // ---- read aloud (signed-in only, server-side Piper) -----------------------
  // Each paragraph is synthesized on demand by POSTing its text to /api/tts and
  // playing the returned WAV. We fetch the next paragraph while the current one
  // plays, so the model-load + synthesis latency is hidden behind playback.
  // speakGen is bumped on every stop/start/voice-change; any async callback that
  // fires afterwards checks it against its captured gen and no-ops if stale.
  var VOICE_KEY = "pete.reader.voice.v1";
  var speaking = false;
  var speakGen = 0;
  var speakParas = [];   // <p> elements currently highlighted
  var speakItems = [];   // [{el, text}] for the whole article
  var speakIdx = 0;      // index into speakItems currently playing
  var speakReq = null;   // in-flight fetch for the current paragraph (has .ctrl)
  var prefetch = null;   // { idx, gen, promise, abort } for the next paragraph
  var audioEl = null;    // shared <audio> element
  var currentURL = null; // object URL currently loaded into audioEl

  // Device-local voice choice, like the type prefs (see loadType).
  var ttsVoice = "";
  (function loadVoice() {
    if (!TTS_CFG) return;
    var saved = "";
    try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
    var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
    ttsVoice = ok ? saved : TTS_CFG.default;
  })();
  function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }

  function speakingText() {
    if (!articleEl) return [];
    var out = [];
    var h = articleEl.querySelector(".pete-reader-title");
    if (h && h.textContent.trim()) out.push({ el: null, text: h.textContent.trim() });
    articleEl.querySelectorAll(".pete-reader-body p").forEach(function (p) {
      var t = p.textContent.trim();
      if (t) out.push({ el: p, text: t });
    });
    return out;
  }
  function clearSpeakHighlight() {
    speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
    speakParas = [];
  }
  function highlight(item) {
    clearSpeakHighlight();
    if (item && item.el) {
      item.el.classList.add("is-speaking");
      speakParas.push(item.el);
      try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
    }
  }

  // ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
  // where promise resolves to an object URL for the audio blob.
  function ttsFetch(text) {
    var ctrl = new AbortController();
    var promise = fetch("/api/tts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ voice: ttsVoice, text: text }),
      signal: ctrl.signal,
    }).then(function (r) {
      if (!r.ok) throw new Error("tts " + r.status);
      return r.blob();
    }).then(function (b) { return URL.createObjectURL(b); });
    return { ctrl: ctrl, promise: promise };
  }
  function abortPrefetch() {
    if (!prefetch) return;
    try { prefetch.ctrl.abort(); } catch (e) {}
    // If it already resolved to a URL, reclaim it.
    prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
    prefetch = null;
  }
  function startPrefetch(i, gen) {
    var item = speakItems[i];
    if (!item) { prefetch = null; return; }
    var req = ttsFetch(item.text);
    prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
  }
  function playURL(url, gen) {
    if (!audioEl) audioEl = new Audio();
    if (currentURL) URL.revokeObjectURL(currentURL);
    currentURL = url;
    audioEl.src = url;
    audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
    audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
    var pr = audioEl.play();
    if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
  }
  function playIdx(i) {
    if (!speaking) return;
    var gen = speakGen;
    var item = speakItems[i];
    if (!item) { stopSpeak(); return; }
    speakIdx = i;
    highlight(item);
    var urlP;
    if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
      urlP = prefetch.promise;
      prefetch = null; // hand off; do not abort/revoke this one
    } else {
      abortPrefetch();
      var req = ttsFetch(item.text);
      speakReq = req;
      urlP = req.promise;
    }
    urlP.then(function (url) {
      if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
      speakReq = null;
      startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
      playURL(url, gen);
    }).catch(function () {
      if (!speaking || gen !== speakGen) return;
      speakReq = null;
      playIdx(i + 1); // skip a paragraph that failed rather than stalling
    });
  }
  function teardownAudio() {
    if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
    abortPrefetch();
    if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
    if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
  }
  function stopSpeak() {
    if (!TTS_OK) return;
    speakGen++;
    speaking = false;
    teardownAudio();
    speakItems = [];
    clearSpeakHighlight();
    if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
  }
  function startSpeak() {
    if (!TTS_OK) return;
    if (!bodyReady) { toast("Still loading…"); return; }
    var parts = speakingText();
    if (!parts.length) { toast("Nothing to read yet"); return; }
    speakGen++;
    teardownAudio();
    speaking = true;
    speakItems = parts;
    speakIdx = 0;
    if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
    playIdx(0);
  }
  function toggleSpeak() {
    if (!TTS_OK) return;
    if (speaking) stopSpeak(); else startSpeak();
  }
  // Switching voice restarts the current paragraph with the new voice so the
  // change is audible immediately, keeping our place in the article.
  function changeVoice(id) {
    if (!TTS_CFG) return;
    ttsVoice = id;
    saveVoice();
    if (voiceSel) voiceSel.value = ttsVoice;
    if (speaking) {
      speakGen++;
      teardownAudio();
      playIdx(speakIdx);
    }
  }
  function buildVoiceMenu() {
    if (!voiceSel || !TTS_CFG) return;
    voiceSel.innerHTML = "";
    TTS_CFG.voices.forEach(function (v) {
      var o = document.createElement("option");
      o.value = v.id;
      o.textContent = v.label;
      voiceSel.appendChild(o);
    });
    voiceSel.value = ttsVoice;
    voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
    if (voiceRow) voiceRow.hidden = false;
  }

  // ---- open / close ---------------------------------------------------------
  function openReader() {
    items = collect();
    if (items.length === 0) return;
    open = true;
    overlay.classList.remove("hidden");
    document.body.classList.add("overflow-hidden");
    applyType();
    show(firstUnread());
    // Move focus into the scroll region so Space / PageUp-Down / arrow keys
    // scroll the story, not the page behind it, without a click first.
    if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
  }
  function closeReader() {
    open = false;
    stopSpeak();
    toggleTypeMenu(false);
    if (inflight) { inflight.abort(); inflight = null; }
    clearRelated();
    overlay.classList.add("hidden");
    document.body.classList.remove("overflow-hidden");
  }

  // ---- wiring ---------------------------------------------------------------
  document.addEventListener("DOMContentLoaded", function () {
    // Dim stories already read on this device.
    document.querySelectorAll("[data-story-card]").forEach(function (c) {
      var id = c.getAttribute("data-id");
      if (id && isRead(id)) c.setAttribute("data-read", "1");
    });

    document.querySelectorAll("[data-reader-open]").forEach(function (b) {
      b.addEventListener("click", function (e) { e.preventDefault(); openReader(); });
    });
    if (prevBtn) prevBtn.addEventListener("click", prev);
    if (nextBtn) nextBtn.addEventListener("click", next);
    if (closeBtn) closeBtn.addEventListener("click", closeReader);
    if (backdrop) backdrop.addEventListener("click", closeReader);
    if (readerBookmarkBtn) readerBookmarkBtn.addEventListener("click", function () {
      var it = items[index];
      if (it) setBookmark(it.id, !isBookmarked(it.id));
    });

    // Read-aloud: signed-in only, and only when server-side TTS is configured.
    if (listenBtn) {
      if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
      else listenBtn.style.display = "none";
    }
    // Share: available to everyone.
    if (shareBtn) { shareBtn.style.display = ""; shareBtn.addEventListener("click", shareCurrent); }

    // Text-options popover.
    if (typeBtn) typeBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleTypeMenu(); });
    if (typeMenu) {
      typeMenu.addEventListener("click", function (e) {
        var b = e.target.closest("button");
        if (!b) return;
        e.stopPropagation();
        if (b.hasAttribute("data-size")) typePrefs.size = b.getAttribute("data-size");
        else if (b.hasAttribute("data-font")) typePrefs.font = b.getAttribute("data-font");
        else if (b.hasAttribute("data-paper")) typePrefs.paper = b.getAttribute("data-paper");
        else return;
        saveType();
        applyType();
      });
    }
    // Close the type popover on any outside click.
    document.addEventListener("click", function (e) {
      if (!typeMenu || typeMenu.hidden) return;
      if (e.target.closest("[data-reader-type]")) return;
      toggleTypeMenu(false);
    });
    applyType();

    initUserState();
  });

  // Bookmark buttons live inside the card's <a>; intercept so a tap toggles the
  // bookmark instead of following the link. Delegated so it also covers cards
  // that are added or removed after load.
  document.addEventListener("click", function (e) {
    var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
    if (!btn) return;
    e.preventDefault();
    e.stopPropagation();
    var id = btn.getAttribute("data-story-id");
    if (id) setBookmark(id, !isBookmarked(id));
  });
  document.addEventListener("keydown", function (e) {
    if (e.key !== "Enter" && e.key !== " ") return;
    var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
    if (!btn) return;
    e.preventDefault();
    var id = btn.getAttribute("data-story-id");
    if (id) setBookmark(id, !isBookmarked(id));
  });

  document.addEventListener("keydown", function (e) {
    // Enter feed mode with `f` when nothing is focused and no other overlay is up.
    if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
      var tag = (document.activeElement && document.activeElement.tagName) || "";
      if (tag === "INPUT" || tag === "TEXTAREA") return;
      var searchOpen = document.getElementById("pete-search");
      if (searchOpen && !searchOpen.classList.contains("hidden")) return;
      e.preventDefault();
      openReader();
      return;
    }
    if (!open) return;
    switch (e.key) {
      case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
      case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
      case "Escape":
        e.preventDefault();
        if (typeMenu && !typeMenu.hidden) toggleTypeMenu(false); // esc closes the popover first
        else closeReader();
        break;
      case "s": case "S": e.preventDefault(); shareCurrent(); break;
      case "t": case "T": e.preventDefault(); toggleTypeMenu(); break;
      case "r": case "R": if (TTS_OK) { e.preventDefault(); toggleSpeak(); } break;
      case "o": case "Enter": {
        var it = items[index];
        var href = it && safeURL(it.url);
        if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); }
        break;
      }
      case "u": {
        var cur = items[index];
        if (cur) setRead(cur.id, false); // let the user undo an accidental read
        break;
      }
      case "b": case "B": {
        if (!SIGNED_IN) break;
        var it = items[index];
        if (it) { e.preventDefault(); setBookmark(it.id, !isBookmarked(it.id)); }
        break;
      }
    }
  });
})();
