package recommend

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

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

// fakeSource records the queries the engine makes and replays canned answers.
type fakeSource struct {
	mu sync.Mutex

	itemsByFilter map[string][]json.RawMessage
	similar       map[string][]json.RawMessage
	itemsErr      error
	similarErr    error
	nextUp        []json.RawMessage
	nextUpErr     error

	genreQueries []string
	similarSeeds []string
}

type fakeCuratedLibrary struct {
	byGenre map[string][]json.RawMessage
}

type fakeGenreLibrary struct {
	*fakeCuratedLibrary
	genresByType map[string][]string
}

func (f *fakeGenreLibrary) LibraryGenres(
	_ context.Context,
	itemTypes []string,
	_ int,
) ([]string, error) {
	if len(itemTypes) == 0 {
		return nil, nil
	}
	return f.genresByType[itemTypes[0]], nil
}

type fakeForYouLibrary struct {
	items []json.RawMessage
}

func (f *fakeForYouLibrary) AllRecommendationCandidates(
	_ context.Context,
) ([]json.RawMessage, error) {
	return f.items, nil
}

func (f *fakeForYouLibrary) LibraryCandidates(
	_ context.Context,
	_ []string,
	_ int,
) ([]json.RawMessage, error) {
	return f.items, nil
}

type fakeTracearr struct {
	sessions []tracearr.Session
}

func (f fakeTracearr) History(
	_ context.Context,
	_ string,
	_ int,
) ([]tracearr.Session, error) {
	return f.sessions, nil
}

func (f *fakeCuratedLibrary) LibraryCandidates(
	_ context.Context,
	_ []string,
	_ int,
) ([]json.RawMessage, error) {
	return nil, nil
}

func (f *fakeCuratedLibrary) CuratedCandidates(
	_ context.Context,
	_ []string,
	genres, studios []string,
	_ int,
) ([]json.RawMessage, error) {
	key := strings.Join(genres, "|")
	if len(studios) > 0 {
		key = "studio:" + studios[0]
	}
	return f.byGenre[key], nil
}

func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.itemsErr != nil {
		return nil, f.itemsErr
	}
	if genres := params.Get("Genres"); genres != "" {
		f.genreQueries = append(f.genreQueries, genres)
	}
	key := params.Get("Filters")
	items := f.itemsByFilter[key]
	if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit {
		items = items[:limit]
	}
	return &emby.ItemsResult{Items: items}, nil
}

func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.similarSeeds = append(f.similarSeeds, itemID)
	if f.similarErr != nil {
		return nil, f.similarErr
	}
	return &emby.ItemsResult{Items: f.similar[itemID]}, nil
}

func (f *fakeSource) NextUp(
	_ context.Context,
	_ emby.Credentials,
	_ url.Values,
) (*emby.ItemsResult, error) {
	if f.nextUpErr != nil {
		return nil, f.nextUpErr
	}
	return &emby.ItemsResult{Items: f.nextUp}, nil
}

func TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(t *testing.T) {
	now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
	catalogue := Decode([]json.RawMessage{
		json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
		json.RawMessage(`{"Id":"finished-first","Name":"Finished First","Type":"Series"}`),
		json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","Type":"Series"}`),
		json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
		json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
		json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
	})
	session := func(show string, season, episode, daysAgo int) tracearr.Session {
		value := tracearr.Session{
			ID: "session-" + show, MediaType: "episode", ShowTitle: show,
			SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode),
			Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano),
		}
		return value
	}
	sessions := []tracearr.Session{
		session("Early Show", 1, 2, 40),
		session("Finished First", 1, 10, 50),
		session("Deep Show", 2, 8, 60),
		session("Complete Show", 4, 10, 50),
		session("Recent Show", 1, 3, 5),
	}
	unknown := session("Unknown Season", 1, 2, 30)
	unknown.SeasonNumber = nil
	sessions = append(sessions, unknown)
	nextUp := Decode([]json.RawMessage{
		json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
		json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`),
		json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
		json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
		json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
	})

	got := abandonedShowCandidates(
		newCatalogueIndex(catalogue),
		sessions,
		nextUp,
		compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
		now,
	)
	if len(got) != 3 {
		t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got)
	}
	byID := map[string]PreparedCandidate{}
	for _, candidate := range got {
		byID[candidate.ItemID] = candidate
	}
	if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" {
		t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason)
	}
	if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" {
		t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason)
	}
	if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" {
		t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason)
	}
}

func raw(id, name, itemType string, genres ...string) json.RawMessage {
	quoted := make([]string, 0, len(genres))
	for _, g := range genres {
		quoted = append(quoted, `"`+g+`"`)
	}
	return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType +
		`","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`)
}

func testEngine(source Source) *Engine {
	engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
	engine.MinRowItems = 2
	return engine
}

func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsResumable": {raw("ep1", "Good News", "Episode", "Drama")},
			"IsPlayed":    {raw("m1", "Dune", "Movie", "Science Fiction")},
			"IsFavorite":  {raw("m2", "Arrival", "Movie", "Science Fiction")},
			"IsUnplayed": {
				raw("c1", "Blade Runner", "Movie", "Science Fiction"),
				raw("c2", "Solaris", "Movie", "Science Fiction"),
				raw("c3", "Barbie", "Movie", "Comedy"),
			},
		},
		similar: map[string][]json.RawMessage{
			"ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")},
			"m1":  {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")},
		},
	}

	rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatalf("BuildRows: %v", err)
	}
	if len(rows) != 3 {
		t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows))
	}

	if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
		t.Fatalf("unexpected first row: %+v", rows[0])
	}
	last := rows[len(rows)-1]
	if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
		t.Fatalf("unexpected history row: %+v", last)
	}
	if last.ID != "recommended" {
		t.Fatalf("history row id should be stable, got %q", last.ID)
	}
}

func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsResumable": {},
			"IsPlayed": {
				json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
			},
			"IsFavorite": {},
		},
	}
	engine := testEngine(source)
	engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
		json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`),
		json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`),
	}}
	session := tracearr.Session{
		MediaType:        "movie",
		MediaTitle:       "Arrival",
		Watched:          true,
		Platform:         "Android TV",
		SourceVideoCodec: "h264",
		VideoDecision:    "directplay",
	}
	engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
	engine.MinRowItems = 1

	rows, err := engine.BuildForYou(
		context.Background(),
		emby.Credentials{UserID: "u1"},
		"Matt",
		ForYouOptions{AvailableMinutes: 100},
	)
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) != 1 || len(rows[0].Items) != 1 {
		t.Fatalf("rows = %+v", rows)
	}
	if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) ||
		!strings.Contains(string(rows[0].Items[0]), `100-minute window`) {
		t.Fatalf("explanation missing: %s", rows[0].Items[0])
	}
	if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) {
		t.Fatalf("over-budget item was retained: %s", rows[0].Items[0])
	}
}

func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
		"IsResumable": {},
		"IsPlayed": {
			json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`),
		},
		"IsFavorite": {},
	}}
	catalogue := []json.RawMessage{
		json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
	}
	for i := 0; i < 30; i++ {
		catalogue = append(catalogue, json.RawMessage(
			`{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+
				`","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`,
		))
	}
	engine := testEngine(source)
	engine.Library = &fakeForYouLibrary{items: catalogue}
	session := tracearr.Session{
		ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival",
		MediaType: "movie", Year: intPointer(2016), Watched: true,
		StartedAt: "2026-07-20T08:00:00Z",
	}
	session.User.ID = "trace-user"
	session.User.Username = "Matt"

	result, err := engine.PrepareForYou(
		context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt",
		[]tracearr.Session{session},
	)
	if err != nil {
		t.Fatalf("PrepareForYou: %v", err)
	}
	if len(result.Candidates) < 30 {
		t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates))
	}
	foundSpecific := false
	for _, candidate := range result.Candidates {
		if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") {
			foundSpecific = true
			break
		}
	}
	if !foundSpecific {
		t.Fatal("expected a candidate explanation grounded in the completed Tracearr title")
	}
	if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 {
		t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings)
	}
}

func intPointer(value int) *int { return &value }

func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) {
	seen := map[int]bool{}
	for i := 0; i < 20; i++ {
		seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true
	}
	if len(seen) != 3 {
		t.Fatalf("evidence indices = %+v, want all three sources represented", seen)
	}
}

func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsPlayed": {
				raw("m1", "Dune", "Movie", "Science Fiction"),
				raw("m2", "Alien", "Movie", "Science Fiction", "Horror"),
			},
			"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
		},
	}

	if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil {
		t.Fatalf("BuildRows: %v", err)
	}

	if len(source.genreQueries) != 1 {
		t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries)
	}
	// Emby reads "|" as OR, so one query covers every top genre.
	if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") {
		t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0])
	}
}

func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
		},
		similar: map[string][]json.RawMessage{
			// Emby suggests something the user already finished; it must not appear.
			"m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")},
		},
	}
	engine := testEngine(source)
	engine.MinRowItems = 1

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatalf("BuildRows: %v", err)
	}

	for _, row := range rows {
		for _, item := range row.Items {
			if strings.Contains(string(item), `"Id":"m1"`) {
				t.Fatalf("row %q contained an already-watched item", row.ID)
			}
		}
	}
}

func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) {
	played := make([]json.RawMessage, 0, 61)
	for i := 0; i < 60; i++ {
		played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama"))
	}
	played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama"))
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played},
		similar:       map[string][]json.RawMessage{},
	}
	engine := testEngine(source)
	engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
		raw("old-watched", "Old Watched", "Movie", "Drama"),
		raw("new-pick", "New Pick", "Movie", "Drama"),
	}}
	engine.MinRowItems = 1

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatalf("BuildRows: %v", err)
	}
	for _, row := range rows {
		for _, candidate := range row.Items {
			if strings.Contains(string(candidate), `"Id":"old-watched"`) {
				t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID)
			}
		}
	}
}

func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
		"IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")},
	}}
	engine := testEngine(source)
	engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
		json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`),
		raw("unseen", "Moon", "Movie", "Science Fiction"),
	}}
	year := 2016
	session := tracearr.Session{
		MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true,
	}
	engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
	engine.MinRowItems = 1

	rows, err := engine.BuildForYou(
		context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{},
	)
	if err != nil {
		t.Fatalf("BuildForYou: %v", err)
	}
	if len(rows) != 1 || len(rows[0].Items) != 1 ||
		!strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) {
		t.Fatalf("Tracearr-completed title was not excluded: %+v", rows)
	}
}

