//! 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 crate::load::DegradationLevel;
use crate::schema::{EntityId, Window};
use crate::signals::SignalLedger;

use super::context::ScoredCandidate;
use crate::ranking::profile::{Gate, SignalAgg};

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

/// Read a signal aggregation for a candidate. Returns 0.0 on any error or
/// missing data -- scoring must never fail, only degrade.
///
/// 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)
pub(crate) fn read_agg(
    entity_id: EntityId,
    signal: &str,
    agg: &SignalAgg,
    window: Window,
    ledger: &SignalLedger,
    degradation: DegradationLevel,
) -> f64 {
    let window = if degradation.coarsens_aggregates() {
        match agg {
            SignalAgg::Value => Window::AllTime,
            SignalAgg::Velocity => Window::TwentyFourHours,
            _ => window,
        }
    } else {
        window
    };
    match agg {
        SignalAgg::Value => {
            #[allow(clippy::cast_precision_loss)]
            let count = ledger
                .read_windowed_count(entity_id, signal, window)
                .unwrap_or(0) as f64;
            count
        }
        SignalAgg::Velocity => ledger
            .read_velocity(entity_id, signal, window)
            .unwrap_or(0.0),
        SignalAgg::DecayScore => ledger
            .read_decay_score(entity_id, signal, 0)
            .unwrap_or(None)
            .unwrap_or(0.0),
        SignalAgg::Ratio | SignalAgg::RelativeVelocity => {
            // Not yet implemented -- planned for M3 when cross-signal reads are available.
            // Returns 0.0; gates using these aggregations will fail (filter out candidates).
            tracing::warn!(
                signal = %signal,
                "SignalAgg::Ratio / RelativeVelocity not yet implemented; returning 0.0"
            );
            0.0
        }
    }
}

// -- 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.
pub(super) fn passes_gates(entity_id: EntityId, gates: &[Gate], ledger: &SignalLedger) -> bool {
    for gate in gates {
        let value = read_agg(
            entity_id,
            &gate.signal,
            &gate.agg,
            gate.window,
            ledger,
            DegradationLevel::Full,
        );
        if value < gate.min_threshold {
            return false;
        }
    }
    true
}

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

/// Min-max normalize candidate scores to `[0.0, 1.0]`.
///
/// If all candidates have the same score, they are all set to 1.0.
pub(super) fn normalize(candidates: &mut [ScoredCandidate]) {
    if candidates.is_empty() {
        return;
    }
    // Clamp non-finite scores (NaN, +/-Inf) to 0.0 before normalization.
    for c in candidates.iter_mut() {
        if !c.score.is_finite() {
            c.score = 0.0;
        }
    }
    let min = candidates
        .iter()
        .map(|c| c.score)
        .fold(f64::INFINITY, f64::min);
    let max = candidates
        .iter()
        .map(|c| c.score)
        .fold(f64::NEG_INFINITY, f64::max);
    let range = max - min;
    for c in candidates.iter_mut() {
        c.score = if range < f64::EPSILON {
            1.0
        } else {
            (c.score - min) / range
        };
    }
}

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

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp};
    use crate::signals::NoopWalWriter;

    #[test]
    fn read_agg_unimplemented_returns_zero() {
        let mut builder = SchemaBuilder::new();
        for sig in &["view", "share", "like"] {
            let _ = builder
                .signal(
                    sig,
                    EntityKind::Item,
                    DecaySpec::Exponential {
                        half_life: Duration::from_secs(7 * 24 * 3600),
                    },
                )
                .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
                .velocity(true)
                .add();
        }
        let schema = builder.build().unwrap();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

        let base_ns = 1_708_000_000_000_000_000u64;
        for i in 0u64..5 {
            let entity_id = EntityId::new(i + 1);
            let ts = Timestamp::from_nanos(base_ns - i * 3_600_000_000_000);
            ledger
                .record_signal("view", entity_id, (5 - i) as f64, ts)
                .unwrap();
            ledger
                .record_signal("share", entity_id, (i % 3) as f64, ts)
                .unwrap();
            ledger
                .record_signal("like", entity_id, (i % 2) as f64, ts)
                .unwrap();
        }

        let entity_id = EntityId::new(1);
        // SignalAgg::Ratio is not implemented in M2; always returns 0.0.
        let result = read_agg(
            entity_id,
            "view",
            &SignalAgg::Ratio,
            Window::AllTime,
            &ledger,
            DegradationLevel::Full,
        );
        assert_eq!(result, 0.0);
    }

    #[test]
    fn passes_gates_below_threshold_excluded() {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::OneHour])
            .velocity(false)
            .add();
        let schema = builder.build().unwrap();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        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));
    }

    #[test]
    fn passes_gates_at_threshold_included() {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::OneHour])
            .velocity(false)
            .add();
        let schema = builder.build().unwrap();
        let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
        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));
    }

    #[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);
        assert_eq!(candidates[0].score, 1.0);
    }

    #[test]
    fn normalize_all_nan_clamps_to_one() {
        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, 1.0,
                "NaN score should be clamped to 1.0 after normalize"
            );
        }
    }
}
