//! Blueprint serialization (C24): a `Composite` blueprint is projected to a
//! canonical, versioned data value and reconstructed from it (`blueprint_from_json`
//! in this module's loader half). `Composite`/`PrimitiveBuilder` cannot derive
//! serde — they hold a `Box<dyn Fn>` build closure — so the format is a faithful
//! serde **projection**: it carries each primitive's compiled-in **type identity**
//! (`PrimitiveBuilder::label`), its instance name, and its bound params; the build
//! closure and the declared schema are re-derived on load from the injected
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).

use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};

/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;

/// Top-level envelope: the version is read before the payload is interpreted.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct BlueprintDoc {
    pub format_version: u32,
    pub blueprint: CompositeData,
}

/// Serde mirror of a `Composite` (the in-memory type holds build closures and
/// cannot derive serde). Field order here is the canonical JSON field order.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CompositeData {
    pub name: String,
    pub nodes: Vec<NodeData>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub edges: Vec<Edge>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub input_roles: Vec<Role>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub output: Vec<OutField>,
}

/// A blueprint item: a primitive (referenced by type identity) or a nested
/// composite (recursion). Externally tagged: `{"primitive": ..}` / `{"composite": ..}`.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeData {
    Primitive(PrimitiveData),
    Composite(CompositeData),
}

/// A primitive node as data: its compiled-in type identity, optional instance
/// name, and bound params. The schema + build closure are re-derived on load.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PrimitiveData {
    #[serde(rename = "type")]
    pub type_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub bound: Vec<BoundParam>,
}

/// Serializer failure (typed, named).
#[derive(Debug)]
pub enum SerializeError {
    Json(serde_json::Error),
}

fn project(c: &Composite) -> CompositeData {
    CompositeData {
        name: c.name().to_string(),
        nodes: c.nodes().iter().map(project_node).collect(),
        edges: c.edges().to_vec(),
        input_roles: c.input_roles().to_vec(),
        output: c.output().to_vec(),
    }
}

fn project_node(n: &BlueprintNode) -> NodeData {
    match n {
        BlueprintNode::Primitive(b) => {
            // Canonical: bound params in ascending original-slot order, mirroring
            // the loader's re-bind canonicalization, so serialization is
            // bind-order-independent (a precondition for content-addressing, #158).
            // Identity today — every #155 node has <=1 param — but it holds by
            // construction once a multi-param node enters the vocabulary.
            let mut bound = b.bound_params().to_vec();
            bound.sort_by_key(|bp| bp.pos);
            NodeData::Primitive(PrimitiveData {
                type_id: b.label(),
                name: b.instance_name().map(str::to_string),
                bound,
            })
        }
        BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
    }
}

fn build_doc(c: &Composite) -> BlueprintDoc {
    BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
}

fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
    serde_json::to_string(doc).map_err(SerializeError::Json)
}

/// Serialize a blueprint to canonical, versioned JSON: compact, struct-declaration
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
    serialize_doc(&build_doc(c))
}

/// The identity-canonical form (#171): the canonical document with every
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
/// bound-param names, role names, output re-export names. Everything load-bearing
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
/// and bound positions/kinds/values (param openness stays identity-bearing: a
/// bound slot is textually present, an open one absent). Research-side comparison
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
    let mut doc = build_doc(c);
    strip_debug_symbols(&mut doc.blueprint);
    serialize_doc(&doc)
}

fn strip_debug_symbols(b: &mut CompositeData) {
    b.name = String::new();
    for node in &mut b.nodes {
        match node {
            NodeData::Primitive(p) => {
                p.name = None;
                for bp in &mut p.bound {
                    bp.name = String::new(); // pos/kind/value survive
                }
            }
            NodeData::Composite(c) => strip_debug_symbols(c),
        }
    }
    for role in &mut b.input_roles {
        role.name = String::new(); // targets + order survive
    }
    for out in &mut b.output {
        out.name = String::new(); // (node, field) + order survive
    }
}

/// Loader failure (typed, named — never a panic, never a silent wrong graph).
#[derive(Debug)]
pub enum LoadError {
    /// Malformed or structurally-invalid JSON.
    Json(serde_json::Error),
    /// `format_version` the loader does not understand — the **Tier-2
    /// (must-understand)** refusal. The format extends under a two-tier discipline
    /// (#156): a **Tier-1** additive-optional change (a new optional field /
    /// section) does NOT bump the version — an older reader tolerates it (serde
    /// ignores unknown fields; see `unknown_optional_field_is_tolerated_byte_identically`)
    /// and, by C1, a new optional field defaults to prior behaviour. A **Tier-2**
    /// load-bearing change (a new node type, edge semantics, or structural-axis
    /// kind) MUST bump `BLUEPRINT_FORMAT_VERSION`, so an old reader refuses here
    /// rather than silently building a different graph (C1 / C18). The
    /// per-section required-flag scheme is deferred until a real sub-version Tier-2
    /// addition needs finer granularity than a version bump.
    UnsupportedVersion { found: u32, supported: u32 },
    /// `resolve` returned `None` for a serialized `type_id` (outside the injected
    /// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
    UnknownNodeType(String),
}

