package recommend

import (
	"context"
	"encoding/json"
	"hash/fnv"
	"log/slog"
	"math"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/ponzischeme89/memby/server/internal/emby"
	"github.com/ponzischeme89/memby/server/internal/tracearr"
)

// Row is one horizontal strip on the TV home screen.
type Row struct {
	ID    string            `json:"id"`
	Title string            `json:"title"`
	Kind  string            `json:"kind"`
	Items []json.RawMessage `json:"items"`
}

// Source is the slice of the Emby client this package needs, narrowed so tests can
// supply a fake without a server.
type Source interface {
	Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
	Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error)
}

// NextUpSource is optional because the on-demand engine and small test sources do not
// need it. The production Emby client implements it; prepared rebuilds use one Next Up
// request to prove that an abandoned programme still has an unwatched episode.
type NextUpSource interface {
	NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
}

// LibrarySource is the imported catalogue. When present, the candidate pool comes from
// Postgres instead of Emby, which takes the rebuild off Emby entirely.
type LibrarySource interface {
	LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
}

// CuratedLibrarySource is the optional richer catalogue query used for server-authored
// collections. Keeping it separate preserves compatibility with simpler test sources.
type CuratedLibrarySource interface {
	CuratedCandidates(
		ctx context.Context,
		itemTypes, genres, studios []string,
		limit int,
	) ([]json.RawMessage, error)
}

// GenreLibrarySource lets an imported Sonarr/Radarr/Emby catalogue describe itself.
// It is optional so recommendation sources that predate genre discovery still work.
type GenreLibrarySource interface {
	LibraryGenres(ctx context.Context, itemTypes []string, minItems int) ([]string, error)
}

type TracearrSource interface {
	History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
}

type BehaviorSource interface {
	BrowsingCandidates(
		ctx context.Context,
		userID string,
		since time.Time,
		limit int,
	) ([]json.RawMessage, error)
}

// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
// the user's profile determines both item order and shelf order.
type CuratedRow struct {
	ID              string
	Title           string
	Kind            string
	ItemTypes       []string
	Genres          []string
	Studios         []string
	RequireAffinity bool
}

type Engine struct {
	source Source
	log    *slog.Logger

	// Library is optional; nil (or an empty library) falls back to querying Emby.
	Library  LibrarySource
	Tracearr TracearrSource
	Behavior BehaviorSource

	// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
	// looks broken next to full rows, so short rows are dropped entirely.
	MinRowItems int
	// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
	// screen rather than a wall of near-duplicates.
	MaxSimilarRows int
	// SeedPool is how far back down the watch history those rows may be anchored. It is
	// deliberately several times MaxSimilarRows: the rows are drawn from bands of this
	// window and rotate within them daily, which is what keeps the launcher moving for a
	// household part-way through one series.
	SeedPool       int
	RowSize        int
	CuratedRows    []CuratedRow
	WeightedConfig WeightedConfig

	// Location defines the viewer's local day/time windows. Now is injectable so
	// contextual placement stays deterministic in tests.
	Location *time.Location
	Now      func() time.Time
}

// ForYouOptions are request-scoped constraints chosen on the television.
type ForYouOptions struct {
	// AvailableMinutes is zero for no time limit.
	AvailableMinutes int
}

type compatibilityProfile struct {
	directCodecs    map[string]int
	transcodeCodecs map[string]int
}

// BuildForYou creates the dedicated, explainable recommendation area. Emby supplies
// catalogue metadata, Tracearr supplies completion and real device/playback outcomes,
// and Memby's own row analytics supplies browsing intent.
func (e *Engine) BuildForYou(
	ctx context.Context,
	cred emby.Credentials,
	username string,
	options ForYouOptions,
) ([]Row, error) {
	history, favorites, err := e.gatherSignals(ctx, cred)
	if err != nil {
		return nil, err
	}
	profile := BuildProfile(history, favorites)

	var sessions []tracearr.Session
	contextAffinity := NewContextAffinityProfile()
	if e.Tracearr != nil {
		if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
			e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
		} else {
			sessions = recommendationSessions(fetched)
			contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
		}
	}

	browsed := map[string]bool{}
	if e.Behavior != nil {
		raws, browseErr := e.Behavior.BrowsingCandidates(
			ctx,
			cred.UserID,
			time.Now().Add(-30*24*time.Hour),
			30,
		)
		if browseErr != nil {
			e.log.Warn("browsing signals unavailable", "error", browseErr)
		} else {
			for i, item := range Decode(raws) {
				weight := 0.55 * powDecay(0.92, i)
				profile.absorbTaste(item, weight)
				browsed[item.ID] = true
			}
		}
	}

	genres := profile.TopGenres(4)
	if len(genres) == 0 {
		return []Row{}, nil
	}
	candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres)
	if !ok {
		return []Row{}, nil
	}

	compatibility := buildCompatibilityProfile(sessions)
	items := rankForYouAt(
		profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
		contextAffinity, e.now(), e.Location,
	)
	if len(items) < e.MinRowItems {
		return []Row{}, nil
	}

	raws := make([]json.RawMessage, 0, len(items))
	for _, item := range items {
		reason, compatibilityLabel := explainRecommendation(
			profile,
			item,
			options.AvailableMinutes,
			compatibility,
			browsed[item.ID],
		)
		raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel))
	}
	title := "Top picks for you"
	if options.AvailableMinutes > 0 {
		title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes"
	}
	return []Row{{
		ID:    "for-you:picks",
		Title: title,
		Kind:  "for-you",
		Items: raws,
	}}, nil
}

