//! Gated behind the `slow-tests` feature: these shell out to the `cqs`
//! binary and cold-load the full model stack per invocation, adding ~2h
//! to CI. PR CI skips; nightly runs `cargo test --features "gpu-index slow-tests"`.
//! See issue #980.
#![cfg(feature = "slow-tests")]

//! CLI integration tests
//!
//! End-to-end tests for the cqs command-line interface.
//!
//! Tests that access the ML model are serialized to prevent HuggingFace Hub
//! lock contention in CI environments.

use assert_cmd::Command;
use predicates::prelude::*;
use serial_test::serial;
use std::fs;
use tempfile::TempDir;

/// Get a Command for the cqs binary
fn cqs() -> Command {
    #[allow(deprecated)]
    Command::cargo_bin("cqs").expect("Failed to find cqs binary")
}

/// Create a temporary directory with a sample Rust file
fn setup_project() -> TempDir {
    let dir = TempDir::new().expect("Failed to create temp dir");
    let src_dir = dir.path().join("src");
    fs::create_dir(&src_dir).expect("Failed to create src dir");
    fs::write(
        src_dir.join("lib.rs"),
        r#"
/// Adds two numbers
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

/// Subtracts b from a
pub fn subtract(a: i32, b: i32) -> i32 {
    a - b
}
"#,
    )
    .expect("Failed to write test file");
    dir
}

#[test]
fn test_help_output() {
    cqs()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Semantic code search"));
}

#[test]
fn test_version_output() {
    cqs()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains("cqs"));
}

#[test]
#[serial]
fn test_init_creates_cqs_directory() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Created .cqs/"));

    assert!(
        dir.path().join(".cqs").exists(),
        ".cqs directory should exist"
    );
}

#[test]
#[serial]
fn test_init_idempotent() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    // First init
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Second init should also succeed
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();
}

#[test]
fn test_stats_requires_init() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["stats"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not found"));
}

