package server

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"html/template"
	"net/http"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/libnovel/scraper/internal/orchestrator"
	"github.com/libnovel/scraper/internal/scraper"
	"github.com/libnovel/scraper/internal/writer"
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	goldhtml "github.com/yuin/goldmark/renderer/html"
)

// md is the shared goldmark instance used for all markdown→HTML conversions.
var md = goldmark.New(
	goldmark.WithExtensions(extension.Typographer, extension.Table),
	goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)

// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
// grouped loosely by language prefix:
//
//	af_ / am_  American English female / male
//	bf_ / bm_  British English female / male
//	ef_ / em_  Spanish female / male
//	ff_        French female
//	hf_ / hm_  Hindi female / male
//	if_ / im_  Italian female / male
//	jf_ / jm_  Japanese female / male
//	pf_ / pm_  Portuguese female / male
//	zf_ / zm_  Chinese female / male
var kokoroVoices = []string{
	// American English
	"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
	"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
	"af_sarah", "af_sky",
	"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
	"am_michael", "am_onyx", "am_puck",
	// British English
	"bf_alice", "bf_emma", "bf_lily",
	"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
	// Spanish
	"ef_dora", "em_alex",
	// French
	"ff_siwis",
	// Hindi
	"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
	// Italian
	"if_sara", "im_nicola",
	// Japanese
	"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
	// Portuguese
	"pf_dora", "pm_alex",
	// Chinese
	"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
	"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}

// voiceInfo holds the parsed display metadata for a single Kokoro voice.
type voiceInfo struct {
	ID     string // raw voice ID, e.g. "af_bella"
	Name   string // display name, e.g. "Bella"
	Lang   string // language label, e.g. "EN-US"
	Gender string // "F" or "M"
}

// langLabel maps the two-letter prefix to a human-readable language tag.
var langLabel = map[string]string{
	"a": "EN-US",
	"b": "EN-GB",
	"e": "ES",
	"f": "FR",
	"h": "HI",
	"i": "IT",
	"j": "JA",
	"p": "PT",
	"z": "ZH",
}

// parseVoice decodes a Kokoro voice ID into display metadata.
// IDs follow the pattern  {lang}{gender}_{name}  e.g. "af_bella".
func parseVoice(id string) voiceInfo {
	v := voiceInfo{ID: id, Name: id, Lang: "?", Gender: "?"}
	if len(id) < 3 || id[2] != '_' {
		return v
	}
	lc := string(id[0])
	gc := string(id[1])
	name := id[3:]
	if l, ok := langLabel[lc]; ok {
		v.Lang = l
	}
	switch gc {
	case "f":
		v.Gender = "F"
	case "m":
		v.Gender = "M"
	}
	// Capitalise name, replace underscores with spaces.
	if len(name) > 0 {
		runes := []rune(name)
		runes[0] -= 'a' - 'A'
		v.Name = strings.ReplaceAll(string(runes), "_", " ")
	}
	return v
}

// parseVoices converts a slice of raw voice IDs to voiceInfo structs.
func parseVoices(ids []string) []voiceInfo {
	out := make([]voiceInfo, len(ids))
	for i, id := range ids {
		out[i] = parseVoice(id)
	}
	return out
}

// ─── shared layout ────────────────────────────────────────────────────────────

const layoutHead = `<!DOCTYPE html>
<html lang="en" class="bg-zinc-950 text-zinc-100">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{.Title}} — libnovel</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
  <style>
    .prose p            { margin-bottom: 1em; }
    .prose h1,.prose h2,
    .prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
    .prose h4  { font-size: 1.05rem; }
    .prose h3  { font-size: 1.2rem; }
    .prose h2  { font-size: 1.4rem; }
    .prose h1  { font-size: 1.7rem; }
    .prose em  { font-style: italic; }
    .prose strong { font-weight: 700; }
    .prose hr  { border-color: #3f3f46; margin: 2em 0; }
    .prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
  </style>
</head>
<body class="min-h-screen">`

const layoutFoot = `</body></html>`

func renderPage(w http.ResponseWriter, title, body string) {
	t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_ = t.Execute(w, struct{ Title string }{Title: title})
}

func renderFragment(w http.ResponseWriter, body string) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, body)
}

func isHTMX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}

