package recommendation

import (
	"math"
	"math/rand"
	"testing"
	"time"
)

func defaultWeights() ScoringWeights {
	return ScoringWeights{
		BaseWeight:      1.0,
		LikeBoost:       2.0,
		RecencyWeight:   1.0,
		SkipPenalty:     1.0,
		JitterMagnitude: 0.1,
	}
}

func fixedRNG(v float64) func() float64 {
	return func() float64 { return v }
}

func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) {
	in := ScoringInputs{}
	now := time.Now().UTC()
	got := Score(in, defaultWeights(), now, fixedRNG(0.5))
	// rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0
	// expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0
	want := 2.0
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_LikeBoost(t *testing.T) {
	notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
	liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5))
	if math.Abs(liked-notLiked-2.0) > 1e-9 {
		t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked)
	}
}

func TestScore_RecencyRamp_15Days(t *testing.T) {
	now := time.Now().UTC()
	played := now.Add(-15 * 24 * time.Hour)
	got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5))
	// recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5
	want := 1.5
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) {
	now := time.Now().UTC()
	played := now.Add(-60 * 24 * time.Hour)
	got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5))
	want := 2.0 // base + capped recency (1.0)
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_SkipRatio(t *testing.T) {
	// 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5
	in := ScoringInputs{PlayCount: 4, SkipCount: 2}
	got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5))
	// recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0;
	// for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above
	// explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5.
	want := 1.5
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) {
	in := ScoringInputs{PlayCount: 0, SkipCount: 0}
	got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5))
	want := 2.0 // base + recency, no skip penalty
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_JitterBounds(t *testing.T) {
	r := rand.New(rand.NewSource(42))
	w := defaultWeights()
	now := time.Now().UTC()
	mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0
	for i := 0; i < 1000; i++ {
		got := Score(ScoringInputs{}, w, now, r.Float64)
		if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 {
			t.Fatalf("score %v outside jitter band [%v, %v]",
				got, mid-w.JitterMagnitude, mid+w.JitterMagnitude)
		}
	}
}

func TestScore_Determinism(t *testing.T) {
	in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3}
	w := defaultWeights()
	now := time.Now().UTC()
	a := Score(in, w, now, fixedRNG(0.7))
	b := Score(in, w, now, fixedRNG(0.7))
	if a != b {
		t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b)
	}
}

func TestRecencyDecay_NilIsOne(t *testing.T) {
	if got := recencyDecay(nil, time.Now()); got != 1.0 {
		t.Errorf("recencyDecay(nil) = %v, want 1.0", got)
	}
}

func TestRecencyDecay_Clamping(t *testing.T) {
	now := time.Now().UTC()
	cases := []struct {
		ageDays float64
		want    float64
	}{
		{0, 0.0},
		{15, 0.5},
		{30, 1.0},
		{45, 1.0},
	}
	for _, c := range cases {
		t.Run("", func(t *testing.T) {
			past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour)))
			got := recencyDecay(&past, now)
			if math.Abs(got-c.want) > 1e-9 {
				t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want)
			}
		})
	}
}

func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) {
	if got := skipRatio(0, 0); got != 0.0 {
		t.Errorf("skipRatio(0,0) = %v, want 0.0", got)
	}
}

func TestSkipRatio_Half(t *testing.T) {
	if got := skipRatio(4, 2); got != 0.5 {
		t.Errorf("skipRatio(4,2) = %v, want 0.5", got)
	}
}

func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) {
	w := defaultWeights()
	w.ContextWeight = 2.0
	in := ScoringInputs{ContextualMatchScore: 1.0}
	got := Score(in, w, time.Now(), fixedRNG(0.5))
	// base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0
	want := 4.0
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) {
	w := defaultWeights()
	w.ContextWeight = 2.0
	in := ScoringInputs{ContextualMatchScore: 0.5}
	got := Score(in, w, time.Now(), fixedRNG(0.5))
	// base 1.0 + recency 1.0 + contextual 1.0 = 3.0
	want := 3.0
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) {
	wWithCtx := defaultWeights()
	wWithCtx.ContextWeight = 2.0
	withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5))
	withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
	if math.Abs(withCtx-withoutCtx) > 1e-9 {
		t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
	}
}

func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) {
	w := defaultWeights()
	w.SimilarityWeight = 2.0
	in := ScoringInputs{SimilarityScore: 1.0}
	got := Score(in, w, time.Now(), fixedRNG(0.5))
	// base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0
	want := 4.0
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) {
	w := defaultWeights()
	w.SimilarityWeight = 2.0
	in := ScoringInputs{SimilarityScore: 0.5}
	got := Score(in, w, time.Now(), fixedRNG(0.5))
	// base 1.0 + recency 1.0 + similarity 1.0 = 3.0
	want := 3.0
	if math.Abs(got-want) > 1e-9 {
		t.Errorf("score = %v, want %v", got, want)
	}
}

func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) {
	wWithSim := defaultWeights()
	wWithSim.SimilarityWeight = 2.0
	withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5))
	withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
	if math.Abs(withSim-withoutSim) > 1e-9 {
		t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim)
	}
}
