//! Stateless helpers that bridge the signal ledger to the scoring pipeline.
//!
//! These functions read aggregations from the ledger and apply gate/normalization
//! logic. They depend on `ScoredCandidate` (from `context`) and profile types,
//! but contain no executor state.

use super::context::ScoredCandidate;
use crate::{
    load::DegradationLevel,
    ranking::profile::{Exclude, Gate, SignalAgg},
    schema::{EntityId, Window},
    signals::SignalLedger,
};

// -- Degradation window substitution ------------------------------------------

/// Coarsest velocity bucket used when `DegradationLevel::coarsens_aggregates()`.
///
/// Under coarse-aggregate degradation, a `SignalAgg::Velocity` read is forced to
/// this window (the coarsest velocity bucket) to cut fine-grained ledger scan
/// cost. Exposed at module scope so the social-graph trending path in
/// [`super::scoring`] -- which reads velocity directly off the per-user signal
/// index rather than through [`read_agg`] -- can read the *same* window and stay
/// in lockstep with the substitution rule here.
pub(super) const COARSE_VELOCITY_WINDOW: Window = Window::TwentyFourHours;

/// Coarsest count bucket used when `DegradationLevel::coarsens_aggregates()`.
///
/// Under coarse-aggregate degradation, a `SignalAgg::Value` read is forced to the
/// warm-tier all-time count (O(1)) instead of a fine-grained windowed scan.
pub(super) const COARSE_VALUE_WINDOW: Window = Window::AllTime;

// -- Signal reading -----------------------------------------------------------

/// Read a signal aggregation for a candidate against an explicit query clock.
///
/// Propagates the ledger's schema error when `signal` is not defined, so a
/// profile that slipped a typo'd signal name past registration fails loud
/// instead of silently scoring 0.0. Missing *data* (no recorded signals) is
/// not an error -- the ledger returns 0 / 0.0 / `None` for those, which we map
/// to 0.0.
///
/// `now_ns` is the query's logical clock, captured **once** per query by the
/// executor and threaded into every ledger read so the whole candidate set is
/// decayed/aged to one consistent "now". Without it each candidate would read
/// `Timestamp::now()` independently and re-running the identical query against
/// an unchanged ledger would yield slightly different scores (Accuracy-W,
/// M0-M10 review): all the time-dependent reads here route through the ledger's
/// explicit-clock `_at` variants.
///
/// Under `DegradationLevel::CoarseAggregates` (or `NoDiversity`), windows are
/// substituted to reduce ledger scan cost:
/// - `SignalAgg::Value` → `Window::AllTime` (warm-tier count, O(1))
/// - `SignalAgg::Velocity` → `Window::TwentyFourHours` (coarsest velocity bucket)
///
/// # Errors
///
/// - `TidalError::Schema(UnknownSignalType)` if `signal` is not in the schema.
/// - `TidalError::Internal` if `agg` is `Ratio` / `RelativeVelocity`: the
///   registry rejects those at profile registration, so reaching this arm is a
///   tidalDB invariant violation, not a recoverable runtime condition.
pub(crate) fn read_agg(
    entity_id: EntityId,
    signal: &str,
    agg: &SignalAgg,
    window: Window,
    ledger: &SignalLedger,
    degradation: DegradationLevel,
    now_ns: u64,
) -> crate::Result<f64> {
    let window = if degradation.coarsens_aggregates() {
        match agg {
            SignalAgg::Value => COARSE_VALUE_WINDOW,
            SignalAgg::Velocity => COARSE_VELOCITY_WINDOW,
            _ => window,
        }
    } else {
        window
    };
    match agg {
        SignalAgg::Value => {
            #[allow(clippy::cast_precision_loss)]
            let count = ledger.read_windowed_count_at(entity_id, signal, window, now_ns)? as f64;
            Ok(count)
        }
        SignalAgg::Velocity => ledger.read_velocity_at(entity_id, signal, window, now_ns),
        // `DecayScore` intentionally ignores `window`. A decay score is an O(1)
        // running quantity -- the exponentially-weighted sum maintained in the
        // hot tier (`HotSignalState`) -- decayed forward from its last update to
        // the read time. It is not derived from a windowed bucket scan, so there
        // is no per-window decay score to select: the half-life (encoded in the
        // signal's decay rate, here index 0) fully determines how the score ages,
        // not the `Window` argument. The argument is accepted only so every
        // aggregation arm shares one signature; passing different windows for a
        // `DecayScore` term yields the same value by construction.
        SignalAgg::DecayScore => Ok(ledger
            .read_decay_score_at(entity_id, signal, 0, now_ns)?
            .unwrap_or(0.0)),
        SignalAgg::Ratio | SignalAgg::RelativeVelocity => {
            // Not yet implemented -- planned for M3 when cross-signal reads are
            // available. The profile registry rejects these aggregations up
            // front (`ProfileError::UnsupportedAggregation`), so reaching this
            // arm means a profile bypassed validation: surface it as an
            // internal invariant violation rather than silently scoring 0.0.
            Err(crate::TidalError::internal(
                "read_agg",
                format!("aggregation '{}' is not implemented", agg.label()),
            ))
        }
    }
}

