// Package backend implements the HTTP API server for the LibNovel backend.
//
// The server exposes all endpoints consumed by the SvelteKit UI:
//   - Book/chapter reads from PocketBase/MinIO via bookstore interfaces
//   - Task creation (scrape + audio) via taskqueue.Producer — the runner binary
//     picks up and executes those tasks asynchronously
//   - Presigned MinIO URLs for media playback/upload
//   - Session-scoped reading progress
//   - Live novelfire.net search (no scraper interface needed; direct HTTP)
//   - Kokoro voice list
//
// The backend never scrapes directly. All scraping (metadata, chapter list,
// chapter text, audio TTS) is delegated to the runner binary via PocketBase
// task records. GET /api/book-preview enqueues a task when the book is absent.
//
// All external dependencies are injected as interfaces; concrete types live in
// internal/storage and are wired by cmd/backend/main.go.
package backend

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"sync"
	"time"

	sentryhttp "github.com/getsentry/sentry-go/http"
	"github.com/libnovel/backend/internal/bookstore"
	"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/pockettts"
	"github.com/libnovel/backend/internal/taskqueue"
	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

// Dependencies holds all external services the backend server depends on.
// Every field is an interface so test doubles can be injected freely.
type Dependencies struct {
	// BookReader reads book metadata and chapter text from PocketBase/MinIO.
	BookReader bookstore.BookReader
	// RankingStore reads ranking data from PocketBase.
	RankingStore bookstore.RankingStore
	// AudioStore checks audio object existence and computes MinIO keys.
	AudioStore bookstore.AudioStore
	// TranslationStore checks translation existence and reads/writes translated markdown.
	TranslationStore bookstore.TranslationStore
	// PresignStore generates short-lived MinIO URLs.
	PresignStore bookstore.PresignStore
	// ProgressStore reads/writes per-session reading progress.
	ProgressStore bookstore.ProgressStore
	// CoverStore reads and writes book cover images from MinIO.
	// If nil, the cover endpoint falls back to a CDN redirect.
	CoverStore bookstore.CoverStore
	// ChapterImageStore reads and writes per-chapter illustration images from MinIO.
	// If nil, chapter image endpoints return 404/503.
	ChapterImageStore bookstore.ChapterImageStore
	// Producer creates scrape/audio tasks in PocketBase.
	Producer taskqueue.Producer
	// TaskReader reads scrape/audio task records from PocketBase.
	TaskReader taskqueue.Reader
	// SearchIndex provides full-text book search via Meilisearch.
	// If nil, the local-only fallback search is used.
	SearchIndex meili.Client
	// Kokoro is the Kokoro TTS client (used for voice list only in the backend;
	// audio generation is done by the runner).
	Kokoro kokoro.Client
	// PocketTTS is the pocket-tts client (used for voice list only in the backend;
	// audio generation is done by the runner).
	PocketTTS pockettts.Client
	// CFAI is the Cloudflare Workers AI TTS client (used for voice sample
	// generation and audio-stream live TTS; audio task generation is done by the runner).
	CFAI cfai.Client
	// ImageGen is the Cloudflare Workers AI image generation client.
	// If nil, image generation endpoints return 503.
	ImageGen cfai.ImageGenClient
	// TextGen is the Cloudflare Workers AI text generation client.
	// If nil, text generation endpoints return 503.
	TextGen cfai.TextGenClient
	// BookWriter writes book metadata and chapter refs to PocketBase.
	// Used by admin text-gen apply endpoints.
	BookWriter bookstore.BookWriter
	// AIJobStore tracks long-running AI generation jobs in PocketBase.
	// If nil, job persistence is disabled (jobs still run but are not recorded).
	AIJobStore bookstore.AIJobStore
	// Log is the structured logger.
	Log *slog.Logger
}

// Config holds HTTP server tuning parameters.
type Config struct {
	// Addr is the listen address, e.g. ":8080".
	Addr string
	// DefaultVoice is used when no voice is specified in audio requests.
	DefaultVoice string
	// Version and Commit are embedded in /health and /api/version responses.
	Version string
	Commit  string
}

// Server is the HTTP API server.
type Server struct {
	cfg  Config
	deps Dependencies

	// voiceMu guards cachedVoices. Populated lazily on first GET /api/voices.
	voiceMu      sync.RWMutex
	cachedVoices []domain.Voice
}

// New creates a Server from cfg and deps.
func New(cfg Config, deps Dependencies) *Server {
	if cfg.DefaultVoice == "" {
		cfg.DefaultVoice = "af_bella"
	}
	if deps.Log == nil {
		deps.Log = slog.Default()
	}
	if deps.SearchIndex == nil {
		deps.SearchIndex = meili.NoopClient{}
	}
	return &Server{cfg: cfg, deps: deps}
}

