package api

import (
	"context"
	"encoding/json"
	"math/rand/v2"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode"

	"github.com/ponzischeme89/memby/server/internal/appupdate"
	"github.com/ponzischeme89/memby/server/internal/cache"
	"github.com/ponzischeme89/memby/server/internal/emby"
	"github.com/ponzischeme89/memby/server/internal/recommend"
	"github.com/ponzischeme89/memby/server/internal/store"
)

// Field sets mirror what each TV row actually renders. Asking Emby for less is the
// single biggest lever on home-screen latency, so keep these tight.
const (
	// Resume and Next Up contain episodes as well as movies. Keep their descriptive and
	// rating fields in the row payload: an episode otherwise reaches the TV with only a
	// title and progress, and the first ratings request can cache that incomplete state
	// before the richer focus lookup finishes.
	fieldsContinue = "Overview,Genres,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio"
	fieldsRow      = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
	// Status is a series' production state ("Continuing"/"Ended"). The television needs it
	// to decide whether it is estimating a finish or a catch-up, and the detail call is
	// where a field like this belongs — adding it to a home query is a startup cost.
	fieldsDetail      = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
	fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"

	rowImageTypes         = "Backdrop,Primary,Logo"
	screensaverImageTypes = "Backdrop,Logo"
)

type homeResponse struct {
	// Rows is the home screen as the server wants it drawn: order, titles and kinds all
	// decided here, so a new row (a recommendation strip, a seasonal collection) ships
	// without touching the TV app. The client renders whatever arrives.
	Rows []recommend.Row `json:"rows"`

	// The fixed rows are also sent flat. They are what the client caches for an
	// instant cold start, and what the direct-to-Emby path still produces.
	ContinueWatching []json.RawMessage `json:"continueWatching"`
	NextUp           []json.RawMessage `json:"nextUp"`
	Favorites        []json.RawMessage `json:"favorites"`
	LatestMovies     []json.RawMessage `json:"latestMovies"`

	// Partial is true when at least one row failed upstream. The TV shows what arrived
	// and flags a refresh error rather than blanking the screen.
	Partial bool `json:"partial"`

	// Deliberately no update verdict here: this payload is cached per user, and the
	// verdict depends on the *client's* version, so a cached body would hand one TV's
	// answer to another running a different build. The client asks /v1/update instead.
}

