//! End-to-end coverage for the `aura graph build` / `aura graph introspect`
//! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv,
//! the exact surface a data-level author uses.

use std::io::Write;
use std::process::{Command, Stdio};

const BIN: &str = env!("CARGO_BIN_EXE_aura");

/// Run `aura <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
    let mut child = Command::new(BIN)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn aura");
    child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
    let out = child.wait_with_output().expect("wait aura");
    (
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
        out.status.success(),
    )
}

/// Like `run`, but returns the exact process exit CODE (not just success/failure).
/// The exit-code partition (#175 iteration 2) is a property of the integer, so its
/// tests must assert `Some(1)` vs `Some(2)`, which `run`'s bool cannot express.
fn run_code(args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
    let mut child = Command::new(BIN)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn aura");
    child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
    let out = child.wait_with_output().expect("wait aura");
    (String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
}

const SIGNAL_DOC: &str = r#"[
    {"op":"source","role":"price","kind":"F64"},
    {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
    {"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
    {"op":"add","type":"Sub"},
    {"op":"add","type":"Bias"},
    {"op":"feed","role":"price","into":["fast.series","slow.series"]},
    {"op":"connect","from":"fast.value","to":"sub.lhs"},
    {"op":"connect","from":"slow.value","to":"sub.rhs"},
    {"op":"connect","from":"sub.value","to":"bias.signal"},
    {"op":"expose","from":"bias.bias","as":"bias"}
]"#;

/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
const UNCONNECTED_DOC: &str = r#"[
    {"op":"source","role":"price","kind":"F64"},
    {"op":"add","type":"SMA","name":"fast"},
    {"op":"add","type":"SMA","name":"slow"},
    {"op":"add","type":"Sub"},
    {"op":"feed","role":"price","into":["fast.series","slow.series"]},
    {"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;

#[test]
fn graph_build_emits_blueprint_for_a_valid_document() {
    let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(ok, "exit success");
    assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}");
    assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}

/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
#[test]
fn graph_build_emit_is_byte_canonical_no_trailing_newline() {
    let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(ok, "exit success");
    assert!(!stdout.ends_with('\n'), "no trailing newline on the canonical emit: {stdout:?}");
    assert!(stdout.contains("\"format_version\":1"), "still the #155 envelope: {stdout}");
}

#[test]
fn graph_build_fails_fast_at_the_offending_op() {
    let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit");
    assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}");
    assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}");
    assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}

#[test]
fn graph_introspect_vocabulary_lists_the_node_types() {
    let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
    assert!(ok, "exit success");
    assert!(stdout.contains("SMA"), "lists SMA: {stdout}");
    assert!(stdout.contains("Bias"), "lists Bias: {stdout}");
    assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}");
}

/// The #160 roster macro's whole point is that `std_vocabulary`'s match arms and
/// `std_vocabulary_types`'s enumerable list can never drift apart — but that
/// guarantee is checked in-process, inside `aura-std`'s own unit tests. This pins
/// the SAME closed-roster count one layer further out, through a code path the
/// roster macro cannot see: `Env::type_ids()` (project ∪ std concatenation) and
/// the CLI's println loop, observed across the real process boundary. A bug in
/// that CLI-side plumbing (a dropped or duplicated entry in the concatenation or
/// the print loop) would pass every roster-internal test yet still change what a
/// user actually sees from `aura graph introspect --vocabulary` with no project
/// loaded — this test is the only thing that would catch it.
#[test]
fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
    let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
    assert!(ok, "exit success");
    let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(
        lines.len(),
        29,
        "the std-only (no project) vocabulary has exactly the roster's 29 entries: {stdout}"
    );
}

#[test]
fn graph_introspect_node_answers_ports_without_a_build() {
    let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
    assert!(ok, "exit success");
    assert!(stdout.contains("series"), "lists the input port: {stdout}");
    assert!(stdout.contains("value"), "lists the output field: {stdout}");
}

#[test]
fn graph_introspect_unwired_reports_open_slots() {
    let doc = r#"[
        {"op":"add","type":"SMA","name":"fast"},
        {"op":"add","type":"Sub"},
        {"op":"connect","from":"fast.value","to":"sub.lhs"}
    ]"#;
    let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc);
    assert!(ok, "exit success");
    assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}");
    assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}");
}

/// Property (holistic finalize path, binary contract): a fault only decidable
/// at end-of-document (a 0-covered interior slot) makes `aura graph build` exit
/// non-zero, route the cause to *stderr* as a `finalize:` fault (NOT attributed
/// to any op index), and emit nothing on stdout. The eager fail-fast path (above)
/// already exercises a per-op fault; this pins the iteration's eager/holistic
/// gate split as the binary's observable exit-code + stream-routing contract,
/// not merely the engine `replay` return value.
#[test]
fn graph_build_reports_a_holistic_fault_at_finalize() {
    let (stdout, stderr, ok) = run(&["graph", "build"], UNCONNECTED_DOC);
    assert!(!ok, "non-zero exit on a finalize fault");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
    assert!(stderr.contains("finalize: slot") && stderr.contains("is unconnected"),
        "the holistic fault renders by-identifier, got: {stderr}");
}

/// Property (determinism, domain invariant 1, at the CLI seam): the same op-list
/// document piped to `aura graph build` twice yields byte-identical blueprint
/// JSON on stdout. The #155 canonical form has no run-to-run ordering or
/// formatting jitter — a precondition for content-addressing a blueprint (#158).
/// The non-empty guard rules out two equal-but-empty error paths masquerading as
/// agreement.
#[test]
fn graph_build_is_byte_identical_across_runs() {
    let (first, _e1, ok1) = run(&["graph", "build"], SIGNAL_DOC);
    let (second, _e2, ok2) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(ok1 && ok2, "both runs succeed");
    assert!(!first.is_empty(), "non-empty blueprint output");
    assert_eq!(first, second, "byte-identical blueprint across runs");
}

