package recommendation

import (
	"fmt"
	"testing"

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

// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
// diversity rules from slice #2373 are covered here in the fast lane rather
// than behind the integration gate.

func seedID(n byte) pgtype.UUID {
	var u pgtype.UUID
	u.Bytes[0] = n
	u.Valid = true
	return u
}

// candidate builds a pool entry attributed to the given dominant seed.
func candidate(mbid string, score float64, dominant byte) ArtistSuggestion {
	return ArtistSuggestion{
		MBID:  mbid,
		Name:  mbid,
		Score: score,
		Attribution: []SeedContribution{
			{ArtistID: seedID(dominant), Contribution: score},
		},
	}
}

// poolOf returns n candidates in descending score order, spread across
// `seeds` distinct dominant seeds.
func poolOf(n int, seeds int) []ArtistSuggestion {
	out := make([]ArtistSuggestion, 0, n)
	for i := 0; i < n; i++ {
		out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds)))
	}
	return out
}

func mbids(in []ArtistSuggestion) []string {
	out := make([]string, 0, len(in))
	for _, s := range in {
		out = append(out, s.MBID)
	}
	return out
}

func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) {
	pool := poolOf(5, 5)
	got := selectSuggestions(pool, 12, "2026-08-01")
	if len(got) != 5 {
		t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got))
	}
	if got[0].MBID != "mbid-00" {
		t.Errorf("first = %q, want mbid-00", got[0].MBID)
	}
}

func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) {
	got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01")
	if len(got) != 12 {
		t.Fatalf("len = %d, want 12", len(got))
	}
}

// The reported symptom: the deck must change day to day without the user
// requesting anything.
func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) {
	pool := poolOf(48, 8)
	day1 := mbids(selectSuggestions(pool, 12, "2026-08-01"))
	day2 := mbids(selectSuggestions(pool, 12, "2026-08-02"))

	if fmt.Sprint(day1) == fmt.Sprint(day2) {
		t.Fatalf("deck identical across days: %v", day1)
	}
	// ...but stable WITHIN a day, or the surface would reshuffle on every
	// pull-to-refresh, which reads as broken rather than fresh.
	again := mbids(selectSuggestions(pool, 12, "2026-08-01"))
	if fmt.Sprint(day1) != fmt.Sprint(again) {
		t.Errorf("same day differed:\n %v\n %v", day1, again)
	}
}

// The strongest matches should never rotate out of sight.
func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
	pool := poolOf(48, 8)
	for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} {
		got := selectSuggestions(pool, 12, day)
		if got[0].MBID != "mbid-00" {
			t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID)
		}
	}
}

// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
//
// The pool is deliberately SKEWED — one seed owns the entire top of the score
// ranking — because that is the only shape where a cap can bite. With scores
// spread evenly across seeds the top-N is already diverse and the cap is
// untestable (an earlier version of this test asserted against an even pool and
// could not fail).
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
	const dominantRun = 20
	pool := make([]ArtistSuggestion, 0, 48)
	for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores
		pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0))
	}
	for i := 0; i < 28; i++ { // seeds 1..9 hold everything below
		pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9)))
	}

	got := selectSuggestions(pool, 12, "2026-08-01")
	if len(got) != 12 {
		t.Fatalf("len = %d, want 12", len(got))
	}

	perSeed := map[pgtype.UUID]int{}
	for _, s := range got {
		perSeed[s.Attribution[0].ArtistID]++
	}
	// Uncapped, the top 12 by score would be 12 of seed 0's neighbours.
	if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) {
		t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v",
			n, maxPerSeedFor(12), mbids(got))
	}
	if len(perSeed) < 4 {
		t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got))
	}
}

// Diversity must not starve the deck (rule #131): a pool hanging off a single
// seed should still fill, not collapse to maxPerSeed entries.
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
	got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
	if len(got) != 12 {
		t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got))
	}
	if got[0].MBID != "mbid-00" {
		t.Errorf("first = %q, want mbid-00", got[0].MBID)
	}
}

// A candidate with no attribution has no seed to blame, so it must never be
// held back by the diversity cap.
func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) {
	pool := []ArtistSuggestion{
		candidate("a", 0.9, 1),
		candidate("b", 0.8, 1),
		{MBID: "orphan", Score: 0.1},
	}
	preferred, overflow := partitionPerSeed(pool, 1)
	if len(preferred) != 2 || preferred[1].MBID != "orphan" {
		t.Errorf("preferred = %v, want [a orphan]", mbids(preferred))
	}
	if len(overflow) != 1 || overflow[0].MBID != "b" {
		t.Errorf("overflow = %v, want [b]", mbids(overflow))
	}
}

func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) {
	if got := poolSizeFor(12); got != 48 {
		t.Errorf("poolSizeFor(12) = %d, want 48", got)
	}
	if got := poolSizeFor(50); got != suggestionPoolMax {
		t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax)
	}
}
