//! `aura-composites` — reusable composite-builders for the feed-forward R loop: the volatility stop and
//! the per-symbol RiskExecutor. Both are `GraphBuilder` compositions (from
//! `aura-engine`) of `aura-std` primitives, so this crate sits *above* both and is
//! the single place where the engine's builder and the standard nodes are wired
//! together.
//!
//! It lives in its own crate on purpose. The engine routes type-erased `Scalar`
//! records and never names a concrete node — `aura_engine::summarize_r` reads the
//! `PositionManagement` record positionally, by column index, without linking
//! `aura-std`. Keeping these node-naming builders out of `aura-engine` preserves
//! that: the engine stays `-> aura-core` only (domain-free), `aura-std` stays the
//! lean node library (`-> aura-core`), and the `aura-engine -> aura-std` edge these
//! builders would otherwise force is dissolved. `aura-composites -> {aura-engine,
//! aura-std}` is the one layer that couples both, and the graph stays acyclic.
//!
//! The Veto is a documented seam, not a node (a pass-through identity is exactly
//! what C19/C23 DCE deletes), so it appears nowhere here.
use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_std::{
    CostSum, Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub,
    COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES,
};

/// The volatility stop as a composition of primitives:
/// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price − Delay(price, 1)`
/// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`.
pub fn vol_stop(length: i64, k: f64) -> Composite {
    vol_stop_inner(Some((length, k)))
}

/// The single `vol_stop` topology, with its two knobs either BOUND to constants
/// (`Some((length, k))` — the [`vol_stop`] form, byte-identical to the original
/// build-time bind) or left OPEN (`None` — the gridding form: the EWMA and scale nodes
/// are `named` `stop_length` / `stop_k` so their slots surface in `param_space` under
/// the `.stop_length.length` / `.stop_k.weights[0]` suffixes). One body, so the
/// topology — every node, edge, and exposed role — cannot drift between the two arms;
/// only the two knobs differ (the `Option<i64>`-knob idiom `r_sma_graph` uses).
fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
    let mut g = GraphBuilder::new("vol_stop");
    let price = g.input_role("price");
    let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price
    let sub = g.add(Sub::builder()); // Δ = price − prev
    let sq = g.add(Mul::builder()); // Δ·Δ = Δ²
    // EWMA variance: length bound (Some) or open and named `stop_length` (None).
    let ema = g.add(match knobs {
        Some((length, _)) => Ema::builder().bind("length", Scalar::i64(length)),
        None => Ema::builder().named("stop_length"),
    });
    let sqrt = g.add(Sqrt::builder()); // → σ (price units)
    // k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
    let scale = g.add(match knobs {
        Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
        None => LinComb::builder(1).named("stop_k"),
    });
    g.feed(price, [delay.input("series"), sub.input("lhs")]);
    g.connect(delay.output("value"), sub.input("rhs"));
    g.connect(sub.output("value"), sq.input("lhs"));
    g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs
    g.connect(sq.output("value"), ema.input("series"));
    g.connect(ema.output("value"), sqrt.input("value"));
    g.connect(sqrt.output("value"), scale.input("term[0]"));
    g.expose(scale.output("value"), "stop_distance");
    g.build().expect("vol_stop composite wires")
}

/// The protective stop-rule axis (C11 structural): a fixed distance, or the
/// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the
/// RiskExecutor embeds either by identical downstream wiring.
///
/// Mirrors the sibling axis enum [`RollMode`](aura_engine::RollMode); `Eq` is omitted
/// because the `f64` payloads forbid it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum StopRule {
    Fixed(f64),
    Vol { length: i64, k: f64 },
}

/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
/// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense
/// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM;
/// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`.
pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
    // Add the chosen stop into the shared body's builder: a FixedStop node directly,
    // or the BOUND `vol_stop` composite — byte-identical to the pre-dedup arms.
    risk_executor_inner(
        Box::new(move |g| match stop {
            StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
            StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
        }),
        risk_budget,
    )
}

/// The shared `risk_executor` body, given a closure that adds the chosen stop node
/// (returning its handle). Open input roles `bias` + `price`, internal
/// `<stop> → Sizer → PositionManagement`, exposing every field of PM's dense R-record;
/// price fans to BOTH the stop and PM, bias to the Sizer and PM, the Sizer's `size`
/// into PM. Every stop exposes `price → stop_distance`, so a fixed stop, a bound
/// vol-stop, or an OPEN vol-stop all embed by this one identical downstream wiring —
/// the single place the fan / Sizer / PM / exposed record live, so [`risk_executor`]
/// and [`risk_executor_vol_open`] cannot drift apart (one body, two stop arms).
fn risk_executor_inner(
    add_stop: Box<dyn FnOnce(&mut GraphBuilder) -> NodeHandle + '_>,
    risk_budget: f64,
) -> Composite {
    let mut g = GraphBuilder::new("risk_executor");
    let bias = g.input_role("bias");
    let price = g.input_role("price");
    let stop = add_stop(&mut g);
    let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget)));
    let pm = g.add(PositionManagement::builder());
    g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM
    g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM
    g.connect(stop.output("stop_distance"), sizer.input("stop_distance"));
    g.connect(stop.output("stop_distance"), pm.input("stop_distance"));
    g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM
    for field in PM_FIELD_NAMES {
        g.expose(pm.output(field), field);
    }
    g.build().expect("risk_executor wires")
}