/// Read a fixed sort-mode signal, treating a signal the schema does not define
/// as 0.0 rather than an error.
///
/// Sort modes (`Hot`, `Trending`, `MostViewed`, ...) read a fixed generic
/// signal vocabulary (`view`, `like`, `share`, `completion`, ...). Unlike a
/// profile's user-declared gate/boost terms -- which the registry validates
/// against the schema and which therefore fail loud here -- these sort-mode
/// reads are optional infrastructure: any given application schema legitimately
/// defines only a subset of the generic vocabulary (e.g. a consumer's schema
/// has no `view` signal). A missing signal contributes 0.0 to the sort score; every
/// other error still propagates.
///
/// `now_ns` is the query clock threaded by the executor (see [`read_agg`]).
///
/// # Errors
///
/// Propagates any non-`UnknownSignalType` [`read_agg`] error (notably the
/// `Ratio` / `RelativeVelocity` invariant violation).
pub(super) fn read_agg_for_sort(
    entity_id: EntityId,
    signal: &str,
    agg: &SignalAgg,
    window: Window,
    ledger: &SignalLedger,
    degradation: DegradationLevel,
    now_ns: u64,
) -> crate::Result<f64> {
    match read_agg(entity_id, signal, agg, window, ledger, degradation, now_ns) {
        Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
            Ok(0.0)
        }
        other => other,
    }
}

// -- Gate checking ------------------------------------------------------------

/// Check whether a candidate passes all gate thresholds.
///
/// Gates are correctness filters -- always use `Full` degradation so that
/// window precision is never sacrificed for gate evaluation. `now_ns` is the
/// query clock threaded by the executor (see [`read_agg`]) so the gate decision
/// ages every candidate to the same "now" as the score.
///
/// # Errors
///
/// Propagates any [`read_agg`] error (unknown signal / unsupported
/// aggregation), so a misconfigured gate fails the whole query loudly instead
/// of silently excluding every candidate.
pub(super) fn passes_gates(
    entity_id: EntityId,
    gates: &[Gate],
    ledger: &SignalLedger,
    now_ns: u64,
) -> crate::Result<bool> {
    for gate in gates {
        let value = read_agg(
            entity_id,
            &gate.signal,
            &gate.agg,
            gate.window,
            ledger,
            DegradationLevel::Full,
            now_ns,
        )?;
        if value < gate.min_threshold {
            return Ok(false);
        }
    }
    Ok(true)
}

/// Check whether a candidate survives all hard-exclusion rules.
///
/// Implements spec 09-ranking-scoring.md Stage 2 (Hard Exclusion). A candidate
/// is *removed* (returns `false`) the moment any `Exclude` term's aggregation
/// strictly exceeds its `above` threshold -- e.g. `Exclude { signal: "hide",
/// above: 0.0 }` drops any item the user has hidden at all. This mirrors
/// [`passes_gates`] (a hard filter, not a soft score adjustment) but inverts the
/// comparison: gates demand a *minimum*, excludes impose a *maximum*.
///
/// Like gates, excludes are correctness filters: they always read at
/// [`DegradationLevel::Full`] so window precision is never traded away while
/// deciding whether content the user asked to never see can appear. `now_ns` is
/// the query clock threaded by the executor (see [`read_agg`]) so the exclusion
/// decision ages every candidate to the same "now" as the score.
///
/// # Errors
///
/// Propagates any [`read_agg`] error (unknown signal / unsupported aggregation),
/// so a misconfigured exclude fails the whole query loudly instead of silently
/// admitting content that should have been removed.
pub(super) fn passes_excludes(
    entity_id: EntityId,
    excludes: &[Exclude],
    ledger: &SignalLedger,
    now_ns: u64,
) -> crate::Result<bool> {
    for exclude in excludes {
        let value = read_agg(
            entity_id,
            &exclude.signal,
            &exclude.agg,
            exclude.window,
            ledger,
            DegradationLevel::Full,
            now_ns,
        )?;
        if value > exclude.above {
            return Ok(false);
        }
    }
    Ok(true)
}

// -- Normalization ------------------------------------------------------------