/// Property (read-only query, clean rejection): `aura graph introspect --node`
/// with a type outside the closed vocabulary exits non-zero, names the offending
/// type on stderr, and prints no partial port listing on stdout. The introspect
/// surface's only negative-path E2E — it fails cleanly rather than half-answering.
#[test]
fn graph_introspect_node_rejects_an_unknown_type() {
    let (stdout, stderr, ok) = run(&["graph", "introspect", "--node", "Bogus"], "");
    assert!(!ok, "non-zero exit for an unknown type");
    assert!(stdout.is_empty(), "no partial port listing emitted: {stdout}");
    assert!(stderr.contains("Bogus"), "names the offending type: {stderr}");
}

/// Property (#162 item 2, bind-mismatch prose at the binary seam): an `aura graph
/// build` whose bind value-kind disagrees with the param's declared kind exits
/// non-zero and writes the cause as prose to stderr — naming the param path and
/// both the expected and actual kind — and never leaks the raw `KindMismatch`
/// Debug struct the pre-#162 `{err:?}` presenter exposed. SMA's `length` is i64,
/// so binding an `F64` is the smallest kind disagreement. Pins the user-facing
/// bind-error ergonomics at the binary, not merely the in-process presenter.
#[test]
fn graph_build_renders_a_bind_kind_mismatch_as_prose() {
    let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit on a bad bind");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(stderr.contains("param fast.length expects I64 but got F64"),
        "phrases the bind mismatch as prose: {stderr}");
    assert!(!stderr.contains("KindMismatch"),
        "does not leak the Debug struct name: {stderr}");
}

/// Property (#162 item 3, discoverable bind form at the binary seam): `aura graph
/// introspect --node` annotates each param with its typed-Scalar bind wrapper, so
/// a hand-author learns the `{"<Kind>": <v>}` value form from the CLI alone
/// without reading source. SMA's `length` is i64, so the shown form is the `I64`
/// wrapper.
#[test]
fn graph_introspect_node_shows_the_typed_bind_form() {
    let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
    assert!(ok, "exit success");
    assert!(stdout.contains("(bind {\"I64\": <v>})"),
        "shows the typed-Scalar bind-value form for the i64 param: {stdout}");
}

/// Property (#162 item 1, by-identifier finalize fault at the binary seam, a
/// non-unconnected variant): a fault decidable only at finalize that is NOT a
/// 0-covered slot — here one source role fanning into two slots of differing
/// scalar kind (an `f64` `Sub.lhs` and a `bool` `And.a`); `feed` runs no eager
/// kind check, so the disagreement surfaces only at the holistic finalize gate —
/// renders by *identifier*: `aura graph build` exits non-zero, routes a
/// `finalize:` fault to stderr, and names the offending role ("price") in prose,
/// never a raw machine role index. The companion unconnected-slot test pins one
/// finalize variant; this pins that the by-identifier translation generalises
/// across finalize variants.
#[test]
fn graph_build_renders_role_kind_mismatch_at_finalize_by_identifier() {
    let doc = r#"[
        {"op":"source","role":"price","kind":"F64"},
        {"op":"add","type":"Sub","name":"sub"},
        {"op":"add","type":"And","name":"conj"},
        {"op":"feed","role":"price","into":["sub.lhs","conj.a"]}
    ]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit on a finalize fault");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
    assert!(stderr.contains("input role price fans into slots of differing kinds"),
        "names the offending role by-identifier in prose: {stderr}");
    assert!(!stderr.contains("RoleKindMismatch"),
        "does not leak the Debug variant name: {stderr}");
}

