//! Per-session signal tracking: running decay scores and windowed counts.

use std::sync::atomic::{AtomicU64, Ordering};

use crate::signals::BucketedCounter;
use crate::signals::decay::{NANOS_PER_SEC, forward_decay_step};

/// Decay lambda for session signals (5-minute half-life).
///
/// `λ = ln(2) / t½ = ln(2) / 300s`
pub const DEFAULT_SESSION_LAMBDA: f64 = std::f64::consts::LN_2 / 300.0;

// ── SessionHotState ───────────────────────────────────────────────────────────

/// Per-signal-type running decay state for a session.
///
/// Delegates the running-score arithmetic to the canonical
/// [`forward_decay_step`] kernel (the single source of truth mandated by
/// `CODING_GUIDELINES.md` §3) so this tier cannot drift from `HotSignalState`.
/// It keeps its own single-atomic CAS loop and timestamp advance — sessions are
/// not on the 200-entity hot ranking path, so a simpler, non-cache-aligned
/// struct suffices.
pub struct SessionHotState {
    /// Exponentially decayed running score, stored as `f64::to_bits()`.
    score: AtomicU64,
    /// Timestamp of the last update, nanoseconds since Unix epoch.
    last_update_ns: AtomicU64,
    /// Total signals written for this signal type in this session.
    count: AtomicU64,
}

impl SessionHotState {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            score: AtomicU64::new(0_f64.to_bits()),
            last_update_ns: AtomicU64::new(0),
            count: AtomicU64::new(0),
        }
    }

    /// Update the running decay score with a new signal event.
    ///
    /// Delegates the two-branch decay arithmetic to the canonical
    /// [`forward_decay_step`] kernel so this tier uses the exact same math as
    /// `HotSignalState` (`CODING_GUIDELINES.md` §3):
    ///
    /// - **In-order** (`ts_ns >= prev_ts`): decay forward then add full weight,
    ///   and advance `last_update_ns` to `ts_ns`.
    /// - **Out-of-order** (`ts_ns < prev_ts`): fold in the *pre-decayed* weight
    ///   (`weight * exp(-λ × age)`) and do **not** regress `last_update_ns`.
    ///
    /// Before this delegated to the kernel, the out-of-order branch clamped `dt`
    /// to zero and added the late weight at full value, silently over-crediting
    /// stale activity — the exact divergence the kernel exists to retire.
    pub fn on_signal(&self, weight: f64, ts_ns: u64, lambda: f64) {
        let prev_ts = self.last_update_ns.load(Ordering::Acquire);

        // The advance decision depends only on the temporal ordering, not on the
        // score, so it is identical across CAS retries; the kernel returns the
        // same `advance_timestamp` value on every attempt.
        let advance_timestamp = ts_ns >= prev_ts;

        // CAS loop: re-run the kernel against the freshest stored score on each
        // attempt so a concurrent writer's update is never clobbered.
        loop {
            let old_bits = self.score.load(Ordering::Acquire);
            let old_score = f64::from_bits(old_bits);
            let step = forward_decay_step(old_score, prev_ts, ts_ns, lambda, weight);
            debug_assert_eq!(step.advance_timestamp, advance_timestamp);
            if self
                .score
                .compare_exchange(
                    old_bits,
                    step.new_score.to_bits(),
                    Ordering::AcqRel,
                    Ordering::Acquire,
                )
                .is_ok()
            {
                break;
            }
        }

        if advance_timestamp {
            // In-order: advance the timestamp. CAS failure is acceptable — a
            // concurrent writer already pushed `last_update_ns` further forward.
            let _ = self.last_update_ns.compare_exchange(
                prev_ts,
                ts_ns,
                Ordering::Release,
                Ordering::Relaxed,
            );
        }
        // Out-of-order: do NOT advance `last_update_ns` — it already reflects a
        // more recent time (CODING_GUIDELINES §3).

        self.count.fetch_add(1, Ordering::Relaxed);
    }

    /// Compute the current decayed score at `ts_now_ns`.
    #[must_use]
    pub fn current_score(&self, ts_now_ns: u64, lambda: f64) -> f64 {
        let score_bits = self.score.load(Ordering::Acquire);
        let score = f64::from_bits(score_bits);
        let ts = self.last_update_ns.load(Ordering::Acquire);
        if ts == 0 {
            return 0.0;
        }
        #[allow(clippy::cast_precision_loss)]
        // Nanosecond delta fits in f64 mantissa for practical durations.
        let dt_secs = if ts_now_ns > ts {
            (ts_now_ns - ts) as f64 / NANOS_PER_SEC
        } else {
            0.0
        };
        score * (-lambda * dt_secs).exp()
    }

    /// Frozen score (no further decay applied) — for archived sessions.
    #[must_use]
    pub fn frozen_score(&self) -> f64 {
        f64::from_bits(self.score.load(Ordering::Relaxed))
    }

    /// Total number of signals received for this signal type.
    #[must_use]
    pub fn count(&self) -> u64 {
        self.count.load(Ordering::Relaxed)
    }
}

impl Default for SessionHotState {
    fn default() -> Self {
        Self::new()
    }
}

// ── SessionSignalState ────────────────────────────────────────────────────────

/// Combined hot-decay + windowed-count state for a single signal type in a session.
///
/// Stores the running decay score (via `SessionHotState`) plus bucketed window
/// counts (via `BucketedCounter`). The `lambda` is captured at construction time
/// from the schema's `DecaySpec` so snapshot building doesn't need schema access.
pub struct SessionSignalState {
    /// Running exponential decay score for this signal type.
    pub hot: SessionHotState,
    /// Minute-level bucketed event counts for windowed queries.
    pub windows: BucketedCounter,
    /// Signal-type-specific decay rate (λ = ln(2) / `half_life_secs`).
    pub lambda: f64,
}

