package recommend

import (
	"encoding/json"
	"testing"
)

func item(id, name, itemType string, genres []string, rating float64) Item {
	return Item{ID: id, Name: name, Type: itemType, Genres: genres, CommunityRating: rating}
}

func episode(id, name, seriesID, seriesName string, genres []string) Item {
	it := item(id, name, "Episode", genres, 0)
	it.SeriesID = seriesID
	it.SeriesName = seriesName
	return it
}

func TestBuildProfileWeightsRecentHistoryHigher(t *testing.T) {
	history := []Item{
		item("1", "Newest", "Movie", []string{"Science Fiction"}, 8),
		item("2", "Older", "Movie", []string{"Comedy"}, 8),
	}
	profile := BuildProfile(history, nil)

	if profile.GenreWeights["Science Fiction"] <= profile.GenreWeights["Comedy"] {
		t.Fatalf("recent genre should outweigh older: %+v", profile.GenreWeights)
	}
}

func TestBuildProfileSeedsSeriesRatherThanEpisode(t *testing.T) {
	history := []Item{
		episode("ep1", "Good News", "sev", "Severance", []string{"Drama"}),
	}
	profile := BuildProfile(history, nil)

	if len(profile.Seeds) != 1 {
		t.Fatalf("expected one seed, got %+v", profile.Seeds)
	}
	if profile.Seeds[0].ID != "sev" || profile.Seeds[0].Name != "Severance" {
		t.Fatalf("expected the series as seed, got %+v", profile.Seeds[0])
	}
	// The series must count as seen, or we would recommend a show already in progress.
	if !profile.Seen["sev"] {
		t.Fatal("series id should be marked seen")
	}
}

func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
	history := []Item{
		episode("ep2", "Half Loop", "sev", "Severance", nil),
		episode("ep1", "Good News", "sev", "Severance", nil),
		item("m1", "Dune", "Movie", nil, 0),
	}
	profile := BuildProfile(history, nil)

	if len(profile.Seeds) != 2 {
		t.Fatalf("expected 2 distinct seeds, got %d: %+v", len(profile.Seeds), profile.Seeds)
	}
}

func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
	history := []Item{
		episode("ep2", "Second", "series", "Series", []string{"Drama"}),
		episode("ep1", "First", "series", "Series", []string{"Drama"}),
		item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
	}
	profile := BuildProfile(history, nil)

	if profile.GenreWeights["Drama"] != 1 {
		t.Fatalf("repeated series weight = %v, want the newest occurrence only",
			profile.GenreWeights["Drama"])
	}
	if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
		t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
	}
}

func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
	fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
	fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})

	if fromFavorite.GenreWeights["Horror"] >= fromHistory.GenreWeights["Horror"] {
		t.Fatal("a favourite should weigh less than the most recent play")
	}
}

func TestTopGenresIsDeterministicOnTies(t *testing.T) {
	profile := Profile{GenreWeights: map[string]float64{"Western": 1, "Action": 1, "Drama": 2}}
	for range 20 {
		got := profile.TopGenres(3)
		want := []string{"Drama", "Action", "Western"}
		for i := range want {
			if got[i] != want[i] {
				t.Fatalf("unstable ordering: got %v, want %v", got, want)
			}
		}
	}
}

func TestScoreExcludesWhatTheUserAlreadySaw(t *testing.T) {
	profile := BuildProfile([]Item{item("seen", "Seen", "Movie", []string{"Drama"}, 0)}, nil)

	if score := profile.Score(item("seen", "Seen", "Movie", []string{"Drama"}, 8)); score >= 0 {
		t.Fatalf("watched item should be excluded, scored %v", score)
	}

	inProgress := item("new", "New", "Movie", []string{"Drama"}, 8)
	inProgress.UserData.PlaybackPositionTicks = 500
	if score := profile.Score(inProgress); score >= 0 {
		t.Fatalf("in-progress item should be excluded, scored %v", score)
	}
}

