//! Pure scoring formulas.
//!
//! Every function in this module is a stateless mathematical formula: it takes
//! numeric inputs and returns a score. No I/O, no struct methods, no ledger
//! access. This makes the formulas independently testable and trivially
//! verifiable against their source definitions.

/// Additive weight applied to the creator-interaction boost.
///
/// When a user has interacted with a creator, items from that creator receive
/// an additive score boost of `interaction_weight * INTERACTION_BOOST_WEIGHT`
/// before normalization. 0.3 is a tuning constant that keeps interaction
/// boosts meaningful without overwhelming the base signal score.
pub(super) const INTERACTION_BOOST_WEIGHT: f64 = 0.3;

/// Weight applied to a candidate's normalized retrieval (fused RRF) relevance
/// score when seeding the base of [`super::ProfileExecutor::compute_raw_score`].
///
/// On the SEARCH path the fused RRF score from Stage 1c is the *primary* ordering
/// signal (see `builtins::search`: "the fused RRF score from Stage 1c is the
/// primary ordering signal; the profile adds a small quality overlay on top");
/// the profile contributes only a light quality overlay. The default `search`
/// profile's overlay is `view*0.5 + like*0.8` over `DecayScore` reads. Crucially,
/// a `DecayScore` is a *running sum*, not a `[0, 1]` quantity, so the overlay for
/// a lightly-engaged item is already on the order of `~1.0`–`1.5`.
///
/// For the fused order to remain the anchor (rather than a co-equal term that
/// popularity can overturn), the relevance seed must out-scale that light overlay
/// by a wide margin. With relevance pre-normalized to `[0, 1]` by the caller, a
/// weight of `10.0` puts the relevance contribution in `[0, 10]` -- an order of
/// magnitude above the light overlay -- so the most-relevant document leads and
/// the quality overlay only reorders candidates of near-equal relevance (tie
/// breaking), exactly as the documented contract requires. It is deliberately
/// *not* unbounded: a genuinely strong quality overlay on a heavily-engaged item
/// can still nudge near-tied results, which is the intended overlay behavior.
pub(super) const RELEVANCE_BASE_WEIGHT: f64 = 10.0;

/// Additive weight applied to the preference-vector cosine boost.
///
/// When a candidate carries a content embedding, its cosine similarity to the
/// user's preference vector (in `[-1.0, 1.0]`) is multiplied by this weight and
/// added to the score before normalization. 0.3 mirrors
/// [`INTERACTION_BOOST_WEIGHT`] so embedding affinity and creator affinity
/// contribute on the same scale without overwhelming the base signal score.
pub(super) const PREFERENCE_BOOST_WEIGHT: f64 = 0.3;

/// Hot: `log10(max(upvotes - downvotes, 1)) / (age_hours + 2)^gravity`
pub(super) fn hot_score(views: f64, age_hours: f64, gravity: f64) -> f64 {
    views.max(1.0).log10() / (age_hours + 2.0).powf(gravity)
}

/// Trending: weighted sum of view and share velocity.
pub(super) fn trending_score(view_velocity: f64, share_velocity: f64) -> f64 {
    2.0f64.mul_add(share_velocity, view_velocity)
}

/// Controversial: `(pos * neg) / (pos + neg)^2`
pub(super) fn controversial_score(pos: f64, neg: f64) -> f64 {
    let denom = (pos + neg).powi(2);
    if denom < f64::EPSILON {
        0.0
    } else {
        (pos * neg) / denom
    }
}

/// Hidden gems: `quality / log10(view_count + 10)`
pub(super) fn hidden_gems_score(quality: f64, view_count: f64) -> f64 {
    quality / (view_count + 10.0).log10()
}