func powDecay(base float64, position int) float64 {
	return math.Pow(base, float64(position))
}

func (e *Engine) applyTracearrSignals(
	profile *Profile,
	history []Item,
	sessions []tracearr.Session,
) ContextAffinityProfile {
	contextAffinity := NewContextAffinityProfile()
	byTitle := make(map[string]Item, len(history))
	for _, item := range history {
		byTitle[item.TitleKey()] = item
	}
	for i, session := range sessions {
		if session.Completion() > 0 {
			profile.SeenTitles[tracearrSeenKey(session)] = true
		}
		item, ok := byTitle[session.TitleKey()]
		if !ok {
			continue
		}
		// Completion distinguishes "finished it twice" from "abandoned after ten
		// minutes"; recency lets changing tastes move promptly.
		weight := (0.2 + session.Completion()) * powDecay(0.985, i)
		profile.absorbTaste(item, weight)
		if started, ok := parseTracearrTime(session.StartedAt); ok {
			contextAffinity.Add(item, started, session.Completion(), i, e.Location)
		}
	}
	return contextAffinity
}

func (e *Engine) libraryCandidatesForYou(
	ctx context.Context,
	cred emby.Credentials,
	genres []string,
) ([]Item, bool) {
	if e.Library != nil {
		raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12)
		if err == nil && len(raws) > 0 {
			return Decode(raws), true
		}
		if err != nil {
			e.log.Warn("for-you library candidates failed; falling back to emby", "error", err)
		}
	}
	result, err := e.source.Items(ctx, cred, url.Values{
		"IncludeItemTypes": {"Movie,Series"},
		"Recursive":        {"true"},
		"Filters":          {"IsUnplayed"},
		"Genres":           {strings.Join(genres, "|")},
		"SortBy":           {"CommunityRating"},
		"SortOrder":        {"Descending"},
		"Limit":            {strconv.Itoa(e.RowSize * 8)},
		"Fields":           {candidateFields + ",MediaStreams,Container"},
		"ImageTypeLimit":   {"1"},
		"EnableImages":     {"true"},
		"EnableImageTypes": {rowImageTypes},
		"EnableUserData":   {"true"},
	})
	if err != nil {
		e.log.Warn("for-you emby candidates failed", "error", err)
		return nil, false
	}
	return Decode(result.Items), len(result.Items) > 0
}

func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile {
	profile := compatibilityProfile{
		directCodecs:    map[string]int{},
		transcodeCodecs: map[string]int{},
	}
	for _, session := range sessions {
		if !session.IsTelevisionSession() {
			continue
		}
		for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} {
			codec = strings.ToLower(strings.TrimSpace(codec))
			if codec == "" {
				continue
			}
			if session.IsTranscode ||
				strings.EqualFold(session.VideoDecision, "transcode") ||
				strings.EqualFold(session.AudioDecision, "transcode") {
				profile.transcodeCodecs[codec]++
			} else {
				profile.directCodecs[codec]++
			}
		}
	}
	return profile
}

func compatibilityScore(item Item, profile compatibilityProfile) float64 {
	var score float64
	var known int
	for _, stream := range item.MediaStreams {
		if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") {
			continue
		}
		codec := strings.ToLower(strings.TrimSpace(stream.Codec))
		direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec]
		if direct+transcode == 0 {
			continue
		}
		known++
		score += float64(direct-transcode) / float64(direct+transcode)
	}
	if known == 0 {
		return 0
	}
	return score / float64(known)
}

