package playlists

import (
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)

// testUserID is a stable UUID used to seed the daily-determinism RNGs in
// scoreAndSortCandidates / pickHeadAndTail / pickTopN. Tests pass it to
// every call so the jitter is reproducible across test runs.
var testUserID = pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}

// makeCand constructs a Candidate with distinct track/album/artist IDs
// and a SimilarityScore that drives distinguishable scores when passed
// through recommendation.Score. Using SimilarityScore alone is sufficient
// because systemMixWeights.SimilarityWeight = 1.5 (non-zero), so
// different similarity values produce different scores.
//
// Tests below use similarity values with gaps wide enough that the
// systemMixWeights JitterMagnitude (±0.1) cannot perturb the top-of-pool
// ordering, so assertions about head order remain meaningful.
func makeCand(trackN, albumN, artistN int, similarity float64) recommendation.Candidate {
	var tID, aID, arID pgtype.UUID
	tID.Valid, aID.Valid, arID.Valid = true, true, true
	tID.Bytes[15] = byte(trackN)
	aID.Bytes[15] = byte(albumN)
	arID.Bytes[15] = byte(artistN)
	return recommendation.Candidate{
		Track: dbq.Track{ID: tID, AlbumID: aID, ArtistID: arID},
		Inputs: recommendation.ScoringInputs{
			SimilarityScore: similarity,
		},
	}
}

func TestCapCandidatesByAlbumAndArtist_AlbumCap(t *testing.T) {
	// discoverMaxTracksPerAlbum = 2: third track on album 10 must be dropped.
	in := []recommendation.Candidate{
		makeCand(1, 10, 100, 1.0),
		makeCand(2, 10, 101, 0.9),
		makeCand(3, 10, 102, 0.8), // 3rd from album 10 → drop
		makeCand(4, 11, 103, 0.7),
	}
	got := capCandidatesByAlbumAndArtist(in)
	if len(got) != 3 {
		t.Errorf("len = %d, want 3 (album 10 capped at 2)", len(got))
	}
}

func TestCapCandidatesByAlbumAndArtist_ArtistCap(t *testing.T) {
	// discoverMaxTracksPerArtist = 3: fourth track by artist 100 must be dropped.
	in := []recommendation.Candidate{
		makeCand(1, 10, 100, 1.0),
		makeCand(2, 11, 100, 0.9),
		makeCand(3, 12, 100, 0.8),
		makeCand(4, 13, 100, 0.7), // 4th by artist 100 → drop
		makeCand(5, 14, 101, 0.6),
	}
	got := capCandidatesByAlbumAndArtist(in)
	if len(got) != 4 {
		t.Errorf("len = %d, want 4 (artist 100 capped at 3)", len(got))
	}
}

func TestCapCandidatesByAlbumAndArtist_PreservesOrder(t *testing.T) {
	// All distinct albums and artists: all kept, original order preserved.
	in := []recommendation.Candidate{
		makeCand(3, 10, 100, 0.5),
		makeCand(1, 11, 101, 0.9),
		makeCand(2, 12, 102, 0.7),
	}
	got := capCandidatesByAlbumAndArtist(in)
	if len(got) != 3 {
		t.Fatalf("len = %d, want 3", len(got))
	}
	for i, want := range []byte{3, 1, 2} {
		if got[i].Track.ID.Bytes[15] != want {
			t.Errorf("got[%d].ID = %d, want %d", i, got[i].Track.ID.Bytes[15], want)
		}
	}
}

func TestCapCandidatesByAlbumAndArtist_Empty(t *testing.T) {
	got := capCandidatesByAlbumAndArtist(nil)
	if len(got) != 0 {
		t.Errorf("len = %d, want 0", len(got))
	}
}

