//! V10 Block M — Fractional-differentiation f64 adapter for the v10 feature
//! pipeline.
//!
//! Thin streaming wrapper around `ml_labeling::fractional_diff::FractionalCoeffs`
//! providing an **f64 in / f64 out** interface. The existing
//! `ml_labeling::fractional_diff::StreamingDifferentiator` uses an i64
//! fixed-point I/O (designed for the label pipeline's `i64` storage with
//! ×10_000 scaling) which is wrong for our f64-resident close prices and
//! introduces precision loss. This module reuses the **shared binomial
//! coefficient calculator** (`FractionalCoeffs`) but maintains the rolling
//! window directly in f64 — no shortcuts.
//!
//! ## Motivation (Lopez de Prado *AdvFinML* Ch. 5)
//!
//! Standard differencing (`Δp = p_t − p_{t-1}`) achieves stationarity by
//! removing ALL memory. Fractional differencing applies a non-integer `d` such
//! that the series becomes stationary while preserving long-memory structure.
//! For asset prices, typical `d` values:
//!   - `d = 0.3` — light differencing, retains most autocorrelation
//!   - `d = 0.5` — balanced (the most-cited value in the literature)
//!   - `d = 0.7` — heavy differencing, near-fully-differenced
//!
//! The v10 fxcache pipeline emits all three as separate features (Block M, 3
//! dims): the model picks which trade-off works best at our bar resolution.

use std::collections::VecDeque;

use ml_labeling::fractional_diff::FractionalCoeffs;

/// Streaming fractional differentiator with **native f64 I/O**.
///
/// Holds a rolling window of the most recent `max_lags` raw values and the
/// shared `FractionalCoeffs` (binomial-coefficient series derived from `d` and
/// `threshold`). Each `process(x)` call appends `x` to the window, evicts the
/// oldest if over capacity, then computes
///
/// ```text
///   y_t = Σ_{i=0}^{N-1} c_i · x_{t−i}
/// ```
///
/// where `c_i` are the binomial coefficients and `N = min(coeff.len(),
/// window.len())`.
///
/// Returns `f64::NAN` until the window has reached `min_window` samples (the
/// warmup gate prevents reporting under-determined values that can later
/// poison rolling-window normalization downstream).
#[derive(Debug, Clone)]
pub struct FracDiffF64 {
    coeffs: FractionalCoeffs,
    window: VecDeque<f64>,
    max_lags: usize,
    min_window: usize,
}

impl FracDiffF64 {
    /// Construct with explicit knobs.
    ///
    /// * `diff_order` — the fractional differencing power `d` (commonly 0.3-0.7
    ///   for price series).
    /// * `max_lags` — maximum window size; also caps the number of binomial
    ///   coefficients computed (early-stop when `|c_i| < threshold`).
    /// * `threshold` — coefficient magnitude below which we stop generating
    ///   coefficients. `1e-4` is the Lopez de Prado default.
    /// * `min_window` — minimum samples before `process` returns a non-NaN
    ///   value. Pass `0` to default to `max_lags / 2`.
    pub fn new(diff_order: f64, max_lags: usize, threshold: f64, min_window: usize) -> Self {
        let coeffs = FractionalCoeffs::new(diff_order, max_lags, threshold);
        let effective_min = if min_window == 0 {
            max_lags / 2
        } else {
            min_window
        };
        Self {
            coeffs,
            window: VecDeque::with_capacity(max_lags),
            max_lags,
            min_window: effective_min,
        }
    }

    /// Conventional defaults: `max_lags=100`, `threshold=1e-4`, `min_window=50`.
    /// Caller picks `d` (0.3 / 0.5 / 0.7).
    pub fn with_defaults(diff_order: f64) -> Self {
        Self::new(diff_order, 100, 1e-4, 50)
    }

    /// Process one new value. Returns the fractionally-differenced output, or
    /// `f64::NAN` if fewer than `min_window` samples have been observed.
    pub fn process(&mut self, value: f64) -> f64 {
        self.window.push_back(value);
        if self.window.len() > self.max_lags {
            self.window.pop_front();
        }
        if self.window.len() < self.min_window {
            return f64::NAN;
        }
        let n = self.coeffs.len().min(self.window.len());
        let mut diff_value = 0.0_f64;
        for i in 0..n {
            let window_idx = self.window.len() - 1 - i;
            diff_value += self.coeffs.get(i) * self.window[window_idx];
        }
        diff_value
    }