func rankForYou(
	profile Profile,
	candidates []Item,
	availableMinutes int,
	compatibility compatibilityProfile,
	limit int,
) []Item {
	return rankForYouAt(
		profile, candidates, availableMinutes, compatibility, limit,
		ContextAffinityProfile{}, time.Time{}, nil,
	)
}

func rankForYouAt(
	profile Profile,
	candidates []Item,
	availableMinutes int,
	compatibility compatibilityProfile,
	limit int,
	contextAffinity ContextAffinityProfile,
	now time.Time,
	location *time.Location,
) []Item {
	type scored struct {
		item       Item
		score      float64
		contextRaw float64
		confidence float64
	}
	ranked := make([]scored, 0, len(candidates))
	seen := map[string]bool{}
	var maxContext float64
	for _, candidate := range candidates {
		if seen[candidate.ID] {
			continue
		}
		seen[candidate.ID] = true
		base := profile.Score(candidate)
		if base < 0 {
			continue
		}
		runtime := candidate.RuntimeMinutes()
		if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) {
			continue
		}
		score := base + compatibilityScore(candidate, compatibility)*1.4
		if availableMinutes > 0 {
			// Prefer a satisfying fit over something dramatically shorter, without
			// allowing runtime to overwhelm taste.
			score += float64(runtime) / float64(availableMinutes) * 0.35
		}
		contextRaw, confidence := contextAffinity.Score(candidate, now, location)
		if contextRaw > maxContext {
			maxContext = contextRaw
		}
		ranked = append(ranked, scored{
			item: candidate, score: score, contextRaw: contextRaw, confidence: confidence,
		})
	}
	if maxContext > 0 {
		for i := range ranked {
			// A maximum 1.5-point lift is enough to rearrange similarly relevant
			// posters without allowing a routine to overpower taste or quality.
			ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
		}
	}
	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].item.Name < ranked[j].item.Name
	})
	if limit > 0 && len(ranked) > limit {
		ranked = ranked[:limit]
	}
	out := make([]Item, 0, len(ranked))
	for _, entry := range ranked {
		out = append(out, entry.item)
	}
	return out
}

func (e *Engine) now() time.Time {
	if e.Now != nil {
		return e.Now()
	}
	return time.Now()
}

func explainRecommendation(
	profile Profile,
	item Item,
	availableMinutes int,
	compatibility compatibilityProfile,
	browsed bool,
) (string, string) {
	top := profile.TopGenres(5)
	matched := ""
	for _, wanted := range top {
		for _, genre := range item.Genres {
			if strings.EqualFold(wanted, genre) {
				matched = genre
				break
			}
		}
		if matched != "" {
			break
		}
	}
	reasons := make([]string, 0, 3)
	if browsed {
		reasons = append(reasons, "You explored this recently")
	} else if matched != "" {
		reasons = append(reasons, "Matches your "+matched+" viewing")
	} else if len(profile.Seeds) > 0 {
		reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
	} else {
		reasons = append(reasons, "Matches your recent viewing")
	}
	if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
		reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
	}
	compatibilityLabel := ""
	switch score := compatibilityScore(item, compatibility); {
	case score > 0.2:
		compatibilityLabel = "Direct plays well on this TV"
		reasons = append(reasons, compatibilityLabel)
	case score < -0.2:
		compatibilityLabel = "May need transcoding on this TV"
	default:
		compatibilityLabel = "TV compatibility not yet learned"
	}
	return strings.Join(reasons, " · "), compatibilityLabel
}

func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage {
	var item map[string]any
	if err := json.Unmarshal(raw, &item); err != nil {
		return raw
	}
	item["MembyRecommendationReason"] = reason
	item["MembyCompatibility"] = compatibility
	enriched, err := json.Marshal(item)
	if err != nil {
		return raw
	}
	return enriched
}

// EnrichPreparedRecommendation adds request-time details to a candidate selected from
// the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's
// current time budget is appended here.
func EnrichPreparedRecommendation(
	raw json.RawMessage,
	reason, compatibility string,
	availableMinutes int,
) json.RawMessage {
	if availableMinutes > 0 {
		reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window"
	}
	return enrichRecommendation(raw, reason, compatibility)
}

