//! Integration tests for the `find_dead_code` MCP tool (4.1.0 Front X3).
//!
//! Four non-ignored tests validate response shape and parameter behaviour
//! using synthetic [`RepoGraph`]s built via [`build_graph_from_files_pub`]
//! and calling `compute_dead_code` directly.  The `#[ignore]`-d smoke test
//! spawns the release binary via JSON-RPC stdio.

use std::collections::HashSet;

use ripvec_core::repo_map::{
    CallRef, Definition, FileNode, build_graph_from_files_pub, compute_dead_code,
};

// ---------------------------------------------------------------------------
// Helpers shared across tests
// ---------------------------------------------------------------------------

fn def(name: &str, start_line: u32, end_line: u32) -> Definition {
    Definition {
        name: name.to_string(),
        kind: "function_item".to_string(),
        start_line,
        end_line,
        scope: String::new(),
        signature: None,
        start_byte: 0,
        end_byte: 0,
        calls: vec![],
    }
}

fn def_calling(name: &str, start_line: u32, end_line: u32, callee: (u32, u16)) -> Definition {
    Definition {
        name: name.to_string(),
        kind: "function_item".to_string(),
        start_line,
        end_line,
        scope: String::new(),
        signature: None,
        start_byte: 0,
        end_byte: 0,
        calls: vec![CallRef {
            name: "callee".to_string(),
            qualified_path: None,
            receiver_type: None,
            byte_offset: 0,
            resolved: Some(callee),
        }],
    }
}

fn file_node(path: &str, defs: Vec<Definition>) -> FileNode {
    FileNode {
        path: path.to_string(),
        defs,
        imports: vec![],
    }
}

/// Write one JSON-RPC 2.0 message with Content-Length framing.
fn rpc_write(w: &mut dyn std::io::Write, msg: &serde_json::Value) {
    let body = serde_json::to_vec(msg).unwrap();
    write!(w, "Content-Length: {}\r\n\r\n", body.len()).unwrap();
    w.write_all(&body).unwrap();
    w.flush().unwrap();
}

/// Read one JSON-RPC 2.0 message with Content-Length framing.
fn rpc_read(r: &mut dyn std::io::Read) -> serde_json::Value {
    let mut headers = Vec::new();
    let mut buf = [0u8; 1];
    while !headers.ends_with(b"\r\n\r\n") {
        r.read_exact(&mut buf).expect("reading header byte");
        headers.push(buf[0]);
    }
    let header_str = String::from_utf8_lossy(&headers);
    let cl: usize = header_str
        .lines()
        .find(|l| l.to_lowercase().starts_with("content-length:"))
        .and_then(|l| l.split(':').nth(1))
        .and_then(|v| v.trim().parse().ok())
        .expect("Content-Length header");
    let mut body = vec![0u8; cl];
    r.read_exact(&mut body).expect("reading body");
    serde_json::from_slice(&body).expect("parse JSON response")
}

// ---------------------------------------------------------------------------
// Test 1: find_dead_code_returns_valid_response_shape
// ---------------------------------------------------------------------------

/// Validates that compute_dead_code returns all required fields and that
/// the cluster shape is consistent: root_def_idx is a member, size matches
/// member_def_indices.len(), and total_lines is sensible.
///
/// X3 acceptance: `test:find_dead_code_returns_valid_response_shape`
#[test]
fn find_dead_code_returns_valid_response_shape() {
    let files = vec![
        file_node("src/main.rs", vec![def("main", 1, 10)]),
        FileNode {
            path: "src/dead.rs".to_string(),
            defs: vec![def_calling("dead_a", 1, 5, (1, 1)), def("dead_b", 7, 12)],
            imports: vec![],
        },
    ];

    let graph = build_graph_from_files_pub(files);
    let entries: HashSet<usize> = vec![0].into_iter().collect();
    let report = compute_dead_code(&graph, &entries, true);

    // Top-level response fields must be present and coherent.
    assert!(
        (0.0_f32..=1.0).contains(&report.dead_fraction),
        "dead_fraction must be in [0.0, 1.0], got {}",
        report.dead_fraction
    );
    assert_eq!(
        report.total_dead_defs + report.total_live_defs,
        graph.def_ranks.len(),
        "total_dead + total_live must equal total defs"
    );

    // Each cluster must have coherent internal structure.
    for cluster in &report.dead_clusters {
        assert_eq!(
            cluster.size,
            cluster.member_def_indices.len(),
            "cluster.size must equal member_def_indices.len()"
        );
        assert!(
            cluster.member_def_indices.contains(&cluster.root_def_idx),
            "root_def_idx must be in member_def_indices"
        );
        assert!(
            cluster.total_lines < 1_000_000,
            "total_lines sanity overflow: {}",
            cluster.total_lines
        );
    }
}

