//! The `aura graph` model serializer (#13, cycle 0026): turns the engine's
//! authored blueprint (graph-as-data, C9) into the canonical, deterministic JSON
//! model the browser viewer consumes. Read-only — walks structure + declared
//! `NodeSchema`s only, never `eval`/`compile`/`bootstrap`. The model JSON is
//! hand-rolled (no serde) — small, closed, deterministic (C14). Since cycle 0033
//! moved `RunReport::to_json` to serde, this is the engine's last hand-rolled
//! JSON writer.

use crate::{BlueprintNode, Composite};
use aura_core::{Firing, ScalarKind};

/// A scalar kind as the lowercase type string the model + C4 colour palette use.
/// The `Debug` form is PascalCase, so this lowercase mapping is spelled out.
fn kind_str(k: ScalarKind) -> &'static str {
    match k {
        ScalarKind::I64 => "i64",
        ScalarKind::F64 => "f64",
        ScalarKind::Bool => "bool",
        ScalarKind::Timestamp => "timestamp",
    }
}

/// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64`
/// uses the shortest round-trippable debug form, which keeps a decimal point on
/// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in
/// the render. The value side of `kind_str`.
fn scalar_str(s: aura_core::Scalar) -> String {
    use aura_core::ScalarKind;
    match s.kind() {
        ScalarKind::I64 => s.as_i64().to_string(),
        ScalarKind::F64 => format!("{:?}", s.as_f64()),
        ScalarKind::Bool => s.as_bool().to_string(),
        ScalarKind::Timestamp => s.as_ts().0.to_string(),
    }
}

/// One input port's firing policy as a model string: `"any"` or `"barrier <N>"`.
fn firing_str(f: Firing) -> String {
    match f {
        Firing::Any => "any".to_string(),
        Firing::Barrier(n) => format!("barrier {n}"),
    }
}