func NewEngine(source Source, log *slog.Logger) *Engine {
	return &Engine{
		source:         source,
		log:            log,
		MinRowItems:    4,
		MaxSimilarRows: 3,
		SeedPool:       12,
		RowSize:        20,
		WeightedConfig: DefaultWeightedConfig(),
		CuratedRows: []CuratedRow{
			{
				ID:        "curated:apple-tv",
				Title:     "Apple TV+ Shows",
				Kind:      "shows",
				ItemTypes: []string{"Series"},
				Studios:   []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
			},
			{
				ID:        "curated:drama-shows",
				Title:     "Drama Shows",
				Kind:      "shows",
				ItemTypes: []string{"Series"},
				Genres:    []string{"Drama"},
			},
			{
				ID:        "curated:comedy-shows",
				Title:     "Comedy Shows",
				Kind:      "shows",
				ItemTypes: []string{"Series"},
				Genres:    []string{"Comedy"},
			},
			{
				ID:        "curated:horror-shows",
				Title:     "Horror Shows",
				Kind:      "shows",
				ItemTypes: []string{"Series"},
				Genres:    []string{"Horror"},
			},
			// Movie shelves are deliberately numerous definitions but sparse output:
			// only the user's six strongest matching genres and three strongest studio
			// families survive buildCuratedRows.
			movieGenreRow("action", "Action"),
			movieGenreRow("adventure", "Adventure"),
			movieGenreRow("animation", "Animation"),
			movieGenreRow("comedy", "Comedy"),
			movieGenreRow("crime", "Crime"),
			movieGenreRow("documentary", "Documentary"),
			movieGenreRow("drama", "Drama"),
			movieGenreRow("family", "Family"),
			movieGenreRow("fantasy", "Fantasy"),
			movieGenreRow("horror", "Horror"),
			movieGenreRow("mystery", "Mystery"),
			movieGenreRow("romance", "Romance"),
			movieGenreRow("science-fiction", "Science Fiction"),
			movieGenreRow("thriller", "Thriller"),
			movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
			movieStudioRow(
				"disney", "Disney",
				"Disney", "Walt Disney Pictures", "Walt Disney Animation Studios",
			),
			movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"),
			movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."),
			movieStudioRow(
				"dreamworks", "DreamWorks",
				"DreamWorks", "DreamWorks Pictures", "DreamWorks Animation",
			),
			movieStudioRow(
				"warner-bros", "Warner Bros.",
				"Warner Bros.", "Warner Bros. Pictures", "Warner Brothers",
			),
			movieStudioRow(
				"universal", "Universal",
				"Universal Pictures", "Universal Studios",
			),
			movieStudioRow("a24", "A24", "A24"),
			movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"),
		},
	}
}

func movieGenreRow(id, genre string) CuratedRow {
	return CuratedRow{
		ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies",
		ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true,
	}
}

func movieStudioRow(id, title string, studios ...string) CuratedRow {
	return CuratedRow{
		ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies",
		ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true,
	}
}

const (
	historyFields   = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
	candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
	rowImageTypes   = "Backdrop,Primary,Logo"
)

// BuildRows produces the recommendation rows for one user.
//
// Cost is a handful of Emby queries, which is why callers cache the result rather than
// computing it on every home load.
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
	return e.BuildRowsForUser(ctx, cred, "")
}

// BuildRowsForUser combines Emby state with Tracearr playback outcomes, Memby's own
// browsing events and the imported Sonarr/Radarr-backed catalogue. BuildRows remains as
// the compatibility entry point for tests and callers that do not know a username.
func (e *Engine) BuildRowsForUser(
	ctx context.Context,
	cred emby.Credentials,
	username string,
) ([]Row, error) {
	history, favorites, err := e.gatherSignals(ctx, cred)
	if err != nil {
		return nil, err
	}

	profile := BuildProfile(history, favorites)
	contextAffinity := NewContextAffinityProfile()
	if e.Tracearr != nil && strings.TrimSpace(username) != "" {
		if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
			e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
		} else {
			contextAffinity = e.applyTracearrSignals(
				&profile, history, recommendationSessions(sessions),
			)
		}
	}
	if e.Behavior != nil {
		if raws, browseErr := e.Behavior.BrowsingCandidates(
			ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 40,
		); browseErr != nil {
			e.log.Warn("browsing signals unavailable for shelves", "error", browseErr)
		} else {
			for i, item := range Decode(raws) {
				profile.absorbTaste(item, 0.55*powDecay(0.92, i))
			}
		}
	}
	profile = contextAffinity.Contextualized(profile, e.now(), e.Location)

	variation := dailySeed(cred.UserID)
	rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
	if !profile.IsEmpty() {
		for _, seed := range e.seedsFor(profile, variation) {
			row, ok := e.similarRow(ctx, cred, profile, seed, variation)
			if ok {
				rows = append(rows, row)
			}
		}

		if row, ok := e.historyRow(ctx, cred, profile); ok {
			rows = append(rows, row)
		}
	}
	rows = append(rows, e.buildCuratedRows(ctx, profile, variation)...)
	return rows, nil
}