// ---------------------------------------------------------------------------
// Test 2: find_dead_code_excludes_test_paths_by_default
// ---------------------------------------------------------------------------

/// Validates that when `include_test_paths=false` the test-file entry seeds
/// are stripped, causing test-path defs to count as dead.
///
/// X3 acceptance: `test:find_dead_code_excludes_test_paths_by_default`
#[test]
fn find_dead_code_excludes_test_paths_by_default() {
    // tests/ path -> is_test_path heuristic should fire.
    let files = vec![
        file_node("tests/unit_test.rs", vec![def("test_helper", 1, 5)]),
        file_node("src/dead_prod.rs", vec![def("dead_fn", 1, 5)]),
        file_node("src/main.rs", vec![def("main_fn", 1, 5)]),
    ];

    let graph = build_graph_from_files_pub(files);
    // Both test file entry AND main are in the entry set.
    let entries: HashSet<usize> = vec![0, 2].into_iter().collect();

    // With include_test_paths=true: test seed counts, only dead_prod is dead.
    let report_incl = compute_dead_code(&graph, &entries, true);
    assert_eq!(
        report_incl.total_dead_defs, 1,
        "include_test_paths=true: only dead_fn should be dead, got {}",
        report_incl.total_dead_defs
    );

    // With include_test_paths=false: test seed is stripped, so test_helper
    // and dead_fn are both dead.
    let report_excl = compute_dead_code(&graph, &entries, false);
    assert_eq!(
        report_excl.total_dead_defs, 2,
        "include_test_paths=false: test_helper + dead_fn should be dead, got {}",
        report_excl.total_dead_defs
    );
}

// ---------------------------------------------------------------------------
// Test 3: find_dead_code_max_clusters_caps_output
// ---------------------------------------------------------------------------

/// Validates that max_clusters truncates results and sets capped=true.
///
/// X3 acceptance: `test:find_dead_code_max_clusters_caps_output`
#[test]
fn find_dead_code_max_clusters_caps_output() {
    // Build 12 completely isolated dead files (no calls between them).
    let mut files = vec![file_node("src/main.rs", vec![def("main", 1, 5)])];
    for i in 0..12 {
        files.push(file_node(
            &format!("src/dead_{i}.rs"),
            vec![def(&format!("dead_fn_{i}"), 1, 5)],
        ));
    }

    let graph = build_graph_from_files_pub(files);
    let entries: HashSet<usize> = vec![0].into_iter().collect();
    let report = compute_dead_code(&graph, &entries, true);

    // Baseline: all 12 dead defs exist as 12 singleton clusters.
    assert_eq!(report.total_dead_defs, 12, "expected 12 dead defs");
    assert_eq!(
        report.dead_clusters.len(),
        12,
        "expected 12 clusters before cap"
    );

    // Simulated max_clusters=10: take first 10, capped=true.
    let max_clusters = 10_usize;
    let all_count = report.dead_clusters.len();
    let capped = all_count > max_clusters;
    let truncated: Vec<_> = report.dead_clusters.iter().take(max_clusters).collect();

    assert_eq!(truncated.len(), 10, "truncated to max_clusters=10");
    assert!(capped, "capped must be true when clusters > max_clusters");
}

// ---------------------------------------------------------------------------
// Test 4: find_dead_code_min_cluster_size_filters_singletons
// ---------------------------------------------------------------------------