func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
	profile := Profile{
		GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1},
		Seen:         map[string]bool{}, SeenTitles: map[string]bool{},
	}
	evidence := map[string][]PreparedEvidence{
		"drama": {{
			ItemID: "arrival", Title: "Arrival",
			Genres: []string{"Drama", "Science Fiction"},
		}},
	}
	counts := map[string]int{}
	kinds := map[string]int{}
	for i := 0; i < 20; i++ {
		candidate := item(
			"candidate-"+strconv.Itoa(i), "Candidate", "Movie",
			[]string{"Drama", "Science Fiction"}, 7,
		)
		_, _, kind, _, _ := explainPreparedRecommendation(
			profile, candidate, compatibilityProfile{}, false, evidence, counts,
		)
		kinds[kind]++
	}
	if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
		t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
	}
	if kinds["genre"] == 0 {
		t.Fatalf("reason kinds were not mixed: %+v", kinds)
	}
}

func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) {
	profile := Profile{
		GenreWeights: map[string]float64{"Drama": 2},
		Seen:         map[string]bool{}, SeenTitles: map[string]bool{},
	}
	evidence := map[string][]PreparedEvidence{
		"drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}},
	}
	_, _, kind, _, _ := explainPreparedRecommendation(
		profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7),
		compatibilityProfile{}, false, evidence, map[string]int{},
	)
	if kind != "genre" {
		t.Fatalf("one broad shared genre produced %q, want genre", kind)
	}
}