func TestScoreExcludesEpisodesOfASeriesInProgress(t *testing.T) {
	profile := BuildProfile([]Item{episode("ep1", "Pilot", "sev", "Severance", []string{"Drama"})}, nil)

	candidate := episode("ep9", "Finale", "sev", "Severance", []string{"Drama"})
	if score := profile.Score(candidate); score >= 0 {
		t.Fatalf("another episode of a watched series should be excluded, scored %v", score)
	}
}

func TestScoreDoesNotRewardGenreStuffing(t *testing.T) {
	profile := Profile{
		GenreWeights:  map[string]float64{"Drama": 1, "Action": 1, "Comedy": 1, "Horror": 1},
		StudioWeights: map[string]float64{},
		Seen:          map[string]bool{},
	}

	focused := item("a", "Focused", "Movie", []string{"Drama"}, 0)
	stuffed := item("b", "Stuffed", "Movie", []string{"Drama", "Action", "Comedy", "Horror"}, 0)

	// The stuffed title still scores higher — it genuinely matches more of the profile —
	// but the sqrt penalty must keep it from scoring 4x the focused one.
	if profile.Score(stuffed) >= 4*profile.Score(focused) {
		t.Fatalf("genre stuffing was not penalised: focused=%v stuffed=%v",
			profile.Score(focused), profile.Score(stuffed))
	}
}

func TestRankOrdersByAffinityAndDropsDuplicates(t *testing.T) {
	profile := BuildProfile([]Item{item("h", "History", "Movie", []string{"Science Fiction"}, 0)}, nil)

	candidates := []Item{
		item("c1", "Comedy Pick", "Movie", []string{"Comedy"}, 9),
		item("c2", "Sci-Fi Pick", "Movie", []string{"Science Fiction"}, 5),
		item("c2", "Sci-Fi Pick (dupe)", "Movie", []string{"Science Fiction"}, 5),
		item("h", "History", "Movie", []string{"Science Fiction"}, 10),
	}

	ranked := Rank(profile, candidates, 10)

	if len(ranked) != 2 {
		t.Fatalf("expected 2 results (dupe collapsed, watched dropped), got %d: %+v", len(ranked), ranked)
	}
	if ranked[0].ID != "c2" {
		t.Fatalf("genre affinity should beat a higher rating, got %q first", ranked[0].ID)
	}
}

func TestRankRespectsLimit(t *testing.T) {
	profile := Profile{GenreWeights: map[string]float64{"Drama": 1}, Seen: map[string]bool{}}
	candidates := make([]Item, 0, 30)
	for i := range 30 {
		candidates = append(candidates, item(string(rune('a'+i)), "Title", "Movie", []string{"Drama"}, 5))
	}
	if got := len(Rank(profile, candidates, 8)); got != 8 {
		t.Fatalf("limit not applied: got %d", got)
	}
}

func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
	profile := Profile{
		GenreWeights:  map[string]float64{"Comedy": 2},
		StudioWeights: map[string]float64{"Apple TV+": 0.5},
	}
	if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
		t.Fatalf("CollectionAffinity = %v, want 2.5", got)
	}
}

func TestDecodeKeepsRawPayload(t *testing.T) {
	raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
	items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})

	if len(items) != 1 {
		t.Fatalf("expected malformed and id-less items to be skipped, got %d", len(items))
	}
	// The raw payload must survive untouched: it carries image tags the TV needs and
	// that this package never models.
	if string(items[0].Raw) != string(raw) {
		t.Fatalf("raw payload was altered: %s", items[0].Raw)
	}
}

func TestFilterUnseenPreservesEmbyOrdering(t *testing.T) {
	profile := BuildProfile([]Item{item("seen", "Seen", "Movie", nil, 0)}, nil)
	candidates := []Item{
		item("seen", "Seen", "Movie", nil, 0),
		item("b", "Second", "Movie", nil, 0),
		item("a", "First", "Movie", nil, 0),
	}

	got := FilterUnseen(profile, candidates, 10)

	if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" {
		t.Fatalf("ordering not preserved: %+v", got)
	}
}
