//! The CLI-side serde front-end for the construction op-script (#157, §C): a
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
//! build` / `aura graph introspect` subcommands here drive these ops through the
//! injected `aura_std::std_vocabulary`.

use std::collections::BTreeMap;
use std::path::Path;

use aura_engine::{
    blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
    Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
};
use serde::Deserialize;

// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_std::std_vocabulary;

/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
enum OpDoc {
    Source { role: String, kind: ScalarKind },
    Input { role: String },
    Add {
        #[serde(rename = "type")]
        type_id: String,
        // `name` mirrors the builder's `.named("fast")`; `add`'s node identifier is a
        // naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
        #[serde(rename = "name", default)]
        as_name: Option<String>,
        #[serde(default)]
        bind: BTreeMap<String, Scalar>,
    },
    Feed { role: String, into: Vec<String> },
    Connect { from: String, to: String },
    Expose {
        from: String,
        #[serde(rename = "as")]
        as_name: String,
    },
}

impl OpDoc {
    /// The op-kind label for the `op N (kind): cause` message.
    fn kind_label(&self) -> &'static str {
        match self {
            OpDoc::Source { .. } => "source",
            OpDoc::Input { .. } => "input",
            OpDoc::Add { .. } => "add",
            OpDoc::Feed { .. } => "feed",
            OpDoc::Connect { .. } => "connect",
            OpDoc::Expose { .. } => "expose",
        }
    }
}

impl From<OpDoc> for Op {
    fn from(d: OpDoc) -> Op {
        match d {
            OpDoc::Source { role, kind } => Op::Source { role, kind },
            OpDoc::Input { role } => Op::Input { role },
            OpDoc::Add { type_id, as_name, bind } => {
                Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
            }
            OpDoc::Feed { role, into } => Op::Feed { role, into },
            OpDoc::Connect { from, to } => Op::Connect { from, to },
            OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
        }
    }
}

/// Phrase one `OpError` as a human cause (the engine error types are
/// `Display`-free by convention; this is the CLI's presentation layer). The
/// exhaustive match means a new `OpError` variant forces an update here.
fn format_op_error(e: &OpError) -> String {
    match e {
        OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
        OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
        OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
        OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
        OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
        OpError::UnknownRole(s) => format!("unknown role {s:?}"),
        OpError::MalformedPort(s) => format!("malformed port reference {s:?}"),
        OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"),
        OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"),
        OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"),
        OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"),
        OpError::KindMismatch { from, to, producer, consumer } => {
            format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})")
        }
        OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"),
        OpError::WouldCycle { from, to } => format!("connecting {from} -> {to} would close a cycle"),
        OpError::BadParam { node, err } => match err {
            BindOpError::UnknownParam(p) => format!("node {node} has no param {p:?}"),
            BindOpError::AmbiguousParam(p) => format!("node {node} has ambiguous param {p:?}"),
            BindOpError::KindMismatch { param, expected, got } => {
                format!("param {node}.{param} expects {expected:?} but got {got:?}")
            }
        },
        OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
        OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
        OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
        OpError::Incomplete(ce) => format!("{ce:?}"),
    }
}

/// Parse a JSON op-list document and replay it through the env's vocabulary into
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
    let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
    let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
    let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
    replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
        let cause = format_op_error(&err);
        match labels.get(idx) {
            Some(kind) => format!("op {idx} ({kind}): {cause}"),
            None => format!("finalize: {cause}"),
        }
    })
}

/// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
    let composite = composite_from_str(doc, env)?;
    blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
}

/// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd(env: &crate::project::Env) {
    use std::io::Read;
    let mut doc = String::new();
    if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
        eprintln!("aura: reading stdin: {e}");
        std::process::exit(1);
    }
    match build_from_str(&doc, env) {
        // `print!`, not `println!`: the canonical artifact is exactly the library
        // `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value
        // with no trailing newline. The CLI is a transport; it must not frame the
        // canonical bytes (#164).
        Ok(json) => print!("{json}"),
        Err(msg) => {
            eprintln!("aura: {msg}");
            std::process::exit(1);
        }
    }
}

/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary.
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
    let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
    let schema = builder.schema();
    let mut out = format!("{}\n", builder.label());
    for port in &schema.inputs {
        out.push_str(&format!("  in   {}:{:?}\n", port.name, port.kind));
    }
    for field in &schema.output {
        out.push_str(&format!("  out  {}:{:?}\n", field.name, field.kind));
    }
    for p in builder.params() {
        out.push_str(&format!("  param {}:{:?}  (bind {{\"{:?}\": <v>}})\n", p.name, p.kind, p.kind));
    }
    Ok(out)
}