/// Property (#158 acc 1, the content-id surface): `aura graph introspect --content-id`
/// prints the 64-hex SHA256 of the op-script's canonical blueprint — deterministic across
/// runs and distinct for a structurally different topology. It is the same hash `aura run`
/// stamps as `topology_hash` (both via the one `content_id` primitive over the canonical
/// `blueprint_to_json` bytes), so a data-authored topology has a stable, comparable content
/// id. (Note: an op-script and the Rust `sma_signal` builder produce *different*
/// canonical forms — composite debug-name, an unbound `Bias.scale` — so they are different
/// topologies by the byte definition; content-addressing keys on the canonical form.)
#[test]
fn graph_introspect_content_id_is_deterministic_and_distinguishes() {
    let (a, _e, ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    assert!(ok, "exit success");
    let id = a.trim();
    assert_eq!(id.len(), 64, "a 64-hex SHA256 content id: {id:?}");
    assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");

    // deterministic: the same op-script yields the same content id.
    let (b, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    assert!(ok2, "exit success");
    assert_eq!(id, b.trim(), "same op-script -> same content id");

    // distinguishes: a structurally different topology (slow length 4 -> 5) differs.
    let other = SIGNAL_DOC.replace("\"I64\":4", "\"I64\":5");
    let (c, _e3, ok3) = run(&["graph", "introspect", "--content-id"], &other);
    assert!(ok3, "exit success");
    assert_ne!(id, c.trim(), "a different topology -> a different content id");
}

/// Property (negative): `aura graph introspect --content-id` on a malformed op-list exits
/// non-zero with the cause on stderr and prints no content id — it fails cleanly, never a
/// partial/garbage hash.
#[test]
fn graph_introspect_content_id_rejects_a_bad_document() {
    let (stdout, stderr, ok) = run(&["graph", "introspect", "--content-id"], r#"[{"op":"add","type":"Nope"}]"#);
    assert!(!ok, "non-zero exit on a bad op-list");
    assert!(stdout.is_empty(), "no content id emitted on error: {stdout}");
    assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}

/// Property (exit-code partition, #175 iteration 2, deviation #8): a malformed
/// op-script piped to `aura graph build` is a RUNTIME failure — bad stdin *content*,
/// exit 1 — NOT the usage code 2. The attribution principle classifies bad
/// piped-stdin data as environment/runtime (the command line was well-formed),
/// distinct from a command-line fault. The sibling `graph_build_fails_fast_at_the_
/// offending_op` pins only `!ok` (non-zero); this pins the exact integer, so a
/// regression that reverted the graph_construct stdin site to the pre-split exit 2
/// fails here.
#[test]
fn graph_build_bad_stdin_content_is_runtime_exit_1() {
    let (stderr, code) = run_code(&["graph", "build"], r#"[{"op":"add","type":"Nope"}]"#);
    assert_eq!(code, Some(1), "bad stdin content is a runtime failure -> exit 1; stderr: {stderr}");
    assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
}

/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
/// content on the `--unwired`/`--content-id` branches, which is runtime exit 1. This
/// pins the argv-fault-vs-stdin-fault boundary the attribution principle draws at
/// the exact integer; the sibling `graph_introspect_node_rejects_an_unknown_type`
/// checks only non-zero, and would pass whether the code were 1 or 2.
#[test]
fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
    let (stderr, code) = run_code(&["graph", "introspect", "--node", "Bogus"], "");
    assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
    assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
}

/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
/// that differ only in debug names — the renamed twin keeps the same identity id
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
/// instance name and in every `fast.*` reference, so the replaced document stays
/// internally consistent.
#[test]
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
    let renamed = SIGNAL_DOC.replace("fast", "speedy");
    let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
    assert!(ok, "exit success");
    let ia = ia.trim().to_string();
    assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
    assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
    let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
    assert!(ok2, "exit success");
    assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
    let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
    assert!(ok3 && ok4, "exit success");
    assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
}

/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
/// one per line, content id first — each byte-equal to its single-flag output,
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
#[test]
fn graph_introspect_content_and_identity_id_combine() {
    let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
    assert!(ok, "exit success");
    let lines: Vec<&str> = both.lines().collect();
    assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
    let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
    assert!(ok2 && ok3, "exit success");
    assert_eq!(lines[0], content.trim(), "content id first");
    assert_eq!(lines[1], identity.trim(), "identity id second");
    assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
}

/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
/// bad op-list exits non-zero with the cause on stderr and prints no id.
#[test]
fn graph_introspect_identity_id_rejects_a_bad_document() {
    let (stdout, stderr, ok) =
        run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
    assert!(!ok, "non-zero exit on a bad op-list");
    assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
    assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}

/// Property (usage gate — the previously untested count!=1 dispatch path):
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
/// usage line on stderr.
#[test]
fn graph_introspect_no_flag_is_usage_exit_2() {
    let (stderr, code) = run_code(&["graph", "introspect"], "");
    assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
    assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
}

/// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
    let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}"));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create temp cwd");
    dir
}

/// Run `aura <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
    let out = Command::new(BIN)
        .args(args)
        .current_dir(dir)
        .output()
        .expect("binary runs");
    (
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
        out.status.code(),
    )
}

/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
    format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}

/// The absolute path of a demo blueprint shipped in the public examples/
/// gallery (#159 copy+prove) — distinct from `fixture()`, which reads the
/// internal tests/fixtures/ copies the emitter builds from.
fn example(name: &str) -> String {
    format!("{}/examples/{name}", env!("CARGO_MANIFEST_DIR"))
}

/// The id extracted from a `registered blueprint {id} ({path})` line.
/// Prefix-tolerant across the #194 convention change (the display form dropped
/// the glued `content:` prefix): the id is bare either way.
fn registered_id(stdout: &str) -> String {
    stdout
        .lines()
        .find(|l| l.starts_with("registered blueprint "))
        .expect("register line")
        .trim_start_matches("registered blueprint ")
        .split(' ')
        .next()
        .expect("id")
        .trim_start_matches("content:")
        .to_string()
}

/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
/// the document through the vocabulary, canonicalizes, and stores it content-
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
#[test]
fn graph_register_stores_and_prints_content_id() {
    let dir = temp_cwd("register");
    let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    // #194: the register verb prints the bare id — `registered blueprint <id> (<path>)`.
    assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}");
    let id = registered_id(&stdout);
    assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
    assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
    assert!(
        dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
        "stored under runs/blueprints/<id>.json: {stdout}"
    );
}

/// Register is write-once content-addressed: the same document registers to the
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
#[test]
fn graph_register_is_idempotent() {
    let dir = temp_cwd("register-idempotent");
    let bp = example("r_sma.json");
    let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
    let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
    assert_eq!(code1, Some(0), "first register: {out1} {err1}");
    assert_eq!(code2, Some(0), "second register: {out2} {err2}");
    assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}

/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
    let dir = temp_cwd("params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}

/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
/// id is exactly the store key `graph register` prints — the file is shape-
/// discriminated from the op-list form and canonicalized by the blueprint rules.
#[test]
fn graph_content_id_accepts_a_blueprint_file() {
    let dir = temp_cwd("content-id-file");
    let bp = example("r_sma.json");
    let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
    assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
    let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]);
    assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}");
    assert_eq!(id_out.trim(), registered_id(&reg_out), "file content id == store key");
}

/// The one-mode guard extends over the new `--params` mode: zero modes and two
/// modes both refuse usage-exit-2, and the usage line names the new mode.
#[test]
fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
    let dir = temp_cwd("mode-guard");
    let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]);
    assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}");
    assert!(err0.contains("--params <FILE|ID>"), "usage line names --params: {err0}");
    let (_out2, err2, code2) =
        run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
    assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
    assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
}

/// Property (#196, the register->params-by-id round trip): `--params <ID>`
/// with a registered blueprint's content id resolves through the project
/// store and prints exactly the same raw axis namespace `--params <FILE>`
/// prints for the same document — the advertised `<FILE|ID>` addressing is
/// two paths to the identical answer, not merely two accepted shapes.
#[test]
fn graph_params_by_content_id_matches_params_by_file() {
    let dir = temp_cwd("params-by-id");
    let bp = fixture("r_sma_open.json");
    let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
    assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
    let id = registered_id(&reg_out);
    let (by_id_out, by_id_err, by_id_code) =
        run_in(&dir, &["graph", "introspect", "--params", &id]);
    assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
    let (by_file_out, by_file_err, by_file_code) =
        run_in(&dir, &["graph", "introspect", "--params", &bp]);
    assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
    assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
    assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}

