package backend

// handlers.go — all HTTP request handlers for the backend server.
//
// Handler naming mirrors the route table in server.go:
//   handleScrapeCatalogue, handleScrapeBook, handleScrapeBookRange
//   handleScrapeStatus, handleScrapeTasks
//   handleBrowse, handleSearch
//   handleGetRanking, handleGetCover
//   handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
//   handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
//   handleTTSAnnounce
//   handleVoices
//   handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
//   handlePresignAvatarUpload, handlePresignAvatar
//   handleGetProgress, handleSetProgress, handleDeleteProgress
//
// Key design choices vs. old scraper:
//   - POST /scrape* creates a PocketBase task record and returns 202 with the
//     task_id — it does NOT run the orchestrator inline.
//   - POST /api/audio creates a PocketBase audio task and returns 202 — the
//     runner binary executes TTS generation asynchronously.
//   - GET /api/audio/status polls PocketBase for the task record status.
//   - GET /api/audio-proxy reads the completed audio object from MinIO via a
//     presigned URL redirect (the runner has already uploaded the bytes).
//   - GET /api/browse and /api/search fetch novelfire.net live (no MinIO cache).
//   - GET /api/cover redirects to the source cover URL live.
//   - GET /api/ranking reads from the PocketBase ranking collection (populated
//     by the runner after each catalogue scrape).
//   - GET /api/book-preview returns stored data when in library, or enqueues a
//     scrape task and returns 202 when not. The backend never scrapes directly.
//   - GET /api/chapter-text-preview scrapes a chapter live from novelfire.net
//     directly (no runner task, no store writes). Used for unscraped books.

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/libnovel/backend/internal/cfai"
	"github.com/libnovel/backend/internal/domain"
	"github.com/libnovel/backend/internal/kokoro"
	"github.com/libnovel/backend/internal/meili"
	"github.com/libnovel/backend/internal/novelfire/htmlutil"
	"github.com/libnovel/backend/internal/pockettts"
	"github.com/libnovel/backend/internal/scraper"
	"github.com/yuin/goldmark"
)

const (
	novelFireBase   = "https://novelfire.net"
	novelFireDomain = "novelfire.net"
)

// ── Scrape task creation ───────────────────────────────────────────────────────

// handleScrapeCatalogue handles POST /scrape.
// Creates a "catalogue" scrape task in PocketBase and returns 202 with the task ID.
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
	taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "catalogue", "", 0, 0)
	if err != nil {
		s.deps.Log.Error("handleScrapeCatalogue: CreateScrapeTask failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to create task")
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}

// handleScrapeBook handles POST /scrape/book.
// Body: {"url": "https://novelfire.net/book/..."}
func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) {
	var body struct {
		URL string `json:"url"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
		jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
		return
	}
	taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book", body.URL, 0, 0)
	if err != nil {
		s.deps.Log.Error("handleScrapeBook: CreateScrapeTask failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to create task")
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}

// handleScrapeBookRange handles POST /scrape/book/range.
// Body: {"url": "...", "from": N, "to": M}
func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) {
	var body struct {
		URL  string `json:"url"`
		From int    `json:"from"`
		To   int    `json:"to"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
		jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
		return
	}
	taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book_range", body.URL, body.From, body.To)
	if err != nil {
		s.deps.Log.Error("handleScrapeBookRange: CreateScrapeTask failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to create task")
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}

// handleCancelTask handles POST /api/cancel-task/{id}.
// Transitions a pending task (scrape or audio) to status=cancelled.
// Returns 404 if the task does not exist, 409 if it cannot be cancelled
// (e.g. already running/done).
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		jsonError(w, http.StatusBadRequest, "missing task id")
		return
	}
	if err := s.deps.Producer.CancelTask(r.Context(), id); err != nil {
		s.deps.Log.Warn("handleCancelTask: CancelTask failed", "id", id, "err", err)
		jsonError(w, http.StatusConflict, "could not cancel task: "+err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled", "id": id})
}

// ── Scrape task status / history ───────────────────────────────────────────────

// handleScrapeStatus handles GET /api/scrape/status.
// Returns the most recent scrape task status (or {"running":false} if none).
func (s *Server) handleScrapeStatus(w http.ResponseWriter, r *http.Request) {
	tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
	if err != nil {
		s.deps.Log.Error("handleScrapeStatus: ListScrapeTasks failed", "err", err)
		writeJSON(w, 0, map[string]bool{"running": false})
		return
	}
	running := false
	for _, t := range tasks {
		if t.Status == domain.TaskStatusRunning || t.Status == domain.TaskStatusPending {
			running = true
			break
		}
	}
	writeJSON(w, 0, map[string]bool{"running": running})
}

// handleScrapeTasks handles GET /api/scrape/tasks.
// Returns all scrape task records from PocketBase, newest first.
func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) {
	tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
	if err != nil {
		s.deps.Log.Error("handleScrapeTasks: ListScrapeTasks failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to list tasks")
		return
	}
	if tasks == nil {
		tasks = []domain.ScrapeTask{}
	}
	writeJSON(w, 0, tasks)
}

// ── Browse & search ────────────────────────────────────────────────────────────

// NovelListing represents a single novel entry from the novelfire browse/search page.
type NovelListing struct {
	Slug     string `json:"slug"`
	Title    string `json:"title"`
	Cover    string `json:"cover"`
	Rank     string `json:"rank"`
	Rating   string `json:"rating"`
	Chapters string `json:"chapters"`
	URL      string `json:"url"`
}

// handleSearch handles GET /api/search.
// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all")
//
// Local search is powered by Meilisearch when configured; falls back to a
// substring match against PocketBase book records otherwise.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query().Get("q")
	if len([]rune(q)) < 2 {
		jsonError(w, http.StatusBadRequest, "query must be at least 2 characters")
		return
	}

	source := r.URL.Query().Get("source")
	if source == "" {
		source = "all"
	}

	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
	defer cancel()

	var localResults, remoteResults []NovelListing

	// Local search: Meilisearch → PocketBase substring fallback
	if source == "local" || source == "all" {
		meiliBooks, meiliErr := s.deps.SearchIndex.Search(ctx, q, 50)
		if meiliErr == nil && len(meiliBooks) > 0 {
			for _, b := range meiliBooks {
				localResults = append(localResults, NovelListing{
					Slug:  b.Slug,
					Title: b.Title,
					Cover: b.Cover,
					URL:   b.SourceURL,
				})
			}
		} else {
			// Fallback: substring match against PocketBase
			books, err := s.deps.BookReader.ListBooks(ctx)
			if err != nil {
				s.deps.Log.Warn("search: ListBooks failed", "err", err)
			} else {
				qLower := strings.ToLower(q)
				for _, b := range books {
					if strings.Contains(strings.ToLower(b.Title), qLower) ||
						strings.Contains(strings.ToLower(b.Author), qLower) {
						localResults = append(localResults, NovelListing{
							Slug:  b.Slug,
							Title: b.Title,
							Cover: b.Cover,
							URL:   b.SourceURL,
						})
					}
				}
			}
		}
	}

	// Remote search (novelfire.net)
	if source == "remote" || source == "all" {
		searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q)
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
		if err == nil {
			req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
			req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
			if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil {
				defer resp.Body.Close()
				if resp.StatusCode == http.StatusOK {
					parsed, _ := parseBrowsePage(resp.Body)
					remoteResults = parsed
				}
			}
		}
	}

	// Merge: local first, de-duplicate remote
	localSlugs := make(map[string]bool, len(localResults))
	for _, item := range localResults {
		localSlugs[item.Slug] = true
	}
	combined := make([]NovelListing, 0, len(localResults)+len(remoteResults))
	combined = append(combined, localResults...)
	for _, item := range remoteResults {
		if !localSlugs[item.Slug] {
			combined = append(combined, item)
		}
	}

	writeJSON(w, 0, map[string]any{
		"results":      combined,
		"local_count":  len(localResults),
		"remote_count": len(remoteResults),
	})
}

