package recommendation

import (
	"context"
	"testing"

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

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

// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
// candidates. These cover the SQL rather than the provider chain — the
// eligibility query is the piece with real risk in it (a GROUP BY over a
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
// branches), and it's consumed by this package's slice-6 ranking.
//
// Fixtures live here because the harness and seedUnmatched do.

const (
	tagVersionCurrent = 2
	tagVersionOld     = 1
)

// listEligible is the query under test, at the current provider version.
func listEligible(t *testing.T, pool *pgxpool.Pool, limit int32) []dbq.ListCandidateArtistsMissingTagsRow {
	t.Helper()
	rows, err := dbq.New(pool).ListCandidateArtistsMissingTags(context.Background(),
		dbq.ListCandidateArtistsMissingTagsParams{
			TagSourcesVersion: tagVersionCurrent,
			Limit:             limit,
		})
	if err != nil {
		t.Fatalf("ListCandidateArtistsMissingTags: %v", err)
	}
	return rows
}

func setState(t *testing.T, pool *pgxpool.Pool, mbid, source string, version int32) {
	t.Helper()
	if err := dbq.New(pool).SetCandidateArtistTagState(context.Background(),
		dbq.SetCandidateArtistTagStateParams{
			CandidateMbid: mbid, TagSource: source, TagSourcesVersion: version,
		}); err != nil {
		t.Fatalf("SetCandidateArtistTagState: %v", err)
	}
}

func mbidsOf(rows []dbq.ListCandidateArtistsMissingTagsRow) []string {
	out := make([]string, 0, len(rows))
	for _, r := range rows {
		out = append(out, r.CandidateMbid)
	}
	return out
}

func TestCandidateTags_NeverProcessedIsEligible(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, user.ID, seed.ID)
	seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)

	rows := listEligible(t, pool, 10)
	if len(rows) != 1 {
		t.Fatalf("len = %d, want 1: %v", len(rows), mbidsOf(rows))
	}
	if rows[0].CandidateName != "Candidate One" {
		t.Errorf("name = %q, want Candidate One", rows[0].CandidateName)
	}
	if rows[0].TotalScore != 0.9 {
		t.Errorf("score = %v, want 0.9", rows[0].TotalScore)
	}
}

// An in-library candidate's tags belong in track_tags, and the suggestion query
// filters it out anyway — enriching it would be wasted API budget.
func TestCandidateTags_InLibraryCandidateIsExcluded(t *testing.T) {
	pool := newPool(t)
	seed := seedArtist(t, pool, "Seed", "")
	seedArtist(t, pool, "Already Here", "cand-in-lib")
	seedUnmatched(t, pool, seed.ID, "cand-in-lib", "Already Here", 0.9)

	if rows := listEligible(t, pool, 10); len(rows) != 0 {
		t.Errorf("len = %d, want 0: %v", len(rows), mbidsOf(rows))
	}
}

func TestCandidateTags_SettledWithTagsIsExcluded(t *testing.T) {
	pool := newPool(t)
	seed := seedArtist(t, pool, "Seed", "")
	seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
	setState(t, pool, "cand-1", "musicbrainz", tagVersionCurrent)

	if rows := listEligible(t, pool, 10); len(rows) != 0 {
		t.Errorf("len = %d, want 0 (already enriched): %v", len(rows), mbidsOf(rows))
	}
}

// A candidate that settled 'none' becomes eligible again when the provider set
// widens (version bump) — that's the whole point of the version column. It must
// NOT be eligible at the current version, or the worker re-fetches it forever.
func TestCandidateTags_SettledNoneReopensOnlyOnVersionBump(t *testing.T) {
	pool := newPool(t)
	seed := seedArtist(t, pool, "Seed", "")
	seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)

	setState(t, pool, "cand-1", "none", tagVersionCurrent)
	if rows := listEligible(t, pool, 10); len(rows) != 0 {
		t.Errorf("current version: len = %d, want 0 (settled)", len(rows))
	}

	setState(t, pool, "cand-1", "none", tagVersionOld)
	if rows := listEligible(t, pool, 10); len(rows) != 1 {
		t.Errorf("older version: len = %d, want 1 (eligible again)", len(rows))
	}
}

// artist_similarity_unmatched holds one row per (seed, candidate, source).
// Without the GROUP BY, a candidate that five seeds point at would be fetched
// five times — five times the MusicBrainz budget for identical data.
func TestCandidateTags_ManySeedsCollapseToOneRowAndSumScores(t *testing.T) {
	pool := newPool(t)
	seedA := seedArtist(t, pool, "Seed A", "")
	seedB := seedArtist(t, pool, "Seed B", "")
	seedUnmatched(t, pool, seedA.ID, "cand-1", "Candidate One", 0.4)
	seedUnmatched(t, pool, seedB.ID, "cand-1", "Candidate One", 0.3)

	rows := listEligible(t, pool, 10)
	if len(rows) != 1 {
		t.Fatalf("len = %d, want 1 (grouped): %v", len(rows), mbidsOf(rows))
	}
	if got := rows[0].TotalScore; got < 0.69 || got > 0.71 {
		t.Errorf("total_score = %v, want ~0.7 (summed across seeds)", got)
	}
}

