//! `ConstantCost` — a flat round-trip cost charged once per closed trade, in R.
//! The simplest cost node of the C10 cost-model graph: a stateless [`CostNode`]
//! factor whose price-unit numerator is a flat `cost_per_trade`. The shared
//! [`CostRunner`] supplies the co-temporality skeleton (geometry gating, the
//! `cost_per_trade / |entry - stop|` R-normalization, the closed/open charge, the
//! running `cum`, the 3-field emit). R-pure: notional cancels (C10).

use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};

use crate::cost::{cost_node_builder, CostNode, CostRunner};

/// A flat per-trade cost in price units (`cost_per_trade`), emitted in R via the
/// shared [`CostRunner`].
pub struct ConstantCost {
    cost_per_trade: f64,
}

impl ConstantCost {
    /// A flat per-trade cost node: the factor wrapped in the shared [`CostRunner`].
    pub fn new(cost_per_trade: f64) -> CostRunner<ConstantCost> {
        assert!(cost_per_trade >= 0.0, "ConstantCost cost_per_trade must be >= 0");
        CostRunner::new(ConstantCost { cost_per_trade })
    }

    /// The param-generic recipe: one `cost_per_trade` F64 knob, no extra inputs.
    pub fn builder() -> PrimitiveBuilder {
        cost_node_builder(
            "ConstantCost",
            Vec::new(),
            vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
            |p| Box::new(ConstantCost::new(p[0].f64())),
        )
    }
}

impl CostNode for ConstantCost {
    fn label(&self) -> String {
        format!("ConstantCost({})", self.cost_per_trade)
    }
    fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
        self.cost_per_trade
    }
}

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

    fn cols() -> Vec<AnyColumn> {
        vec![
            AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
            AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
            AnyColumn::with_capacity(ScalarKind::F64, 1),  // entry
            AnyColumn::with_capacity(ScalarKind::F64, 1),  // stop
        ]
    }

    #[test]
    fn no_geometry_yet_withholds() {
        let mut c = ConstantCost::new(2.0);
        let inputs = cols(); // entry column empty
        assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
    }

    #[test]
    fn closed_charges_cost_per_trade_over_latched() {
        let mut c = ConstantCost::new(2.0);
        let mut inputs = cols();
        inputs[0].push(Scalar::bool(true)).unwrap(); // closed
        inputs[1].push(Scalar::bool(false)).unwrap(); // not open
        inputs[2].push(Scalar::f64(100.0)).unwrap(); // entry
        inputs[3].push(Scalar::f64(96.0)).unwrap(); // stop -> latched 4.0
        // cost_in_r = 2.0/4.0 = 0.5; cum = 0.5; open_cost_in_r = 0.0
        assert_eq!(
            c.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.5), Cell::from_f64(0.5), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn open_emits_would_be_cost_not_charged_to_cum() {
        let mut c = ConstantCost::new(2.0);
        let mut inputs = cols();
        inputs[0].push(Scalar::bool(false)).unwrap(); // not closed
        inputs[1].push(Scalar::bool(true)).unwrap(); // open
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
        // cost_in_r = 0 (no close); cum stays 0; open_cost_in_r = 0.5
        assert_eq!(
            c.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice())
        );
    }

    #[test]
    fn zero_latched_contributes_no_cost() {
        let mut c = ConstantCost::new(2.0);
        let mut inputs = cols();
        inputs[0].push(Scalar::bool(true)).unwrap();
        inputs[1].push(Scalar::bool(false)).unwrap();
        inputs[2].push(Scalar::f64(100.0)).unwrap();
        inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0 -> no divide
        assert_eq!(
            c.eval(Ctx::new(&inputs, Timestamp(0))),
            Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
        );
    }

    #[test]
    fn label_carries_the_cost() {
        // Parameterised sibling convention: the identifying param is in the label,
        // so two differently-priced cost nodes disambiguate in a trace.
        assert_eq!(ConstantCost::new(2.0).label(), "ConstantCost(2)");
        assert_eq!(ConstantCost::new(0.5).label(), "ConstantCost(0.5)");
    }

    #[test]
    #[should_panic(expected = "cost_per_trade must be >= 0")]
    fn new_panics_on_negative_cost() {
        let _ = ConstantCost::new(-1.0);
    }

    #[test]
    fn cum_accumulates_across_closes() {
        let mut c = ConstantCost::new(2.0);
        let mut a = cols();
        a[0].push(Scalar::bool(true)).unwrap();
        a[1].push(Scalar::bool(false)).unwrap();
        a[2].push(Scalar::f64(100.0)).unwrap();
        a[3].push(Scalar::f64(96.0)).unwrap(); // 0.5
        let _ = c.eval(Ctx::new(&a, Timestamp(0)));
        let mut b = cols();
        b[0].push(Scalar::bool(true)).unwrap();
        b[1].push(Scalar::bool(false)).unwrap();
        b[2].push(Scalar::f64(100.0)).unwrap();
        b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2.0 -> 1.0; cum 1.5
        assert_eq!(
            c.eval(Ctx::new(&b, Timestamp(1))),
            Some([Cell::from_f64(1.0), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice())
        );
    }
}