func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
	library, ok := e.Library.(CuratedLibrarySource)
	if !ok {
		return nil
	}
	curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
	if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
		curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
	}
	type rankedDefinition struct {
		definition CuratedRow
		affinity   float64
		order      int
	}
	definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
	for order, definition := range curatedDefinitions {
		affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
		// Studio signals are intentionally damped while scoring individual titles.
		// Restore enough weight at shelf level for a genuinely followed studio to earn
		// a row before genre shelves consume all of its films during deduplication.
		if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
			affinity *= 3
		}
		ranked := rankedDefinition{
			definition: definition,
			affinity:   affinity,
			order:      order,
		}
		if definition.RequireAffinity && ranked.affinity <= 0 {
			continue
		}
		definitions = append(definitions, ranked)
	}
	sort.SliceStable(definitions, func(i, j int) bool {
		if definitions[i].affinity != definitions[j].affinity {
			return definitions[i].affinity > definitions[j].affinity
		}
		left := stableVariation(seed, definitions[i].definition.ID)
		right := stableVariation(seed, definitions[j].definition.ID)
		if left != right {
			return left < right
		}
		return definitions[i].order < definitions[j].order
	})

	rows := make([]Row, 0, len(definitions))
	// A series may carry several genres. Give it to the user's highest-affinity shelf
	// only, so scrolling Shows never reveals the same card again under another label.
	seenItems := make(map[string]struct{})
	for _, ranked := range definitions {
		definition := ranked.definition
		raws, err := library.CuratedCandidates(
			ctx,
			definition.ItemTypes,
			definition.Genres,
			definition.Studios,
			e.RowSize*6,
		)
		if err != nil {
			e.log.Warn("curated row failed", "row", definition.ID, "error", err)
			continue
		}
		rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
		rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
		items := make([]Item, 0, e.RowSize)
		for _, item := range rankedItems {
			if _, seen := seenItems[item.ID]; seen {
				continue
			}
			items = append(items, item)
			if len(items) == e.RowSize {
				break
			}
		}
		if len(items) < e.MinRowItems {
			continue
		}
		for _, item := range items {
			seenItems[item.ID] = struct{}{}
		}
		rows = append(rows, Row{
			ID:    definition.ID,
			Title: definition.Title,
			Kind:  definition.Kind,
			Items: Raws(items),
		})
	}
	return rows
}

func (e *Engine) catalogueGenreRows(
	ctx context.Context,
	source GenreLibrarySource,
	definitions []CuratedRow,
) []CuratedRow {
	// Ask for extra depth because watched titles and cross-shelf deduplication reduce
	// the usable pool. A genre is emitted only if MinRowItems unseen cards survive.
	minimumDepth := e.MinRowItems * 2
	for _, itemType := range []string{"Series", "Movie"} {
		genres, err := source.LibraryGenres(ctx, []string{itemType}, minimumDepth)
		if err != nil {
			e.log.Warn("library genre discovery failed", "type", itemType, "error", err)
			continue
		}
		for _, genre := range genres {
			idPrefix, titleSuffix, kind := "curated:shows:genre:", " Shows", "shows"
			if itemType == "Movie" {
				idPrefix, titleSuffix, kind = "curated:movies:genre:", " Movies", "movies"
			}
			id := idPrefix + rowSlug(genre)
			replaced := false
			for index := range definitions {
				existing := &definitions[index]
				if existing.Kind == kind && len(existing.Genres) == 1 &&
					strings.EqualFold(existing.Genres[0], genre) {
					// Catalogue depth proves this can be a useful shelf even when it
					// is outside the viewer's established affinity.
					existing.RequireAffinity = false
					replaced = true
					break
				}
			}
			if !replaced {
				definitions = append(definitions, CuratedRow{
					ID: id, Title: genre + titleSuffix, Kind: kind,
					ItemTypes: []string{itemType}, Genres: []string{genre},
				})
			}
		}
	}
	return definitions
}

func rowSlug(value string) string {
	var out strings.Builder
	dash := false
	for _, r := range strings.ToLower(strings.TrimSpace(value)) {
		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
			if dash && out.Len() > 0 {
				out.WriteByte('-')
			}
			out.WriteRune(r)
			dash = false
		} else {
			dash = true
		}
	}
	if out.Len() > 0 {
		return out.String()
	}
	return strconv.FormatUint(stableVariation("", value), 36)
}

func dailySeed(userID string) string {
	return userID + ":" + time.Now().UTC().Format("2006-01-02")
}