/// Min-max normalize candidate scores to `[0.0, 1.0]`.
///
/// If all (finite) candidates have the same score (`max_score == min_score`),
/// they are all set to **0.5** per spec 09-ranking-scoring.md §8.2 -- the
/// neutral "no signal differentiates these" midpoint, not the maximally-
/// confident `1.0`. Ordering is unaffected (every score is equal); the reported
/// `score` field is what callers surface for explainability/thresholding, so a
/// degenerate all-equal set must read as neutral rather than top-confidence.
///
/// # Infinite "sort last / first" sentinels
///
/// Several sort modes emit `f64::NEG_INFINITY` (or `f64::INFINITY`) as a
/// deliberate "sort last / first" sentinel for missing metadata (`Shortest`,
/// `Longest`, `DateSaved`). `f64::total_cmp` (used by `finalize`'s sort) already
/// ranks those infinities at the correct extreme, so the *order* is right by the
/// time we get here. Normalization must keep `score` **monotonic with that rank**.
///
/// The min/max range is therefore computed over the **finite** scores only, and
/// the infinite sentinels are mapped directly to the matching end of the output
/// range afterwards: `NEG_INFINITY -> 0.0` (rank-last bottom), `INFINITY -> 1.0`
/// (rank-first top). This fixes the anomaly where clamping `NEG_INFINITY -> 0.0`
/// *before* taking the min let a missing item become the maximum on a negated
/// scale: for `Shortest`, every present item has a *negative* score (`-duration`),
/// so a pre-clamped `0.0` floated above them and the last-ranked (missing) item
/// reported the highest score `1.0`. Computing the range over finite scores and
/// folding sentinels to the correct end keeps the reported score consistent with
/// the rank for the whole class of `±Inf`-sentinel sorts.
///
/// `NaN` has no defined order and is neutralized to `0.0` at score *construction*
/// (`finite_score`) before it can reach the sort, so it does not appear here; any
/// `NaN` that slipped through is treated as a finite `0.0` for robustness.
pub(super) fn normalize(candidates: &mut [ScoredCandidate]) {
    if candidates.is_empty() {
        return;
    }
    // Defensive: a stray NaN (which has no order) is mapped to the neutral 0.0,
    // matching `finite_score`. `±Inf` sentinels are intentionally preserved --
    // they are folded to the correct end of the output range below.
    for c in candidates.iter_mut() {
        if c.score.is_nan() {
            c.score = 0.0;
        }
    }
    // Range is measured over FINITE scores only, so an infinite sentinel never
    // skews min/max (and so never becomes the artificial extreme of the range).
    let mut min = f64::INFINITY;
    let mut max = f64::NEG_INFINITY;
    for c in candidates.iter() {
        if c.score.is_finite() {
            min = min.min(c.score);
            max = max.max(c.score);
        }
    }
    // No finite scores at all (every candidate is a ±Inf sentinel): fold each to
    // its end of the range directly. With no finite reference, NEG_INFINITY -> 0,
    // INFINITY -> 1. (`is_sign_positive()` distinguishes the two infinities.)
    if !min.is_finite() {
        for c in candidates.iter_mut() {
            c.score = if c.score.is_sign_positive() { 1.0 } else { 0.0 };
        }
        return;
    }
    let range = max - min;
    for c in candidates.iter_mut() {
        c.score = if c.score.is_infinite() {
            // Sentinel folds to the matching end of the output range:
            // +Inf "sort first" -> top (1.0); -Inf "sort last" -> bottom (0.0).
            if c.score.is_sign_positive() { 1.0 } else { 0.0 }
        } else if range < f64::EPSILON {
            // All candidates scored equally: neutral midpoint per spec §8.2.
            0.5
        } else {
            (c.score - min) / range
        };
    }
}

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

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)]
mod tests {
    use super::*;
    use crate::{
        // Shared ledger fixtures (DRY-S, M0-M10 review).
        ranking::test_fixtures::{ledger_for, seeded_ledger},
        schema::Timestamp,
    };

    #[test]
    fn read_agg_unimplemented_aggregation_errors() {
        let ledger = seeded_ledger();

        let entity_id = EntityId::new(1);
        // SignalAgg::Ratio is not implemented; reaching read_agg with it is an
        // internal invariant violation (registration rejects it), so it errors
        // rather than silently scoring 0.0.
        let result = read_agg(
            entity_id,
            "view",
            &SignalAgg::Ratio,
            Window::AllTime,
            &ledger,
            DegradationLevel::Full,
            Timestamp::now().as_nanos(),
        );
        assert!(matches!(result, Err(crate::TidalError::Internal(_))));
    }

    #[test]
    fn read_agg_unknown_signal_errors() {
        let ledger = ledger_for(&["view"]);
        // "nonexistent" is not in the schema -> schema error propagates.
        let result = read_agg(
            EntityId::new(1),
            "nonexistent",
            &SignalAgg::Value,
            Window::OneHour,
            &ledger,
            DegradationLevel::Full,
            Timestamp::now().as_nanos(),
        );
        assert!(matches!(result, Err(crate::TidalError::Schema(_))));
    }