// ── Ranking ────────────────────────────────────────────────────────────────────

// handleGetRanking handles GET /api/ranking.
// Returns all ranking items sorted by rank ascending.
func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
	items, err := s.deps.RankingStore.ReadRankingItems(r.Context())
	if err != nil {
		s.deps.Log.Error("handleGetRanking: ReadRankingItems failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to read ranking")
		return
	}
	if items == nil {
		items = []domain.RankingItem{}
	}
	writeJSON(w, 0, items)
}

// handleGetCover handles GET /api/cover/{domain}/{slug}.
// Serves the cover image directly from MinIO when available; falls back to a
// redirect to the novelfire CDN when the cover has not yet been downloaded.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		http.Error(w, "missing slug", http.StatusBadRequest)
		return
	}

	// Fast path: serve from MinIO if the cover has been downloaded.
	if s.deps.CoverStore != nil {
		data, ct, ok, err := s.deps.CoverStore.GetCover(r.Context(), slug)
		if err != nil {
			s.deps.Log.Warn("handleGetCover: GetCover error", "slug", slug, "err", err)
		}
		if ok && len(data) > 0 {
			if ct == "" {
				ct = "image/jpeg"
			}
			w.Header().Set("Content-Type", ct)
			w.Header().Set("Cache-Control", "public, max-age=86400")
			_, _ = w.Write(data)
			return
		}
	}

	// Fallback: redirect to the CDN. The caller sees a working image; the
	// cover will be populated on the next catalogue refresh run.
	coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
	http.Redirect(w, r, coverURL, http.StatusFound)
}

// ── Preview (live scrape, no store writes) ─────────────────────────────────────

// handleBookPreview handles GET /api/book-preview/{slug}.
//
// If the book is already in the library (PocketBase), returns its metadata and
// chapter index immediately (200).
//
// If the book is not yet in the library, enqueues a "book" scrape task and
// returns 202 Accepted with the task_id. The runner will scrape the book
// asynchronously; the client should poll GET /api/scrape/status or
// GET /api/scrape/tasks to detect completion, then re-request this endpoint.
//
// The backend never scrapes directly — all scraping is the runner's job.
func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	ctx := r.Context()

	meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
	if err != nil {
		s.deps.Log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err)
		inLib = false
	}

	if inLib {
		// Fast path: book is already scraped — return stored data.
		chapters, cerr := s.deps.BookReader.ListChapters(ctx, slug)
		if cerr != nil {
			s.deps.Log.Warn("book-preview: ListChapters failed", "slug", slug, "err", cerr)
		}
		writeJSON(w, 0, map[string]any{
			"in_lib":   true,
			"meta":     meta,
			"chapters": chapters,
		})
		return
	}

	// Book not in library — enqueue a range scrape task for the first 20 chapters
	// so the user can start reading quickly. Remaining chapters can be scraped
	// later via the book detail page or the admin scrape panel.
	bookURL := r.URL.Query().Get("source_url")
	if bookURL == "" {
		bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug)
	}

	const previewFrom, previewTo = 1, 20
	taskID, err := s.deps.Producer.CreateScrapeTask(ctx, "book_range", bookURL, previewFrom, previewTo)
	if err != nil {
		s.deps.Log.Error("book-preview: CreateScrapeTask failed", "slug", slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to enqueue scrape task")
		return
	}

	s.deps.Log.Info("book-preview: enqueued range scrape task", "slug", slug, "task_id", taskID,
		"from", previewFrom, "to", previewTo)
	writeJSON(w, http.StatusAccepted, map[string]any{
		"in_lib":  false,
		"task_id": taskID,
		"message": fmt.Sprintf("scraping first %d chapters; poll /api/scrape/tasks for completion", previewTo),
	})
}

// ── Chapter text ───────────────────────────────────────────────────────────────

// handleChapterText handles GET /api/chapter-text/{slug}/{n}.
// Returns plain text (markdown stripped) of a stored chapter.
func (s *Server) handleChapterText(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.deps.BookReader.ReadChapter(r.Context(), slug, n)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	fmt.Fprint(w, stripMarkdown(raw))
}

// handleChapterMarkdown handles GET /api/chapter-markdown/{slug}/{n}.
//
// Returns the raw markdown content of a stored chapter directly from MinIO.
// This is used by the SvelteKit UI as a simpler alternative to presign+fetch:
// it avoids the need for the SvelteKit server to reach MinIO directly, and
// gives a clean 404 when the chapter has not been scraped yet.
func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
		return
	}
	raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
	if err != nil {
		s.deps.Log.Warn("chapter-markdown: not found in MinIO", "slug", slug, "n", n, "err", err)
		http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	fmt.Fprint(w, raw)
}

// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}.
//
// Fetches a chapter live from novelfire.net and returns its plain text without
// writing anything to PocketBase or MinIO. This is the preview path used when
// a chapter has not yet been scraped into the library.
//
// Optional query params:
//
//	chapter_url — the canonical chapter URL (preferred over constructing one)
//	title       — hint for the chapter title (used when the page title is empty)
//
// Response: {"slug":string,"number":int,"title":string,"text":string,"url":string}
func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid slug or chapter number")
		return
	}

	// Determine the chapter URL to fetch.
	chapterURL := r.URL.Query().Get("chapter_url")
	if chapterURL == "" {
		// Best-effort: novelfire chapter URLs follow /book/{slug}/chapter-{n}
		chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n)
	}

	titleHint := r.URL.Query().Get("title")

	ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
	defer cancel()

	// Fetch the chapter page.
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, chapterURL, nil)
	if err != nil {
		s.deps.Log.Error("chapter-text-preview: build request failed", "url", chapterURL, "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to build request")
		return
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		s.deps.Log.Warn("chapter-text-preview: fetch failed", "url", chapterURL, "err", err)
		jsonError(w, http.StatusBadGateway, "failed to fetch chapter")
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		jsonError(w, http.StatusNotFound, "chapter not found")
		return
	}
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		s.deps.Log.Warn("chapter-text-preview: upstream error",
			"url", chapterURL, "status", resp.StatusCode, "body_snippet", string(body))
		jsonError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode))
		return
	}

	bodyBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		s.deps.Log.Error("chapter-text-preview: read body failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to read response")
		return
	}

	// Parse HTML and extract the #content node.
	root, err := htmlutil.ParseHTML(string(bodyBytes))
	if err != nil {
		s.deps.Log.Error("chapter-text-preview: html parse failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to parse chapter HTML")
		return
	}

	container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
	if container == nil {
		s.deps.Log.Warn("chapter-text-preview: #content not found", "url", chapterURL)
		jsonError(w, http.StatusNotFound, "chapter content not found on page")
		return
	}

	markdownText := htmlutil.NodeToMarkdown(container)
	plainText := stripMarkdown(markdownText)

	// Extract the chapter title from the page <title> or <h1> if not hinted.
	chapterTitle := titleHint
	if chapterTitle == "" {
		// Try <h1 class="chapter-title"> first, then <h2 class="chapter-title">
		for _, tag := range []string{"h1", "h2", "h3"} {
			if node := htmlutil.FindFirst(root, scraper.Selector{Tag: tag, Class: "chapter-title"}); node != nil {
				chapterTitle = strings.TrimSpace(htmlutil.TextContent(node))
				break
			}
		}
	}
	if chapterTitle == "" {
		chapterTitle = fmt.Sprintf("Chapter %d", n)
	}

	writeJSON(w, 0, map[string]any{
		"slug":   slug,
		"number": n,
		"title":  chapterTitle,
		"text":   plainText,
		"url":    chapterURL,
	})
}

// handleReindex handles POST /api/reindex/{slug}.
// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects.
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	count, err := s.deps.BookReader.ReindexChapters(r.Context(), slug)
	if err != nil {
		s.deps.Log.Error("reindex failed", "slug", slug, "indexed", count, "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]any{
			"error":   err.Error(),
			"indexed": count,
		})
		return
	}

	s.deps.Log.Info("reindex complete", "slug", slug, "indexed", count)
	writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
}

// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
// Removes duplicate chapters_idx records for a book, keeping the latest record
// per chapter number. Returns the number of duplicate records deleted.
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
	if err != nil {
		s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]any{
			"error":   err.Error(),
			"deleted": deleted,
		})
		return
	}

	s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
	writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
}

// ── Audio ──────────────────────────────────────────────────────────────────────

// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
// Creates an audio_jobs task in PocketBase (runner executes asynchronously).
// Returns 200 immediately if audio already exists in MinIO.
// Returns 202 with the task_id if a new task was created.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 {
		jsonError(w, http.StatusBadRequest, "invalid chapter")
		return
	}

	voice := s.cfg.DefaultVoice
	var body struct {
		Voice string `json:"voice"`
	}
	if r.Body != nil {
		_ = json.NewDecoder(r.Body).Decode(&body)
	}
	if body.Voice != "" {
		voice = body.Voice
	}

	cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)

	// Fast path: audio already in MinIO
	audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
	if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
		proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
		writeJSON(w, 0, map[string]string{"url": proxyURL, "status": "done"})
		return
	}

	// Check if a task is already pending/running
	task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
	if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
		writeJSON(w, http.StatusAccepted, map[string]string{
			"task_id": task.ID,
			"status":  string(task.Status),
		})
		return
	}

	// Create a new audio task
	taskID, err := s.deps.Producer.CreateAudioTask(r.Context(), slug, n, voice)
	if err != nil {
		s.deps.Log.Error("handleAudioGenerate: CreateAudioTask failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to create audio task")
		return
	}

	writeJSON(w, http.StatusAccepted, map[string]string{
		"task_id": taskID,
		"status":  "pending",
	})
}

// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
// Polls PocketBase for the audio task status.
// Query params: voice (optional, defaults to DefaultVoice)
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid params")
		return
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	// Fast path: audio exists in MinIO
	audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
	if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
		proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
		writeJSON(w, 0, map[string]string{
			"status": "done",
			"url":    proxyURL,
		})
		return
	}

	cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
	task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
	if !found {
		writeJSON(w, 0, map[string]string{"status": "idle"})
		return
	}

	resp := map[string]string{
		"status":  string(task.Status),
		"task_id": task.ID,
	}
	if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
		resp["error"] = task.ErrorMessage
	}
	writeJSON(w, 0, resp)
}

// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
// Redirects to a presigned MinIO URL for the generated audio object.
// Query params: voice (optional, defaults to DefaultVoice)
func (s *Server) handleAudioProxy(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
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
	if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
		http.Error(w, "audio not generated yet", http.StatusNotFound)
		return
	}

	presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
	if err != nil {
		s.deps.Log.Error("handleAudioProxy: PresignAudio failed", "slug", slug, "n", n, "err", err)
		http.Error(w, "presign failed", http.StatusInternalServerError)
		return
	}

	http.Redirect(w, r, presignURL, http.StatusFound)
}