fn reconstruct(
    d: &CompositeData,
    resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
    let mut nodes = Vec::with_capacity(d.nodes.len());
    for nd in &d.nodes {
        nodes.push(match nd {
            NodeData::Primitive(p) => {
                let mut b = resolve(&p.type_id)
                    .ok_or_else(|| LoadError::UnknownNodeType(p.type_id.clone()))?;
                if let Some(n) = &p.name {
                    b = b.named(n);
                }
                // Re-apply bound params BY NAME. The effective param vector is
                // order-independent (each `bind` computes its slot against the
                // shrunk schema); ascending original `pos` is the canonical order.
                let mut bound: Vec<&BoundParam> = p.bound.iter().collect();
                bound.sort_by_key(|bp| bp.pos);
                for bp in bound {
                    b = b.bind(&bp.name, bp.value);
                }
                BlueprintNode::Primitive(b)
            }
            NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?),
        });
    }
    Ok(Composite::new(
        d.name.clone(),
        nodes,
        d.edges.clone(),
        d.input_roles.clone(),
        d.output.clone(),
    ))
}

/// Load a blueprint from canonical JSON, resolving each primitive's type identity
/// through the injected `resolve` (e.g. `aura_std::std_vocabulary`). The version
/// envelope is checked before the payload is reconstructed.
pub fn blueprint_from_json(
    data: &str,
    resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, LoadError> {
    let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
    if doc.format_version != BLUEPRINT_FORMAT_VERSION {
        return Err(LoadError::UnsupportedVersion {
            found: doc.format_version,
            supported: BLUEPRINT_FORMAT_VERSION,
        });
    }
    reconstruct(&doc.blueprint, resolve)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::blueprint::{Composite, OutField, Role};
    use crate::harness::{Edge, Target};
    use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
    use aura_core::{Scalar, ScalarKind, Timestamp};
    use aura_std::{Bias, Recorder, Sma, Sub};
    use std::sync::mpsc;

    // Open param point for the sink-free signal (fast.length is bound): [slow.length, bias.scale].
    fn signal_point() -> Vec<Scalar> {
        vec![Scalar::i64(4), Scalar::f64(0.5)]
    }

    // Nest `signal` under a root that records its single `bias` output, run the
    // 7-tick synthetic price fixture, and collect the recorded trace.
    fn run_recording(signal: Composite) -> Vec<(Timestamp, Vec<Scalar>)> {
        let (tx, rx) = mpsc::channel();
        let root = Composite::new(
            "h",
            vec![
                crate::blueprint::BlueprintNode::Composite(signal),
                Recorder::builder(vec![ScalarKind::F64], aura_core::Firing::Any, tx).into(),
            ],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // bias -> recorder
            vec![Role {
                name: "src".into(),
                targets: vec![Target { node: 0, slot: 0 }], // price -> nested signal's price port
                source: Some(ScalarKind::F64),
            }],
            vec![],
        );
        let prices = crate::test_fixtures::synthetic_prices();
        let mut h = root.bootstrap_with_params(signal_point()).expect("bootstraps");
        h.run(vec![Box::new(VecSource::new(prices))]);
        rx.try_iter().collect()
    }

    #[test]
    fn serialized_blueprint_runs_bit_identical_to_rust_built() {
        let rust_built = crate::test_fixtures::sink_free_sma_cross_signal();
        let json = blueprint_to_json(&rust_built).expect("serializes");
        let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");

        let trace_rust = run_recording(rust_built);
        let trace_loaded = run_recording(loaded);

        assert_eq!(trace_rust, trace_loaded, "serialized-then-loaded run diverged");
        assert!(!trace_loaded.is_empty(), "trace must be populated (non-degenerate)");
    }

    #[test]
    fn serializer_and_loader_are_inverse() {
        let original = crate::test_fixtures::sink_free_sma_cross_signal();
        let json = blueprint_to_json(&original).expect("serializes");
        let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
        // serialize -> load -> serialize is byte-stable (canonical round-trip identity)
        assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
    }

    #[test]
    fn nested_composite_round_trips() {
        // recursion + a nested bound param + a role: wrap the signal composite in
        // an outer composite that re-exports its `bias` output. Proves the loop is
        // not flat-SMA-specific.
        let inner = crate::test_fixtures::sink_free_sma_cross_signal();
        let outer = Composite::new(
            "outer",
            vec![crate::blueprint::BlueprintNode::Composite(inner)],
            vec![],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 0, field: 0, name: "bias".into() }],
        );
        let json = blueprint_to_json(&outer).expect("serializes");
        let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
        assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
        // the nested composite survived as a composite, not flattened
        assert!(json.contains(r#"{"composite":{"name":"sma_cross""#), "nested composite preserved");
    }

    #[test]
    fn signal_serializes_to_canonical_golden() {
        let signal = crate::test_fixtures::sink_free_sma_cross_signal();
        let json = blueprint_to_json(&signal).expect("serializes");
        let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
        // pins: canonical field order, format_version envelope, AND omit-defaults
        // (the unnamed/unbound `Sub` + `Bias` carry no `name`/`bound` keys; the
        // `price` role carries no `source` key).
        assert_eq!(json, golden);
    }

    #[test]
    fn unknown_node_type_fails_named() {
        // a valid envelope naming a type outside the vocabulary -> clean, named error
        let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"NoSuchNode"}}]}}"#;
        // `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
        // build closure and is not `Debug`, which `unwrap_err`'s bound would require.
        let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
        assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
    }

    #[test]
    fn unsupported_version_fails_named() {
        let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
        let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
        assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
    }

    #[test]
    fn unknown_optional_field_is_tolerated_byte_identically() {
        // Forward-compat (C24 / #156, Tier-1): a future writer that has additively
        // extended the format emits optional keys the CURRENT loader does not know.
        // The loader must silently ignore them and reconstruct the IDENTICAL graph —
        // never refuse, never let an unknown key leak into the built blueprint. serde
        // ignores unknown fields by default (no deny_unknown_fields). This pins the
        // additive property the canonical-golden + omit-defaults discipline claims but
        // no existing test exercises.
        let canonical =
            blueprint_to_json(&crate::test_fixtures::sink_free_sma_cross_signal()).expect("serializes");

        // Inject unknown optional keys at two additive shapes a new writer might emit:
        // a doc-level section ("metadata") and a per-primitive key ("annotations").
        let with_unknown = canonical
            .replacen(
                r#"{"format_version":1,"#,
                r#"{"format_version":1,"metadata":{"author":"future"},"#,
                1,
            )
            .replacen(r#"{"type":"Sub"}"#, r#"{"type":"Sub","annotations":["future"]}"#, 1);
        assert_ne!(with_unknown, canonical, "probe must actually inject unknown keys");

        // tolerated, not refused
        let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::std_vocabulary(t))
            .expect("an unknown optional field is tolerated, not refused");

        // the unknown keys did not leak into the graph: re-serialization equals the
        // canonical form WITHOUT them (byte-identical reconstruction).
        assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical);

        // and the loaded graph runs bit-identically to the plain canonical one (C1).
        let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads");
        assert_eq!(
            run_recording(loaded),
            run_recording(plain),
            "run diverged under an unknown optional field",
        );
    }

    // A minimal SMA-cross variant with controllable debug names, boundness, and
    // wiring for the identity-projection property tests — the same 4-node shape
    // as `test_fixtures::sink_free_sma_cross_signal`.
    fn identity_probe(
        comp_name: &str,
        fast_name: &str,
        fast_len: Option<i64>,
        swap_sub_slots: bool,
    ) -> Composite {
        let mut fast = Sma::builder().named(fast_name);
        if let Some(l) = fast_len {
            fast = fast.bind("length", Scalar::i64(l));
        }
        let (fast_slot, slow_slot) = if swap_sub_slots { (1, 0) } else { (0, 1) };
        Composite::new(
            comp_name,
            vec![
                fast.into(),
                Sma::builder().named("slow").into(),
                Sub::builder().into(),
                Bias::builder().into(),
            ],
            vec![
                Edge { from: 0, to: 2, slot: fast_slot, from_field: 0 },
                Edge { from: 1, to: 2, slot: slow_slot, from_field: 0 },
                Edge { from: 2, to: 3, slot: 0, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 3, field: 0, name: "bias".into() }],
        )
    }

    /// #171 acc 1 (engine layer): twins identical up to debug names (composite
    /// name, instance name) share the identity JSON while their canonical JSON
    /// differs.
    #[test]
    fn renamed_twins_share_identity_json_not_canonical_json() {
        let a = identity_probe("sma_cross", "fast", Some(2), false);
        let b = identity_probe("renamed", "speedy", Some(2), false);
        assert_ne!(
            blueprint_to_json(&a).expect("serializes"),
            blueprint_to_json(&b).expect("serializes"),
            "canonical bytes keep the debug names apart"
        );
        let ia = blueprint_identity_json(&a).expect("identity-serializes");
        let ib = blueprint_identity_json(&b).expect("identity-serializes");
        assert_eq!(ia, ib, "identity form is debug-name-blind");
        assert!(!ia.contains("speedy") && !ia.contains("sma_cross"), "no debug symbol leaks: {ia}");
    }

    /// #171 acc 2 (openness is identity-bearing): a blueprint with `fast.length`
    /// bound and its open twin never share an identity JSON.
    #[test]
    fn open_vs_bound_param_changes_identity() {
        let bound = identity_probe("x", "fast", Some(2), false);
        let open = identity_probe("x", "fast", None, false);
        assert_ne!(
            blueprint_identity_json(&bound).expect("identity-serializes"),
            blueprint_identity_json(&open).expect("identity-serializes"),
            "boundness survives the identity projection"
        );
    }

    /// #171 acc 2 (bound values are identity-bearing): value twins differing in
    /// one bound value (I64:2 vs I64:3) never share an identity JSON.
    #[test]
    fn bound_value_changes_identity() {
        assert_ne!(
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
                .expect("identity-serializes"),
            blueprint_identity_json(&identity_probe("x", "fast", Some(3), false))
                .expect("identity-serializes"),
            "a bound value survives the identity projection"
        );
    }

    /// Wiring is identity-bearing: swapping the two Sub input slots (fast->rhs,
    /// slow->lhs) is a different topology, hence a different identity JSON.
    #[test]
    fn edge_swap_changes_identity() {
        assert_ne!(
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), false))
                .expect("identity-serializes"),
            blueprint_identity_json(&identity_probe("x", "fast", Some(2), true))
                .expect("identity-serializes"),
            "edge slots survive the identity projection"
        );
    }

    /// Important (nested-composite recursion, C23/invariant 12 — fractal
    /// composition): the interior debug names of a NESTED composite (composite
    /// name, instance names) must vanish through the identity projection too —
    /// proves the `NodeData::Composite(c) => strip_debug_symbols(c)` recursion
    /// arm is load-bearing, not dead code the flat-composite tests never reach.
    #[test]
    fn nested_composite_interior_names_are_identity_blind() {
        fn wrap(inner: Composite) -> Composite {
            Composite::new(
                "outer",
                vec![crate::blueprint::BlueprintNode::Composite(inner)],
                vec![],
                vec![Role {
                    name: "price".into(),
                    targets: vec![Target { node: 0, slot: 0 }],
                    source: None,
                }],
                vec![OutField { node: 0, field: 0, name: "bias".into() }],
            )
        }
        let a = wrap(identity_probe("sma_cross", "fast", Some(2), false));
        let b = wrap(identity_probe("renamed", "speedy", Some(2), false));
        assert_ne!(
            blueprint_to_json(&a).expect("serializes"),
            blueprint_to_json(&b).expect("serializes"),
            "canonical bytes keep the nested debug names apart"
        );
        let ia = blueprint_identity_json(&a).expect("identity-serializes");
        let ib = blueprint_identity_json(&b).expect("identity-serializes");
        assert_eq!(ia, ib, "identity form is blind to nested-composite interior debug names");
        assert!(
            !ia.contains("speedy") && !ia.contains("sma_cross"),
            "no interior debug symbol leaks: {ia}"
        );
    }

    /// #171 acc 1 (role names and output re-export names are debug symbols
    /// too, C23): twins differing only in the input-role name or the output
    /// re-export name share the identity JSON while their canonical JSON
    /// differs — proves the role/output strip loops in `strip_debug_symbols`
    /// are load-bearing, not dead code the differential tests never exercise.
    #[test]
    fn renamed_role_and_output_share_identity_json_not_canonical_json() {
        fn probe(role_name: &str, out_name: &str) -> Composite {
            Composite::new(
                "x",
                vec![
                    Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
                    Sma::builder().named("slow").into(),
                    Sub::builder().into(),
                    Bias::builder().into(),
                ],
                vec![
                    Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                    Edge { from: 1, to: 2, slot: 1, from_field: 0 },
                    Edge { from: 2, to: 3, slot: 0, from_field: 0 },
                ],
                vec![Role {
                    name: role_name.into(),
                    targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                    source: None,
                }],
                vec![OutField { node: 3, field: 0, name: out_name.into() }],
            )
        }
        let a = probe("price", "bias");
        let b = probe("close", "signal");
        assert_ne!(
            blueprint_to_json(&a).expect("serializes"),
            blueprint_to_json(&b).expect("serializes"),
            "canonical bytes keep role/output names apart"
        );
        assert_eq!(
            blueprint_identity_json(&a).expect("identity-serializes"),
            blueprint_identity_json(&b).expect("identity-serializes"),
            "identity form is role-name- and output-name-blind"
        );
    }
}
