//! V10 Block Y — Online LOB Principal Component Analysis.
//!
//! ## Motivation
//!
//! The raw order book at level L is described by 4L scalars:
//!   `(P_b^1, V_b^1, P_a^1, V_a^1, …, P_b^L, V_b^L, P_a^L, V_a^L)`
//!
//! These features are highly correlated (e.g., bid prices at L1-L5 move
//! together). Feeding them all to an MLP is inefficient: most variance lies
//! along a few latent dimensions. The standard remedy is **principal
//! component analysis** — find orthogonal directions that capture maximal
//! variance.
//!
//! Per Cont & Stoikov (2014) and Cont/Cucuringu's spectral graph work,
//! the first ~3 PCs of an LOB capture:
//! - **PC1**: bid-ask asymmetry (depth imbalance signed by side)
//! - **PC2**: depth concavity (whether book thickens or thins with depth)
//! - **PC3**: spread / mid-price drift
//!
//! ## Algorithm: Sanger's Generalized Hebbian Algorithm (GHA)
//!
//! Sanger (1989) gives an online rule that converges to the top-k
//! eigenvectors of the data covariance:
//!
//! ```text
//!   y_i = w_i · x
//!   x_i_residual = x - Σ_{j ≤ i} y_j · w_j   // deflate by lower-rank PCs
//!   w_i ← w_i + η · (y_i · x_i_residual)     // Oja-style update
//!   w_i ← w_i / ||w_i||                       // normalize
//! ```
//!
//! Output: the k projections `y_i` are the **PC scores** for this snapshot.
//!
//! ## Input feature construction
//!
//! Per-snapshot delta features (vs previous snapshot):
//!   `[Δbid_px_1, …, Δbid_px_L, Δbid_sz_1, …, Δbid_sz_L,
//!     Δask_px_1, …, Δask_px_L, Δask_sz_1, …, Δask_sz_L]`
//!
//! At L=5 levels → 20-dimensional input. We track 3 PCs → 3 scalar outputs.

use data::providers::databento::mbp10::{BidAskPair, Mbp10Snapshot};

/// V10 Block Y — Online LOB PCA via Sanger's rule.
///
/// Streaming algorithm with O(K × D) per-update cost (K = num components, D =
/// feature dim). Converges to true top-K eigenvectors of the data covariance
/// in expectation.
#[derive(Debug, Clone)]
pub struct OnlineLobPca {
    n_components: usize,
    feature_dim: usize,
    levels: usize,
    /// Each component is a `feature_dim`-vector; we track `n_components` total.
    components: Vec<Vec<f64>>,
    /// Sanger's rule learning rate (typically 1e-3 to 1e-2).
    eta: f64,
    /// Previous snapshot for delta computation.
    prev_snapshot: Option<Mbp10Snapshot>,
}

impl OnlineLobPca {
    /// Create with explicit settings. Components initialized to deterministic
    /// orthonormal seeds (sin/cos based) — robust cold start without RNG.
    pub fn new(n_components: usize, levels: usize, eta: f64) -> Self {
        let feature_dim = 4 * levels; // (bid_px Δ, bid_sz Δ, ask_px Δ, ask_sz Δ) × L
        let components = (0..n_components)
            .map(|c| {
                let v: Vec<f64> = (0..feature_dim)
                    .map(|i| {
                        // Distinct seed direction per component, then normalize.
                        ((c + 1) as f64 * 0.7 + (i + 1) as f64 * 0.13).sin()
                    })
                    .collect();
                normalize(v)
            })
            .collect();
        Self {
            n_components,
            feature_dim,
            levels,
            components,
            eta,
            prev_snapshot: None,
        }
    }

    /// V10 defaults: 3 components, 5 levels, η=5e-3.
    pub fn with_defaults() -> Self {
        Self::new(3, 5, 5e-3)
    }

