//! Bayesian Online Change Point Detection (BOCPD).
//!
//! Implements the Adams & MacKay (2007) algorithm for detecting abrupt
//! changes in streaming data. Uses a Normal-Inverse-Gamma conjugate prior
//! for Gaussian data, producing:
//! - Run length distribution at each time step
//! - Change point probability
//! - Most likely current run length
//!
//! The algorithm is O(T) per update in the truncated approximation
//! (run length distribution is pruned to keep computation bounded).
//!
//! Pro tier required.

use pyo3::prelude::*;
use std::sync::Mutex;

// ===========================================================================
// Constants
// ===========================================================================

/// Maximum run length to track (truncation for memory/speed).
const MAX_RUN_LENGTH: usize = 2000;

/// Minimum probability mass to retain (pruning threshold).
const MIN_PROB_MASS: f64 = 1e-30;

// ===========================================================================
// Result type
// ===========================================================================

/// Result of a single BOCPD update step.
#[pyclass(frozen)]
#[derive(Debug, Clone)]
pub struct BocpdResult {
    /// Probability that the most recent observation is a change point.
    #[pyo3(get)]
    pub change_prob: f64,
    /// Most likely run length (number of observations since last change).
    #[pyo3(get)]
    pub most_likely_run_length: usize,
    /// Whether this observation is classified as a change point
    /// (change_prob exceeds the implicit threshold based on run length).
    #[pyo3(get)]
    pub is_changepoint: bool,
}

#[pymethods]
impl BocpdResult {
    fn __repr__(&self) -> String {
        format!(
            "BocpdResult(change_prob={:.6}, run_length={}, is_changepoint={})",
            self.change_prob, self.most_likely_run_length, self.is_changepoint
        )
    }
}

// ===========================================================================
// Normal-Inverse-Gamma conjugate model
// ===========================================================================

/// Parameters for a single run-length hypothesis in the NIG conjugate model.
/// Each run length r has its own posterior (mu, kappa, alpha, beta) for the
/// Gaussian parameters (mean, variance).
#[derive(Debug, Clone)]
struct NigParams {
    mu: f64,
    kappa: f64,
    alpha: f64,
    beta: f64,
}

impl NigParams {
    fn new(mu: f64, kappa: f64, alpha: f64, beta: f64) -> Self {
        Self {
            mu,
            kappa,
            alpha,
            beta,
        }
    }

    /// Update the NIG posterior with a new observation x.
    /// Returns the updated parameters (non-mutating).
    fn update(&self, x: f64) -> Self {
        let kappa_new = self.kappa + 1.0;
        let mu_new = (self.kappa * self.mu + x) / kappa_new;
        let alpha_new = self.alpha + 0.5;
        let beta_new =
            self.beta + 0.5 * self.kappa * (x - self.mu).powi(2) / kappa_new;
        Self {
            mu: mu_new,
            kappa: kappa_new,
            alpha: alpha_new,
            beta: beta_new,
        }
    }

    /// Log-predictive density: the Student-t distribution that arises as the
    /// marginal likelihood of a new observation x under this NIG posterior.
    ///
    /// The predictive is Student-t with:
    ///   df = 2 * alpha
    ///   location = mu
    ///   scale^2 = beta * (kappa + 1) / (alpha * kappa)
    fn log_predictive(&self, x: f64) -> f64 {
        let df = 2.0 * self.alpha;
        let scale_sq = self.beta * (self.kappa + 1.0) / (self.alpha * self.kappa);

        if scale_sq <= 0.0 || !scale_sq.is_finite() || df <= 0.0 {
            return -50.0; // Return a small but finite value
        }

        student_t_log_pdf(x, self.mu, scale_sq, df)
    }
}

/// Log-PDF of the Student-t distribution:
///   t(x | mu, scale^2, df)
///
/// Using the standard parameterization:
///   p(x) = Gamma((df+1)/2) / (Gamma(df/2) * sqrt(df * pi * scale^2))
///          * (1 + (x-mu)^2 / (df * scale^2))^{-(df+1)/2}
fn student_t_log_pdf(x: f64, mu: f64, scale_sq: f64, df: f64) -> f64 {
    let z = (x - mu).powi(2) / (df * scale_sq);

    // log Gamma((df+1)/2) - log Gamma(df/2)
    let lg1 = ln_gamma(0.5 * (df + 1.0));
    let lg2 = ln_gamma(0.5 * df);

    lg1 - lg2 - 0.5 * (df * std::f64::consts::PI * scale_sq).ln() - 0.5 * (df + 1.0) * (1.0 + z).ln()
}