/// #194 (the prefix trap): a `--params <ID>` target may carry the display
/// `content:` prefix copied verbatim from register/introspect output — the CLI
/// strips it and resolves the bare store address, printing exactly the same raw
/// axis namespace the bare id (and the file) print.
#[test]
fn graph_params_tolerates_content_prefix_on_target() {
    let dir = temp_cwd("params-content-prefix");
    let bp = fixture("r_sma_open.json");
    let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
    assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
    let id = registered_id(&reg_out);
    let (pfx_out, pfx_err, pfx_code) =
        run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
    assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
    assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
}

/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
/// that was never registered refuses with a store-miss message, not a panic
/// or a silent empty namespace — the registry lookup branch of
/// `resolve_blueprint_text` is exercised.
#[test]
fn graph_params_by_unregistered_id_reports_a_store_miss() {
    let dir = temp_cwd("params-missing-id");
    let bogus_id = "a".repeat(64);
    let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
    assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
    assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
    assert!(
        stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
        "names the miss: {stderr}"
    );
}

/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
/// existing file nor a 64-hex content id refuses naming both readings — the
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
/// target resolution via `is_content_id`.
#[test]
fn graph_params_target_neither_file_nor_id_is_refused() {
    let dir = temp_cwd("params-neither");
    let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
    assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
    assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
    assert!(
        stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
        "names both readings: {stderr}"
    );
}

/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
/// shape-discriminated correctly and produces the SAME content id as piping
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
/// exercised end-to-end, not just its `Object` sibling.
#[test]
fn graph_content_id_accepts_an_op_list_file() {
    let dir = temp_cwd("content-id-op-list-file");
    let op_list_path = dir.join("op-list.json");
    std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
    let (file_out, file_err, file_code) = run_in(
        &dir,
        &["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
    );
    assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
    let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
    assert!(stdin_ok, "stdin variant succeeds");
    assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
}

/// Property (#196, negative): a FILE whose top-level JSON value is neither an
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
/// is refused, exercising `composite_from_any`'s fall-through arm.
#[test]
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
    let dir = temp_cwd("content-id-neither-shape");
    let bad_path = dir.join("bad.json");
    std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
    let (stdout, stderr, code) = run_in(
        &dir,
        &["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
    );
    assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
    assert!(stdout.is_empty(), "no content id emitted: {stdout}");
    assert!(
        stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
        "names the shape refusal: {stderr}"
    );
}

/// Property (#196, negative): `register` on a syntactically-valid blueprint
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
/// it once through `register` closes the whole family.
#[test]
fn graph_register_rejects_an_unknown_node_type_with_prose() {
    let dir = temp_cwd("register-unknown-node");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
    assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
    assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
    assert!(
        stderr.contains("unknown node type \"Nope\""),
        "names the unknown node type: {stderr}"
    );
}

/// Property (#202 headline, the on-ramp is shape-invariant): `aura graph register`
/// accepts an op-script document (a JSON array) exactly as it accepts a #155
/// blueprint envelope (a JSON object) — it shape-discriminates the array form,
/// builds it through the same one-build tail, and stores it under the SAME content
/// id it stores that op-script's `graph build` envelope under. The content id is
/// shape-invariant (an op-script and its built envelope hash identically), so the
/// two on-ramp shapes must agree on the store key; `register` must not reject the
/// op-script with the raw serde `invalid type: map, expected u32` message the
/// direct-`blueprint_from_json` path currently leaks.
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
    let dir = temp_cwd("register-op-script");
    // The op-script form (a JSON array of ops), written to a file.
    let op_script = dir.join("op-script.json");
    std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
    // Its `graph build` envelope (a JSON object), the shape `register` already accepts.
    let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(built, "graph build produces the envelope");
    let envelope = dir.join("envelope.json");
    std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");

    // The envelope shape registers today (green sibling); the op-script must too.
    let (env_out, env_err, env_code) =
        run_in(&dir, &["graph", "register", envelope.to_str().unwrap()]);
    assert_eq!(env_code, Some(0), "register envelope: {env_out} {env_err}");
    let (op_out, op_err, op_code) =
        run_in(&dir, &["graph", "register", op_script.to_str().unwrap()]);
    assert_eq!(op_code, Some(0), "register op-script exits 0: {op_out} {op_err}");

    assert_eq!(
        registered_id(&op_out),
        registered_id(&env_out),
        "op-script and its built envelope register to the same content id"
    );
}

/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
/// envelope — the raw axis namespace it prints for the op-script equals the one it
/// prints for that op-script's built envelope, both exit 0. Currently `--params`
/// rejects the op-script with the same raw serde `invalid type: map, expected u32`
/// message `register` does (both route through the direct `blueprint_from_json`).
#[test]
fn graph_params_accepts_an_op_script_matching_its_envelope() {
    let dir = temp_cwd("params-op-script");
    let op_script = dir.join("op-script.json");
    std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
    let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(built, "graph build produces the envelope");
    let envelope = dir.join("envelope.json");
    std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");

    let (env_out, env_err, env_code) =
        run_in(&dir, &["graph", "introspect", "--params", envelope.to_str().unwrap()]);
    assert_eq!(env_code, Some(0), "params on envelope: {env_out} {env_err}");
    let (op_out, op_err, op_code) =
        run_in(&dir, &["graph", "introspect", "--params", op_script.to_str().unwrap()]);
    assert_eq!(op_code, Some(0), "params on op-script exits 0: {op_out} {op_err}");
    assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
}