// handleHome answers the entire launcher in one round trip.
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	limit := queryInt(r, "limit", 24, 100)
	sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
	radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
	hero := supportsHomeHero(r)
	key := cache.UserKey(
		sess.EmbyUserID,
		"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
			":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
			":d"+sess.DeviceID,
	)

	if raw, err := s.cache.Get(ctx, key); err == nil {
		w.Header().Set("X-Memby-Cache", "hit")
		writeRaw(w, http.StatusOK, raw)
		return
	}

	cred := credentials(sess)
	var (
		mu             sync.Mutex
		failures       int
		out            homeResponse
		sonarrRow      *recommend.Row
		radarrRow      *recommend.Row
		forYouRows     []recommend.Row
		forYouRowStale bool
		seriesPlayed   map[string]time.Time
		wg             sync.WaitGroup
	)

	run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
		wg.Add(1)
		go func() {
			defer wg.Done()
			result, err := fetch()
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				failures++
				s.loggerFor(ctx).Warn("home row failed", "error", err)
				return
			}
			*dest = result.Items
		}()
	}

	run(&out.ContinueWatching, func() (*emby.ItemsResult, error) {
		return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
			"Recursive":  {"true"},
			"MediaTypes": {"Video"},
			"Limit":      {itoa(limit)},
		}, fieldsContinue))
	})
	run(&out.Favorites, func() (*emby.ItemsResult, error) {
		return s.emby.Items(ctx, cred, rowParams(url.Values{
			"Filters":          {"IsFavorite"},
			"IncludeItemTypes": {"Movie,Series"},
			"Recursive":        {"true"},
			"SortBy":           {"SortName"},
			"SortOrder":        {"Ascending"},
			"Limit":            {itoa(limit)},
		}, fieldsRow))
	})
	run(&out.NextUp, func() (*emby.ItemsResult, error) {
		return s.emby.NextUp(ctx, cred, rowParams(url.Values{
			"Limit": {itoa(limit)},
		}, fieldsContinue))
	})
	// What orders the Next Up half of Continue Watching. A failure here is not a failed
	// row: the merge falls back to putting the resume items first, which is the order
	// the launcher had before the two rows became one.
	wg.Add(1)
	go func() {
		defer wg.Done()
		played, err := s.recentlyPlayedSeries(ctx, cred)
		if err != nil {
			s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
			return
		}
		mu.Lock()
		seriesPlayed = played
		mu.Unlock()
	}()
	run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
		newReleaseDays := s.weightedConfig().NewReleaseDays
		if newReleaseDays < 1 {
			newReleaseDays = recommend.DefaultWeightedConfig().NewReleaseDays
		}
		return s.emby.Items(ctx, cred, rowParams(url.Values{
			"IncludeItemTypes": {"Movie"},
			"Recursive":        {"true"},
			// Eligibility precedes personalization: this row means a real recent
			// release, not merely an old film imported into the library yesterday.
			"MinPremiereDate": {time.Now().AddDate(0, 0, -newReleaseDays).UTC().Format(time.RFC3339)},
			"SortBy":          {"PremiereDate"},
			"SortOrder":       {"Descending"},
			"Limit":           {itoa(limit)},
		}, fieldsRow))
	})
	if sonarrSchedule {
		wg.Add(1)
		go func() {
			defer wg.Done()
			row, err := s.sonarrAiringTodayRow(ctx)
			if err != nil {
				s.loggerFor(ctx).Warn("sonarr calendar row failed", "error", err)
				return
			}
			mu.Lock()
			sonarrRow = row
			mu.Unlock()
		}()
	}
	if radarrSchedule {
		wg.Add(1)
		go func() {
			defer wg.Done()
			row, err := s.radarrUpcomingMoviesRow(ctx)
			if err != nil {
				s.loggerFor(ctx).Warn("radarr calendar row failed", "error", err)
				return
			}
			mu.Lock()
			radarrRow = row
			mu.Unlock()
		}()
	}
	if s.forYou != nil {
		// The prepared pool is one indexed PostgreSQL read. It runs beside the Emby
		// calls and deliberately has no live-engine fallback, so Home can never inherit
		// Tracearr fan-out or recommendation rebuild latency.
		wg.Add(1)
		go func() {
			defer wg.Done()
			location := s.cfg.SonarrLocation
			if location == nil {
				location = time.UTC
			}
			window := homeForYouWindowAt(time.Now().In(location))
			prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
			if err != nil {
				s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
				return
			}
			if !hit || len(prepared) == 0 {
				return
			}
			homeRows := preparedHomeForYouRows(prepared, window)
			if len(homeRows) == 0 {
				return
			}
			mu.Lock()
			forYouRows = homeRows
			forYouRowStale = stale
			mu.Unlock()
		}()
	}

	wg.Wait()

	if failures == 4 {
		writeError(w, http.StatusBadGateway, "could not reach the emby server")
		return
	}
	out.Partial = failures > 0
	ensureSlices(&out)
	// One row, not two: an episode finished a minute ago should be followed by the next
	// one at the front of Continue Watching rather than moving the show to a different
	// row. NextUp stays on the wire for televisions that predate the merge.
	out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
	// A current show with an episode today is more immediately useful than a long-lived
	// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
	// promotion, not a replacement ranking for Continue Watching.
	out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)

	// Recommendations are read from their own long-lived cache. A miss means this
	// response ships without them and a rebuild starts in the background — the home
	// screen never waits on the engine.
	recommendations := s.cachedRecommendations(ctx, sess.EmbyUserID)
	if recommendations == nil {
		s.refreshRecommendationsInBackground(sess)
	}
	rows := baseRows(out)
	nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
	if len(forYouRows) > 0 {
		nearContinue = append(nearContinue, forYouRows...)
		if forYouRowStale {
			s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
			s.forYou.RefreshAsync(sess, false)
		}
	}
	if sonarrRow != nil {
		nearContinue = append(nearContinue, *sonarrRow)
	}
	if radarrRow != nil {
		nearContinue = append(nearContinue, *radarrRow)
	}
	if len(nearContinue) > 0 {
		// Personalised discovery and the upcoming schedule are most useful immediately
		// after Continue Watching, before the broader library collections.
		rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
	}
	out.Rows = append(rows, recommendations...)
	out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
	if stats, err := s.store.UserRowStats(
		ctx,
		sess.EmbyUserID,
		time.Now().Add(-45*24*time.Hour),
	); err != nil {
		s.loggerFor(ctx).Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
	} else {
		out.Rows = personalizeHomeRows(out.Rows, stats)
	}
	out.Rows = s.personalizeTitles(ctx, sess, out.Rows)
	out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
	out.Rows = deduplicateRows(out.Rows)
	// Ratings ride on the cards themselves. Only what is already stored is attached, so
	// the launcher pays one indexed read rather than a request per poster, and a card
	// shows its scores as it is drawn instead of when focus reaches it.
	s.decorateHomeRatings(ctx, &out)
	// The hero is composed last, from the finished rows, because that is the only point
	// at which the ratings it ranks by are already attached. It is prepended rather than
	// inserted: the television consumes this row instead of drawing it, so its position
	// among the shelves means nothing, and being first is what lets an older reader that
	// does draw it put it somewhere sensible.
	if hero {
		if row := s.heroRow(ctx, out.Rows, time.Now()); row != nil {
			out.Rows = append([]recommend.Row{*row}, out.Rows...)
		}
	}

	body, err := json.Marshal(out)
	if err != nil {
		s.loggerFor(ctx).Error("home encode failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not build the home payload")
		return
	}
	// A partial payload is served but never cached: the next request should retry.
	if !out.Partial {
		if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil {
			s.loggerFor(ctx).Warn("home cache write failed", "error", err)
		}
	}
	w.Header().Set("X-Memby-Cache", "miss")
	writeRaw(w, http.StatusOK, body)
}

