//! Parity tests against the `ruptures` Python library.
//!
//! Fixtures are generated by `scripts/generate_ruptures_fixtures.py`
//! (pinned to ruptures==1.1.9) and stored as JSON under
//! `tests/data/ruptures_fixtures/`. Each fixture records the input
//! signal, the algorithm + mode + params, and the ruptures-computed
//! breakpoints + total cost.
//!
//! This test loads each fixture, runs the Rust port with identical
//! params, and asserts both breakpoint lists and total cost match
//! (exact on integers, relative tolerance on floats).

#![cfg(feature = "serde")]

use std::fs;
use std::path::PathBuf;

use anofox_forecast::changepoint::{
    BinsegDetector, BottomUpDetector, CostL2, Detector, DynpDetector, PeltDetector, Signal,
    WindowDetector,
};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Fixture {
    #[serde(rename = "_ruptures_version")]
    ruptures_version: String,
    algo: String,
    cost: String,
    mode: String,
    params: serde_json::Value,
    signal: Vec<f64>,
    n: usize,
    bkps: Vec<usize>,
    total_cost: f64,
}

fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("data")
        .join("ruptures_fixtures")
}

fn load_fixture(name: &str) -> Fixture {
    let path = fixtures_dir().join(format!("{}.json", name));
    let raw =
        fs::read_to_string(&path).unwrap_or_else(|_| panic!("missing fixture: {}", path.display()));
    serde_json::from_str(&raw)
        .unwrap_or_else(|e| panic!("malformed fixture {}: {}", path.display(), e))
}

/// Compute the total L2 cost across the segments of a breakpoint list.
fn total_l2_cost(values: &[f64], bkps: &[usize]) -> f64 {
    let mut total = 0.0;
    let mut start = 0usize;
    for &end in bkps {
        if end > start {
            let seg = &values[start..end];
            let mean = seg.iter().sum::<f64>() / seg.len() as f64;
            for &x in seg {
                total += (x - mean).powi(2);
            }
        }
        start = end;
    }
    total
}

fn param(fx: &Fixture, key: &str) -> f64 {
    fx.params
        .get(key)
        .and_then(|v| v.as_f64())
        .unwrap_or_else(|| panic!("fixture {} missing param {}", fx.algo, key))
}

fn param_usize(fx: &Fixture, key: &str) -> usize {
    fx.params
        .get(key)
        .and_then(|v| v.as_u64())
        .unwrap_or_else(|| panic!("fixture {} missing usize param {}", fx.algo, key)) as usize
}

#[test]
fn pelt_l2_three_level_shifts() {
    let fx = load_fixture("pelt_l2_3_level_shifts");
    assert_eq!(fx.cost, "l2");
    assert_eq!(fx.mode, "pen");
    let signal = Signal::univariate(&fx.signal);
    let mut detector = PeltDetector::new(CostL2::new())
        .min_size(param_usize(&fx, "min_size"))
        .jump(param_usize(&fx, "jump"));
    detector.fit(&signal).unwrap();
    let result = detector.predict_pen(param(&fx, "pen")).unwrap();
    assert_eq!(result.bkps, fx.bkps, "PELT bkps must match ruptures");
    let rust_cost = total_l2_cost(&fx.signal, &result.bkps);
    let abs_diff = (rust_cost - fx.total_cost).abs();
    let rel_tol = 1e-6 * fx.total_cost.abs().max(1.0);
    assert!(
        abs_diff <= rel_tol,
        "total cost mismatch: rust = {}, ruptures = {} (Δ = {})",
        rust_cost,
        fx.total_cost,
        abs_diff
    );
}

#[test]
fn dynp_l2_n_bkps() {
    let fx = load_fixture("dynp_l2_n_bkps_3");
    let signal = Signal::univariate(&fx.signal);
    let mut detector = DynpDetector::new(CostL2::new())
        .min_size(param_usize(&fx, "min_size"))
        .jump(param_usize(&fx, "jump"));
    detector.fit(&signal).unwrap();
    let n_bkps = param_usize(&fx, "n_bkps");
    let result = detector.predict_n_bkps(n_bkps).unwrap();
    assert_eq!(result.bkps, fx.bkps);
    let rust_cost = total_l2_cost(&fx.signal, &result.bkps);
    let abs_diff = (rust_cost - fx.total_cost).abs();
    assert!(
        abs_diff <= 1e-6 * fx.total_cost.abs().max(1.0),
        "Dynp total cost: rust {} vs ruptures {}",
        rust_cost,
        fx.total_cost
    );
}

#[test]
fn binseg_l2_pen() {
    let fx = load_fixture("binseg_l2_pen_5");
    let signal = Signal::univariate(&fx.signal);
    let mut detector = BinsegDetector::new(CostL2::new())
        .min_size(param_usize(&fx, "min_size"))
        .jump(param_usize(&fx, "jump"));
    detector.fit(&signal).unwrap();
    let result = detector.predict_pen(param(&fx, "pen")).unwrap();
    assert_eq!(result.bkps, fx.bkps, "Binseg bkps must match ruptures");
    let rust_cost = total_l2_cost(&fx.signal, &result.bkps);
    assert!((rust_cost - fx.total_cost).abs() <= 1e-6 * fx.total_cost.abs().max(1.0));
}

#[test]
fn bottom_up_l2_n_bkps() {
    let fx = load_fixture("bottom_up_l2_n_bkps_2");
    let signal = Signal::univariate(&fx.signal);
    let mut detector = BottomUpDetector::new(CostL2::new())
        .min_size(param_usize(&fx, "min_size"))
        .jump(param_usize(&fx, "jump"));
    detector.fit(&signal).unwrap();
    let result = detector.predict_n_bkps(param_usize(&fx, "n_bkps")).unwrap();
    assert_eq!(result.bkps, fx.bkps);
    let rust_cost = total_l2_cost(&fx.signal, &result.bkps);
    assert!((rust_cost - fx.total_cost).abs() <= 1e-6 * fx.total_cost.abs().max(1.0));
}

#[test]
fn window_l2_n_bkps() {
    let fx = load_fixture("window_l2_n_bkps_2");
    let signal = Signal::univariate(&fx.signal);
    let mut detector = WindowDetector::new(CostL2::new())
        .width(param_usize(&fx, "width"))
        .min_size(param_usize(&fx, "min_size"))
        .jump(param_usize(&fx, "jump"));
    detector.fit(&signal).unwrap();
    let result = detector.predict_n_bkps(param_usize(&fx, "n_bkps")).unwrap();
    // Window is approximate; allow ±2 jump-units of tolerance on the bkps.
    let jump = param_usize(&fx, "jump");
    let tol = (2 * jump) as isize;
    assert_eq!(result.bkps.last(), fx.bkps.last(), "terminal n must match");
    assert_eq!(
        result.bkps.len(),
        fx.bkps.len(),
        "must produce the same number of bkps"
    );
    for (rust_bkp, rup_bkp) in result.bkps.iter().zip(fx.bkps.iter()) {
        let diff = (*rust_bkp as isize - *rup_bkp as isize).abs();
        assert!(
            diff <= tol,
            "Window bkp diff {} exceeds ±{}: rust = {}, ruptures = {}",
            diff,
            tol,
            rust_bkp,
            rup_bkp
        );
    }
}