/// Shuffle pseudo-random draw in `[0, 1)` from a per-(user, minute, item) seed.
///
/// Spec §11.6: `shuffle_score(item) = random(seed) * quality_weight(item)` with
/// `seed = hash(user_id, timestamp_minute)` — "same results for same user within
/// 1 minute". The item id is folded into the hash *after* the seed so the per-item
/// draw varies within a single seed (otherwise every item would share one random
/// value and `quality_weight` alone would order them, collapsing the random
/// sampling the mode exists to provide). Holding `(user_id, timestamp_minute)`
/// fixed makes the whole permutation reproducible across a page refresh, and
/// changing the user *or* crossing a minute boundary reshuffles — exactly the
/// "deterministic within short time windows" contract.
///
/// `user_id == 0` is the anonymous / no-user case (RETRIEVE without `FOR USER`):
/// the draw is then a stable per-minute global permutation rather than per-user
/// variety, which is the best we can do without a user identity.
pub(super) fn shuffle_random(user_id: u64, timestamp_minute: u64, entity_id: u64) -> f64 {
    // Divide by 2^64 (named constant; `u64::MAX as f64` rounds to 2^64 anyway) so
    // the result is in [0, 1) — a uniform pseudo-random draw seeded by the tuple.
    const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
    let mut hasher = blake3::Hasher::new();
    hasher.update(&user_id.to_le_bytes());
    hasher.update(&timestamp_minute.to_le_bytes());
    hasher.update(&entity_id.to_le_bytes());
    let hash = hasher.finalize();
    let bytes = hash.as_bytes();
    let arr: [u8; 8] = [
        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
    ];
    let v = u64::from_le_bytes(arr);
    #[allow(clippy::cast_precision_loss)]
    {
        v as f64 / TWO_POW_64
    }
}

/// Shuffle quality score (spec §11.6):
/// `completion_rate * 0.5 + like_ratio * 0.3 + log10(views + 1) * 0.2`.
///
/// Inputs are read from the ledger by the caller: `completion_rate` is the
/// `completion` running decay score clamped to `[0, 1]`, `like_ratio` is
/// `likes / (views + 1)` clamped to `[0, 1]`, and `views` is the all-time view
/// count. A floor of `0.0` guards against a degenerate negative composite so the
/// `sqrt` in `shuffle_quality_weight` is always real.
pub(super) fn shuffle_quality_score(completion_rate: f64, like_ratio: f64, views: f64) -> f64 {
    let log_views = (views + 1.0).log10();
    completion_rate
        .mul_add(0.5, like_ratio.mul_add(0.3, log_views * 0.2))
        .max(0.0)
}

/// Shuffle quality weight: `sqrt(quality_score)` (spec §11.6). High-quality items
/// get a larger multiplier on their random draw, so they are *more likely* to
/// surface without being guaranteed. A zero-quality item keeps a weight of 0 and
/// therefore scores 0 (sorted last among shuffled items), matching the spec's
/// "high-quality items are more likely to appear but not guaranteed".
pub(super) fn shuffle_quality_weight(quality_score: f64) -> f64 {
    quality_score.max(0.0).sqrt()
}

/// Haversine great-circle distance in kilometres.
///
/// Computes the shortest-path distance between two points on the Earth's
/// surface given their latitude and longitude in decimal degrees.
#[must_use]
pub fn haversine_km(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
    const R: f64 = 6371.0; // Earth's mean radius in km
    let dlat = (lat2 - lat1).to_radians();
    let dlng = (lng2 - lng1).to_radians();
    let a = (lat1.to_radians().cos() * lat2.to_radians().cos())
        .mul_add((dlng / 2.0).sin().powi(2), (dlat / 2.0).sin().powi(2));
    2.0 * R * a.sqrt().asin()
}

