package backend

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/libnovel/backend/internal/cfai"
	"github.com/libnovel/backend/internal/domain"
)

// chapterNamesBatchSize is the number of chapters sent per LLM request.
// Keeps output well within the 4096-token response limit (~30 tokens/title).
const chapterNamesBatchSize = 100

// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
// Returns the list of supported Cloudflare AI text generation models.
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
		return
	}
	models := s.deps.TextGen.Models()
	writeJSON(w, 0, map[string]any{"models": models})
}

// ── Chapter names ─────────────────────────────────────────────────────────────

// textGenChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names.
type textGenChapterNamesRequest struct {
	// Slug is the book slug whose chapters to process.
	Slug string `json:"slug"`
	// Pattern is a free-text description of the desired naming convention,
	// e.g. "Chapter {n}: {brief scene description}".
	Pattern string `json:"pattern"`
	// Model is the CF Workers AI model ID. Defaults to the recommended model when empty.
	Model string `json:"model"`
	// MaxTokens limits response length (0 = model default).
	MaxTokens int `json:"max_tokens"`
	// FromChapter is the first chapter to process (1-based). 0 = start from chapter 1.
	FromChapter int `json:"from_chapter"`
	// ToChapter is the last chapter to process (inclusive). 0 = process all.
	ToChapter int `json:"to_chapter"`
	// JobID is an optional existing ai_job ID for resuming a previous run.
	// If set, the handler resumes from items_done instead of starting from scratch.
	JobID string `json:"job_id"`
}

// proposedChapterTitle is a single chapter with its AI-proposed title.
type proposedChapterTitle struct {
	Number int `json:"number"`
	// OldTitle is the current title stored in the database.
	OldTitle string `json:"old_title"`
	// NewTitle is the AI-proposed replacement.
	NewTitle string `json:"new_title"`
}

// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
	// JobID is the PB ai_job ID for this run (emitted on the first event only).
	JobID string `json:"job_id,omitempty"`
	// Batch is the 1-based batch index.
	Batch int `json:"batch"`
	// TotalBatches is the total number of batches.
	TotalBatches int `json:"total_batches"`
	// ChaptersDone is the cumulative count of chapters processed so far.
	ChaptersDone int `json:"chapters_done"`
	// TotalChapters is the total chapter count for this book.
	TotalChapters int `json:"total_chapters"`
	// Model is the CF AI model used.
	Model string `json:"model"`
	// Chapters contains the proposed titles for this batch.
	Chapters []proposedChapterTitle `json:"chapters"`
	// Error is non-empty if this batch failed.
	Error string `json:"error,omitempty"`
	// Done is true on the final sentinel event (no Chapters).
	Done bool `json:"done,omitempty"`
}

// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
// to the LLM sequentially, and streams results back as Server-Sent Events so
// the frontend can show live progress. Each SSE data line is a JSON-encoded
// chapterNamesBatchEvent. The final event has Done=true.
//
// Does NOT persist anything — the frontend shows a diff and the user must
// confirm via POST /api/admin/text-gen/chapter-names/apply.
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
		return
	}

	var req textGenChapterNamesRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if strings.TrimSpace(req.Pattern) == "" {
		jsonError(w, http.StatusBadRequest, "pattern is required")
		return
	}

	// Load existing chapter list.
	allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
		return
	}
	if len(allChapters) == 0 {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
		return
	}

	// Apply chapter range filter.
	chapters := allChapters
	if req.FromChapter > 0 || req.ToChapter > 0 {
		filtered := chapters[:0]
		for _, ch := range allChapters {
			if req.FromChapter > 0 && ch.Number < req.FromChapter {
				continue
			}
			if req.ToChapter > 0 && ch.Number > req.ToChapter {
				break
			}
			filtered = append(filtered, ch)
		}
		chapters = filtered
	}
	if len(chapters) == 0 {
		jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
		return
	}

	model := cfai.TextModel(req.Model)
	if model == "" {
		model = cfai.DefaultTextModel
	}
	// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
	maxTokens := req.MaxTokens
	if maxTokens <= 0 {
		maxTokens = 4096
	}

	// Index existing titles for old/new diff.
	existing := make(map[int]string, len(chapters))
	for _, ch := range chapters {
		existing[ch.Number] = ch.Title
	}

	// Partition chapters into batches.
	batches := chunkChapters(chapters, chapterNamesBatchSize)
	totalBatches := len(batches)

	s.deps.Log.Info("admin: text-gen chapter-names requested",
		"slug", req.Slug, "chapters", len(chapters),
		"batches", totalBatches, "model", model, "max_tokens", maxTokens)

	systemPrompt := `You are a chapter title editor for a web novel platform. ` +
		`The user provides a list of chapter numbers with their current titles, ` +
		`and a naming pattern template. ` +
		`Your job: produce one new title for every chapter, following the pattern exactly. ` +
		`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (2–5 word) scene hint derived from the existing title. ` +
		`RULES: ` +
		`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
		`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
		`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
		`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
		`5. Each element: {"number": <int>, "title": <string>}. ` +
		`6. Output every chapter in the input list, in order. Do not skip any.`

	// Switch to SSE before writing anything.
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
	flusher, canFlush := w.(http.Flusher)

	sseWrite := func(evt chapterNamesBatchEvent) {
		b, _ := json.Marshal(evt)
		fmt.Fprintf(w, "data: %s\n\n", b)
		if canFlush {
			flusher.Flush()
		}
	}

	// Create or resume an ai_job record for tracking.
	var jobID string
	resumeFrom := 0
	jobCtx := r.Context()
	var jobCancel context.CancelFunc

	if s.deps.AIJobStore != nil {
		if req.JobID != "" {
			if existingJob, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), req.JobID); ok {
				jobID = req.JobID
				resumeFrom = existingJob.ItemsDone
				_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
					"status":      string(domain.TaskStatusRunning),
					"items_total": len(chapters),
				})
			}
		}
		if jobID == "" {
			jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
			id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
				Kind:       "chapter-names",
				Slug:       req.Slug,
				Status:     domain.TaskStatusRunning,
				FromItem:   req.FromChapter,
				ToItem:     req.ToChapter,
				ItemsTotal: len(chapters),
				Model:      string(model),
				Payload:    jobPayload,
				Started:    time.Now(),
			})
			if createErr == nil {
				jobID = id
			}
		}
		if jobID != "" {
			jobCtx, jobCancel = context.WithCancel(r.Context())
			registerCancelJob(jobID, jobCancel)
			defer deregisterCancelJob(jobID)
			defer jobCancel()
		}
	}

	chaptersDone := resumeFrom
	firstEvent := true
	for i, batch := range batches {
		if jobCtx.Err() != nil {
			return // client disconnected or cancelled
		}
		// Skip batches already processed in a previous run.
		batchEnd := (i + 1) * chapterNamesBatchSize
		if batchEnd <= resumeFrom {
			continue
		}

		var chapterListSB strings.Builder
		for _, ch := range batch {
			chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
		}
		userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())

		raw, genErr := s.deps.TextGen.Generate(jobCtx, cfai.TextRequest{
			Model: model,
			Messages: []cfai.TextMessage{
				{Role: "system", Content: systemPrompt},
				{Role: "user", Content: userPrompt},
			},
			MaxTokens: maxTokens,
		})
		if genErr != nil {
			s.deps.Log.Error("admin: text-gen chapter-names batch failed",
				"batch", i+1, "err", genErr)
			evt := chapterNamesBatchEvent{
				Batch:         i + 1,
				TotalBatches:  totalBatches,
				ChaptersDone:  chaptersDone,
				TotalChapters: len(chapters),
				Model:         string(model),
				Error:         genErr.Error(),
			}
			if firstEvent {
				evt.JobID = jobID
				firstEvent = false
			}
			sseWrite(evt)
			continue
		}

		proposed := parseChapterTitlesJSON(raw)
		result := make([]proposedChapterTitle, 0, len(proposed))
		for _, p := range proposed {
			result = append(result, proposedChapterTitle{
				Number:   p.Number,
				OldTitle: existing[p.Number],
				NewTitle: p.Title,
			})
		}
		chaptersDone += len(batch)

		if jobID != "" && s.deps.AIJobStore != nil {
			_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
				"items_done": chaptersDone,
			})
		}

		evt := chapterNamesBatchEvent{
			Batch:         i + 1,
			TotalBatches:  totalBatches,
			ChaptersDone:  chaptersDone,
			TotalChapters: len(chapters),
			Model:         string(model),
			Chapters:      result,
		}
		if firstEvent {
			evt.JobID = jobID
			firstEvent = false
		}
		sseWrite(evt)
	}

	// Mark job as done in PB.
	if jobID != "" && s.deps.AIJobStore != nil {
		status := domain.TaskStatusDone
		if jobCtx.Err() != nil {
			status = domain.TaskStatusCancelled
		}
		_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
			"status":     string(status),
			"items_done": chaptersDone,
			"finished":   time.Now().Format(time.RFC3339),
		})
	}

	// Final sentinel event.
	sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
}