func stableVariation(seed, value string) uint64 {
	hash := fnv.New64a()
	_, _ = hash.Write([]byte(seed))
	_, _ = hash.Write([]byte{0})
	_, _ = hash.Write([]byte(value))
	return hash.Sum64()
}

// diversifyRanked preserves relevance bands while changing the exact poster sequence
// per user and day. Affinity/rating still choose each five-card band; variation only
// decides which equally strong-looking option catches the eye first.
func diversifyRanked(items []Item, seed string, bandSize int) []Item {
	if bandSize < 2 || len(items) < 2 {
		return items
	}
	out := append([]Item(nil), items...)
	for start := 0; start < len(out); start += bandSize {
		end := start + bandSize
		if end > len(out) {
			end = len(out)
		}
		sort.SliceStable(out[start:end], func(i, j int) bool {
			return stableVariation(seed, out[start+i].ID) <
				stableVariation(seed, out[start+j].ID)
		})
	}
	return out
}

// gatherSignals reads what the user has watched and favourited, in parallel.
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
	var (
		wg       sync.WaitGroup
		mu       sync.Mutex
		firstErr error
	)

	fetch := func(dest *[]Item, params url.Values) {
		wg.Add(1)
		go func() {
			defer wg.Done()
			result, fetchErr := e.source.Items(ctx, cred, params)
			mu.Lock()
			defer mu.Unlock()
			if fetchErr != nil {
				if firstErr == nil {
					firstErr = fetchErr
				}
				return
			}
			*dest = Decode(result.Items)
		}()
	}

	// In-progress titles are the strongest signal available, so they lead the history
	// list and pick up the heaviest recency weights.
	var resumable, played []Item
	fetch(&resumable, url.Values{
		"Filters":          {"IsResumable"},
		"IncludeItemTypes": {"Movie,Episode"},
		"Recursive":        {"true"},
		"SortBy":           {"DatePlayed"},
		"SortOrder":        {"Descending"},
		// Imported catalogue rows have no user state, so this query is also the
		// exclusion set. Household libraries are small enough to fetch it completely.
		"Limit":          {"5000"},
		"Fields":         {historyFields},
		"EnableUserData": {"true"},
		"EnableImages":   {"false"},
	})
	fetch(&played, url.Values{
		"Filters":          {"IsPlayed"},
		"IncludeItemTypes": {"Movie,Episode,Series"},
		"Recursive":        {"true"},
		"SortBy":           {"DatePlayed"},
		"SortOrder":        {"Descending"},
		"Limit":            {"5000"},
		"Fields":           {historyFields},
		"EnableUserData":   {"true"},
		"EnableImages":     {"false"},
	})
	fetch(&favorites, url.Values{
		"Filters":          {"IsFavorite"},
		"IncludeItemTypes": {"Movie,Series"},
		"Recursive":        {"true"},
		"SortBy":           {"SortName"},
		"Limit":            {"40"},
		"Fields":           {historyFields},
		"EnableUserData":   {"true"},
		"EnableImages":     {"false"},
	})

	wg.Wait()
	if firstErr != nil {
		return nil, nil, firstErr
	}
	return append(resumable, played...), favorites, nil
}

func recommendationSessions(sessions []tracearr.Session) []tracearr.Session {
	out := make([]tracearr.Session, 0, len(sessions))
	for _, session := range sessions {
		// Prerolls are delivery mechanics, not a viewer choice. Treating dozens of
		// completed prerolls as taste evidence overwhelms real household history.
		if strings.HasPrefix(session.TitleKey(), "preroll") {
			continue
		}
		out = append(out, session)
	}
	return out
}

func tracearrSeenKey(session tracearr.Session) string {
	key := session.TitleKey()
	if key != "" && strings.EqualFold(session.MediaType, "movie") &&
		session.Year != nil && *session.Year > 0 {
		return key + "|" + strconv.Itoa(*session.Year)
	}
	return key
}

// libraryCandidates reads the pool from the imported library. Returns ok=false when
// there is no library, it is empty, or it errors — every one of which means "ask Emby".
func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) {
	if e.Library == nil {
		return nil, false
	}
	raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*6)
	if err != nil {
		e.log.Warn("library candidates failed; falling back to emby", "error", err)
		return nil, false
	}
	if len(raws) == 0 {
		return nil, false
	}
	return Decode(raws), true
}

func (e *Engine) seedsFor(profile Profile, variation string) []Seed {
	return selectSeeds(profile.Seeds, e.SeedPool, e.MaxSimilarRows, variation)
}

