//! `Resample` — aggregates a fine OHLC stream (e.g. M1) into coarser OHLC bars
//! (e.g. 15-minute), emitting a **completed** bar ONLY on rollover (C2: a bar is
//! actionable only once complete — never a partial bar).
//!
//! **Schema.** FOUR `f64` inputs `open, high, low, close`, each
//! `Firing::Barrier(0)`: they are co-fresh, fed by four separate M1 sources at
//! the same M1 timestamp, so `Barrier(0)` makes the node fire exactly once when
//! all four carry that timestamp (the `Ohlcv` test fixture in `harness.rs` is the
//! precedent — the first multi-field-output + Barrier group in the engine).
//! Output is a 4-field record `open, high, low, close` (each `f64`). One param
//! `period_minutes: i64`.
//!
//! **Accumulator over the current bucket.** `open` = first M1 open of the window,
//! `high` = running max of M1 highs, `low` = running min of M1 lows, `close` =
//! last M1 close.
//!
//! **Emit-on-rollover via `ctx.now()`.** With
//! `period_ns = period_minutes * 60 * 1_000_000_000`, the bucket of an M1 instant
//! is `ctx.now().0 / period_ns` (integer division on the i64 epoch-ns). Per fired
//! eval (all 4 inputs present, Barrier-gated):
//! - **first ever sample:** start the accumulator, return `None`.
//! - **same bucket:** fold the M1 into the accumulator, return `None`.
//! - **rollover (`bucket > current_bucket`):** the accumulator is a COMPLETE bar
//!   → emit `[open, high, low, close]`; THEN restart the accumulator from this
//!   M1 and adopt the new bucket; return `Some(the completed bar)`.
//!
//! **Timestamp (C4).** The completed bar carries no timestamp of its own — the
//! engine stamps the emission cycle's `ctx.now()` (the new bucket's first M1
//! instant, which is the close instant of the bar just emitted).
//! The node only returns the 4 OHLC values.
//!
//! **Partial last bar is DROPPED.** There is no end-of-stream flush: a bucket with
//! no following rollover tick never emits. This is C2 again — an incomplete bar is
//! not actionable.

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

/// Open/high/low/close accumulator for the current bucket, plus the bucket index
/// it belongs to. Held across cycles in node state (like `SimBroker`/`Latch`).
struct Acc {
    bucket: i64,
    open: f64,
    high: f64,
    low: f64,
    close: f64,
}

/// Resamples a fine 4-field OHLC stream into coarser OHLC bars, emitting a
/// completed bar only on bucket rollover (C2). The first multi-field-output node
/// in `aura-std`.
pub struct Resample {
    period_ns: i64,
    acc: Option<Acc>,
    out: [Cell; 4],
}

impl Resample {
    /// Build a `Resample` aggregating into `period_minutes`-wide buckets
    /// (must be >= 1).
    pub fn new(period_minutes: i64) -> Self {
        assert!(period_minutes >= 1, "Resample period_minutes must be >= 1");
        Self {
            period_ns: period_minutes * 60 * 1_000_000_000,
            acc: None,
            out: [Cell::from_f64(0.0); 4],
        }
    }

    /// The param-generic recipe for a blueprint primitive: declares
    /// `period_minutes` and builds through `Resample::new`.
    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Resample",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "open".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "high".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "low".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "close".into() },
                ],
                output: vec![
                    FieldSpec { name: "open".into(), kind: ScalarKind::F64 },
                    FieldSpec { name: "high".into(), kind: ScalarKind::F64 },
                    FieldSpec { name: "low".into(), kind: ScalarKind::F64 },
                    FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
                ],
                params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
            },
            |p| Box::new(Resample::new(p[0].i64())),
        )
    }
}

impl Node for Resample {
    fn lookbacks(&self) -> Vec<usize> {
        // each of the four inputs is read at depth 1 (newest M1 sample only); the
        // window-in-progress lives in node state, not in the input columns.
        vec![1, 1, 1, 1]
    }

    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
        // Barrier(0) guarantees all four arrive co-fresh; read the newest M1.
        let (o, h, l, c) = (ctx.f64_in(0)[0], ctx.f64_in(1)[0], ctx.f64_in(2)[0], ctx.f64_in(3)[0]);
        let bucket = ctx.now().snap_to_nearest_minute().0 / self.period_ns;