/// Lanczos approximation of ln(Gamma(x)) for x > 0.
/// Uses the Lanczos approximation with g=7, n=9 coefficients.
/// Accurate to ~15 digits across the positive reals.
fn ln_gamma(x: f64) -> f64 {
    if x <= 0.0 {
        return f64::INFINITY;
    }

    const G: f64 = 7.0;
    const COEFF: [f64; 9] = [
        0.99999999999980993,
        676.5203681218851,
        -1259.1392167224028,
        771.32342877765313,
        -176.61502916214059,
        12.507343278686905,
        -0.13857109526572012,
        9.9843695780195716e-6,
        1.5056327351493116e-7,
    ];

    if x < 0.5 {
        // Reflection formula
        let log_pi = std::f64::consts::PI.ln();
        let sin_val = (std::f64::consts::PI * x).sin().abs();
        if sin_val < 1e-300 {
            return f64::INFINITY;
        }
        return log_pi - sin_val.ln() - ln_gamma(1.0 - x);
    }

    let z = x - 1.0;
    let mut sum = COEFF[0];
    for i in 1..9 {
        sum += COEFF[i] / (z + i as f64);
    }
    let t = z + G + 0.5;

    // ln(sqrt(2*pi)) + (z+0.5)*ln(t) - t + ln(sum)
    0.5 * (2.0 * std::f64::consts::PI).ln() + (z + 0.5) * t.ln() - t + sum.ln()
}

// ===========================================================================
// BOCPD inner state
// ===========================================================================

struct BocpdInner {
    /// Hazard rate: 1/lambda where lambda is the expected run length.
    /// P(change) = 1/hazard_rate at each step.
    hazard_rate: f64,

    /// Prior NIG parameters (used when a new run begins).
    prior_mu: f64,
    prior_kappa: f64,
    prior_alpha: f64,
    prior_beta: f64,

    /// Run length distribution: run_length_probs[r] = P(run_length = r | data).
    /// Index 0 = run length 0 (just had a change point).
    run_length_probs: Vec<f64>,

    /// NIG posterior parameters for each run-length hypothesis.
    /// params[r] holds the NIG posterior after r observations in this run.
    params: Vec<NigParams>,

    /// Number of observations processed.
    n_obs: usize,
}

impl BocpdInner {
    fn new(hazard_rate: f64, mu0: f64, kappa0: f64, alpha0: f64, beta0: f64) -> Self {
        // Initially: run length 0 with probability 1
        let prior = NigParams::new(mu0, kappa0, alpha0, beta0);
        Self {
            hazard_rate,
            prior_mu: mu0,
            prior_kappa: kappa0,
            prior_alpha: alpha0,
            prior_beta: beta0,
            run_length_probs: vec![1.0],
            params: vec![prior],
            n_obs: 0,
        }
    }

    /// Process a new observation and update the run length distribution.
    ///
    /// Implements the Adams & MacKay (2007) message-passing algorithm:
    /// 1. Compute predictive probabilities for each run length
    /// 2. Compute growth probabilities (no change)
    /// 3. Compute changepoint probability (run length resets to 0)
    /// 4. Normalize
    /// 5. Update sufficient statistics
    fn update(&mut self, x: f64) -> BocpdResult {
        let n = self.run_length_probs.len();
        let h = 1.0 / self.hazard_rate; // hazard function (constant)

        // Step 1: Compute predictive log-likelihoods for each run length
        let mut log_pred = Vec::with_capacity(n);
        for r in 0..n {
            log_pred.push(self.params[r].log_predictive(x));
        }

        // Step 2: Growth probabilities
        // growth[r+1] = run_length_probs[r] * pred[r] * (1 - h)
        let mut new_probs = Vec::with_capacity(n + 1);
        new_probs.push(0.0); // placeholder for changepoint mass

        // Use log-space for numerical stability, then exponentiate
        let mut growth_masses = Vec::with_capacity(n);
        for r in 0..n {
            let log_mass = self.run_length_probs[r].max(1e-300).ln()
                + log_pred[r]
                + (1.0 - h).max(1e-300).ln();
            growth_masses.push(log_mass);
        }

        // Find max for log-sum-exp
        let max_log = growth_masses
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);

