//! Multi-resolution input aggregation config.
//!
//! Builds each input position in a fixed-length window from a different
//! time-scale aggregation: e.g. 10 raw ticks + 10 aggregates over 30 ticks
//! each + 12 aggregates over 100 ticks each. Total positions = sum of counts.
//!
//! See `docs/superpowers/plans/2026-05-22-multi-resolution-input.md` for
//! design rationale: the seq_len=32 raw-tick window cannot encode the
//! K=1000 horizon's macrostructure (1:31 context-to-horizon ratio).

use std::str::FromStr;

use anyhow::{bail, Result};

/// One aggregation scale: `(window_ticks, position_count)`.
/// `window_ticks == 1` means raw (one snapshot per position).
pub type AggregationScale = (usize, usize);

/// Multi-resolution input window. Scales are stored fine-to-coarse,
/// matching the CLI grammar `1:10,30:10,100:12`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MultiResolutionConfig {
    scales: Vec<AggregationScale>,
}

impl MultiResolutionConfig {
    /// Production default: 32 raw ticks (single-scale). Restores the
    /// pre-multi-resolution baseline behavior because the experimental
    /// `default_three_scale` Phase 1 layout regressed all horizons in
    /// the alpha-perception-htpp6 smoke (2026-05-22; auc_h100: 0.681 →
    /// 0.512, auc_h1000: 0.576 → 0.526). Used by alpha_train CLI default
    /// and ml-backtesting harness.
    pub fn single_scale_32() -> Self {
        Self { scales: vec![(1, 32)] }
    }

    /// Phase 1 EXPERIMENTAL design: 10 raw + 10 aggregated@30 + 12
    /// aggregated@100 = 32 positions covering 1510 ticks. Falsified by
    /// alpha-perception-htpp6 (2026-05-22) — all horizons regressed
    /// vs single-scale. Retained for future variant experiments
    /// (sub-variants like 28 raw + 4 agg@100); NOT the production default.
    pub fn default_three_scale() -> Self {
        Self { scales: vec![(1, 10), (30, 10), (100, 12)] }
    }

    /// Construct from an arbitrary scales list. Validates the same
    /// invariants as `FromStr::from_str`. Most call sites should use
    /// `default_three_scale()` or `FromStr`; this exists for tests that
    /// pin specific layouts.
    pub fn from_scales(scales: Vec<AggregationScale>) -> Result<Self> {
        validate_scales(&scales)?;
        Ok(Self { scales })
    }

    /// Total number of input positions across all scales. The trainer's
    /// CfC + heads + Adam scratch are sized for this.
    pub fn total_positions(&self) -> usize {
        self.scales.iter().map(|(_, c)| c).sum()
    }

    /// Number of source snapshots needed before the anchor's forward
    /// edge to fill the entire window. Used by the loader's min-snapshot
    /// guard.
    pub fn required_lookback_ticks(&self) -> usize {
        self.scales.iter().map(|(w, c)| w * c).sum()
    }

    /// Scales in fine-to-coarse order (matches CLI grammar).
    pub fn scales(&self) -> &[AggregationScale] {
        &self.scales
    }
}

fn validate_scales(scales: &[AggregationScale]) -> Result<()> {
    if scales.is_empty() {
        bail!("multi-resolution: at least one scale required");
    }
    let mut prev_scale = 0_usize;
    for &(scale, count) in scales {
        if scale == 0 {
            bail!("multi-resolution: scale must be > 0");
        }
        if count == 0 {
            bail!("multi-resolution: count must be > 0");
        }
        if scale <= prev_scale {
            bail!(
                "multi-resolution: scales must be strictly increasing; \
                 got scale={scale} after prev_scale={prev_scale}"
            );
        }
        prev_scale = scale;
    }
    Ok(())
}

impl FromStr for MultiResolutionConfig {
    type Err = anyhow::Error;

