package api

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

	"github.com/ponzischeme89/memby/server/internal/mdblist"
	"github.com/ponzischeme89/memby/server/internal/store"
)

func TestSelectedMovieRatingsFiltersOrdersAndLabelsAvailableValues(t *testing.T) {
	got := selectedMovieRatings(
		[]string{"letterboxd", "imdb", "tomatoes", "rogerebert", "metacritic"},
		[]mdblist.Rating{
			{Source: "imdb", Value: 8.2},
			{Source: "rttomatoes", Value: 91},
			{Source: "letterboxd", Value: 4.15},
			{Source: "rogerebert", Value: 0},
			{Source: "metacritic", Value: 101},
		},
	)
	if len(got) != 3 {
		t.Fatalf("ratings = %+v", got)
	}
	if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.2", Scale: "/5"}) ||
		got[1].Name != "IMDb" || got[1].Score != "8.2" || got[1].Scale != "/10" ||
		got[2].Name != "Rotten Tomatoes" || got[2].Score != "91" || got[2].Scale != "%" {
		t.Fatalf("formatted ratings = %+v", got)
	}
}

// A round score is the case the strip is read on: "7" beside "8.2" looks like a score out
// of a different scale, so a fractional scale keeps its decimal and a whole one never
// gains one.
func TestFormatRatingScoreKeepsTheDecimalOfAFractionalScale(t *testing.T) {
	for _, tc := range []struct {
		source string
		value  float64
		want   string
	}{
		{"imdb", 7, "7.0"},
		{"imdb", 8.26, "8.3"},
		{"letterboxd", 4, "4.0"},
		{"tomatoes", 91, "91"},
		{"metacritic", 87.4, "87"},
	} {
		if got := formatRatingScore(tc.value, movieRatingSources[tc.source]); got != tc.want {
			t.Fatalf("%s %v = %q, want %q", tc.source, tc.value, got, tc.want)
		}
	}
}

func TestMovieProviderPrefersTMDBAndFallsBackToIMDb(t *testing.T) {
	provider, id := movieProvider(map[string]string{"ImDb": "tt0111161", "TmDb": "278"})
	if provider != "tmdb" || id != "278" {
		t.Fatalf("provider = %q %q", provider, id)
	}
	provider, id = movieProvider(map[string]string{"IMDb": "tt0111161"})
	if provider != "imdb" || id != "tt0111161" {
		t.Fatalf("fallback provider = %q %q", provider, id)
	}
}

func TestRatingsNeedRefreshServesStoredValuesForAMonthAndRetriesEmptyOnesSooner(t *testing.T) {
	now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
	scored := []mdblist.Rating{{Source: "imdb", Value: 8.2}}
	if ratingsNeedRefresh(scored, now.Add(-29*24*time.Hour), now) {
		t.Fatal("a month-old score should still be considered current")
	}
	if !ratingsNeedRefresh(scored, now.Add(-31*24*time.Hour), now) {
		t.Fatal("an expired score should be refreshed")
	}
	// Nothing found is worth asking about again sooner: a new release gains scores.
	if ratingsNeedRefresh(nil, now.Add(-2*24*time.Hour), now) {
		t.Fatal("a recent empty answer should be kept")
	}
	if !ratingsNeedRefresh(nil, now.Add(-4*24*time.Hour), now) {
		t.Fatal("an old empty answer should be retried")
	}
	if !ratingsNeedRefresh(scored, time.Time{}, now) {
		t.Fatal("an unknown fetch time should be refreshed")
	}
}

func TestRatingKeyForRatesEpisodesAsTheirSeriesAndPrefersTMDB(t *testing.T) {
	key, ok := ratingKeyFor("Episode", map[string]string{"Tmdb": "1396", "Imdb": "tt0903747"})
	if !ok || key != (store.RatingKey{MediaType: "show", Provider: "tmdb", ProviderID: "1396"}) {
		t.Fatalf("episode key = %+v %v", key, ok)
	}
	key, ok = ratingKeyFor("Movie", map[string]string{"IMDB": "tt0111161"})
	if !ok || key != (store.RatingKey{MediaType: "movie", Provider: "imdb", ProviderID: "tt0111161"}) {
		t.Fatalf("movie key = %+v %v", key, ok)
	}
	if _, ok := ratingKeyFor("Movie", map[string]string{"Tvdb": "1234"}); ok {
		t.Fatal("an item MDBList cannot be asked about must not produce a key")
	}
	if _, ok := ratingKeyFor("BoxSet", map[string]string{"Tmdb": "9"}); ok {
		t.Fatal("only films and shows are rated")
	}
}

func TestInjectItemRatingsKeepsEveryOtherFieldOfEmbysPayload(t *testing.T) {
	raw := json.RawMessage(`{"Id":"42","Name":"Arrival","UnknownToThisBuild":{"a":1}}`)
	out := injectItemRatings(raw, []movieRating{
		{Source: "imdb", Name: "IMDb", Score: "7.9", Scale: "/10"},
	})
	var decoded map[string]json.RawMessage
	if err := json.Unmarshal(out, &decoded); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if string(decoded["UnknownToThisBuild"]) != `{"a":1}` || string(decoded["Name"]) != `"Arrival"` {
		t.Fatalf("payload lost fields: %s", out)
	}
	var ratings []movieRating
	if json.Unmarshal(decoded[ratingsItemField], &ratings) != nil || len(ratings) != 1 ||
		ratings[0].Name != "IMDb" {
		t.Fatalf("ratings = %s", decoded[ratingsItemField])
	}
	// Nothing to say is said by saying nothing: an empty list must not add the field.
	if got := injectItemRatings(raw, nil); string(got) != string(raw) {
		t.Fatalf("empty ratings changed the payload: %s", got)
	}
}

func TestItemIDsInDeduplicatesAcrossRowsAndStopsAtTheLimit(t *testing.T) {
	item := func(id string) json.RawMessage { return json.RawMessage(`{"Id":"` + id + `"}`) }
	ids := itemIDsIn([][]json.RawMessage{
		{item("a"), item("b")},
		{item("b"), item("c"), json.RawMessage(`{"Name":"no id"}`)},
	}, 10)
	if len(ids) != 3 || ids[0] != "a" || ids[2] != "c" {
		t.Fatalf("ids = %v", ids)
	}
	if capped := itemIDsIn([][]json.RawMessage{{item("a"), item("b"), item("c")}}, 2); len(capped) != 2 {
		t.Fatalf("capped = %v", capped)
	}
}

func TestClaimRatingsBudgetLimitsTheDayAndHonoursABackoff(t *testing.T) {
	s := &Server{}
	now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
	for spent := 0; spent < ratingsWarmDailyBudget; spent++ {
		if !s.claimRatingsBudget(now) {
			t.Fatalf("budget refused at %d", spent)
		}
	}
	if s.claimRatingsBudget(now) {
		t.Fatal("the daily budget should be spent")
	}
	// A new day restores it.
	if !s.claimRatingsBudget(now.Add(25 * time.Hour)) {
		t.Fatal("a new window should restore the budget")
	}
	s.blockRatingsWarming(now.Add(26 * time.Hour))
	if s.claimRatingsBudget(now.Add(25*time.Hour + time.Minute)) {
		t.Fatal("a refused provider should stop the warmer entirely")
	}
}