    #[test]
    fn passes_gates_below_threshold_excluded() {
        let ledger = ledger_for(&["view"]);
        let entity_id = EntityId::new(1);
        // Record 3 signals; windowed count = 3, below threshold of 5.
        for _ in 0..3 {
            ledger
                .record_signal("view", entity_id, 1.0, Timestamp::now())
                .unwrap();
        }
        let gates = vec![Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::OneHour,
            min_threshold: 5.0,
        }];
        // count=3 < threshold=5 -> candidate excluded.
        assert!(!passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
    }

    #[test]
    fn passes_gates_at_threshold_included() {
        let ledger = ledger_for(&["view"]);
        let entity_id = EntityId::new(1);
        // Record exactly 5 signals; windowed count = 5, meets threshold exactly.
        for _ in 0..5 {
            ledger
                .record_signal("view", entity_id, 1.0, Timestamp::now())
                .unwrap();
        }
        let gates = vec![Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::OneHour,
            min_threshold: 5.0,
        }];
        // count=5 >= threshold=5 -> candidate included.
        assert!(passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
    }

    #[test]
    fn normalize_single_element() {
        let mut candidates = vec![ScoredCandidate {
            entity_id: EntityId::new(1),
            score: 42.0,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        }];
        normalize(&mut candidates);
        // A single candidate is a degenerate all-equal set (range == 0), so it
        // normalizes to the neutral midpoint 0.5 per spec §8.2, not 1.0.
        assert_eq!(candidates[0].score, 0.5);
    }

    fn candidate(id: u64, score: f64) -> ScoredCandidate {
        ScoredCandidate {
            entity_id: EntityId::new(id),
            score,
            signal_snapshot: vec![],
            creator_id: None,
            format: None,
        }
    }

    #[test]
    fn normalize_neg_inf_sentinel_on_negated_scale_folds_to_bottom() {
        // Regression (Accuracy-W): `Shortest` scores present items as `-duration`
        // (all negative) and a missing item as NEG_INFINITY. The candidates arrive
        // already sorted descending: -60 (60s, shortest), -120 (120s), -Inf
        // (missing). Before the fix, NEG_INFINITY was clamped to 0.0 *before*
        // taking the min, so it became the maximum on the negated scale and
        // normalized to 1.0 -- the last-ranked item reporting the highest score.
        let mut candidates = vec![
            candidate(3, -60.0),
            candidate(1, -120.0),
            candidate(2, f64::NEG_INFINITY),
        ];
        normalize(&mut candidates);

        // Finite range is [-120, -60]; the shortest (−60) maps to 1.0, the 120s
        // (−120) to 0.0, and the missing sentinel folds to the bottom (0.0).
        assert_eq!(candidates[0].score, 1.0);
        assert_eq!(candidates[1].score, 0.0);
        assert_eq!(candidates[2].score, 0.0);
        // Score stays monotonic with rank -- the tail never outscores the head.
        assert!(candidates.last().unwrap().score <= candidates.first().unwrap().score);
        for w in candidates.windows(2) {
            assert!(w[0].score >= w[1].score);
        }
    }

    #[test]
    fn normalize_pos_inf_sentinel_folds_to_top() {
        // The `+Inf` "sort first" sentinel folds to the top of the output range,
        // while finite scores are min-max normalized over their own range.
        let mut candidates = vec![
            candidate(1, f64::INFINITY),
            candidate(2, 10.0),
            candidate(3, 2.0),
        ];
        normalize(&mut candidates);
        assert_eq!(candidates[0].score, 1.0); // +Inf -> top
        assert_eq!(candidates[1].score, 1.0); // max finite -> top of finite range
        assert_eq!(candidates[2].score, 0.0); // min finite -> bottom
    }

    #[test]
    fn normalize_all_infinite_sentinels_fold_to_ends() {
        // No finite scores at all: each sentinel folds directly to its end.
        let mut candidates = vec![candidate(1, f64::INFINITY), candidate(2, f64::NEG_INFINITY)];
        normalize(&mut candidates);
        assert_eq!(candidates[0].score, 1.0);
        assert_eq!(candidates[1].score, 0.0);
    }

    #[test]
    fn normalize_all_nan_folds_to_neutral_midpoint() {
        // Every NaN is neutralized to 0.0 first, making this a degenerate
        // all-equal set (range == 0), which normalizes to the neutral 0.5 per
        // spec §8.2 -- not the maximally-confident 1.0.
        let mut candidates: Vec<ScoredCandidate> = (1u64..=3)
            .map(|i| ScoredCandidate {
                entity_id: EntityId::new(i),
                score: f64::NAN,
                signal_snapshot: vec![],
                creator_id: None,
                format: None,
            })
            .collect();
        normalize(&mut candidates);
        for c in &candidates {
            assert_eq!(
                c.score, 0.5,
                "an all-NaN (all-equal) set should fold to the neutral 0.5 midpoint"
            );
        }
    }
}