// selectSeeds chooses which watched titles anchor "Because you watched …" rows.
//
// Taking the freshest few is the obvious rule and is also why the launcher went stale: the
// head of history is a resumable title and the series somebody is part-way through, and
// neither moves for weeks — so the same two rows came back day after day. Instead the
// recent window is cut into equal bands and one seed is drawn from each by a variation
// that changes daily. Three properties are the point and are unit-tested:
//
//   - The bands are in recency order, so the first row is still anchored to something
//     watched lately and the rows under it reach further back. It is a rotation within
//     bands, never a shuffle of the whole window.
//   - The same day always yields the same seeds. Rows are rebuilt on every cache miss and
//     a set of rows that re-picked each time would change under somebody browsing.
//   - Nothing new has to be watched for the launcher to move on.
func selectSeeds(seeds []Seed, pool, max int, variation string) []Seed {
	if max <= 0 || len(seeds) == 0 {
		return nil
	}
	if pool < max {
		pool = max
	}
	if pool > len(seeds) {
		pool = len(seeds)
	}
	if pool <= max {
		return append([]Seed(nil), seeds[:pool]...)
	}

	band := pool / max
	out := make([]Seed, 0, max)
	for index := 0; index < max; index++ {
		start := index * band
		end := start + band
		// The last band takes the remainder, so a pool that does not divide evenly is
		// still drawn from in full rather than having its oldest entries stranded.
		if index == max-1 {
			end = pool
		}
		best := start
		for candidate := start + 1; candidate < end; candidate++ {
			if stableVariation(variation, seeds[candidate].ID) <
				stableVariation(variation, seeds[best].ID) {
				best = candidate
			}
		}
		out = append(out, seeds[best])
	}
	return out
}

// similarRow asks Emby what resembles a title the user just watched. Emby's own
// similarity scoring beats anything computed here, so this only filters out the seen and
// varies the order within relevance bands, the way curated shelves do — a row anchored to
// the same seed on two consecutive days must not present the same posters in the same
// order, or rotating the seeds is the only thing that ever changes.
func (e *Engine) similarRow(
	ctx context.Context,
	cred emby.Credentials,
	profile Profile,
	seed Seed,
	variation string,
) (Row, bool) {
	result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
		"UserId":           {cred.UserID},
		"Limit":            {strconv.Itoa(e.RowSize * 2)},
		"Fields":           {candidateFields},
		"ImageTypeLimit":   {"1"},
		"EnableImageTypes": {rowImageTypes},
		"EnableUserData":   {"true"},
	})
	if err != nil {
		// One dead row should never sink the home screen.
		e.log.Warn("similar lookup failed", "seed", seed.ID, "error", err)
		return Row{}, false
	}

	items := FilterUnseen(profile, Decode(result.Items), e.RowSize)
	if len(items) < e.MinRowItems {
		return Row{}, false
	}
	items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
	return Row{
		ID:    "similar:" + seed.ID,
		Title: "Because you watched " + seed.Name,
		Kind:  "similar",
		Items: Raws(items),
	}, true
}

// RelatedTo answers a detail page: why this viewer might enjoy one title, and what else
// in the library is like it.
//
// Both halves come from the same profile the home rows are built from, so the page can
// never explain a taste the engine does not hold. Every part of it degrades rather than
// fails: the only error this returns is the viewer having gone away, because a detail
// page that cannot explain itself still has to open, and a carousel is worth having
// without a reason strip above it.
func (e *Engine) RelatedTo(
	ctx context.Context,
	cred emby.Credentials,
	item Item,
	limit int,
) (reasons []string, related []Item, err error) {
	if err := ctx.Err(); err != nil {
		return nil, nil, err
	}
	if limit <= 0 {
		limit = e.RowSize
	}

	// The profile is the expensive half and the optional one: it costs the same Emby
	// fan-out the home rows pay for, and everything below still works without it. Why
	// falls back to catalogue facts and FilterUnseen keeps everything, so a household
	// whose history request timed out gets a carousel instead of a failed page.
	var profile Profile
	history, favorites, signalErr := e.gatherSignals(ctx, cred)
	switch {
	case signalErr != nil && ctx.Err() != nil:
		return nil, nil, ctx.Err()
	case signalErr != nil:
		e.log.Warn(
			"related profile unavailable; answering without one",
			"item", item.ID, "error", signalErr,
		)
	default:
		profile = BuildProfile(history, favorites)
	}
	reasons = Why(profile, item, ReasonLimit)

	return reasons, e.relatedCandidates(ctx, cred, profile, item, limit), nil
}