// handleAudioStream handles GET /api/audio-stream/{slug}/{n}.
//
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
// (same as handleAudioProxy) — the client plays from storage immediately.
//
// Slow path (first request): streams audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, subsequent
// requests hit the fast path and skip TTS generation entirely.
//
// Query params:
//
//	voice  (optional, defaults to DefaultVoice)
//	format (optional, "mp3" or "wav"; defaults to "mp3")
//
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
// raw WAV frames to the client with lower latency at the cost of larger files.
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 {
		jsonError(w, http.StatusBadRequest, "invalid chapter")
		return
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	format := r.URL.Query().Get("format")
	if format != "wav" {
		format = "mp3"
	}

	contentType := "audio/mpeg"
	if format == "wav" {
		contentType = "audio/wav"
	}

	audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)

	// ── Fast path: already in MinIO ───────────────────────────────────────────
	if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
		presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
		if err != nil {
			s.deps.Log.Error("handleAudioStream: PresignAudio failed", "slug", slug, "n", n, "err", err)
			jsonError(w, http.StatusInternalServerError, "presign failed")
			return
		}
		http.Redirect(w, r, presignURL, http.StatusFound)
		return
	}

	// ── Slow path: generate + stream + save ───────────────────────────────────

	// Read the chapter text.
	raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
	if err != nil {
		s.deps.Log.Error("handleAudioStream: ReadChapter failed", "slug", slug, "n", n, "err", err)
		jsonError(w, http.StatusNotFound, "chapter not found")
		return
	}
	text := stripMarkdown(raw)
	if text == "" {
		jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
		return
	}

	// Open the TTS stream (WAV or MP3 depending on format param).
	var audioStream io.ReadCloser
	if format == "wav" {
		if cfai.IsCFAIVoice(voice) {
			if s.deps.CFAI == nil {
				jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
				return
			}
			audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
		} else if pockettts.IsPocketTTSVoice(voice) {
			if s.deps.PocketTTS == nil {
				jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
				return
			}
			audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
		} else {
			if s.deps.Kokoro == nil {
				jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
				return
			}
			audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
		}
	} else {
		if cfai.IsCFAIVoice(voice) {
			if s.deps.CFAI == nil {
				jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
				return
			}
			audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
		} else if pockettts.IsPocketTTSVoice(voice) {
			if s.deps.PocketTTS == nil {
				jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
				return
			}
			audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
		} else {
			if s.deps.Kokoro == nil {
				jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
				return
			}
			audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
		}
	}
	if err != nil {
		s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
		jsonError(w, http.StatusInternalServerError, "tts stream failed")
		return
	}
	defer audioStream.Close()

	// Tee: every byte read from audioStream is written to both the HTTP
	// response and a pipe that feeds the MinIO upload goroutine.
	pr, pw := io.Pipe()

	// MinIO upload runs concurrently. Size -1 triggers multipart upload.
	uploadDone := make(chan error, 1)
	go func() {
		uploadDone <- s.deps.AudioStore.PutAudioStream(
			context.Background(), // use background — request ctx may cancel after client disconnects
			audioKey, pr, -1, contentType,
		)
	}()

	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
	w.WriteHeader(http.StatusOK)

	flusher, canFlush := w.(http.Flusher)

	tee := io.TeeReader(audioStream, pw)
	buf := make([]byte, 32*1024)
	for {
		nr, readErr := tee.Read(buf)
		if nr > 0 {
			if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
				// Client disconnected — abort upload pipe so goroutine exits.
				pw.CloseWithError(writeErr)
				<-uploadDone
				return
			}
			if canFlush {
				flusher.Flush()
			}
		}
		if readErr != nil {
			if readErr == io.EOF {
				break
			}
			s.deps.Log.Warn("handleAudioStream: read error mid-stream", "err", readErr)
			pw.CloseWithError(readErr)
			<-uploadDone
			return
		}
	}

	// Signal end of stream to the MinIO upload goroutine.
	pw.Close()
	if uploadErr := <-uploadDone; uploadErr != nil {
		s.deps.Log.Error("handleAudioStream: MinIO upload failed", "key", audioKey, "err", uploadErr)
		// Audio was already streamed to the client — just log; don't error.
		// The next request will re-stream since the object is absent.
	}
	// Note: we do not call FinishAudioTask here — the backend has no Consumer.
	// handleAudioStatus fast-paths on AudioExists, so the UI will see "done"
	// on its next poll as soon as the MinIO object is present.
}

// handleTTSAnnounce handles GET /api/tts-announce.
//
// Streams a short TTS clip for arbitrary text — used by the UI to announce
// the upcoming chapter number/title through the real <audio> element instead
// of the Web Speech API (which is silently muted on mobile after the audio
// session ends).
//
// Query params:
//   - text  — the text to synthesize (required, max 300 chars)
//   - voice — voice ID (defaults to server default)
//   - format — "mp3" or "wav" (default "mp3")
//
// No MinIO caching — announcement clips are tiny and ephemeral.
func (s *Server) handleTTSAnnounce(w http.ResponseWriter, r *http.Request) {
	text := r.URL.Query().Get("text")
	if text == "" {
		jsonError(w, http.StatusBadRequest, "text is required")
		return
	}
	if len(text) > 300 {
		text = text[:300]
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	format := r.URL.Query().Get("format")
	if format != "wav" {
		format = "mp3"
	}

	contentType := "audio/mpeg"
	if format == "wav" {
		contentType = "audio/wav"
	}

	var (
		audioStream io.ReadCloser
		err         error
	)

	if format == "wav" {
		if cfai.IsCFAIVoice(voice) {
			if s.deps.CFAI == nil {
				jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
				return
			}
			audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
		} else if pockettts.IsPocketTTSVoice(voice) {
			if s.deps.PocketTTS == nil {
				jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
				return
			}
			audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
		} else {
			if s.deps.Kokoro == nil {
				jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
				return
			}
			audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
		}
	} else {
		if cfai.IsCFAIVoice(voice) {
			if s.deps.CFAI == nil {
				jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
				return
			}
			audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
		} else if pockettts.IsPocketTTSVoice(voice) {
			if s.deps.PocketTTS == nil {
				jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
				return
			}
			audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
		} else {
			if s.deps.Kokoro == nil {
				jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
				return
			}
			audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
		}
	}
	if err != nil {
		s.deps.Log.Error("handleTTSAnnounce: TTS stream failed", "voice", voice, "err", err)
		jsonError(w, http.StatusInternalServerError, "tts stream failed")
		return
	}
	defer audioStream.Close()

	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Accel-Buffering", "no")
	w.WriteHeader(http.StatusOK)

	flusher, canFlush := w.(http.Flusher)
	buf := make([]byte, 32*1024)
	for {
		nr, readErr := audioStream.Read(buf)
		if nr > 0 {
			if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
				return
			}
			if canFlush {
				flusher.Flush()
			}
		}
		if readErr != nil {
			break
		}
	}
}
//
// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,
// roughly 1-2 minutes of audio) so the client can start playing immediately
// while the full audio is generated in the background by the runner.
//
// Fast path: if a preview object already exists in MinIO, redirects to its
// presigned URL (no regeneration).
//
// Slow path: generates the first chunk via CF AI, streams the MP3 bytes to the
// client, and simultaneously uploads to MinIO under a "_preview" key so future
// requests hit the fast path.
//
// Only CF AI voices are expected here. Calling this with a Kokoro/PocketTTS
// voice falls back to the normal audio-stream endpoint behaviour.
//
// Query params:
//
//	voice  (required — must be a cfai: voice)
func (s *Server) handleAudioPreview(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 {
		jsonError(w, http.StatusBadRequest, "invalid chapter")
		return
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	if s.deps.CFAI == nil {
		jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
		return
	}

	// Preview key: same as normal key with a "_preview" suffix before the extension.
	// e.g. slug/1/cfai:luna_preview.mp3
	previewKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice+"_preview", "mp3")

	// ── Fast path: preview already in MinIO ──────────────────────────────────
	if s.deps.AudioStore.AudioExists(r.Context(), previewKey) {
		presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), previewKey, 1*time.Hour)
		if err != nil {
			s.deps.Log.Error("handleAudioPreview: PresignAudio failed", "slug", slug, "n", n, "err", err)
			jsonError(w, http.StatusInternalServerError, "presign failed")
			return
		}
		http.Redirect(w, r, presignURL, http.StatusFound)
		return
	}

	// ── Slow path: generate first chunk + stream + save ──────────────────────

	// Read the chapter text.
	raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
	if err != nil {
		s.deps.Log.Error("handleAudioPreview: ReadChapter failed", "slug", slug, "n", n, "err", err)
		jsonError(w, http.StatusNotFound, "chapter not found")
		return
	}
	text := stripMarkdown(raw)
	if text == "" {
		jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
		return
	}

	// Take only the first ~1 800 characters — one CF AI chunk, roughly 1-2 min.
	const previewChars = 1800
	firstChunk := text
	if len([]rune(text)) > previewChars {
		runes := []rune(text)
		firstChunk = string(runes[:previewChars])
		// Walk back to last sentence boundary (. ! ?) to avoid a mid-word cut.
		for i := previewChars - 1; i > previewChars/2; i-- {
			r := runes[i]
			if r == '.' || r == '!' || r == '?' || r == '\n' {
				firstChunk = string(runes[:i+1])
				break
			}
		}
	}

	// Generate the preview chunk via CF AI.
	mp3, err := s.deps.CFAI.GenerateAudio(r.Context(), firstChunk, voice)
	if err != nil {
		s.deps.Log.Error("handleAudioPreview: GenerateAudio failed", "slug", slug, "n", n, "voice", voice, "err", err)
		jsonError(w, http.StatusInternalServerError, "tts generation failed")
		return
	}

	// Upload to MinIO in the background so the next request hits the fast path.
	go func() {
		if uploadErr := s.deps.AudioStore.PutAudio(
			context.Background(), previewKey, mp3,
		); uploadErr != nil {
			s.deps.Log.Error("handleAudioPreview: MinIO upload failed", "key", previewKey, "err", uploadErr)
		}
	}()

	w.Header().Set("Content-Type", "audio/mpeg")
	w.Header().Set("Content-Length", strconv.Itoa(len(mp3)))
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(mp3)
}

