//! `RollingMin` — minimum over the last `length` values of one f64 input.
//!
//! The mirror of `RollingMax`: an **ascending** monotonic deque (front = window min).
//! Each cycle pops from the back every element >= the new sample, pushes the new one,
//! and evicts the front once it leaves the window. See `rolling_max.rs` for the shared
//! rationale (O(1) amortized, window in node state, warm-up skip-emit, C7).

use aura_core::{
    Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
    ScalarKind,
};
use std::collections::VecDeque;

/// Minimum over the last `length` values of one f64 input, maintained by an ascending
/// monotonic deque (front = current window min). `None` during warm-up.
pub struct RollingMin {
    length: usize,
    deque: VecDeque<(f64, u64)>, // ascending; front = window min
    seen: u64,
    count: usize,
    out: [Cell; 1],
}

impl RollingMin {
    /// Build a rolling min of window `length` (must be >= 1).
    pub fn new(length: usize) -> Self {
        assert!(length >= 1, "RollingMin length must be >= 1");
        Self {
            length,
            deque: VecDeque::with_capacity(length),
            seen: 0,
            count: 0,
            out: [Cell::from_f64(0.0)],
        }
    }

    /// The param-generic recipe for a blueprint primitive: declares `length` and builds
    /// through `RollingMin::new`.
    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "RollingMin",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
                output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
            },
            |p| Box::new(RollingMin::new(p[0].i64() as usize)),
        )
    }
}

impl Node for RollingMin {
    fn lookbacks(&self) -> Vec<usize> {
        vec![1]
    }

    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
        let w = ctx.f64_in(0);
        if w.is_empty() {
            return None;
        }
        let x = w[0];
        let idx = self.seen;
        self.seen += 1;
        // ascending invariant: a back element >= x can never again be the min — drop it.
        while let Some(&(v, _)) = self.deque.back() {
            if v >= x {
                self.deque.pop_back();
            } else {
                break;
            }
        }
        self.deque.push_back((x, idx));
        while let Some(&(_, i)) = self.deque.front() {
            if i + self.length as u64 <= idx {
                self.deque.pop_front();
            } else {
                break;
            }
        }
        if self.count < self.length {
            self.count += 1;
        }
        if self.count < self.length {
            return None;
        }
        self.out[0] = Cell::from_f64(self.deque.front().expect("deque non-empty after push").0);
        Some(&self.out)
    }

    fn label(&self) -> String {
        format!("RollingMin({})", self.length)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use aura_core::{AnyColumn, Scalar, Timestamp};

    fn drive(node: &mut RollingMin, feed: &[f64]) -> Vec<Option<f64>> {
        let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
        let mut out = Vec::new();
        for &v in feed {
            inputs[0].push(Scalar::f64(v)).unwrap();
            out.push(node.eval(Ctx::new(&inputs, Timestamp(0))).map(|r| r[0].f64()));
        }
        out
    }

    #[test]
    fn rolling_min_warms_up_then_tracks_the_window_min() {
        // min of [9,7,8], [7,8,5], [8,5,6]; silent for the first two.
        let mut node = RollingMin::new(3);
        let got = drive(&mut node, &[9.0, 7.0, 8.0, 5.0, 6.0]);
        assert_eq!(got, vec![None, None, Some(7.0), Some(5.0), Some(5.0)]);
    }

    #[test]
    fn rolling_min_evicts_the_expiring_window_minimum() {
        // length 2; the early small value must leave the window: [1,9]->1, [9,8]->8, [8,7]->7.
        let mut node = RollingMin::new(2);
        let got = drive(&mut node, &[1.0, 9.0, 8.0, 7.0]);
        assert_eq!(got, vec![None, Some(1.0), Some(8.0), Some(7.0)]);
    }

    #[test]
    fn rolling_min_length_one_is_identity() {
        let mut node = RollingMin::new(1);
        assert_eq!(drive(&mut node, &[7.0, 9.0, 4.0]), vec![Some(7.0), Some(9.0), Some(4.0)]);
    }

    #[test]
    fn deque_matches_naive_window_min() {
        // the monotonic deque must equal a naive O(N) per-window min at every warmed
        // cycle, across a long deterministic series (mirrors RollingMax's cross-check).
        // u64 + wrapping arithmetic matches rolling_max.rs and avoids i32 overflow panics.
        let length = 37;
        let mut node = RollingMin::new(length);
        let series: Vec<f64> = (0..3_000u64)
            .map(|i| (i.wrapping_mul(1103515245).wrapping_add(12345) % 1000) as f64 * 0.5 - 250.0)
            .collect();
        let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
        for (t, &x) in series.iter().enumerate() {
            inputs[0].push(Scalar::f64(x)).unwrap();
            let got = node.eval(Ctx::new(&inputs, Timestamp(0))).map(|r| r[0].f64());
            if t + 1 >= length {
                let want = series[t + 1 - length..=t].iter().cloned().fold(f64::INFINITY, f64::min);
                assert_eq!(got, Some(want), "at t={t}");
            } else {
                assert_eq!(got, None, "silent until warmed up at t={t}");
            }
        }
    }

    #[test]
    fn lookback_is_one_window_lives_in_node_state() {
        assert_eq!(RollingMin::new(20).lookbacks(), vec![1]);
    }

    #[test]
    fn length_must_be_at_least_one() {
        let r = std::panic::catch_unwind(|| RollingMin::new(0));
        assert!(r.is_err(), "RollingMin::new(0) must panic");
    }

    #[test]
    fn label_carries_the_window() {
        assert_eq!(RollingMin::new(20).label(), "RollingMin(20)");
    }

    #[test]
    fn builder_declares_length_param_and_value_output() {
        // the blueprint seam later tasks bootstrap through: the param-generic recipe
        // declares a single I64 `length` knob and an F64 `value` output (mirrors
        // RollingMax's builder_declares_length_param_and_value_output).
        let schema = RollingMin::builder().schema().clone();
        assert_eq!(schema.params, vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }]);
        assert_eq!(schema.output.len(), 1);
        assert_eq!(schema.output[0].name, "value");
        assert_eq!(schema.output[0].kind, ScalarKind::F64);
    }

    #[test]
    fn builder_input_slot_is_named_series() {
        // the named f64 input the breakout graph wires close into (mirrors RollingMax's
        // builder_input_slot_is_named_series).
        assert_eq!(RollingMin::builder().schema().inputs[0].name, "series");
    }

    #[test]
    fn builder_bind_removes_length_from_param_space() {
        // a bound length reports an empty param surface; the open form keeps it (mirrors
        // RollingMax's builder_bind_removes_length_from_param_space).
        let bound = RollingMin::builder().named("channel").bind("length", Scalar::i64(20));
        assert!(bound.schema().params.is_empty());
        assert_eq!(RollingMin::builder().named("channel").params().len(), 1);
    }

    #[test]
    fn builder_bound_node_builds_with_injected_length() {
        // built from an empty open slice, the bound builder yields a RollingMin(20) —
        // the `p[0].i64() as usize` build closure is exercised end-to-end (mirrors
        // RollingMax's builder_bound_node_builds_with_injected_length).
        let node = RollingMin::builder().bind("length", Scalar::i64(20)).build(&[]);
        assert_eq!(node.label(), "RollingMin(20)");
    }
}