func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
	sessions := []tracearr.Session{
		{MediaTitle: "PreRoll_Swirls"},
		{MediaTitle: "A Real Film"},
	}
	got := recommendationSessions(sessions)
	if len(got) != 1 || got[0].MediaTitle != "A Real Film" {
		t.Fatalf("recommendation sessions = %+v", got)
	}
}

func TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) {
	seriesRaw, _ := json.Marshal(map[string]any{
		"Id": "series-1", "Name": "The Show", "Type": "Series",
		"People":  []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}},
		"Studios": []map[string]string{{"Name": "Great Studio"}},
		"Genres":  []string{"Drama"},
	})
	episodeRaw, _ := json.Marshal(map[string]any{
		"Id": "episode-1", "Name": "Pilot", "Type": "Episode",
		"SeriesId": "series-1", "SeriesName": "The Show",
	})
	series := Decode([]json.RawMessage{seriesRaw})[0]
	episode := Decode([]json.RawMessage{episodeRaw})[0]

	got := newCatalogueIndex([]Item{series}).evidenceItem(episode)

	if got.ID != "series-1" || len(got.People) != 1 ||
		len(got.Studios) != 1 || len(got.Genres) != 1 {
		t.Fatalf("episode evidence was not enriched from its series: %+v", got)
	}
}

func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsPlayed":   {raw("m1", "Dune", "Movie", "Science Fiction")},
			"IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")},
		},
		similar: map[string][]json.RawMessage{
			"m1": {raw("s1", "Foundation", "Series", "Science Fiction")},
		},
	}
	engine := testEngine(source)
	engine.MinRowItems = 5

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatalf("BuildRows: %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows))
	}
}

func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}

	rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"})
	if err != nil {
		t.Fatalf("BuildRows: %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("a new user should get no rows, got %v", rowTitles(rows))
	}
	if len(source.similarSeeds) != 0 {
		t.Fatal("no seeds means no similarity lookups should be attempted")
	}
}

func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsPlayed": {
				raw("history", "Funny History", "Episode", "Comedy"),
			},
		},
		similar: map[string][]json.RawMessage{},
	}
	engine := testEngine(source)
	engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
		"Comedy": {
			raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
			raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
		},
		"Drama": {
			raw("drama-1", "Drama One", "Series", "Drama"),
			raw("drama-2", "Drama Two", "Series", "Drama"),
		},
	}}
	engine.CuratedRows = []CuratedRow{
		{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
		{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
	}

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) != 2 {
		t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
	}
	if rows[0].ID != "comedy" || rows[1].ID != "drama" {
		t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
	}
	if rows[0].Kind != "shows" {
		t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
	}
}

func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) {
	engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
	got := map[string]string{}
	for _, row := range engine.CuratedRows {
		got[row.ID] = row.Title
	}

	for id, title := range map[string]string{
		"curated:comedy-shows": "Comedy Shows",
		"curated:drama-shows":  "Drama Shows",
		"curated:horror-shows": "Horror Shows",
	} {
		if got[id] != title {
			t.Fatalf("%s title = %q, want %q", id, got[id], title)
		}
	}
}

func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) {
	engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
	got := map[string]CuratedRow{}
	for _, row := range engine.CuratedRows {
		got[row.ID] = row
	}

	for _, id := range []string{
		"curated:movies:genre:science-fiction",
		"curated:movies:genre:animation",
		"curated:movies:studio:pixar",
		"curated:movies:studio:disney",
	} {
		row, ok := got[id]
		if !ok {
			t.Fatalf("missing default movie shelf %q", id)
		}
		if row.Kind != "movies" || !row.RequireAffinity {
			t.Fatalf("movie shelf %q = %+v", id, row)
		}
	}
}

