package recommend

import (
	"encoding/json"
	"testing"
	"time"
)

func rankedFixture(id, name, kind string, genres []string, minutes int) Item {
	raw, _ := json.Marshal(map[string]any{
		"Id": id, "Name": name, "Type": kind, "Genres": genres,
		"RunTimeTicks": int64(minutes) * 600_000_000,
	})
	item := Item{
		ID: id, Name: name, Type: kind, Genres: genres,
		RunTimeTicks: int64(minutes) * 600_000_000, CommunityRating: 7,
		Raw: raw,
	}
	return item
}

func TestWeightedRankPersonalizesTwoUsersFromRepeatedCompletedPatterns(t *testing.T) {
	now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC)
	dramaHistory := []ViewingEvidence{
		{Item: rankedFixture("d1", "Drama One", "Movie", []string{"Drama"}, 100), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
		{Item: rankedFixture("d2", "Drama Two", "Series", []string{"Drama"}, 45), Completion: .96, OccurredAt: now.Add(-48 * time.Hour)},
	}
	comedyHistory := []ViewingEvidence{
		{Item: rankedFixture("c1", "Comedy One", "Movie", []string{"Comedy"}, 95), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
		{Item: rankedFixture("c2", "Comedy Two", "Series", []string{"Comedy"}, 25), Completion: .98, OccurredAt: now.Add(-48 * time.Hour)},
	}
	candidates := []Item{
		rankedFixture("new-comedy", "A Comedy", "Movie", []string{"Comedy"}, 90),
		rankedFixture("new-drama", "A Drama", "Movie", []string{"Drama"}, 90),
	}
	cfg := DefaultWeightedConfig()
	drama := WeightedRank(BuildWeightedProfile(dramaHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
	comedy := WeightedRank(BuildWeightedProfile(comedyHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
	if drama[0].Item.ID != "new-drama" || comedy[0].Item.ID != "new-comedy" {
		t.Fatalf("personalized first items = drama:%s comedy:%s", drama[0].Item.ID, comedy[0].Item.ID)
	}
	if drama[0].Explanation.Components["genre"] <= 0 ||
		len(drama[0].Explanation.Reasons) == 0 {
		t.Fatalf("missing explainable component scores: %#v", drama[0].Explanation)
	}
}

func TestWeightedRankRequiresRepeatedAffinityEvidence(t *testing.T) {
	now := time.Now()
	profile := BuildWeightedProfile([]ViewingEvidence{{
		Item:       rankedFixture("once", "One Accidental Start", "Movie", []string{"Horror"}, 90),
		Completion: .05, OccurredAt: now,
	}}, now, time.UTC)
	ranked := WeightedRank(profile, []Item{
		rankedFixture("horror", "Horror", "Movie", []string{"Horror"}, 90),
		rankedFixture("other", "Other", "Movie", []string{"Drama"}, 90),
	}, nil, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
	for _, value := range ranked {
		if value.Explanation.Components["genre"] != 0 {
			t.Fatalf("one event established genre affinity: %#v", value.Explanation.Components)
		}
	}
}

func TestOnboardingRatingCreatesImmediateMetadataAffinity(t *testing.T) {
	profile := WeightedProfile{}
	rated := rankedFixture(
		"rated", "Arrival", "Movie", []string{"Science Fiction", "Drama"}, 116,
	)
	rated.Studios = []struct {
		Name string `json:"Name"`
	}{{Name: "Paramount"}}
	rated.People = []Person{
		{Name: "Amy Adams", Type: "Actor"},
		{Name: "Denis Villeneuve", Type: "Director"},
	}
	profile.ApplyOnboardingRating(rated, 5, 2)

	candidate := rankedFixture(
		"candidate", "Another Arrival", "Movie", []string{"Science Fiction"}, 120,
	)
	candidate.Studios = rated.Studios
	candidate.People = rated.People
	ranked := WeightedRank(
		profile, []Item{candidate}, nil, RankIntent{}, DefaultWeightedConfig(), 1,
	)
	if len(ranked) != 1 {
		t.Fatal("rated metadata produced no candidate")
	}
	components := ranked[0].Explanation.Components
	for _, key := range []string{"genre", "studio", "actor", "director"} {
		if components[key] <= 0 {
			t.Fatalf("%s component = %.3f, want positive: %#v", key, components[key], components)
		}
	}
	if !profile.Seen[rated.ID] {
		t.Fatal("the explicitly rated title was not marked seen")
	}
}

func TestOnboardingActressesFeedActorAffinity(t *testing.T) {
	profile := WeightedProfile{}
	profile.ApplyOnboarding(OnboardingPreferences{Actresses: []string{"Amy Adams"}}, 2)
	got := profile.Actors["amy adams"]
	if got.Evidence != 2 || got.Weight <= 0 {
		t.Fatalf("actress affinity = %#v", got)
	}
}

func TestWeightedRankNewReleaseEligibilityPrecedesPersonalization(t *testing.T) {
	now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
	old := rankedFixture("old", "Perfect Old Match", "Movie", []string{"Drama"}, 90)
	old.PremiereDate = "2020-01-01T00:00:00Z"
	fresh := rankedFixture("fresh", "Fresh Adjacent", "Movie", []string{"Comedy"}, 90)
	fresh.PremiereDate = "2026-07-01T00:00:00Z"
	profile := BuildWeightedProfile([]ViewingEvidence{
		{Item: rankedFixture("d1", "D1", "Movie", []string{"Drama"}, 90), Completion: 1},
		{Item: rankedFixture("d2", "D2", "Movie", []string{"Drama"}, 90), Completion: 1},
	}, now, time.UTC)
	ranked := WeightedRank(profile, []Item{old, fresh}, nil, RankIntent{
		Now: now, NewReleasesOnly: true,
	}, DefaultWeightedConfig(), 10)
	if len(ranked) != 1 || ranked[0].Item.ID != "fresh" {
		t.Fatalf("new release eligibility = %#v", ranked)
	}
}

func TestWeightedRankExcludesNegativeAndPenalizesIgnoredImpressions(t *testing.T) {
	now := time.Now()
	profile := WeightedProfile{
		Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
		Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
		Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
		AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
		ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
		ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{"blocked": true},
		TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
	}
	items := []Item{
		rankedFixture("ignored", "Ignored", "Movie", nil, 90),
		rankedFixture("fresh", "Fresh", "Movie", nil, 90),
		rankedFixture("blocked", "Blocked", "Movie", nil, 90),
	}
	ranked := WeightedRank(profile, items, map[string]ItemExposure{
		"ignored": {Impressions: 12},
	}, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
	if len(ranked) != 2 || ranked[0].Item.ID != "fresh" {
		t.Fatalf("fatigue/negative ordering = %#v", ranked)
	}
}

func TestWeightedRankSessionFitSupportsBeforeBedIntent(t *testing.T) {
	now := time.Date(2026, 7, 31, 23, 0, 0, 0, time.UTC)
	slot := currentContextSlot(now, time.UTC)
	profile := WeightedProfile{
		Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
		Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
		Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
		AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
		ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
		ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
		TypicalSessionMins: map[string]float64{slot: 28}, SessionEvidence: map[string]int{slot: 5},
	}
	ranked := WeightedRank(profile, []Item{
		rankedFixture("film", "Long Film", "Movie", nil, 125),
		rankedFixture("episode", "One Episode", "Series", nil, 27),
	}, nil, RankIntent{Now: now, Location: time.UTC, PreferShort: true}, DefaultWeightedConfig(), 10)
	if ranked[0].Item.ID != "episode" {
		t.Fatalf("bedtime ordering = %s", ranked[0].Item.ID)
	}
}
