//! `PositionManagement` — the stateful heart of feed-forward risk-based execution. Turns a
//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes.
//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the
//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the
//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end
//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY:
//! `realized_r = direction * (exit - entry) / latched_distance`.
use aura_core::{
    Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
    Timestamp,
};

/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r`
/// matches the raw i64 across the crate boundary (the record streams as type-erased
/// `Scalar`s, C4 SoA, not as this enum), so the encoding is the load-bearing contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i64)]
pub enum ExitReason {
    Stop = 0,
    BiasFlip = 1,
    ReversalLeg = 2,
    WindowEnd = 3,
}

/// Dense record column layout — the lockstep contract shared (by convention) with the
/// `Recorder` tap kinds and `summarize_r`'s column reads.
pub const WIDTH: usize = 14;
pub const FIELD_NAMES: [&str; WIDTH] = [
    "closed_this_cycle",
    "realized_r",
    "exit_reason",
    "was_stopped",
    "direction",
    "entry_ts",
    "entry_price",
    "stop_price",
    "exit_price",
    "conviction_at_entry",
    "size",
    "open",
    "unrealized_r",
    "cum_realized_r",
];
pub const RECORD_KINDS: [ScalarKind; WIDTH] = {
    use ScalarKind::*;
    [
        Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64,
    ]
};

// The frozen-R latch: an open position records its entry, its latched (frozen at
// entry) R-distance, the protective stop level, and entry metadata. R is computed
// against `latched_dist`, never a re-read distance — the R-denominator is frozen.
struct Open {
    dir: i64,
    entry: f64,
    latched_dist: f64,
    stop_level: f64,
    entry_ts: Timestamp,
    bias_abs: f64,
}

pub struct PositionManagement {
    pos: Option<Open>,
    cum_realized_r: f64,
    out: [Cell; WIDTH],
}
impl PositionManagement {
    pub fn new() -> Self {
        Self {
            pos: None,
            cum_realized_r: 0.0,
            out: [Cell::from_f64(0.0); WIDTH],
        }
    }
    pub fn builder() -> PrimitiveBuilder {
        let inputs = vec![
            PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
            PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
            PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
            PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() },
        ];
        let output = FIELD_NAMES
            .iter()
            .zip(RECORD_KINDS)
            .map(|(n, k)| FieldSpec { name: (*n).into(), kind: k })
            .collect();
        PrimitiveBuilder::new(
            "PositionManagement",
            NodeSchema { inputs, output, params: vec![] },
            |_| Box::new(PositionManagement::new()),
        )
    }
}
impl Default for PositionManagement {
    fn default() -> Self {
        Self::new()
    }
}

fn sign0(v: f64) -> f64 {
    if v > 0.0 {
        1.0
    } else if v < 0.0 {
        -1.0
    } else {
        0.0
    }
}

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

    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
        let pw = ctx.f64_in(1);
        if pw.is_empty() {
            return None; // no price yet — nothing to do
        }
        let price = pw[0];
        let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
        let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
        let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired
        let now = ctx.now();

        let mut closed = false;
        let mut realized = 0.0;
        let mut reason = ExitReason::Stop as i64;
        let mut was_stopped = false;
        let mut ex_dir = 0i64;
        let mut ex_entry_ts = Timestamp(0);
        let mut ex_entry = 0.0;
        let mut ex_stop = 0.0;
        let mut ex_exit = 0.0;
        let mut ex_bias_abs = 0.0;

        // (A) Maintain/exit the position held into this cycle, marked to `price`.
        if let Some(p) = &self.pos {
            let stop_hit = (p.dir > 0 && price <= p.stop_level)
                || (p.dir < 0 && price >= p.stop_level);
            let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0
            if stop_hit {
                let fill = if p.dir > 0 {
                    price.min(p.stop_level)
                } else {
                    price.max(p.stop_level)
                };
                // size-invariant: R is a pure stop-distance ratio (feed-forward).
                realized = p.dir as f64 * (fill - p.entry) / p.latched_dist;
                was_stopped = true;
                reason = ExitReason::Stop as i64;
                ex_exit = fill;
                (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
                    (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
                self.cum_realized_r += realized;
                closed = true;
                self.pos = None;
            } else if bias_exit {
                // size-invariant: R is a pure stop-distance ratio (feed-forward).
                realized = p.dir as f64 * (price - p.entry) / p.latched_dist;
                reason = if sign0(bias) != 0.0 {
                    ExitReason::ReversalLeg as i64
                } else {
                    ExitReason::BiasFlip as i64
                };
                ex_exit = price;
                (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
                    (p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
                self.cum_realized_r += realized;
                closed = true;
                self.pos = None;
            }
        }
        // (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available.
        if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 {
            let dir = sign0(bias) as i64;
            let stop_level = if dir > 0 { price - dist } else { price + dist };
            self.pos = Some(Open {
                dir,
                entry: price,
                latched_dist: dist,
                stop_level,
                entry_ts: now,
                bias_abs: bias.abs(),
            });
        }
        // (C) Open-position fields (carry for window-end) + unrealized mark.
        let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos {
            Some(p) => (
                true,
                p.dir as f64 * (price - p.entry) / p.latched_dist,
                p.dir,
                p.entry_ts,
                p.entry,
                p.stop_level,
                p.bias_abs,
            ),
            None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0),
        };
        // Trade-detail columns describe the CLOSED trade if closed, else the OPEN one.
        let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed {
            (ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs)
        } else {
            (o_dir, o_ets, o_entry, o_stop, 0.0, o_babs)
        };
        // `direction` (col 4) tracks the position OPEN at cycle end — it is carried for
        // window-end synthesis and names the reopened leg on a reversal. It falls back
        // to the closed trade's direction only when flat at cycle end. The other
        // trade-detail columns (and the debug_assert below) stay keyed to the CLOSED
        // trade's geometry via `d_*`.
        let out_dir = if open { o_dir } else { d_dir };
        self.out = [
            Cell::from_bool(closed),
            Cell::from_f64(realized),
            Cell::from_i64(reason),
            Cell::from_bool(was_stopped),
            Cell::from_i64(out_dir),
            Cell::from_ts(d_ets),
            Cell::from_f64(d_entry),
            Cell::from_f64(d_stop),
            Cell::from_f64(d_exit),
            Cell::from_f64(d_babs),
            Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant
            Cell::from_bool(open),
            Cell::from_f64(unrealized),
            Cell::from_f64(self.cum_realized_r),
        ];
        // Scale-robust tolerance: the column re-derivation reconstructs the R-denominator
        // via `d_entry - d_stop`, a catastrophic-cancellation subtraction at tiny-pip price
        // scale (entry ~1.1, dist ~1e-6) that drifts from the cancellation-free frozen
        // `latched_dist` `realized` was computed with. A relative tolerance (floored at the
        // historical absolute 1e-9 for |R| <= 1) keeps the guard meaningful while letting the
        // two build profiles agree on tiny-pip instruments — `realized` itself is exact.
        debug_assert!(
            !closed
                || (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs()
                    < 1e-9 * realized.abs().max(1.0)
        );
        Some(&self.out)
    }

    fn label(&self) -> String {
        "PositionManagement".into()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use aura_core::{AnyColumn, Scalar};
    // Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row.
    fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec<Cell> {
        cols[0].push(Scalar::f64(bias)).unwrap();
        cols[1].push(Scalar::f64(price)).unwrap();
        cols[2].push(Scalar::f64(dist)).unwrap();
        cols[3].push(Scalar::f64(size)).unwrap();
        n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
    }
    // size defaults to 1.0 (the iter-1 placeholder value), so every existing case is
    // behaviour-identical under the new 4-input shape.
    fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
        step_sized(n, cols, bias, price, dist, 1.0)
    }
    fn cols() -> Vec<AnyColumn> { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }

    #[test]
    fn emits_one_dense_record_per_cycle() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
        assert_eq!(row.len(), WIDTH);
        assert!(!row[0].bool()); // closed_this_cycle = false
        assert!(!row[11].bool()); // open = false
    }

    // (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R.
    // Scaling size leaves every realized_r identical (the property that keeps the research loop
    // feed-forward); col 10 reflects the size so we know it actually flowed end-to-end.
    #[test]
    fn realized_r_is_invariant_under_size() {
        let run = |size: f64| {
            let mut n = PositionManagement::new();
            let mut c = cols();
            let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100
            step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size)          // bias->0 exit @110
        };
        let small = run(1.0);
        let big = run(7.0);
        assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant");
        assert_eq!(small[1].f64(), 1.0); // (110-100)/10
        assert_eq!(small[10].f64(), 1.0); // size column reflects the input
        assert_eq!(big[10].f64(), 7.0);
    }

    // (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
    // 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
    // move to 110; the flip closes it THERE, never retroactively flattening it).
    #[test]
    fn no_lookahead_bias_exit_realises_the_held_move() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
        let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit
        assert!(row[0].bool());                 // closed_this_cycle
        assert_eq!(row[1].f64(), 1.0);          // realized_r = +1.0
        assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64);
        assert!(!row[3].bool());                // not stopped
    }
    // A position opened this cycle never earns the pre-entry move: entered @110, its
    // own-cycle unrealized R is 0.
    #[test]
    fn entry_does_not_earn_pre_entry_move() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
        let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110
        assert!(row[11].bool());                // open
        assert_eq!(row[12].f64(), 0.0);         // unrealized_r = 0 at entry cycle
    }
    // (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1.
    #[test]
    fn stop_out_is_not_capped_at_minus_one_r() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
        let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90)
        assert!(row[0].bool());
        assert!(row[3].bool());                 // was_stopped
        assert_eq!(row[2].i64(), ExitReason::Stop as i64);
        assert_eq!(row[1].f64(), -2.0);         // (80-100)/10 = -2R, the honest tail
    }
    // A no-gap stop fills exactly at the stop -> exactly -1R.
    #[test]
    fn no_gap_stop_is_exactly_minus_one_r() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
        let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly
        assert_eq!(row[1].f64(), -1.0);
    }
    // (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg
    // (one record, ReversalLeg) and reopens short as state (open=true, dir=-1).
    #[test]
    fn reversal_closes_one_leg_and_reopens() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0);  // open long @100
        let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110
        assert!(row[0].bool());                  // one close
        assert_eq!(row[1].f64(), 1.0);           // closed long: (110-100)/10
        assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64);
        assert!(row[11].bool());                 // reopened: open
        assert_eq!(row[4].i64(), -1);            // new direction short
    }
    // Property: the realized_r <-> dense-record-columns redundancy guard holds for a
    // tiny-pip instrument (EURUSD-scale entry, a sub-pip stop distance). `eval` stores
    // `realized` from the cancellation-free frozen `latched_dist`; the in-`eval`
    // redundancy guard RE-derives R from the record columns as
    // `direction * (exit - entry) / |entry - stop|`, reconstructing the R-denominator
    // by the subtraction `entry - stop`. At a price scale ~1.1 with a stop distance
    // ~1e-6, that subtraction loses ~6 significant digits relative to the magnitude of
    // `entry` (~2e-16 ULP / 1e-6 dist), so the recomputed denominator drifts from the
    // frozen `latched_dist`; with a deep gap-through-stop realising a large |R|, the
    // recomputed R diverges from the (correct) stored `realized` by more than the
    // guard's ABSOLUTE 1e-9 tolerance, panicking a debug build while a release build
    // (assert compiled out) returns the correct R. The stored `realized` is correct;
    // the guard must use a scale-robust (relative) tolerance so the two build profiles
    // agree. A no-op for large-pip instruments (GER40), where the cancellation is
    // negligible.
    #[test]
    fn tiny_pip_gap_stop_does_not_trip_the_redundancy_guard() {
        let entry = 1.10000_f64; // EURUSD-scale price
        let dist = 0.000001_f64; // sub-pip vol-stop distance (a quiet M1 bar)
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, entry, dist); // open long @1.10000, stop @1.099999
        // A fast gap straight through the stop: price 1.09990, ~100e-6 below entry ->
        // a ~-100R loss (stop-outs are NOT capped at -1R; the honest tail). In a debug
        // build the current absolute-tolerance guard PANICS here.
        let row = step(&mut n, &mut c, 1.0, 1.09990_f64, dist);
        assert!(row[0].bool(), "closed_this_cycle");
        assert!(row[3].bool(), "was_stopped");
        // The stored realized_r and the record-column re-derivation must agree within a
        // tolerance robust to the tiny price scale (relative, not absolute 1e-9).
        let realized = row[1].f64();
        let (dir, d_entry, d_stop, d_exit) =
            (row[4].i64() as f64, row[6].f64(), row[7].f64(), row[8].f64());
        let recomputed = dir * (d_exit - d_entry) / (d_entry - d_stop).abs();
        assert!(
            (realized - recomputed).abs() <= 1e-9 * realized.abs().max(1.0),
            "realized_r ({realized}) must match the column re-derivation ({recomputed}) \
             within a scale-robust tolerance",
        );
    }

    // (4) Open at window end is carried on the last row: open=true, the unrealized R the
    // post-run fold will force-close, and the direction for window-end synthesis.
    #[test]
    fn open_at_window_end_is_carried_on_the_last_row() {
        let mut n = PositionManagement::new();
        let mut c = cols();
        let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0);  // open long @100
        let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close
        assert!(!row[0].bool());            // not closed
        assert!(row[11].bool());            // open
        assert_eq!(row[12].f64(), 0.5);     // unrealized (105-100)/10
        assert_eq!(row[4].i64(), 1);        // direction carried for window-end synthesis
    }
}