// chunkChapters splits a chapter slice into batches of at most size n.
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
	var batches [][]domain.ChapterInfo
	for len(chapters) > 0 {
		end := n
		if end > len(chapters) {
			end = len(chapters)
		}
		batches = append(batches, chapters[:end])
		chapters = chapters[end:]
	}
	return batches
}

// parseChapterTitlesJSON extracts the JSON array from a model response.
// It tolerates markdown fences and surrounding prose.
type rawChapterTitle struct {
	Number int    `json:"number"`
	Title  string `json:"title"`
}

func parseChapterTitlesJSON(raw string) []rawChapterTitle {
	// Strip markdown fences if present.
	s := raw
	if idx := strings.Index(s, "```json"); idx >= 0 {
		s = s[idx+7:]
	} else if idx := strings.Index(s, "```"); idx >= 0 {
		s = s[idx+3:]
	}
	if idx := strings.LastIndex(s, "```"); idx >= 0 {
		s = s[:idx]
	}
	// Find the JSON array boundaries.
	start := strings.Index(s, "[")
	end := strings.LastIndex(s, "]")
	if start < 0 || end <= start {
		return nil
	}
	s = s[start : end+1]
	var out []rawChapterTitle
	json.Unmarshal([]byte(s), &out) //nolint:errcheck
	return out
}