    /// Parse `--multi-resolution` CLI value: `scale:count[,scale:count]...`.
    fn from_str(s: &str) -> Result<Self> {
        let mut scales: Vec<AggregationScale> = Vec::new();
        for raw in s.split(',') {
            let raw = raw.trim();
            if raw.is_empty() {
                bail!("multi-resolution: empty segment in '{s}'");
            }
            let (scale_s, count_s) = raw
                .split_once(':')
                .ok_or_else(|| anyhow::anyhow!("multi-resolution: missing ':' in '{raw}'"))?;
            let scale: usize = scale_s.trim().parse().map_err(|e| {
                anyhow::anyhow!("multi-resolution: scale '{scale_s}' not a usize: {e}")
            })?;
            let count: usize = count_s.trim().parse().map_err(|e| {
                anyhow::anyhow!("multi-resolution: count '{count_s}' not a usize: {e}")
            })?;
            scales.push((scale, count));
        }
        validate_scales(&scales)?;
        Ok(Self { scales })
    }
}

impl std::fmt::Display for MultiResolutionConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for (i, (scale, count)) in self.scales.iter().enumerate() {
            if i > 0 {
                write!(f, ",")?;
            }
            write!(f, "{scale}:{count}")?;
        }
        Ok(())
    }
}

use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS};

/// Aggregate a slice of consecutive `Mbp10RawInput`s into one pseudo-snapshot.
/// Field semantics:
/// - Book prices/sizes: arithmetic mean.
/// - `prev_mid`: mid of first snapshot — `cur.mid - agg.prev_mid` is window return.
/// - `trade_signed_vol`, `trade_count`: sum (flow accumulators).
/// - `ts_ns`: timestamp of last snapshot (as-of time).
/// - `prev_ts_ns`: timestamp of first snapshot — gives `Δt = ts - prev_ts`
///   spanning the window, which the snap-feature kernel's Fourier dt encoder
///   reads as the position's time scale.
/// - `regime`: copy from last snapshot — EMAs already smoothed by the loader.
///
/// # Panics
///
/// Panics if `window` is empty. The loader's sequence builder guarantees
/// non-empty windows by construction; an empty slice indicates a bug.
pub fn aggregate_window<T: AsRef<Mbp10RawInput>>(window: &[T]) -> Mbp10RawInput {
    assert!(!window.is_empty(), "aggregate_window: window cannot be empty");
    let n = window.len() as f32;
    let first = window.first().unwrap().as_ref();
    let last = window.last().unwrap().as_ref();

    let mut out = Mbp10RawInput::default();

    for lvl in 0..BOOK_LEVELS {
        let mut sum_bid_px = 0.0_f32;
        let mut sum_bid_sz = 0.0_f32;
        let mut sum_ask_px = 0.0_f32;
        let mut sum_ask_sz = 0.0_f32;
        for w in window {
            let x = w.as_ref();
            sum_bid_px += x.bid_px[lvl];
            sum_bid_sz += x.bid_sz[lvl];
            sum_ask_px += x.ask_px[lvl];
            sum_ask_sz += x.ask_sz[lvl];
        }
        out.bid_px[lvl] = sum_bid_px / n;
        out.bid_sz[lvl] = sum_bid_sz / n;
        out.ask_px[lvl] = sum_ask_px / n;
        out.ask_sz[lvl] = sum_ask_sz / n;
    }

    out.prev_mid = first.prev_mid;

    let mut sum_signed: f32 = 0.0;
    let mut sum_count: u32 = 0;
    for w in window {
        let x = w.as_ref();
        sum_signed += x.trade_signed_vol;
        sum_count = sum_count.saturating_add(x.trade_count);
    }
    out.trade_signed_vol = sum_signed;
    out.trade_count = sum_count;

    out.ts_ns = last.ts_ns;
    out.prev_ts_ns = first.ts_ns;
    out.regime = last.regime;

    out
}

