//! Signal combination and processing for prediction market strategies.
//!
//! All functions are `#[inline]` for zero-overhead inlining at call sites.

use pyo3::prelude::*;

/// Combine multiple (value, weight) signals using the specified method.
///
/// Methods:
/// - "weighted_avg" (default): weighted average of signal values
/// - "rank": average of rank-normalized values (0..1)
/// - "zscore": weighted average of z-score normalized values, then sigmoid to [0,1]
///
/// Returns 0.0 if no valid signals.
#[pyfunction]
#[pyo3(signature = (signals, method="weighted_avg"))]
#[inline]
pub fn combine_signals(signals: Vec<(f64, f64)>, method: &str) -> f64 {
    if signals.is_empty() {
        return 0.0;
    }

    // Filter out NaN/Inf values
    let valid: Vec<(f64, f64)> = signals
        .into_iter()
        .filter(|(v, w)| v.is_finite() && w.is_finite() && *w > 0.0)
        .collect();

    if valid.is_empty() {
        return 0.0;
    }

    match method {
        "rank" => {
            // Rank-normalize: sort values, assign ranks 0..1, average
            let n = valid.len();
            if n == 1 {
                return valid[0].0;
            }
            let mut indexed: Vec<(usize, f64)> =
                valid.iter().enumerate().map(|(i, (v, _))| (i, *v)).collect();
            indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));

            let mut ranks = vec![0.0f64; n];
            for (rank, (orig_idx, _)) in indexed.iter().enumerate() {
                ranks[*orig_idx] = rank as f64 / (n - 1).max(1) as f64;
            }

            let total_weight: f64 = valid.iter().map(|(_, w)| w).sum();
            if total_weight <= 0.0 {
                return 0.0;
            }
            let weighted_sum: f64 = ranks
                .iter()
                .zip(valid.iter())
                .map(|(r, (_, w))| r * w)
                .sum();
            weighted_sum / total_weight
        }
        "zscore" => {
            let values: Vec<f64> = valid.iter().map(|(v, _)| *v).collect();
            let n = values.len() as f64;
            let mean = values.iter().sum::<f64>() / n;
            let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
            let std_dev = variance.sqrt();

            if std_dev < 1e-12 {
                // All values are the same — return mean
                return mean;
            }

            let total_weight: f64 = valid.iter().map(|(_, w)| w).sum();
            if total_weight <= 0.0 {
                return 0.0;
            }
            let weighted_z: f64 = valid
                .iter()
                .map(|(v, w)| ((v - mean) / std_dev) * w)
                .sum::<f64>()
                / total_weight;

            // Sigmoid to [0,1]
            1.0 / (1.0 + (-weighted_z).exp())
        }
        _ => {
            // weighted_avg (default)
            let total_weight: f64 = valid.iter().map(|(_, w)| w).sum();
            if total_weight <= 0.0 {
                return 0.0;
            }
            let weighted_sum: f64 = valid.iter().map(|(v, w)| v * w).sum();
            weighted_sum / total_weight
        }
    }
}

/// Exponential moving average of a series.
///
/// `span` determines the decay factor: alpha = 2 / (span + 1).
/// Returns the final EMA value, or 0.0 if input is empty.
#[pyfunction]
#[inline]
pub fn ema(values: Vec<f64>, span: usize) -> f64 {
    if values.is_empty() || span == 0 {
        return 0.0;
    }
    let alpha = 2.0 / (span as f64 + 1.0);
    let mut result = values[0];
    if result.is_nan() || result.is_infinite() {
        result = 0.0;
    }
    for &v in &values[1..] {
        if v.is_finite() {
            result = alpha * v + (1.0 - alpha) * result;
        }
    }
    result
}

/// Z-score normalization: (value - mean) / std.
///
/// Returns 0.0 if std is zero or any input is NaN/Inf.
#[pyfunction]
#[inline]
pub fn zscore(value: f64, mean: f64, std: f64) -> f64 {
    if !value.is_finite() || !mean.is_finite() || !std.is_finite() || std.abs() < 1e-12 {
        return 0.0;
    }
    (value - mean) / std
}

/// Exponential time decay weight.
///
/// `age_secs`: how old the observation is (seconds).
/// `half_life_secs`: time for the weight to decay to 0.5.
///
/// Returns weight in (0, 1]. Returns 0.0 for invalid inputs.
#[pyfunction]
#[inline]
pub fn decay_weight(age_secs: f64, half_life_secs: f64) -> f64 {
    if !age_secs.is_finite()
        || !half_life_secs.is_finite()
        || age_secs < 0.0
        || half_life_secs <= 0.0
    {
        return 0.0;
    }
    let lambda = (2.0_f64).ln() / half_life_secs;
    (-lambda * age_secs).exp()
}