/// Property (#226, the render positional closes the #202 on-ramp family):
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
/// JSON object). Its two introspect siblings (`register`, `introspect --params`,
/// pinned above) already shape-discriminate via `composite_from_any`, but the
/// render positional still parses via the direct `blueprint_from_json` and so
/// rejects the op-script form — leaking the raw serde `invalid type: map,
/// expected u32` mislabeled as "blueprint document is not valid JSON". The
/// op-script and its own `graph build` envelope canonicalize to the same
/// Composite (the register content-id parity above proves the bytes match), so
/// both must render byte-identically at exit 0.
#[test]
fn graph_render_accepts_an_op_script_matching_its_envelope() {
    let dir = temp_cwd("render-op-script");
    let op_script = dir.join("op-script.json");
    std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
    let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
    assert!(built, "graph build produces the envelope");
    let envelope = dir.join("envelope.json");
    std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");

    let (env_out, env_err, env_code) = run_in(&dir, &["graph", envelope.to_str().unwrap()]);
    assert_eq!(env_code, Some(0), "render envelope: {env_err}");
    let (op_out, op_err, op_code) = run_in(&dir, &["graph", op_script.to_str().unwrap()]);
    assert_eq!(op_code, Some(0), "render op-script exits 0: {op_err}");
    assert!(
        !op_err.contains("blueprint document is not valid JSON"),
        "must not mislabel a valid op-script as invalid JSON: {op_err}"
    );
    assert_eq!(op_out, env_out, "op-script and its built envelope render byte-identically");
}

/// Property (#159): the shipped closed example (`examples/r_sma.json`) is
/// genuinely closed, not merely byte-identical to the builder — `graph
/// introspect --params` on it reports zero unbound params. The Cut-1
/// byte-fidelity proof (`shipped_r_sma_examples_are_byte_identical_to_the_builder`,
/// main.rs) only pins the example's bytes to the builder's serialization; it says
/// nothing about whether that serialization is actually closed. This pins the
/// "closed" claim the demo makes to its audience.
#[test]
fn shipped_r_sma_example_is_genuinely_closed() {
    let dir = temp_cwd("example-closed-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}

/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
/// usable sweep-axis template from its public gallery location, not just a
/// byte-identical artifact — `graph introspect --params` lists exactly the two
/// unbound SMA lengths, in lowering order, matching the raw axis namespace the
/// internal `sma_signal_open.json` fixture prints (#196). The byte-fidelity
/// proof only checks the example's bytes match the builder; this checks the
/// shipped copy is actually introspectable/bindable from where a consumer runs
/// it.
#[test]
fn open_r_sma_fixture_lists_its_axis_namespace() {
    let dir = temp_cwd("example-open-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(
        stdout, "fast.length:I64\nslow.length:I64\n",
        "the raw open params, in order, from the public gallery copy"
    );
}

/// Property (#248): the four `_open` blueprint twins were RELOCATED out of the
/// public gallery, not deleted — each stem is present under `tests/fixtures/`
/// (the internal sweep-axis fixture location) and absent from `examples/` (the
/// public, user-facing gallery). This is the acceptance criterion's core claim
/// ("`examples/` ships no `_open` variants") stated as a filesystem fact, so a
/// future example addition that reintroduces a twin — or a migration that moves
/// the reference but forgets the file — fails here even though every
/// `include_str!`/path-string call site already resolves.
#[test]
fn open_twins_are_relocated_from_examples_to_fixtures_not_deleted() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    for stem in ["r_sma", "r_breakout", "r_meanrev", "r_channel"] {
        let gallery_path = format!("{manifest_dir}/examples/{stem}_open.json");
        assert!(
            !std::path::Path::new(&gallery_path).exists(),
            "{stem}_open.json must not ship in the public examples/ gallery (#248)"
        );
        let fixture_path = format!("{manifest_dir}/tests/fixtures/{stem}_open.json");
        assert!(
            std::path::Path::new(&fixture_path).exists(),
            "{stem}_open.json must survive relocated under tests/fixtures/ (#248)"
        );
    }
}

/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_breakout_example_is_genuinely_closed() {
    let dir = temp_cwd("r-breakout-example-closed-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}

/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
#[test]
fn open_r_breakout_fixture_lists_its_axis_namespace() {
    let dir = temp_cwd("r-breakout-example-open-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(
        stdout, "channel_length:I64\n",
        "the ganged channel knob — one public axis for the two rolling windows (#61)"
    );
}

/// Property (#61 + #159 cut 2): binding the shipped open example's ganged
/// `channel_length` axis through the REAL `aura sweep` CLI pipeline (argv ->
/// `parse_axes` -> `compile_with_cells`'s gang expansion) reproduces the exact
/// metrics of running the shipped CLOSED example, whose `channel_hi`/
/// `channel_lo` are separately hardwired to the same value. The equivalence
/// between a gang and its member-bound twin is already pinned at the builder/
/// engine level (aura-engine's `gang_e2e.rs`, and this crate's
/// `r_breakout_example_loaded_runs_identically_to_the_carved_signal`, which
/// calls `run_signal_r` directly); this instead drives the actual public
/// binary end-to-end on the actual shipped files, so a regression in the
/// CLI's own axis-name resolution or grid-point binding (e.g. silently
/// treating the gang as an unbound param, or fanning the value to only one
/// member) would be caught here even if the internal builder-level
/// equivalence still holds.
#[test]
fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
    let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
    let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
    assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
    let closed: serde_json::Value =
        serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");

    let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
    let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
        &sweep_dir,
        &["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
    );
    assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
    let lines: Vec<&str> = sweep_stdout.lines().collect();
    assert_eq!(lines.len(), 1, "one grid point for a single-value axis: {sweep_stdout}");
    let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");

    assert_eq!(
        member["report"]["metrics"], closed["metrics"],
        "channel_length=3, bound via the real sweep CLI, must fan out to both channel_hi \
         and channel_lo identically to the closed example's hardwired channel=3"
    );
}

/// Property (#159 cut 3): the shipped closed example (`examples/r_meanrev.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_meanrev_example_is_genuinely_closed() {
    let dir = temp_cwd("r-meanrev-example-closed-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}

/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
/// lists its raw axis namespace, in lowering (node-add) order.
#[test]
fn open_r_meanrev_fixture_lists_its_axis_namespace() {
    let dir = temp_cwd("r-meanrev-example-open-params");
    let (stdout, stderr, code) =
        run_in(&dir, &["graph", "introspect", "--params", &fixture("r_meanrev_open.json")]);
    assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
    assert_eq!(
        stdout, "window:I64\nband.factor:F64\n",
        "the ganged EWMA window + the independent band knob, in lowering order"
    );
}

/// Property (#61 + #159 cut 3): a sweep over TWO axes at once — the ganged
/// `window` knob (fusing `mean_window.length`/`var_window.length`) alongside
/// the independent, un-ganged `band.factor` knob — binds both correctly
/// through the real CLI grid, matching the shipped closed example's hardwired
/// window=3/band=2.0. This is a distinct risk from the r_breakout gang test
/// (a lone gang axis): a mis-scoped gang-expansion map that shifts sibling
/// param positions could silently mis-bind the co-present un-ganged axis
/// instead (or vice-versa) even though each axis alone still resolves.
#[test]
fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
    let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
    let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
    assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
    let closed: serde_json::Value =
        serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");

    let sweep_dir = temp_cwd("r-meanrev-gang-axis-sweep");
    let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
        &sweep_dir,
        &[
            "sweep", &fixture("r_meanrev_open.json"),
            "--axis", "r_meanrev_signal.window=3",
            "--axis", "r_meanrev_signal.band.factor=2.0",
        ],
    );
    assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
    let lines: Vec<&str> = sweep_stdout.lines().collect();
    assert_eq!(lines.len(), 1, "one grid point for two single-value axes: {sweep_stdout}");
    let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");

    assert_eq!(
        member["report"]["metrics"], closed["metrics"],
        "window=3 (ganged) + band.factor=2.0 (plain), bound via the real sweep CLI, must \
         match the closed example's hardwired window=3/band=2.0"
    );
}

/// The op-script surface accepts a gang op and the built blueprint exposes
/// ONE public knob for the fused pair (#61).
#[test]
fn graph_build_accepts_a_gang_op() {
    let dir = temp_cwd("graph-build-gang-op");
    let ops = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"gang","as":"length","into":["a.length","b.length"]},
      {"op":"add","type":"Sub"},
      {"op":"feed","role":"price","into":["a.series","b.series"]},
      {"op":"connect","from":"a.value","to":"sub.lhs"},
      {"op":"connect","from":"b.value","to":"sub.rhs"},
      {"op":"expose","from":"sub.value","as":"bias"}
    ]"#;
    let (bp, stderr, ok) = run(&["graph", "build"], ops);
    assert!(ok, "graph build: stderr: {stderr}");
    // Write the built blueprint to a file, then introspect its raw param space
    // (mirrors `graph_content_id_accepts_an_op_list_file`'s file-write plumbing).
    let bp_path = dir.join("bp.json");
    std::fs::write(&bp_path, &bp).expect("write built blueprint");
    let (params, stderr2, code2) =
        run_in(&dir, &["graph", "introspect", "--params", bp_path.to_str().unwrap()]);
    assert_eq!(code2, Some(0), "stderr: {stderr2}");
    assert_eq!(params, "length:I64\n", "one fused public knob, not two members");
}