// respond writes either a full page or an HTMX fragment depending on the request.
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
	if isHTMX(r) {
		renderFragment(w, fragment)
		return
	}
	renderPage(w, title,
		`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
}

// ─── GET / — book catalogue ───────────────────────────────────────────────────

const homeTmpl = `
<style>
  .home-progress-bar { transition: width 0.4s ease; }
  .sort-btn { transition: background 0.15s, color 0.15s; }
  .sort-btn.active { background: #78350f; color: #fbbf24; }
  .genre-chip { transition: background 0.15s, color 0.15s, border-color 0.15s; }
  .genre-chip.active { background: #78350f; color: #fbbf24; border-color: #92400e; }
</style>

<div class="max-w-4xl mx-auto px-4 py-8 overflow-hidden" hx-history="false">

  <!-- ── Header ── -->
  <div class="flex items-center justify-between mb-1">
    <h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
    <div class="flex items-center gap-1">
      <a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
         class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
      <a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
         class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
    </div>
  </div>

  <!-- ── Stats strip ── -->
  <div id="stats-strip" class="hidden flex items-center gap-4 text-xs text-zinc-500 mb-5 flex-wrap">
    <span id="stat-books"></span>
    <span class="text-zinc-700">·</span>
    <span id="stat-chapters"></span>
    <span class="text-zinc-700">·</span>
    <span id="stat-time"></span>
    <span id="stat-completed-wrap" class="hidden">
      <span class="text-zinc-700">·</span>
      <span id="stat-completed"></span>
    </span>
  </div>
  <p id="stats-fallback" class="text-zinc-400 text-sm mb-5">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>

  <!-- ── Hero: last-read book (populated by JS) ── -->
  <div id="hero-section" class="hidden mb-6"></div>

  <!-- ── Continue reading (populated by JS) ── -->
  <div id="continue-reading-section" class="hidden mb-6">
    <h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Continue reading</h2>
    <div id="continue-reading-grid" class="grid gap-3 sm:grid-cols-2"></div>
  </div>

  <!-- ── Recently added (populated by JS) ── -->
  <div id="recently-added-section" class="hidden mb-6">
    <h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Recently added</h2>
    <div id="recently-added-grid" class="grid gap-3 sm:grid-cols-2"></div>
  </div>

  <!-- ── Filter + Sort + Genre chips ── -->
  <div class="mb-4 space-y-3">
    <div class="flex gap-2">
      <input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
             autocomplete="off" spellcheck="false"
             class="flex-1 min-w-0 rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
    </div>
    <!-- Sort bar -->
    <div class="flex gap-1.5 flex-wrap" id="sort-bar">
      <button class="sort-btn active text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="alpha">A–Z</button>
      <button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="recent">Recently read</button>
      <button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="added">Recently added</button>
      <button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="downloaded">Most downloaded</button>
    </div>
    <!-- Genre chips (populated by JS) -->
    <div id="genre-chips" class="flex gap-1.5 flex-wrap hidden"></div>
    <p id="filter-count" class="text-xs text-zinc-500 hidden"></p>
  </div>

  <!-- ── All books grid ── -->
  <div id="available-section">
    <h2 id="available-heading" class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3{{if eq (len .Books) 0}} hidden{{end}}">All books</h2>
    <div id="books-grid" class="grid gap-3 sm:grid-cols-2">
      {{range .Books}}
      <a href="/books/{{.Slug}}"
         hx-get="/books/{{.Slug}}"
         hx-target="#main-content"
         hx-push-url="true"
         hx-swap="innerHTML"
         data-slug="{{.Slug}}"
         data-title="{{.Title}}"
         data-downloaded="{{.Downloaded}}"
         data-added="{{.AddedAt}}"
         data-total="{{.TotalChapters}}"
         data-cover="{{.Cover}}"
         data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
         data-genres="{{range .Genres}}{{.}}|{{end}}"
         class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors cursor-pointer min-w-0 overflow-hidden">
        <div class="flex gap-3">
          {{if .Cover}}
          <img src="{{.Cover}}" alt="cover" loading="lazy" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
          {{end}}
          <div class="min-w-0 flex-1">
            <h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate text-sm leading-snug">{{.Title}}</h2>
            {{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
            <!-- progress bar injected by JS -->
            <div class="book-progress-wrap hidden mt-2">
              <div class="h-0.5 rounded-full bg-zinc-700 overflow-hidden">
                <div class="book-progress-bar home-progress-bar h-full bg-amber-500 rounded-full" style="width:0%"></div>
              </div>
              <p class="book-progress-label text-[0.65rem] text-zinc-500 mt-0.5"></p>
            </div>
            <div class="flex gap-1.5 mt-2 flex-wrap items-center">
              {{if .Status}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
              {{if .TotalChapters}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.TotalChapters}} ch</span>{{end}}
              {{if .Downloaded}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-900/60 text-amber-400">{{.Downloaded}} dl</span>{{end}}
              <span class="book-unread-badge hidden text-[0.65rem] px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300 font-medium"></span>
            </div>
          </div>
        </div>
      </a>
      {{else}}
      <p class="text-zinc-500 col-span-2">No books scraped yet.</p>
      {{end}}
    </div>
  </div>
</div>

<script>
(function () {
  /* ── state ─────────────────────────────────────────────────────────────── */
  var progress = {};
  try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
  // progress = { slug: chapterNum, ... }

  var progressTs = {};
  try { progressTs = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
  // progressTs = { slug: timestampMs, ... }

  var booksGrid           = document.getElementById('books-grid');
  var continueSection     = document.getElementById('continue-reading-section');
  var continueGrid        = document.getElementById('continue-reading-grid');
  var recentlyAddedSec    = document.getElementById('recently-added-section');
  var recentlyAddedGrid   = document.getElementById('recently-added-grid');
  var heroSection         = document.getElementById('hero-section');
  var statsStrip          = document.getElementById('stats-strip');
  var statsFallback       = document.getElementById('stats-fallback');
  var genreChipsEl        = document.getElementById('genre-chips');

  var allCards = booksGrid ? Array.from(booksGrid.querySelectorAll('[data-slug]')) : [];

  /* ── reading_progress_ts: record timestamps for "recently read" sort ───── */
  // On first load after upgrade, seed timestamps for existing progress entries.
  var tsUpdated = false;
  Object.keys(progress).forEach(function(slug) {
    if (!progressTs[slug]) { progressTs[slug] = Date.now(); tsUpdated = true; }
  });
  if (tsUpdated) {
    try { localStorage.setItem('reading_progress_ts', JSON.stringify(progressTs)); } catch(_) {}
  }

  /* ── progress bars + unread badges ─────────────────────────────────────── */
  allCards.forEach(function(card) {
    var slug       = card.dataset.slug;
    var total      = parseInt(card.dataset.total, 10) || 0;
    var downloaded = parseInt(card.dataset.downloaded, 10) || 0;
    var chapterNum = progress[slug] ? parseInt(progress[slug], 10) : 0;
    var base       = downloaded || total;

    if (chapterNum > 0 && base > 0) {
      var pct = Math.min(100, Math.round(chapterNum / base * 100));
      var wrap = card.querySelector('.book-progress-wrap');
      var bar  = card.querySelector('.book-progress-bar');
      var lbl  = card.querySelector('.book-progress-label');
      if (wrap && bar && lbl) {
        bar.style.width = pct + '%';
        lbl.textContent = 'Ch.\u00a0' + chapterNum + (base ? '\u00a0/\u00a0' + base : '') + '\u00a0\u2014\u00a0' + pct + '%';
        wrap.classList.remove('hidden');
      }
    }

    // Unread badge: downloaded > last-read chapter
    if (downloaded > 0 && chapterNum > 0 && downloaded > chapterNum) {
      var unread = downloaded - chapterNum;
      var badge  = card.querySelector('.book-unread-badge');
      if (badge) {
        badge.textContent = '+' + unread + ' new';
        badge.classList.remove('hidden');
      }
    }
  });

  /* ── stats strip ─────────────────────────────────────────────────────────── */
  var totalChaptersRead = 0;
  var booksCompleted    = 0;
  Object.keys(progress).forEach(function(slug) {
    var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
    var ch = parseInt(progress[slug], 10) || 0;
    totalChaptersRead += ch;
    if (card) {
      var total = parseInt(card.dataset.total, 10) || 0;
      var dl    = parseInt(card.dataset.downloaded, 10) || 0;
      var base  = dl || total;
      if (base > 0 && ch >= base) booksCompleted++;
    }
  });

  if (totalChaptersRead > 0) {
    var estMins = Math.round(totalChaptersRead * 12); // ~12 min/chapter avg
    var timeLabel = estMins >= 60
      ? Math.floor(estMins / 60) + 'h ' + (estMins % 60) + 'm'
      : estMins + 'm';
    document.getElementById('stat-books').textContent    = allCards.length + ' book' + (allCards.length !== 1 ? 's' : '');
    document.getElementById('stat-chapters').textContent = totalChaptersRead + ' ch read';
    document.getElementById('stat-time').textContent     = '~' + timeLabel + ' read';
    if (booksCompleted > 0) {
      document.getElementById('stat-completed').textContent = booksCompleted + ' completed';
      document.getElementById('stat-completed-wrap').classList.remove('hidden');
    }
    statsStrip.classList.remove('hidden');
    statsStrip.style.display = 'flex';
    if (statsFallback) statsFallback.classList.add('hidden');
  }

  /* ── hero card: most recently read ─────────────────────────────────────── */
  var heroSlug = null, heroTs = 0;
  Object.keys(progressTs).forEach(function(slug) {
    if (progressTs[slug] > heroTs && progress[slug]) {
      heroTs = progressTs[slug]; heroSlug = slug;
    }
  });
  if (heroSlug && heroSection) {
    var heroCard = booksGrid ? booksGrid.querySelector('[data-slug="' + heroSlug + '"]') : null;
    if (heroCard) {
      var heroChapter  = parseInt(progress[heroSlug], 10) || 0;
      var heroTotal    = parseInt(heroCard.dataset.total, 10) || parseInt(heroCard.dataset.downloaded, 10) || 0;
      var heroPct      = heroTotal > 0 ? Math.min(100, Math.round(heroChapter / heroTotal * 100)) : 0;
      var heroCover    = heroCard.dataset.cover || '';
      var heroTitle    = heroCard.dataset.title || '';
      var heroHref     = heroCard.getAttribute('href');
      var heroGet      = heroCard.getAttribute('hx-get');

      heroSection.innerHTML =
        '<a href="' + heroHref + '"' +
        ' hx-get="' + heroGet + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
        ' class="group relative block rounded-2xl overflow-hidden border border-zinc-700 hover:border-amber-500 transition-colors cursor-pointer"' +
        ' style="' + (heroCover ? 'background-image:linear-gradient(to right,#09090b 40%,rgba(9,9,11,0.4)),url(' + heroCover + ');background-size:cover;background-position:right center;' : 'background:#18181b;') + '">' +
        '<div class="relative z-10 p-5 sm:p-6">' +
          '<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-amber-500 mb-1">Continue reading</p>' +
          '<h2 class="text-xl sm:text-2xl font-bold text-zinc-100 group-hover:text-amber-400 transition-colors leading-snug mb-2 max-w-sm">' + escHtml(heroTitle) + '</h2>' +
          '<p class="text-sm text-zinc-400 mb-4">Chapter\u00a0' + heroChapter + (heroTotal ? '\u00a0/\u00a0' + heroTotal : '') + (heroPct ? '\u00a0\u2014\u00a0' + heroPct + '%' : '') + '</p>' +
          '<div class="w-48 h-1 rounded-full bg-zinc-700 overflow-hidden mb-4">' +
            '<div class="h-full bg-amber-500 rounded-full" style="width:' + heroPct + '%"></div>' +
          '</div>' +
          '<span class="inline-block text-sm font-semibold px-4 py-2 rounded-xl bg-amber-500 text-zinc-900 group-hover:bg-amber-400 transition-colors">Resume</span>' +
        '</div>' +
        '</a>';
      heroSection.classList.remove('hidden');
      htmx.process(heroSection);
    }
  }

  /* ── continue reading (excluding hero) ──────────────────────────────────── */
  if (continueGrid) continueGrid.innerHTML = '';
  var inProgress = Object.keys(progress).sort(function(a,b) {
    return (progressTs[b]||0) - (progressTs[a]||0);
  });
  var moved = 0;
  inProgress.forEach(function(slug) {
    if (slug === heroSlug) return; // hero already shown
    var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
    if (!card) return;
    var a = buildContinueCard(card, progress[slug]);
    continueGrid.appendChild(a);
    moved++;
  });
  if (moved > 0) continueSection.classList.remove('hidden');

  /* ── recently added (top 4, excluding books already in continue reading) ── */
  if (recentlyAddedGrid) recentlyAddedGrid.innerHTML = '';
  var progressSlugs = new Set(Object.keys(progress));
  var byAdded = allCards.slice().sort(function(a, b) {
    return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
  });
  var addedShown = 0;
  byAdded.forEach(function(card) {
    if (addedShown >= 4) return;
    if (progressSlugs.has(card.dataset.slug)) return; // already in continue reading
    recentlyAddedGrid.appendChild(buildSimpleCard(card));
    addedShown++;
  });
  if (addedShown > 0) recentlyAddedSec.classList.remove('hidden');

  /* ── genre chips ──────────────────────────────────────────────────────── */
  var allGenres = {};
  allCards.forEach(function(card) {
    (card.dataset.genres || '').split('|').forEach(function(g) {
      g = g.trim();
      if (g) allGenres[g] = (allGenres[g] || 0) + 1;
    });
  });
  var sortedGenres = Object.keys(allGenres).sort(function(a,b) { return allGenres[b]-allGenres[a]; }).slice(0, 12);
  var activeGenres = new Set();
  if (sortedGenres.length > 1 && genreChipsEl) {
    sortedGenres.forEach(function(g) {
      var btn = document.createElement('button');
      btn.className = 'genre-chip text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400';
      btn.textContent = g;
      btn.addEventListener('click', function() {
        if (activeGenres.has(g)) { activeGenres.delete(g); btn.classList.remove('active'); }
        else { activeGenres.add(g); btn.classList.add('active'); }
        applyFilters();
      });
      genreChipsEl.appendChild(btn);
    });
    genreChipsEl.classList.remove('hidden');
  }

  /* ── sort ─────────────────────────────────────────────────────────────── */
  var currentSort = 'alpha';
  document.querySelectorAll('.sort-btn').forEach(function(btn) {
    btn.addEventListener('click', function() {
      document.querySelectorAll('.sort-btn').forEach(function(b) { b.classList.remove('active'); });
      btn.classList.add('active');
      currentSort = btn.dataset.sort;
      applySortAndFilter();
    });
  });

  /* ── filter ───────────────────────────────────────────────────────────── */
  var input   = document.getElementById('filter-input');
  var countEl = document.getElementById('filter-count');
  if (input) input.addEventListener('input', applyFilters);

  function applyFilters() { applySortAndFilter(); }

  function applySortAndFilter() {
    var q = input ? input.value.trim().toLowerCase() : '';
    // Sort cards
    var sorted = allCards.slice().sort(function(a, b) {
      if (currentSort === 'alpha')      return (a.dataset.title||'').localeCompare(b.dataset.title||'');
      if (currentSort === 'downloaded') return (parseInt(b.dataset.downloaded,10)||0) - (parseInt(a.dataset.downloaded,10)||0);
      if (currentSort === 'added')      return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
      if (currentSort === 'recent') {
        var ta = progressTs[a.dataset.slug] || 0;
        var tb = progressTs[b.dataset.slug] || 0;
        if (tb !== ta) return tb - ta;
        return (a.dataset.title||'').localeCompare(b.dataset.title||'');
      }
      return 0;
    });
    // Re-append in sorted order, apply visibility
    var shown = 0;
    sorted.forEach(function(card) {
      var matchText  = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
      var matchGenre = activeGenres.size === 0 || (function() {
        var cardGenres = (card.dataset.genres||'').split('|').map(function(g){return g.trim();});
        return Array.from(activeGenres).every(function(g) { return cardGenres.indexOf(g) !== -1; });
      })();
      var match = matchText && matchGenre;
      card.style.display = match ? '' : 'none';
      if (match) shown++;
      booksGrid.appendChild(card); // re-order in DOM
    });
    if ((q || activeGenres.size > 0) && countEl) {
      countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
      countEl.classList.remove('hidden');
    } else if (countEl) {
      countEl.classList.add('hidden');
    }
  }

  /* ── helpers ──────────────────────────────────────────────────────────── */
  function escHtml(s) {
    return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function buildSimpleCard(card) {
    var a = document.createElement('a');
    a.href = card.getAttribute('href');
    a.setAttribute('hx-get',      card.getAttribute('hx-get'));
    a.setAttribute('hx-target',   card.getAttribute('hx-target'));
    a.setAttribute('hx-push-url', card.getAttribute('hx-push-url'));
    a.setAttribute('hx-swap',     card.getAttribute('hx-swap'));
    a.setAttribute('data-slug',   card.dataset.slug);
    a.className = card.className;
    a.innerHTML = card.innerHTML;
    htmx.process(a);
    return a;
  }

  function buildContinueCard(card, chapterNum) {
    var a = buildSimpleCard(card);
    if (chapterNum) {
      var meta = a.querySelector('.min-w-0');
      if (meta) {
        var old = meta.querySelector('.chapter-progress-line');
        if (old) old.parentNode.removeChild(old);
        var chLine = document.createElement('p');
        chLine.className = 'chapter-progress-line text-xs text-amber-400 mt-1';
        chLine.textContent = 'Chapter ' + chapterNum;
        var author = meta.querySelector('p');
        if (author) author.insertAdjacentElement('afterend', chLine);
        else {
          var title = meta.querySelector('h2');
          if (title) title.insertAdjacentElement('afterend', chLine);
        }
      }
    }
    // dismiss button
    var dismissBtn = document.createElement('button');
    dismissBtn.type = 'button';
    dismissBtn.title = 'Remove from continue reading';
    dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
    dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
    dismissBtn.addEventListener('click', function(e) {
      e.preventDefault(); e.stopPropagation();
      var cardEl   = this.closest('[data-slug]');
      var cardSlug = cardEl ? cardEl.dataset.slug : null;
      if (cardSlug) {
        var p = {}; try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
        delete p[cardSlug];
        localStorage.setItem('reading_progress', JSON.stringify(p));
        var ts = {}; try { ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
        delete ts[cardSlug];
        localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
      }
      var target = cardEl || this.parentElement;
      target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
      target.style.transform  = 'translateX(-60px)';
      target.style.opacity    = '0';
      setTimeout(function() {
        target.remove();
        var remaining = continueGrid.querySelectorAll('[data-slug]');
        if (remaining.length === 0) continueSection.classList.add('hidden');
      }, 260);
    });
    a.style.position = 'relative';
    a.appendChild(dismissBtn);
    return a;
  }

}());
</script>`

// homeBookItem wraps BookMeta with the count of chapters already on disk
// and the Unix timestamp of when the book was added (metadata.yaml mtime).
type homeBookItem struct {
	scraper.BookMeta
	Downloaded int
	AddedAt    int64
}

func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}

	books, err := s.writer.ListBooks()
	if err != nil {
		http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
		return
	}

	items := make([]homeBookItem, len(books))
	for i, b := range books {
		items[i] = homeBookItem{
			BookMeta:   b,
			Downloaded: s.writer.CountChapters(b.Slug),
			AddedAt:    s.writer.MetadataMtime(b.Slug),
		}
	}

	t := template.Must(template.New("home").Parse(homeTmpl))
	var buf bytes.Buffer
	_ = t.Execute(&buf, struct {
		Books interface{}
	}{
		Books: items,
	})

	s.respond(w, r, "Home", buf.String())
}

// ─── GET /scrape — add a new book ─────────────────────────────────────────────

const scrapeTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
  <a href="/"
     hx-get="/"
     hx-target="#main-content"
     hx-push-url="true"
     hx-swap="innerHTML"
     class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
    ← All books
  </a>

  <h1 class="text-3xl font-bold text-zinc-100 mb-1">Add a book</h1>
  <p class="text-zinc-400 text-sm mb-8">Search the rankings or paste a novelfire.net URL to scrape a new book.</p>

  <form id="scrape-form"
        hx-post="/ui/scrape/book"
        hx-target="#scrape-status"
        hx-swap="innerHTML">
    <div class="relative mb-3" id="scrape-search-wrap">
      <input type="text"
             id="scrape-search"
             autocomplete="off"
             spellcheck="false"
             placeholder="Search rankings or paste a URL…"
             class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
      <!-- hidden url field submitted to HTMX -->
      <input type="url" name="url" id="scrape-url" required class="hidden" />
      <!-- dropdown -->
      <ul id="scrape-dropdown"
          class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
      </ul>
    </div>
    <button type="submit"
            class="px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors">
      Scrape
    </button>
  </form>
  <div id="scrape-status" class="mt-4"></div>
</div>

<script>
(function () {
  var RANKING = {{.RankingJSON}};
  if (!RANKING || !RANKING.length) return;

  var searchInput = document.getElementById('scrape-search');
  var urlInput    = document.getElementById('scrape-url');
  var dropdown    = document.getElementById('scrape-dropdown');
  var form        = document.getElementById('scrape-form');
  if (!searchInput || !urlInput || !dropdown || !form) return;

  var activeIdx = -1;

  function syncURLField(val) {
    val = val.trim();
    if (/^https?:\/\//i.test(val)) {
      urlInput.value = val;
    } else {
      urlInput.value = '';
    }
  }

  function closeDrop() {
    dropdown.classList.add('hidden');
    dropdown.innerHTML = '';
    activeIdx = -1;
  }

  function selectItem(item) {
    var url = item.source_url || '';
    searchInput.value = url;
    urlInput.value    = url;
    closeDrop();
  }

  function buildItem(item, q) {
    var li = document.createElement('li');
    li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
    li.dataset.url = item.source_url || '';

    if (item.cover) {
      var img = document.createElement('img');
      img.src = item.cover;
      img.alt = '';
      img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
      li.appendChild(img);
    } else {
      var ph = document.createElement('div');
      ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
      li.appendChild(ph);
    }

    var txt = document.createElement('div');
    txt.className = 'min-w-0 flex-1';

    var title = document.createElement('p');
    title.className = 'text-sm font-medium text-zinc-100 truncate';
    title.textContent = item.title || '';
    txt.appendChild(title);

    if (item.author) {
      var author = document.createElement('p');
      author.className = 'text-xs text-zinc-400 truncate mt-0.5';
      author.textContent = item.author;
      txt.appendChild(author);
    }

    if (item.source_url) {
      var urlHint = document.createElement('p');
      urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
      urlHint.textContent = item.source_url;
      txt.appendChild(urlHint);
    }

    var meta = document.createElement('div');
    meta.className = 'flex gap-1.5 mt-1 flex-wrap';
    if (item.status) {
      var s = document.createElement('span');
      s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
      s.textContent = item.status;
      meta.appendChild(s);
    }
    if (item.rank) {
      var r = document.createElement('span');
      r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
      r.textContent = '#' + item.rank;
      meta.appendChild(r);
    }
    txt.appendChild(meta);
    li.appendChild(txt);

    return li;
  }

  function setActive(idx, items) {
    var lis = dropdown.querySelectorAll('li');
    lis.forEach(function (li, i) {
      if (i === idx) li.classList.add('bg-zinc-800');
      else li.classList.remove('bg-zinc-800');
    });
    activeIdx = idx;
    if (idx >= 0 && idx < items.length) {
      var url = items[idx].source_url || '';
      searchInput.value = url;
      urlInput.value    = url;
    }
  }

  function showDrop(items, q) {
    dropdown.innerHTML = '';
    activeIdx = -1;
    if (!items.length) { closeDrop(); return; }
    items.forEach(function (item, i) {
      var li = buildItem(item, q);
      li.addEventListener('mousedown', function (e) {
        e.preventDefault();
        selectItem(item);
      });
      dropdown.appendChild(li);
    });
    dropdown.classList.remove('hidden');
  }

  function filterRanking(q) {
    return RANKING.filter(function (item) {
      var genres = (item.genres || []).join(' ');
      var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '') + ' ' + genres).toLowerCase();
      return haystack.indexOf(q) !== -1;
    }).slice(0, 10);
  }

  searchInput.addEventListener('input', function () {
    var q = searchInput.value.trim().toLowerCase();
    syncURLField(searchInput.value);
    if (!q) { closeDrop(); return; }
    if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
    showDrop(filterRanking(q), q);
  });

  searchInput.addEventListener('keydown', function (e) {
    var q = searchInput.value.trim().toLowerCase();
    var items = /^https?:\/\//i.test(q) ? [] : filterRanking(q);

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setActive(Math.min(activeIdx + 1, items.length - 1), items);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActive(Math.max(activeIdx - 1, 0), items);
    } else if (e.key === 'Enter' && activeIdx >= 0) {
      e.preventDefault();
      if (items[activeIdx]) selectItem(items[activeIdx]);
    } else if (e.key === 'Escape') {
      closeDrop();
    }
  });

  form.addEventListener('htmx:configRequest', function (e) {
    var val = searchInput.value.trim();
    if (!urlInput.value && /^https?:\/\//i.test(val)) {
      urlInput.value = val;
    }
  });

  form.addEventListener('submit', function () {
    var val = searchInput.value.trim();
    if (!urlInput.value && /^https?:\/\//i.test(val)) {
      urlInput.value = val;
    }
  });

  document.addEventListener('mousedown', function (e) {
    if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
      closeDrop();
    }
  });
}());
</script>`

func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
	rankingItems, _ := s.writer.ReadRankingItems()
	rankingJSON, _ := json.Marshal(rankingItems)

	t := template.Must(template.New("scrape").Parse(scrapeTmpl))
	var buf bytes.Buffer
	_ = t.Execute(&buf, struct {
		RankingJSON template.JS
	}{
		RankingJSON: template.JS(rankingJSON),
	})

	s.respond(w, r, "Add a book", buf.String())
}

// ─── GET /ranking — ranking page ───────────────────────────────────────────────

const rankingTmpl = `
<style>
  .facet-btn.active { background: rgba(245,158,11,0.12); color: #f59e0b; border-color: #92400e; }
  .ranking-row { transition: background 0.12s, border-color 0.12s; }
  .ranking-row:hover { background: rgba(255,255,255,0.03); }
  .add-btn { transition: background 0.15s, color 0.15s; }
  #ranking-filter-bar { position: sticky; top: 0; z-index: 20; background: #09090b; border-bottom: 1px solid #27272a; }
</style>

<div class="max-w-3xl mx-auto overflow-hidden">

  <!-- Cover zoom overlay -->
  <div id="cover-zoom-overlay"
       class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
       onclick="this.classList.add('hidden');this.classList.remove('flex');document.getElementById('cover-zoom-img').src='';">
    <img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
  </div>

  <!-- Header (non-sticky) -->
  <div class="px-4 pt-6 pb-4">
    <a href="/" hx-get="/" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
       class="text-xs text-zinc-500 hover:text-amber-400 mb-4 inline-flex items-center gap-1">
      ← All books
    </a>
    <div class="flex items-end justify-between gap-3 mt-3 flex-wrap">
      <div>
        <h1 class="text-2xl font-bold text-zinc-100 leading-tight">Novel Rankings</h1>
        <div class="flex items-center gap-2 mt-1 flex-wrap text-xs text-zinc-500">
          {{if .TotalItems}}<span id="ranking-shown-count">{{.TotalItems}} novels</span>{{end}}
          {{if .CachedAt}}<span>· cached {{.CachedAt}}</span>{{end}}
          <span id="ranking-lib-count" class="text-teal-500 hidden"></span>
        </div>
      </div>
      <form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
        <input type="hidden" name="pages" value="5">
        <button type="submit"
                class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-amber-600 hover:text-amber-400 transition-colors">
          <svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
          </svg>
          Refresh
        </button>
      </form>
    </div>
    <div id="ranking-refresh-status" class="mt-2"></div>
  </div>

  <!-- Sticky filter bar -->
  <div id="ranking-filter-bar" class="px-4 py-3 space-y-2.5">
    <!-- Search -->
    <div class="relative">
      <svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 105 11a6 6 0 0012 0z"/>
      </svg>
      <input id="ranking-filter" type="search" placeholder="Search title or author…"
             autocomplete="off" spellcheck="false"
             class="w-full rounded-lg bg-zinc-900 border border-zinc-700 pl-8 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-amber-500 transition-colors" />
    </div>

    <!-- Filter chips row -->
    <div class="flex items-center gap-1.5 flex-wrap">
      <span class="text-[0.65rem] text-zinc-600 uppercase tracking-wide mr-0.5">Filter:</span>
      <button type="button" data-facet="local" data-value="1"
              class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors font-medium">
        In library
      </button>
      {{range .AllStatuses}}
      <button type="button" data-facet="status" data-value="{{.}}"
              class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
        {{.}}
      </button>
      {{end}}
      {{range .TopGenres}}
      <button type="button" data-facet="genre" data-value="{{.}}"
              class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
        {{.}}
      </button>
      {{end}}
      <button type="button" id="ranking-clear-filters"
              class="hidden text-xs px-2 py-0.5 rounded-full text-zinc-600 hover:text-zinc-300 transition-colors">
        ✕ clear
      </button>
    </div>
  </div>

  <!-- List -->
  <div id="ranking-grid" class="divide-y divide-zinc-800/60">
    {{range .Books}}
    {{if .Local}}
    <a href="/books/{{.Slug}}"
       hx-get="/books/{{.Slug}}"
       hx-target="#main-content"
       hx-push-url="true"
       hx-swap="innerHTML"
       data-filter="{{.Title}} {{.Author}} {{range .Genres}}{{.}} {{end}}"
       data-status="{{.Status}}"
       data-genres="{{range .Genres}}{{.}}|{{end}}"
       data-local="1"
       class="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">
      <!-- Rank -->
      <div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
        {{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
      </div>
      <!-- Cover -->
      <div class="flex-shrink-0">
        {{if .Cover}}
        <img src="{{.Cover}}" alt="cover" loading="lazy"
             class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
             onclick="event.preventDefault();event.stopPropagation();(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
        {{else}}
        <div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
        {{end}}
      </div>
      <!-- Info -->
      <div class="min-w-0 flex-1 py-0.5">
        <div class="flex items-start justify-between gap-2">
          <h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">{{.Title}}</h2>
          <span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>
        </div>
        {{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
        <div class="flex gap-1 flex-wrap items-center mt-1.5">
          {{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-teal-900/60 text-teal-400">{{.Status}}</span>{{end}}
          {{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
        </div>
      </div>
    </a>
    {{else}}
    <div data-filter="{{.Title}} {{.Author}} {{range .Genres}}{{.}} {{end}}"
         data-status="{{.Status}}"
         data-genres="{{range .Genres}}{{.}}|{{end}}"
         data-local="0"
         {{if .SourceURL}}data-scrape-url="{{.SourceURL}}"{{end}}
         class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">
      <!-- Rank -->
      <div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
        {{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
      </div>
      <!-- Cover -->
      <div class="flex-shrink-0">
        {{if .Cover}}
        <img src="{{.Cover}}" alt="cover" loading="lazy"
             class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
             onclick="event.stopPropagation();(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
        {{else}}
        <div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
        {{end}}
      </div>
      <!-- Info -->
      <div class="min-w-0 flex-1 py-0.5">
        <h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">{{.Title}}</h2>
        {{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
        <div class="flex gap-1 flex-wrap items-center mt-1.5">
          {{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
          {{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
        </div>
        {{if .SourceURL}}
        <div class="flex items-center gap-2 mt-2">
          <button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors"
                  data-action="add" data-url="{{.SourceURL}}">
            + Add to library
          </button>
          <button onclick="event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
                  class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">
            source ↗
          </button>
        </div>
        {{end}}
      </div>
    </div>
    {{end}}
    {{else}}
    <div class="px-4 py-16 text-center">
      <p class="text-zinc-500 mb-4 text-sm">No ranking data cached yet.</p>
      <form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
        <input type="hidden" name="pages" value="5">
        <button type="submit" class="text-sm px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-zinc-900 font-semibold transition-colors">
          Fetch rankings from novelfire.net
        </button>
      </form>
    </div>
    {{end}}
  </div>

  <!-- Load more -->
  <div id="load-more-wrap" class="px-4 py-6 text-center hidden">
    <button id="load-more-btn"
            class="text-sm px-5 py-2 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-amber-600 hover:text-amber-400 transition-colors">
      Show more
    </button>
    <p id="load-more-label" class="text-xs text-zinc-600 mt-2"></p>
  </div>
</div>

<script>
(function () {
  var ALL_ITEMS  = {{.AllItemsJSON}};
  var PAGE_SIZE  = 40;

  var input       = document.getElementById('ranking-filter');
  var clearBtn    = document.getElementById('ranking-clear-filters');
  var grid        = document.getElementById('ranking-grid');
  var loadWrap    = document.getElementById('load-more-wrap');
  var loadBtn     = document.getElementById('load-more-btn');
  var loadLabel   = document.getElementById('load-more-label');
  var libCount    = document.getElementById('ranking-lib-count');
  var shownCount  = document.getElementById('ranking-shown-count');
  if (!grid) return;

  // Show library count
  var inLib = ALL_ITEMS.filter(function(it){ return it.local; }).length;
  if (inLib > 0 && libCount) {
    libCount.textContent = '· ' + inLib + ' in library';
    libCount.classList.remove('hidden');
  }

  var active = { status: new Set(), genre: new Set(), local: new Set() };
  var visibleCount = 0;
  var currentItems = [];

  function esc(s) {
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function buildRow(it) {
    var genres   = (it.genres || []).slice(0, 5);
    var genreStr = genres.join('|') + (genres.length ? '|' : '');
    var rankHtml = it.rank
      ? '<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5"><span class="text-sm font-bold text-amber-400">#' + it.rank + '</span></div>'
      : '<div class="w-8 flex-shrink-0"></div>';
    var coverHtml = it.cover
      ? '<div class="flex-shrink-0"><img src="' + esc(it.cover) + '" alt="cover" loading="lazy" class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"' +
        ' onclick="event.preventDefault();event.stopPropagation();(function(src){var o=document.getElementById(\'cover-zoom-overlay\');document.getElementById(\'cover-zoom-img\').src=src;o.classList.remove(\'hidden\');o.classList.add(\'flex\');})(this.src)"></div>'
      : '<div class="flex-shrink-0"><div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">' + esc(it.rank ? '#'+it.rank : '?') + '</div></div>';
    var statusBadge = it.status
      ? '<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full ' + (it.local ? 'bg-teal-900/60 text-teal-400' : 'bg-zinc-800 text-zinc-400') + '">' + esc(it.status) + '</span>'
      : '';
    var genreBadges = genres.map(function(g) {
      return '<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">' + esc(g) + '</span>';
    }).join('');

    if (it.local) {
      return '<a href="/books/' + esc(it.slug) + '"' +
        ' hx-get="/books/' + esc(it.slug) + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
        ' data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
        ' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="1"' +
        ' class="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">' +
        rankHtml + coverHtml +
        '<div class="min-w-0 flex-1 py-0.5">' +
          '<div class="flex items-start justify-between gap-2">' +
            '<h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
            '<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>' +
          '</div>' +
          (it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
          '<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
        '</div></a>';
    } else {
      var addBtn = it.source_url
        ? '<button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors" data-action="add" data-url="' + esc(it.source_url) + '">+ Add to library</button>'
        : '';
      var srcBtn = it.source_url
        ? '<button onclick="event.stopPropagation();window.open(\'' + it.source_url.replace(/'/g,"\\'") + '\',\'_blank\',\'noopener,noreferrer\')" class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">source ↗</button>'
        : '';
      var actionRow = (addBtn || srcBtn)
        ? '<div class="flex items-center gap-2 mt-2">' + addBtn + srcBtn + '</div>'
        : '';
      return '<div data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
        ' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="0"' +
        (it.source_url ? ' data-scrape-url="' + esc(it.source_url) + '"' : '') +
        ' class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">' +
        rankHtml + coverHtml +
        '<div class="min-w-0 flex-1 py-0.5">' +
          '<h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
          (it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
          '<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
          actionRow +
        '</div></div>';
    }
  }

  function isFiltering() {
    return (input && input.value.trim() !== '') ||
           active.status.size > 0 || active.genre.size > 0 || active.local.size > 0;
  }

  function updateShownCount(n, total) {
    if (!shownCount) return;
    if (isFiltering()) {
      shownCount.textContent = n + ' of ' + total + ' matched';
    } else {
      shownCount.textContent = total + ' novels';
    }
  }

  function applyFilters() {
    var q = input ? input.value.trim().toLowerCase() : '';
    currentItems = ALL_ITEMS.filter(function(it) {
      var txt = ((it.title||'') + ' ' + (it.author||'')).toLowerCase();
      if (q && txt.indexOf(q) === -1) return false;
      if (active.status.size > 0) {
        var hit = false;
        active.status.forEach(function(v){ if ((it.status||'').toLowerCase() === v.toLowerCase()) hit = true; });
        if (!hit) return false;
      }
      if (active.genre.size > 0) {
        var gs = (it.genres||[]).map(function(g){ return g.toLowerCase(); });
        var ok = true;
        active.genre.forEach(function(v){ if (gs.indexOf(v.toLowerCase()) === -1) ok = false; });
        if (!ok) return false;
      }
      if (active.local.size > 0 && !it.local) return false;
      return true;
    });

    visibleCount = 0;
    grid.innerHTML = '';
    renderMore();

    if (clearBtn) {
      if (isFiltering()) clearBtn.classList.remove('hidden');
      else clearBtn.classList.add('hidden');
    }
  }

  function renderMore() {
    var batch = currentItems.slice(visibleCount, visibleCount + PAGE_SIZE);
    var frag  = document.createDocumentFragment();
    batch.forEach(function(it) {
      var tmp = document.createElement('div');
      tmp.innerHTML = buildRow(it);
      var el = tmp.firstElementChild;
      frag.appendChild(el);
    });
    grid.appendChild(frag);
    htmx.process(grid);
    visibleCount += batch.length;
    var remaining = currentItems.length - visibleCount;
    updateShownCount(visibleCount, currentItems.length);
    if (remaining > 0) {
      loadWrap.classList.remove('hidden');
      loadLabel.textContent = visibleCount + ' of ' + currentItems.length + ' shown · ' + remaining + ' more';
    } else {
      loadWrap.classList.add('hidden');
    }
  }

  if (loadBtn) loadBtn.addEventListener('click', renderMore);

  // Initial render from ALL_ITEMS
  currentItems = ALL_ITEMS;
  grid.innerHTML = '';
  renderMore();

  // Facet chips
  document.querySelectorAll('.facet-btn').forEach(function(btn) {
    btn.addEventListener('click', function() {
      var facet = btn.dataset.facet;
      var val   = btn.dataset.value;
      if (!active[facet]) return;
      if (active[facet].has(val)) {
        active[facet].delete(val);
        btn.classList.remove('active');
      } else {
        active[facet].add(val);
        btn.classList.add('active');
      }
      applyFilters();
    });
  });

  if (input) input.addEventListener('input', applyFilters);

  if (clearBtn) {
    clearBtn.addEventListener('click', function() {
      if (input) input.value = '';
      ['status','genre','local'].forEach(function(f){ active[f].clear(); });
      document.querySelectorAll('.facet-btn').forEach(function(b){ b.classList.remove('active'); });
      applyFilters();
    });
  }

  /* ── Add to library ─────────────────────────────────────────────────────── */
  function submitScrape(url, cardEl) {
    var badge = document.createElement('div');
    badge.className = 'flex items-center text-sm px-4 py-3 text-amber-300 bg-amber-950/60 border-l-2 border-amber-600';
    badge.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>Adding to library…';
    badge.setAttribute('hx-get', '/ui/scrape/status');
    badge.setAttribute('hx-trigger', 'every 3s');
    badge.setAttribute('hx-target', 'this');
    badge.setAttribute('hx-swap', 'outerHTML');
    if (cardEl) {
      cardEl.replaceWith(badge);
    } else {
      grid.prepend(badge);
    }
    htmx.process(badge);
    var body = new URLSearchParams();
    body.set('url', url);
    fetch('/ui/scrape/book', { method: 'POST', body: body });
  }

  grid.addEventListener('click', function(e) {
    var btn = e.target.closest('[data-action="add"]');
    if (!btn) return;
    e.stopPropagation();
    var url = btn.dataset.url;
    if (!url) return;
    var card = btn.closest('.ranking-card');
    if (btn.dataset.confirm === '1') {
      submitScrape(url, card);
    } else {
      btn.dataset.confirm = '1';
      btn.textContent = 'Confirm?';
      btn.classList.add('bg-amber-600','text-zinc-900','border-amber-600');
      btn.classList.remove('bg-zinc-800','text-zinc-400','border-zinc-700');
      setTimeout(function() {
        if (btn.dataset.confirm === '1') {
          btn.dataset.confirm = '';
          btn.textContent = '+ Add to library';
          btn.classList.remove('bg-amber-600','text-zinc-900','border-amber-600');
          btn.classList.add('bg-zinc-800','text-zinc-400','border-zinc-700');
        }
      }, 3000);
    }
  });

}());
</script>`

// rankingViewItem enriches a RankingItem with whether it is present in the
// local book library, so the template can highlight it differently.
type rankingViewItem struct {
	writer.RankingItem
	Local bool
}

// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
	out := make([]rankingViewItem, len(items))
	for i, it := range items {
		out[i] = rankingViewItem{
			RankingItem: it,
			Local:       localSlugs[it.Slug],
		}
	}
	return out
}

// pageNum is one entry in the ranking pagination bar.
// Num == 0 is a sentinel that renders as an ellipsis gap.
type pageNum struct {
	Num int
}

// rankingPageNums builds a pagination list with smart ellipsis.
// It always shows: first 2, last 2, and a ±2 window around current.
// Gaps between non-consecutive runs are filled with a sentinel (Num==0) for "…".
// Pass current=0 when there is no concept of a current page (e.g. fetch bar).
func rankingPageNums(total, current int) []pageNum {
	if total <= 0 {
		return nil
	}
	show := make(map[int]bool)
	// First 2 and last 2.
	for i := 1; i <= 2 && i <= total; i++ {
		show[i] = true
	}
	for i := total - 1; i <= total; i++ {
		if i >= 1 {
			show[i] = true
		}
	}
	// ±2 window around current page.
	if current > 0 {
		for i := current - 2; i <= current+2; i++ {
			if i >= 1 && i <= total {
				show[i] = true
			}
		}
	}

	// Collect and sort.
	pages := make([]int, 0, len(show))
	for p := range show {
		pages = append(pages, p)
	}
	for i := 0; i < len(pages); i++ {
		for j := i + 1; j < len(pages); j++ {
			if pages[j] < pages[i] {
				pages[i], pages[j] = pages[j], pages[i]
			}
		}
	}

	// Build output with ellipsis sentinels between non-consecutive pages.
	out := make([]pageNum, 0, len(pages)*2)
	for i, p := range pages {
		if i > 0 && p > pages[i-1]+1 {
			out = append(out, pageNum{0})
		}
		out = append(out, pageNum{p})
	}
	return out
}

const rankingPageSize = 20

// handleRanking serves the ranking page from the cached ranking.json file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
// Supports ?page=N for browsing through cached items (20 per page).
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
	rankingItems, err := s.writer.ReadRankingItems()
	if err != nil {
		s.log.Error("failed to read cached ranking", "err", err)
	}

	cachedAt := ""
	if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
		cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
	}

	// Parse requested display page (1-indexed).
	currentPage := 1
	if p := r.URL.Query().Get("page"); p != "" {
		if n, err2 := strconv.Atoi(p); err2 == nil && n > 0 {
			currentPage = n
		}
	}

	totalItems := len(rankingItems)
	totalPages := 1
	if totalItems > 0 {
		totalPages = (totalItems + rankingPageSize - 1) / rankingPageSize
	}
	if currentPage > totalPages {
		currentPage = totalPages
	}

	// Slice items for the current display page.
	start := (currentPage - 1) * rankingPageSize
	end := start + rankingPageSize
	if end > totalItems {
		end = totalItems
	}
	pageItems := rankingItems
	if totalItems > 0 {
		pageItems = rankingItems[start:end]
	}

	t := template.Must(template.New("ranking").Parse(rankingTmpl))
	var buf bytes.Buffer

	// Collect distinct genres and statuses across ALL items for facet filters.
	genreFreq := map[string]int{}
	statusSet := map[string]bool{}
	for _, it := range rankingItems {
		if it.Status != "" {
			statusSet[it.Status] = true
		}
		for _, g := range it.Genres {
			genreFreq[g]++
		}
	}
	allStatuses := sortedKeys(statusSet)

	// Build top-10 genres sorted by frequency descending.
	type genreCount struct {
		name  string
		count int
	}
	gcSlice := make([]genreCount, 0, len(genreFreq))
	for g, c := range genreFreq {
		gcSlice = append(gcSlice, genreCount{g, c})
	}
	sort.Slice(gcSlice, func(i, j int) bool {
		if gcSlice[i].count != gcSlice[j].count {
			return gcSlice[i].count > gcSlice[j].count
		}
		return gcSlice[i].name < gcSlice[j].name
	})
	topN := 10
	if len(gcSlice) < topN {
		topN = len(gcSlice)
	}
	topGenres := make([]string, topN)
	for i := 0; i < topN; i++ {
		topGenres[i] = gcSlice[i].name
	}

	// Encode full dataset + local slugs for client-side cross-page filtering.
	localSlugs := s.writer.LocalSlugs()
	type rankingJSONItem struct {
		Rank      int      `json:"rank"`
		Slug      string   `json:"slug"`
		Title     string   `json:"title"`
		Author    string   `json:"author,omitempty"`
		Cover     string   `json:"cover,omitempty"`
		Status    string   `json:"status,omitempty"`
		Genres    []string `json:"genres,omitempty"`
		SourceURL string   `json:"source_url,omitempty"`
		Local     bool     `json:"local"`
	}
	allItemsForJS := make([]rankingJSONItem, len(rankingItems))
	for i, it := range rankingItems {
		allItemsForJS[i] = rankingJSONItem{
			Rank:      it.Rank,
			Slug:      it.Slug,
			Title:     it.Title,
			Author:    it.Author,
			Cover:     it.Cover,
			Status:    it.Status,
			Genres:    it.Genres,
			SourceURL: it.SourceURL,
			Local:     localSlugs[it.Slug],
		}
	}
	allItemsJSON, _ := json.Marshal(allItemsForJS)

	_ = t.Execute(&buf, struct {
		Books        interface{}
		CachedAt     string
		FetchNums    []pageNum
		DisplayNums  []pageNum
		CurrentPage  int
		TotalPages   int
		TotalItems   int
		TopGenres    []string
		AllStatuses  []string
		AllItemsJSON template.JS
	}{
		Books:        toRankingViewItems(pageItems, localSlugs),
		CachedAt:     cachedAt,
		FetchNums:    rankingPageNums(100, 0),
		DisplayNums:  rankingPageNums(totalPages, currentPage),
		CurrentPage:  currentPage,
		TotalPages:   totalPages,
		TotalItems:   totalItems,
		TopGenres:    topGenres,
		AllStatuses:  allStatuses,
		AllItemsJSON: template.JS(allItemsJSON),
	})
	s.respond(w, r, "Rankings", buf.String())
}

// handleRankingRefresh starts an async scrape of novelfire.net/ranking and
// immediately returns a polling badge. The browser polls /ui/ranking/status
// until the job finishes, then follows an HX-Redirect back to /ranking.
//
// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means
// fetch all pages; otherwise at most that many pages are scraped.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	maxPages := 0
	if p := strings.TrimSpace(r.FormValue("pages")); p != "" {
		if n, err := strconv.Atoi(p); err == nil && n > 0 {
			maxPages = n
		}
	}

	s.mu.Lock()
	if s.rankingRunning {
		s.mu.Unlock()
		renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…"))
		return
	}
	s.rankingRunning = true
	s.mu.Unlock()

	go func() {
		defer func() {
			s.mu.Lock()
			s.rankingRunning = false
			s.mu.Unlock()
		}()

		// Allow ~90 s per page; minimum 120 s for a single page.
		timeout := 120 * time.Second
		if maxPages > 1 {
			timeout = time.Duration(maxPages) * 90 * time.Second
		}
		ctx, cancel := context.WithTimeout(context.Background(), timeout)
		defer cancel()

		rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)

		var rankingItems []writer.RankingItem
		for rankingCh != nil || errCh != nil {
			select {
			case meta, ok := <-rankingCh:
				if !ok {
					rankingCh = nil
				} else {
					rankingItems = append(rankingItems, writer.RankingItem{
						Rank:      meta.Ranking,
						Slug:      meta.Slug,
						Title:     meta.Title,
						Author:    meta.Author,
						Cover:     meta.Cover,
						Status:    meta.Status,
						Genres:    meta.Genres,
						SourceURL: meta.SourceURL,
					})
				}
			case err, ok := <-errCh:
				if !ok {
					errCh = nil
				} else if err != nil {
					s.log.Error("ranking scrape error", "err", err)
				}
			}
		}

		if len(rankingItems) > 0 {
			if err := s.writer.WriteRanking(rankingItems); err != nil {
				s.log.Error("failed to save ranking", "err", err)
			}
		}
	}()

	renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
}

// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
// While running it returns a self-replacing badge; when done it issues an
// HX-Redirect so the browser navigates to /ranking.
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	running := s.rankingRunning
	s.mu.Unlock()

	if running {
		renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
		return
	}
	// Job done — redirect the HTMX request to the ranking page.
	w.Header().Set("HX-Redirect", "/ranking")
	w.WriteHeader(http.StatusOK)
}

// rankingStatusHTML returns a self-replacing polling badge for the ranking
// refresh job. state is "running" or "done".
func rankingStatusHTML(state, msg string) string {
	var colour, dot, poll string
	switch state {
	case "running":
		colour = "text-amber-300 bg-amber-950 border-amber-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
		poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
	default:
		colour = "text-green-300 bg-green-950 border-green-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
	}
	return fmt.Sprintf(
		`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
		colour, poll, dot, template.HTMLEscapeString(msg),
	)
}

// ─── GET /ranking/view — view ranking markdown ─────────────────────────────────

const rankingViewTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
  <a href="/ranking"
     hx-get="/ranking"
     hx-target="#main-content"
     hx-push-url="true"
     hx-swap="innerHTML"
     class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
    ← Back to Rankings
  </a>

  <h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>

  <pre class="text-xs text-zinc-300 bg-zinc-900 border border-zinc-700 rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-words">{{.JSON}}</pre>
</div>`

func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
	items, err := s.writer.ReadRankingItems()
	if err != nil {
		http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if len(items) == 0 {
		http.NotFound(w, r)
		return
	}

	pretty, err := json.MarshalIndent(items, "", "  ")
	if err != nil {
		http.Error(w, "json marshal error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
	var buf bytes.Buffer
	_ = t.Execute(&buf, struct{ JSON string }{JSON: string(pretty)})

	s.respond(w, r, "Ranking Data", buf.String())
}

// ─── GET /books/{slug} — chapter list ────────────────────────────────────────

const chapterPageSize = 50

const bookTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
  <a href="/"
     hx-get="/"
     hx-target="#main-content"
     hx-push-url="true"
     hx-swap="innerHTML"
     class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
    ← All books
  </a>

  <!-- Cover zoom overlay -->
  <div id="cover-zoom-overlay"
       class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
       onclick="document.getElementById('cover-zoom-overlay').classList.add('hidden');document.getElementById('cover-zoom-overlay').classList.remove('flex');">
    <img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
  </div>

  <!-- Summary zoom overlay -->
  <div id="summary-zoom-overlay"
       class="fixed inset-0 z-50 hidden items-start justify-center bg-black/80 cursor-zoom-out p-4 pt-8 overflow-y-auto"
       onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
    <div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
      <h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Summary</h2>
      <div id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed space-y-3"></div>
      <button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
              class="mt-5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
    </div>
  </div>

  <div class="flex gap-5 mb-8 mt-4">
    {{if .Meta.Cover}}
    <img src="{{.Meta.Cover}}" alt="cover"
         id="cover-thumb"
         class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg cursor-zoom-in"
         onclick="(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
    {{end}}
    <div class="flex-1 min-w-0">
      <div class="flex items-center gap-3 flex-wrap">
        <h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
        {{if .Meta.SourceURL}}
        <a href="{{.Meta.SourceURL}}" target="_blank" rel="noopener noreferrer"
           title="View on novelfire.net"
           class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded text-zinc-400 hover:text-zinc-200 transition-colors">
          <svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
          </svg>
          Source
        </a>
        <form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
          <input type="hidden" name="url" value="{{.Meta.SourceURL}}">
          <button type="submit"
            class="text-xs px-2 py-1 rounded text-amber-400 hover:text-amber-300 transition-colors border-0 bg-transparent cursor-pointer"
            title="Re-scrape from source">
            Refresh
          </button>
        </form>
        {{end}}
      </div>
      {{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
      <div class="flex gap-2 mt-2 flex-wrap">
        {{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
        {{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
        <span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
      </div>
       {{if .Meta.Summary}}
       <p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
          onclick="openSummary(this)"
          data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
       {{end}}
    </div>
  </div>

  <!-- Resume reading button — populated by JS from localStorage -->
  <div id="resume-bar" class="mb-4 hidden">
    <a id="resume-link"
       hx-target="#main-content"
       hx-push-url="true"
       hx-swap="innerHTML"
       class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer">
      ▶ Resume — Chapter <span id="resume-chapter-num"></span>
    </a>
  </div>

  <!-- Latest chapter button — shown by JS when book has not been started -->
  {{if .LastChapter}}
  <div id="latest-bar" class="mb-4 hidden">
    <a href="/books/{{.Slug}}/chapters/{{.LastChapter}}"
       hx-get="/books/{{.Slug}}/chapters/{{.LastChapter}}"
       hx-target="#main-content"
       hx-push-url="true"
       hx-swap="innerHTML"
       class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-zinc-300 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer border border-zinc-700 hover:border-amber-500">
      ↓ Latest — Chapter {{.LastChapter}}
    </a>
  </div>
  {{end}}

  <h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
  <ul id="chapter-list" class="space-y-1">
    {{range .Chapters}}
    <li>
      <a href="/books/{{$.Slug}}/chapters/{{.Number}}"
         hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
         hx-target="#main-content"
         hx-push-url="true"
         hx-swap="innerHTML"
         class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
         data-chapter="{{.Number}}">
        <span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
        <div class="min-w-0 flex-1">
          <span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
          {{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
        </div>
        <span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
      </a>
    </li>
    {{else}}
    <li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
    {{end}}
  </ul>

  {{if gt .TotalPages 1}}
  <div id="chapter-pagination" class="mt-6 flex justify-center gap-1 flex-wrap">
    {{if gt .CurrentPage 1}}
    <a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
       hx-target="#chapter-list"
       hx-swap="innerHTML"
       hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
       hx-on::after-request="applyProgress('{{.Slug}}')"
       class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
    {{end}}
    {{range pages .TotalPages}}
    {{if eq . $.CurrentPage}}
    <span class="px-3 py-1.5 rounded-lg text-amber-400 text-sm font-semibold">{{.}}</span>
    {{else}}
    <a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
       hx-target="#chapter-list"
       hx-swap="innerHTML"
       hx-push-url="/books/{{$.Slug}}?page={{.}}"
       hx-on::after-request="applyProgress('{{$.Slug}}')"
       class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">{{.}}</a>
    {{end}}
    {{end}}
    {{if lt .CurrentPage .TotalPages}}
    <a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
       hx-target="#chapter-list"
       hx-swap="innerHTML"
       hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
       hx-on::after-request="applyProgress('{{.Slug}}')"
       class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
    {{end}}
  </div>
  {{end}}
</div>

<script>
  window.openSummary = function(el) {
    var t = el.dataset.full || '';
    var o = document.getElementById('summary-zoom-overlay');
    var c = document.getElementById('summary-zoom-text');
    c.innerHTML = '';
    var paras = t.split(/\n+/).filter(function(s) { return s.trim().length > 0; });
    if (paras.length <= 1) {
      paras = t.split(/(?<=[.!?][\u201d\u2019"']?)\s{2,}/).filter(function(s) { return s.trim().length > 0; });
    }
    if (paras.length <= 1) { paras = [t]; }
    paras.forEach(function(p) {
      var el = document.createElement('p');
      el.textContent = p.trim();
      c.appendChild(el);
    });
    o.classList.remove('hidden');
    o.classList.add('flex');
  };

(function () {
  var SLUG = '{{.Slug}}';
  var LS_KEY = 'reading_progress';

  function getProgress() {
    try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
  }

  window.applyProgress = function(slug) {
    var progress = getProgress();
    var saved = progress[slug];

    // Show/hide latest bar (only when no progress recorded for this book).
    var latestBar = document.getElementById('latest-bar');
    if (latestBar) {
      if (!saved) {
        latestBar.classList.remove('hidden');
      } else {
        latestBar.classList.add('hidden');
      }
    }

    if (!saved) return;

    // Highlight the saved chapter row.
    document.querySelectorAll('.chapter-row').forEach(function (a) {
      var dot = a.querySelector('.progress-dot');
      if (!dot) return;
      if (parseInt(a.dataset.chapter) === saved) {
        dot.classList.remove('hidden');
        a.classList.add('bg-zinc-800/50');
      }
    });

    // Show resume bar.
    var bar  = document.getElementById('resume-bar');
    var link = document.getElementById('resume-link');
    var num  = document.getElementById('resume-chapter-num');
    if (bar && link && num) {
      var href = '/books/' + slug + '/chapters/' + saved;
      num.textContent = saved;
      link.setAttribute('href', href);
      link.setAttribute('hx-get', href);
      htmx.process(link);
      bar.classList.remove('hidden');
    }
  };

  applyProgress(SLUG);
}());
</script>`

func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")

	meta, ok, err := s.writer.ReadMetadata(slug)
	if err != nil {
		http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if !ok {
		http.NotFound(w, r)
		return
	}

	chapters, err := s.writer.ListChapters(slug)
	if err != nil {
		http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
		return
	}

	total := len(chapters)
	totalPages := (total + chapterPageSize - 1) / chapterPageSize
	if totalPages < 1 {
		totalPages = 1
	}

	currentPage := 1
	page := chapters
	if total > chapterPageSize {
		page = chapters[:chapterPageSize]
	}

	funcMap := template.FuncMap{
		"pages": func(n int) []int {
			out := make([]int, n)
			for i := range out {
				out[i] = i + 1
			}
			return out
		},
		"prev": func(n int) int { return n - 1 },
		"next": func(n int) int { return n + 1 },
	}

	t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl))
	var buf bytes.Buffer
	lastChapter := 0
	if total > 0 {
		lastChapter = chapters[total-1].Number
	}
	_ = t.Execute(&buf, struct {
		Slug            string
		Meta            interface{}
		Chapters        interface{}
		TotalDownloaded int
		TotalPages      int
		CurrentPage     int
		LastChapter     int
	}{
		Slug:            slug,
		Meta:            meta,
		Chapters:        page,
		TotalDownloaded: total,
		TotalPages:      totalPages,
		CurrentPage:     currentPage,
		LastChapter:     lastChapter,
	})

	s.respond(w, r, meta.Title, buf.String())
}

// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ────────

const chapterPageTmpl = `{{range .Chapters}}
<li>
  <a href="/books/{{$.Slug}}/chapters/{{.Number}}"
     hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
     hx-target="#main-content"
     hx-push-url="true"
     hx-swap="innerHTML"
     class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
     data-chapter="{{.Number}}">
    <span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
    <div class="min-w-0 flex-1">
      <span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
      {{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
    </div>
    <span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
  </a>
</li>
{{end}}
<div id="chapter-pagination" hx-swap-oob="true" class="mt-6 flex justify-center gap-1 flex-wrap">
  {{if gt .CurrentPage 1}}
  <a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
     hx-target="#chapter-list"
     hx-swap="innerHTML"
     hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
     hx-on::after-request="applyProgress('{{.Slug}}')"
     class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
  {{end}}
  {{range pages .TotalPages}}
  {{if eq . $.CurrentPage}}
  <span class="px-3 py-1.5 rounded-lg bg-amber-700 text-white text-sm font-semibold">{{.}}</span>
  {{else}}
  <a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
     hx-target="#chapter-list"
     hx-swap="innerHTML"
     hx-push-url="/books/{{$.Slug}}?page={{.}}"
     hx-on::after-request="applyProgress('{{$.Slug}}')"
     class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">{{.}}</a>
  {{end}}
  {{end}}
  {{if lt .CurrentPage .TotalPages}}
  <a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
     hx-target="#chapter-list"
     hx-swap="innerHTML"
     hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
     hx-on::after-request="applyProgress('{{.Slug}}')"
     class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
  {{end}}
</div>`

func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	currentPage := 1
	if p := r.URL.Query().Get("page"); p != "" {
		if n, err := strconv.Atoi(p); err == nil && n > 0 {
			currentPage = n
		}
	}

	chapters, err := s.writer.ListChapters(slug)
	if err != nil {
		http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
		return
	}

	total := len(chapters)
	totalPages := (total + chapterPageSize - 1) / chapterPageSize
	if totalPages < 1 {
		totalPages = 1
	}

	start := (currentPage - 1) * chapterPageSize
	if start >= total {
		w.WriteHeader(http.StatusNoContent)
		return
	}
	end := start + chapterPageSize
	if end > total {
		end = total
	}

	funcMap := template.FuncMap{
		"pages": func(n int) []int {
			out := make([]int, n)
			for i := range out {
				out[i] = i + 1
			}
			return out
		},
		"prev": func(n int) int { return n - 1 },
		"next": func(n int) int { return n + 1 },
	}

	t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl))
	var buf bytes.Buffer
	_ = t.Execute(&buf, struct {
		Slug        string
		Chapters    interface{}
		TotalPages  int
		CurrentPage int
	}{
		Slug:        slug,
		Chapters:    chapters[start:end],
		TotalPages:  totalPages,
		CurrentPage: currentPage,
	})

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = buf.WriteTo(w)
}

// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────

const chapterTmpl = `
<style>
  /* ── Reader typography ──────────────────────────────────────────────────── */
  #reader-body {
    font-size: 1.0625rem;
    line-height: 1.85;
    color: #d4d4d8;
  }
  #reader-body p {
    margin: 0 0 0.9em 0;
  }
  #reader-body p.tts-active {
    background: rgba(251,191,36,0.10);
    border-left: 2px solid #f59e0b;
    margin-left: -0.75rem;
    padding-left: calc(0.75rem - 2px);
    border-radius: 0 4px 4px 0;
    transition: background 0.15s;
  }

  /* ── Prevent sticky nav obscuring anchors ───────────────────────────────── */
  #main-content { scroll-padding-top: 3.5rem; padding-bottom: 6rem; }

  /* ── Audio state badges ─────────────────────────────────────────────────── */
  .queue-badge {
    display: inline-flex; align-items: center;
    padding: 0.15em 0.55em;
    border-radius: 9999px;
    font-size: 0.6rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
  }
  .queue-badge-idle        { background: #27272a; color: #52525b; }
  .queue-badge-generating  { background: #78350f; color: #fbbf24; }
  .queue-badge-ready       { background: #14532d; color: #4ade80; }
  .queue-badge-playing     { background: #1e3a5f; color: #60a5fa; }
  .queue-badge-paused      { background: #1c1917; color: #a8a29e; }
  .queue-badge-error       { background: #450a0a; color: #f87171; }
  .queue-badge-time        { text-transform: none; letter-spacing: 0; }

  /* ── Seek bar ───────────────────────────────────────────────────────────── */
  #seek-track { height: 1.25rem; cursor: pointer; touch-action: none; user-select: none; width: min(38rem, 90%); margin: auto; }
  #seek-times { width: min(38rem, 95%); margin: auto; }
  #seek-rail  { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%); height: 2px; background: #3f3f46; border-radius: 9999px; overflow: hidden; pointer-events: none; }
  #seek-fill  { position: absolute; left: 0; top: 0; height: 100%; background: #f59e0b; }
  #seek-thumb { position: absolute; top: 50%; transform: translateY(-50%) translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: #fbbf24; box-shadow: 0 1px 4px rgba(0,0,0,.5); pointer-events: none; transition: transform 0.1s; }
  #seek-thumb:hover { transform: translateY(-50%) translateX(-50%) scale(1.3); }

  /* ── Safe-area inset ────────────────────────────────────────────────────── */
  #mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }

  /* ── Settings bottom sheet (mobile) ────────────────────────────────────── */
  #settings-backdrop {
    display: none;
    position: fixed; inset: 0; z-index: 90;
    background: rgba(0,0,0,0.5);
  }
  #settings-panel {
    /* mobile: bottom sheet */
    position: fixed;
    left: 0; right: 0; bottom: 0;
    z-index: 100;
    max-height: 92dvh;
    overflow-y: auto;
    overscroll-behavior: contain;
    background: #18181b;
    border-top: 1px solid #27272a;
    border-radius: 1rem 1rem 0 0;
    box-shadow: 0 -4px 32px rgba(0,0,0,.5);
    transform: translateY(100%);
    transition: transform 0.32s cubic-bezier(0.32,0.72,0,1);
    will-change: transform;
    padding-bottom: env(safe-area-inset-bottom, 0);
  }
  #settings-panel.sheet-open {
    transform: translateY(0);
  }
  .sheet-handle {
    width: 2.5rem; height: 4px;
    background: #3f3f46; border-radius: 9999px;
    margin: 0.75rem auto 0;
    flex-shrink: 0;
  }
  @media (min-width: 640px) {
    #settings-backdrop { display: none !important; }
    #settings-panel {
      position: absolute;
      left: auto; right: max(0.5rem, calc(50% - 32rem + 0.5rem));
      bottom: calc(100% + 0.25rem);
      top: auto;
      width: 20rem;
      max-height: 80vh;
      border-radius: 0.75rem;
      border: 1px solid #27272a;
      transform: none !important;
      transition: none !important;
    }
    #settings-panel.sheet-open { transform: none !important; }
    .sheet-handle { display: none; }
  }

  /* ── Toggle switch ──────────────────────────────────────────────────────── */
  .toggle-switch { position: relative; display: inline-block; width: 2.25rem; height: 1.25rem; flex-shrink: 0; }
  .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
  .toggle-track { position: absolute; inset: 0; background: #3f3f46; border-radius: 9999px; transition: background 0.2s; cursor: pointer; }
  .toggle-track::after { content: ''; position: absolute; left: 0.2rem; top: 50%; transform: translateY(-50%); width: 0.85rem; height: 0.85rem; background: #fff; border-radius: 50%; transition: left 0.2s; }
  .toggle-switch input:checked + .toggle-track { background: #f59e0b; }
  .toggle-switch input:checked + .toggle-track::after { left: calc(100% - 0.2rem - 0.85rem); }
  .toggle-switch input:focus-visible + .toggle-track { outline: 2px solid #f59e0b; outline-offset: 2px; }
</style>

<!-- ─── Sticky top toolbar ─────────────────────────────────────────────────── -->
<nav id="reader-header" aria-label="Reader controls"
     class="sticky top-0 z-50 bg-zinc-950/95 backdrop-blur border-b border-zinc-800/60">
  <div class="max-w-2xl mx-auto px-3 flex items-center gap-1 h-12">

    <!-- Back to book -->
    <a href="/books/{{.Slug}}"
       hx-get="/books/{{.Slug}}"
       hx-target="#main-content"
       hx-push-url="true"
       hx-swap="innerHTML"
       title="Back to chapter list"
       aria-label="Back to chapter list"
       class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors text-lg no-underline">
      &#9776;
    </a>

    <!-- Chapter title (tappable — opens chapter list) -->
    <button id="chapter-list-btn" type="button" onclick="toggleChapterList()"
            aria-haspopup="listbox" aria-expanded="false"
            class="flex-1 min-w-0 h-9 flex items-center justify-center px-2 bg-transparent border-none cursor-pointer">
      <span class="text-sm text-zinc-200 truncate max-w-full leading-none">{{.Title}}</span>
    </button>

    <!-- Chapter position -->
    <span class="flex-shrink-0 text-xs text-zinc-600 tabular-nums">
      {{.ChapterN}}{{if .AllChapters}} / {{len .AllChapters}}{{end}}
    </span>

  </div>

  <!-- TTS status strip (generating / buffering messages) -->
  <div id="tts-status-bar" hidden
       class="px-4 py-0.5 bg-zinc-900/80 text-[0.7rem] text-zinc-500 text-center border-t border-zinc-800/40"
       aria-live="polite" aria-atomic="true">
    <span id="tts-status"></span>
  </div>

  <!-- Chapter list drawer -->
  <div id="chapter-list-panel"
       role="listbox"
       aria-label="Chapter list"
       hidden
       class="absolute left-0 right-0 top-full max-h-[60vh] overflow-y-auto bg-zinc-900 border-b border-zinc-800 shadow-2xl z-[99]">
    <div class="max-w-2xl mx-auto py-2">
      {{range .AllChapters}}
      <a href="/books/{{$.Slug}}/chapters/{{.Number}}"
         hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
         hx-target="#main-content"
         hx-push-url="true"
         hx-swap="innerHTML"
         role="option"
         aria-selected="{{if eq .Number $.ChapterN}}true{{else}}false{{end}}"
         onclick="closeChapterList()"
         {{if eq .Number $.ChapterN}}data-current="1"{{end}}
         class="flex items-baseline gap-3 px-4 py-2 no-underline transition-colors hover:bg-zinc-800 {{if eq .Number $.ChapterN}}bg-zinc-800{{end}}">
        <span class="flex-shrink-0 text-xs text-zinc-600 w-8 text-right tabular-nums">{{.Number}}</span>
        <span class="text-sm {{if eq .Number $.ChapterN}}text-amber-400 font-medium{{else}}text-zinc-300{{end}} leading-snug">
          {{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}
        </span>
        {{if eq .Number $.ChapterN}}<span class="ml-auto flex-shrink-0 text-[0.65rem] text-amber-500/80">now</span>{{end}}
      </a>
      {{end}}
    </div>
  </div>
</nav>

<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
<main class="max-w-[38rem] mx-auto px-5 pt-8 pb-4">

  <!-- Chapter heading — smaller and secondary, not a huge h1 -->
  <div class="mb-6">
    <p class="text-xs font-medium text-amber-500/80 uppercase tracking-widest mb-1.5">Chapter {{.ChapterN}}</p>
    <h1 class="text-xl font-bold text-zinc-100 leading-snug">{{.Title}}</h1>
    {{if .ChapterDate}}<p class="text-xs text-zinc-600 mt-1">{{.ChapterDate}}</p>{{end}}
   </div>

  <article id="chapter-article" id="reader-body">
    {{.HTML}}
  </article>

</main>

<!-- ─── Fixed bottom player ───────────────────────────────────────────────── -->
<aside id="mini-player" aria-label="Audio player"
       class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950/95 backdrop-blur border-t border-zinc-800/60">

  <!-- Seek track (full width, sits flush at top of player) -->
  <div id="seek-track" style="display:none" class="relative w-full">
    <div id="seek-rail">
      <div id="seek-fill"></div>
    </div>
    <div id="seek-thumb" style="left:0%"></div>
  </div>

  <!-- Time row -->
  <div id="seek-times" style="display:none"
       class="px-1 pt-1 flex items-center justify-between">
    <span id="seek-cur" class="text-[0.6rem] tabular-nums text-zinc-600">0:00</span>
    <span id="seek-tot" class="text-[0.6rem] tabular-nums text-zinc-700">0:00</span>
  </div>

  <!-- Main control row -->
  <div class="max-w-2xl mx-auto px-3 py-2">
    <div class="flex items-center gap-2 h-11">

      <!-- Prev chapter -->
      {{if .PrevN}}
      <a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
         hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
         hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
         title="Previous chapter" aria-label="Previous chapter"
         class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors no-underline">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><polyline points="15 18 9 12 15 6"/></svg>
      </a>
      {{else}}<span class="flex-shrink-0 w-10"></span>{{end}}

      <!-- Play / Pause -->
      <button id="player-play-btn" type="button" onclick="ttsToggle()"
              aria-label="Play / Pause"
              class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors shadow-sm">
        <span id="player-play-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M8 5v14l11-7z"/></svg></span>
      </button>

      <!-- Centre info -->
      <div class="flex-1 min-w-0 flex items-center gap-2 px-1">
        <span id="player-title" class="text-xs font-medium text-zinc-300 truncate">Ch.&#x00A0;{{.ChapterN}}</span>
        <span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0" hidden></span>
        <!-- next-chapter prefetch badge — shown only during/after prefetch -->
        <span id="player-next-row" hidden class="flex-shrink-0">
          <span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
        </span>
      </div>

      <!-- Settings -->
      <button id="settings-btn" type="button" onclick="toggleSettings()"
              aria-label="Reader settings" aria-haspopup="dialog"
              class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-xl text-zinc-400 hover:text-amber-400 hover:bg-zinc-800 border-none cursor-pointer bg-transparent transition-colors">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
      </button>

      <!-- Next chapter -->
      {{if .NextN}}
      <a href="/books/{{.Slug}}/chapters/{{.NextN}}"
         hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
         hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
         title="Next chapter" aria-label="Next chapter"
         class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors no-underline">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><polyline points="9 18 15 12 9 6"/></svg>
      </a>
      {{else}}<span class="flex-shrink-0 w-10"></span>{{end}}

    </div>
  </div>

  <!-- Backdrop (mobile only) -->
  <div id="settings-backdrop" onclick="closeSettings()"></div>

  <!-- Settings panel — bottom sheet on mobile, popover on sm+ -->
  <div id="settings-panel"
       role="dialog"
       aria-label="Reader settings"
       aria-modal="true"
       style="display:none">

    <!-- Drag handle -->
    <div class="sheet-handle" id="sheet-handle"></div>

    <div class="p-4 space-y-4">

      <!-- Speed -->
      <div>
        <span class="block text-xs text-zinc-500 mb-1.5">Speed</span>
        <div id="speed-btns" class="flex gap-1.5">
          <button type="button" data-speed="1"    onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1×</button>
          <button type="button" data-speed="1.25" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.25×</button>
          <button type="button" data-speed="1.5"  onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-amber-500 bg-amber-500/10 text-amber-300">1.5×</button>
          <button type="button" data-speed="1.75" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.75×</button>
          <button type="button" data-speed="2"    onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">2×</button>
        </div>
      </div>

      <!-- Toggles -->
      <label class="flex items-center justify-between gap-3 cursor-pointer select-none">
        <span class="text-sm text-zinc-300">Auto-play next chapter</span>
        <span class="toggle-switch"><input id="tts-autoplay" type="checkbox" /><span class="toggle-track"></span></span>
      </label>
      <label class="flex items-center justify-between gap-3 cursor-pointer select-none">
        <span class="text-sm text-zinc-300">Auto-scroll to paragraph</span>
        <span class="toggle-switch"><input id="tts-autoscroll" type="checkbox" checked /><span class="toggle-track"></span></span>
      </label>

      <!-- Divider -->
      <div class="border-t border-zinc-800"></div>

      <!-- Voice selector -->
      <div>
        <span class="block text-xs text-zinc-500 mb-1.5">Voice</span>

        <!-- hidden native select — JS reads/writes .value here -->
        <select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
          {{range .Voices}}
          <option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
          {{end}}
        </select>

        <!-- custom dropdown trigger -->
        <button type="button" id="voice-trigger"
                onclick="toggleVoiceDropdown()"
                class="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-200 text-sm hover:border-zinc-500 transition-colors">
          <span id="voice-trigger-label" class="truncate">{{range .Voices}}{{if eq .ID $.DefaultVoice}}{{.Name}} ({{.Lang}} · {{.Gender}}){{end}}{{end}}</span>
          <svg id="voice-trigger-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" class="flex-shrink-0 text-zinc-500 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
        </button>

        <!-- dropdown list -->
        <div id="voice-dropdown" hidden
             class="mt-1 rounded-lg border border-zinc-700 bg-zinc-900 overflow-hidden shadow-xl">
          <div class="max-h-48 overflow-y-auto overscroll-contain">
            {{range .Voices}}
            <button type="button"
                    data-voice="{{.ID}}"
                    data-label="{{.Name}} ({{.Lang}} · {{.Gender}})"
                    onclick="selectVoiceOption(this)"
                    class="voice-opt w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 transition-colors
                           {{if eq .ID $.DefaultVoice}}bg-zinc-800/60 text-zinc-100{{end}}">
              <span class="truncate">{{.Name}}</span>
              <span class="text-xs text-zinc-500 flex-shrink-0">{{.Lang}} · {{.Gender}}</span>
            </button>
            {{end}}
          </div>
        </div>
      </div>

    </div>
  </div>

  <!-- Hidden native audio -->
  <audio id="tts-audio-native" preload="none" style="display:none"></audio>
</aside>

<script>
(function () {
  var NEXT_N    = {{.NextN}};
  var PREV_N    = {{.PrevN}};
  var SLUG      = '{{.Slug}}';
  var CHAPTER_N = {{.ChapterN}};
  var COVER_URL = '{{.Cover}}';

  // ── reading progress ──────────────────────────────────────────────────────
  (function saveProgress() {
    try {
      var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
      p[SLUG] = CHAPTER_N;
      localStorage.setItem('reading_progress', JSON.stringify(p));
      var ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}');
      ts[SLUG] = Date.now();
      localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
    } catch(_) {}
  })();

  // ── generation counter (stale-closure guard) ──────────────────────────────
  window.__ttsGen = (window.__ttsGen || 0) + 1;
  var myGen = window.__ttsGen;
  function stale() { return window.__ttsGen !== myGen; }

  if (window.__ttsBeforeSwap) {
    document.body.removeEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
  }

  // ── DOM refs ──────────────────────────────────────────────────────────────
  var audio           = document.getElementById('tts-audio-native');
  var statusEl        = document.getElementById('tts-status');
  var statusBar       = document.getElementById('tts-status-bar');
  var voiceSel        = document.getElementById('tts-voice');
  var speedBtns       = document.querySelectorAll('.speed-btn');
  var autoplayChk     = document.getElementById('tts-autoplay');
  var autoscrollChk   = document.getElementById('tts-autoscroll');
  var article         = document.getElementById('chapter-article');
  var playerPlayBtn   = document.getElementById('player-play-btn');
  var playerPlayIcon  = document.getElementById('player-play-icon');
  var playerTitle     = document.getElementById('player-title');
  var playerStateBadge= document.getElementById('player-state-badge');
  var seekTrack       = document.getElementById('seek-track');
  var seekRail        = document.getElementById('seek-rail');
  var seekFill        = document.getElementById('seek-fill');
  var seekThumb       = document.getElementById('seek-thumb');
  var seekCur         = document.getElementById('seek-cur');
  var seekTot         = document.getElementById('seek-tot');
  var seekTimes       = document.getElementById('seek-times');
  var settingsPanel   = document.getElementById('settings-panel');
  var chapterListPanel= document.getElementById('chapter-list-panel');
  var chapterListBtn  = document.getElementById('chapter-list-btn');

  // ── badge helper ──────────────────────────────────────────────────────────
  var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
                       'queue-badge-playing','queue-badge-paused','queue-badge-error',
                       'queue-badge-time'];
  function setBadge(el, state) {
    BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
    el.classList.add('queue-badge-' + state);
    el.textContent = state;
  }

  // ── time formatter ────────────────────────────────────────────────────────
  function fmtTime(s) {
    if (!isFinite(s) || s < 0) return '0:00';
    var m = Math.floor(s / 60);
    var sec = Math.floor(s % 60);
    return m + ':' + (sec < 10 ? '0' : '') + sec;
  }

  // ── seek bar ──────────────────────────────────────────────────────────────
  function showSeek()  { seekTrack.style.display = ''; seekTimes.style.display = ''; }
  function hideSeek()  { seekTrack.style.display = 'none'; seekTimes.style.display = 'none'; }

  function updateSeek() {
    var cur = audio.currentTime || 0;
    var tot = audio.duration;
    var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
    var ps  = pct.toFixed(2) + '%';
    seekFill.style.width = ps;
    seekThumb.style.left = ps;
    seekCur.textContent  = fmtTime(cur);
    seekTot.textContent  = isFinite(tot) ? fmtTime(tot) : '0:00';
  }

  function seekFromEvent(e) {
    var rect = seekRail.getBoundingClientRect();
    var clientX = (e.touches ? e.touches[0] : e).clientX;
    var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    if (audio.duration && isFinite(audio.duration)) {
      audio.currentTime = ratio * audio.duration;
      updateSeek();
    }
  }
  (function attachSeek() {
    var dragging = false;
    seekTrack.addEventListener('mousedown', function(e) { dragging = true; seekFromEvent(e); e.preventDefault(); });
    seekTrack.addEventListener('touchstart', function(e) { dragging = true; seekFromEvent(e); }, { passive: true });
    document.addEventListener('mousemove',  function(e) { if (dragging) seekFromEvent(e); });
    document.addEventListener('touchmove',  function(e) { if (dragging) { seekFromEvent(e); e.preventDefault(); } }, { passive: false });
    document.addEventListener('mouseup',    function(e) { if (dragging) { seekFromEvent(e); dragging = false; } });
    document.addEventListener('touchend',   function()  { dragging = false; });
  })();

  // ── status strip ──────────────────────────────────────────────────────────
  function setStatus(text) {
    statusEl.textContent = text;
    statusBar.style.display = text ? '' : 'none';
  }

  // ── panel toggles ─────────────────────────────────────────────────────────
  window.closeChapterList = function () { chapterListPanel.style.display = 'none'; };
  window.toggleChapterList = function () {
    var open = chapterListPanel.style.display !== 'none';
    closeSettings();
    if (open) { closeChapterList(); }
    else {
      chapterListPanel.style.display = 'block';
      var cur = chapterListPanel.querySelector('[data-current="1"]');
      if (cur) cur.scrollIntoView({ block: 'center' });
    }
  };
  var settingsBackdrop = document.getElementById('settings-backdrop');

  function openSettings() {
    closeChapterList();
    settingsPanel.style.display = 'block';
    // rAF so display:block is painted before the transition fires
    requestAnimationFrame(function () {
      settingsPanel.classList.add('sheet-open');
    });
    settingsBackdrop.style.display = 'block';
  }
  function closeSettings() {
    settingsPanel.classList.remove('sheet-open');
    settingsBackdrop.style.display = 'none';
    // hide after slide-down transition; fallback for desktop (no transition)
    var done = false;
    function hide() { if (!done) { done = true; settingsPanel.style.display = 'none'; } }
    settingsPanel.addEventListener('transitionend', function h() {
      settingsPanel.removeEventListener('transitionend', h);
      hide();
    });
    setTimeout(hide, 400); // fallback if transitionend never fires
  }
  window.closeSettings = closeSettings;
  window.toggleSettings = function () {
    if (settingsPanel.style.display !== 'none') closeSettings();
    else openSettings();
  };

  // ── Bottom sheet drag-to-dismiss / drag-to-expand ─────────────────────────
  (function sheetDrag() {
    var handle   = document.getElementById('sheet-handle');
    if (!handle) return;
    var startY   = 0, startH = 0, dragging = false;
    var DISMISS_THRESHOLD = 80; // px swipe down to dismiss

    function onStart(clientY) {
      if (window.innerWidth >= 640) return; // desktop: no drag
      startY   = clientY;
      startH   = settingsPanel.getBoundingClientRect().height;
      dragging = true;
      settingsPanel.style.transition = 'none';
    }
    function onMove(clientY) {
      if (!dragging) return;
      var dy = clientY - startY; // positive = dragging down
      if (dy < 0) {
        // stretching up — increase height, clamped to 92dvh
        var newH = Math.min(startH - dy, window.innerHeight * 0.92);
        settingsPanel.style.maxHeight = newH + 'px';
        settingsPanel.style.transform = 'translateY(0)';
      } else {
        // dragging down — translate panel down
        settingsPanel.style.transform = 'translateY(' + dy + 'px)';
        settingsPanel.style.maxHeight = '';
      }
    }
    function onEnd(clientY) {
      if (!dragging) return;
      dragging = false;
      settingsPanel.style.transition = '';
      var dy = clientY - startY;
      if (dy > DISMISS_THRESHOLD) {
        closeSettings();
        settingsPanel.style.maxHeight = '';
      } else {
        settingsPanel.style.transform = '';
        // snap: if we dragged up, keep the new height
        if (dy < 0) {
          var snapped = Math.min(startH - dy, window.innerHeight * 0.92);
          settingsPanel.style.maxHeight = snapped + 'px';
        }
      }
    }

    handle.addEventListener('touchstart', function (e) { onStart(e.touches[0].clientY); }, { passive: true });
    handle.addEventListener('touchmove',  function (e) { onMove(e.touches[0].clientY); },  { passive: true });
    handle.addEventListener('touchend',   function (e) { onEnd(e.changedTouches[0].clientY); }, { passive: true });
  }());

  if (window.__ttsClickOutside) document.removeEventListener('click', window.__ttsClickOutside);
  window.__ttsClickOutside = function (e) {
    if (stale()) { document.removeEventListener('click', window.__ttsClickOutside); return; }
    var sb = document.getElementById('settings-btn');
    if (settingsPanel.style.display !== 'none' &&
        !settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target) &&
        e.target !== settingsBackdrop) {
      closeSettings();
    }
    if (chapterListPanel.style.display !== 'none' &&
        !chapterListPanel.contains(e.target) &&
        e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
      closeChapterList();
    }
    // close voice dropdown if clicking outside it
    var dd      = document.getElementById('voice-dropdown');
    var trigger = document.getElementById('voice-trigger');
    if (dd && !dd.hidden && trigger &&
        !dd.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
      dd.hidden = true;
      var chevron = document.getElementById('voice-trigger-chevron');
      if (chevron) chevron.style.transform = '';
    }
  };
  document.addEventListener('click', window.__ttsClickOutside);

  // ── localStorage settings ─────────────────────────────────────────────────
  var LS_SPEED = 'tts_speed', LS_VOICE = 'tts_voice', LS_AUTONEXT = 'tts_autonext', LS_AUTOSCROLL = 'tts_autoscroll';
  function selectSpeed(btn) {
    speedBtns.forEach(function (b) {
      var active = b === btn;
      b.classList.toggle('speed-active',     active);
      b.classList.toggle('border-amber-500', active);
      b.classList.toggle('bg-amber-500/10',  active);
      b.classList.toggle('text-amber-300',   active);
      b.classList.toggle('border-zinc-700',  !active);
      b.classList.toggle('bg-zinc-800',      !active);
      b.classList.toggle('text-zinc-300',    !active);
    });
    try { localStorage.setItem(LS_SPEED, btn.dataset.speed); } catch(_) {}
    if (audio.src) audio.playbackRate = parseFloat(btn.dataset.speed);
  }
  window.selectSpeed = selectSpeed;
  (function loadSettings() {
    var spd = localStorage.getItem(LS_SPEED);
    var activeSpd = spd || '1.5';
    var btn = document.querySelector('.speed-btn[data-speed="' + activeSpd + '"]');
    if (btn) selectSpeed(btn);
    var vc = localStorage.getItem(LS_VOICE);
    if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
    var an = localStorage.getItem(LS_AUTONEXT);
    if (an !== null) autoplayChk.checked = an === 'true';
    var as = localStorage.getItem(LS_AUTOSCROLL);
    if (as !== null) autoscrollChk.checked = as !== 'false';
  })();
  voiceSel.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_VOICE, voiceSel.value); });
  autoplayChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); });
  autoscrollChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTOSCROLL, autoscrollChk.checked ? 'true' : 'false'); });

  // ── paragraph indexing ────────────────────────────────────────────────────
  var paras      = Array.prototype.slice.call((article || {querySelectorAll: function(){return[];}}).querySelectorAll('p'));
  var activePara = null;

  // ── auto-scroll pause on user interaction ─────────────────────────────────
  var autoScrollEnabled = true;
  var autoScrollTimer   = null;
  function resetAutoScrollTimer() {
    autoScrollEnabled = false;
    clearTimeout(autoScrollTimer);
    autoScrollTimer = setTimeout(function () { autoScrollEnabled = true; }, 3000);
  }
  document.addEventListener('wheel',     resetAutoScrollTimer, { passive: true });
  document.addEventListener('touchmove', resetAutoScrollTimer, { passive: true });
  document.addEventListener('keydown', function (e) {
    var scrollKeys = { ArrowUp:1, ArrowDown:1, PageUp:1, PageDown:1, Home:1, End:1, ' ':1 };
    if (scrollKeys[e.key]) resetAutoScrollTimer();
  });

  function highlightPara(idx) {
    if (activePara) activePara.classList.remove('tts-active');
    activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
    if (activePara) {
      activePara.classList.add('tts-active');
      if (autoscrollChk.checked && autoScrollEnabled) activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }

  // ── UI state helpers ──────────────────────────────────────────────────────
  var SVG_PLAY  = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M8 5v14l11-7z"/></svg>';
  var SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
  function setPlayIcon(icon) { playerPlayIcon.innerHTML = icon; }
  function getSpeed() {
    var b = document.querySelector('.speed-btn.speed-active');
    return b ? parseFloat(b.dataset.speed) : 1.5;
  }

  function setGenerating() {
    setPlayIcon('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M12 3a9 9 0 0 1 9 9" style="animation:tts-spin 0.8s linear infinite"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.8s" repeatCount="indefinite"/></path></svg>');
    playerPlayBtn.disabled = true;
    voiceSel.disabled = true; speedBtns.forEach(function(b){b.disabled=true;});
    setBadge(playerStateBadge, 'generating');
    playerStateBadge.hidden = false;
    playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
    setStatus('Generating audio\u2026');
    hideSeek();
  }
  function setPlaying() {
    if (stale()) return;
    setPlayIcon(SVG_PAUSE);
    playerPlayBtn.disabled = false;
    voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
    setBadge(playerStateBadge, 'playing');
    playerStateBadge.hidden = false;
    var pct = (audio.duration && isFinite(audio.duration))
      ? Math.round((audio.currentTime / audio.duration) * 100) : 0;
    playerStateBadge.textContent = pct + '%';
    playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
    setStatus('');
    showSeek();
    if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
  }
  function setPaused() {
    if (stale()) return;
    setPlayIcon(SVG_PLAY);
    setBadge(playerStateBadge, 'paused');
    playerStateBadge.hidden = false;
    var pct = (audio.duration && isFinite(audio.duration))
      ? Math.round((audio.currentTime / audio.duration) * 100) : 0;
    playerStateBadge.textContent = pct + '%';
    setStatus('');
    showSeek();
    if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
  }
  function setStopped() {
    highlightPara(-1);
    setPlayIcon(SVG_PLAY);
    playerPlayBtn.disabled = false;
    voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
    if (genStartTime > 0) {
      var elapsed = Math.round((Date.now() - genStartTime) / 1000);
      var label = elapsed >= 60 ? Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's' : elapsed + 's';
      setBadge(playerStateBadge, 'idle');
      playerStateBadge.textContent = label;
      playerStateBadge.classList.add('queue-badge-time');
      playerStateBadge.hidden = false;
      genStartTime = 0;
    } else {
      playerStateBadge.hidden = true;
    }
    playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
    setStatus('');
    hideSeek();
  }
  function setError(msg) {
    if (stale()) return;
    highlightPara(-1);
    setPlayIcon(SVG_PLAY);
    playerPlayBtn.disabled = false;
    voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
    setBadge(playerStateBadge, 'error');
    playerStateBadge.hidden = false;
    playerTitle.textContent = 'Error';
    setStatus('Error: ' + msg);
    hideSeek();
  }

  // ── audio generation ─────────────────────────────────────────────────────
  var currentAudioCtrl = null;
  var genStartTime     = 0;

  function generateAudio(chapterN, cb) {
    if (currentAudioCtrl) currentAudioCtrl.abort();
    var ctrl = new AbortController();
    currentAudioCtrl = ctrl;
    genStartTime = Date.now();
    fetch('/ui/audio/' + SLUG + '/' + chapterN, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ voice: voiceSel.value, speed: 1 }),
      signal: ctrl.signal
    })
    .then(function (res) {
      if (!res.ok) return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
      return res.json();
    })
    .then(function (data) {
      if (stale()) return;
      if (!data || !data.url) throw new Error('no url in response');
      cb(data.url);
    })
    .catch(function (e) {
      if (e.name === 'AbortError' || stale()) return;
      setError(e.message);
    });
  }

  // ── audio events ──────────────────────────────────────────────────────────
  function onTimeUpdate() {
    if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
    updateSeek();
    if (audio.duration && isFinite(audio.duration) && !audio.paused) {
      playerStateBadge.textContent = Math.round((audio.currentTime / audio.duration) * 100) + '%';
    }
    if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
    var idx = Math.min(Math.floor((audio.currentTime / audio.duration) * paras.length), paras.length - 1);
    if (!activePara || activePara !== paras[idx]) highlightPara(idx);
  }

  function onCanPlay()    { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } audio.autoplay = false; if (audio.paused) audio.play().then(setPlaying).catch(function(e){ if (e.name === 'NotAllowedError') setPaused(); else setError(e.message); }); }
  function onWaiting()    { if (!stale()) setStatus('Buffering\u2026'); }
  function onPlaying()    { if (!stale()) setPlaying(); }
  function onPause()      { if (!stale() && !audio.ended) setPaused(); }
  function onAudioError() { if (!stale()) setError('audio error'); }
  function onEnded() {
    if (stale()) { audio.removeEventListener('ended', onEnded); return; }
    audio.src = '';
    if (autoplayChk.checked && NEXT_N) goNextChapter(); else setStopped();
  }

  audio.addEventListener('canplay',    onCanPlay);
  audio.addEventListener('waiting',    onWaiting);
  audio.addEventListener('playing',    onPlaying);
  audio.addEventListener('pause',      onPause);
  audio.addEventListener('play',       onPlaying);
  audio.addEventListener('error',      onAudioError);
  audio.addEventListener('timeupdate', onTimeUpdate);
  audio.addEventListener('ended',      onEnded);

  // ── auto-next ─────────────────────────────────────────────────────────────
  function goNextChapter() {
    if (!NEXT_N) return;
    try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
    window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
  }

  function stop() {
    if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
    audio.pause(); audio.src = '';
    setStopped();
  }

  // ── Media Session ─────────────────────────────────────────────────────────
  function registerMediaSession() {
    if (!('mediaSession' in navigator)) return;
    navigator.mediaSession.metadata = new MediaMetadata({
      title:  'Ch.\u00a0' + CHAPTER_N,
      artist: SLUG.replace(/-/g, ' '),
      album:  'libnovel',
      artwork: COVER_URL ? [{ src: COVER_URL, sizes: '512x512', type: 'image/jpeg' }] : []
    });
    navigator.mediaSession.setActionHandler('play',  function() { audio.play().then(setPlaying).catch(function(){}); });
    navigator.mediaSession.setActionHandler('pause', function() { audio.pause(); setPaused(); });
    navigator.mediaSession.setActionHandler('stop',  function() { stop(); });
    if (NEXT_N) navigator.mediaSession.setActionHandler('nexttrack', function() { try { localStorage.setItem('tts_autostart','1'); } catch(_){} window.location.href = '/books/'+SLUG+'/chapters/'+NEXT_N; });
    if (PREV_N) navigator.mediaSession.setActionHandler('previoustrack', function() { window.location.href = '/books/'+SLUG+'/chapters/'+PREV_N; });
    navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
  }

  function loadAndPlay(url) {
    // Always set autoplay=true before load(): the browser needs this hint set
    // synchronously before load() regardless of whether we got here via a user
    // gesture or a programmatic trigger (e.g. chapter transition).  The canplay
    // handler will call play() as well, but autoplay on the element is the only
    // reliable path after an async fetch where the gesture stack is gone.
    audio.autoplay = true;
    audio.src = url; audio.load();
    audio.playbackRate = getSpeed();
    registerMediaSession();
  }

  function startAudio(autoplay) {
    setGenerating();
    highlightPara(0);
    // Check if audio was already generated for this chapter this session.
    // If so, play it directly without re-generating.
    fetch('/ui/audio/' + SLUG + '/' + CHAPTER_N + '?voice=' + encodeURIComponent(voiceSel.value) + '&speed=1')
      .then(function(res) {
        if (stale()) return;
        if (res.ok) {
          return res.json().then(function(data) {
            if (stale()) return;
            if (data && data.url) { loadAndPlay(data.url); return; }
            throw new Error('no url in response');
          });
        }
        // Not cached yet — fall through to generation.
        generateAudio(CHAPTER_N, function(url) {
          if (stale()) return;
          loadAndPlay(url);
        });
      })
      .catch(function(e) {
        if (stale()) return;
        setError(e.message);
      });
  }

  window.ttsToggle = function () {
    if (audio.src) {
      if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); });
      else audio.pause();
      return;
    }
    startAudio();
  };

  window.__ttsBeforeSwap = function () {
    // If TTS is active (generating or has audio loaded), persist the autostart
    // flag so the incoming chapter's init code will restart TTS automatically.
    var wasActive = currentAudioCtrl !== null || (audio.src && !audio.ended);
    if (wasActive) {
      try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
    }
    window.__ttsGen++; stop();
  };
  document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);

  var _autostart = false;
  try { _autostart = localStorage.getItem('tts_autostart') === '1'; } catch(_) {}
  if (_autostart) {
    try { localStorage.removeItem('tts_autostart'); } catch(_) {}
    autoplayChk.checked = true;
    setTimeout(function() { startAudio(true); }, 100);
  } else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
    autoplayChk.checked = true;
    setTimeout(function() { startAudio(true); }, 100);
  }

  // ── double-tap left/right to navigate ────────────────────────────────────
  if (window.__ttsDoubleTap) document.removeEventListener('touchend', window.__ttsDoubleTap);
  (function initDoubleTap() {
    var lastTap = 0, lastSide = '';
    var THRESHOLD   = 300;
    var INTERACTIVE = ['A','BUTTON','INPUT','SELECT','TEXTAREA','LABEL'];
    window.__ttsDoubleTap = function (e) {
      if (stale()) { document.removeEventListener('touchend', window.__ttsDoubleTap); return; }
      var el = e.target;
      while (el && el !== document.body) { if (INTERACTIVE.indexOf(el.tagName) !== -1) return; el = el.parentElement; }
      var touch = e.changedTouches[0];
      var side  = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
      var now   = Date.now();
      var gap   = now - lastTap;
      if (gap < THRESHOLD && side === lastSide) {
        lastTap = 0; lastSide = '';
        if (side === 'right' && NEXT_N) window.location.href = '/books/'+SLUG+'/chapters/'+NEXT_N;
        else if (side === 'left' && PREV_N) window.location.href = '/books/'+SLUG+'/chapters/'+PREV_N;
      } else { lastTap = now; lastSide = side; }
    };
    document.addEventListener('touchend', window.__ttsDoubleTap, { passive: true });
  }());
}());

// ── Custom voice dropdown ─────────────────────────────────────────────────
window.toggleVoiceDropdown = function () {
  var dd      = document.getElementById('voice-dropdown');
  var chevron = document.getElementById('voice-trigger-chevron');
  if (!dd) return;
  var opening = dd.hidden;
  dd.hidden = !opening;
  if (chevron) chevron.style.transform = opening ? 'rotate(180deg)' : '';
};

window.selectVoiceOption = function (btn) {
  var voiceSel = document.getElementById('tts-voice');
  var label    = document.getElementById('voice-trigger-label');
  var dd       = document.getElementById('voice-dropdown');
  var chevron  = document.getElementById('voice-trigger-chevron');
  if (!voiceSel) return;

  // update hidden select
  voiceSel.value = btn.dataset.voice;
  try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}

  // update trigger label
  if (label) label.textContent = btn.dataset.label;

  // highlight active row
  if (dd) {
    dd.querySelectorAll('.voice-opt').forEach(function (b) {
      var active = b === btn;
      b.classList.toggle('bg-zinc-800/60', active);
      b.classList.toggle('text-zinc-100',  active);
    });
    dd.hidden = true;
  }
  if (chevron) chevron.style.transform = '';

  // fire change event so existing voice listener persists
  voiceSel.dispatchEvent(new Event('change'));
};

// sync trigger label from localStorage on load
(function syncVoiceTrigger() {
  var saved = null;
  try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
  if (!saved) return;
  var btn = document.querySelector('.voice-opt[data-voice="' + saved + '"]');
  if (btn) window.selectVoiceOption(btn);
})();
</script>`

func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 {
		http.NotFound(w, r)
		return
	}

	raw, err := s.writer.ReadChapter(slug, n)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	// Strip the first heading line so it isn't rendered as a duplicate <h1>
	// inside the article (the template already renders an explicit <h1>).
	rawForHTML := stripFirstHeadingLine(raw)

	var htmlBuf bytes.Buffer
	if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil {
		http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	chapters, _ := s.writer.ListChapters(slug)
	prevN, nextN := adjacentChapters(chapters, n)

	title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
	chapterTitle, chapterDate := writer.SplitChapterTitle(title)

	// Load cover URL for Media Session artwork (best-effort; ignore errors).
	var coverURL string
	if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
		coverURL = meta.Cover
	}

	t := template.Must(template.New("chapter").Parse(chapterTmpl))
	var buf bytes.Buffer
	_ = t.Execute(&buf, struct {
		Slug         string
		HTML         template.HTML
		PrevN        int
		NextN        int
		ChapterN     int
		Title        string
		ChapterDate  string
		AllChapters  interface{}
		Voices       []voiceInfo
		DefaultVoice string
		Cover        string
	}{
		Slug:         slug,
		HTML:         template.HTML(htmlBuf.String()),
		PrevN:        prevN,
		NextN:        nextN,
		ChapterN:     n,
		Title:        chapterTitle,
		ChapterDate:  chapterDate,
		AllChapters:  chapters,
		Voices:       parseVoices(s.voices()),
		DefaultVoice: s.kokoroVoice,
		Cover:        coverURL,
	})

	s.respond(w, r, chapterTitle, buf.String())
}

// ─── helpers ──────────────────────────────────────────────────────────────────

// sortedKeys returns the keys of a string-bool map in sorted order.
func sortedKeys(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	// Simple insertion sort — sets are small (< 100 items).
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j] < out[j-1]; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out
}

// stripMarkdown removes Markdown syntax and returns clean plain text.
func stripMarkdown(src string) string {
	src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
	src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
	src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
	src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
	src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
	src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
	src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
	src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
	src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
	return strings.TrimSpace(src)
}

// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
	for i, ch := range chapters {
		if ch.Number == n {
			if i > 0 {
				prev = chapters[i-1].Number
			}
			if i < len(chapters)-1 {
				next = chapters[i+1].Number
			}
			return
		}
	}
	return
}

// stripFirstHeadingLine removes the first non-empty line if it is a markdown
// heading (starts with one or more "#"). This prevents the heading from being
// rendered as a duplicate <h1> inside the article when the template already
// renders an explicit title above the article.
func stripFirstHeadingLine(src string) string {
	lines := strings.SplitN(src, "\n", -1)
	for i, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			continue
		}
		if strings.HasPrefix(trimmed, "#") {
			// Remove this line and return the rest.
			rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n")
			return strings.TrimLeft(rest, "\n")
		}
		// First non-empty line is not a heading — nothing to strip.
		break
	}
	return src
}

// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
	for _, line := range strings.SplitN(md, "\n", 20) {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		return strings.TrimPrefix(line, "# ")
	}
	return fallback
}

// ─── POST /ui/scrape/book — form submission ───────────────────────────────────

func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
	bookURL := strings.TrimSpace(r.FormValue("url"))
	if bookURL == "" {
		renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
		return
	}

	s.mu.Lock()
	already := s.running
	if !already {
		s.running = true
	}
	s.mu.Unlock()

	if already {
		renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
		return
	}

	cfg := s.oCfg
	cfg.SingleBookURL = bookURL

	go func() {
		defer func() {
			s.mu.Lock()
			s.running = false
			s.mu.Unlock()
		}()

		ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
		defer cancel()

		o := orchestrator.New(cfg, s.novel, s.log)
		if err := o.Run(ctx); err != nil {
			s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
		}
	}()

	// Return a status badge that polls until the job finishes.
	renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}

// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────

func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	running := s.running
	s.mu.Unlock()

	if running {
		// Keep polling every 3 s while the job is in progress.
		renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
		return
	}
	// Job finished — show a done badge and stop polling.
	renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}

// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
	var colour, dot, poll string
	switch state {
	case "running":
		colour = "text-amber-300 bg-amber-950 border-amber-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
		poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
	case "done":
		colour = "text-green-300 bg-green-950 border-green-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
	case "busy":
		colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
	default: // error
		colour = "text-red-300 bg-red-950 border-red-800"
		dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
	}
	return fmt.Sprintf(
		`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
		colour, poll, dot, template.HTMLEscapeString(msg),
	)
}