/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
    let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
    let resolver = |t: &str| env.resolve(t);
    let mut session = GraphSession::new("introspect", &resolver);
    for (i, d) in docs.into_iter().enumerate() {
        session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
    }
    let mut out = String::new();
    for (slot, kind) in session.unwired() {
        out.push_str(&format!("{slot}:{kind:?}\n"));
    }
    Ok(out)
}

/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
/// group must be set; zero or more than one is the usage error (exit 2). The id
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
/// combine (one build, both ids, content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
    let count = cmd.vocabulary as usize
        + cmd.node.is_some() as usize
        + cmd.unwired as usize
        + cmd.params.is_some() as usize
        + (cmd.content_id.is_some() || cmd.identity_id) as usize;
    if count != 1 {
        eprintln!(
            "aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
        );
        std::process::exit(2);
    }
    if cmd.vocabulary {
        for t in env.type_ids() {
            println!("{t}");
        }
    } else if let Some(type_id) = cmd.node.as_deref() {
        match introspect_node(type_id, env) {
            Ok(s) => print!("{s}"),
            Err(m) => {
                eprintln!("aura: {m}");
                std::process::exit(2);
            }
        }
    } else if cmd.unwired {
        use std::io::Read;
        let mut doc = String::new();
        if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
            eprintln!("aura: reading stdin: {e}");
            std::process::exit(1);
        }
        match introspect_unwired(&doc, env) {
            Ok(s) => print!("{s}"),
            Err(m) => {
                eprintln!("aura: {m}");
                std::process::exit(1);
            }
        }
    } else if let Some(target) = cmd.params.as_deref() {
        // --params <FILE|ID> (#196): the RAW composite param space — exactly the
        // namespace campaign axes are validated against (`validate_campaign_refs`
        // checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
        // is the sweep-verb view, not the campaign view).
        match params_lines(target, env) {
            Ok(s) => print!("{s}"),
            Err(m) => {
                eprintln!("aura: {m}");
                std::process::exit(1);
            }
        }
    } else {
        // --content-id [FILE] / --identity-id (combinable): one build, then each
        // requested id on its own line, content id first. With a FILE value the
        // document is read from it, shape-discriminated (#196): a JSON array is a
        // construction op-list, a JSON object the #155 blueprint envelope — so a
        // registered blueprint's printed id is exactly its store key. Without a
        // FILE the op-list is read from stdin (the pre-#196 behaviour, unchanged).
        // The content id (#158) is the SHA256 of the same `blueprint_to_json`
        // bytes `graph build` emits; the identity id (#171) the SHA256 of the
        // debug-name-blind `blueprint_identity_json` form — both via the one
        // shared `crate::content_id` primitive `topology_hash` also uses, so all
        // surfaces agree by construction.
        let composite = match cmd.content_id.as_ref() {
            Some(Some(file)) => {
                let text = match std::fs::read_to_string(file) {
                    Ok(t) => t,
                    Err(e) => {
                        eprintln!("aura: cannot read {}: {e}", file.display());
                        std::process::exit(1);
                    }
                };
                match composite_from_any(&text, env) {
                    Ok(c) => c,
                    Err(m) => {
                        eprintln!("aura: {m}");
                        std::process::exit(1);
                    }
                }
            }
            _ => {
                use std::io::Read;
                let mut doc = String::new();
                if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
                    eprintln!("aura: reading stdin: {e}");
                    std::process::exit(1);
                }
                match composite_from_str(&doc, env) {
                    Ok(c) => c,
                    Err(m) => {
                        eprintln!("aura: {m}");
                        std::process::exit(1);
                    }
                }
            }
        };
        if cmd.content_id.is_some() {
            match blueprint_to_json(&composite) {
                Ok(json) => println!("{}", crate::content_id(&json)),
                Err(e) => {
                    eprintln!("aura: serialize error: {e:?}");
                    std::process::exit(1);
                }
            }
        }
        if cmd.identity_id {
            match blueprint_identity_json(&composite) {
                Ok(json) => println!("{}", crate::content_id(&json)),
                Err(e) => {
                    eprintln!("aura: serialize error: {e:?}");
                    std::process::exit(1);
                }
            }
        }
    }
}

/// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the
/// engine error types are `Display`-free by convention — this is the CLI's
/// presentation layer, like `format_op_error`). `pub(crate)`: shared by the
/// `graph build` path (below) and `aura run`'s loaded-blueprint branch
/// (main.rs) — env-agnostic on purpose, so both callers get identical wording
/// (#184).
pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
    match e {
        LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"),
        LoadError::UnsupportedVersion { found, supported } => {
            format!("blueprint format_version {found} is unsupported (this build reads {supported})")
        }
        LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
    }
}

/// (#185) When an unresolved type id LOOKS project-namespaced (`::`) but no
/// project was loaded for this invocation (`env.provenance()` is `None`),
/// append a hint pointing at the missing `Aura.toml` — the likeliest cause is
/// "ran outside the project directory", not "genuinely unknown std id". Bare
/// std-vocabulary ids (no `::`) and invocations that DO have a project loaded
/// get no hint.
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
    match e {
        LoadError::UnknownNodeType(t) if t.contains("::") && env.provenance().is_none() => Some(
            "type id looks project-namespaced but no Aura.toml was found — run inside a project directory"
                .to_string(),
        ),
        _ => None,
    }
}

/// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc`
/// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest
/// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single-
/// sourced here so the three dual-grammar subcommands cannot diverge (#184's
/// convention, extended). Two refusals, checked in order:
///
/// 1. The document parses as JSON but is an op-script ARRAY (#157), not a
///    built blueprint envelope — refused with a targeted hint pointing at
///    `aura graph build`, since `blueprint_from_json` would otherwise surface
///    a confusing internal serde shape mismatch.
/// 2. `blueprint_from_json` fails to load the envelope — phrased house-style
///    via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it
///    applies), never the raw `LoadError` Debug form.
///
/// `Ok(())` means the document loaded cleanly; callers that only need the
/// validation (not the `Composite`) re-parse `doc` themselves afterward
/// (`blueprint_from_json` is cheap and infallible at that point).
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
    if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
        return Err(
            "this is an op-script (an op array), not a built blueprint — run \
             'aura graph build' first"
                .to_string(),
        );
    }
    blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| {
        let mut msg = blueprint_load_prose(&e);
        if let Some(hint) = unresolved_namespace_hint(&e, env) {
            msg.push_str(" — ");
            msg.push_str(&hint);
        }
        msg
    })
}

/// #196: build a `Composite` from a document that is EITHER a #155 blueprint
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules.
fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
    let value: serde_json::Value =
        serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
    match value {
        serde_json::Value::Array(_) => composite_from_str(text, env),
        serde_json::Value::Object(_) => blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| {
            let mut msg = blueprint_load_prose(&e);
            if let Some(hint) = unresolved_namespace_hint(&e, env) {
                msg.push_str(" — ");
                msg.push_str(&hint);
            }
            msg
        }),
        _ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()),
    }
}

/// Resolve a blueprint document's bytes from a file path or a 64-hex content
/// id in the project store (the campaign-run target-addressing convention).
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
    // A CLI arg tolerates the `content:` display prefix (#194); doc ref
    // fields stay bare-only.
    let target = target
        .strip_prefix("content:")
        .filter(|t| crate::campaign_run::is_content_id(t))
        .unwrap_or(target);
    let path = Path::new(target);
    if path.is_file() {
        return std::fs::read_to_string(path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()));
    }
    if crate::campaign_run::is_content_id(target) {
        return match env.registry().get_blueprint(target) {
            Ok(Some(json)) => Ok(json),
            Ok(None) => Err(format!("no blueprint {target} in the project store")),
            Err(e) => Err(e.to_string()),
        };
    }
    Err(format!("'{target}' is neither a readable .json file nor a 64-hex content id"))
}

/// `aura graph introspect --params <FILE|ID>` (#196): one `{name}:{kind:?}`
/// line per open param of the RAW composite (no harness wrap) — the
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
    use std::fmt::Write as _;
    let text = resolve_blueprint_text(target, env)?;
    // Shape-discriminated like file-mode --content-id: an op-script (array)
    // builds through the one-build tail, an envelope (object) loads (#202).
    let composite = composite_from_any(&text, env)?;
    let mut out = String::new();
    for p in composite.param_space() {
        let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
    }
    Ok(out)
}