// ── Translation ────────────────────────────────────────────────────────────────

// supportedTranslationLangs is the set of target locales the backend accepts.
// Source is always "en".
var supportedTranslationLangs = map[string]bool{
	"ru": true, "id": true, "pt": true, "fr": true,
}

// handleTranslationGenerate handles POST /api/translation/{slug}/{n}.
// Query params: lang (required, one of ru|id|pt|fr)
//
// Returns 200 immediately if translation already exists in MinIO.
// Returns 202 with task_id if a new task was created.
// Returns 503 if TranslationStore is nil (feature disabled).
func (s *Server) handleTranslationGenerate(w http.ResponseWriter, r *http.Request) {
	if s.deps.TranslationStore == nil {
		jsonError(w, http.StatusServiceUnavailable, "machine translation not configured")
		return
	}

	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 {
		jsonError(w, http.StatusBadRequest, "invalid chapter")
		return
	}

	lang := r.URL.Query().Get("lang")
	if !supportedTranslationLangs[lang] {
		jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
		return
	}

	cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)

	// Fast path: translation already in MinIO
	key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
	if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
		writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
		return
	}

	// Check if a task is already pending/running
	task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
	if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
		writeJSON(w, http.StatusAccepted, map[string]string{
			"task_id": task.ID,
			"status":  string(task.Status),
			"lang":    lang,
		})
		return
	}

	// Create a new translation task
	taskID, err := s.deps.Producer.CreateTranslationTask(r.Context(), slug, n, lang)
	if err != nil {
		s.deps.Log.Error("handleTranslationGenerate: CreateTranslationTask failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to create translation task")
		return
	}

	writeJSON(w, http.StatusAccepted, map[string]string{
		"task_id": taskID,
		"status":  "pending",
		"lang":    lang,
	})
}

// handleTranslationStatus handles GET /api/translation/status/{slug}/{n}.
// Query params: lang (required)
func (s *Server) handleTranslationStatus(w http.ResponseWriter, r *http.Request) {
	if s.deps.TranslationStore == nil {
		writeJSON(w, 0, map[string]string{"status": "unavailable"})
		return
	}

	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid params")
		return
	}

	lang := r.URL.Query().Get("lang")
	if !supportedTranslationLangs[lang] {
		jsonError(w, http.StatusBadRequest, "unsupported lang")
		return
	}

	// Fast path: translation exists in MinIO
	key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
	if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
		writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
		return
	}

	cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)
	task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
	if !found {
		writeJSON(w, 0, map[string]string{"status": "idle", "lang": lang})
		return
	}

	resp := map[string]string{
		"status":  string(task.Status),
		"task_id": task.ID,
		"lang":    lang,
	}
	if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
		resp["error"] = task.ErrorMessage
	}
	writeJSON(w, 0, resp)
}

// handleTranslationRead handles GET /api/translation/{slug}/{n}.
// Query params: lang (required)
//
// Returns {"html": "<p>...</p>", "lang": "ru"} from the MinIO-cached translation.
// Returns 404 when the translation has not been generated yet.
func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
	if s.deps.TranslationStore == nil {
		http.Error(w, `{"error":"machine translation not configured"}`, http.StatusServiceUnavailable)
		return
	}

	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid params")
		return
	}

	lang := r.URL.Query().Get("lang")
	if !supportedTranslationLangs[lang] {
		jsonError(w, http.StatusBadRequest, "unsupported lang")
		return
	}

	key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
	md, err := s.deps.TranslationStore.GetTranslation(r.Context(), key)
	if err != nil {
		s.deps.Log.Warn("handleTranslationRead: translation not found", "slug", slug, "n", n, "lang", lang, "err", err)
		jsonError(w, http.StatusNotFound, "translation not available")
		return
	}

	var buf bytes.Buffer
	if err := goldmark.Convert([]byte(md), &buf); err != nil {
		s.deps.Log.Error("handleTranslationRead: markdown conversion failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to render translation")
		return
	}

	writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
}

// handleAdminTranslationJobs handles GET /api/admin/translation/jobs.
// Returns the full list of translation jobs sorted by started descending.
func (s *Server) handleAdminTranslationJobs(w http.ResponseWriter, r *http.Request) {
	tasks, err := s.deps.TaskReader.ListTranslationTasks(r.Context())
	if err != nil {
		s.deps.Log.Error("handleAdminTranslationJobs: ListTranslationTasks failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to list translation jobs")
		return
	}
	type jobRow struct {
		ID           string `json:"id"`
		CacheKey     string `json:"cache_key"`
		Slug         string `json:"slug"`
		Chapter      int    `json:"chapter"`
		Lang         string `json:"lang"`
		Status       string `json:"status"`
		WorkerID     string `json:"worker_id"`
		ErrorMessage string `json:"error_message"`
		Started      string `json:"started"`
		Finished     string `json:"finished"`
	}
	rows := make([]jobRow, 0, len(tasks))
	for _, t := range tasks {
		rows = append(rows, jobRow{
			ID:           t.ID,
			CacheKey:     t.CacheKey,
			Slug:         t.Slug,
			Chapter:      t.Chapter,
			Lang:         t.Lang,
			Status:       string(t.Status),
			WorkerID:     t.WorkerID,
			ErrorMessage: t.ErrorMessage,
			Started:      t.Started.Format(time.RFC3339),
			Finished:     t.Finished.Format(time.RFC3339),
		})
	}
	writeJSON(w, 0, map[string]any{"jobs": rows})
}