impl SessionSignalState {
    /// Create a new state for a signal type with the given decay rate.
    ///
    /// `now_ns` initialises the `BucketedCounter` start time.
    #[must_use]
    pub fn new(now_ns: u64, lambda: f64) -> Self {
        Self {
            hot: SessionHotState::new(),
            windows: BucketedCounter::with_start_time(now_ns),
            lambda,
        }
    }

    /// Record a new signal event, updating both the hot score and window counts.
    pub fn on_signal(&self, weight: f64, ts_ns: u64) {
        self.hot.on_signal(weight, ts_ns, self.lambda);
        self.windows.increment(ts_ns);
    }

    /// Compute the current decayed score at `ts_now_ns`.
    #[must_use]
    pub fn current_score(&self, ts_now_ns: u64) -> f64 {
        self.hot.current_score(ts_now_ns, self.lambda)
    }

    /// Frozen score (no further decay) — for archived sessions.
    #[must_use]
    pub fn frozen_score(&self) -> f64 {
        self.hot.frozen_score()
    }
}

// ── SignalSnapEntry ───────────────────────────────────────────────────────────

/// Per-signal-type statistics captured in a `SessionSnapshot`.
#[derive(Debug, Clone)]
pub struct SignalSnapEntry {
    /// Decayed score at snapshot time (frozen for closed, current for active).
    pub decay_score: f64,
    /// Count of events in the 1-hour window.
    pub window_1h: u64,
    /// Total number of signals received for this signal type in this session.
    pub count: u64,
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_hot_state_basic() {
        let state = SessionHotState::new();
        let now_ns = 1_000_000_000u64; // 1 second
        // No signals yet.
        assert!(state.current_score(now_ns, 0.01).abs() < 1e-12);

        // Write a signal.
        state.on_signal(1.0, now_ns, 0.01);
        // Score should be ~1.0 immediately.
        let score = state.current_score(now_ns, 0.01);
        assert!((score - 1.0).abs() < 1e-6, "score={score}");
        assert_eq!(state.count(), 1);
    }

    #[test]
    fn out_of_order_event_contributes_strictly_less_than_in_order() {
        // CODING_GUIDELINES §3: a late-arriving event must be pre-decayed by its
        // age, so it contributes strictly less than the same event arriving
        // in-order. Before the kernel delegation, the out-of-order branch clamped
        // dt to zero and added the weight at full value (over-crediting).
        let lambda = std::f64::consts::LN_2 / 300.0; // 5-min half-life
        let last_update_ns = 10_000_000_000u64; // t = 10s
        let weight = 3.0;

        // In-order: first event lands exactly at the (sentinel-free) base time.
        let in_order = SessionHotState::new();
        in_order.on_signal(weight, last_update_ns, lambda);

        // Out-of-order: prime last_update to t=10s, then apply a late event at
        // t=5s (age = 5s). The contribution must be pre-decayed.
        let out_of_order = SessionHotState::new();
        out_of_order.on_signal(0.0, last_update_ns, lambda); // advance timestamp only
        out_of_order.on_signal(weight, 5_000_000_000u64, lambda);

        let in_order_score = in_order.frozen_score();
        let out_of_order_score = out_of_order.frozen_score();
        assert!(
            out_of_order_score < in_order_score,
            "out-of-order {out_of_order_score} must contribute less than in-order {in_order_score}"
        );
        // It must still be positive (finite age) and match the analytical pre-decay.
        assert!(out_of_order_score > 0.0);
        let expected = weight * (-lambda * 5.0).exp();
        assert!(
            (out_of_order_score - expected).abs() < 1e-9,
            "out-of-order {out_of_order_score} != analytical pre-decay {expected}"
        );
    }

    #[test]
    #[allow(clippy::cast_precision_loss)]
    fn out_of_order_event_does_not_regress_timestamp() {
        // After a late event, the running score must still decay forward from the
        // newer (un-regressed) timestamp, not the late event's time.
        let lambda = std::f64::consts::LN_2 / 300.0;
        let t_recent = 10_000_000_000u64; // t = 10s
        let state = SessionHotState::new();
        state.on_signal(1.0, t_recent, lambda); // in-order, advances to t=10s
        state.on_signal(1.0, 5_000_000_000u64, lambda); // out-of-order at t=5s

        // current_score one half-life past t_recent should reflect decay from
        // t=10s, not t=5s — i.e. last_update did not regress to 5s.
        let one_hl = t_recent + 300_000_000_000u64;
        let from_recent = state.current_score(one_hl, lambda);
        // If the timestamp had regressed to 5s, the same query would over-decay.
        // Recompute the score had last_update been 5s to prove the difference.
        let stored = state.frozen_score();
        let dt_if_regressed = (one_hl - 5_000_000_000u64) as f64 / 1_000_000_000.0;
        let if_regressed = stored * (-lambda * dt_if_regressed).exp();
        assert!(
            from_recent > if_regressed,
            "timestamp must not regress: {from_recent} should exceed regressed {if_regressed}"
        );
    }

    #[test]
    fn session_hot_state_decay() {
        let state = SessionHotState::new();
        // Use a non-zero base time; ts=0 is the sentinel for "no signals ever".
        let t0 = 1_000_000_000u64; // 1 second in nanoseconds
        let lambda = std::f64::consts::LN_2 / 300.0; // 5-min half-life
        state.on_signal(1.0, t0, lambda);

        // After 300s (one half-life), score should be ~0.5.
        let t1 = t0 + 300_000_000_000u64;
        let score = state.current_score(t1, lambda);
        assert!(
            (score - 0.5).abs() < 1e-4,
            "expected ~0.5 after half-life, got {score}"
        );
    }
}
