//! Hermetic tests for the offline workspace graph builder (no network).

use std::fs;
use std::path::Path;

use dependable_fetch::{
    DependencyGraph, GraphSource, NodeKind, TreeOptions, WorkspaceGraphOptions,
    build_workspace_graph,
};
use tempfile::TempDir;

/// A three-member workspace (a -> b -> c, a -> serde) with a realistic v4
/// `Cargo.lock` that includes a git dep and a duplicate-version external.
fn write_workspace(dir: &Path) {
    fs::write(
        dir.join("Cargo.toml"),
        r#"
[workspace]
resolver = "2"
members = ["crates/a", "crates/b", "crates/c"]
exclude = ["crates/legacy"]
"#,
    )
    .unwrap();

    let members = [
        (
            "a",
            r#"
[package]
name = "a"
version = "0.1.0"

[dependencies]
b = { path = "../b" }
serde = "1"
gitdep = { git = "https://example.com/gitdep" }
"#,
        ),
        (
            "b",
            r#"
[package]
name = "b"
version = "0.1.0"

[dependencies]
c = { path = "../c" }
serde = "1"
"#,
        ),
        (
            "c",
            r#"
[package]
name = "c"
version = "0.1.0"
"#,
        ),
    ];
    for (name, manifest) in members {
        let crate_dir = dir.join("crates").join(name);
        fs::create_dir_all(&crate_dir).unwrap();
        fs::write(crate_dir.join("Cargo.toml"), manifest).unwrap();
    }
    // An excluded crate that must NOT be treated as a workspace member.
    let legacy = dir.join("crates").join("legacy");
    fs::create_dir_all(&legacy).unwrap();
    fs::write(
        legacy.join("Cargo.toml"),
        "[package]\nname = \"legacy\"\nversion = \"0.1.0\"\n",
    )
    .unwrap();
}

fn write_lockfile(dir: &Path) {
    fs::write(
        dir.join("Cargo.lock"),
        r#"# This file is automatically @generated by Cargo.
version = 4

[[package]]
name = "a"
version = "0.1.0"
dependencies = [
 "b",
 "gitdep",
 "serde",
]

[[package]]
name = "b"
version = "0.1.0"
dependencies = [
 "c",
 "serde",
]

[[package]]
name = "c"
version = "0.1.0"

[[package]]
name = "gitdep"
version = "0.2.0"
source = "git+https://example.com/gitdep#abc123"

[[package]]
name = "serde"
version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
 "syn 2.0.0",
]

[[package]]
name = "syn"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "syn"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#,
    )
    .unwrap();
}

fn kind_of(graph: &DependencyGraph, name: &str) -> NodeKind {
    graph
        .nodes()
        .iter()
        .find(|n| n.name == name)
        .unwrap_or_else(|| panic!("node {name} missing"))
        .kind
}

/// Depth-first flatten of every reachable `(name, deduped)`.
fn flatten(graph: &DependencyGraph) -> Vec<(String, bool)> {
    fn walk(
        graph: &DependencyGraph,
        n: &dependable_fetch::TreeNode,
        out: &mut Vec<(String, bool)>,
    ) {
        out.push((graph.nodes()[n.node].name.clone(), n.deduped));
        for c in &n.children {
            walk(graph, c, out);
        }
    }
    let tree = graph.tree(&TreeOptions::default());
    let mut out = Vec::new();
    for r in &tree.roots {
        walk(graph, r, &mut out);
    }
    out
}

#[test]
fn builds_resolved_graph_from_lockfile() {
    let tmp = TempDir::new().unwrap();
    write_workspace(tmp.path());
    write_lockfile(tmp.path());

    let built = build_workspace_graph(tmp.path(), &WorkspaceGraphOptions::default()).unwrap();
    assert_eq!(built.source, GraphSource::Lockfile);
    let g = &built.graph;

    // Classification: members vs external vs git.
    assert_eq!(kind_of(g, "a"), NodeKind::Workspace);
    assert_eq!(kind_of(g, "b"), NodeKind::Workspace);
    assert_eq!(kind_of(g, "c"), NodeKind::Workspace);
    assert_eq!(kind_of(g, "serde"), NodeKind::Registry);
    assert_eq!(kind_of(g, "gitdep"), NodeKind::Git);

    // The excluded crate is not a member.
    assert!(g.nodes().iter().all(|n| n.name != "legacy"));

    // Inter-member edge a -> b, and the duplicate syn versions are distinct.
    let flat = flatten(g);
    assert!(flat.iter().any(|(n, _)| n == "a"));
    assert!(flat.iter().any(|(n, _)| n == "b"));
    // serde appears once fully and once deduped (under a and under b).
    let serde_full = flat.iter().filter(|(n, d)| n == "serde" && !d).count();
    let serde_dedup = flat.iter().filter(|(n, d)| n == "serde" && *d).count();
    assert_eq!(serde_full, 1);
    assert!(serde_dedup >= 1);
}

#[test]
fn duplicate_versions_are_distinct_nodes() {
    let tmp = TempDir::new().unwrap();
    write_workspace(tmp.path());
    write_lockfile(tmp.path());
    let built = build_workspace_graph(tmp.path(), &WorkspaceGraphOptions::default()).unwrap();
    let versions: Vec<&str> = built
        .graph
        .nodes()
        .iter()
        .filter(|n| n.name == "syn")
        .map(|n| n.version.as_str())
        .collect();
    assert!(versions.contains(&"1.0.0"));
    assert!(versions.contains(&"2.0.0"));
}

#[test]
fn invert_reaches_dependents() {
    let tmp = TempDir::new().unwrap();
    write_workspace(tmp.path());
    write_lockfile(tmp.path());
    // Root the (inverted) graph at serde: it should reach a and b.
    let mut opts = WorkspaceGraphOptions::default();
    opts.package = Some("serde".to_owned());
    let built = build_workspace_graph(tmp.path(), &opts).unwrap();
    let inverted = built.graph.inverted();
    let flat = flatten(&inverted);
    let names: Vec<&str> = flat.iter().map(|(n, _)| n.as_str()).collect();
    assert!(names.contains(&"serde"));
    assert!(names.contains(&"a"));
    assert!(names.contains(&"b"));
}

#[test]
fn falls_back_to_shallow_graph_without_lockfile() {
    let tmp = TempDir::new().unwrap();
    write_workspace(tmp.path()); // no lockfile written

    let built = build_workspace_graph(tmp.path(), &WorkspaceGraphOptions::default()).unwrap();
    assert_eq!(built.source, GraphSource::Manifests);
    let g = &built.graph;

    // Members are still classified as workspace, and their direct external and
    // git deps are present with the right kinds.
    assert_eq!(kind_of(g, "a"), NodeKind::Workspace);
    assert_eq!(kind_of(g, "serde"), NodeKind::Registry);
    assert_eq!(kind_of(g, "gitdep"), NodeKind::Git);

    // The inter-member edge a -> b survives from the path dependency.
    let flat = flatten(g);
    let a = flat.iter().position(|(n, _)| n == "a");
    assert!(a.is_some());
    assert!(flat.iter().any(|(n, _)| n == "b"));
}