        // Changepoint mass: sum of run_length_probs[r] * pred[r] * h for all r
        let mut cp_log_masses = Vec::with_capacity(n);
        for r in 0..n {
            let log_mass = self.run_length_probs[r].max(1e-300).ln()
                + log_pred[r]
                + h.max(1e-300).ln();
            cp_log_masses.push(log_mass);
        }

        let cp_max = cp_log_masses
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);

        // Compute in linear space relative to max
        let overall_max = max_log.max(cp_max);

        // Changepoint mass (run length = 0)
        let cp_mass: f64 = cp_log_masses
            .iter()
            .map(|&lm| (lm - overall_max).exp())
            .sum();
        new_probs[0] = cp_mass;

        // Growth masses (run length r -> r+1)
        for &lm in &growth_masses {
            new_probs.push((lm - overall_max).exp());
        }

        // Step 3: Normalize
        let total: f64 = new_probs.iter().sum();
        if total > 0.0 {
            for p in new_probs.iter_mut() {
                *p /= total;
            }
        } else {
            // Degenerate: reset to uniform
            let uniform = 1.0 / new_probs.len() as f64;
            for p in new_probs.iter_mut() {
                *p = uniform;
            }
        }

        // Step 4: Update NIG parameters for each run length
        let mut new_params = Vec::with_capacity(new_probs.len());
        // Run length 0: fresh prior
        new_params.push(NigParams::new(
            self.prior_mu,
            self.prior_kappa,
            self.prior_alpha,
            self.prior_beta,
        ));
        // Run length r+1: update from params[r]
        for r in 0..n {
            new_params.push(self.params[r].update(x));
        }

        // Step 5: Truncate if too long (keep only significant mass)
        if new_probs.len() > MAX_RUN_LENGTH {
            // Keep only the top MAX_RUN_LENGTH entries by mass
            // Redistribute truncated mass to changepoint
            let mut truncated_mass = 0.0;
            for r in MAX_RUN_LENGTH..new_probs.len() {
                truncated_mass += new_probs[r];
            }
            new_probs.truncate(MAX_RUN_LENGTH);
            new_params.truncate(MAX_RUN_LENGTH);
            new_probs[0] += truncated_mass;

            // Re-normalize
            let total: f64 = new_probs.iter().sum();
            if total > 0.0 {
                for p in new_probs.iter_mut() {
                    *p /= total;
                }
            }
        }

        // Prune negligible entries from the tail
        while new_probs.len() > 1 && *new_probs.last().unwrap() < MIN_PROB_MASS {
            new_probs.pop();
            new_params.pop();
        }

        self.run_length_probs = new_probs;
        self.params = new_params;
        self.n_obs += 1;

        // Compute outputs
        let change_prob = self.run_length_probs[0];
        let most_likely_rl = self.most_likely_run_length();
        let is_changepoint = change_prob > 0.5
            || (change_prob > 0.3 && most_likely_rl <= 1);

        BocpdResult {
            change_prob,
            most_likely_run_length: most_likely_rl,
            is_changepoint,
        }
    }

    /// Index of the most probable run length.
    fn most_likely_run_length(&self) -> usize {
        self.run_length_probs
            .iter()
            .enumerate()
            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
            .map(|(i, _)| i)
            .unwrap_or(0)
    }

    /// Change point probability (mass at run length 0).
    fn change_probability(&self) -> f64 {
        if self.run_length_probs.is_empty() {
            0.0
        } else {
            self.run_length_probs[0]
        }
    }

    /// Reset detector to initial state.
    fn reset(&mut self) {
        let prior = NigParams::new(
            self.prior_mu,
            self.prior_kappa,
            self.prior_alpha,
            self.prior_beta,
        );
        self.run_length_probs = vec![1.0];
        self.params = vec![prior];
        self.n_obs = 0;
    }
}

// ===========================================================================
// PyO3 interface
// ===========================================================================

/// Bayesian Online Change Point Detector (Adams & MacKay 2007).
///
/// Detects abrupt changes in streaming data by maintaining a distribution
/// over possible run lengths (time since last change point). Uses a
/// Normal-Inverse-Gamma conjugate prior for Gaussian data.
///
/// Pro tier required.
#[pyclass]
pub struct BocpdDetector {
    inner: Mutex<BocpdInner>,
}