/// Register all signal functions in the Python module.
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(combine_signals, m)?)?;
    m.add_function(wrap_pyfunction!(ema, m)?)?;
    m.add_function(wrap_pyfunction!(zscore, m)?)?;
    m.add_function(wrap_pyfunction!(decay_weight, m)?)?;
    Ok(())
}

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

    #[test]
    fn combine_weighted_avg() {
        // (0.6, 1.0) and (0.8, 2.0) → (0.6*1 + 0.8*2) / 3 = 2.2/3 ≈ 0.7333
        let result = combine_signals(vec![(0.6, 1.0), (0.8, 2.0)], "weighted_avg");
        assert!((result - 2.2 / 3.0).abs() < 1e-10);
    }

    #[test]
    fn combine_weighted_avg_single() {
        let result = combine_signals(vec![(0.5, 1.0)], "weighted_avg");
        assert!((result - 0.5).abs() < 1e-10);
    }

    #[test]
    fn combine_empty() {
        assert_eq!(combine_signals(vec![], "weighted_avg"), 0.0);
    }

    #[test]
    fn combine_nan_filtered() {
        let result = combine_signals(vec![(f64::NAN, 1.0), (0.5, 1.0)], "weighted_avg");
        assert!((result - 0.5).abs() < 1e-10);
    }

    #[test]
    fn combine_rank() {
        // (0.2, 1.0), (0.5, 1.0), (0.8, 1.0) → ranks 0.0, 0.5, 1.0 → avg 0.5
        let result = combine_signals(vec![(0.2, 1.0), (0.5, 1.0), (0.8, 1.0)], "rank");
        assert!((result - 0.5).abs() < 1e-10);
    }

    #[test]
    fn combine_zscore_equal() {
        // All same value → returns that value (no normalization possible)
        let result = combine_signals(vec![(0.5, 1.0), (0.5, 1.0)], "zscore");
        assert!((result - 0.5).abs() < 1e-10);
    }

    #[test]
    fn combine_zscore_symmetric() {
        // Symmetric values with equal weights → sigmoid(0) = 0.5
        let result = combine_signals(vec![(0.3, 1.0), (0.7, 1.0)], "zscore");
        assert!((result - 0.5).abs() < 1e-10);
    }

    #[test]
    fn ema_basic() {
        let result = ema(vec![1.0, 2.0, 3.0, 4.0], 3);
        // alpha = 0.5, starting at 1.0
        // 1.0 → 0.5*2 + 0.5*1 = 1.5 → 0.5*3 + 0.5*1.5 = 2.25 → 0.5*4 + 0.5*2.25 = 3.125
        assert!((result - 3.125).abs() < 1e-10);
    }

    #[test]
    fn ema_single() {
        assert!((ema(vec![5.0], 10) - 5.0).abs() < 1e-10);
    }

    #[test]
    fn ema_empty() {
        assert_eq!(ema(vec![], 10), 0.0);
    }

    #[test]
    fn ema_nan_skipped() {
        let result = ema(vec![1.0, f64::NAN, 3.0], 3);
        // NaN skipped: 1.0 → 0.5*3 + 0.5*1 = 2.0
        assert!((result - 2.0).abs() < 1e-10);
    }

    #[test]
    fn zscore_basic() {
        // (10 - 5) / 2 = 2.5
        assert!((zscore(10.0, 5.0, 2.0) - 2.5).abs() < 1e-10);
    }

    #[test]
    fn zscore_zero_std() {
        assert_eq!(zscore(10.0, 5.0, 0.0), 0.0);
    }

    #[test]
    fn zscore_nan() {
        assert_eq!(zscore(f64::NAN, 5.0, 2.0), 0.0);
    }

    #[test]
    fn decay_weight_zero_age() {
        assert!((decay_weight(0.0, 60.0) - 1.0).abs() < 1e-10);
    }

    #[test]
    fn decay_weight_one_half_life() {
        let w = decay_weight(60.0, 60.0);
        assert!((w - 0.5).abs() < 1e-10);
    }

    #[test]
    fn decay_weight_two_half_lives() {
        let w = decay_weight(120.0, 60.0);
        assert!((w - 0.25).abs() < 1e-10);
    }

    #[test]
    fn decay_weight_invalid() {
        assert_eq!(decay_weight(-1.0, 60.0), 0.0);
        assert_eq!(decay_weight(10.0, 0.0), 0.0);
        assert_eq!(decay_weight(10.0, -5.0), 0.0);
        assert_eq!(decay_weight(f64::NAN, 60.0), 0.0);
    }

    #[test]
    fn combine_zero_weight_filtered() {
        let result = combine_signals(vec![(0.9, 0.0), (0.5, 1.0)], "weighted_avg");
        assert!((result - 0.5).abs() < 1e-10);
    }
}