// ListenAndServe registers all routes and starts the HTTP server.
// It blocks until ctx is cancelled, then performs a graceful shutdown.
func (s *Server) ListenAndServe(ctx context.Context) error {
	mux := http.NewServeMux()

	// Health / version
	mux.HandleFunc("GET /health", s.handleHealth)
	mux.HandleFunc("GET /api/version", s.handleVersion)

	// Scrape task creation (202 Accepted — runner executes asynchronously)
	mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
	mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
	mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)

	// Scrape task status / history
	mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus)
	mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)

	// Cancel a pending task (scrape or audio)
	mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask)

	// Browse & search
	mux.HandleFunc("GET /api/search", s.handleSearch)

	// Catalogue (Meilisearch-backed browse + search — preferred path for UI)
	mux.HandleFunc("GET /api/catalogue", s.handleCatalogue)

	// Ranking (from PocketBase)
	mux.HandleFunc("GET /api/ranking", s.handleGetRanking)

	// Cover proxy (live URL redirect)
	mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover)

	// Book preview (enqueues scrape task if not in library; returns stored data if already scraped)
	mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview)

	// Chapter text (served from MinIO via PocketBase index)
	mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
	// Raw markdown chapter content — served directly from MinIO by the backend.
	// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
	mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)

	// Chapter text preview — live scrape from novelfire.net, no store writes.
	// Used when the chapter is not yet in the library (preview mode).
	mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview)

	// Reindex chapters_idx from MinIO
	mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)

	// Audio task creation (backend creates task; runner executes)
	mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
	mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
	mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
	// Streaming audio: serves from MinIO if cached, else streams live TTS
	// while simultaneously uploading to MinIO for future requests.
	mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
	// TTS for arbitrary short text (chapter announcements) — no MinIO caching.
	mux.HandleFunc("GET /api/tts-announce", s.handleTTSAnnounce)
	// CF AI preview: generates only the first ~1 800-char chunk so the client
	// can start playing immediately while the full audio is generated by the runner.
	mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)

	// Translation task creation (backend creates task; runner executes via LibreTranslate)
	mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
	mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
	mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)

	// Admin translation endpoints
	mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
	mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)

	// Admin audio endpoints
	mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
	mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
	mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)

	// Admin image generation endpoints
	mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
	mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
	mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
	mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
	mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)

	// Chapter image serving
	mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
	mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)

	// Admin text generation endpoints (chapter names + book description)
	mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
	mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
	mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
	mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
	mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
	mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
	mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)

	// Admin catalogue enrichment endpoints
	mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
	mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
	mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
	mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
	mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
	mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
	mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
	mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)

	// Admin AI job tracking endpoints
	mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
	mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
	mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)

	// Auto-prompt generation from book/chapter content
	mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)

	// Admin data repair endpoints
	mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)

	// Import (PDF/EPUB)
	mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
	mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
	mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)

	// Notifications
	mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
	mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
	mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
	mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
	mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)

	// Voices list
	mux.HandleFunc("GET /api/voices", s.handleVoices)

	// Presigned URLs
	mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
	mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
	mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
	mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
	mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
	mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)

	// EPUB export
	mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)

	// Reading progress
	mux.HandleFunc("GET /api/progress", s.handleGetProgress)
	mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
	mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)

	// Wrap mux with OTel tracing (no-op when no TracerProvider is set),
	// then with Sentry for panic recovery and error reporting.
	var handler http.Handler = mux
	handler = otelhttp.NewHandler(handler, "libnovel.backend",
		otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
	)
	handler = sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(handler)

	srv := &http.Server{
		Addr:         s.cfg.Addr,
		Handler:      handler,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
		IdleTimeout:  60 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()
	s.deps.Log.Info("backend: HTTP server listening", "addr", s.cfg.Addr)

	select {
	case <-ctx.Done():
		s.deps.Log.Info("backend: context cancelled, starting graceful shutdown")
		shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		if err := srv.Shutdown(shutCtx); err != nil {
			s.deps.Log.Error("backend: graceful shutdown failed", "err", err)
			return err
		}
		s.deps.Log.Info("backend: shutdown complete")
		return nil
	case err := <-errCh:
		return err
	}
}

// ── Session cookie helpers ─────────────────────────────────────────────────────

const sessionCookieName = "libnovel_session"

func sessionID(r *http.Request) string {
	c, err := r.Cookie(sessionCookieName)
	if err != nil {
		return ""
	}
	return c.Value
}

func newSessionID() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func ensureSession(w http.ResponseWriter, r *http.Request) string {
	if id := sessionID(r); id != "" {
		return id
	}
	id, err := newSessionID()
	if err != nil {
		id = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   365 * 24 * 60 * 60,
	})
	return id
}

// ── Utility helpers ────────────────────────────────────────────────────────────

// writeJSON writes v as a JSON response with status code. Status 0 → 200.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	if status != 0 {
		w.WriteHeader(status)
	}
	_ = json.NewEncoder(w).Encode(v)
}