#[pymethods]
impl BocpdDetector {
    /// Create a new BOCPD detector.
    ///
    /// Args:
    ///     hazard_rate: Expected run length between change points (default 250).
    ///                  Higher = less sensitive, lower = more sensitive.
    ///     mu0: Prior mean (default 0.0).
    ///     kappa0: Prior precision scaling (default 1.0). Higher = stronger prior on mean.
    ///     alpha0: Prior shape for variance (default 1.0). Higher = stronger prior.
    ///     beta0: Prior scale for variance (default 1.0). Higher = larger expected variance.
    #[new]
    #[pyo3(signature = (hazard_rate=250.0, mu0=0.0, kappa0=1.0, alpha0=1.0, beta0=1.0))]
    fn new(
        hazard_rate: f64,
        mu0: f64,
        kappa0: f64,
        alpha0: f64,
        beta0: f64,
    ) -> PyResult<Self> {
        crate::auth::require_pro()
            .map_err(|e| pyo3::exceptions::PyPermissionError::new_err(e.to_string()))?;
        if hazard_rate <= 0.0 || !hazard_rate.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "hazard_rate must be > 0 and finite",
            ));
        }
        if kappa0 <= 0.0 || !kappa0.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "kappa0 must be > 0 and finite",
            ));
        }
        if alpha0 <= 0.0 || !alpha0.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "alpha0 must be > 0 and finite",
            ));
        }
        if beta0 <= 0.0 || !beta0.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "beta0 must be > 0 and finite",
            ));
        }
        if !mu0.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "mu0 must be finite",
            ));
        }
        Ok(Self {
            inner: Mutex::new(BocpdInner::new(hazard_rate, mu0, kappa0, alpha0, beta0)),
        })
    }

    /// Process a new observation and update the run length distribution.
    ///
    /// Args:
    ///     observation: New data point (scalar).
    ///
    /// Returns:
    ///     BocpdResult with change probability, run length, and changepoint flag.
    fn update(&self, observation: f64) -> PyResult<BocpdResult> {
        crate::auth::require_pro()
            .map_err(|e| pyo3::exceptions::PyPermissionError::new_err(e.to_string()))?;
        if !observation.is_finite() {
            return Err(pyo3::exceptions::PyValueError::new_err(
                "observation must be finite",
            ));
        }
        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        Ok(inner.update(observation))
    }

    /// Get the full run length distribution.
    ///
    /// Returns:
    ///     List of probabilities. Index r = P(run_length = r | data).
    fn run_length_distribution(&self) -> Vec<f64> {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner.run_length_probs.clone()
    }

    /// Get the most likely run length.
    fn most_likely_run_length(&self) -> usize {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner.most_likely_run_length()
    }

    /// Get the current change point probability (mass at run length 0).
    fn change_probability(&self) -> f64 {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner.change_probability()
    }

    /// Reset the detector to its initial state.
    fn reset(&self) {
        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner.reset();
    }

    /// Get the number of observations processed.
    fn n_observations(&self) -> usize {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner.n_obs
    }

    /// Run the detector over a batch of observations.
    ///
    /// Args:
    ///     observations: List of data points.
    ///
    /// Returns:
    ///     List of BocpdResult, one per observation.
    fn run_batch(&self, observations: Vec<f64>) -> PyResult<Vec<BocpdResult>> {
        crate::auth::require_pro()
            .map_err(|e| pyo3::exceptions::PyPermissionError::new_err(e.to_string()))?;
        for (i, &v) in observations.iter().enumerate() {
            if !v.is_finite() {
                return Err(pyo3::exceptions::PyValueError::new_err(format!(
                    "observation[{}] is not finite: {}",
                    i, v
                )));
            }
        }
        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        let mut results = Vec::with_capacity(observations.len());
        for &obs in &observations {
            results.push(inner.update(obs));
        }
        Ok(results)
    }

    /// Get the expected run length (weighted mean of the distribution).
    fn expected_run_length(&self) -> f64 {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        inner
            .run_length_probs
            .iter()
            .enumerate()
            .map(|(r, &p)| r as f64 * p)
            .sum()
    }

    fn __repr__(&self) -> String {
        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
        format!(
            "BocpdDetector(hazard_rate={:.0}, n_obs={}, run_lengths={}, change_prob={:.4})",
            inner.hazard_rate,
            inner.n_obs,
            inner.run_length_probs.len(),
            inner.change_probability()
        )
    }
}

// ===========================================================================
// Registration
// ===========================================================================

pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<BocpdDetector>()?;
    m.add_class::<BocpdResult>()?;
    Ok(())
}