func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
		"IsPlayed": {
			json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`),
		},
	}}
	engine := testEngine(source)
	engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
		"Comedy": {
			raw("comedy-1", "Comedy One", "Movie", "Comedy"),
			raw("comedy-2", "Comedy Two", "Movie", "Comedy"),
		},
		"Drama": {
			raw("drama-1", "Drama One", "Movie", "Drama"),
			raw("drama-2", "Drama Two", "Movie", "Drama"),
		},
		"studio:Pixar": {
			raw("pixar-1", "Pixar One", "Movie", "Animation"),
			raw("pixar-2", "Pixar Two", "Movie", "Animation"),
		},
	}}
	engine.CuratedRows = []CuratedRow{
		movieGenreRow("comedy", "Comedy"),
		movieGenreRow("drama", "Drama"),
		movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
	}

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatal(err)
	}
	curated := make([]Row, 0, len(rows))
	for _, row := range rows {
		if strings.HasPrefix(row.ID, "curated:movies:") {
			curated = append(curated, row)
		}
	}
	if len(curated) != 2 {
		t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated))
	}
	if curated[0].ID != "curated:movies:studio:pixar" ||
		curated[1].ID != "curated:movies:genre:comedy" {
		t.Fatalf("movie shelf order = %+v", rowTitles(curated))
	}
}

func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
		"IsPlayed": {raw("history", "Funny", "Episode", "Comedy")},
	}}
	engine := testEngine(source)
	engine.MinRowItems = 1
	engine.RowSize = 3
	engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
		"Comedy": {
			raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
			raw("comedy", "Comedy Only", "Series", "Comedy"),
		},
		"Drama": {
			raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
			raw("drama", "Drama Only", "Series", "Drama"),
		},
	}}
	engine.CuratedRows = []CuratedRow{
		{ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
		{ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
	}

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatal(err)
	}
	ids := map[string]int{}
	for _, row := range rows {
		for _, item := range Decode(row.Items) {
			ids[item.ID]++
		}
	}
	if ids["shared"] != 1 {
		t.Fatalf("shared card appeared %d times across curated rows", ids["shared"])
	}
}

func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
	engine := testEngine(source)
	engine.MinRowItems = 1
	engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
		"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
	}}
	engine.CuratedRows = []CuratedRow{{
		ID: "drama", Title: "Drama TV Shows", Kind: "shows",
		ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
	}}

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
	if err != nil {
		t.Fatal(err)
	}
	if len(rows) != 1 || rows[0].ID != "drama" {
		t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
	}
}

func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) {
	source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
	engine := testEngine(source)
	engine.Library = &fakeGenreLibrary{
		fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
			"Western": {
				raw("western-1", "Western One", "Movie", "Western"),
				raw("western-2", "Western Two", "Movie", "Western"),
			},
			"Reality": {
				raw("reality-1", "Reality One", "Series", "Reality"),
				raw("reality-2", "Reality Two", "Series", "Reality"),
			},
		}},
		genresByType: map[string][]string{
			"Movie":  {"Western"},
			"Series": {"Reality"},
		},
	}

	rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
	if err != nil {
		t.Fatal(err)
	}
	got := map[string]bool{}
	for _, row := range rows {
		got[row.ID] = true
	}
	if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] {
		t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows))
	}
}

func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) {
	items := make([]Item, 0, 12)
	for i := 0; i < 12; i++ {
		items = append(items, Item{ID: strconv.Itoa(i)})
	}
	first := diversifyRanked(items, "user:day", 5)
	second := diversifyRanked(items, "user:day", 5)
	if fmt.Sprint(first) != fmt.Sprint(second) {
		t.Fatal("same user/day seed must produce stable poster ordering")
	}
	for index, item := range first {
		if index/5 != mustAtoi(t, item.ID)/5 {
			t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first)
		}
	}
}

func mustAtoi(t *testing.T, value string) int {
	t.Helper()
	parsed, err := strconv.Atoi(value)
	if err != nil {
		t.Fatal(err)
	}
	return parsed
}

// A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
	source := &fakeSource{
		itemsByFilter: map[string][]json.RawMessage{
			"IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")},
			"IsUnplayed": {
				raw("c1", "Solaris", "Movie", "Science Fiction"),
				raw("c2", "Blade Runner", "Movie", "Science Fiction"),
			},
		},
		similarErr: errors.New("emby is unwell"),
	}

	rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
	if err != nil {
		t.Fatalf("BuildRows should not fail: %v", err)
	}
	if len(rows) != 1 || rows[0].Kind != "recommended" {
		t.Fatalf("expected the history row to survive, got %v", rowTitles(rows))
	}
}

func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) {
	source := &fakeSource{itemsErr: errors.New("emby down")}

	if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil {
		t.Fatal("expected an error when the history queries fail")
	}
}

func rowTitles(rows []Row) []string {
	out := make([]string, 0, len(rows))
	for _, row := range rows {
		out = append(out, row.Title)
	}
	return out
}

func testSeeds(count int) []Seed {
	seeds := make([]Seed, 0, count)
	for index := 0; index < count; index++ {
		id := "s" + strconv.Itoa(index)
		seeds = append(seeds, Seed{ID: id, Name: "Title " + id})
	}
	return seeds
}

func seedIDs(seeds []Seed) []string {
	out := make([]string, 0, len(seeds))
	for _, seed := range seeds {
		out = append(out, seed.ID)
	}
	return out
}

// The head of a household's history barely moves while they work through one series, so
// the seeds must move without it.
func TestSelectSeedsRotatesWithoutNewHistory(t *testing.T) {
	seeds := testSeeds(12)
	days := map[string]bool{}
	for day := 1; day <= 14; day++ {
		chosen := selectSeeds(seeds, 12, 3, "u1:2026-08-"+strconv.Itoa(day))
		if len(chosen) != 3 {
			t.Fatalf("day %d chose %d seeds", day, len(chosen))
		}
		days[strings.Join(seedIDs(chosen), ",")] = true
	}
	if len(days) < 4 {
		t.Fatalf("a fortnight produced only %d distinct seed sets: %v", len(days), days)
	}
}

// Rotation is within recency bands, never a shuffle of the whole window: the first row is
// still anchored to something watched lately.
func TestSelectSeedsKeepsRecencyBands(t *testing.T) {
	seeds := testSeeds(12)
	for day := 1; day <= 30; day++ {
		chosen := selectSeeds(seeds, 12, 3, "u1:day-"+strconv.Itoa(day))
		for index, seed := range chosen {
			position, err := strconv.Atoi(strings.TrimPrefix(seed.ID, "s"))
			if err != nil {
				t.Fatalf("unexpected seed id %q", seed.ID)
			}
			if position < index*4 || position >= (index+1)*4 {
				t.Fatalf("row %d drew %s from outside its band", index, seed.ID)
			}
		}
	}
}

// Rows are rebuilt on every cache miss, so the same day must always yield the same seeds.
func TestSelectSeedsIsStableWithinADay(t *testing.T) {
	seeds := testSeeds(20)
	first := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07"))
	for attempt := 0; attempt < 5; attempt++ {
		if got := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")); !slices.Equal(got, first) {
			t.Fatalf("same day produced %v then %v", first, got)
		}
	}
	other := seedIDs(selectSeeds(seeds, 12, 3, "u2:2026-08-07"))
	if slices.Equal(other, first) {
		t.Log("two users may coincide; only a smoke check")
	}
}

// A short history has nothing to rotate: take what there is, newest first.
func TestSelectSeedsFallsBackToRecencyWhenShort(t *testing.T) {
	if got := seedIDs(selectSeeds(testSeeds(2), 12, 3, "u1:day")); !slices.Equal(got, []string{"s0", "s1"}) {
		t.Fatalf("short history = %v", got)
	}
	if got := selectSeeds(nil, 12, 3, "u1:day"); len(got) != 0 {
		t.Fatalf("no history should seed nothing, got %v", got)
	}
}

// A pool that does not divide evenly must not strand its oldest entries.
func TestSelectSeedsLastBandTakesTheRemainder(t *testing.T) {
	seeds := testSeeds(11)
	reached := map[string]bool{}
	for day := 1; day <= 40; day++ {
		chosen := selectSeeds(seeds, 11, 3, "u1:day-"+strconv.Itoa(day))
		reached[chosen[2].ID] = true
	}
	if !reached["s10"] {
		t.Fatalf("the oldest seed was never reachable: %v", reached)
	}
}
