//! Fall-risk-elevated primitive (§3.12.1 row 7).
//!
//! Continuous 0..100 score derived from gait instability + near-fall
//! frequency over a rolling 24 h window. Emits a Scalar state every
//! tick when active; emits a one-shot event when the score crosses
//! `fall_risk_event_threshold` (default 70).
//!
//! v1 simplification: score = clamp(100, 10 * near_falls_24h +
//! 50 * recent_motion_variance), where:
//!   - near_falls_24h: count of `fall_detected` events in the trailing
//!     24 h window (we don't expose near-falls separately in the
//!     broadcast yet, so we approximate with confirmed falls)
//!   - recent_motion_variance: variance of motion over the trailing
//!     60 s.
//!
//! v2 will use the gait-instability score directly once it lands in
//! the pose tracker (see ADR-027 §A4).

use std::collections::VecDeque;
use std::time::Duration;

use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};

const RECENT_MOTION_WINDOW: Duration = Duration::from_secs(60);
const FALL_HISTORY_WINDOW: Duration = Duration::from_secs(24 * 3600);

#[derive(Debug, Default, Clone)]
pub struct FallRiskElevated {
    pub last_score: f64,
    /// (timestamp, motion).
    motion_history: VecDeque<(Duration, f64)>,
    /// Timestamps of fall_detected=true events.
    fall_history: VecDeque<Duration>,
    /// True iff last emit was above the configured event threshold.
    above_threshold: bool,
}

impl FallRiskElevated {
    pub fn new() -> Self { Self::default() }

    fn variance(samples: &VecDeque<(Duration, f64)>) -> f64 {
        if samples.is_empty() { return 0.0; }
        let mean = samples.iter().map(|(_, m)| m).sum::<f64>() / samples.len() as f64;
        let v = samples
            .iter()
            .map(|(_, m)| (m - mean).powi(2))
            .sum::<f64>()
            / samples.len() as f64;
        v
    }

    pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
        if snap.since_start < cfg.warmup {
            return PrimitiveState::Idle;
        }

        // Maintain rolling motion history.
        self.motion_history.push_back((snap.since_start, snap.motion));
        while let Some(&(t, _)) = self.motion_history.front() {
            if snap.since_start.saturating_sub(t) > RECENT_MOTION_WINDOW {
                self.motion_history.pop_front();
            } else {
                break;
            }
        }

        // Maintain rolling fall history.
        if snap.fall_detected {
            self.fall_history.push_back(snap.since_start);
        }
        while let Some(&t) = self.fall_history.front() {
            if snap.since_start.saturating_sub(t) > FALL_HISTORY_WINDOW {
                self.fall_history.pop_front();
            } else {
                break;
            }
        }

        let near_falls = self.fall_history.len() as f64;
        let var = Self::variance(&self.motion_history);
        let score = (10.0 * near_falls + 50.0 * var).clamp(0.0, 100.0);
        self.last_score = score;

        // Event on crossing threshold upward.
        let was_above = self.above_threshold;
        self.above_threshold = score >= cfg.fall_risk_event_threshold;
        if !was_above && self.above_threshold {
            return PrimitiveState::Event {
                event_type: "fall_risk_elevated",
                reason: Reason::new(&["score>=70", "crossed_threshold"]),
            };
        }
        PrimitiveState::Scalar {
            value: score,
            reason: Reason::new(&["score_published"]),
        }
    }
}

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

    fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() }

    #[test]
    fn warmup_blocks_score() {
        let mut p = FallRiskElevated::new();
        let s = RawSnapshot {
            since_start: Duration::from_secs(30),
            motion: 0.5,
            ..Default::default()
        };
        assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
    }

    #[test]
    fn emits_scalar_when_active() {
        let mut p = FallRiskElevated::new();
        let s = RawSnapshot {
            since_start: Duration::from_secs(120),
            motion: 0.10,
            ..Default::default()
        };
        let state = p.tick(&s, &cfg());
        assert!(matches!(state, PrimitiveState::Scalar { .. }));
    }

    #[test]
    fn score_grows_with_falls() {
        let mut p = FallRiskElevated::new();
        // Establish baseline with no falls.
        let _ = p.tick(&RawSnapshot {
            since_start: Duration::from_secs(120),
            motion: 0.05,
            ..Default::default()
        }, &cfg());
        let base_score = p.last_score;
        // Add some falls.
        for t in 121..125 {
            let s = RawSnapshot {
                since_start: Duration::from_secs(t),
                motion: 0.05,
                fall_detected: true,
                ..Default::default()
            };
            let _ = p.tick(&s, &cfg());
        }
        // Score should be higher than baseline.
        assert!(p.last_score > base_score);
    }

    #[test]
    fn emits_event_when_crossing_threshold() {
        let mut p = FallRiskElevated::new();
        // Inject 7 falls → score ≥ 70.
        let mut last_state = PrimitiveState::Idle;
        for t in 120..127 {
            let s = RawSnapshot {
                since_start: Duration::from_secs(t),
                motion: 0.05,
                fall_detected: true,
                ..Default::default()
            };
            last_state = p.tick(&s, &cfg());
        }
        // One of those ticks must have emitted the crossing event.
        // Since we only catch the last call's return, check the score.
        assert!(p.above_threshold, "should be above threshold");
        // The crossing-event return is on the first tick that crosses.
        // Verify the type via a fresh sequence.
        let mut p2 = FallRiskElevated::new();
        let _ = p2.tick(&RawSnapshot {
            since_start: Duration::from_secs(120),
            motion: 0.05,
            ..Default::default()
        }, &cfg());
        let mut saw_event = false;
        for t in 121..130 {
            let s = RawSnapshot {
                since_start: Duration::from_secs(t),
                motion: 0.05,
                fall_detected: true,
                ..Default::default()
            };
            if matches!(p2.tick(&s, &cfg()), PrimitiveState::Event { .. }) {
                saw_event = true;
                break;
            }
        }
        assert!(saw_event, "should have emitted crossing event");
        // Suppress unused warning.
        let _ = last_state;
    }

    #[test]
    fn fall_history_evicts_after_24h() {
        let mut p = FallRiskElevated::new();
        // Inject fall.
        let _ = p.tick(&RawSnapshot {
            since_start: Duration::from_secs(120),
            motion: 0.05,
            fall_detected: true,
            ..Default::default()
        }, &cfg());
        // 25 hours later — the fall should evict from the window.
        let _ = p.tick(&RawSnapshot {
            since_start: Duration::from_secs(120 + 25 * 3600),
            motion: 0.05,
            ..Default::default()
        }, &cfg());
        assert!(p.fall_history.is_empty(), "fall must evict after 24h");
    }
}