// ===========================================================================
// Tests
// ===========================================================================

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

    // -----------------------------------------------------------------------
    // ln_gamma tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_ln_gamma_known_values() {
        // Gamma(1) = 1, ln(1) = 0
        assert!((ln_gamma(1.0)).abs() < 1e-10);

        // Gamma(2) = 1, ln(1) = 0
        assert!((ln_gamma(2.0)).abs() < 1e-10);

        // Gamma(3) = 2, ln(2) = 0.6931...
        assert!((ln_gamma(3.0) - 2.0_f64.ln()).abs() < 1e-8);

        // Gamma(0.5) = sqrt(pi), ln(sqrt(pi)) = 0.5*ln(pi)
        let expected = 0.5 * std::f64::consts::PI.ln();
        assert!(
            (ln_gamma(0.5) - expected).abs() < 1e-8,
            "ln_gamma(0.5) = {}, expected {}",
            ln_gamma(0.5),
            expected
        );

        // Gamma(5) = 24, ln(24)
        assert!(
            (ln_gamma(5.0) - 24.0_f64.ln()).abs() < 1e-8,
            "ln_gamma(5) = {}, expected {}",
            ln_gamma(5.0),
            24.0_f64.ln()
        );
    }

    #[test]
    fn test_ln_gamma_large() {
        // Stirling's approximation check for large values
        let x = 100.0;
        let lg = ln_gamma(x);
        // Should be approximately (x-0.5)*ln(x) - x + 0.5*ln(2*pi)
        let stirling = (x - 0.5) * x.ln() - x + 0.5 * (2.0 * std::f64::consts::PI).ln();
        // Stirling's is an approximation, so allow wider tolerance
        assert!(
            (lg - stirling).abs() / lg.abs() < 0.01,
            "relative error too large: lg={}, stirling={}",
            lg,
            stirling
        );
    }

    // -----------------------------------------------------------------------
    // Student-t tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_student_t_peak() {
        // PDF should be highest at the location parameter
        let at_mu = student_t_log_pdf(0.0, 0.0, 1.0, 5.0);
        let away = student_t_log_pdf(3.0, 0.0, 1.0, 5.0);
        assert!(at_mu > away);
    }

    #[test]
    fn test_student_t_symmetric() {
        let left = student_t_log_pdf(-2.0, 0.0, 1.0, 5.0);
        let right = student_t_log_pdf(2.0, 0.0, 1.0, 5.0);
        assert!((left - right).abs() < 1e-10);
    }

    // -----------------------------------------------------------------------
    // NIG conjugate update
    // -----------------------------------------------------------------------

    #[test]
    fn test_nig_update() {
        let prior = NigParams::new(0.0, 1.0, 1.0, 1.0);
        let post = prior.update(2.0);

        // kappa should increase by 1
        assert!((post.kappa - 2.0).abs() < 1e-10);
        // mu should shift toward 2.0
        assert!(post.mu > 0.0 && post.mu < 2.0);
        // alpha should increase by 0.5
        assert!((post.alpha - 1.5).abs() < 1e-10);
        // beta should increase (positive observation away from prior mean)
        assert!(post.beta > prior.beta);
    }

    #[test]
    fn test_nig_multiple_updates() {
        let mut params = NigParams::new(0.0, 1.0, 1.0, 1.0);
        for _ in 0..100 {
            params = params.update(5.0);
        }
        // After many observations of 5.0, mu should converge to 5.0
        assert!(
            (params.mu - 5.0).abs() < 0.1,
            "mu = {}",
            params.mu
        );
    }

    #[test]
    fn test_nig_predictive_finite() {
        let params = NigParams::new(0.0, 1.0, 1.0, 1.0);
        let lp = params.log_predictive(1.0);
        assert!(lp.is_finite(), "log_predictive = {}", lp);
        assert!(lp < 0.0, "log_predictive should be negative");
    }

    // -----------------------------------------------------------------------
    // BOCPD detector
    // -----------------------------------------------------------------------

    #[test]
    fn test_bocpd_initial_state() {
        let det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);
        assert_eq!(det.run_length_probs.len(), 1);
        assert_eq!(det.run_length_probs[0], 1.0);
        assert_eq!(det.n_obs, 0);
    }

    #[test]
    fn test_bocpd_single_update() {
        let mut det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);
        let result = det.update(0.0);

        assert!(result.change_prob >= 0.0 && result.change_prob <= 1.0);
        assert_eq!(det.n_obs, 1);
        // Run length distribution should now have 2 entries
        assert_eq!(det.run_length_probs.len(), 2);
        // Probabilities should sum to 1
        let sum: f64 = det.run_length_probs.iter().sum();
        assert!((sum - 1.0).abs() < 1e-10, "sum = {}", sum);
    }

    #[test]
    fn test_bocpd_stationary_data() {
        // Stationary data should have low change probability after warmup
        let mut det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);

        // Feed 100 observations from the same distribution (mean=0, small variation)
        let mut last_result = BocpdResult {
            change_prob: 0.0,
            most_likely_run_length: 0,
            is_changepoint: false,
        };
        for i in 0..100 {
            let x = (i as f64 * 0.37).sin() * 0.1; // Small deterministic variation
            last_result = det.update(x);
        }

        // After 100 stationary observations, change_prob should be small
        assert!(
            last_result.change_prob < 0.3,
            "change_prob = {} (should be low for stationary data)",
            last_result.change_prob
        );
        // Most likely run length should be substantial
        assert!(
            last_result.most_likely_run_length > 10,
            "run_length = {} (should be large for stationary data)",
            last_result.most_likely_run_length
        );
    }

    #[test]
    fn test_bocpd_mean_shift() {
        // Mean shift should trigger change detection
        let mut det = BocpdInner::new(100.0, 0.0, 0.1, 1.0, 0.1);

        // 50 observations from N(0, 0.1)
        for i in 0..50 {
            let x = (i as f64 * 0.37).sin() * 0.1;
            det.update(x);
        }

        // Sharp mean shift to 10.0
        let mut detected_change = false;
        for _ in 0..10 {
            let result = det.update(10.0);
            if result.change_prob > 0.1 {
                detected_change = true;
            }
        }

        assert!(
            detected_change,
            "failed to detect mean shift from 0 to 10"
        );
    }

    #[test]
    fn test_bocpd_run_length_distribution_sums_to_one() {
        let mut det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);

        for i in 0..50 {
            let x = (i as f64 * 0.7).sin();
            det.update(x);
            let sum: f64 = det.run_length_probs.iter().sum();
            assert!(
                (sum - 1.0).abs() < 1e-8,
                "step {}: distribution sums to {}",
                i,
                sum
            );
        }
    }

    #[test]
    fn test_bocpd_reset() {
        let mut det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);
        for _ in 0..20 {
            det.update(1.0);
        }
        assert!(det.n_obs == 20);
        assert!(det.run_length_probs.len() > 1);

        det.reset();
        assert_eq!(det.n_obs, 0);
        assert_eq!(det.run_length_probs.len(), 1);
        assert_eq!(det.run_length_probs[0], 1.0);
    }

    #[test]
    fn test_bocpd_hazard_sensitivity() {
        // Lower hazard rate = more sensitive to changes
        let mut det_sensitive = BocpdInner::new(10.0, 0.0, 1.0, 1.0, 1.0);
        let mut det_insensitive = BocpdInner::new(1000.0, 0.0, 1.0, 1.0, 1.0);

        // Same data
        for i in 0..30 {
            let x = (i as f64 * 0.37).sin() * 0.1;
            det_sensitive.update(x);
            det_insensitive.update(x);
        }

        // The sensitive detector should have more mass at short run lengths
        // (higher change probability)
        let cp_sensitive = det_sensitive.change_probability();
        let cp_insensitive = det_insensitive.change_probability();
        assert!(
            cp_sensitive >= cp_insensitive,
            "sensitive cp={} < insensitive cp={}",
            cp_sensitive,
            cp_insensitive
        );
    }

    #[test]
    fn test_bocpd_most_likely_run_length() {
        let mut det = BocpdInner::new(250.0, 0.0, 1.0, 1.0, 1.0);

        // After stationary observations, most likely run length should grow
        for _ in 0..50 {
            det.update(0.0);
        }

        let ml_rl = det.most_likely_run_length();
        assert!(
            ml_rl > 0,
            "most likely run length should be > 0 after stationary data"
        );
    }

    #[test]
    fn test_bocpd_variance_change() {
        // Variance change should also trigger detection
        let mut det = BocpdInner::new(50.0, 0.0, 0.1, 1.0, 0.01);

        // Low variance phase
        for i in 0..50 {
            let x = (i as f64 * 0.37).sin() * 0.01;
            det.update(x);
        }

        // High variance phase
        let mut detected = false;
        for i in 0..20 {
            let x = (i as f64 * 0.37).sin() * 5.0;
            let result = det.update(x);
            if result.change_prob > 0.1 {
                detected = true;
            }
        }

        assert!(detected, "failed to detect variance change");
    }
}