// The pool is far larger than one pass can drain at ~1 req/s, so the ordering
// IS the feature: the strongest candidates must be enriched first, or the ones
// that actually reach a user's deck starve behind the long tail.
func TestCandidateTags_StrongestCandidatesComeFirst(t *testing.T) {
	pool := newPool(t)
	seed := seedArtist(t, pool, "Seed", "")
	seedUnmatched(t, pool, seed.ID, "weak", "Weak", 0.1)
	seedUnmatched(t, pool, seed.ID, "strong", "Strong", 0.95)
	seedUnmatched(t, pool, seed.ID, "middle", "Middle", 0.5)

	rows := listEligible(t, pool, 10)
	want := []string{"strong", "middle", "weak"}
	got := mbidsOf(rows)
	if len(got) != 3 {
		t.Fatalf("len = %d, want 3: %v", len(got), got)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("order = %v, want %v", got, want)
		}
	}

	// And the limit takes the strongest, not an arbitrary slice.
	if top := mbidsOf(listEligible(t, pool, 1)); len(top) != 1 || top[0] != "strong" {
		t.Errorf("limit 1 returned %v, want [strong]", top)
	}
}

func TestCandidateTags_InsertKeepsTheStrongerWeight(t *testing.T) {
	pool := newPool(t)
	q := dbq.New(pool)
	ctx := context.Background()
	ins := func(w float64) {
		if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
			CandidateMbid: "cand-1", Tag: "shoegaze", Weight: w,
		}); err != nil {
			t.Fatalf("InsertCandidateArtistTag: %v", err)
		}
	}
	ins(0.8)
	ins(0.3) // weaker second write must not clobber

	rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"cand-1"})
	if err != nil {
		t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("len = %d, want 1", len(rows))
	}
	if rows[0].Weight != 0.8 {
		t.Errorf("weight = %v, want 0.8 (GREATEST)", rows[0].Weight)
	}
}

func TestCandidateTags_ListForMbidsIgnoresUnaskedCandidates(t *testing.T) {
	pool := newPool(t)
	q := dbq.New(pool)
	ctx := context.Background()
	for _, mbid := range []string{"want-1", "want-2", "other"} {
		if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
			CandidateMbid: mbid, Tag: "rock", Weight: 1,
		}); err != nil {
			t.Fatalf("insert %s: %v", mbid, err)
		}
	}
	rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"want-1", "want-2"})
	if err != nil {
		t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
	}
	if len(rows) != 2 {
		t.Errorf("len = %d, want 2", len(rows))
	}
	for _, r := range rows {
		if r.CandidateMbid == "other" {
			t.Error("returned a candidate that wasn't asked for")
		}
	}
}

// The similarity feed is refetched and churns, so without the sweep the cache
// only grows. Both halves must survive/die together for the right candidates.
func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
	pool := newPool(t)
	q := dbq.New(pool)
	ctx := context.Background()

	seed := seedArtist(t, pool, "Seed", "")
	seedUnmatched(t, pool, seed.ID, "live", "Live", 0.9)
	// "gone" is cached but no longer in the feed; "adopted" got added to the
	// library since, so its tags belong in track_tags now.
	seedArtist(t, pool, "Adopted", "adopted")
	seedUnmatched(t, pool, seed.ID, "adopted", "Adopted", 0.8)

	for _, mbid := range []string{"live", "gone", "adopted"} {
		if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
			CandidateMbid: mbid, Tag: "rock", Weight: 1,
		}); err != nil {
			t.Fatalf("insert %s: %v", mbid, err)
		}
		setState(t, pool, mbid, "musicbrainz", tagVersionCurrent)
	}

	deleted, err := q.GcDeleteOrphanedCandidateArtistTags(ctx)
	if err != nil {
		t.Fatalf("GcDeleteOrphanedCandidateArtistTags: %v", err)
	}
	if deleted != 2 {
		t.Errorf("deleted %d tag rows, want 2 (gone + adopted)", deleted)
	}
	deletedState, err := q.GcDeleteOrphanedCandidateArtistTagState(ctx)
	if err != nil {
		t.Fatalf("GcDeleteOrphanedCandidateArtistTagState: %v", err)
	}
	if deletedState != 2 {
		t.Errorf("deleted %d state rows, want 2", deletedState)
	}

	rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"live", "gone", "adopted"})
	if err != nil {
		t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
	}
	if len(rows) != 1 || rows[0].CandidateMbid != "live" {
		t.Errorf("survivors = %v, want [live] only", rows)
	}
}