// handleAdminTextGenChapterNamesAsync handles POST /api/admin/text-gen/chapter-names/async.
//
// Fire-and-forget variant: validates inputs, creates an ai_job record, spawns a
// background goroutine, and returns HTTP 202 with {job_id} immediately. The
// goroutine runs all batches, stores the proposed titles in the job payload, and
// marks the job done/failed/cancelled when finished.
//
// The client can poll GET /api/admin/ai-jobs/{id} for progress, then call
// POST /api/admin/text-gen/chapter-names/apply once the job is "done".
func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
		return
	}

	var req textGenChapterNamesRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if strings.TrimSpace(req.Pattern) == "" {
		jsonError(w, http.StatusBadRequest, "pattern is required")
		return
	}

	// Load existing chapter list (use request context — just for validation).
	allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
		return
	}
	if len(allChapters) == 0 {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
		return
	}

	// Apply chapter range filter.
	chapters := allChapters
	if req.FromChapter > 0 || req.ToChapter > 0 {
		filtered := chapters[:0]
		for _, ch := range allChapters {
			if req.FromChapter > 0 && ch.Number < req.FromChapter {
				continue
			}
			if req.ToChapter > 0 && ch.Number > req.ToChapter {
				break
			}
			filtered = append(filtered, ch)
		}
		chapters = filtered
	}
	if len(chapters) == 0 {
		jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
		return
	}

	model := cfai.TextModel(req.Model)
	if model == "" {
		model = cfai.DefaultTextModel
	}
	maxTokens := req.MaxTokens
	if maxTokens <= 0 {
		maxTokens = 4096
	}

	// Index existing titles for old/new diff.
	existing := make(map[int]string, len(chapters))
	for _, ch := range chapters {
		existing[ch.Number] = ch.Title
	}

	batches := chunkChapters(chapters, chapterNamesBatchSize)
	totalBatches := len(batches)

	if s.deps.AIJobStore == nil {
		jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
		return
	}

	jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
	jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
		Kind:       "chapter-names",
		Slug:       req.Slug,
		Status:     domain.TaskStatusPending,
		FromItem:   req.FromChapter,
		ToItem:     req.ToChapter,
		ItemsTotal: len(chapters),
		Model:      string(model),
		Payload:    jobPayload,
		Started:    time.Now(),
	})
	if createErr != nil {
		jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
		return
	}

	jobCtx, jobCancel := context.WithCancel(context.Background())
	registerCancelJob(jobID, jobCancel)

	s.deps.Log.Info("admin: text-gen chapter-names async started",
		"job_id", jobID, "slug", req.Slug,
		"chapters", len(chapters), "batches", totalBatches, "model", model)

	// Mark running before returning so the UI sees it immediately.
	_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
		"status": string(domain.TaskStatusRunning),
	})

	systemPrompt := `You are a chapter title editor for a web novel platform. ` +
		`The user provides a list of chapter numbers with their current titles, ` +
		`and a naming pattern template. ` +
		`Your job: produce one new title for every chapter, following the pattern exactly. ` +
		`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (2–5 word) scene hint derived from the existing title. ` +
		`RULES: ` +
		`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
		`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
		`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
		`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
		`5. Each element: {"number": <int>, "title": <string>}. ` +
		`6. Output every chapter in the input list, in order. Do not skip any.`

	// Capture all locals needed in the goroutine.
	store := s.deps.AIJobStore
	textGen := s.deps.TextGen
	logger := s.deps.Log
	capturedModel := model
	capturedMaxTokens := maxTokens
	capturedPattern := req.Pattern
	capturedSlug := req.Slug

	go func() {
		defer deregisterCancelJob(jobID)
		defer jobCancel()

		var allResults []proposedChapterTitle
		chaptersDone := 0

		for i, batch := range batches {
			if jobCtx.Err() != nil {
				_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
					"status":   string(domain.TaskStatusCancelled),
					"finished": time.Now().Format(time.RFC3339),
				})
				return
			}

			var chapterListSB strings.Builder
			for _, ch := range batch {
				chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
			}
			userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", capturedPattern, chapterListSB.String())

			raw, genErr := textGen.Generate(jobCtx, cfai.TextRequest{
				Model: capturedModel,
				Messages: []cfai.TextMessage{
					{Role: "system", Content: systemPrompt},
					{Role: "user", Content: userPrompt},
				},
				MaxTokens: capturedMaxTokens,
			})
			if genErr != nil {
				logger.Error("admin: text-gen chapter-names async batch failed",
					"job_id", jobID, "batch", i+1, "err", genErr)
				// Continue — skip errored batch rather than aborting.
				continue
			}

			proposed := parseChapterTitlesJSON(raw)
			for _, p := range proposed {
				allResults = append(allResults, proposedChapterTitle{
					Number:   p.Number,
					OldTitle: existing[p.Number],
					NewTitle: p.Title,
				})
			}
			chaptersDone += len(batch)

			_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
				"items_done": chaptersDone,
			})
		}

		// Persist results into payload so the UI can load them for review.
		resultsJSON, _ := json.Marshal(allResults)
		finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
			capturedPattern, capturedSlug, string(resultsJSON))

		status := domain.TaskStatusDone
		if jobCtx.Err() != nil {
			status = domain.TaskStatusCancelled
		}
		_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
			"status":     string(status),
			"items_done": chaptersDone,
			"finished":   time.Now().Format(time.RFC3339),
			"payload":    finalPayload,
		})
		logger.Info("admin: text-gen chapter-names async done",
			"job_id", jobID, "slug", capturedSlug,
			"results", len(allResults), "status", string(status))
	}()

	writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

// ── Apply chapter names ───────────────────────────────────────────────────────

// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.
type applyChapterNamesRequest struct {
	// Slug is the book slug to update.
	Slug string `json:"slug"`
	// Chapters is the list of chapters to save (number + new_title pairs).
	// The UI may modify individual titles before confirming.
	Chapters []applyChapterEntry `json:"chapters"`
}

type applyChapterEntry struct {
	Number int    `json:"number"`
	Title  string `json:"title"`
}