// jsonError writes a JSON error body and the given status code.
func jsonError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

// voices returns the merged list of available voices from Kokoro and pocket-tts.
// On the first call it fetches from both services and caches the result.
// Falls back to the hardcoded Kokoro list on error.
func (s *Server) voices(ctx context.Context) []domain.Voice {
	s.voiceMu.RLock()
	cached := s.cachedVoices
	s.voiceMu.RUnlock()
	if len(cached) > 0 {
		return cached
	}

	fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	var result []domain.Voice

	// ── Kokoro voices ─────────────────────────────────────────────────────────
	var kokoroIDs []string
	if s.deps.Kokoro != nil {
		ids, err := s.deps.Kokoro.ListVoices(fetchCtx)
		if err != nil || len(ids) == 0 {
			s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
			ids = kokoroVoiceIDs
		} else {
			s.deps.Log.Info("backend: fetched kokoro voices", "count", len(ids))
		}
		kokoroIDs = ids
	} else {
		kokoroIDs = kokoroVoiceIDs
	}
	for _, id := range kokoroIDs {
		result = append(result, kokoroVoice(id))
	}

	// ── Pocket-TTS voices ─────────────────────────────────────────────────────
	if s.deps.PocketTTS != nil {
		ids, err := s.deps.PocketTTS.ListVoices(fetchCtx)
		if err != nil {
			s.deps.Log.Warn("backend: could not fetch pocket-tts voices", "err", err)
		} else {
			for _, id := range ids {
				result = append(result, pocketTTSVoice(id))
			}
			s.deps.Log.Info("backend: fetched pocket-tts voices", "count", len(ids))
		}
	}

	// ── Cloudflare AI voices ──────────────────────────────────────────────────
	if s.deps.CFAI != nil {
		for _, speaker := range cfai.Speakers() {
			gender := "m"
			if cfai.IsFemale(speaker) {
				gender = "f"
			}
			result = append(result, domain.Voice{
				ID:     cfai.VoiceID(speaker),
				Engine: "cfai",
				Lang:   "en",
				Gender: gender,
			})
		}
		s.deps.Log.Info("backend: loaded CF AI voices", "count", len(cfai.Speakers()))
	}

	s.voiceMu.Lock()
	s.cachedVoices = result
	s.voiceMu.Unlock()
	return result
}

// kokoroVoice builds a domain.Voice for a Kokoro voice ID.
// The two-character prefix encodes language and gender:
//
//	af/am → en-us f/m  |  bf/bm → en-gb f/m
//	ef/em → es    f/m  |  ff    → fr    f
//	hf/hm → hi    f/m  |  if/im → it    f/m
//	jf/jm → ja    f/m  |  pf/pm → pt    f/m
//	zf/zm → zh    f/m
func kokoroVoice(id string) domain.Voice {
	type meta struct{ lang, gender string }
	prefixMap := map[string]meta{
		"af": {"en-us", "f"}, "am": {"en-us", "m"},
		"bf": {"en-gb", "f"}, "bm": {"en-gb", "m"},
		"ef": {"es", "f"}, "em": {"es", "m"},
		"ff": {"fr", "f"},
		"hf": {"hi", "f"}, "hm": {"hi", "m"},
		"if": {"it", "f"}, "im": {"it", "m"},
		"jf": {"ja", "f"}, "jm": {"ja", "m"},
		"pf": {"pt", "f"}, "pm": {"pt", "m"},
		"zf": {"zh", "f"}, "zm": {"zh", "m"},
	}
	if len(id) >= 2 {
		if m, ok := prefixMap[id[:2]]; ok {
			return domain.Voice{ID: id, Engine: "kokoro", Lang: m.lang, Gender: m.gender}
		}
	}
	return domain.Voice{ID: id, Engine: "kokoro", Lang: "en", Gender: ""}
}

// pocketTTSVoice builds a domain.Voice for a pocket-tts voice ID.
// All pocket-tts voices are English audiobook narrators.
func pocketTTSVoice(id string) domain.Voice {
	femaleVoices := map[string]struct{}{
		"alba": {}, "fantine": {}, "cosette": {}, "eponine": {},
		"azelma": {}, "anna": {}, "vera": {}, "mary": {}, "jane": {}, "eve": {},
	}
	gender := "m"
	if _, ok := femaleVoices[id]; ok {
		gender = "f"
	}
	return domain.Voice{ID: id, Engine: "pocket-tts", Lang: "en", Gender: gender}
}

// handleHealth handles GET /health.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, 0, map[string]string{
		"status":  "ok",
		"version": s.cfg.Version,
		"commit":  s.cfg.Commit,
	})
}

// handleVersion handles GET /api/version.
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, 0, map[string]string{
		"version": s.cfg.Version,
		"commit":  s.cfg.Commit,
	})
}