// handleAdminTranslationBulk handles POST /api/admin/translation/bulk.
// Body: {"slug": "...", "lang": "ru", "from": 1, "to": 50}
// Enqueues one translation task per chapter in the range [from, to] inclusive.
func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Slug string `json:"slug"`
		Lang string `json:"lang"`
		From int    `json:"from"`
		To   int    `json:"to"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}
	if body.Slug == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if !supportedTranslationLangs[body.Lang] {
		jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
		return
	}
	if body.From < 1 || body.To < body.From {
		jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
		return
	}
	if body.To-body.From > 999 {
		jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
		return
	}

	var taskIDs []string
	for n := body.From; n <= body.To; n++ {
		id, err := s.deps.Producer.CreateTranslationTask(r.Context(), body.Slug, n, body.Lang)
		if err != nil {
			s.deps.Log.Error("handleAdminTranslationBulk: CreateTranslationTask failed",
				"slug", body.Slug, "chapter", n, "lang", body.Lang, "err", err)
			jsonError(w, http.StatusInternalServerError,
				fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
			return
		}
		taskIDs = append(taskIDs, id)
	}

	writeJSON(w, http.StatusAccepted, map[string]any{
		"enqueued": len(taskIDs),
		"task_ids": taskIDs,
	})
}

// ── Admin Audio ────────────────────────────────────────────────────────────────

// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
// Returns all audio jobs, optionally filtered by slug (?slug=...).
// Sorted by started descending.
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
	tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
	if err != nil {
		s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
		return
	}

	// Optional slug filter.
	slugFilter := r.URL.Query().Get("slug")

	type jobRow struct {
		ID           string `json:"id"`
		CacheKey     string `json:"cache_key"`
		Slug         string `json:"slug"`
		Chapter      int    `json:"chapter"`
		Voice        string `json:"voice"`
		Status       string `json:"status"`
		WorkerID     string `json:"worker_id"`
		ErrorMessage string `json:"error_message"`
		Started      string `json:"started"`
		Finished     string `json:"finished"`
	}
	rows := make([]jobRow, 0, len(tasks))
	for _, t := range tasks {
		if slugFilter != "" && t.Slug != slugFilter {
			continue
		}
		rows = append(rows, jobRow{
			ID:           t.ID,
			CacheKey:     t.CacheKey,
			Slug:         t.Slug,
			Chapter:      t.Chapter,
			Voice:        t.Voice,
			Status:       string(t.Status),
			WorkerID:     t.WorkerID,
			ErrorMessage: t.ErrorMessage,
			Started:      t.Started.Format(time.RFC3339),
			Finished:     t.Finished.Format(time.RFC3339),
		})
	}
	writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
}

// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
//
// Enqueues one audio task per chapter in [from, to].
// skip_existing (default true): skip chapters already cached in MinIO — use this
// to resume a previously interrupted bulk job.
// force: if true, enqueue even when a pending/running task already exists.
// Max 1000 chapters per request.
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Slug         string `json:"slug"`
		Voice        string `json:"voice"`
		From         int    `json:"from"`
		To           int    `json:"to"`
		SkipExisting *bool  `json:"skip_existing"` // pointer so we can detect omission
		Force        bool   `json:"force"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}
	if body.Slug == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if body.Voice == "" {
		body.Voice = s.cfg.DefaultVoice
	}
	if body.From < 1 || body.To < body.From {
		jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
		return
	}
	if body.To-body.From > 999 {
		jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
		return
	}

	// skip_existing defaults to true (resume-friendly).
	skipExisting := true
	if body.SkipExisting != nil {
		skipExisting = *body.SkipExisting
	}

	var taskIDs []string
	skipped := 0

	for n := body.From; n <= body.To; n++ {
		// Skip chapters already cached in MinIO.
		if skipExisting {
			audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
			if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
				skipped++
				continue
			}
		}

		// Skip chapters with an active (pending/running) task unless force=true.
		if !body.Force {
			cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
			existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
			if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
				skipped++
				continue
			}
		}

		id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
		if err != nil {
			s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
				"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
			jsonError(w, http.StatusInternalServerError,
				fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
			return
		}
		taskIDs = append(taskIDs, id)
	}

	writeJSON(w, http.StatusAccepted, map[string]any{
		"enqueued": len(taskIDs),
		"skipped":  skipped,
		"task_ids": taskIDs,
	})
}

// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
// Body: {"slug": "..."}
// Cancels all pending and running audio tasks for the given slug.
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Slug string `json:"slug"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		jsonError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}
	if body.Slug == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}

	cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
	if err != nil {
		s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
			"slug", body.Slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
		return
	}

	writeJSON(w, 0, map[string]any{"cancelled": cancelled})
}

// ── Voices ─────────────────────────────────────────────────────────────────────
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
}

// ── Presigned URLs ─────────────────────────────────────────────────────────────

// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid params")
		return
	}

	u, err := s.deps.PresignStore.PresignChapter(r.Context(), slug, n, 15*time.Minute)
	if err != nil {
		s.deps.Log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
		jsonError(w, http.StatusInternalServerError, "presign failed")
		return
	}
	writeJSON(w, 0, map[string]string{"url": u})
}

// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
// Query params: voice (optional)
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	n, err := strconv.Atoi(r.PathValue("n"))
	if err != nil || n < 1 || slug == "" {
		jsonError(w, http.StatusBadRequest, "invalid params")
		return
	}

	voice := r.URL.Query().Get("voice")
	if voice == "" {
		voice = s.cfg.DefaultVoice
	}

	key := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
	if !s.deps.AudioStore.AudioExists(r.Context(), key) {
		http.NotFound(w, r)
		return
	}

	u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
	if err != nil {
		s.deps.Log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
		jsonError(w, http.StatusInternalServerError, "presign failed")
		return
	}
	writeJSON(w, 0, map[string]string{"url": u})
}

// voiceSampleText is the phrase synthesised for every voice sample.
const voiceSampleText = "Hello! This is a preview of what I sound like. I hope you enjoy listening to your stories with my voice."

// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
// If the sample has not been generated yet it synthesises it on the fly via
// the appropriate TTS engine (Kokoro for kokoro voices, pocket-tts for
// pocket-tts voices), stores the result in MinIO, and returns the presigned URL.
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
	voice := r.PathValue("voice")
	if voice == "" {
		jsonError(w, http.StatusBadRequest, "missing voice")
		return
	}

	key := kokoro.VoiceSampleKey(voice)
	if cfai.IsCFAIVoice(voice) {
		key = cfai.VoiceSampleKey(voice)
	}

	// Generate sample on demand when it is not in MinIO yet.
	if !s.deps.AudioStore.AudioExists(r.Context(), key) {
		s.deps.Log.Info("generating voice sample on demand", "voice", voice)

		var (
			mp3 []byte
			err error
		)
		if cfai.IsCFAIVoice(voice) {
			if s.deps.CFAI == nil {
				jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
				return
			}
			mp3, err = s.deps.CFAI.GenerateAudio(r.Context(), voiceSampleText, voice)
		} else if pockettts.IsPocketTTSVoice(voice) {
			if s.deps.PocketTTS == nil {
				jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
				return
			}
			mp3, err = s.deps.PocketTTS.GenerateAudio(r.Context(), voiceSampleText, voice)
		} else {
			mp3, err = s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
		}
		if err != nil {
			s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err)
			jsonError(w, http.StatusInternalServerError, "voice sample generation failed")
			return
		}
		if err := s.deps.AudioStore.PutAudio(r.Context(), key, mp3); err != nil {
			s.deps.Log.Error("voice sample upload failed", "voice", voice, "err", err)
			jsonError(w, http.StatusInternalServerError, "voice sample upload failed")
			return
		}
	}

	u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
	if err != nil {
		s.deps.Log.Error("presign voice sample failed", "voice", voice, "err", err)
		jsonError(w, http.StatusInternalServerError, "presign failed")
		return
	}
	writeJSON(w, 0, map[string]string{"url": u})
}

// handleAvatarUpload handles PUT /api/avatar-upload/{userId}.
// The request body must be the raw image bytes; Content-Type must be
// image/jpeg, image/png, or image/webp.
//
// This endpoint is called by the SvelteKit server (not the browser directly),
// so MinIO credentials and internal networking are not a concern.
//
// Returns: { "key": "<objectKey>" }
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
	userID := r.PathValue("userId")
	if userID == "" {
		jsonError(w, http.StatusBadRequest, "missing userId")
		return
	}

	ct := r.Header.Get("Content-Type")
	var ext string
	switch {
	case strings.HasPrefix(ct, "image/jpeg"):
		ext = "jpg"
	case strings.HasPrefix(ct, "image/png"):
		ext = "png"
	case strings.HasPrefix(ct, "image/webp"):
		ext = "webp"
	default:
		jsonError(w, http.StatusBadRequest, "unsupported content-type; use image/jpeg, image/png, or image/webp")
		return
	}

	const maxSize = 5 << 20 // 5 MiB
	data, err := io.ReadAll(io.LimitReader(r.Body, maxSize+1))
	if err != nil {
		jsonError(w, http.StatusBadRequest, "failed to read body")
		return
	}
	if len(data) > maxSize {
		jsonError(w, http.StatusRequestEntityTooLarge, "image too large (max 5 MiB)")
		return
	}
	if len(data) == 0 {
		jsonError(w, http.StatusBadRequest, "empty body")
		return
	}

	key, err := s.deps.PresignStore.PutAvatar(r.Context(), userID, ext, ct, data)
	if err != nil {
		s.deps.Log.Error("avatar upload failed", "userId", userID, "err", err)
		jsonError(w, http.StatusInternalServerError, "upload failed")
		return
	}
	writeJSON(w, 0, map[string]string{"key": key})
}

// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
// Query params: ext (jpg|png|webp, defaults to jpg)
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
	userID := r.PathValue("userId")
	if userID == "" {
		jsonError(w, http.StatusBadRequest, "missing userId")
		return
	}

	ext := r.URL.Query().Get("ext")
	switch ext {
	case "jpg", "jpeg":
		ext = "jpg"
	case "png":
		ext = "png"
	case "webp":
		ext = "webp"
	default:
		ext = "jpg"
	}

	uploadURL, key, err := s.deps.PresignStore.PresignAvatarUpload(r.Context(), userID, ext)
	if err != nil {
		s.deps.Log.Error("presign avatar upload failed", "userId", userID, "err", err)
		jsonError(w, http.StatusInternalServerError, "presign failed")
		return
	}
	writeJSON(w, 0, map[string]string{"upload_url": uploadURL, "key": key})
}

// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
	userID := r.PathValue("userId")
	if userID == "" {
		jsonError(w, http.StatusBadRequest, "missing userId")
		return
	}

	u, found, err := s.deps.PresignStore.PresignAvatarURL(r.Context(), userID)
	if err != nil {
		s.deps.Log.Error("presign avatar failed", "userId", userID, "err", err)
		jsonError(w, http.StatusInternalServerError, "presign failed")
		return
	}
	if !found {
		http.NotFound(w, r)
		return
	}
	writeJSON(w, 0, map[string]string{"url": u})
}

// ── Progress ───────────────────────────────────────────────────────────────────

// handleGetProgress handles GET /api/progress.
// Returns {"slug": chapterNum, "slug_ts": timestampMs, ...}
func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) {
	sid := ensureSession(w, r)
	entries, err := s.deps.ProgressStore.AllProgress(r.Context(), sid)
	if err != nil {
		s.deps.Log.Error("AllProgress failed", "err", err)
		entries = nil
	}

	progress := make(map[string]any, len(entries)*2)
	for _, p := range entries {
		progress[p.Slug] = p.Chapter
		progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli()
	}
	writeJSON(w, 0, progress)
}

// handleSetProgress handles POST /api/progress/{slug}.
// Body: {"chapter": N}
func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) {
	sid := ensureSession(w, r)
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	var body struct {
		Chapter int `json:"chapter"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 {
		jsonError(w, http.StatusBadRequest, "invalid body")
		return
	}

	p := domain.ReadingProgress{
		Slug:      slug,
		Chapter:   body.Chapter,
		UpdatedAt: time.Now(),
	}
	if err := s.deps.ProgressStore.SetProgress(r.Context(), sid, p); err != nil {
		s.deps.Log.Error("SetProgress failed", "slug", slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "store error")
		return
	}
	writeJSON(w, 0, map[string]string{})
}

// handleDeleteProgress handles DELETE /api/progress/{slug}.
func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
	sid := ensureSession(w, r)
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	if err := s.deps.ProgressStore.DeleteProgress(r.Context(), sid, slug); err != nil {
		s.deps.Log.Error("DeleteProgress failed", "slug", slug, "err", err)
		// non-fatal
	}
	writeJSON(w, 0, map[string]string{})
}

// ── Catalogue (Meilisearch-backed browse + search) ────────────────────────────