func TestPickHeadAndTail_SmallPool(t *testing.T) {
	// Pool < headN+tailN → return up to total entries, no tail split.
	in := []recommendation.Candidate{
		makeCand(1, 10, 100, 1.0),
		makeCand(2, 11, 101, 0.9),
		makeCand(3, 12, 102, 0.8),
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
	if len(got) != 3 {
		t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
	}
}

func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
	// Pool == headN+tailN: no tail to sample from, returns all.
	// Wide score gaps (1.0 per step) keep jitter from perturbing order.
	in := make([]recommendation.Candidate, 0, 7)
	for i := 0; i < 7; i++ {
		in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
	if len(got) != 7 {
		t.Errorf("len = %d, want 7 (pool == total)", len(got))
	}
}

func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
	// Pool of 100 distinct (album, artist) pairs; no caps trim.
	// With headN=20, tailN=5, expect 25 entries total.
	// Wide score gaps (1.0 per step) keep jitter from perturbing order.
	in := make([]recommendation.Candidate, 0, 100)
	for i := 0; i < 100; i++ {
		in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
	if len(got) != 25 {
		t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
	}
}

func TestPickHeadAndTail_Determinism(t *testing.T) {
	// Same inputs, same dateStr → identical output both times.
	in := make([]recommendation.Candidate, 0, 100)
	for i := 0; i < 100; i++ {
		in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
	}
	now := time.Now()
	got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
	got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
	if len(got1) != len(got2) {
		t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
	}
	for i := range got1 {
		if got1[i].TrackID.Bytes[15] != got2[i].TrackID.Bytes[15] {
			t.Errorf("position %d differs across calls (not deterministic within day)", i)
			break
		}
	}
}

func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
	// Head (positions 0..headN-1) must match across different date strings.
	// Tail (positions headN..headN+tailN-1) should differ for different dates
	// because tieBreakHash incorporates the date.
	in := make([]recommendation.Candidate, 0, 100)
	for i := 0; i < 100; i++ {
		in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
	}
	now := time.Now()
	day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
	day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5)
	if len(day1) != 25 || len(day2) != 25 {
		t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
	}

	// Head (score-sorted, deterministic): positions 0..19 must match.
	for i := 0; i < 20; i++ {
		if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
			t.Errorf("head position %d differs across date strings (should be score-stable)", i)
		}
	}

	// Tail (tieBreakHash order): at least one position should differ.
	tailDiffers := false
	for i := 20; i < 25; i++ {
		if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
			tailDiffers = true
			break
		}
	}
	if !tailDiffers {
		t.Errorf("tail identical across different date strings (tieBreakHash not using dateStr)")
	}
}

func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
	// With headN=5 and a 50-candidate pool, tailStart = 2*5 = 10.
	// Tail samples come from positions >=10, not from positions 5..9.
	// Verify: the first 5 positions contain tracks from the top-5 by score,
	// and positions 5..9 (the "buffer zone") do not appear in the tail.
	//
	// Tracks 1..50 have similarity 0.99..0.01 (descending), so
	// after scoring the sorted order is tracks 1,2,3,...,50.
	// Top 5 head = tracks 1..5.
	// Buffer zone (positions 5..9) = tracks 6..10.
	// Tail pool = tracks 11..50; tail sample = 3 from that pool.
	in := make([]recommendation.Candidate, 0, 50)
	for i := 0; i < 50; i++ {
		// trackN = i+1, score descends: track 1 scores highest.
		// Wide score gaps (1.0 per step) keep jitter from perturbing order.
		sim := float64(50 - i)
		in = append(in, makeCand(i+1, i+1, i+1, sim))
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
	if len(got) != 8 {
		t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
	}

	// Head tracks must be tracks 1..5 (highest scorers).
	for i := 0; i < 5; i++ {
		if got[i].TrackID.Bytes[15] != byte(i+1) {
			t.Errorf("head[%d] = track %d, want track %d",
				i, got[i].TrackID.Bytes[15], i+1)
		}
	}

	// Buffer-zone tracks 6..10 must NOT appear in the tail positions 5..7.
	bufferSet := map[byte]bool{6: true, 7: true, 8: true, 9: true, 10: true}
	for i := 5; i < 8; i++ {
		if bufferSet[got[i].TrackID.Bytes[15]] {
			t.Errorf("tail[%d] = track %d (buffer zone); should come from positions >=10",
				i-5, got[i].TrackID.Bytes[15])
		}
	}
}

func TestPickTopN_DiversityCap(t *testing.T) {
	// Verify pickTopN now enforces the diversity cap.
	// 5 tracks from the same artist: only 3 should survive the cap.
	in := []recommendation.Candidate{
		makeCand(1, 10, 100, 1.0),
		makeCand(2, 11, 100, 0.9),
		makeCand(3, 12, 100, 0.8),
		makeCand(4, 13, 100, 0.7), // 4th by artist → dropped by cap
		makeCand(5, 14, 100, 0.6), // 5th by artist → dropped by cap
	}
	got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
	if len(got) != 3 {
		t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
	}
}

func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
	// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
	// taste picks, the 5 tail entries the freshness injection (#1249).
	in := make([]recommendation.Candidate, 0, 100)
	for i := 0; i < 100; i++ {
		in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
	if len(got) != 25 {
		t.Fatalf("len = %d, want 25", len(got))
	}
	for i := 0; i < 20; i++ {
		if got[i].PickKind != pickKindTaste {
			t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
		}
	}
	for i := 20; i < 25; i++ {
		if got[i].PickKind != pickKindFresh {
			t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
		}
	}
}