// -- Tests --------------------------------------------------------------------

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod tests {
    use super::*;

    #[test]
    fn score_hot_decays_older_candidates() {
        // hot_score = log10(max(views, 1)) / (age_hours + 2)^gravity.
        // Older content (more hours) scores lower than newer with equal views.
        let gravity = 1.8_f64;
        let score_new = hot_score(50.0, 2.0, gravity);
        let score_old = hot_score(50.0, 48.0, gravity);
        assert!(
            score_new > score_old,
            "newer content should score higher than older content with the same view count"
        );
    }

    #[test]
    fn score_trending_uses_velocity() {
        // trending_score = view_velocity + 2.0 * share_velocity.
        // Positive velocity inputs yield a positive score.
        let score = trending_score(2.0, 1.0);
        assert!(score > 0.0);
        assert_eq!(trending_score(0.0, 0.0), 0.0);
    }

    #[test]
    fn controversial_peaks_at_balanced_pos_neg() {
        // controversial_score = (pos*neg) / (pos+neg)^2, which is maximized at
        // pos == neg (value 0.25) and falls off as the split skews either way.
        let balanced = controversial_score(50.0, 50.0);
        let skewed = controversial_score(90.0, 10.0);
        let one_sided = controversial_score(100.0, 0.0);
        assert!(
            (balanced - 0.25).abs() < 1e-9,
            "balanced pos/neg should peak at 0.25, got {balanced}"
        );
        assert!(
            balanced > skewed,
            "balanced ({balanced}) should outscore skewed ({skewed})"
        );
        assert!(
            skewed > one_sided,
            "skewed ({skewed}) should outscore one-sided ({one_sided})"
        );
    }

    #[test]
    fn controversial_zero_when_no_signals() {
        // Guarded denominator: no pos and no neg -> 0.0, not NaN.
        let score = controversial_score(0.0, 0.0);
        assert_eq!(score, 0.0);
        assert!(score.is_finite());
    }

    #[test]
    fn hidden_gems_rewards_quality_at_low_views() {
        // hidden_gems_score = quality / log10(view_count + 10). For equal quality,
        // a low-view item outscores a high-view one (the "undiscovered" bonus).
        let low_views = hidden_gems_score(0.8, 0.0);
        let high_views = hidden_gems_score(0.8, 1000.0);
        assert!(
            low_views > high_views,
            "low-view gem ({low_views}) should outscore high-view item ({high_views})"
        );
        // At view_count=0, denominator is log10(10) = 1, so score == quality.
        assert!(
            (low_views - 0.8).abs() < 1e-9,
            "score at 0 views should equal quality"
        );
    }

    #[test]
    fn shuffle_random_is_stable_for_fixed_seed_and_in_unit_range() {
        // Spec §11.6: the draw is deterministic for a fixed (user, minute, item)
        // tuple and lies in [0, 1).
        let a = shuffle_random(42, 1000, 7);
        let b = shuffle_random(42, 1000, 7);
        assert_eq!(a, b, "same (user, minute, item) must give the same draw");
        assert!((0.0..1.0).contains(&a), "draw must be in [0, 1), got {a}");
    }

    #[test]
    fn shuffle_random_varies_by_user_minute_and_item() {
        // Changing any seed component changes the draw (no fixed global hash).
        let base = shuffle_random(1, 100, 9);
        assert_ne!(base, shuffle_random(2, 100, 9), "user must change the draw");
        assert_ne!(
            base,
            shuffle_random(1, 101, 9),
            "minute must change the draw"
        );
        assert_ne!(base, shuffle_random(1, 100, 8), "item must change the draw");
    }

    #[test]
    fn shuffle_quality_weight_rewards_quality() {
        // quality_weight = sqrt(quality_score); higher quality -> larger weight.
        let low = shuffle_quality_weight(shuffle_quality_score(0.1, 0.0, 1.0));
        let high = shuffle_quality_weight(shuffle_quality_score(1.0, 1.0, 1000.0));
        assert!(high > low, "higher quality must yield a larger weight");
        // Zero quality -> zero weight -> a zero shuffle score (sorted last).
        assert_eq!(
            shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)),
            0.0
        );
        // Weight is always real (the quality floor guards the sqrt).
        assert!(shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)).is_finite());
    }

    #[test]
    fn haversine_same_point_is_zero() {
        let d = haversine_km(40.7128, -74.0060, 40.7128, -74.0060);
        assert!(d.abs() < 1e-6, "same point should be 0 km, got {d}");
    }

    #[test]
    fn haversine_nyc_to_london() {
        // NYC (40.7128, -74.0060) to London (51.5074, -0.1278)
        // Expected: ~5570 km
        let d = haversine_km(40.7128, -74.0060, 51.5074, -0.1278);
        assert!(
            (5500.0..5650.0).contains(&d),
            "NYC to London should be ~5570 km, got {d}"
        );
    }

    #[test]
    fn haversine_antipodes() {
        // North pole (90, 0) to south pole (-90, 0) = half circumference ~20015 km
        let d = haversine_km(90.0, 0.0, -90.0, 0.0);
        assert!(
            (20_000.0..20_040.0).contains(&d),
            "pole to pole should be ~20015 km, got {d}"
        );
    }
}