/// Validates that min_cluster_size=2 suppresses singleton dead defs.
///
/// X3 acceptance: `test:find_dead_code_min_cluster_size_filters_singletons`
#[test]
fn find_dead_code_min_cluster_size_filters_singletons() {
    let files = vec![
        file_node("src/main.rs", vec![def("main", 1, 5)]),
        // A 3-node dead chain.
        FileNode {
            path: "src/dead_chain.rs".to_string(),
            defs: vec![
                def_calling("dead_a", 1, 2, (1, 1)),
                def_calling("dead_b", 3, 4, (1, 2)),
                def("dead_c", 5, 6),
            ],
            imports: vec![],
        },
        // An isolated singleton dead def.
        file_node("src/singleton.rs", vec![def("lone_wolf", 1, 3)]),
    ];

    let graph = build_graph_from_files_pub(files);
    let entries: HashSet<usize> = vec![0].into_iter().collect();
    let report = compute_dead_code(&graph, &entries, true);

    // Baseline: 4 dead defs, 2 clusters (size 3 + size 1).
    assert_eq!(report.total_dead_defs, 4, "expected 4 dead defs");
    assert_eq!(report.dead_clusters.len(), 2, "expected 2 clusters total");

    // min_cluster_size=2 should suppress the singleton.
    let min_size = 2_usize;
    let filtered: Vec<_> = report
        .dead_clusters
        .iter()
        .filter(|c| c.size >= min_size)
        .collect();
    assert_eq!(
        filtered.len(),
        1,
        "min_cluster_size=2 should yield 1 cluster"
    );
    assert_eq!(filtered[0].size, 3, "retained cluster must have size 3");
}

// ---------------------------------------------------------------------------
// Test 5: find_dead_code_live_corpus_smoke (ignored -- requires release binary)
// ---------------------------------------------------------------------------

/// End-to-end live test: spawns the release binary via JSON-RPC stdio,
/// calls find_dead_code on the ripvec corpus, and validates the response.
///
/// Requires: `cargo build --release -p ripvec-mcp` to have been run.
///
/// X3 acceptance: `behavior:live-measurement-dead_fraction-0.05-0.35`
#[test]
#[ignore = "requires release binary; run with: cargo test -p ripvec-mcp --test find_dead_code_e2e -- --ignored"]
fn find_dead_code_live_corpus_smoke() {
    use std::process::{Command, Stdio};

    let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
    let binary = workspace_root.join("target/release/ripvec-mcp");
    assert!(
        binary.exists(),
        "release binary not found at {}; run: cargo build --release -p ripvec-mcp",
        binary.display()
    );

    let mut child = Command::new(&binary)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn ripvec-mcp");

    let stdin = child.stdin.as_mut().expect("stdin");
    let stdout = child.stdout.as_mut().expect("stdout");

    rpc_write(
        stdin,
        &serde_json::json!({
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "e2e-test", "version": "0.1"}
            }
        }),
    );
    let init_resp = rpc_read(stdout);
    assert!(
        init_resp.get("result").is_some(),
        "initialize failed: {init_resp}"
    );

    rpc_write(
        stdin,
        &serde_json::json!({
            "jsonrpc": "2.0",
            "method": "notifications/initialized",
            "params": {}
        }),
    );

    rpc_write(
        stdin,
        &serde_json::json!({
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "find_dead_code",
                "arguments": {
                    "root": workspace_root.to_str().unwrap(),
                    "include_test_paths": false,
                    "max_clusters": 50,
                    "min_cluster_size": 1
                }
            }
        }),
    );
    let resp = rpc_read(stdout);
    child.stdin.take();
    child.wait().ok();

    let result = resp.get("result").unwrap_or_else(|| {
        panic!("tool call failed: {resp}");
    });

    let content = result["content"].as_array().expect("content array");
    assert!(!content.is_empty(), "content must not be empty");

    let data: serde_json::Value = {
        let first = &content[0];
        if first["type"] == "text" {
            serde_json::from_str(first["text"].as_str().unwrap()).unwrap()
        } else {
            first["json"].clone()
        }
    };

    let dead_fraction = data["dead_fraction"].as_f64().expect("dead_fraction field");
    assert!(
        (0.05..=0.35).contains(&dead_fraction),
        "dead_fraction {dead_fraction:.3} outside expected [0.05, 0.35] for ripvec corpus"
    );

    let ep_detected = data["entry_points_detected"]
        .as_array()
        .expect("entry_points_detected array");
    assert!(
        !ep_detected.is_empty(),
        "entry_points_detected must be non-empty for a Rust corpus"
    );

    let clusters = data["dead_clusters"]
        .as_array()
        .expect("dead_clusters array");
    assert!(
        !clusters.is_empty(),
        "expected at least one dead cluster on ripvec corpus"
    );

    eprintln!(
        "ripvec live: dead_fraction={dead_fraction:.3}, {} clusters, entry_kinds={ep_detected:?}",
        clusters.len()
    );
}