/// A JSON string literal: wrap in quotes, escape `"` and `\` (the minimal set
/// sufficient here — model names are author-controlled identifiers).
fn json_str(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

/// A `[name, kind]` JSON pair.
fn named_kind(name: &str, kind: ScalarKind) -> String {
    format!("[{},{}]", json_str(name), json_str(kind_str(kind)))
}

/// One input port as a model tuple: `["<kind>","<firing>","<name>"]`. The name is
/// the port's non-load-bearing label (`PortSpec.name`); for a composite boundary
/// it is the `Role.name` carried through `derive_signature`.
fn port_json(p: &aura_core::PortSpec) -> String {
    format!(
        "[{},{},{}]",
        json_str(kind_str(p.kind)),
        json_str(&firing_str(p.firing)),
        json_str(&p.name)
    )
}

/// Comma-join with no surrounding brackets.
fn join(items: impl IntoIterator<Item = String>) -> String {
    items.into_iter().collect::<Vec<_>>().join(",")
}

/// The `{ "prim": { … } }` record for a primitive node. `role` defaults to
/// `"node"`; a `vec![]` output is the C8 sink. (Source is minted separately for
/// bound root roles, see `scope_parts`.)
fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
    let s = b.schema();
    let role = if s.output.is_empty() { "sink" } else { "node" };
    let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
    let ins = join(s.inputs.iter().map(port_json));
    let outs = join(s.output.iter().map(|f| named_kind(&f.name, f.kind)));
    // explicit instance name only — an unnamed node carries no "name" field
    // (node_name()'s lowercased-type default would be a redundant type-duplicate)
    let name = match b.instance_name() {
        Some(n) => format!(r#""name":{},"#, json_str(n)),
        None => String::new(),
    };
    // bound params: present only when the node has binds (mirrors the conditional
    // "name" field). each entry: [pos,"name","kind","value"].
    let bound = if b.bound_params().is_empty() {
        String::new()
    } else {
        let items = join(b.bound_params().iter().map(|bp| {
            format!(
                "[{},{},{},{}]",
                bp.pos,
                json_str(&bp.name),
                json_str(kind_str(bp.kind)),
                json_str(&scalar_str(bp.value)),
            )
        }));
        format!(r#","bound":[{items}]"#)
    };
    format!(
        r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
        json_str(&b.label()),
        json_str(role),
    )
}

/// The pieces of one scope: the `"key":node,…` entries of the nodes object and the
/// edge-JSON list, returned separately so a composite scope can extend `edges` with
/// its `@role`/`#N` boundary endpoints before formatting. `is_root` mints a
/// synthetic source node (+ its edges) per bound input-role (`source.is_some()`).
fn scope_parts(c: &Composite, is_root: bool) -> (Vec<String>, Vec<String>) {
    let mut nodes: Vec<String> = Vec::new();
    for (i, n) in c.nodes().iter().enumerate() {
        let body = match n {
            BlueprintNode::Primitive(b) => prim_record(b),
            BlueprintNode::Composite(inner) => format!(r#"{{"comp":{}}}"#, json_str(inner.name())),
        };
        nodes.push(format!("{}:{}", json_str(&i.to_string()), body));
    }
    let mut edges: Vec<String> = c
        .edges()
        .iter()
        .map(|e| {
            format!(
                "[{},{}]",
                json_str(&format!("{}.o{}", e.from, e.from_field)),
                json_str(&format!("{}.i{}", e.to, e.slot)),
            )
        })
        .collect();
    if is_root {
        for r in c.input_roles().iter().filter(|r| r.source.is_some()) {
            let kind = r.source.unwrap();
            let key = format!("src_{}", r.name);
            nodes.push(format!(
                r#"{}:{{"prim":{{"type":{},"role":"source","params":[],"ins":[],"outs":[{}]}}}}"#,
                json_str(&key),
                json_str(&r.name),
                named_kind(&r.name, kind),
            ));
            for t in &r.targets {
                edges.push(format!(
                    "[{},{}]",
                    json_str(&format!("{key}.o0")),
                    json_str(&format!("{}.i{}", t.node, t.slot)),
                ));
            }
        }
    }
    (nodes, edges)
}

/// One scope as `{ "nodes": {…}, "edges": [...] }`.
fn scope_json(c: &Composite, is_root: bool) -> String {
    let (nodes, edges) = scope_parts(c, is_root);
    format!(r#"{{"nodes":{{{}}},"edges":[{}]}}"#, nodes.join(","), edges.join(","))
}

/// A composite's interior scope plus its boundary: `inputs` (each role's declared
/// kind+firing — the interior port it feeds), `outputs` (`[name, kind]` per
/// re-exported field), and the interior `nodes`/`edges` with `@role` / `#N`
/// endpoints folded onto the edge list.
fn composite_def_json(c: &Composite) -> String {
    // inputs: one port per input role; kind from the interior slot it feeds, name
    // from the role (the boundary `Role.name`, mirroring `derive_signature`).
    let inputs = join(c.input_roles().iter().map(|r| {
        let kind = r
            .source
            .or_else(|| r.targets.first().and_then(|t| input_slot_kind(c, t.node, t.slot)))
            .unwrap_or(ScalarKind::F64);
        format!("[{},{},{}]", json_str(kind_str(kind)), json_str("any"), json_str(&r.name))
    }));
    // outputs: [name, kind] per OutField. Kind from the producing node's field.
    let outputs = join(c.output().iter().map(|of| {
        let kind = field_kind(c, of.node, of.field).unwrap_or(ScalarKind::F64);
        named_kind(&of.name, kind)
    }));
    // interior scope (non-root: no synthetic sources), then extend the edge list
    // with the boundary endpoints: @role per role-target, #N per output binding.
    let (nodes, mut edges) = scope_parts(c, false);
    for r in c.input_roles() {
        for t in &r.targets {
            edges.push(format!(
                "[{},{}]",
                json_str(&format!("@{}", r.name)),
                json_str(&format!("{}.i{}", t.node, t.slot)),
            ));
        }
    }
    for (n, of) in c.output().iter().enumerate() {
        edges.push(format!(
            "[{},{}]",
            json_str(&format!("{}.o{}", of.node, of.field)),
            json_str(&format!("#{n}")),
        ));
    }
    format!(
        r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]}}"#,
        nodes.join(","),
        edges.join(","),
    )
}

/// The scalar kind of node `node`'s input slot `slot`, read from its signature.
fn input_slot_kind(c: &Composite, node: usize, slot: usize) -> Option<ScalarKind> {
    c.nodes().get(node)?.signature().inputs.get(slot).map(|p| p.kind)
}

/// The scalar kind of node `node`'s output field `field`, read from its schema.
fn field_kind(c: &Composite, node: usize, field: usize) -> Option<ScalarKind> {
    match c.nodes().get(node)? {
        BlueprintNode::Primitive(b) => b.schema().output.get(field).map(|f| f.kind),
        BlueprintNode::Composite(inner) => {
            let of = inner.output().get(field)?;
            field_kind(inner, of.node, of.field)
        }
    }
}

/// Every distinct composite type in the blueprint, first-seen order, keyed by
/// name.
fn distinct_composites(root: &Composite) -> Vec<&Composite> {
    fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
        for it in items {
            if let BlueprintNode::Composite(c) = it
                && !seen.contains(&c.name())
            {
                seen.push(c.name());
                out.push(c);
                walk(c.nodes(), seen, out);
            }
        }
    }
    let mut seen = Vec::new();
    let mut out = Vec::new();
    walk(root.nodes(), &mut seen, &mut out);
    out
}

