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

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

use crate::signals::BucketedCounter;

/// 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.
///
/// Mirrors `HotSignalState`'s running-score formula (CAS loop, same decay math)
/// but uses a simpler, non-cache-aligned struct — sessions are not on the
/// 200-entity hot ranking path.
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.
    ///
    /// Uses the same CAS formula as `HotSignalState`:
    /// `S(t) = S(prev) * exp(−λ × dt) + weight`.
    pub fn on_signal(&self, weight: f64, ts_ns: u64, lambda: f64) {
        let prev_ts = self.last_update_ns.load(Ordering::Acquire);
        #[allow(clippy::cast_precision_loss)]
        // Nanosecond delta fits in f64 mantissa for practical durations.
        let dt_secs = if ts_ns > prev_ts {
            (ts_ns - prev_ts) as f64 / 1_000_000_000.0
        } else {
            0.0
        };
        let decay_factor = (-lambda * dt_secs).exp();

        // CAS loop: forward-decay old score then add weight.
        loop {
            let old_bits = self.score.load(Ordering::Acquire);
            let old_score = f64::from_bits(old_bits);
            let new_score = old_score.mul_add(decay_factor, weight);
            let new_bits = new_score.to_bits();
            if self
                .score
                .compare_exchange(old_bits, new_bits, Ordering::AcqRel, Ordering::Acquire)
                .is_ok()
            {
                break;
            }
        }

        // Advance timestamp only if the new event is strictly later.
        let _ = self.last_update_ns.compare_exchange(
            prev_ts,
            ts_ns,
            Ordering::Release,
            Ordering::Relaxed,
        );

        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 / 1_000_000_000.0
        } 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_eq!(state.current_score(now_ns, 0.01), 0.0);

        // 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 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}"
        );
    }
}
