//! End-to-end coverage for the construction op-script (#157, cycle 0088): a
//! declarative, replayable by-identifier op-script (`replay` over a `Vec<Op>`)
//! builds a runnable blueprint through validated ops, reusing the engine's
//! existing gates at two cadences — eager (per-op) and holistic (finalize).
//!
//! The in-module tests in `construction.rs` reach into crate internals
//! (`crate::GraphBuilder`, `crate::CompileError`, `super::replay`). These tests
//! use only the **exported** surface — `aura_engine::{replay, Op, OpError,
//! GraphBuilder, Composite, FlatGraph, Scalar, ScalarKind}` plus the injected
//! `aura_std::std_vocabulary` — the exact seam the iteration-2 CLI (`aura graph
//! build`/`introspect`) and the World consume. A refactor that made `Op`,
//! `OpError`, `replay`, or a needed re-export (`ScalarKind`, `Scalar`)
//! unreachable across the crate boundary would break that consumer while leaving
//! every in-crate test green; that is the regression class this file pins, in
//! addition to the per-property invariants below.

use aura_engine::{replay, GraphBuilder, Op, OpError, Scalar, ScalarKind};
use aura_std::{std_vocabulary, Bias, Sma, Sub};

/// The flat root's open param point: fast.length(i64), slow.length(i64),
/// bias.scale(f64), in param-space traversal order. The same vector is applied to
/// both construction paths, so it cancels and the only thing under test is
/// construction identity. Deterministic — no time, no randomness.
fn params() -> [Scalar; 3] {
    [Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)]
}

/// The SMA-cross signal as a construction op-script: a bound `price` source, a
/// fast/slow SMA pair fed from it, their spread through `Sub`, a `Bias`, and the
/// `bias` field exposed at the boundary. The smallest script that exercises every
/// op kind (`Source`/`Add`/`Feed`/`Connect`/`Expose`) at once.
fn signal_ops() -> Vec<Op> {
    vec![
        Op::Source { role: "price".into(), kind: ScalarKind::F64 },
        Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
        Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
        Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
        Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
        Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
        Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
        Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
        Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
        Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
    ]
}

/// Property (C24 acceptance-1, through the public seam): an op-script replayed via
/// the exported `replay` + the injected `aura_std::std_vocabulary` compiles to a
/// `FlatGraph` whose index-wired topology (`edges`) and lowered bound sources
/// (`sources`) are **identical** to the same signal hand-wired on the exported
/// `GraphBuilder` surface and compiled with the same param point. The two
/// authoring surfaces are one construction semantics (C24) — proven entirely
/// through `aura_engine`/`aura_std` public exports (the CLI/World seam). The
/// non-empty `edges` guard keeps the equality from being the vacuous match of two
/// empty graphs.
#[test]
fn replayed_construction_compiles_identically_to_graphbuilder() {
    // (a) op-script replay through the public construction surface.
    let replay_flat = replay("root", signal_ops(), &std_vocabulary)
        .expect("op-script resolves")
        .compile_with_params(&params())
        .expect("replay compiles");

    // (b) the GraphBuilder twin — identical topology, identical param point.
    let mut g = GraphBuilder::new("root");
    let fast = g.add(Sma::builder().named("fast"));
    let slow = g.add(Sma::builder().named("slow"));
    let sub = g.add(Sub::builder());
    let bias = g.add(Bias::builder());
    let price = g.source_role("price", ScalarKind::F64);
    g.feed(price, [fast.input("series"), slow.input("series")]);
    g.connect(fast.output("value"), sub.input("lhs"));
    g.connect(slow.output("value"), sub.input("rhs"));
    g.connect(sub.output("value"), bias.input("signal"));
    g.expose(bias.output("bias"), "bias");
    let builder_flat = g
        .build()
        .expect("root resolves")
        .compile_with_params(&params())
        .expect("twin compiles");

    assert!(!replay_flat.edges.is_empty(), "the signal must wire a non-trivial topology");
    assert_eq!(replay_flat.edges, builder_flat.edges, "construction topology diverged from the builder twin");
    assert_eq!(replay_flat.sources, builder_flat.sources, "lowered bound sources diverged from the builder twin");
}

/// Property (eager fail-fast cadence, at the public boundary): an eagerly-decidable
/// fault — here an `Add` of a `type_id` outside the closed vocabulary — stops
/// `replay` AT the offending op and is reported by `(op_index, OpError)`, the
/// op_index being the position of the bad op (2 here), not the end of the script.
/// The earlier valid ops do not mask it and the later ops never run. This is the
/// by-index error attribution the CLI/World relies on to point an author at the
/// exact failing op, asserted through the exported `replay`/`Op`/`OpError`.
#[test]
fn replay_attributes_eager_fault_to_its_op_index() {
    let ops = vec![
        Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
        Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },                // 1 ok
        Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] },               // 2 fails
        Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },               // 3 never runs
    ];
    // `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
    // closures and is not `Debug`, which `unwrap_err`'s bound would require.
    let err = replay("g", ops, &std_vocabulary).err().unwrap();
    assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
}

/// Property (holistic finalize cadence, at the public boundary): a fault that is
/// only decidable at end-of-document — input-slot totality (the 0-cover arm of
/// exactly-once wiring) — passes every per-op check and is caught by the implicit
/// finalize step, attributed to index `ops.len()` (6 here), strictly past any real
/// op index. Every op below applies cleanly; `sub.rhs` is left unwired, so only the
/// holistic `validate_wiring` gate at finalize can reject it, as the by-identifier
/// `OpError::UnconnectedPort { .. }`. Together with the
/// eager-cadence test, this pins the iteration's eager/holistic gate split as
/// observable error attribution across the crate boundary.
#[test]
fn replay_attributes_holistic_fault_to_finalize_step() {
    let ops = vec![
        Op::Source { role: "price".into(), kind: ScalarKind::F64 },                    // 0
        Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
        Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
        Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },                // 3
        Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
        Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },               // 5
        // sub.rhs deliberately left unwired -> only finalize can decide totality.
    ];
    let finalize_index = ops.len(); // 6 — the implicit finalize step
    let err = replay("g", ops, &std_vocabulary).err().unwrap();
    assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
        "the holistic fault is still attributed to the finalize step, got {err:?}");
}
