//! File-based tests for the public `Network` bindings.
//!
//! Each test is a pair of files in `tests/network/`: `<name>.nxs` (the program) and
//! `<name>.toml` (the expected network). The expected network is deserialized with `serde`/`toml`
//! into [`ExpectedNetwork`]; the actual `Network` returned by [`Nexus::run_file`] is lowered into
//! the same structs via the public API only. Both are normalized (sorted, so authoring order is
//! irrelevant) and compared with `pretty_assertions::assert_eq!`.
//!
//! Add a test by dropping a `<name>.nxs` / `<name>.toml` pair into `tests/network/`; no Rust
//! changes needed.
//!
//! The `.toml` schema is three arrays-of-tables (any may be omitted → empty):
//! - `[[nodes]]` ; leaf components: `path` (string), `type` (string), `ports` (array of
//!   `"<dir> <name>"`, e.g. `"out output"`), `attributes` (inline table of resolved String
//!   values, e.g. `{ device = "hw:0" }`; omitted → empty, since attributes are nodes-only).
//! - `[[groups]]`; composite nodes (groups, and the synthetic top-level array container):
//!   `path` (string), `ports` (array). Groups never have attributes.
//! - `[[connections]]`; `from` / `to`, each an inline table `{ node = "<path>", port = "<name>" }`.
//!
//! Paths are `/`-separated (root segment = the result binding's name, or index segments for a
//! top-level array). An empty / comment-only `.toml` means "no network" (`root: None`).

use libtest_mimic::{Arguments, Failed, Trial};
use nexus_lang::{Network, Nexus, Node, WalkEvent};
use serde::Deserialize;
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

const TEST_DIR: &str = "tests/network";

fn main() {
    let args = Arguments::from_args();
    libtest_mimic::run(&args, collect_tests()).exit();
}

// ---------------------------------------------------------------------------
// Expected-network model (deserialized from `<name>.toml`)
// ---------------------------------------------------------------------------

#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
struct ExpectedNetwork {
    #[serde(default)]
    nodes: Vec<NodeRow>,
    #[serde(default)]
    groups: Vec<GroupRow>,
    #[serde(default)]
    connections: Vec<ConnRow>,
}

/// A leaf node (no children): an external component.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(deny_unknown_fields)]
struct NodeRow {
    path: String,
    #[serde(rename = "type")]
    kind: String,
    #[serde(default)]
    ports: Vec<String>,
    #[serde(default)]
    attributes: BTreeMap<String, String>,
}

/// A composite node (has children): a group, or the synthetic top-level array container.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(deny_unknown_fields)]
struct GroupRow {
    path: String,
    #[serde(default)]
    ports: Vec<String>,
}

/// A directed connection between two endpoints.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConnRow {
    from: Endpoint,
    to: Endpoint,
}

/// A connection endpoint: a node/group `path` plus a `port` name (inline table in TOML).
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(deny_unknown_fields)]
struct Endpoint {
    node: String,
    port: String,
}

impl ExpectedNetwork {
    /// Lower the public `Network` into the same struct shape, using the public API only.
    fn from_network(network: &Network) -> Self {
        let mut out = ExpectedNetwork::default();

        if let Some(root) = &network.root {
            collect_tree(root, &root.name, &mut out);
        }

        let paths = network.node_paths();
        // `build_network` guarantees every surviving connection's endpoints are part of the
        // result tree, so this must always resolve. A miss here means that guarantee broke.
        let resolve = |id: u64| {
            paths
                .get(&id)
                .cloned()
                .unwrap_or_else(|| panic!("connection endpoint {id} has no path (D-4 regression)"))
        };
        for c in &network.connections {
            out.connections.push(ConnRow {
                from: Endpoint {
                    node: resolve(c.from.node_id),
                    port: c.from.port_name.clone(),
                },
                to: Endpoint {
                    node: resolve(c.to.node_id),
                    port: c.to.port_name.clone(),
                },
            });
        }

        out
    }

    /// Sort port lists and the three tables so comparison is independent of authoring order.
    fn normalized(mut self) -> Self {
        for n in &mut self.nodes {
            n.ports.sort();
        }
        for g in &mut self.groups {
            g.ports.sort();
        }
        self.nodes.sort();
        self.groups.sort();
        self.connections.sort();
        self
    }
}

fn collect_tree(node: &Node, path: &str, out: &mut ExpectedNetwork) {
    let ports: Vec<String> = node
        .ports
        .iter()
        .map(|p| format!("{} {}", p.direction, p.name))
        .collect();

    if node.children.is_empty() {
        out.nodes.push(NodeRow {
            path: path.to_owned(),
            kind: node.type_name.clone(),
            ports,
            attributes: node.attributes.clone(),
        });
    } else {
        out.groups.push(GroupRow {
            path: path.to_owned(),
            ports,
        });
        for child in &node.children {
            collect_tree(child, &format!("{path}/{}", child.name), out);
        }
    }
}

// ---------------------------------------------------------------------------
// Discovery & execution
// ---------------------------------------------------------------------------

fn collect_tests() -> Vec<Trial> {
    let mut paths = index_nxs_files(Path::new(TEST_DIR));
    paths.sort();

    let mut tests: Vec<Trial> = paths
        .into_iter()
        .map(|nxs| {
            let stem = nxs.file_stem().unwrap().to_string_lossy().into_owned();
            let expected = nxs.with_extension("toml");
            Trial::test(format!("network/{stem}"), move || run_case(&nxs, &expected))
        })
        .collect();

    // API-invariant test (not an expected-output case): `node_paths()` and `node_index()` must
    // agree, and every connection endpoint must resolve to an indexed node.
    tests.push(Trial::test("network/node_paths_and_index_agree", || {
        node_paths_and_index_agree()
    }));

    // `Network::walk()` invariants; not expected-output cases, so hand-built `Network` values
    // rather than `.nxs`/`.toml` pairs.
    tests.push(Trial::test("network/walk_empty_network", || {
        walk_empty_network()
    }));
    tests.push(Trial::test("network/walk_single_leaf", walk_single_leaf));
    tests.push(Trial::test("network/walk_nested_ordering", || {
        walk_nested_ordering()
    }));
    tests.push(Trial::test("network/walk_empty_group_is_leaf", || {
        walk_empty_group_is_leaf()
    }));

    tests
}