func prioritizeAiringTodayContinue(
	items []json.RawMessage,
	schedule *recommend.Row,
) []json.RawMessage {
	if len(items) < 2 || schedule == nil {
		return items
	}
	today := map[string]bool{}
	for _, raw := range schedule.Items {
		var entry struct {
			Name string `json:"Name"`
			Day  string `json:"MembyAirDayLabel"`
		}
		if json.Unmarshal(raw, &entry) == nil && strings.EqualFold(entry.Day, "Today") {
			if key := normalizedShowKey(entry.Name); key != "" {
				today[key] = true
			}
		}
	}
	if len(today) == 0 {
		return items
	}
	promoted := make([]json.RawMessage, 0, len(items))
	rest := make([]json.RawMessage, 0, len(items))
	for _, raw := range items {
		var item struct {
			Name       string `json:"Name"`
			Type       string `json:"Type"`
			SeriesName string `json:"SeriesName"`
		}
		if json.Unmarshal(raw, &item) != nil {
			rest = append(rest, raw)
			continue
		}
		name := item.Name
		if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
			name = item.SeriesName
		}
		if !strings.EqualFold(item.Type, "Episode") && !strings.EqualFold(item.Type, "Series") {
			rest = append(rest, raw)
			continue
		}
		if today[normalizedShowKey(name)] {
			promoted = append(promoted, raw)
		} else {
			rest = append(rest, raw)
		}
	}
	return append(promoted, rest...)
}

