// Package recommendation implements the weighted-shuffle scoring engine
// from spec §6. The Score function is pure and takes an injectable RNG so
// tests can pin jitter to deterministic values.
package recommendation

import (
	"time"
)

// ScoringInputs are the per-track facts the score function consumes.
// ContextualMatchScore is in [0, 1] — max similarity between the user's
// current session vector and any non-seed contextual_like row for this
// track. Set by LoadCandidates after a bulk fetch.
// SimilarityScore is in [0, 1]; 0 when no signal (random fill).
type ScoringInputs struct {
	IsGeneralLiked       bool
	LastPlayedAt         *time.Time // nil = never played
	PlayCount            int        // total play_events
	SkipCount            int        // play_events with was_skipped=true
	ContextualMatchScore float64    // [0, 1]; 0 when no signal
	SimilarityScore      float64    // [0, 1]; 0 when no signal (random fill)
	// TasteMatchScore is the candidate's fit to the user's learned taste
	// profile (#796 phase 2), in [-1, +1]: positive draws a track toward the
	// user's taste, negative reflects passive avoidance, 0 when there's no
	// profile signal (cold start / artist+tags absent from the profile).
	TasteMatchScore float64
	// ContextAffinityScore is the candidate artist's time-of-day/weekday
	// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
	// artist's plays concentrate in the current daypart × weekday-type cell
	// more than the user's baseline, negative when under-represented, 0 when
	// there's no history (cold-start neutral).
	ContextAffinityScore float64
}

// ScoringWeights are the operator-tunable knobs. Defaults live in
// config.RecommendationConfig and are propagated here per request.
type ScoringWeights struct {
	BaseWeight        float64
	LikeBoost         float64
	RecencyWeight     float64
	SkipPenalty       float64
	JitterMagnitude   float64
	ContextWeight     float64
	SimilarityWeight  float64
	TasteWeight       float64
	ContextTimeWeight float64
}

// Score computes the weighted-shuffle score per spec §6:
//
//	score = base
//	      + (is_general_liked ? LikeBoost : 0)
//	      + recency_decay * RecencyWeight
//	      - skip_ratio * SkipPenalty
//	      + contextual_match_score * ContextWeight
//	      + similarity_score * SimilarityWeight
//	      + taste_match_score * TasteWeight
//	      + context_affinity_score * ContextTimeWeight
//	      + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed
// value in tests.
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
	s := w.BaseWeight
	if in.IsGeneralLiked {
		s += w.LikeBoost
	}
	s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
	s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
	s += in.ContextualMatchScore * w.ContextWeight
	s += in.SimilarityScore * w.SimilarityWeight
	s += in.TasteMatchScore * w.TasteWeight
	s += in.ContextAffinityScore * w.ContextTimeWeight
	s += (rng()*2 - 1) * w.JitterMagnitude
	return s
}

// recencyDecay returns a value in [0, 1]:
//   - never played → 1.0 (cold-start tracks compete favorably with stale ones).
//   - age < 30 days → linear ramp age_days / 30.
//   - age ≥ 30 days → 1.0 (capped).
//
// Negative ages (clock skew) clamp to 0 to avoid math weirdness.
func recencyDecay(lastPlayed *time.Time, now time.Time) float64 {
	if lastPlayed == nil {
		return 1.0
	}
	age := now.Sub(*lastPlayed)
	days := age.Hours() / 24
	if days < 0 {
		return 0.0
	}
	if days >= 30 {
		return 1.0
	}
	return days / 30.0
}

// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0
// rather than dividing by zero, so they aren't penalized.
func skipRatio(plays, skips int) float64 {
	if plays == 0 {
		return 0.0
	}
	return float64(skips) / float64(plays)
}