// handleAdminTextGenApplyChapterNames handles POST /api/admin/text-gen/chapter-names/apply.
//
// Persists the confirmed chapter titles to PocketBase chapters_idx.
func (s *Server) handleAdminTextGenApplyChapterNames(w http.ResponseWriter, r *http.Request) {
	if s.deps.BookWriter == nil {
		jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
		return
	}

	var req applyChapterNamesRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if len(req.Chapters) == 0 {
		jsonError(w, http.StatusBadRequest, "chapters is required")
		return
	}

	refs := make([]domain.ChapterRef, 0, len(req.Chapters))
	for _, ch := range req.Chapters {
		if ch.Number <= 0 {
			continue
		}
		refs = append(refs, domain.ChapterRef{
			Number: ch.Number,
			Title:  strings.TrimSpace(ch.Title),
		})
	}

	if err := s.deps.BookWriter.WriteChapterRefs(r.Context(), req.Slug, refs); err != nil {
		s.deps.Log.Error("admin: apply chapter names failed", "slug", req.Slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "write chapter refs: "+err.Error())
		return
	}

	s.deps.Log.Info("admin: chapter names applied", "slug", req.Slug, "count", len(refs))
	writeJSON(w, 0, map[string]any{"updated": len(refs)})
}

// ── Book description ──────────────────────────────────────────────────────────

// textGenDescriptionRequest is the JSON body for POST /api/admin/text-gen/description.
type textGenDescriptionRequest struct {
	// Slug is the book slug whose description to regenerate.
	Slug string `json:"slug"`
	// Instructions is an optional free-text hint for the AI,
	// e.g. "Write a 3-sentence blurb, avoid spoilers, dramatic tone."
	Instructions string `json:"instructions"`
	// Model is the CF Workers AI model ID. Defaults to recommended when empty.
	Model string `json:"model"`
	// MaxTokens limits response length (0 = model default).
	MaxTokens int `json:"max_tokens"`
}

// textGenDescriptionResponse is the JSON body returned by POST /api/admin/text-gen/description.
type textGenDescriptionResponse struct {
	// OldDescription is the current summary stored in the database.
	OldDescription string `json:"old_description"`
	// NewDescription is the AI-proposed replacement.
	NewDescription string `json:"new_description"`
	// Model is the model that was used.
	Model string `json:"model"`
}

// handleAdminTextGenDescription handles POST /api/admin/text-gen/description.
//
// Reads the current book metadata, sends it to the LLM, and returns a proposed
// new description. Does NOT persist anything — the user must confirm via
// POST /api/admin/text-gen/description/apply.
func (s *Server) handleAdminTextGenDescription(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
		return
	}

	var req textGenDescriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}

	// Load current book metadata.
	meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
		return
	}
	if !ok {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
		return
	}

	systemPrompt := `You are a book description writer for a web novel platform. ` +
		`Given a book's title, author, genres, and current description, write an improved ` +
		`description that accurately captures the story. ` +
		`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`

	instructions := strings.TrimSpace(req.Instructions)
	if instructions == "" {
		instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging."
	}

	userPrompt := fmt.Sprintf(
		"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
		meta.Title,
		meta.Author,
		strings.Join(meta.Genres, ", "),
		meta.Status,
		meta.Summary,
		instructions,
	)

	model := cfai.TextModel(req.Model)
	if model == "" {
		model = cfai.DefaultTextModel
	}

	s.deps.Log.Info("admin: text-gen description requested",
		"slug", req.Slug, "model", model)

	newDesc, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
		Model: model,
		Messages: []cfai.TextMessage{
			{Role: "system", Content: systemPrompt},
			{Role: "user", Content: userPrompt},
		},
		MaxTokens: req.MaxTokens,
	})
	if genErr != nil {
		s.deps.Log.Error("admin: text-gen description failed", "err", genErr)
		jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
		return
	}

	writeJSON(w, 0, textGenDescriptionResponse{
		OldDescription: meta.Summary,
		NewDescription: strings.TrimSpace(newDesc),
		Model:          string(model),
	})
}

// ── Apply description ─────────────────────────────────────────────────────────

// applyDescriptionRequest is the JSON body for POST /api/admin/text-gen/description/apply.
type applyDescriptionRequest struct {
	// Slug is the book slug to update.
	Slug string `json:"slug"`
	// Description is the new summary text to persist.
	Description string `json:"description"`
}

// handleAdminTextGenApplyDescription handles POST /api/admin/text-gen/description/apply.
//
// Updates only the summary field in PocketBase, leaving all other book metadata
// unchanged.
func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *http.Request) {
	if s.deps.BookWriter == nil {
		jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
		return
	}

	var req applyDescriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}
	if strings.TrimSpace(req.Description) == "" {
		jsonError(w, http.StatusBadRequest, "description is required")
		return
	}

	// Read existing metadata so we can write it back with only summary changed.
	meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
		return
	}
	if !ok {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
		return
	}

	meta.Summary = strings.TrimSpace(req.Description)
	if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
		s.deps.Log.Error("admin: apply description failed", "slug", req.Slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
		return
	}

	s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
	writeJSON(w, 0, map[string]any{"updated": true})
}