// Coverage is the operator's window onto the honest ceiling: processed vs
// actually-tagged. A big gap means thin upstream data, not a broken worker.
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
	pool := newPool(t)
	setState(t, pool, "has-tags", "musicbrainz", tagVersionCurrent)
	setState(t, pool, "mixed-tags", "mixed", tagVersionCurrent)
	setState(t, pool, "no-tags", "none", tagVersionCurrent)

	got, err := dbq.New(pool).CountCandidateArtistTagCoverage(context.Background())
	if err != nil {
		t.Fatalf("CountCandidateArtistTagCoverage: %v", err)
	}
	if got.Processed != 3 {
		t.Errorf("processed = %d, want 3", got.Processed)
	}
	if got.WithTags != 2 {
		t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
	}
}

// --- Slice 6 (#2377): the taste-tag blend, end to end ---
//
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
// sides from the DB and that the blend reaches the returned deck.

func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
	t.Helper()
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
		 ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
		userID, tag, weight,
	); err != nil {
		t.Fatalf("seedTasteTag: %v", err)
	}
}

func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
	t.Helper()
	if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
		dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
	); err != nil {
		t.Fatalf("seedCandidateTag: %v", err)
	}
}

// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
// similarity the reorder assertion below could not fail.
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
	t.Helper()
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, userID, seed.ID)
	seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
	seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
}

func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	twoCandidatePool(t, pool, user.ID)

	seedTasteTag(t, pool, user.ID, "shoegaze", 10)
	seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
	seedCandidateTag(t, pool, "loud", "death metal", 1.0)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 2 {
		t.Fatalf("len = %d, want 2", len(out))
	}
	if out[0].MBID != "match" {
		t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
	}
	// And it explains itself.
	if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
		t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
	}
	if out[1].MatchedTags != nil {
		t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
	}
}

// The same fixture with the knob at 0 must return pure similarity order — the
// operator's off switch, verified against a real DB rather than assumed.
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	twoCandidatePool(t, pool, user.ID)

	seedTasteTag(t, pool, user.ID, "shoegaze", 10)
	seedCandidateTag(t, pool, "match", "shoegaze", 1.0)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if out[0].MBID != "loud" {
		t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
	}
}

// A user with no taste profile must still get a full deck in similarity order:
// the cold-start path, which is every user's first days (rule #131).
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	twoCandidatePool(t, pool, user.ID)
	// Candidate tags exist, but the user has no taste tags to match them.
	seedCandidateTag(t, pool, "match", "shoegaze", 1.0)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 2 {
		t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
	}
	if out[0].MBID != "loud" {
		t.Errorf("first = %q, want loud", out[0].MBID)
	}
}

// An untagged candidate must never be dropped or sunk just because another
// candidate has tags — permanently-partial coverage (#2376) must not become a
// permanent ranking penalty.
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	twoCandidatePool(t, pool, user.ID)

	seedTasteTag(t, pool, user.ID, "shoegaze", 10)
	seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
	// "loud" is deliberately left with NO cached tags at all.

	// Compared against the same request with the term disabled, rather than
	// against a literal. The pool score is signal-weighted by the seed query
	// (ln(1+signal) x similarity), so hardcoding a number here would assert
	// against the seeding maths — a different layer — and break whenever that
	// changes. The property under test is only that the blend leaves an
	// untagged candidate alone.
	blended, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
	if err != nil {
		t.Fatalf("SuggestArtists(blended): %v", err)
	}
	baseline, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
	if err != nil {
		t.Fatalf("SuggestArtists(baseline): %v", err)
	}
	if len(blended) != 2 {
		t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(blended))
	}

	scoreOf := func(out []ArtistSuggestion, mbid string) (float64, bool) {
		for _, s := range out {
			if s.MBID == mbid {
				return s.Score, true
			}
		}
		return 0, false
	}
	want, ok := scoreOf(baseline, "loud")
	if !ok {
		t.Fatal("untagged candidate missing from the baseline deck")
	}
	got, ok := scoreOf(blended, "loud")
	if !ok {
		t.Fatal("untagged candidate vanished once the tag term was enabled")
	}
	if got != want {
		t.Errorf("untagged score = %v, want %v (identical to the term-disabled run)", got, want)
	}
	// Sanity: the tagged candidate DID move, so the comparison above is
	// meaningful rather than both runs being trivially identical.
	if tagged, _ := scoreOf(blended, "match"); tagged == mustScore(t, baseline, "match") {
		t.Error("the tagged candidate's score did not change — the term did nothing")
	}
}

func mustScore(t *testing.T, out []ArtistSuggestion, mbid string) float64 {
	t.Helper()
	for _, s := range out {
		if s.MBID == mbid {
			return s.Score
		}
	}
	t.Fatalf("no candidate %q in %v", mbid, out)
	return 0
}