impl AsRef<Mbp10RawInput> for Mbp10RawInput {
    fn as_ref(&self) -> &Mbp10RawInput {
        self
    }
}

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

    #[test]
    fn three_scale_default_total_32_lookback_1510() {
        let cfg = MultiResolutionConfig::default_three_scale();
        assert_eq!(cfg.total_positions(), 32);
        assert_eq!(cfg.required_lookback_ticks(), 1510);
        assert_eq!(cfg.scales(), &[(1, 10), (30, 10), (100, 12)]);
    }

    #[test]
    fn parse_three_scale_phase1() {
        let cfg: MultiResolutionConfig = "1:10,30:10,100:12".parse().unwrap();
        assert_eq!(cfg.scales(), &[(1, 10), (30, 10), (100, 12)]);
        assert_eq!(cfg.total_positions(), 32);
    }

    #[test]
    fn parse_single_scale_debug() {
        let cfg: MultiResolutionConfig = "1:32".parse().unwrap();
        assert_eq!(cfg.scales(), &[(1, 32)]);
        assert_eq!(cfg.total_positions(), 32);
    }

    #[test]
    fn parse_rejects_non_increasing_scales() {
        let res: Result<MultiResolutionConfig> = "30:10,1:10".parse();
        assert!(res.is_err(), "scales must be strictly increasing");
    }

    #[test]
    fn parse_rejects_zero_scale() {
        let res: Result<MultiResolutionConfig> = "0:10".parse();
        assert!(res.is_err());
    }

    #[test]
    fn parse_rejects_zero_count() {
        let res: Result<MultiResolutionConfig> = "1:0".parse();
        assert!(res.is_err());
    }

    #[test]
    fn parse_rejects_empty_string() {
        let res: Result<MultiResolutionConfig> = "".parse();
        assert!(res.is_err());
    }

    #[test]
    fn display_round_trips() {
        let cfg = MultiResolutionConfig::default_three_scale();
        let s = format!("{cfg}");
        let parsed: MultiResolutionConfig = s.parse().unwrap();
        assert_eq!(cfg, parsed);
    }

    use crate::cfc::snap_features::Mbp10RawInput;

    fn synth_input(ts_ns: u64, mid: f32) -> Mbp10RawInput {
        let mut x = Mbp10RawInput::default();
        x.bid_px[0] = mid - 0.125;
        x.ask_px[0] = mid + 0.125;
        x.bid_sz[0] = 100.0;
        x.ask_sz[0] = 100.0;
        x.prev_mid = mid;
        x.trade_signed_vol = 1.0;
        x.trade_count = 1;
        x.ts_ns = ts_ns;
        x.prev_ts_ns = ts_ns.saturating_sub(100);
        x
    }

    #[test]
    fn aggregate_window_single_tick_passthrough_fields() {
        let s = vec![synth_input(1000, 5000.0)];
        let agg = aggregate_window(&s);
        assert_eq!(agg.ts_ns, 1000);
        assert_eq!(agg.prev_ts_ns, 1000); // first.ts_ns == last.ts_ns for n=1
        assert!((agg.prev_mid - 5000.0).abs() < 1e-5);
        assert!((agg.bid_px[0] - (5000.0 - 0.125)).abs() < 1e-5);
        assert!((agg.trade_signed_vol - 1.0).abs() < 1e-5);
        assert_eq!(agg.trade_count, 1);
    }

    #[test]
    fn aggregate_window_30_ticks_means_and_sums() {
        let snaps: Vec<Mbp10RawInput> = (0..30u64)
            .map(|i| synth_input(1000 + i * 100, 5000.0 + i as f32))
            .collect();
        let agg = aggregate_window(&snaps);

        // ts spans the window: prev_ts_ns = first.ts_ns, ts_ns = last.ts_ns.
        assert_eq!(agg.prev_ts_ns, 1000);
        assert_eq!(agg.ts_ns, 1000 + 29 * 100);

        // prev_mid is mid of FIRST tick so cur.mid - prev_mid spans the return.
        assert!((agg.prev_mid - 5000.0).abs() < 1e-5);

        // bid_px[0] is MEAN.
        let expected = (0..30).map(|i| 5000.0 + i as f32).sum::<f32>() / 30.0 - 0.125;
        assert!((agg.bid_px[0] - expected).abs() < 1e-3);

        // trade_signed_vol is SUM (flow).
        assert!((agg.trade_signed_vol - 30.0).abs() < 1e-5);
        assert_eq!(agg.trade_count, 30);
    }

    #[test]
    #[should_panic(expected = "window cannot be empty")]
    fn aggregate_window_empty_panics() {
        let _ = aggregate_window::<Mbp10RawInput>(&[]);
    }
}