/// `.nxs` files in `dir`, excluding `_lib` companion files (reachable via `use`, not tests).
fn index_nxs_files(dir: &Path) -> Vec<PathBuf> {
    fs::read_dir(dir)
        .unwrap_or_else(|_| panic!("Failed to read directory {}", dir.display()))
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| {
            p.extension().is_some_and(|e| e == "nxs")
                && !p
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .is_some_and(|s| s.contains("_lib"))
        })
        .collect()
}

fn run_case(nxs: &Path, expected_path: &Path) -> Result<(), Failed> {
    let (network, _warnings) = Nexus::run_file(nxs).map_err(|e| format!("{e:?}"))?;
    let actual = ExpectedNetwork::from_network(&network).normalized();

    let text = fs::read_to_string(expected_path).map_err(|_| {
        Failed::from(format!(
            "missing expected-output file: {}",
            expected_path.display()
        ))
    })?;
    let expected = toml::from_str::<ExpectedNetwork>(&text)
        .map_err(|e| Failed::from(format!("invalid TOML in {}: {e}", expected_path.display())))?
        .normalized();

    pretty_assertions::assert_eq!(expected, actual);
    Ok(())
}

fn node_paths_and_index_agree() -> Result<(), Failed> {
    let source = r"
        node type A { out output; }
        node type B { in input;  }
        let g = group {
            self.a = node A;
            self.b = node B;
            a.output -> b.input;
            port output = a.output;
        };
        g
    ";
    let (network, _) = Nexus::run(source).map_err(|e| format!("{e:?}"))?;

    let paths = network.node_paths();
    let index = network.node_index();

    if paths.len() != index.len() {
        return Err(Failed::from(format!(
            "node_paths ({}) and node_index ({}) disagree on size",
            paths.len(),
            index.len()
        )));
    }

    for conn in &network.connections {
        if !index.contains_key(&conn.from.node_id) || !index.contains_key(&conn.to.node_id) {
            return Err(Failed::from("connection endpoint missing from node_index"));
        }
    }

    Ok(())
}

/// Build a bare `Node` for hand-constructed `Network` values (ports/attributes unused by
/// `walk()`, so left empty).
fn leaf(id: u64, name: &str, type_name: &str) -> Node {
    Node {
        id,
        name: name.to_owned(),
        type_name: type_name.to_owned(),
        ports: Vec::new(),
        children: Vec::new(),
        attributes: BTreeMap::new(),
    }
}

fn group(id: u64, name: &str, children: Vec<Node>) -> Node {
    Node {
        children,
        ..leaf(id, name, "group")
    }
}

fn walk_empty_network() -> Result<(), Failed> {
    let network = Network {
        root: None,
        connections: Vec::new(),
    };

    if network.walk().next().is_some() {
        return Err(Failed::from("walk() over an empty network yielded events"));
    }

    Ok(())
}

fn walk_single_leaf() -> Result<(), Failed> {
    let root = leaf(0, "a", "Source");
    let network = Network {
        root: Some(root),
        connections: Vec::new(),
    };

    let events: Vec<_> = network.walk().collect();
    match events.as_slice() {
        [WalkEvent::Leaf(node)] if node.id == 0 => Ok(()),
        other => Err(Failed::from(format!(
            "expected a single Leaf event, got {other:?}"
        ))),
    }
}

fn walk_nested_ordering() -> Result<(), Failed> {
    // root (group) -> [a (leaf), g (group) -> [b (leaf)]]
    let root = group(
        0,
        "root",
        vec![
            leaf(1, "a", "Source"),
            group(2, "g", vec![leaf(3, "b", "Sink")]),
        ],
    );
    let network = Network {
        root: Some(root),
        connections: Vec::new(),
    };

    let ids: Vec<(u64, &str)> = network
        .walk()
        .map(|event| match event {
            WalkEvent::Enter(n) => (n.id, "Enter"),
            WalkEvent::Leaf(n) => (n.id, "Leaf"),
            WalkEvent::Leave(n) => (n.id, "Leave"),
        })
        .collect();

    let expected = vec![
        (0, "Enter"),
        (1, "Leaf"),
        (2, "Enter"),
        (3, "Leaf"),
        (2, "Leave"),
        (0, "Leave"),
    ];

    if ids != expected {
        return Err(Failed::from(format!(
            "unexpected walk order: got {ids:?}, expected {expected:?}"
        )));
    }

    Ok(())
}

fn walk_empty_group_is_leaf() -> Result<(), Failed> {
    // An empty `group {}` has no children, so it walks as `Leaf` even though its
    // `type_name` is `"group"`; the discriminator is `children.is_empty()`, not the name.
    let root = group(0, "g", Vec::new());
    let network = Network {
        root: Some(root),
        connections: Vec::new(),
    };

    let events: Vec<_> = network.walk().collect();
    match events.as_slice() {
        [WalkEvent::Leaf(node)] if node.type_name == "group" => Ok(()),
        other => Err(Failed::from(format!(
            "expected an empty group to walk as a single Leaf, got {other:?}"
        ))),
    }
}