    /// Clear the rolling window. The pre-computed coefficients are retained.
    pub fn reset(&mut self) {
        self.window.clear();
    }

    /// Current window occupancy.
    pub fn window_size(&self) -> usize {
        self.window.len()
    }

    /// `true` when `process` will return non-NaN values.
    pub fn is_ready(&self) -> bool {
        self.window.len() >= self.min_window
    }

    /// Number of active binomial coefficients (post early-stop truncation).
    pub fn coeff_count(&self) -> usize {
        self.coeffs.len()
    }
}

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

    #[test]
    fn test_frac_diff_returns_nan_until_warmup() {
        let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
        for i in 0..49 {
            let v = fd.process((i as f64) + 100.0);
            assert!(v.is_nan(), "expected NaN at sample {i} (pre-warmup), got {v}");
        }
        let v = fd.process(149.0);
        assert!(v.is_finite(), "expected finite at sample 50 (post-warmup), got {v}");
    }

    #[test]
    fn test_frac_diff_zero_order_is_identity() {
        // d=0 → c_0 = 1.0, all other coefficients = 0 (or below threshold).
        // process(x) should converge to the latest x once warmup completes.
        let mut fd = FracDiffF64::new(0.0, 100, 1e-4, 5);
        let mut last = 0.0;
        for i in 0..60 {
            last = fd.process((i as f64) + 100.0);
        }
        // i=59 (0-indexed) → 159
        assert!(
            (last - 159.0).abs() < 1e-9,
            "d=0 must act as identity, got {last}"
        );
    }

    #[test]
    fn test_frac_diff_d1_approximates_first_difference() {
        // d=1 binomial coefficients: c_0=1, c_1=-1, c_2=0, c_3=0, ...
        // Applied to a linear ramp x_t = t, first difference = 1.
        let mut fd = FracDiffF64::new(1.0, 100, 1e-4, 5);
        let mut latest_diff = f64::NAN;
        for i in 0..30 {
            let v = fd.process(i as f64);
            if v.is_finite() {
                latest_diff = v;
            }
        }
        // Steady-state first-difference of linear ramp = 1.0
        assert!(
            (latest_diff - 1.0).abs() < 1e-6,
            "d=1.0 on linear ramp must give first-difference ≈ 1.0, got {latest_diff}"
        );
    }

    #[test]
    fn test_frac_diff_d05_produces_finite_bounded_outputs_on_log_price() {
        // Realistic input: log price around log(110) ≈ 4.7 with mild oscillation.
        // Expected: d=0.5 differencing yields a near-zero-mean stationary
        // series with bounded magnitude (definitely no NaN/Inf, |v| << input).
        let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
        let mut max_abs = 0.0_f64;
        let mut n_finite = 0usize;
        for i in 0..200 {
            let log_p = ((i as f64) * 0.01).sin() + 4.7;
            let v = fd.process(log_p);
            if v.is_finite() {
                max_abs = max_abs.max(v.abs());
                n_finite += 1;
            }
        }
        assert!(
            n_finite > 100,
            "expected >100 finite samples post-warmup, got {n_finite}"
        );
        assert!(
            max_abs < 100.0,
            "d=0.5 outputs should be bounded; max|v|={max_abs} exceeds sanity check"
        );
    }

    #[test]
    fn test_frac_diff_reset_clears_window_preserves_coeffs() {
        let mut fd = FracDiffF64::new(0.5, 100, 1e-4, 50);
        for i in 0..60 {
            fd.process(i as f64);
        }
        let coeffs_before = fd.coeff_count();
        assert!(fd.is_ready());

        fd.reset();
        assert_eq!(fd.window_size(), 0, "reset should clear the window");
        assert!(!fd.is_ready(), "post-reset should require re-warmup");
        assert_eq!(
            fd.coeff_count(),
            coeffs_before,
            "reset must NOT recompute coefficients"
        );
    }
}