func normalizedShowKey(value string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(value) {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// personalizeHomeRows applies a deliberately conservative engagement nudge. New and
// lightly sampled rows keep their authored position; only shelves repeatedly shown and
// ignored lose ground. Continue Watching remains the stable first landmark, while a
// selection or meaningful dwell quickly earns a shelf its position back.
func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommend.Row {
	if len(rows) < 2 || len(stats) == 0 {
		return rows
	}
	byID := make(map[string]store.RowStat, len(stats))
	for _, stat := range stats {
		byID[stat.RowID] = stat
	}
	type rankedRow struct {
		row      recommend.Row
		position int
		score    float64
	}
	ranked := make([]rankedRow, 0, len(rows))
	for position, row := range rows {
		score := 1.0
		if stat, ok := byID[row.ID]; ok && stat.Impressions >= 5 {
			// Two neutral pseudo-impressions prevent a tiny sample from producing an
			// extreme score. Dwell is capped by the ingestion endpoint.
			engagement := float64(stat.Selects)*6 +
				float64(stat.Focuses) +
				float64(stat.DwellMs)/30_000
			score = (engagement + 2) / (float64(stat.Impressions) + 2)
		}
		if row.ID == "continue" {
			score = 1_000
		}
		ranked = append(ranked, rankedRow{row: row, position: position, score: score})
	}
	sort.SliceStable(ranked, func(i, j int) bool {
		if ranked[i].score != ranked[j].score {
			return ranked[i].score > ranked[j].score
		}
		return ranked[i].position < ranked[j].position
	})
	out := make([]recommend.Row, 0, len(ranked))
	for _, entry := range ranked {
		out = append(out, entry.row)
	}
	return out
}

// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
// time-aware general picks. Other For You shelves remain in the dedicated destination.
func preparedHomeForYouRows(
	prepared []recommend.Row,
	window homeForYouWindow,
) []recommend.Row {
	rows := make([]recommend.Row, 0, 2)
	for _, source := range prepared {
		row := source
		switch row.ID {
		case "for-you:pick-up":
			row.Title = "Pick this show up again"
		case "for-you:picks":
			row.ID = "for-you:home:" + window.ID
			row.Title = window.Title
		default:
			continue
		}
		if len(row.Items) > 12 {
			row.Items = row.Items[:12]
		}
		rows = append(rows, row)
	}
	return rows
}

// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
	version := clientVersion(r)
	return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}

// Radarr movie schedules need the movie-specific row presentation introduced in 0.1.79.
func supportsRadarrSchedule(r *http.Request) bool {
	version := clientVersion(r)
	return version != "" && appupdate.CompareVersions(version, "0.1.79") >= 0
}

// The server-composed hero ships in 0.2.27. Gating it matters more than gating a shelf:
// a television that predates it has no idea the "hero" kind is meant to be consumed
// rather than drawn, so it renders the featured cards a second time as a "Featured" row
// of posters beneath the hero it picked for itself.
//
// Note that 0.2.27 is also the version the feature was *added to* rather than a version
// after it, so any 0.2.27 build already in the field is one of the televisions this is
// meant to exclude. That is a deliberate call by the operator; if it bites, moving this
// floor to the next version is the fix, not a client change.
func supportsHomeHero(r *http.Request) bool {
	version := clientVersion(r)
	return version != "" && appupdate.CompareVersions(version, "0.2.27") >= 0
}

// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	limit := queryInt(r, "limit", 200, 400)
	key := cache.UserKey(sess.EmbyUserID, "screensaver:"+itoa(limit))

	var items []json.RawMessage
	if raw, err := s.cache.Get(ctx, key); err == nil {
		_ = json.Unmarshal(raw, &items)
	}

	if items == nil {
		result, err := s.emby.Items(ctx, credentials(sess), url.Values{
			"IncludeItemTypes": {"Movie,Series"},
			"Recursive":        {"true"},
			"Filters":          {"HasBackdrop"},
			"SortBy":           {"Random"},
			"Limit":            {itoa(limit)},
			"Fields":           {fieldsScreensaver},
			"ImageTypeLimit":   {"1"},
			"EnableImageTypes": {screensaverImageTypes},
			"EnableUserData":   {"true"},
		})
		if err != nil {
			s.writeUpstreamError(ctx, w, err, "could not load screensaver items")
			return
		}
		items = result.Items
		if raw, err := json.Marshal(items); err == nil {
			_ = s.cache.Set(ctx, key, raw, s.cfg.ScreensaverTTL)
		}
	}

	shuffled := make([]json.RawMessage, len(items))
	copy(shuffled, items)
	rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })

	writeJSON(w, http.StatusOK, map[string]any{"items": shuffled})
}