// relatedCandidates fills the carousel from the best source that answers, in falling
// order of quality: Emby's own similarity ranking, then the imported catalogue's titles
// in the same genres. The second exists because the first has two failure modes that
// look identical on a TV — Emby erroring, and Emby honestly knowing nothing similar
// about a title nobody has tagged — and an empty strip on a detail page reads as broken
// either way.
func (e *Engine) relatedCandidates(
	ctx context.Context,
	cred emby.Credentials,
	profile Profile,
	item Item,
	limit int,
) []Item {
	var candidates []Item
	result, err := e.source.Similar(ctx, cred, item.ID, url.Values{
		"UserId":           {cred.UserID},
		"Limit":            {strconv.Itoa(limit * 2)},
		"Fields":           {candidateFields},
		"ImageTypeLimit":   {"1"},
		"EnableImages":     {"true"},
		"EnableImageTypes": {rowImageTypes},
		"EnableUserData":   {"true"},
	})
	switch {
	case err != nil && ctx.Err() != nil:
		return nil
	case err != nil:
		e.log.Warn("related lookup failed", "item", item.ID, "error", err)
	case result != nil:
		candidates = excludeItem(Decode(result.Items), item.ID)
	}

	related := FilterUnseen(profile, candidates, limit)
	// A carousel of two looks broken. Someone deep into a franchise has seen most of
	// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
	if len(related) < e.MinRowItems && len(candidates) > len(related) {
		related = trim(candidates, limit)
	}
	if len(related) >= e.MinRowItems {
		return related
	}
	if neighbours := e.genreNeighbours(ctx, profile, item, limit); len(neighbours) > len(related) {
		return neighbours
	}
	return related
}

// genreNeighbours is the answer of last resort: the household's own catalogue, in this
// title's genres, best rated first. It reads from Postgres rather than Emby, so it is
// also the only half of this that still works while Emby is the thing that is down.
func (e *Engine) genreNeighbours(ctx context.Context, profile Profile, item Item, limit int) []Item {
	if e.Library == nil || len(item.Genres) == 0 || ctx.Err() != nil {
		return nil
	}
	raws, err := e.Library.LibraryCandidates(ctx, item.Genres, limit*6)
	if err != nil {
		e.log.Warn("related genre fallback failed", "item", item.ID, "error", err)
		return nil
	}
	candidates := excludeItem(Decode(raws), item.ID)
	if unseen := FilterUnseen(profile, candidates, limit); len(unseen) > 0 {
		return unseen
	}
	return trim(candidates, limit)
}

// excludeItem drops the title the page is about. Emby occasionally returns it in its own
// similarity list, and the imported catalogue always does.
func excludeItem(items []Item, id string) []Item {
	out := make([]Item, 0, len(items))
	for _, candidate := range items {
		if candidate.ID == id {
			continue
		}
		out = append(out, candidate)
	}
	return out
}

func trim(items []Item, limit int) []Item {
	if limit > 0 && len(items) > limit {
		return items[:limit]
	}
	return items
}

// historyRow is the genre-affinity row: unwatched titles from the genres the user has
// been spending time in, ranked by how closely they match the whole profile.
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
	genres := profile.TopGenres(3)
	if len(genres) == 0 {
		return Row{}, false
	}

	if candidates, ok := e.libraryCandidates(ctx, genres); ok {
		items := Rank(profile, candidates, e.RowSize)
		if len(items) < e.MinRowItems {
			return Row{}, false
		}
		return Row{
			ID:    "recommended",
			Title: "Recommended from your watching history",
			Kind:  "recommended",
			Items: Raws(items),
		}, true
	}

	// Emby treats "|" as OR in a Genres filter, so one query covers every top genre.
	result, err := e.source.Items(ctx, cred, url.Values{
		"IncludeItemTypes": {"Movie,Series"},
		"Recursive":        {"true"},
		"Filters":          {"IsUnplayed"},
		"Genres":           {strings.Join(genres, "|")},
		"SortBy":           {"CommunityRating"},
		"SortOrder":        {"Descending"},
		"Limit":            {"120"},
		"Fields":           {candidateFields},
		"ImageTypeLimit":   {"1"},
		"EnableImages":     {"true"},
		"EnableImageTypes": {rowImageTypes},
		"EnableUserData":   {"true"},
	})
	if err != nil {
		e.log.Warn("recommendation candidates failed", "error", err)
		return Row{}, false
	}

	items := Rank(profile, Decode(result.Items), e.RowSize)
	if len(items) < e.MinRowItems {
		return Row{}, false
	}
	return Row{
		ID:    "recommended",
		Title: "Recommended from your watching history",
		Kind:  "recommended",
		Items: Raws(items),
	}, true
}