/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
/// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
    match register_blueprint(file, env) {
        Ok(line) => println!("{line}"),
        Err(m) => {
            eprintln!("aura: {m}");
            std::process::exit(1);
        }
    }
}

fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
    let text = std::fs::read_to_string(file)
        .map_err(|e| format!("cannot read {}: {e}", file.display()))?;
    // Shape-discriminated like file-mode --content-id (#202): either shape
    // canonicalizes to the envelope form, so the stored id is shape-invariant.
    let composite = composite_from_any(&text, env)?;
    let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
    let id = crate::content_id(&canonical);
    let registry = env.registry();
    registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
    Ok(format!(
        "registered blueprint {id} ({})",
        registry.blueprint_path(&id).display()
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The spec's worked-example document deserializes into the op vocabulary and
    /// maps into engine `Op`s that replay into a compilable signal blueprint —
    /// the DTO is a faithful front-end over the engine surface.
    #[test]
    fn opdoc_document_deserializes_and_replays() {
        let doc = 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"}
        ]"#;
        let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
        assert_eq!(docs.len(), 10);
        assert_eq!(docs[1].kind_label(), "add");
        let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
        // both SMA lengths are bound, so the only open param is bias.scale (f64).
        let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
        composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
    }

    #[test]
    fn build_from_str_emits_blueprint_json_for_valid_document() {
        let doc = 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"}
        ]"#;
        let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
        assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
        assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
    }

    /// `add` names a node via `name` (mirroring the builder's `.named("fast")`),
    /// not the old SQL-ish `as`: the identifier resolves in later ops and the
    /// emitted #155 blueprint carries it.
    #[test]
    fn add_names_node_via_name_key() {
        let doc = r#"[
            {"op":"source","role":"price","kind":"F64"},
            {"op":"add","type":"SMA","name":"fast"},
            {"op":"feed","role":"price","into":["fast.series"]},
            {"op":"expose","from":"fast.value","as":"out"}
        ]"#;
        let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
        assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
    }

    #[test]
    fn build_from_str_reports_unknown_node_at_its_op_index() {
        let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
        let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
        assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
    }

    #[test]
    fn build_from_str_reports_unconnected_slot_at_finalize() {
        // sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
        let doc = 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"}
        ]"#;
        let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
        assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
        assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
            "names the unconnected slot by-identifier, got: {err}");
        assert!(!err.contains("node:"), "no raw machine index in the message, got: {err}");
    }

    #[test]
    fn introspect_node_lists_ports_kinds_and_params() {
        let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
        assert!(out.contains("series"), "lists the input port: {out}");
        assert!(out.contains("value"), "lists the output field: {out}");
        assert!(out.contains("length"), "lists the param path: {out}");
        assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
    }

    /// A bind whose value-kind mismatches the param reads as prose, like the connect
    /// mismatch — not a Debug struct.
    #[test]
    fn build_from_str_bad_bind_kind_reads_as_prose() {
        let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
        let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
        assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
    }

    /// A bind to a param the node does not declare reads as prose naming the node
    /// and the unknown param — not a Debug struct.
    #[test]
    fn build_from_str_unknown_bind_param_reads_as_prose() {
        let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
        let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
        assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
    }

    /// The ambiguous-param branch of the bind-error presenter is phrased as prose
    /// naming the node and the ambiguous param. No std primitive carries two
    /// same-named params, so the branch is unreachable end-to-end and is pinned on
    /// the presenter directly.
    #[test]
    fn ambiguous_bind_param_reads_as_prose() {
        let msg = format_op_error(&OpError::BadParam {
            node: "fast".to_string(),
            err: BindOpError::AmbiguousParam("length".to_string()),
        });
        assert_eq!(msg, "node fast has ambiguous param \"length\"");
    }

    /// `introspect --node` shows the typed-Scalar bind-value form so a hand-author
    /// does not have to read source to learn the `{"I64": <v>}` wrapping.
    #[test]
    fn introspect_node_shows_the_bind_form() {
        let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
        assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
    }

    #[test]
    fn introspect_unwired_reports_open_slots_of_a_partial_document() {
        let doc = r#"[
            {"op":"add","type":"SMA","name":"fast"},
            {"op":"add","type":"Sub"},
            {"op":"connect","from":"fast.value","to":"sub.lhs"}
        ]"#;
        let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
        assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
        assert!(out.contains("fast.series"), "fast.series is still open: {out}");
        assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
    }
}
