package backend

// Catalogue enrichment handlers: tagline, genre tagging, content warnings,
// quality scoring, batch cover regeneration, and per-book metadata refresh.
//
// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer).
// All long-running operations support cancellation via r.Context().Done().
// Batch operations use an in-memory cancel registry (cancelJobs map) so the
// frontend can send a cancel request by job ID.

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

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


// ── Tagline ───────────────────────────────────────────────────────────────

// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline.
type textGenTaglineRequest struct {
	Slug      string `json:"slug"`
	Model     string `json:"model"`
	MaxTokens int    `json:"max_tokens"`
}

// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline.
type textGenTaglineResponse struct {
	OldTagline string `json:"old_tagline"`
	NewTagline string `json:"new_tagline"`
	Model      string `json:"model"`
}

// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline.
// Generates a 1-sentence marketing hook for a book.
func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
		return
	}

	var req textGenTaglineRequest
	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
	}

	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
	}

	system := `You are a copywriter for a web novel platform. ` +
		`Given a book's title, genres, and description, write a single punchy tagline ` +
		`(one sentence, under 20 words) that hooks a reader. ` +
		`Output ONLY the tagline — no quotes, no labels, no explanation.`

	user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
		meta.Title,
		strings.Join(meta.Genres, ", "),
		meta.Summary,
	)

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

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

	writeJSON(w, 0, textGenTaglineResponse{
		OldTagline: "", // BookMeta has no tagline field yet — always empty
		NewTagline: strings.TrimSpace(result),
		Model:      string(model),
	})
}

// ── Genres ────────────────────────────────────────────────────────────────

// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres.
type textGenGenresRequest struct {
	Slug      string `json:"slug"`
	Model     string `json:"model"`
	MaxTokens int    `json:"max_tokens"`
}

// textGenGenresResponse is returned by POST /api/admin/text-gen/genres.
type textGenGenresResponse struct {
	CurrentGenres  []string `json:"current_genres"`
	ProposedGenres []string `json:"proposed_genres"`
	Model          string   `json:"model"`
}

// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres.
// Suggests a refined genre list based on the book's description.
func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
		return
	}

	var req textGenGenresRequest
	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
	}

	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
	}

	system := `You are a genre classification expert for a web novel platform. ` +
		`Given a book's title and description, return a JSON array of 2–6 genre tags. ` +
		`Use only well-known web novel genres such as: ` +
		`Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` +
		`Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` +
		`Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` +
		`Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` +
		`Example: ["Fantasy","Adventure","Action"]`

	user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s",
		meta.Title,
		strings.Join(meta.Genres, ", "),
		meta.Summary,
	)

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

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

	proposed := parseStringArrayJSON(raw)

	writeJSON(w, 0, textGenGenresResponse{
		CurrentGenres:  meta.Genres,
		ProposedGenres: proposed,
		Model:          string(model),
	})
}

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

	var req struct {
		Slug   string   `json:"slug"`
		Genres []string `json:"genres"`
	}
	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
	}

	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.Genres = req.Genres
	if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
		s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err)
		jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
		return
	}

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

// ── Content warnings ──────────────────────────────────────────────────────

// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsRequest struct {
	Slug      string `json:"slug"`
	Model     string `json:"model"`
	MaxTokens int    `json:"max_tokens"`
}

// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsResponse struct {
	Warnings []string `json:"warnings"`
	Model    string   `json:"model"`
}

// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings.
// Detects mature or sensitive themes in a book's description.
func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
		return
	}

	var req textGenContentWarningsRequest
	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
	}

	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
	}

	system := `You are a content moderation assistant for a web novel platform. ` +
		`Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` +
		`Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` +
		`Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` +
		`If the book is clean, return an empty array. ` +
		`Output ONLY a raw JSON array of strings — no prose, no markdown. ` +
		`Example: ["Violence","Dark Themes"]`

	user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
		meta.Title,
		strings.Join(meta.Genres, ", "),
		meta.Summary,
	)

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

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

	warnings := parseStringArrayJSON(raw)

	writeJSON(w, 0, textGenContentWarningsResponse{
		Warnings: warnings,
		Model:    string(model),
	})
}

// ── Quality score ─────────────────────────────────────────────────────────

// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score.
type textGenQualityScoreRequest struct {
	Slug      string `json:"slug"`
	Model     string `json:"model"`
	MaxTokens int    `json:"max_tokens"`
}

// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score.
type textGenQualityScoreResponse struct {
	Score    int    `json:"score"`    // 1–5
	Feedback string `json:"feedback"` // brief reasoning
	Model    string `json:"model"`
}

// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score.
// Rates the book description quality on a 1–5 scale with brief feedback.
func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
		return
	}

	var req textGenQualityScoreRequest
	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
	}

	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
	}

	system := `You are a book description quality reviewer for a web novel platform. ` +
		`Rate the provided description on a scale of 1–5 where: ` +
		`1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` +
		`Respond with ONLY a JSON object: {"score": <1-5>, "feedback": "<one sentence explanation>"}. ` +
		`No markdown, no extra text.`

	user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
		meta.Title,
		strings.Join(meta.Genres, ", "),
		meta.Summary,
	)

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

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

	var parsed struct {
		Score    int    `json:"score"`
		Feedback string `json:"feedback"`
	}
	// Strip markdown fences if any.
	clean := extractJSONObject(raw)
	if err := json.Unmarshal([]byte(clean), &parsed); err != nil {
		// Fallback: try to extract a digit.
		parsed.Score = 0
		for _, ch := range raw {
			if ch >= '1' && ch <= '5' {
				parsed.Score = int(ch - '0')
				break
			}
		}
		parsed.Feedback = strings.TrimSpace(raw)
	}

	writeJSON(w, 0, textGenQualityScoreResponse{
		Score:    parsed.Score,
		Feedback: parsed.Feedback,
		Model:    string(model),
	})
}

// ── Batch cover regeneration ──────────────────────────────────────────────

// batchCoverEvent is one SSE event emitted during batch cover regeneration.
type batchCoverEvent struct {
	// JobID is the opaque identifier clients use to cancel this job.
	JobID   string `json:"job_id,omitempty"`
	Done    int    `json:"done"`
	Total   int    `json:"total"`
	Slug    string `json:"slug,omitempty"`
	Error   string `json:"error,omitempty"`
	Skipped bool   `json:"skipped,omitempty"`
	Finish  bool   `json:"finish,omitempty"`
}

// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers.
//
// Streams SSE events as it generates covers for every book that has no cover
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
//
// Supports from_item/to_item to process a sub-range of the catalogue (0-based indices).
// Supports job_id to resume a previously interrupted job.
// The job can be cancelled by calling POST /api/admin/ai-jobs/{id}/cancel.
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
	if s.deps.TextGen == nil || s.deps.ImageGen == nil {
		jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
		return
	}
	if s.deps.CoverStore == nil {
		jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
		return
	}

	var reqBody struct {
		Model    string `json:"model"`
		NumSteps int    `json:"num_steps"`
		Width    int    `json:"width"`
		Height   int    `json:"height"`
		FromItem int    `json:"from_item"`
		ToItem   int    `json:"to_item"`
		JobID    string `json:"job_id"`
	}
	// Body is optional — defaults used if absent.
	json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck

	allBooks, err := s.deps.BookReader.ListBooks(r.Context())
	if err != nil {
		jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
		return
	}

	// Apply range filter.
	books := allBooks
	if reqBody.FromItem > 0 || reqBody.ToItem > 0 {
		from := reqBody.FromItem
		to := reqBody.ToItem
		if to == 0 || to >= len(allBooks) {
			to = len(allBooks) - 1
		}
		if from < 0 {
			from = 0
		}
		if from <= to && from < len(allBooks) {
			books = allBooks[from : to+1]
		}
	}

	// SSE headers.
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no")
	flusher, canFlush := w.(http.Flusher)

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

	total := len(books)
	done := 0

	// Create or resume PB ai_job and register cancel context.
	var pbJobID string
	resumeFrom := 0
	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()

	if s.deps.AIJobStore != nil {
		if reqBody.JobID != "" {
			if existing, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), reqBody.JobID); ok {
				pbJobID = reqBody.JobID
				resumeFrom = existing.ItemsDone
				done = resumeFrom
				_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
					"status":      string(domain.TaskStatusRunning),
					"items_total": total,
				})
			}
		}
		if pbJobID == "" {
			id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
				Kind:       "batch-covers",
				Status:     domain.TaskStatusRunning,
				FromItem:   reqBody.FromItem,
				ToItem:     reqBody.ToItem,
				ItemsTotal: total,
				Started:    time.Now(),
			})
			if createErr == nil {
				pbJobID = id
			}
		}
		if pbJobID != "" {
			registerCancelJob(pbJobID, cancel)
			defer deregisterCancelJob(pbJobID)
		}
	}

	// Use pbJobID as the SSE job_id when available, else a random hex fallback.
	sseJobID := pbJobID
	if sseJobID == "" {
		sseJobID = randomHex(8)
		ctx2, cancel2 := context.WithCancel(r.Context())
		registerCancelJob(sseJobID, cancel2)
		defer deregisterCancelJob(sseJobID)
		defer cancel2()
		cancel() // replace ctx with ctx2
		ctx = ctx2
	}

	// Send initial event with jobID so frontend can store it for cancellation.
	sseWrite(batchCoverEvent{JobID: sseJobID, Done: done, Total: total})

	for i, book := range books {
		if ctx.Err() != nil {
			break
		}
		// Skip already-processed items when resuming.
		if i < resumeFrom {
			continue
		}

		// Check if cover already exists.
		hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
		if hasCover {
			done++
			sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
			if pbJobID != "" && s.deps.AIJobStore != nil {
				_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
			}
			continue
		}

		// Build a prompt from the book metadata.
		prompt := buildCoverPrompt(book)

		// Generate the image via CF AI.
		imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{
			Prompt:   prompt,
			NumSteps: reqBody.NumSteps,
			Width:    reqBody.Width,
			Height:   reqBody.Height,
		})
		if genErr != nil {
			done++
			s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr)
			sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()})
			continue
		}

		// Save to CoverStore.
		if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil {
			done++
			s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr)
			sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()})
			continue
		}

		done++
		s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
		sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
		if pbJobID != "" && s.deps.AIJobStore != nil {
			_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
		}
	}

	if pbJobID != "" && s.deps.AIJobStore != nil {
		status := domain.TaskStatusDone
		if ctx.Err() != nil {
			status = domain.TaskStatusCancelled
		}
		_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
			"status":     string(status),
			"items_done": done,
			"finished":   time.Now().Format(time.RFC3339),
		})
	}

	sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
}

// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel.
// Cancels an in-progress batch cover job by its job ID.
func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) {
	var req struct {
		JobID string `json:"job_id"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" {
		jsonError(w, http.StatusBadRequest, "job_id is required")
		return
	}

	cancelJobsMu.Lock()
	cancel, ok := cancelJobs[req.JobID]
	cancelJobsMu.Unlock()

	if !ok {
		jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID))
		return
	}
	cancel()
	s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID)
	writeJSON(w, 0, map[string]any{"cancelled": true})
}

// ── Refresh metadata (per-book) ────────────────────────────────────────────

// refreshMetadataEvent is one SSE event during per-book metadata refresh.
type refreshMetadataEvent struct {
	Step  string `json:"step"`  // "description" | "tagline" | "cover"
	Done  bool   `json:"done"`
	Error string `json:"error,omitempty"`
}

// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}.
//
// Runs description → tagline → cover generation in sequence for a single book
// and streams SSE progress. Interruptable via client disconnect (r.Context()).
func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	if slug == "" {
		jsonError(w, http.StatusBadRequest, "slug is required")
		return
	}

	meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), 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", slug))
		return
	}

	// SSE headers.
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no")
	flusher, canFlush := w.(http.Flusher)

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

	ctx := r.Context()

	// Step 1 — description.
	if s.deps.TextGen != nil {
		if ctx.Err() == nil {
			newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{
				Model: cfai.DefaultTextModel,
				Messages: []cfai.TextMessage{
					{Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`},
					{Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)},
				},
				MaxTokens: 512,
			})
			if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil {
				meta.Summary = strings.TrimSpace(newDesc)
				if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil {
					sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()})
				} else {
					sseWrite(refreshMetadataEvent{Step: "description"})
				}
			} else if genErr != nil {
				sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()})
			}
		}
	}

	// Step 2 — cover.
	if s.deps.ImageGen != nil && s.deps.CoverStore != nil {
		if ctx.Err() == nil {
			prompt := buildCoverPrompt(meta)
			imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt})
			if genErr == nil {
				if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil {
					sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()})
				} else {
					sseWrite(refreshMetadataEvent{Step: "cover"})
				}
			} else {
				sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()})
			}
		}
	}

	sseWrite(refreshMetadataEvent{Step: "done", Done: true})
}

// ── Helpers ───────────────────────────────────────────────────────────────

// parseStringArrayJSON extracts a JSON string array from model output,
// tolerating markdown fences and surrounding prose.
func parseStringArrayJSON(raw string) []string {
	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]
	}
	start := strings.Index(s, "[")
	end := strings.LastIndex(s, "]")
	if start < 0 || end <= start {
		return nil
	}
	s = s[start : end+1]
	var out []string
	json.Unmarshal([]byte(s), &out) //nolint:errcheck
	return out
}

// extractJSONObject finds the first {...} object in a string.
func extractJSONObject(raw string) string {
	start := strings.Index(raw, "{")
	end := strings.LastIndex(raw, "}")
	if start < 0 || end <= start {
		return raw
	}
	return raw[start : end+1]
}

// buildCoverPrompt constructs a prompt string for cover generation from a book.
func buildCoverPrompt(meta domain.BookMeta) string {
	parts := []string{"book cover art"}
	if meta.Title != "" {
		parts = append(parts, "titled \""+meta.Title+"\"")
	}
	if len(meta.Genres) > 0 {
		parts = append(parts, strings.Join(meta.Genres, ", ")+" genre")
	}
	if meta.Summary != "" {
		summary := meta.Summary
		if len(summary) > 200 {
			summary = summary[:200]
		}
		parts = append(parts, summary)
	}
	return strings.Join(parts, ", ")
}

// randomHex returns a random hex string of n bytes.
func randomHex(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}