/// A `vol_stop`-armed RiskExecutor whose two stop knobs are left **open** (unbound)
/// so they enter `param_space` as sweepable axes — the gridding sibling of
/// [`risk_executor`]`(StopRule::Vol { .. }, ..)`, which binds them to constants. The
/// interior `vol_stop`'s EWMA length and `k`-multiplier nodes are named `stop_length`
/// / `stop_k` so their path-qualified slots are addressable as
/// `<path>.vol_stop.stop_length.length` (I64) and `<path>.vol_stop.stop_k.weights[0]`
/// (F64). Everything else (the price/bias fan, Sizer, PositionManagement, the exposed
/// dense R-record) is identical to [`risk_executor`], so a member built from any
/// `(length, k)` point matches the bound form at the same values byte-for-byte.
pub fn risk_executor_vol_open(risk_budget: f64) -> Composite {
    // The same shared body as [`risk_executor`], with the OPEN vol-stop
    // (`vol_stop_inner(None)`) as the stop arm — so the price/bias fan, Sizer, PM,
    // and exposed dense R-record are guaranteed identical to the bound form, and a
    // member built from any `(length, k)` point matches the bound form byte-for-byte.
    risk_executor_inner(Box::new(|g| g.add(vol_stop_inner(None))), risk_budget)
}

/// A cost-model graph as a composition: `n` cost nodes fanned the 4 PM-geometry
/// inputs, each node's extra inputs surfaced as `cost[k].<port>` roles, all summed
/// by [`CostSum`] into the single 3-field cost-in-R stream the net-R seam consumes
/// (`summarize_r` + the `net_r_equity` tap stay unchanged). Inlines at bootstrap
/// (C11) to the same flat fan-in the hand-wired CLI block produced, so a cost run's
/// `net_expectancy_r` is byte-identical. Requires `n >= 1` (mirrors `CostSum::new`).
///
/// Each cost node is a `PrimitiveBuilder` (built via `cost_node_builder`), whose
/// schema is geometry-prefix-first then the factor's extras (slot `GEOMETRY_WIDTH`
/// onward); `cost_graph` reads `schema().inputs[GEOMETRY_WIDTH..]` to discover the
/// extras and names them `cost[k].<port>` — mirroring `CostSum`'s own `cost[k].*`
/// input vocabulary. Runtime-computed port names are `.leak()`ed to `&'static str`
/// to satisfy the `NodeHandle::input`/`output` bound — fine while cost is run-path
/// only (the graph is built once, a one-shot leak), but to be interned (the
/// `COL_PORTS` production pattern) before cost reaches the per-member sweep path,
/// where a per-build leak would accumulate. See #152.
pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
    assert!(!cost_nodes.is_empty(), "cost_graph needs at least one cost node");
    let n = cost_nodes.len();
    let mut g = GraphBuilder::new("cost_graph");

    // The 4 PM-geometry input roles, fanned to every cost node's geometry inputs.
    let closed = g.input_role("closed");
    let open = g.input_role("open");
    let entry = g.input_role("entry_price");
    let stop = g.input_role("stop_price");

    let agg = g.add(CostSum::builder(n));

    // Per-role geometry fan targets, collected across all nodes, fed once per role.
    let mut closed_t = Vec::with_capacity(n);
    let mut open_t = Vec::with_capacity(n);
    let mut entry_t = Vec::with_capacity(n);
    let mut stop_t = Vec::with_capacity(n);
    for (k, node) in cost_nodes.into_iter().enumerate() {
        // The factor's extra ports = everything past the 4-wide geometry prefix.
        let extra_names: Vec<String> =
            node.schema().inputs[GEOMETRY_WIDTH..].iter().map(|p| p.name.clone()).collect();
        let h = g.add(node);
        closed_t.push(h.input("closed"));
        open_t.push(h.input("open"));
        entry_t.push(h.input("entry_price"));
        stop_t.push(h.input("stop_price"));
        // Each extra input becomes a `cost[k].<port>` composite role.
        for name in &extra_names {
            let role = g.input_role(&format!("cost[{k}].{name}"));
            let port: &'static str = name.clone().leak();
            g.feed(role, [h.input(port)]);
        }
        // The node's 3 cost fields -> CostSum's `cost[k].<field>` inputs.
        for field in COST_FIELD_NAMES {
            let agg_in: &'static str = format!("cost[{k}].{field}").leak();
            g.connect(h.output(field), agg.input(agg_in));
        }
    }
    g.feed(closed, closed_t);
    g.feed(open, open_t);
    g.feed(entry, entry_t);
    g.feed(stop, stop_t);

    // Expose CostSum's aggregate as the composite's 3-field cost output.
    for field in COST_FIELD_NAMES {
        g.expose(agg.output(field), field);
    }
    g.build().expect("cost_graph wires")
}