/// Property (#61, Task 6 wiring): a blueprint built through the op-script `gang`
/// op and then RENDERED via `aura graph <file>` — the real CLI round trip a
/// consumer drives, not a hand-built `Composite` fed straight to `model_to_json`
/// — embeds the fused gang table in the page's inlined `window.AURA_MODEL`. This
/// is the seam that actually connects the construction-layer gang op to the
/// viewer surface: `graph_model.rs`'s `model_carries_gangs_only_for_ganged_scopes`
/// pins the JSON fragment in isolation, and `viewer_gang_param` pins the JS
/// annotation against a hand-written model literal, but neither proves the gang
/// table SURVIVES the build -> file -> render pipeline a consumer actually runs.
#[test]
fn graph_build_gang_op_survives_render_round_trip() {
    let dir = temp_cwd("graph-render-gang-round-trip");
    let ops = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"gang","as":"length","into":["a.length","b.length"]},
      {"op":"add","type":"Sub"},
      {"op":"feed","role":"price","into":["a.series","b.series"]},
      {"op":"connect","from":"a.value","to":"sub.lhs"},
      {"op":"connect","from":"b.value","to":"sub.rhs"},
      {"op":"expose","from":"sub.value","as":"bias"}
    ]"#;
    let (bp, stderr, ok) = run(&["graph", "build"], ops);
    assert!(ok, "graph build: stderr: {stderr}");
    let bp_path = dir.join("bp.json");
    std::fs::write(&bp_path, &bp).expect("write built blueprint");

    let out = std::process::Command::new(BIN)
        .arg("graph")
        .arg(&bp_path)
        .current_dir(&dir)
        .output()
        .expect("spawn aura graph <blueprint>");
    assert_eq!(
        out.status.code(),
        Some(0),
        "`aura graph <blueprint.json>` exit: {:?}\nstderr: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );
    let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
    assert!(
        html.contains(r#""gangs":[{"name":"length","kind":"I64","members":[["#),
        "rendered page does not embed the gang table: {html}"
    );
}

/// Property (#125, the render seam a hand-authored blueprint document actually
/// drives): a composite's `doc` field is not merely a `model_to_json` unit-level
/// concept — `aura graph <file>` on a real on-disk blueprint envelope that
/// carries `"doc":"..."` embeds that exact text in the page's inlined
/// `window.AURA_MODEL` JSON. The graph_model.rs golden pins the JSON fragment in
/// isolation; this proves the doc survives the actual file-load -> render CLI
/// pipeline a consumer runs, not just the library function called directly.
#[test]
fn graph_render_embeds_the_hand_authored_composite_doc() {
    let out = std::process::Command::new(BIN)
        .arg("graph")
        .arg(fixture("doc_blueprint.json"))
        .output()
        .expect("spawn aura graph <blueprint>");
    assert_eq!(
        out.status.code(),
        Some(0),
        "render exit: {:?}\nstderr: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );
    let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
    assert!(
        html.contains(r#","doc":"why this graph: single SMA smoke test""#),
        "rendered page does not embed the composite doc: {html}"
    );
}

/// Property (#125, doc-blind identity at the CLI seam): a blueprint document
/// carrying a `doc` and its doc-less twin are the SAME topology by the identity
/// projection (`--identity-id` agrees) but DIFFERENT documents by the canonical
/// projection (`--content-id` differs) — mirroring the established renamed-twin
/// pattern (`graph_introspect_identity_id_bridges_renamed_op_scripts`) for the
/// new field. `blueprint_serde.rs`'s `doc_round_trips_canonically_and_is_
/// identity_blind` pins this at the library level; this drives the real binary
/// over a real file, the boundary a regression in the CLI's own dispatch could
/// still break even with the library invariant intact.
#[test]
fn graph_doc_field_moves_content_id_but_not_identity_id() {
    let doced = fixture("doc_blueprint.json");
    let doceless_text = std::fs::read_to_string(&doced)
        .expect("read fixture")
        .replace(r#""doc":"why this graph: single SMA smoke test","#, "");
    let dir = temp_cwd("doc-field-ids");
    let doceless = dir.join("doc_blueprint_no_doc.json");
    std::fs::write(&doceless, &doceless_text).expect("write doc-less twin");

    // `--identity-id` has no FILE slot of its own (main.rs `GraphIntrospectCmd`):
    // combine it with `--content-id <FILE>` (both id flags share one build) and
    // read the two printed lines, content id first (mirrors
    // `graph_introspect_content_and_identity_id_combine`).
    let (doced_ids, _e, ok1) =
        run_in(&dir, &["graph", "introspect", "--content-id", &doced, "--identity-id"]);
    let (doceless_ids, _e2, ok2) = run_in(
        &dir,
        &["graph", "introspect", "--content-id", doceless.to_str().unwrap(), "--identity-id"],
    );
    assert_eq!(ok1, Some(0), "doc'd id pair exits 0: {doced_ids}");
    assert_eq!(ok2, Some(0), "doc-less id pair exits 0: {doceless_ids}");
    let doced_lines: Vec<&str> = doced_ids.lines().collect();
    let doceless_lines: Vec<&str> = doceless_ids.lines().collect();
    assert_eq!(doced_lines.len(), 2, "content id then identity id: {doced_ids:?}");
    assert_eq!(doceless_lines.len(), 2, "content id then identity id: {doceless_ids:?}");
    assert_ne!(
        doced_lines[0], doceless_lines[0],
        "the doc is canonical-byte-bearing (content id moves)"
    );
    assert_eq!(
        doced_lines[1], doceless_lines[1],
        "the identity projection is doc-blind (identity id agrees)"
    );
}

/// Property (#125 viewer/#3, nested-composite doc through the real render
/// pipeline): the sibling test above only proves a ROOT-level `doc` reaches
/// the rendered page — the viewer threads a composite's doc onto its own
/// tooltip (INFO.B/INFO.C in graph-viewer.js), which needs the doc present on
/// a NESTED composite def, not just the root. `aura graph <file>` over a
/// blueprint whose one node is a nested composite carrying `doc` must embed
/// that exact string in the emitted page's `composites` section of
/// `window.AURA_MODEL` — the composite-def half of `graph_model.rs`'s
/// `doc_fragment`, exercised through the actual file-load -> render CLI
/// binary, not just the library function called directly.
#[test]
fn graph_render_embeds_a_nested_composites_doc() {
    let out = std::process::Command::new(BIN)
        .arg("graph")
        .arg(fixture("nested_doc_blueprint.json"))
        .output()
        .expect("spawn aura graph <blueprint>");
    assert_eq!(
        out.status.code(),
        Some(0),
        "render exit: {:?}\nstderr: {}",
        out.status,
        String::from_utf8_lossy(&out.stderr)
    );
    let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
    assert!(
        html.contains(r#","doc":"momentum leg: change over a lookback, gated by the trend sign""#),
        "rendered page does not embed the nested composite's doc: {html}"
    );
}

/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
#[test]
fn graph_build_reports_gang_arity_fault_as_prose() {
    let doc = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"gang","as":"solo","into":["a.length"]}
    ]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit on a gang arity fault");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(
        stderr.contains("op 2 (gang): gang `solo`: needs at least two members"),
        "names the op + cause as prose: {stderr}"
    );
    assert!(!stderr.contains("GangArity"), "does not leak the Debug variant name: {stderr}");
}

/// The `gang` op's double-membership refusal (a param already claimed by an
/// earlier gang) reads as prose, naming the offending `node.param`.
#[test]
fn graph_build_reports_already_ganged_fault_as_prose() {
    let doc = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"add","type":"SMA","name":"c"},
      {"op":"gang","as":"lo","into":["a.length","b.length"]},
      {"op":"gang","as":"hi","into":["a.length","c.length"]}
    ]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit on an already-ganged fault");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(
        stderr.contains("op 5 (gang): gang: `a.length` is already ganged"),
        "names the op + doubly-ganged member as prose: {stderr}"
    );
    assert!(!stderr.contains("AlreadyGanged"), "does not leak the Debug variant name: {stderr}");
}