// handleAdminTextGenDescriptionAsync handles POST /api/admin/text-gen/description/async.
//
// Fire-and-forget variant: validates inputs, creates an ai_job record of kind
// "description", spawns a background goroutine that calls the LLM, stores the
// old/new description in the job payload, and marks the job done/failed.
// Returns HTTP 202 with {job_id} immediately.
func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
		return
	}
	if s.deps.AIJobStore == nil {
		jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
		return
	}

	var req textGenDescriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
		return
	}
	if strings.TrimSpace(req.Slug) == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}

	// Load current metadata eagerly so we can fail fast if the book is missing.
	meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
		return
	}
	if !ok {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
		return
	}

	model := cfai.TextModel(req.Model)
	if model == "" {
		model = cfai.DefaultTextModel
	}

	instructions := strings.TrimSpace(req.Instructions)
	if instructions == "" {
		instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging."
	}

	// Encode the initial params (without result) as the starting payload.
	type initPayload struct {
		Instructions   string `json:"instructions"`
		OldDescription string `json:"old_description"`
	}
	initJSON, _ := json.Marshal(initPayload{
		Instructions:   instructions,
		OldDescription: meta.Summary,
	})

	jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
		Kind:    "description",
		Slug:    req.Slug,
		Status:  domain.TaskStatusPending,
		Model:   string(model),
		Payload: string(initJSON),
		Started: time.Now(),
	})
	if createErr != nil {
		jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
		return
	}

	jobCtx, jobCancel := context.WithCancel(context.Background())
	registerCancelJob(jobID, jobCancel)

	_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
		"status": string(domain.TaskStatusRunning),
	})

	s.deps.Log.Info("admin: text-gen description async started",
		"job_id", jobID, "slug", req.Slug, "model", model)

	// Capture locals.
	store := s.deps.AIJobStore
	textGen := s.deps.TextGen
	logger := s.deps.Log
	capturedMeta := meta
	capturedModel := model
	capturedInstructions := instructions
	capturedMaxTokens := req.MaxTokens

	go func() {
		defer deregisterCancelJob(jobID)
		defer jobCancel()

		if jobCtx.Err() != nil {
			_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
				"status":   string(domain.TaskStatusCancelled),
				"finished": time.Now().Format(time.RFC3339),
			})
			return
		}

		systemPrompt := `You are a book description writer for a web novel platform. ` +
			`Given a book's title, author, genres, and current description, write an improved ` +
			`description that accurately captures the story. ` +
			`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`

		userPrompt := fmt.Sprintf(
			"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
			capturedMeta.Title,
			capturedMeta.Author,
			strings.Join(capturedMeta.Genres, ", "),
			capturedMeta.Status,
			capturedMeta.Summary,
			capturedInstructions,
		)

		newDesc, genErr := textGen.Generate(jobCtx, cfai.TextRequest{
			Model: capturedModel,
			Messages: []cfai.TextMessage{
				{Role: "system", Content: systemPrompt},
				{Role: "user", Content: userPrompt},
			},
			MaxTokens: capturedMaxTokens,
		})
		if genErr != nil {
			logger.Error("admin: text-gen description async failed", "job_id", jobID, "err", genErr)
			_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
				"status":        string(domain.TaskStatusFailed),
				"error_message": genErr.Error(),
				"finished":      time.Now().Format(time.RFC3339),
			})
			return
		}

		type resultPayload struct {
			Instructions   string `json:"instructions"`
			OldDescription string `json:"old_description"`
			NewDescription string `json:"new_description"`
		}
		resultJSON, _ := json.Marshal(resultPayload{
			Instructions:   capturedInstructions,
			OldDescription: capturedMeta.Summary,
			NewDescription: strings.TrimSpace(newDesc),
		})

		_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
			"status":      string(domain.TaskStatusDone),
			"items_done":  1,
			"items_total": 1,
			"payload":     string(resultJSON),
			"finished":    time.Now().Format(time.RFC3339),
		})
		logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
	}()

	writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}