func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	term := r.URL.Query().Get("q")
	if len(term) < 2 {
		writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}})
		return
	}
	limit := queryInt(r, "limit", 40, 100)
	key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)

	// Every search the tab performs is recorded here, before the cache is consulted, so a
	// query answered from Redis counts the same as one that reached Emby. The client also
	// posts to /v1/search/history and an older APK is the only thing that records at all —
	// recordSearchQuery's dedupe window is what stops the two writing the same query twice.
	s.recordSearchQuery(ctx, sess, term)

	if raw, err := s.cache.Get(ctx, key); err == nil {
		w.Header().Set("X-Memby-Cache", "hit")
		writeRaw(w, http.StatusOK, raw)
		return
	}

	// Search always asks Emby with the signed-in user's credentials. The imported
	// household catalogue may contain titles hidden by library permissions or parental
	// controls and therefore cannot be an eligibility authority.
	result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
		"SearchTerm":       {term},
		"IncludeItemTypes": {"Movie,Series,Episode"},
		"Recursive":        {"true"},
		"Limit":            {itoa(limit)},
	}, fieldsRow))
	if err != nil {
		s.writeUpstreamError(ctx, w, err, "search failed")
		return
	}
	items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
	s.decorateItemRatings(ctx, items)
	// Instant search fires a request per keystroke past the second character, so this is
	// DEBUG: it is the record of what somebody was looking for when nothing was found,
	// not something to carry in the normal log.
	s.loggerFor(ctx).Debug("search", "query", term, "results", len(items))

	body, err := json.Marshal(map[string]any{"items": nonNil(items)})
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not build search results")
		return
	}
	if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
		s.loggerFor(ctx).Warn("search cache write failed", "error", err)
	}
	w.Header().Set("X-Memby-Cache", "miss")
	writeRaw(w, http.StatusOK, body)
}

const (
	// minSearchQueryRunes matches the client's own floor: one letter matches half a
	// library, so the search tab does not ask below two and neither route records below it.
	minSearchQueryRunes = 2
	// maxSearchQueryRunes bounds what is written to search_history. The query arrives in a
	// URL on one of the two routes, so the table's row size must not be the client's to
	// choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the
	// length of one in English.
	maxSearchQueryRunes = 200
)

// searchQueryRecordable is the one rule both routes apply, so a query the search handler
// records is exactly one the history endpoint would have accepted.
func searchQueryRecordable(term string) bool {
	n := len([]rune(strings.TrimSpace(term)))
	return n >= minSearchQueryRunes && n <= maxSearchQueryRunes
}

// recordSearchQuery writes a query the search tab performed, and never makes the viewer
// wait for it.
//
// Detached from the request context deliberately: instant search cancels the in-flight
// request on every keystroke (the client's collectLatest), so a write hung off r.Context()
// would be abandoned for precisely the searches somebody typed fastest — and the record is
// worth having whether or not they waited for the results.
func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) {
	if s.store == nil || !searchQueryRecordable(term) {
		return
	}
	term = strings.TrimSpace(term)
	log := s.loggerFor(ctx)
	detached := context.WithoutCancel(ctx)
	go func() {
		ctx, cancel := context.WithTimeout(detached, 5*time.Second)
		defer cancel()
		if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil {
			// Telemetry, not the answer: a search whose record failed still returns
			// results, and this is DEBUG for the same reason the search line itself is.
			log.Debug("search not recorded", "query", term, "error", err)
		}
	}()
}

