//! End-to-end CLI integration tests using test fixtures.
//!
//! Each fixture in `tests/fixtures/` contains:
//! - A Cargo.toml (and optionally workspace members)
//! - An expected.report.json with expected output (timestamps use "__TIMESTAMP__" placeholder)
//!
//! These tests run the CLI against each fixture and verify:
//! 1. Exit code matches expected (0=pass, 2=fail)
//! 2. JSON output matches expected (ignoring timestamps)

use assert_cmd::Command;
use depguard_test_util::normalize_nondeterministic;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
use tempfile::TempDir;

/// Helper to get a Command for the depguard binary.
/// Wraps the deprecated cargo_bin to centralize the deprecation warning.
#[allow(deprecated)]
fn depguard_cmd() -> Command {
    Command::cargo_bin("depguard").expect("depguard binary not found - run `cargo build` first")
}

/// Get the path to the test fixtures directory
fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("depguard-cli crate should have a parent directory")
        .parent()
        .expect("crates directory should have a parent (repo root)")
        .join("tests")
        .join("fixtures")
}

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("depguard-cli crate should have a parent directory")
        .parent()
        .expect("crates directory should have a parent (repo root)")
        .to_path_buf()
}

fn validate_report_schema(report: &Value, schema_file: &str) {
    let schema_path = repo_root().join("schemas").join(schema_file);
    let schema_text = std::fs::read_to_string(&schema_path).expect("Failed to read schema file");
    let schema_json: Value = serde_json::from_str(&schema_text).expect("Invalid schema JSON");

    let compiled = jsonschema::validator_for(&schema_json).expect("Failed to compile schema");

    let errors: Vec<String> = compiled
        .iter_errors(report)
        .map(|e| e.to_string())
        .collect();
    if errors.is_empty() {
        return;
    }
    panic!(
        "Report failed schema validation against {}:\n{}",
        schema_file,
        errors.join("\n")
    );
}

/// Run the CLI check command against a fixture and return the JSON report.
fn run_check_on_fixture(fixture_name: &str) -> (i32, Value) {
    run_check_on_fixture_args(fixture_name, &[])
}

fn run_check_on_fixture_args(fixture_name: &str, extra_args: &[&str]) -> (i32, Value) {
    let fixture_path = fixtures_dir().join(fixture_name);
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    let mut cmd = depguard_cmd();
    cmd.arg("--repo-root").arg(&fixture_path).arg("check");
    for arg in extra_args {
        cmd.arg(arg);
    }
    let output = cmd
        .arg("--report-out")
        .arg(&report_path)
        .output()
        .expect("Failed to run command");

    let exit_code = output.status.code().unwrap_or(-1);

    let report_content = std::fs::read_to_string(&report_path).expect("Failed to read report");
    let report: Value = serde_json::from_str(&report_content).expect("Failed to parse report JSON");

    (exit_code, report)
}

/// Load and parse the expected report for a fixture.
fn load_expected_report(fixture_name: &str) -> Value {
    let expected_path = fixtures_dir()
        .join(fixture_name)
        .join("expected.report.json");
    let content = std::fs::read_to_string(&expected_path).expect("Failed to read expected report");
    serde_json::from_str(&content).expect("Failed to parse expected report")
}

/// Compare two JSON values, ignoring timestamp differences.
fn assert_reports_match(actual: Value, expected: Value, fixture_name: &str) {
    let actual_normalized = normalize_nondeterministic(actual);
    let expected_normalized = normalize_nondeterministic(expected);

    assert_eq!(
        actual_normalized,
        expected_normalized,
        "Report mismatch for fixture '{}'.\n\nActual:\n{}\n\nExpected:\n{}",
        fixture_name,
        serde_json::to_string_pretty(&actual_normalized).unwrap(),
        serde_json::to_string_pretty(&expected_normalized).unwrap()
    );
}

// ============================================================================
// Fixture tests
// ============================================================================

#[test]
fn fixture_clean_passes() {
    let (exit_code, report) = run_check_on_fixture("clean");
    let expected = load_expected_report("clean");

    assert_eq!(exit_code, 0, "clean fixture should exit with 0 (pass)");
    assert_reports_match(report, expected, "clean");
}

#[test]
fn fixture_wildcards_fails() {
    let (exit_code, report) = run_check_on_fixture("wildcards");
    let expected = load_expected_report("wildcards");

    assert_eq!(exit_code, 2, "wildcards fixture should exit with 2 (fail)");
    assert_reports_match(report, expected, "wildcards");
}

