package recommendation

import (
	"fmt"
	"testing"
)

// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
// in the fast lane rather than behind the integration gate.
//
// Fixtures here are deliberately SKEWED — candidates that match the taste
// profile to clearly different degrees. An evenly-matching pool cannot exercise
// a re-ranking at all: every candidate gets the same multiplier and the order is
// unchanged whether the blend works or not. Slice 2's first diversity test had
// exactly that defect, so it's called out explicitly here.

func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
	// Every unit of the candidate's tag mass sits on the user's single
	// strongest tag → the whole mass matches at full strength.
	got, matched := tagOverlap(
		TagWeights{"shoegaze": 1.0},
		TagWeights{"shoegaze": 5.0},
	)
	if got != 1.0 {
		t.Errorf("overlap = %v, want 1.0", got)
	}
	if len(matched) != 1 || matched[0] != "shoegaze" {
		t.Errorf("matched = %v, want [shoegaze]", matched)
	}
}

func TestTagOverlap_NoSharedTagsScoresZero(t *testing.T) {
	got, matched := tagOverlap(
		TagWeights{"death metal": 1.0},
		TagWeights{"shoegaze": 5.0},
	)
	if got != 0 {
		t.Errorf("overlap = %v, want 0", got)
	}
	if matched != nil {
		t.Errorf("matched = %v, want nil", matched)
	}
}

// Half the candidate's mass is on a matching tag, and that tag is the user's
// strongest → 0.5.
func TestTagOverlap_PartialMassMatchIsProportional(t *testing.T) {
	got, _ := tagOverlap(
		TagWeights{"shoegaze": 1.0, "death metal": 1.0},
		TagWeights{"shoegaze": 5.0},
	)
	if got != 0.5 {
		t.Errorf("overlap = %v, want 0.5", got)
	}
}

// Matching a tag the user barely likes must score below matching one they love.
func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
	taste := TagWeights{"shoegaze": 10.0, "polka": 1.0}
	strong, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
	weak, _ := tagOverlap(TagWeights{"polka": 1.0}, taste)
	if !(strong > weak) {
		t.Errorf("strong=%v weak=%v — a favourite tag must outscore a marginal one", strong, weak)
	}
	if weak != 0.1 { // 1.0 * (1/10) / 1.0
		t.Errorf("weak = %v, want 0.1", weak)
	}
}

// THE degradation path. Tag coverage for out-of-library candidates is
// permanently partial (#2376) and cold-start users have no taste tags, so both
// must be scored 0 — never negative, never dropped (rule #131).
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
	cases := []struct {
		name             string
		candidate, taste TagWeights
	}{
		{"no candidate tags", nil, TagWeights{"shoegaze": 5}},
		{"no taste tags", TagWeights{"shoegaze": 1}, nil},
		{"both empty", nil, nil},
		{"taste weights all zero", TagWeights{"shoegaze": 1}, TagWeights{"shoegaze": 0}},
		{"candidate weights all zero", TagWeights{"shoegaze": 0}, TagWeights{"shoegaze": 5}},
	}
	for _, c := range cases {
		got, matched := tagOverlap(c.candidate, c.taste)
		if got != 0 {
			t.Errorf("%s: overlap = %v, want 0", c.name, got)
		}
		if matched != nil {
			t.Errorf("%s: matched = %v, want nil", c.name, matched)
		}
	}
}

// A densely-tagged artist must not out-score a focused one merely by having
// more tags — that's what dividing by the candidate's own mass buys.
func TestTagOverlap_IsNotAPopularityContest(t *testing.T) {
	taste := TagWeights{"shoegaze": 5.0}
	focused, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
	sprawling, _ := tagOverlap(TagWeights{
		"shoegaze": 1.0, "rock": 1.0, "alternative": 1.0, "90s": 1.0,
	}, taste)
	if !(focused > sprawling) {
		t.Errorf("focused=%v sprawling=%v — extra unmatched tags must dilute, not add",
			focused, sprawling)
	}
}

// Taste weights accumulate with listening, so raw magnitudes differ wildly
// between a new user and a heavy one while meaning the same thing. Normalizing
// by the user's own strongest tag is what makes the score comparable.
func TestTagOverlap_IsInvariantToTasteMagnitude(t *testing.T) {
	candidate := TagWeights{"shoegaze": 1.0, "dream pop": 1.0}
	newUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2.0, "polka": 1.0})
	heavyUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2000.0, "polka": 1000.0})
	if newUser != heavyUser {
		t.Errorf("newUser=%v heavyUser=%v — scaling all taste weights must not change the result",
			newUser, heavyUser)
	}
}

func TestTagOverlap_MatchedTagsAreOrderedAndCapped(t *testing.T) {
	got, matched := tagOverlap(
		TagWeights{"a": 0.2, "b": 0.9, "c": 0.5, "d": 0.7},
		TagWeights{"a": 10, "b": 10, "c": 10, "d": 10},
	)
	if got <= 0 {
		t.Fatalf("overlap = %v, want > 0", got)
	}
	// All taste weights equal, so candidate weight decides: b(.9) d(.7) c(.5).
	want := []string{"b", "d", "c"}
	if fmt.Sprint(matched) != fmt.Sprint(want) {
		t.Errorf("matched = %v, want %v (strongest first, capped at %d)",
			matched, want, maxMatchedTags)
	}
}