// handleCatalogue handles GET /api/catalogue.
//
// Provides unified browse + search over the locally-indexed book catalogue
// via Meilisearch. Unlike /api/browse this never fetches novelfire.net live —
// it is entirely served from the Meilisearch index populated by the runner.
//
// Query params:
//
//	q      — full-text search query (optional)
//	genre  — genre filter, e.g. "fantasy" or "all" (default "all")
//	status — status filter: "ongoing", "completed", or "all" (default "all")
//	sort   — "popular" (default) | "new" | "top-rated" | "rank"
//	page   — 1-indexed page number (default 1)
//	limit  — items per page (default 20, max 100)
func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	genre := q.Get("genre")
	if genre == "" {
		genre = "all"
	}
	status := q.Get("status")
	if status == "" {
		status = "all"
	}
	sort := q.Get("sort")
	if sort == "" {
		sort = "popular"
	}

	page, _ := strconv.Atoi(q.Get("page"))
	if page <= 0 {
		page = 1
	}
	limit, _ := strconv.Atoi(q.Get("limit"))
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}

	cq := meili.CatalogueQuery{
		Q:      q.Get("q"),
		Genre:  genre,
		Status: status,
		Sort:   sort,
		Page:   page,
		Limit:  limit,
	}

	books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
	if err != nil {
		s.deps.Log.Error("handleCatalogue: Catalogue query failed", "err", err)
		jsonError(w, http.StatusInternalServerError, "search failed")
		return
	}

	hasNext := int64(page*limit) < total

	w.Header().Set("Cache-Control", "public, max-age=60")
	writeJSON(w, 0, map[string]any{
		"books":    books,
		"page":     page,
		"limit":    limit,
		"total":    total,
		"has_next": hasNext,
		"facets": map[string]any{
			"genres":   facets.Genres,
			"statuses": facets.Statuses,
		},
	})
}

// ── Browse page parsing helpers ────────────────────────────────────────────────

// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML.
func (s *Server) fetchBrowsePage(ctx context.Context, pageURL string) ([]NovelListing, bool, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
	if err != nil {
		return nil, false, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
	req.Header.Set("Accept-Language", "en-US,en;q=0.9")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, false, fmt.Errorf("fetch %s: %w", pageURL, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		_, _ = io.Copy(io.Discard, resp.Body)
		return nil, false, fmt.Errorf("upstream returned %d", resp.StatusCode)
	}

	novels, hasNext := parseBrowsePage(resp.Body)
	return novels, hasNext, nil
}

// parseBrowsePage parses a novelfire HTML body and returns novel listings.
// It uses a simple string-scanning approach to avoid importing golang.org/x/net/html
// in this package (that dependency is only in internal/novelfire).
func parseBrowsePage(r io.Reader) ([]NovelListing, bool) {
	data, err := io.ReadAll(r)
	if err != nil {
		return nil, false
	}
	body := string(data)

	var novels []NovelListing
	hasNext := false

	// Detect "next page" link
	if strings.Contains(body, `rel="next"`) ||
		strings.Contains(body, `aria-label="Next"`) ||
		strings.Contains(body, `class="next"`) {
		hasNext = true
	}

	// Extract novel slugs and titles using simple regex patterns.
	// novelfire.net novel items: <li class="novel-item">...</li>
	// Each contains an anchor like <a href="/book/{slug}">
	slugRe := regexp.MustCompile(`href="/book/([^/"]+)"`)
	titleRe := regexp.MustCompile(`class="novel-title[^"]*"[^>]*>([^<]+)<`)
	coverRe := regexp.MustCompile(`data-src="(https?://[^"]+)"`)

	slugMatches := slugRe.FindAllStringSubmatch(body, -1)
	titleMatches := titleRe.FindAllStringSubmatch(body, -1)
	coverMatches := coverRe.FindAllStringSubmatch(body, -1)

	seen := make(map[string]bool)
	for i, sm := range slugMatches {
		slug := sm[1]
		if seen[slug] {
			continue
		}
		seen[slug] = true

		novel := NovelListing{
			Slug: slug,
			URL:  novelFireBase + "/book/" + slug,
		}
		if i < len(titleMatches) {
			novel.Title = strings.TrimSpace(titleMatches[i][1])
		}
		if i < len(coverMatches) {
			novel.Cover = coverMatches[i][1]
		}
		if novel.Title != "" {
			novels = append(novels, novel)
		}
	}

	return novels, hasNext
}

// ── Markdown stripping ─────────────────────────────────────────────────────────

// stripMarkdown removes common markdown syntax from src, returning 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)
}

// ── EPUB export ───────────────────────────────────────────────────────────────

// handleExportEPUB handles GET /api/export/{slug}.
// Generates and streams an EPUB file for the book identified by slug.
// Optional query params: from=N&to=N to limit the chapter range (default: all).
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "missing slug")
		return
	}

	ctx := r.Context()

	// Parse optional from/to range.
	fromStr := r.URL.Query().Get("from")
	toStr := r.URL.Query().Get("to")
	fromN, toN := 0, 0
	if fromStr != "" {
		v, err := strconv.Atoi(fromStr)
		if err != nil || v < 1 {
			jsonError(w, http.StatusBadRequest, "invalid 'from' param")
			return
		}
		fromN = v
	}
	if toStr != "" {
		v, err := strconv.Atoi(toStr)
		if err != nil || v < 1 {
			jsonError(w, http.StatusBadRequest, "invalid 'to' param")
			return
		}
		toN = v
	}

	// Fetch book metadata for title and author.
	meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
	if err != nil || !inLib {
		s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
		jsonError(w, http.StatusNotFound, "book not found")
		return
	}

	// List all chapters.
	chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
	if err != nil {
		s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to list chapters")
		return
	}

	// Filter chapters by from/to range.
	var filtered []epubChapter
	for _, ch := range chapters {
		if fromN > 0 && ch.Number < fromN {
			continue
		}
		if toN > 0 && ch.Number > toN {
			continue
		}

		// Fetch markdown from MinIO.
		mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
		if readErr != nil {
			s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
			// Skip chapters that cannot be fetched.
			continue
		}

		// Convert markdown to HTML using goldmark.
		md := goldmark.New()
		var htmlBuf bytes.Buffer
		if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
			htmlBuf.Reset()
			htmlBuf.WriteString("<p>" + mdText + "</p>")
		}

		filtered = append(filtered, epubChapter{
			Number: ch.Number,
			Title:  ch.Title,
			HTML:   htmlBuf.String(),
		})
	}

	if len(filtered) == 0 {
		jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
		return
	}

	epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
	if err != nil {
		s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
		return
	}

	w.Header().Set("Content-Type", "application/epub+zip")
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
	w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
	w.WriteHeader(http.StatusOK)
	w.Write(epubBytes)
}

// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────

// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
// the Kokoro service is unavailable.
var kokoroVoiceIDs = []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",
}