/// Serialize the harness root composite + every distinct composite type into the
/// canonical graph model (`{ "root": …, "composites": … }`). Read-only (C9): walks
/// structure + declared schemas only, never `eval`/`compile`/`bootstrap`.
pub fn model_to_json(root: &Composite) -> String {
    let composites = join(
        distinct_composites(root)
            .iter()
            .map(|c| format!("{}:{}", json_str(c.name()), composite_def_json(c))),
    );
    format!(
        r#"{{"root":{},"composites":{{{}}}}}"#,
        scope_json(root, true),
        composites,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Edge, OutField, Role, Target};
    use aura_core::{
        Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
    };
    use aura_std::{Exposure, Recorder, Sma, Sub};
    use std::sync::mpsc;

    /// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
    struct Bare;
    impl Node for Bare {
        fn lookbacks(&self) -> Vec<usize> {
            vec![]
        }
        fn eval(&mut self, _c: aura_core::Ctx<'_>) -> Option<&[Cell]> {
            None
        }
    }

    /// A `Sub`-like builder with two f64 inputs (one `Any`, one `Barrier(0)`) and a
    /// single `diff` output — exercises the param/ins/outs serialization shapes.
    fn sub_builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Sub",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() },
                ],
                output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
                params: vec![],
            },
            |_| Box::new(Bare),
        )
    }

    /// The SMA-cross signal as a reusable composite: one bound-free input role
    /// (`price`), one output (the fast-minus-slow spread). Mirrors aura-cli's
    /// `sma_cross` / the engine's `blueprint.rs` fixture.
    fn sma_cross() -> Composite {
        Composite::new(
            "sma_cross",
            vec![
                Sma::builder().named("fast").into(),
                Sma::builder().named("slow").into(),
                Sub::builder().into(),
            ],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, 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: 2, field: 0, name: "out".into() }],
        )
    }

    /// A small, stable root harness exercising the four model elements: a bound
    /// source role (`price`, → a synthetic source node), a composite reference
    /// (`sma_cross`, → a `composites` entry), a plain node (`Exposure`), and a
    /// sink (`Recorder`, `output: vec![]`). A deliberately minimal serializer
    /// fixture, independent of the CLI's built-in sample (`build_sample` in
    /// aura-cli — a richer nested `signals` graph since cycle 0036), kept small so
    /// this golden stays stable; the Recorder receiver is dropped because the
    /// model serializer never runs the graph.
    fn sample_root() -> Composite {
        let (tx, _rx) = mpsc::channel();
        Composite::new(
            "sample",
            vec![
                BlueprintNode::Composite(sma_cross()),
                Exposure::builder().into(),
                Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
            ],
            vec![
                Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread   -> Exposure
                Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> sink
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }], // price -> sma_cross role 0
                source: Some(ScalarKind::F64),
            }],
            vec![], // output: the root ends in a sink, no re-export
        )
    }

    /// A composite with exactly two f64 input roles (each firing `Any`): two `Sma`
    /// leaves, role `a` → leaf 0, role `b` → leaf 1, re-exporting leaf 0.
    fn two_input_composite() -> Composite {
        Composite::new(
            "two_in",
            vec![Sma::builder().into(), Sma::builder().into()],
            vec![],
            vec![
                Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
                Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
            ],
            vec![OutField { node: 0, field: 0, name: "v".into() }],
        )
    }

    /// `sample_root` with one interior edge re-targeted to a different valid node:
    /// the `exposure -> sink` edge instead feeds the `Exposure` node's own input
    /// slot (`to: 1`). Same node set, distinct wiring — the model must differ.
    fn miswired_root() -> Composite {
        let (tx, _rx) = mpsc::channel();
        Composite::new(
            "sample",
            vec![
                BlueprintNode::Composite(sma_cross()),
                Exposure::builder().into(),
                Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
            ],
            vec![
                Edge { from: 0, to: 1, slot: 0, from_field: 0 },
                Edge { from: 1, to: 1, slot: 0, from_field: 0 }, // re-targeted: -> node 1, not the sink
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }],
                source: Some(ScalarKind::F64),
            }],
            vec![],
        )
    }

    // -- Task 2: primitive record ------------------------------------------------

    #[test]
    fn primitive_record_carries_type_role_ins_outs() {
        let got = prim_record(&sub_builder());
        assert_eq!(
            got,
            r#"{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","barrier 0","rhs"]],"outs":[["diff","f64"]]}}"#
        );
    }

    #[test]
    fn prim_record_emits_name_for_explicit_only() {
        // explicitly named → a leading "name" field, ahead of "type"
        let named = prim_record(&sub_builder().named("fast"));
        assert!(
            named.starts_with(r#"{"prim":{"name":"fast","type":"Sub""#),
            "{named}"
        );
        // unnamed → no "name" field at all
        let unnamed = prim_record(&sub_builder());
        assert!(!unnamed.contains(r#""name""#), "{unnamed}");
    }

    #[test]
    fn prim_record_emits_bound_only_when_bound() {
        // a bound i64 param → a "bound" field carrying [pos,"name","kind","value"]
        let bound = prim_record(&Sma::builder().bind("length", Scalar::i64(5)));
        assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}");
        // the bound slot also leaves the tunable "params" surface (bind's tuning side)
        assert!(bound.contains(r#""params":[]"#), "{bound}");

        // a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
        let f = prim_record(&Exposure::builder().bind("scale", Scalar::f64(0.5)));
        assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");

        // an unbound builder → no "bound" field at all
        let unbound = prim_record(&Sma::builder());
        assert!(!unbound.contains(r#""bound""#), "{unbound}");
    }

    // -- Task 3: scope (index keys + synthetic sources + edges) ------------------

    #[test]
    fn scope_emits_index_keyed_nodes_synthetic_sources_and_edges() {
        let root = sample_root();
        let got = scope_json(&root, true);
        // a bound source role is minted as a synthetic source node…
        assert!(got.contains(r#""src_price":{"prim":{"type":"price","role":"source""#), "{got}");
        // …wired to its target by an index-keyed edge:
        assert!(got.contains(r#"["src_price.o0","0.i0"]"#), "{got}");
        // real nodes are index-keyed; the Recorder is a sink:
        assert!(got.contains(r#""role":"sink""#), "{got}");
    }

    // -- Task 4: composite-def + distinct collection -----------------------------

    #[test]
    fn composite_def_carries_inputs_outputs_and_role_output_endpoints() {
        let root = sample_root();
        // the sma_cross interior composite is node 0:
        let inner = match &root.nodes()[0] {
            BlueprintNode::Composite(c) => c,
            _ => panic!(),
        };
        let got = composite_def_json(inner);
        assert!(got.contains(r#""inputs":[["f64","any","price"]]"#), "{got}");
        // an interior input role becomes an @role endpoint:
        assert!(got.contains(r#""@price""#), "{got}");
        // an output binding becomes a #N endpoint:
        assert!(got.contains(r##""#0""##), "{got}");
    }

    #[test]
    fn distinct_composites_collected_once() {
        let root = sample_root();
        let model = model_to_json(&root);
        // sma_cross appears once as a key under "composites":
        assert_eq!(model.matches(r#""sma_cross":{"inputs""#).count(), 1, "{model}");
    }

    // -- Task 5: top-level assembly + determinism + byte golden ------------------

    #[test]
    fn model_is_deterministic() {
        let a = model_to_json(&sample_root());
        let b = model_to_json(&sample_root());
        assert_eq!(a, b);
    }

    #[test]
    fn model_golden() {
        // Re-capture if an intended model-shape change lands: temporarily add a
        // `println!("{}", model_to_json(&sample_root()))` test, run with
        // `-- --nocapture`, and paste the fresh bytes below.
        let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
        assert_eq!(model_to_json(&sample_root()), expected);
    }

    // -- Task 6: structural assertions + mis-wire-differs + read-only ------------

    #[test]
    fn sink_has_empty_outs() {
        // the Recorder node in the root is a sink with no outputs.
        let model = model_to_json(&sample_root());
        assert!(
            model.contains(r#""role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]"#),
            "{model}"
        );
    }

    #[test]
    fn multi_input_composite_has_two_inputs() {
        // a composite with two f64 input roles serialises exactly two ports in "inputs".
        let c = two_input_composite();
        let def = composite_def_json(&c);
        assert!(def.starts_with(r#"{"inputs":[["f64","any","a"],["f64","any","b"]],"#), "{def}");
    }

    #[test]
    fn structural_miswire_changes_the_model() {
        let ok = model_to_json(&sample_root());
        let bad = model_to_json(&miswired_root());
        assert_ne!(ok, bad);
    }

    #[test]
    fn serializer_is_read_only() {
        // Compile-time witness: model_to_json takes &Composite and returns String —
        // no &mut, no eval/compile/bootstrap on the path. Documents the C9 contract;
        // passes by construction.
        fn assert_sig(f: fn(&Composite) -> String) {
            let _ = f;
        }
        assert_sig(model_to_json);
    }
}