        match self.acc.take() {
            // first ever sample: start the accumulator, nothing complete yet.
            None => {
                self.acc = Some(Acc { bucket, open: o, high: h, low: l, close: c });
                None
            }
            // same bucket: fold this M1 into the in-progress bar (still partial).
            Some(mut acc) if bucket == acc.bucket => {
                acc.high = acc.high.max(h);
                acc.low = acc.low.min(l);
                acc.close = c; // last close wins
                self.acc = Some(acc);
                None
            }
            // rollover: the accumulated bar is COMPLETE -> emit it, then restart
            // the accumulator from this M1 in the new bucket (C2 emit-on-rollover).
            Some(acc) => {
                self.out = [
                    Cell::from_f64(acc.open),
                    Cell::from_f64(acc.high),
                    Cell::from_f64(acc.low),
                    Cell::from_f64(acc.close),
                ];
                self.acc = Some(Acc { bucket, open: o, high: h, low: l, close: c });
                Some(&self.out)
            }
        }
    }

    fn label(&self) -> String {
        format!("Resample({}m)", self.period_ns / (60 * 1_000_000_000))
    }
}

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

    /// Four depth-1 f64 input columns, as bootstrap sizes them from `lookbacks`
    /// for the Barrier(0) OHLC group.
    fn ohlc_inputs() -> Vec<AnyColumn> {
        vec![
            AnyColumn::with_capacity(ScalarKind::F64, 1),
            AnyColumn::with_capacity(ScalarKind::F64, 1),
            AnyColumn::with_capacity(ScalarKind::F64, 1),
            AnyColumn::with_capacity(ScalarKind::F64, 1),
        ]
    }

    /// Drive one fired eval: populate all four co-fresh inputs (Barrier-gated, so
    /// in the real graph they always arrive together) and step the node at the M1
    /// instant `ns`. Each input is a capacity-1 ring, so the push overwrites the
    /// single slot — `f64_in(i)[0]` reads exactly this M1 (mirrors the engine
    /// re-presenting the newest sample each cycle, as `sma.rs`'s test does).
    fn step(node: &mut Resample, inputs: &mut [AnyColumn], ns: i64, o: f64, h: f64, l: f64, c: f64) -> Option<Vec<f64>> {
        for (col, v) in inputs.iter_mut().zip([o, h, l, c]) {
            col.push(Scalar::f64(v)).unwrap();
        }
        node.eval(Ctx::new(inputs, Timestamp(ns)))
            .map(|r| r.iter().map(|c| c.f64()).collect())
    }

    #[test]
    fn resample_emits_completed_bar_only_on_rollover_open_first_high_max_low_min_close_last() {
        // THE HEADLINE PROPERTY: a coarse bar is emitted ONLY when the cursor
        // rolls into the next bucket, and it carries open=first M1 open,
        // high=max M1 high, low=min M1 low, close=last M1 close of the WINDOW —
        // C2 (a bar is actionable only once complete; partials are never emitted).
        let period_minutes = 15;
        let period_ns = period_minutes * 60 * 1_000_000_000; // 900_000_000_000
        assert_eq!(period_ns, 900_000_000_000);

        let mut node = Resample::new(period_minutes);
        let mut inputs = ohlc_inputs();

        // --- bucket 0: three M1 ticks, each accumulating, NONE emitted ---
        // t=0   : first sample of bucket 0 -> starts the accumulator.
        assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);
        // t=60e9: same bucket -> high climbs to 103, low to 100, close to 102.
        assert_eq!(step(&mut node, &mut inputs, 60_000_000_000, 100.5, 103.0, 100.0, 102.0), None);
        // t=120e9: same bucket -> high stays 103, low drops to 99? no: low=101 > 99
        //          so running low stays 99; close becomes 101.5 (last).
        assert_eq!(step(&mut node, &mut inputs, 120_000_000_000, 102.0, 102.5, 101.0, 101.5), None);

        // --- first tick of bucket 1 (t=900e9): ROLLOVER -> emit the COMPLETED
        //     bucket-0 bar: open=100.0 (first), high=103.0 (max), low=99.0 (min),
        //     close=101.5 (last). THE load-bearing assertion. ---
        let emitted = step(&mut node, &mut inputs, 900_000_000_000, 101.5, 104.0, 101.0, 103.0);
        assert_eq!(emitted, Some(vec![100.0, 103.0, 99.0, 101.5]));
    }

    #[test]
    fn resample_drops_the_partial_last_bar_and_resets_the_accumulator() {
        // PARTIAL-DROP + clean reset: after bucket 0 rolls over (emitting), the
        // accumulator restarts from bucket 1's first M1. With no further rollover,
        // bucket 1's bar is incomplete and is NEVER emitted (no EOF flush). Feeding
        // a bucket-2 tick THEN emits bucket 1 — proving open=bucket-1's first open
        // (101.5), i.e. the accumulator reset cleanly rather than carrying bucket 0.
        let mut node = Resample::new(15);
        let mut inputs = ohlc_inputs();

        // bucket 0: two ticks -> open=100.0 (first), high=103.0 (max 101,103),
        // low=99.0 (min 99,100), close=102.0 (last close).
        assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);
        assert_eq!(step(&mut node, &mut inputs, 60_000_000_000, 100.5, 103.0, 100.0, 102.0), None);
        // bucket 1's first tick rolls over bucket 0 (emits) and starts bucket 1.
        assert_eq!(
            step(&mut node, &mut inputs, 900_000_000_000, 101.5, 104.0, 101.0, 103.0),
            Some(vec![100.0, 103.0, 99.0, 102.0])
        );
        // another bucket-1 tick: still accumulating bucket 1, NO emission.
        assert_eq!(step(&mut node, &mut inputs, 960_000_000_000, 103.0, 105.0, 102.5, 104.5), None);
        // bucket 2's first tick rolls over bucket 1: open=101.5 (bucket 1's FIRST),
        // high=105.0 (max of 104,105), low=101.0 (min of 101,102.5), close=104.5.
        assert_eq!(
            step(&mut node, &mut inputs, 1_800_000_000_000, 104.5, 106.0, 104.0, 105.5),
            Some(vec![101.5, 105.0, 101.0, 104.5])
        );
        // ...and bucket 2 (the new partial) is NOT flushed: no further emission.
    }

    #[test]
    fn resample_buckets_a_boundary_bar_by_its_nominal_minute_despite_subsecond_jitter() {
        // PROPERTY: bucket membership follows an M1 bar's NOMINAL minute, not the
        // raw provider stamp. Provider M1 stamps carry sub-second jitter around
        // minute boundaries (#280: on ~55% of days the 09:00 bar is stamped
        // 08:59:59.xxx). A bar whose nominal minute *is* a bucket boundary must
        // open the new bucket — and thereby complete the previous one — even when
        // its stamp lands a fraction of a second BEFORE that boundary. It must not
        // be folded back into the previous bucket.
        //
        // 5m buckets: boundaries at 0, 5min, 10min, ... (multiples of period_ns).
        let period_ns = 5 * 60 * 1_000_000_000_i64; // 300_000_000_000
        let mut node = Resample::new(5);
        let mut inputs = ohlc_inputs();

        // Bucket 0 opens with one M1 bar at a clean minute stamp (t=0).
        assert_eq!(step(&mut node, &mut inputs, 0, 100.0, 101.0, 99.0, 100.5), None);

        // The next bar's NOMINAL minute is 05:00 — exactly the bucket-0/bucket-1
        // boundary — but the provider stamped it 600ms early at 04:59:59.400
        // (= 299_400_000_000 ns, 0.6s before the 300_000_000_000 boundary). By its
        // nominal minute it is bucket 1's FIRST bar, so feeding it must ROLL OVER
        // bucket 0 and emit the completed bucket-0 bar [100, 101, 99, 100.5].
        let jittered_boundary_stamp = period_ns - 600_000_000; // 299_400_000_000
        let emitted = step(&mut node, &mut inputs, jittered_boundary_stamp, 105.0, 106.0, 104.0, 105.5);
        assert_eq!(
            emitted,
            Some(vec![100.0, 101.0, 99.0, 100.5]),
            "the jitter-early boundary bar must open the new bucket and complete bucket 0, \
             not fold back into it"
        );
    }

    #[test]
    fn output_is_a_four_field_ohlc_record_barrier_gated() {
        // schema shape: four Barrier(0) f64 inputs named o/h/l/c, four f64 output
        // fields named o/h/l/c, one i64 param. The first multi-field output node.
        let s = Resample::builder().schema().clone();
        let in_names: Vec<&str> = s.inputs.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(in_names, ["open", "high", "low", "close"]);
        assert!(s.inputs.iter().all(|p| p.kind == ScalarKind::F64 && p.firing == Firing::Barrier(0)));
        let out_names: Vec<&str> = s.output.iter().map(|f| f.name.as_str()).collect();
        assert_eq!(out_names, ["open", "high", "low", "close"]);
        assert!(s.output.iter().all(|f| f.kind == ScalarKind::F64));
        assert_eq!(s.params, vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }]);
    }
}