// --- applyTagOverlap ---

func poolFor(specs ...struct {
	mbid  string
	score float64
}) []ArtistSuggestion {
	out := make([]ArtistSuggestion, 0, len(specs))
	for _, s := range specs {
		out = append(out, ArtistSuggestion{MBID: s.mbid, Name: s.mbid, Score: s.score})
	}
	return out
}

type spec = struct {
	mbid  string
	score float64
}

// The payoff, and the reason the fixture is skewed: a taste-matching candidate
// that started BELOW another must be able to overtake it. With an evenly
// matching pool this assertion could not fail.
func TestApplyTagOverlap_TasteMatchOvertakesAStrongerNonMatch(t *testing.T) {
	pool := poolFor(spec{"loud", 1.0}, spec{"match", 0.7})
	candTags := map[string]TagWeights{
		"loud":  {"death metal": 1.0},
		"match": {"shoegaze": 1.0},
	}
	taste := TagWeights{"shoegaze": 5.0}

	got := applyTagOverlap(pool, candTags, taste, 1.0)
	if got[0].MBID != "match" {
		t.Errorf("first = %q, want match (0.7×2 = 1.4 beats 1.0×1)", got[0].MBID)
	}
	if got[0].MatchedTags == nil {
		t.Error("matched tags not stamped onto the winner")
	}
}

// An untagged candidate keeps its score EXACTLY. This is the multiplicative
// blend's whole safety argument: partial coverage costs a candidate nothing.
func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
	pool := poolFor(spec{"untagged", 0.9})
	got := applyTagOverlap(pool, map[string]TagWeights{}, TagWeights{"shoegaze": 5}, 1.0)
	if got[0].Score != 0.9 {
		t.Errorf("score = %v, want 0.9 exactly (no tags must not penalise)", got[0].Score)
	}
	if got[0].MatchedTags != nil {
		t.Errorf("matched = %v, want nil", got[0].MatchedTags)
	}
}

// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
// pre-slice-6 result, not merely similar.
func TestApplyTagOverlap_ZeroWeightIsAnExactNoOp(t *testing.T) {
	pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7})
	candTags := map[string]TagWeights{"b": {"shoegaze": 1.0}}
	got := applyTagOverlap(pool, candTags, TagWeights{"shoegaze": 5.0}, 0)
	if got[0].MBID != "a" || got[0].Score != 1.0 || got[1].Score != 0.7 {
		t.Errorf("weight 0 changed the pool: %+v", got)
	}
	// And it must not stamp matched tags either — the UI would otherwise
	// explain a boost that never happened.
	if got[1].MatchedTags != nil {
		t.Errorf("matched tags stamped while the feature is off: %v", got[1].MatchedTags)
	}
}

// A user with no taste profile is the cold-start case: every candidate scores
// the same multiplier of 1, so similarity order must survive intact.
func TestApplyTagOverlap_NoTasteProfileLeavesOrderIntact(t *testing.T) {
	pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7}, spec{"c", 0.4})
	candTags := map[string]TagWeights{"c": {"shoegaze": 1.0}}
	got := applyTagOverlap(pool, candTags, nil, 1.0)
	for i, want := range []string{"a", "b", "c"} {
		if got[i].MBID != want {
			t.Fatalf("order = %v..., want a b c", got[i].MBID)
		}
	}
}

// Bounded boost: even a perfect match at the maximum weight can only multiply
// by (1 + w), so a candidate cannot be catapulted arbitrarily far.
func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
	pool := poolFor(spec{"perfect", 1.0})
	got := applyTagOverlap(pool,
		map[string]TagWeights{"perfect": {"shoegaze": 1.0}},
		TagWeights{"shoegaze": 5.0}, 2.0)
	if got[0].Score != 3.0 {
		t.Errorf("score = %v, want 3.0 (1.0 × (1 + 2×1))", got[0].Score)
	}
	if got[0].TagOverlap != 1.0 {
		t.Errorf("TagOverlap = %v, want 1.0", got[0].TagOverlap)
	}
}

// Equal blended scores must resolve deterministically, or two candidates could
// swap between requests inside one day — the exact churn the daily rotation
// (#2373) exists to prevent.
func TestApplyTagOverlap_TiesBreakDeterministically(t *testing.T) {
	first := applyTagOverlap(poolFor(spec{"zzz", 1.0}, spec{"aaa", 1.0}),
		map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
	second := applyTagOverlap(poolFor(spec{"aaa", 1.0}, spec{"zzz", 1.0}),
		map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
	if first[0].MBID != "aaa" || second[0].MBID != "aaa" {
		t.Errorf("tie order not deterministic: %q then %q", first[0].MBID, second[0].MBID)
	}
}
