// tagoverlap.go — the taste-tag term for the Discover request surface
// (#2377, milestone #268 slice 6).
//
// Slices 1-2 made the deck stop repeating; this is what makes it *relevant*.
// Before this, a candidate's only claim on a slot was "some artist you play is
// similar to it" — a graph-adjacency fact that says nothing about whether the
// music sounds like anything you actually like. Here the candidate's own
// folksonomy tags (cached by slice 5) are compared against the user's
// taste-profile tags, so the surface can rank on "matches the sound you like"
// and say WHY.
//
// Pure by design — no DB, no clock — so the scoring rules are unit-testable in
// the fast lane rather than behind the integration gate.
package recommendation

import "sort"

// maxMatchedTags caps the "matches: …" explanation. Three is what the existing
// seed attribution shows, and a longer list stops being a reason and becomes a
// tag dump.
const maxMatchedTags = 3

// TagWeights is a tag → weight map. Both sides of the comparison use it:
// candidate tags (normalized [0,1] by the enrichment providers) and the user's
// taste-profile tags (accumulated, unbounded — normalized here).
type TagWeights map[string]float64

// tagOverlap scores how much of a candidate's tag identity the user actually
// likes, in [0,1], and returns the matched tags ordered by contribution.
//
// The measure is: of this candidate's total tag mass, what share sits on tags
// the user likes — each weighted by how strongly they like it?
//
//	overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight
//
// Normalizing the taste side by the user's STRONGEST tag is what makes this
// comparable across users: taste weights accumulate with listening, so a
// heavy listener's raw numbers dwarf a new user's while meaning the same
// thing — "this is my favourite tag". Dividing by the candidate's own total
// mass makes it comparable across candidates, so a densely-tagged artist
// can't out-score a sparsely-tagged one just by having more tags.
//
// Returns (0, nil) when either side is empty. That is the load-bearing
// degradation path: tag coverage for out-of-library candidates is permanently
// partial (#2376), and a cold-start user has no taste tags at all. Both must
// leave the candidate's similarity score untouched rather than sink it —
// rule #131, tiered degradation, never vanish-or-nothing.
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
	if len(candidate) == 0 || len(taste) == 0 {
		return 0, nil
	}

	maxTaste := 0.0
	for _, w := range taste {
		if w > maxTaste {
			maxTaste = w
		}
	}
	// Every taste weight <= 0 carries no preference to match against. Guarding
	// here also avoids dividing by zero below.
	if maxTaste <= 0 {
		return 0, nil
	}

	totalMass := 0.0
	for _, w := range candidate {
		// Negative or zero candidate weights would let a tag subtract from the
		// denominator and inflate the ratio past 1.
		if w > 0 {
			totalMass += w
		}
	}
	if totalMass <= 0 {
		return 0, nil
	}

	type contribution struct {
		tag   string
		score float64
	}
	var matched []contribution
	sum := 0.0
	for tag, candWeight := range candidate {
		if candWeight <= 0 {
			continue
		}
		tasteWeight, ok := taste[tag]
		if !ok || tasteWeight <= 0 {
			continue
		}
		c := candWeight * (tasteWeight / maxTaste)
		sum += c
		matched = append(matched, contribution{tag: tag, score: c})
	}
	if len(matched) == 0 {
		return 0, nil
	}

	// Strongest contribution first; tag name breaks ties so the explanation is
	// deterministic for a given input rather than map-iteration order.
	sort.Slice(matched, func(i, j int) bool {
		if matched[i].score != matched[j].score {
			return matched[i].score > matched[j].score
		}
		return matched[i].tag < matched[j].tag
	})
	names := make([]string, 0, min(len(matched), maxMatchedTags))
	for i := 0; i < len(matched) && i < maxMatchedTags; i++ {
		names = append(names, matched[i].tag)
	}
	return sum / totalMass, names
}

// applyTagOverlap re-scores and re-orders a candidate pool by taste-tag
// overlap, stamping the matched tags onto each suggestion for the UI.
//
// The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — not additive,
// and the difference is the whole safety argument:
//
//   - A candidate with no tags has overlap 0, so its score is EXACTLY
//     unchanged. Partial tag coverage costs a candidate nothing.
//   - Nothing can leapfrog on tags alone. An additive term with a large
//     weight would let a near-zero-similarity artist outrank a strong match
//     just for sharing a popular tag, which reads as noise to the user.
//   - weight 0 disables the feature completely and restores pure similarity
//     order, so the operator's knob has a real off position.
//
// Callers must pass the pool in similarity order; it is returned in blended
// order. Mutates the elements in place (they're the caller's own slice built
// per request), and re-sorts, because selectSuggestions downstream relies on
// score order for its head/tail split.
func applyTagOverlap(
	pool []ArtistSuggestion, candidateTags map[string]TagWeights, taste TagWeights, weight float64,
) []ArtistSuggestion {
	// A zero weight is the operator turning the feature off. Skip the work
	// AND the re-sort so the ordering is bit-for-bit the pre-slice-6 result.
	if weight == 0 || len(taste) == 0 {
		return pool
	}
	for i := range pool {
		overlap, matched := tagOverlap(candidateTags[pool[i].MBID], taste)
		pool[i].MatchedTags = matched
		pool[i].TagOverlap = overlap
		pool[i].Score *= 1 + weight*overlap
	}
	sort.SliceStable(pool, func(i, j int) bool {
		if pool[i].Score != pool[j].Score {
			return pool[i].Score > pool[j].Score
		}
		// Stable tiebreak by MBID. Without it, two candidates on equal scores
		// could swap between requests within the same day, which the daily
		// rotation (#2373) exists to prevent.
		return pool[i].MBID < pool[j].MBID
	})
	return pool
}