#[test]
#[serial]
fn test_stats_shows_counts() {
    let dir = setup_project();

    // Initialize
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Index
    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Stats should show chunk count
    cqs()
        .args(["stats"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Total chunks:"));
}

#[test]
#[serial]
fn test_index_auto_initializes() {
    // Index command auto-creates .cqs if it doesn't exist
    let dir = setup_project();

    assert!(
        !dir.path().join(".cqs").exists(),
        ".cqs should not exist before index"
    );

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Index complete"));

    assert!(
        dir.path().join(".cqs").exists(),
        ".cqs should exist after index"
    );
}

#[test]
#[serial]
fn test_index_parses_files() {
    let dir = setup_project();

    // Initialize
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Index should succeed
    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Index complete"));
}

#[test]
#[serial]
fn test_search_returns_results() {
    let dir = setup_project();

    // Initialize and index
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Search should return at least one result from the indexed functions
    cqs()
        .args(["add numbers"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("results"));
}

#[test]
#[serial]
fn test_search_json_output() {
    let dir = setup_project();

    // Initialize and index
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Search with JSON output
    cqs()
        .args(["--json", "add numbers"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("\"name\""));
}

#[test]
fn test_completions_generates_script() {
    cqs()
        .args(["completions", "bash"])
        .assert()
        .success()
        .stdout(predicate::str::contains("complete"));
}

#[test]
fn test_invalid_option_fails() {
    cqs()
        .args(["--invalid-option-xyz"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("unexpected argument"));
}

// =============================================================================
// Doctor command tests
// =============================================================================

#[test]
#[serial]
fn test_doctor_runs() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["doctor"])
        .current_dir(dir.path())
        .assert()
        .success();
}

#[test]
#[serial]
fn test_doctor_shows_runtime() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["doctor"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Runtime"));
}

#[test]
#[serial]
fn test_doctor_shows_parser() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["doctor"])
        .current_dir(dir.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("Parser"));
}

// =============================================================================
// Call graph command tests (callers/callees)
// =============================================================================

#[test]
fn test_callers_no_index() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["callers", "some_function"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not found").or(predicate::str::contains("Index")));
}

#[test]
fn test_callees_no_index() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["callees", "some_function"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not found").or(predicate::str::contains("Index")));
}

#[test]
#[serial]
fn test_callers_json_output() {
    let dir = setup_project();

    // Initialize and index first
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // callers with --json should return valid JSON (even if empty)
    let output = cqs()
        .args(["callers", "add", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Parse stdout to verify it's valid JSON envelope with array under data
    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));
    assert!(
        parsed["data"].is_array(),
        "callers --json should return array under data"
    );
}

#[test]
#[serial]
fn test_callees_json_output() {
    let dir = setup_project();

    // Initialize and index first
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // callees with --json should return valid JSON
    let output = cqs()
        .args(["callees", "add", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));
    assert!(parsed.is_object(), "callees --json should return object");
    assert!(
        parsed["data"]["name"].is_string(),
        "Should have data.name field"
    );
}

// =============================================================================
// TC10: GC command tests
// =============================================================================

#[test]
fn test_gc_requires_index() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["gc"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not found").or(predicate::str::contains("Index")));
}

#[test]
#[serial]
fn test_gc_json_on_clean_index() {
    let dir = setup_project();

    // Initialize and index
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // GC on a fresh index should find nothing to prune
    let output = cqs()
        .args(["gc", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));

    assert_eq!(
        parsed["data"]["pruned_chunks"], 0,
        "Fresh index should have 0 pruned chunks"
    );
    assert_eq!(
        parsed["data"]["pruned_calls"], 0,
        "Fresh index should have 0 pruned calls"
    );
    assert_eq!(
        parsed["data"]["hnsw_rebuilt"], false,
        "HNSW should not be rebuilt"
    );
}

#[test]
#[serial]
fn test_gc_prunes_missing_files() {
    let dir = setup_project();

    // Initialize and index
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // Delete the source file to make chunks stale
    fs::remove_file(dir.path().join("src/lib.rs")).expect("Failed to remove file");

    // GC should prune the missing file's chunks
    let output = cqs()
        .args(["gc", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));

    // TC-HP-20: assert ALL 5 prune counters, not just pruned_chunks +
    // missing_files. A regression in any of these would silently
    // accumulate orphan call graph / type edge / summary rows.
    assert!(
        parsed["data"]["pruned_chunks"].as_u64().unwrap() > 0,
        "Should prune chunks for missing file"
    );
    assert_eq!(
        parsed["data"]["missing_files"].as_u64().unwrap(),
        1,
        "Should report 1 missing file"
    );
    // The deleted src/lib.rs had call graph entries (add, subtract); their
    // orphan function_calls rows must also be pruned in the same tx.
    assert!(
        parsed["data"]["pruned_calls"].as_u64().is_some(),
        "pruned_calls must be present in GcOutput"
    );
    // Type edges and summaries may or may not be present for this fixture,
    // but the fields must serialize as u64 (never missing, never null).
    assert!(
        parsed["data"]["pruned_type_edges"].as_u64().is_some(),
        "pruned_type_edges must be present in GcOutput"
    );
    assert!(
        parsed["data"]["pruned_summaries"].as_u64().is_some(),
        "pruned_summaries must be present in GcOutput"
    );
    assert_eq!(
        parsed["data"]["hnsw_rebuilt"].as_bool().unwrap(),
        true,
        "HNSW should be rebuilt after pruning chunks"
    );
}

// =============================================================================
// TC11: Dead code command tests
// =============================================================================

#[test]
fn test_dead_requires_index() {
    let dir = TempDir::new().expect("Failed to create temp dir");

    cqs()
        .args(["dead"])
        .current_dir(dir.path())
        .assert()
        .failure()
        .stderr(predicate::str::contains("not found").or(predicate::str::contains("Index")));
}

#[test]
#[serial]
fn test_dead_json_output() {
    let dir = setup_project();

    // Initialize and index
    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // dead --json should return valid JSON
    let output = cqs()
        .args(["dead", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));

    // Should have dead and possibly_dead_pub arrays (under data envelope)
    assert!(
        parsed["data"]["dead"].is_array(),
        "dead --json should have 'data.dead' array, got: {}",
        parsed
    );
    assert!(
        parsed["data"]["possibly_dead_pub"].is_array(),
        "dead --json should have 'data.possibly_dead_pub' array, got: {}",
        parsed
    );

    // Our test project has two pub functions (add, subtract) with no callers
    // They should appear in possibly_dead_pub (since they're public)
    let possibly_dead = parsed["data"]["possibly_dead_pub"].as_array().unwrap();
    assert!(
        !possibly_dead.is_empty(),
        "Public functions with no callers should be in possibly_dead_pub"
    );
}

#[test]
#[serial]
fn test_dead_include_pub_flag() {
    let dir = setup_project();

    cqs()
        .args(["init"])
        .current_dir(dir.path())
        .assert()
        .success();

    cqs()
        .args(["index"])
        .current_dir(dir.path())
        .assert()
        .success();

    // dead --include-pub --json should also succeed
    let output = cqs()
        .args(["dead", "--include-pub", "--json"])
        .current_dir(dir.path())
        .assert()
        .success();

    let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
        .unwrap_or_else(|e| panic!("Invalid JSON output: {} — raw: {}", e, stdout));
    assert!(
        parsed["data"]["dead"].is_array(),
        "Should have 'data.dead' array"
    );
    // With --include-pub, public functions should move to the dead list
    let dead = parsed["data"]["dead"].as_array().unwrap();
    assert!(
        !dead.is_empty(),
        "With --include-pub, public functions should be in 'data.dead' list"
    );
}
