package recommendation

// SimilarityWeights balances the per-axis contribution to the weighted Jaccard
// score. v1 hardcodes the defaults — operators cannot tune via YAML. If
// telemetry justifies it, expose under recommendation.similarity.* later.
type SimilarityWeights struct {
	TagsWeight    float64
	ArtistsWeight float64
}

// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
// Tags carry more signal than artists because a session's "vibe" tracks
// genre more directly than artist identity (a session can mix artists
// within a genre but rarely mixes genres).
var DefaultSimilarityWeights = SimilarityWeights{
	TagsWeight:    0.7,
	ArtistsWeight: 0.3,
}

// Similarity returns weighted-Jaccard similarity in [0, 1] between two
// session vectors. Returns 0 if either input is Seed=true (low-confidence
// vectors don't contribute to scoring).
func Similarity(a, b SessionVector, w SimilarityWeights) float64 {
	if a.Seed || b.Seed {
		return 0.0
	}
	tagJ := setJaccardKeys(a.Tags, b.Tags)
	artistJ := setJaccardSlice(a.Artists, b.Artists)
	return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight
}

// setJaccardKeys collapses two map keysets to sets and returns
// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
func setJaccardKeys(a, b map[string]int) float64 {
	if len(a) == 0 && len(b) == 0 {
		return 0.0
	}
	intersect := 0
	for k := range a {
		if _, ok := b[k]; ok {
			intersect++
		}
	}
	union := len(a) + len(b) - intersect
	if union == 0 {
		return 0.0
	}
	return float64(intersect) / float64(union)
}

// setJaccardSlice deduplicates each input slice into a set and returns
// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
func setJaccardSlice(a, b []string) float64 {
	if len(a) == 0 && len(b) == 0 {
		return 0.0
	}
	aset := make(map[string]struct{}, len(a))
	for _, x := range a {
		aset[x] = struct{}{}
	}
	bset := make(map[string]struct{}, len(b))
	for _, x := range b {
		bset[x] = struct{}{}
	}
	intersect := 0
	for k := range aset {
		if _, ok := bset[k]; ok {
			intersect++
		}
	}
	union := len(aset) + len(bset) - intersect
	if union == 0 {
		return 0.0
	}
	return float64(intersect) / float64(union)
}

// ContextualMatchScore returns the maximum Similarity between the current
// session vector and any non-seed entry in likes. Returns 0 when:
//   - current.Seed is true (no meaningful current context)
//   - likes is empty after filtering out Seed=true entries
//
// The "max" semantics means a single strong contextual match dominates
// over many weak ones — we want to surface the track because it was liked
// in *some* matching context, not because it was vaguely-liked in many.
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 {
	if current.Seed {
		return 0.0
	}
	best := 0.0
	for _, l := range likes {
		if l.Seed {
			continue
		}
		s := Similarity(current, l, w)
		if s > best {
			best = s
		}
	}
	return best
}