/// The `gang` op's kind-mismatch refusal (members of differing scalar kind)
/// reads as prose, naming the offending member and both kinds.
#[test]
fn graph_build_reports_gang_kind_mismatch_as_prose() {
    let doc = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"Bias","name":"b"},
      {"op":"gang","as":"g","into":["a.length","b.scale"]}
    ]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit on a gang kind mismatch");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(
        stderr.contains("op 3 (gang): gang: member `b.scale` is F64, expected I64"),
        "names the op + kind mismatch as prose: {stderr}"
    );
    assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}

/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
/// `gang_fault_prose` wording — never the raw `CompileError`/`GangFault` Debug
/// dump the pre-#61 fallback would otherwise leak. Mirrors
/// `graph_register_rejects_an_unknown_node_type_with_prose`'s pattern, one layer
/// further into the load path.
#[test]
fn graph_register_rejects_a_corrupt_gang_table_with_prose() {
    let dir = temp_cwd("register-bad-gang");
    let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("bad_gang.json")]);
    assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
    assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
    assert!(
        stderr.contains("gang section invalid: gang \"solo\" has fewer than two members"),
        "phrases the corrupt gang table as prose: {stderr}"
    );
    assert!(!stderr.contains("GangFault"), "does not leak the Debug type name: {stderr}");
    assert!(!stderr.contains("BadGang"), "does not leak the Debug variant name: {stderr}");
}