type searchHistoryRequest struct {
	Query string `json:"query"`
}

type searchHistoryResponse struct {
	Queries []string `json:"queries"`
}

const (
	recentSearchDays  = 30
	recentSearchLimit = 10
)

func (s *Server) handleRecentSearches(w http.ResponseWriter, r *http.Request, sess store.Session) {
	if s.store == nil {
		writeError(w, http.StatusInternalServerError, "could not load recent searches")
		return
	}
	since := time.Now().Add(-recentSearchDays * 24 * time.Hour)
	queries, err := s.store.RecentSearches(
		r.Context(),
		sess.EmbyUserID,
		since,
		recentSearchLimit,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not load recent searches")
		return
	}
	if queries == nil {
		queries = []string{}
	}
	writeJSON(w, http.StatusOK, searchHistoryResponse{Queries: queries})
}

func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, sess store.Session) {
	var req searchHistoryRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid search history request")
		return
	}
	query := strings.TrimSpace(req.Query)
	if !searchQueryRecordable(query) {
		writeError(w, http.StatusBadRequest, "search query length is invalid")
		return
	}
	if s.store == nil || s.store.RecordSearch(r.Context(), sess.EmbyUserID, query) != nil {
		writeError(w, http.StatusInternalServerError, "could not record search")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// baseRows describes the three fixed rows.
//
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
// row that vanishes as you watch things is more jarring than an empty one.
//
// There is deliberately no Next Up row: those episodes are merged into Continue Watching
// by mergeContinueWatching, which is where somebody looks for them the moment an episode
// ends.
func baseRows(h homeResponse) []recommend.Row {
	return []recommend.Row{
		{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
		{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
		{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
	}
}

type homeForYouWindow struct {
	ID      string
	Title   string
	Minutes int
}

// homeForYouWindowAt keeps Home useful without asking the viewer for a duration.
// These deliberately broad windows suit a household TV: short before lunch, an
// episode-sized pick in the afternoon/late evening, and film headroom at night.
func homeForYouWindowAt(now time.Time) homeForYouWindow {
	switch hour := now.Hour(); {
	case hour >= 5 && hour < 12:
		return homeForYouWindow{ID: "morning", Title: "Quick morning picks for you", Minutes: 30}
	case hour >= 12 && hour < 17:
		return homeForYouWindow{ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60}
	case hour >= 17 && hour < 23:
		return homeForYouWindow{ID: "evening", Title: "Tonight's picks for you", Minutes: 120}
	default:
		return homeForYouWindow{ID: "late-night", Title: "Late-night picks for you", Minutes: 60}
	}
}

// rowParams applies the query shape every list endpoint shares.
func rowParams(params url.Values, fields string) url.Values {
	params.Set("Fields", fields)
	params.Set("ImageTypeLimit", "1")
	params.Set("EnableImages", "true")
	params.Set("EnableImageTypes", rowImageTypes)
	params.Set("EnableTotalRecordCount", "false")
	params.Set("EnableUserData", "true")
	return params
}

// ensureSlices keeps empty rows as [] rather than null, so kotlinx.serialization can
// decode them into non-null List fields.
func ensureSlices(h *homeResponse) {
	h.ContinueWatching = nonNil(h.ContinueWatching)
	h.NextUp = nonNil(h.NextUp)
	h.Favorites = nonNil(h.Favorites)
	h.LatestMovies = nonNil(h.LatestMovies)
}

func nonNil(items []json.RawMessage) []json.RawMessage {
	if items == nil {
		return []json.RawMessage{}
	}
	return items
}

func itoa(v int) string { return strconv.Itoa(v) }