    /// Update the PCA with a new snapshot and return the projection scores.
    /// Returns `[score_PC1, …, score_PC{n}]` (padded with zeros to length 3
    /// if `n_components < 3`).
    ///
    /// The first call produces all-zero scores (no previous snapshot for
    /// delta). Subsequent calls update + project.
    pub fn update_and_project(&mut self, snapshot: &Mbp10Snapshot) -> [f64; 3] {
        let mut output = [0.0_f64; 3];

        let x: Vec<f64> = match self.prev_snapshot.as_ref() {
            Some(prev) => build_delta_features(snapshot, prev, self.levels),
            None => {
                self.prev_snapshot = Some(snapshot.clone());
                return output; // cold start: no delta available
            }
        };
        self.prev_snapshot = Some(snapshot.clone());

        if x.len() != self.feature_dim {
            return output; // dimension mismatch (e.g., short book) → no-op
        }

        // Sanger's rule: for each PC i in 1..n
        //   y_i = w_i · x
        //   x_residual = x - Σ_{j ≤ i} y_j · w_j
        //   w_i ← w_i + η · y_i · x_residual
        //   w_i ← w_i / ||w_i||
        let mut x_residual = x.clone();
        for i in 0..self.n_components {
            let y_i: f64 = dot(&self.components[i], &x_residual);
            // Update direction
            let mut w_i = self.components[i].clone();
            for k in 0..self.feature_dim {
                w_i[k] += self.eta * y_i * x_residual[k];
            }
            let w_i = normalize(w_i);
            // Deflate residual by THIS component's contribution
            for k in 0..self.feature_dim {
                x_residual[k] -= y_i * w_i[k];
            }
            self.components[i] = w_i;
            if i < 3 {
                output[i] = y_i;
            }
        }

        output
    }

    /// Whether enough data has been seen to produce non-zero scores.
    pub fn is_ready(&self) -> bool {
        self.prev_snapshot.is_some()
    }

    pub fn reset(&mut self) {
        self.prev_snapshot = None;
    }

    /// Borrow the current i-th component (unit-norm direction in feature space).
    pub fn component(&self, i: usize) -> Option<&Vec<f64>> {
        self.components.get(i)
    }
}

impl Default for OnlineLobPca {
    fn default() -> Self {
        Self::with_defaults()
    }
}

/// Build the 4L-dim delta feature vector from consecutive snapshots.
fn build_delta_features(current: &Mbp10Snapshot, previous: &Mbp10Snapshot, levels: usize) -> Vec<f64> {
    let mut features = Vec::with_capacity(4 * levels);
    let n = current.levels.len().min(previous.levels.len()).min(levels);
    // First: bid_px deltas (L1..L_levels)
    for i in 0..levels {
        if i < n {
            let c = BidAskPair::price_to_f64(current.levels[i].bid_px);
            let p = BidAskPair::price_to_f64(previous.levels[i].bid_px);
            features.push(c - p);
        } else {
            features.push(0.0);
        }
    }
    // Then: bid_sz deltas
    for i in 0..levels {
        if i < n {
            let c = current.levels[i].bid_sz as f64;
            let p = previous.levels[i].bid_sz as f64;
            features.push(c - p);
        } else {
            features.push(0.0);
        }
    }
    // Then: ask_px deltas
    for i in 0..levels {
        if i < n {
            let c = BidAskPair::price_to_f64(current.levels[i].ask_px);
            let p = BidAskPair::price_to_f64(previous.levels[i].ask_px);
            features.push(c - p);
        } else {
            features.push(0.0);
        }
    }
    // Then: ask_sz deltas
    for i in 0..levels {
        if i < n {
            let c = current.levels[i].ask_sz as f64;
            let p = previous.levels[i].ask_sz as f64;
            features.push(c - p);
        } else {
            features.push(0.0);
        }
    }
    features
}

fn dot(a: &[f64], b: &[f64]) -> f64 {
    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}