#[test]
fn fixture_path_missing_version_fails() {
    let (exit_code, report) = run_check_on_fixture("path_missing_version");
    let expected = load_expected_report("path_missing_version");

    assert_eq!(
        exit_code, 2,
        "path_missing_version fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "path_missing_version");
}

#[test]
fn fixture_path_safety_fails() {
    let (exit_code, report) = run_check_on_fixture("path_safety");
    let expected = load_expected_report("path_safety");

    assert_eq!(
        exit_code, 2,
        "path_safety fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "path_safety");
}

#[test]
fn fixture_workspace_inheritance_fails() {
    let (exit_code, report) = run_check_on_fixture("workspace_inheritance");
    let expected = load_expected_report("workspace_inheritance");

    assert_eq!(
        exit_code, 2,
        "workspace_inheritance fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "workspace_inheritance");
}

#[test]
fn fixture_default_features_explicit_fails() {
    let (exit_code, report) = run_check_on_fixture("default_features_explicit");
    let expected = load_expected_report("default_features_explicit");

    assert_eq!(
        exit_code, 2,
        "default_features_explicit fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "default_features_explicit");
}

#[test]
fn fixture_git_requires_version_fails() {
    let (exit_code, report) = run_check_on_fixture("git_requires_version");
    let expected = load_expected_report("git_requires_version");

    assert_eq!(
        exit_code, 2,
        "git_requires_version fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "git_requires_version");
}

#[test]
fn fixture_no_multiple_versions_fails() {
    let (exit_code, report) = run_check_on_fixture("no_multiple_versions");
    let expected = load_expected_report("no_multiple_versions");

    assert_eq!(
        exit_code, 2,
        "no_multiple_versions fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "no_multiple_versions");
}

#[test]
fn fixture_optional_unused_fails() {
    let (exit_code, report) = run_check_on_fixture("optional_unused");
    let expected = load_expected_report("optional_unused");

    assert_eq!(
        exit_code, 2,
        "optional_unused fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "optional_unused");
}

#[test]
fn fixture_dev_only_in_normal_fails() {
    let (exit_code, report) = run_check_on_fixture("dev_only_in_normal");
    let expected = load_expected_report("dev_only_in_normal");

    assert_eq!(
        exit_code, 2,
        "dev_only_in_normal fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "dev_only_in_normal");
}

#[test]
fn fixture_multi_violation_fails() {
    let (exit_code, report) = run_check_on_fixture("multi_violation");
    let expected = load_expected_report("multi_violation");

    assert_eq!(
        exit_code, 2,
        "multi_violation fixture should exit with 2 (fail)"
    );

    // Verify deterministic ordering: findings should be sorted by line number
    // (all have same severity and path, so line is the tiebreaker)
    let findings = report["findings"]
        .as_array()
        .expect("findings should be array");
    let lines: Vec<i64> = findings
        .iter()
        .map(|f| f["location"]["line"].as_i64().unwrap())
        .collect();
    let mut sorted_lines = lines.clone();
    sorted_lines.sort();
    assert_eq!(
        lines, sorted_lines,
        "findings should be sorted by line number"
    );

    assert_reports_match(report, expected, "multi_violation");
}

#[test]
fn fixture_no_manifest_passes() {
    let (exit_code, report) = run_check_on_fixture("no_manifest");
    let expected = load_expected_report("no_manifest");

    assert_eq!(
        exit_code, 0,
        "no_manifest fixture should exit with 0 (pass)"
    );
    assert_reports_match(report, expected, "no_manifest");
}

#[test]
fn fixture_publish_false_passes() {
    // Tests that deps.path_requires_version is skipped for crates with publish = false
    // when ignore_publish_false is not set (default behavior)
    let (exit_code, report) = run_check_on_fixture("publish_false");
    let expected = load_expected_report("publish_false");

    assert_eq!(
        exit_code, 0,
        "publish_false fixture should exit with 0 (pass) - path deps allowed for unpublished crates"
    );
    assert_reports_match(report, expected, "publish_false");
}

#[test]
fn fixture_publish_false_enforced_fails() {
    // Tests that deps.path_requires_version IS triggered for crates with publish = false
    // when ignore_publish_false = true is set in the config
    let (exit_code, report) = run_check_on_fixture("publish_false_enforced");
    let expected = load_expected_report("publish_false_enforced");

    assert_eq!(
        exit_code, 2,
        "publish_false_enforced fixture should exit with 2 (fail) - path deps flagged despite publish=false"
    );
    assert_reports_match(report, expected, "publish_false_enforced");
}

#[test]
fn fixture_workspace_members_exclude_fails() {
    let (exit_code, report) = run_check_on_fixture("workspace_members_exclude");
    let expected = load_expected_report("workspace_members_exclude");

    assert_eq!(
        exit_code, 2,
        "workspace_members_exclude fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "workspace_members_exclude");
}

#[test]
fn fixture_nested_workspace_fails() {
    let (exit_code, report) = run_check_on_fixture("nested_workspace");
    let expected = load_expected_report("nested_workspace");

    assert_eq!(
        exit_code, 2,
        "nested_workspace fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "nested_workspace");
}

#[test]
fn fixture_target_deps_fails() {
    let (exit_code, report) = run_check_on_fixture("target_deps");
    let expected = load_expected_report("target_deps");

    assert_eq!(
        exit_code, 2,
        "target_deps fixture should exit with 2 (fail)"
    );
    assert_reports_match(report, expected, "target_deps");
}

#[test]
fn fixture_inline_suppression_fails_with_unsuppressed_dependency() {
    let (exit_code, report) = run_check_on_fixture("inline_suppression");
    let expected = load_expected_report("inline_suppression");

    assert_eq!(
        exit_code, 2,
        "inline_suppression fixture should still fail due to unsuppressed tokio wildcard"
    );
    assert_reports_match(report, expected, "inline_suppression");
}

// ============================================================================
// CLI behavior tests
// ============================================================================

#[test]
fn check_command_creates_output_file() {
    let fixture_path = fixtures_dir().join("clean");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("subdir").join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .success();

    assert!(report_path.exists(), "Report file should be created");
}

#[test]
fn check_with_markdown_output() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");
    let md_path = temp_dir.path().join("report.md");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .arg("--write-markdown")
        .arg("--markdown-out")
        .arg(&md_path)
        .assert()
        .code(2);

    assert!(report_path.exists(), "JSON report should be created");
    assert!(md_path.exists(), "Markdown report should be created");

    let md_content =
        std::fs::read_to_string(&md_path).expect("failed to read generated markdown file");
    assert!(
        md_content.to_lowercase().contains("fail"),
        "Markdown should contain verdict"
    );
    assert!(
        md_content.contains("wildcard"),
        "Markdown should contain finding"
    );
}

#[test]
fn check_with_out_dir_uses_default_layout() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let out_dir = temp_dir.path().join("custom-layout");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--out-dir")
        .arg(&out_dir)
        .arg("--write-markdown")
        .assert()
        .code(2);

    assert!(
        out_dir.join("report.json").exists(),
        "report should be written to out-dir/report.json"
    );
    assert!(
        out_dir.join("comment.md").exists(),
        "markdown should be written to out-dir/comment.md"
    );
}

#[test]
fn check_with_junit_and_jsonl_outputs() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");
    let junit_path = temp_dir.path().join("report.junit.xml");
    let jsonl_path = temp_dir.path().join("report.jsonl");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .arg("--write-junit")
        .arg("--junit-out")
        .arg(&junit_path)
        .arg("--write-jsonl")
        .arg("--jsonl-out")
        .arg(&jsonl_path)
        .assert()
        .code(2);

    assert!(report_path.exists(), "JSON report should be created");
    assert!(junit_path.exists(), "JUnit report should be created");
    assert!(jsonl_path.exists(), "JSONL report should be created");

    let report_text = std::fs::read_to_string(&report_path).expect("read report");
    let report: Value = serde_json::from_str(&report_text).expect("parse report");
    let artifacts = report["artifacts"].as_array().expect("artifacts array");
    assert!(
        artifacts
            .iter()
            .any(|a| { a["path"].as_str() == Some(junit_path.to_string_lossy().as_ref()) }),
        "report should include junit artifact pointer"
    );
    assert!(
        artifacts
            .iter()
            .any(|a| { a["path"].as_str() == Some(jsonl_path.to_string_lossy().as_ref()) }),
        "report should include jsonl artifact pointer"
    );
}

#[test]
fn md_command_renders_from_report() {
    // First, create a report
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    // Then, render markdown from it
    let output = depguard_cmd()
        .arg("md")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run md command");

    assert!(output.status.success(), "md command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.to_lowercase().contains("fail"),
        "Should contain verdict"
    );
}

#[test]
fn annotations_command_renders_gha_format() {
    // First, create a report
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    // Then, render annotations from it
    let output = depguard_cmd()
        .arg("annotations")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run annotations command");

    assert!(
        output.status.success(),
        "annotations command should succeed"
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("::error"),
        "Should contain GHA error annotation format"
    );
}

#[test]
fn sarif_command_renders_sarif_json() {
    // First, create a report
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    // Then, render SARIF from it
    let output = depguard_cmd()
        .arg("sarif")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run sarif command");

    assert!(output.status.success(), "sarif command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("\"version\": \"2.1.0\""));
    assert!(stdout.contains("\"runs\""));
    assert!(stdout.contains("\"ruleId\""));
}

#[test]
fn junit_command_renders_junit_xml() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    let output = depguard_cmd()
        .arg("junit")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run junit command");

    assert!(output.status.success(), "junit command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("<testsuite"));
    assert!(stdout.contains("<testcase"));
}

#[test]
fn jsonl_command_renders_json_lines() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    let output = depguard_cmd()
        .arg("jsonl")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run jsonl command");

    assert!(output.status.success(), "jsonl command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("\"kind\":\"finding\""));
    assert!(stdout.contains("\"kind\":\"summary\""));
}

#[test]
fn markdown_matches_golden_fixture() {
    let fixture_path = fixtures_dir().join("wildcards");
    let expected_path = fixture_path.join("expected.comment.md");
    let expected = std::fs::read_to_string(&expected_path).expect("read expected markdown");

    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    let output = depguard_cmd()
        .arg("md")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run md command");

    assert!(output.status.success(), "md command should succeed");
    let actual = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
    let expected = expected.replace("\r\n", "\n");
    assert_eq!(actual, expected, "Markdown output did not match golden");
}

#[test]
fn annotations_match_golden_fixture() {
    let fixture_path = fixtures_dir().join("wildcards");
    let expected_path = fixture_path.join("expected.annotations.txt");
    let expected = std::fs::read_to_string(&expected_path).expect("read expected annotations");

    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .code(2);

    let output = depguard_cmd()
        .arg("annotations")
        .arg("--report")
        .arg(&report_path)
        .output()
        .expect("Failed to run annotations command");

    assert!(
        output.status.success(),
        "annotations command should succeed"
    );
    let actual = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
    let expected = expected.replace("\r\n", "\n");
    assert_eq!(actual, expected, "Annotations output did not match golden");
}

#[test]
fn explain_command_shows_check_info() {
    let output = depguard_cmd()
        .arg("explain")
        .arg("deps.no_wildcards")
        .output()
        .expect("Failed to run explain command");

    assert!(output.status.success(), "explain command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("wildcard"), "Should explain wildcard check");
}

#[test]
fn explain_command_shows_code_info() {
    let output = depguard_cmd()
        .arg("explain")
        .arg("wildcard_version")
        .output()
        .expect("Failed to run explain command");

    assert!(output.status.success(), "explain command should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("wildcard") || stdout.contains("*"),
        "Should explain wildcard code"
    );
}

#[test]
fn explain_unknown_returns_error() {
    depguard_cmd()
        .arg("explain")
        .arg("nonexistent_check")
        .assert()
        .failure();
}

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

#[test]
fn missing_repo_root_returns_error() {
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let report_path = temp_dir.path().join("report.json");
    let missing_root = temp_dir.path().join("missing-repo");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&missing_root)
        .arg("check")
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .failure();
}

#[test]
fn baseline_command_generates_file_and_suppresses_known_findings() {
    let fixture_path = fixtures_dir().join("wildcards");
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let baseline_path = temp_dir.path().join(".depguard-baseline.json");
    let report_path = temp_dir.path().join("report.json");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("baseline")
        .arg("--output")
        .arg(&baseline_path)
        .assert()
        .success();

    assert!(baseline_path.exists(), "baseline file should be created");

    depguard_cmd()
        .arg("--repo-root")
        .arg(&fixture_path)
        .arg("check")
        .arg("--baseline")
        .arg(&baseline_path)
        .arg("--report-out")
        .arg(&report_path)
        .assert()
        .success();

    let report_text = std::fs::read_to_string(&report_path).expect("read report");
    let report: Value = serde_json::from_str(&report_text).expect("parse report");
    assert_eq!(
        report["verdict"]["status"].as_str(),
        Some("pass"),
        "baseline should suppress existing findings"
    );
    assert_eq!(
        report["findings"].as_array().map(|v| v.len()),
        Some(0),
        "suppressed report should have no findings"
    );
    assert!(
        report["verdict"]["counts"]["suppressed"]
            .as_u64()
            .unwrap_or(0)
            >= 1
    );
}

#[test]
fn report_schema_v2_validates() {
    let (_exit, report) = run_check_on_fixture("clean");
    validate_report_schema(&report, "depguard.report.v2.json");
}

#[test]
fn report_schema_v1_validates() {
    let (_exit, report) = run_check_on_fixture_args("clean", &["--report-version", "v1"]);
    validate_report_schema(&report, "depguard.report.v1.json");
}

#[test]
fn report_schema_envelope_validates() {
    let (_exit, report) = run_check_on_fixture("clean");
    validate_report_schema(&report, "receipt.envelope.v1.json");
}