func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
	// The small-pool fallback is pure top-N-by-score — that IS the taste
	// mechanism, so nothing on this path is an exploration pick.
	in := []recommendation.Candidate{
		makeCand(1, 10, 100, 1.0),
		makeCand(2, 11, 101, 0.9),
		makeCand(3, 12, 102, 0.8),
	}
	got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
	for i, rc := range got {
		if rc.PickKind != pickKindTaste {
			t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
				i, rc.PickKind, pickKindTaste)
		}
	}
}

func TestPickTopN_NoPickKind(t *testing.T) {
	// Songs-like-X and the discovery mixes don't split; their rows must
	// persist pick_kind NULL (empty string here).
	in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
	got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
	if len(got) != 1 || got[0].PickKind != "" {
		t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
	}
}

func TestHeadQuotas(t *testing.T) {
	cases := []struct {
		seeds, headN int
		want         []int
	}{
		{1, 50, []int{50}},
		{2, 50, []int{30, 20}},
		{3, 50, []int{25, 15, 10}},
		{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
	}
	for _, c := range cases {
		got := headQuotas(c.seeds, c.headN)
		if len(got) != len(c.want) {
			t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
		}
		sum := 0
		for i := range got {
			sum += got[i]
			if got[i] != c.want[i] {
				t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
			}
		}
		if sum != c.headN {
			t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
		}
	}
}

func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
	// Seed 0's neighborhood out-scores everything (#1269): without
	// quotas the head would be all seed-0. With headN=10 and quotas
	// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
	// 2 get their guaranteed slots from deeper ranks.
	in := make([]recommendation.Candidate, 0, 30)
	seedOf := map[pgtype.UUID]int{}
	for i := 0; i < 30; i++ {
		c := makeCand(i+1, i+1, i+1, float64(30-i))
		si := 0
		if i >= 20 && i < 25 {
			si = 1
		} else if i >= 25 {
			si = 2
		}
		seedOf[c.Track.ID] = si
		in = append(in, c)
	}
	// Input is already score-descending and cap-free.
	head := pickQuotaHead(in, seedOf, 3, 10)
	if len(head) != 10 {
		t.Fatalf("head len = %d, want 10", len(head))
	}
	want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
	for i, w := range want {
		if head[i].Track.ID.Bytes[15] != w {
			t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
		}
	}
}

func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
	// Seed 1 has a single candidate; its unfilled quota goes to the
	// best remaining candidates regardless of seed.
	in := make([]recommendation.Candidate, 0, 12)
	seedOf := map[pgtype.UUID]int{}
	for i := 0; i < 12; i++ {
		c := makeCand(i+1, i+1, i+1, float64(12-i))
		si := 0
		if i == 11 {
			si = 1 // the worst-ranked candidate is seed 1's only one
		}
		seedOf[c.Track.ID] = si
		in = append(in, c)
	}
	head := pickQuotaHead(in, seedOf, 2, 10)
	if len(head) != 10 {
		t.Fatalf("head len = %d, want 10", len(head))
	}
	// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
	// (track 12), spill tops up with tracks 7-9.
	got := map[byte]bool{}
	for _, c := range head {
		got[c.Track.ID.Bytes[15]] = true
	}
	for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
		if !got[w] {
			t.Errorf("head missing track %d (spill should fill best-first)", w)
		}
	}
}

func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
	// The fresh tail's sample is rank-weighted (#1269): across many
	// days, the mean selected rank must sit well below the uniform
	// draw's ~99.5 for a 200-deep pool.
	pool := make([]recommendation.Candidate, 0, 200)
	for i := 0; i < 200; i++ {
		c := makeCand(1, i+1, i+1, 1.0)
		// Distinct track IDs beyond one byte: bytes wrap at 256.
		c.Track.ID.Bytes[14] = byte(i / 200)
		c.Track.ID.Bytes[15] = byte(i % 200)
		pool = append(pool, c)
	}
	rankOf := map[pgtype.UUID]int{}
	for i, c := range pool {
		rankOf[c.Track.ID] = i
	}
	totalRank, picks := 0, 0
	for day := 1; day <= 28; day++ {
		got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
		if len(got) != 10 {
			t.Fatalf("day %d: len = %d, want 10", day, len(got))
		}
		for _, c := range got {
			totalRank += rankOf[c.Track.ID]
			picks++
		}
	}
	mean := float64(totalRank) / float64(picks)
	if mean > 80 {
		t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
	}
}

func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
	pool := make([]recommendation.Candidate, 0, 60)
	for i := 0; i < 60; i++ {
		pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
	}
	a := pickWeightedTail(pool, "2026-06-05", 8)
	b := pickWeightedTail(pool, "2026-06-05", 8)
	for i := range a {
		if a[i].Track.ID != b[i].Track.ID {
			t.Fatal("weighted tail must be deterministic within a day")
		}
	}
}