fn normalize(mut v: Vec<f64>) -> Vec<f64> {
    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
    if norm > 1e-12 {
        for x in &mut v {
            *x /= norm;
        }
    }
    v
}

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

    fn snap_5l(bid_px_l1: i64, bid_sz_l1: u32, ask_sz_l1: u32) -> Mbp10Snapshot {
        let levels: Vec<BidAskPair> = (0..5)
            .map(|i| BidAskPair {
                bid_px: bid_px_l1 - (i as i64) * 1_000_000_000,
                bid_sz: bid_sz_l1 + (i as u32) * 10,
                bid_ct: 5,
                ask_px: (bid_px_l1 + 40_000_000_000) + (i as i64) * 1_000_000_000,
                ask_sz: ask_sz_l1 + (i as u32) * 10,
                ask_ct: 5,
            })
            .collect();
        Mbp10Snapshot::new("ES.FUT".to_owned(), 0, levels, 0, 0)
    }

    #[test]
    fn test_cold_start_returns_zeros() {
        let mut pca = OnlineLobPca::with_defaults();
        let snap = snap_5l(100_000_000_000, 100, 100);
        let scores = pca.update_and_project(&snap);
        assert_eq!(scores, [0.0; 3], "cold start: no delta available → zero scores");
        assert!(pca.is_ready(), "after first call, ready for delta on next call");
    }

    #[test]
    fn test_unchanged_book_zero_scores() {
        let mut pca = OnlineLobPca::with_defaults();
        let snap = snap_5l(100_000_000_000, 100, 100);
        pca.update_and_project(&snap); // initialize prev
        let scores = pca.update_and_project(&snap);
        // No delta → x = 0 → all projections = 0
        for s in scores.iter() {
            assert!(s.abs() < 1e-9, "unchanged book → zero score, got {s}");
        }
    }

    #[test]
    fn test_components_are_unit_norm() {
        let pca = OnlineLobPca::new(3, 5, 5e-3);
        for i in 0..3 {
            let c = pca.component(i).expect("component exists");
            let norm: f64 = c.iter().map(|x| x * x).sum::<f64>().sqrt();
            assert!(
                (norm - 1.0).abs() < 1e-9,
                "component {i} should be unit norm, got {norm}"
            );
        }
    }

    #[test]
    fn test_components_converge_under_repeated_structured_input() {
        // Feed many snapshots where bid_sz at L1 oscillates while ask_sz stays
        // constant — this creates variance concentrated in the bid_sz_L1
        // dimension. PC1 should learn to capture this.
        let mut pca = OnlineLobPca::new(2, 5, 1e-2);
        let mut prev = snap_5l(100_000_000_000, 100, 100);
        pca.update_and_project(&prev); // init
        let initial_pc1 = pca.component(0).cloned().expect("PC1 exists");

        // 500 oscillations with structured variance
        for i in 1..=500 {
            let sz = (100 + (i % 50) * 20) as u32; // oscillates 100..1100
            let curr = snap_5l(100_000_000_000, sz, 100);
            pca.update_and_project(&curr);
            prev = curr;
        }
        let final_pc1 = pca.component(0).cloned().expect("PC1 exists");

        // PC1 should have moved away from random init (any change indicates learning)
        let cosine: f64 = initial_pc1
            .iter()
            .zip(final_pc1.iter())
            .map(|(a, b)| a * b)
            .sum();
        let _ = prev;
        assert!(
            (cosine - 1.0).abs() > 1e-4,
            "PC1 should have learned (moved from random init); cosine sim = {cosine}"
        );
    }

    #[test]
    fn test_reset_clears_prev_snapshot() {
        let mut pca = OnlineLobPca::with_defaults();
        let snap = snap_5l(100_000_000_000, 100, 100);
        pca.update_and_project(&snap);
        assert!(pca.is_ready());
        pca.reset();
        assert!(!pca.is_ready(), "reset should clear prev_snapshot");
    }

    #[test]
    fn test_truncated_book_returns_zeros() {
        // 3-level book vs 5-level PCA setup → delta vec will be 4*3=12 not 4*5=20
        // → dimension mismatch → no-op (zeros).
        // Actually our build_delta_features pads with zeros to `levels`, so the
        // dimension SHOULD match. Test that this works.
        let mut pca = OnlineLobPca::with_defaults();
        let levels: Vec<BidAskPair> = (0..3)
            .map(|i| BidAskPair {
                bid_px: 100_000_000_000 - (i as i64) * 1_000_000_000,
                bid_sz: 100,
                bid_ct: 5,
                ask_px: 100_040_000_000 + (i as i64) * 1_000_000_000,
                ask_sz: 100,
                ask_ct: 5,
            })
            .collect();
        let snap = Mbp10Snapshot::new("ES.FUT".to_owned(), 0, levels, 0, 0);
        pca.update_and_project(&snap);
        let result = pca.update_and_project(&snap);
        // Same snapshot → zero delta → zero scores (regardless of level count).
        for s in result.iter() {
            assert!(s.abs() < 1e-9, "no-delta should give zero score, got {s}");
        }
    }
}