/// Property (#61, the `blueprint_serde` canonicalization the CLI content-id
/// path relies on): declaring a gang's members in a different order in the
/// op-script's `into` list must NOT change the built blueprint's content id.
/// `project()`'s member/gang sort (task 3) exists precisely so that the
/// write-once content-addressed store (`graph register`'s idempotency
/// contract) sees two authoring-order variants of the identical graph as one
/// blueprint; were the sort ever dropped, this is the CLI-boundary regression
/// that would silently start minting a second id for the same topology.
#[test]
fn graph_build_gang_member_order_does_not_affect_content_id() {
    let forward = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"gang","as":"length","into":["a.length","b.length"]},
      {"op":"add","type":"Sub"},
      {"op":"feed","role":"price","into":["a.series","b.series"]},
      {"op":"connect","from":"a.value","to":"sub.lhs"},
      {"op":"connect","from":"b.value","to":"sub.rhs"},
      {"op":"expose","from":"sub.value","as":"bias"}
    ]"#;
    let reversed = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a"},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"gang","as":"length","into":["b.length","a.length"]},
      {"op":"add","type":"Sub"},
      {"op":"feed","role":"price","into":["a.series","b.series"]},
      {"op":"connect","from":"a.value","to":"sub.lhs"},
      {"op":"connect","from":"b.value","to":"sub.rhs"},
      {"op":"expose","from":"sub.value","as":"bias"}
    ]"#;
    let (id_forward, e1, ok1) = run(&["graph", "introspect", "--content-id"], forward);
    let (id_reversed, e2, ok2) = run(&["graph", "introspect", "--content-id"], reversed);
    assert!(ok1, "forward: {e1}");
    assert!(ok2, "reversed: {e2}");
    assert_eq!(id_forward, id_reversed, "gang member declaration order is content-id-invisible");
}

/// Property (#61, the eager gang gate's bind-then-gang ordering): a member
/// already bound at `add` has left the node's open param schema exactly like
/// any other consumed param, so ganging it reports the generic `UnknownParam`
/// prose — not a gang-specific "already bound" message — proving the gang op
/// resolves each member against the CURRENT open schema (mirroring `try_bind`)
/// rather than against the node's original, pre-bind schema.
#[test]
fn graph_build_gang_on_a_bound_member_reports_unknown_param() {
    let doc = r#"[
      {"op":"source","role":"price","kind":"F64"},
      {"op":"add","type":"SMA","name":"a","bind":{"length":{"I64":5}}},
      {"op":"add","type":"SMA","name":"b"},
      {"op":"gang","as":"length","into":["a.length","b.length"]}
    ]"#;
    let (stdout, stderr, ok) = run(&["graph", "build"], doc);
    assert!(!ok, "non-zero exit: a bound member cannot be ganged");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(
        stderr.contains("op 3 (gang): node a has no param \"length\""),
        "the bound member reads as an ordinary unknown param, not a gang-specific fault: {stderr}"
    );
}

/// Like `run_in`, but also pipes `stdin_doc` to the child — the combination
/// `aura graph build` needs, since it reads its op-script from stdin AND must
/// discover the project from the working directory. Neither `run` (stdin, no
/// cwd) nor `run_in` (cwd, no stdin) covers both.
fn run_in_stdin(
    dir: &std::path::Path,
    args: &[&str],
    stdin_doc: &str,
) -> (String, String, Option<i32>) {
    let mut child = Command::new(BIN)
        .args(args)
        .current_dir(dir)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn aura");
    child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
    let out = child.wait_with_output().expect("wait aura");
    (
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
        out.status.code(),
    )
}

/// Property (#244): the tier-aware unresolved-namespace hint fires on the
/// op-script `aura graph build` path exactly as it does on the blueprint LOAD
/// path (`project_load.rs::data_only_project_hints_attach_for_namespaced_ids`,
/// which this mirrors). Inside a data-only project (a bare `Aura.toml`, no node
/// crate attached), an op-script referencing a project-namespaced type
/// (`nosuch::Node`) the vocabulary cannot resolve refuses non-zero AND the
/// refusal carries the tier hint — "binds no node crate" + the `aura nodes new`
/// attach verb — not the bare unknown-type error. Same consumer mistake, same
/// diagnostic quality across both entry surfaces. (The refusal/non-zero exit
/// already works today; the hint text is the absent behaviour.)
#[test]
fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
    let dir = temp_cwd("graph-build-dataonly-hint");
    std::fs::write(dir.join("Aura.toml"), "").expect("write bare Aura.toml");
    // A single op is enough: `replay` fails fast at the unknown-type `add`.
    let ops = r#"[{"op":"add","type":"nosuch::Node","name":"n"}]"#;
    let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "build"], ops);
    assert_ne!(code, Some(0), "an unresolvable namespaced type refuses: {stderr}");
    assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
    assert!(
        stderr.contains("binds no node crate"),
        "the data-only tier hint fires on the graph-build path: {stderr}"
    );
    assert!(
        stderr.contains("aura nodes new"),
        "the hint points at the attach verb: {stderr}"
    );
}
