//! End-to-end CLI tests. Each test builds an isolated PATH directory
//! with a stub executable and a TOML manifest, then invokes the real
//! `pathlint` binary and asserts on stdout / exit code.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const BIN: &str = env!("CARGO_BIN_EXE_pathlint");

fn run(rules: &Path, path_value: &str) -> (i32, String, String) {
    run_with_args(rules, path_value, &[])
}

fn run_with_args(rules: &Path, path_value: &str, extra: &[&str]) -> (i32, String, String) {
    let mut cmd = Command::new(BIN);
    cmd.arg("--config")
        .arg(rules)
        .arg("--no-glyphs")
        .env("PATH", path_value)
        .env_remove("XDG_CONFIG_HOME");
    for a in extra {
        cmd.arg(a);
    }
    let out = cmd.output().expect("failed to run pathlint binary");
    let code = out.status.code().unwrap_or(-1);
    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
    (code, stdout, stderr)
}

/// Place an executable stub named `command` inside `dir`. On Windows
/// the stub is a `.cmd` file (PATHEXT picks it up); on Unix it is a
/// shell script with the executable bit set.
fn stub(dir: &Path, command: &str) -> PathBuf {
    fs::create_dir_all(dir).unwrap();
    if cfg!(windows) {
        let p = dir.join(format!("{command}.cmd"));
        fs::write(&p, "@echo stub\r\n").unwrap();
        p
    } else {
        let p = dir.join(command);
        fs::write(&p, "#!/bin/sh\necho stub\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = fs::metadata(&p).unwrap().permissions();
            perm.set_mode(0o755);
            fs::set_permissions(&p, perm).unwrap();
        }
        p
    }
}

fn write_rules(dir: &Path, body: &str) -> PathBuf {
    let p = dir.join("pathlint.toml");
    fs::write(&p, body).unwrap();
    p
}

fn join_path(entries: &[&Path]) -> String {
    let sep = if cfg!(windows) { ";" } else { ":" };
    entries
        .iter()
        .map(|p| p.to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join(sep)
}

fn os_tag() -> &'static str {
    if cfg!(windows) {
        "windows"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else {
        "linux"
    }
}

fn key_for_current_os() -> &'static str {
    if cfg!(windows) {
        "windows"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else {
        "linux"
    }
}

#[test]
fn check_reports_ok_when_command_resolves_under_preferred_source() {
    let tmp = tempfile::tempdir().unwrap();
    let preferred = tmp.path().join("preferred");
    stub(&preferred, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["preferred_src"]

[source.preferred_src]
{key} = "{path}"
"#,
        path = preferred.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&preferred]));
    assert_eq!(code, 0, "stdout was: {stdout}");
    assert!(stdout.contains("OK"), "stdout was: {stdout}");
    assert!(stdout.contains("tooly"), "stdout was: {stdout}");
}

#[test]
fn check_reports_ng_when_resolved_from_avoided_source() {
    let tmp = tempfile::tempdir().unwrap();
    let avoid_dir = tmp.path().join("avoid");
    stub(&avoid_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]
avoid   = ["bad"]

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = avoid_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&avoid_dir]));
    assert_eq!(code, 1, "stdout was: {stdout}");
    assert!(stdout.contains("NG"), "stdout was: {stdout}");
    assert!(stdout.contains("tooly"), "stdout was: {stdout}");
}

#[test]
fn check_reports_not_found_unless_optional() {
    let tmp = tempfile::tempdir().unwrap();
    let empty_dir = tmp.path().join("empty");
    fs::create_dir_all(&empty_dir).unwrap();

    let body = r#"
[[expect]]
command = "definitely_no_such_tool_xyz"
"#;
    let rules = write_rules(tmp.path(), body);

    let (code, stdout, _) = run(&rules, &join_path(&[&empty_dir]));
    assert_eq!(code, 1, "stdout was: {stdout}");
    assert!(stdout.contains("not found on PATH"), "stdout was: {stdout}");
}

#[test]
fn optional_missing_command_is_skipped() {
    let tmp = tempfile::tempdir().unwrap();
    let empty_dir = tmp.path().join("empty");
    fs::create_dir_all(&empty_dir).unwrap();

    let body = r#"
[[expect]]
command = "definitely_no_such_tool_xyz"
optional = true
"#;
    let rules = write_rules(tmp.path(), body);

    let (code, stdout, _) = run(&rules, &join_path(&[&empty_dir]));
    assert_eq!(code, 0, "stdout was: {stdout}");
    assert!(stdout.contains("skip"), "stdout was: {stdout}");
}

#[test]
fn os_filter_excludes_other_os() {
    let tmp = tempfile::tempdir().unwrap();
    let some_dir = tmp.path().join("d");
    fs::create_dir_all(&some_dir).unwrap();

    let other = if os_tag() == "windows" {
        "linux"
    } else {
        "windows"
    };
    let body = format!(
        r#"
[[expect]]
command = "definitely_no_such_tool_xyz"
os      = ["{other}"]
"#,
    );
    let rules = write_rules(tmp.path(), &body);

    // Without --verbose the n/a line is hidden, so exit must still be 0.
    let (code, stdout, _) = run(&rules, &join_path(&[&some_dir]));
    assert_eq!(code, 0, "stdout was: {stdout}");
}

#[test]
fn config_error_yields_exit_2() {
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("d");
    stub(&dir, "tooly");

    let body = r#"
[[expect]]
command = "tooly"
prefer  = ["nonexistent_source"]
"#;
    let rules = write_rules(tmp.path(), body);

    let (code, stdout, _) = run(&rules, &join_path(&[&dir]));
    assert_eq!(code, 2, "stdout was: {stdout}");
    assert!(
        stdout.contains("undefined source name"),
        "stdout was: {stdout}"
    );
}

#[test]
fn lazygit_resolves_from_any_of_multiple_preferred_sources() {
    // `lazygit` ships via cargo, winget, and mise. The user does not
    // care which one wins, only that one of them does. prefer is a set
    // of acceptable sources; matching any one is OK.
    let tmp = tempfile::tempdir().unwrap();
    let cargo_dir = tmp.path().join("cargo_bin");
    let winget_dir = tmp.path().join("winget_links");
    let mise_dir = tmp.path().join("mise_shims");
    // Only winget actually contains the binary on this run.
    stub(&winget_dir, "lazygit");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "lazygit"
prefer  = ["my_cargo", "my_winget", "my_mise"]

[source.my_cargo]
{key} = "{cargo}"

[source.my_winget]
{key} = "{winget}"

[source.my_mise]
{key} = "{mise}"
"#,
        cargo = cargo_dir.display().to_string().replace('\\', "/"),
        winget = winget_dir.display().to_string().replace('\\', "/"),
        mise = mise_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    // Only winget_dir is on PATH; cargo_dir / mise_dir are empty
    // directories. lazygit must resolve from winget and the run is OK.
    fs::create_dir_all(&cargo_dir).unwrap();
    fs::create_dir_all(&mise_dir).unwrap();
    let (code, stdout, _) = run(&rules, &join_path(&[&cargo_dir, &winget_dir, &mise_dir]));
    assert_eq!(code, 0, "stdout was: {stdout}");
    assert!(stdout.contains("OK"), "stdout was: {stdout}");
    assert!(
        stdout.contains("my_winget"),
        "matched source should be reported: {stdout}"
    );
}

#[test]
fn mixed_outcomes_yield_exit_1_and_print_each_line() {
    // OK + NG + skip in one TOML — exit 1 because at least one NG.
    let tmp = tempfile::tempdir().unwrap();
    let good_dir = tmp.path().join("good");
    let bad_dir = tmp.path().join("bad");
    stub(&good_dir, "alpha");
    stub(&bad_dir, "beta");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "alpha"
prefer  = ["good"]

[[expect]]
command = "beta"
prefer  = ["good"]
avoid   = ["bad"]

[[expect]]
command = "missing_optional_xyz"
optional = true

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = good_dir.display().to_string().replace('\\', "/"),
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&good_dir, &bad_dir]));
    assert_eq!(code, 1, "stdout was: {stdout}");
    assert!(
        stdout.contains("OK") && stdout.contains("alpha"),
        "alpha OK missing: {stdout}"
    );
    assert!(
        stdout.contains("NG") && stdout.contains("beta"),
        "beta NG missing: {stdout}"
    );
    assert!(
        stdout.contains("skip") && stdout.contains("missing_optional_xyz"),
        "skip missing: {stdout}"
    );
}

#[test]
fn quiet_mode_hides_ok_and_skip_lines() {
    let tmp = tempfile::tempdir().unwrap();
    let good_dir = tmp.path().join("good");
    let bad_dir = tmp.path().join("bad");
    stub(&good_dir, "alpha");
    stub(&bad_dir, "beta");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "alpha"
prefer  = ["good"]

[[expect]]
command = "beta"
prefer  = ["good"]
avoid   = ["bad"]

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = good_dir.display().to_string().replace('\\', "/"),
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run_with_args(&rules, &join_path(&[&good_dir, &bad_dir]), &["--quiet"]);
    assert_eq!(code, 1, "stdout was: {stdout}");
    assert!(!stdout.contains("alpha"), "OK line leaked: {stdout}");
    assert!(stdout.contains("beta"), "NG line missing: {stdout}");
}

#[test]
fn verbose_shows_not_applicable_lines() {
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("d");
    stub(&dir, "alpha");

    let other = if cfg!(windows) { "linux" } else { "windows" };
    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "alpha"
prefer  = ["good"]

[[expect]]
command = "alpha"
prefer  = ["good"]
os      = ["{other}"]

[source.good]
{key} = "{path}"
"#,
        path = dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (default_code, default_stdout, _) = run(&rules, &join_path(&[&dir]));
    assert_eq!(default_code, 0);
    assert!(
        !default_stdout.contains("n/a"),
        "n/a hidden by default: {default_stdout}"
    );

    let (verbose_code, verbose_stdout, _) =
        run_with_args(&rules, &join_path(&[&dir]), &["--verbose"]);
    assert_eq!(verbose_code, 0);
    assert!(
        verbose_stdout.contains("n/a"),
        "verbose should show n/a: {verbose_stdout}"
    );
}

#[test]
fn os_branching_applies_only_current_os_rules() {
    // Same command name with two os-tagged expectations; only the one
    // matching the current OS is evaluated. PRD §8 python example.
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("bin");
    stub(&dir, "python");

    let me = os_tag();
    let other = if me == "windows" { "linux" } else { "windows" };
    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "python"
prefer  = ["mine"]
os      = ["{me}"]

[[expect]]
command = "python"
# This rule references an undefined source — it would ConfigError if
# evaluated. The os filter must keep it from being evaluated at all.
prefer  = ["never_defined"]
os      = ["{other}"]

[source.mine]
{key} = "{path}"
"#,
        path = dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&dir]));
    assert_eq!(code, 0, "stdout was: {stdout}");
    assert!(
        !stdout.contains("ERR"),
        "other-os rule must not produce a config error: {stdout}"
    );
}

#[test]
fn missing_config_path_is_reported_with_exit_2() {
    let tmp = tempfile::tempdir().unwrap();
    let nope = tmp.path().join("does_not_exist.toml");
    let (code, _stdout, stderr) = run(&nope, "");
    assert_eq!(code, 2);
    assert!(stderr.contains("--config"), "stderr was: {stderr}");
}

#[test]
fn require_catalog_below_embedded_passes() {
    // Embedded catalog_version is 1 in 0.0.3. A rules file requesting
    // require_catalog = 1 should run normally, not error.
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("d");
    fs::create_dir_all(&dir).unwrap();
    let body = r#"
require_catalog = 1
"#;
    let rules = write_rules(tmp.path(), body);
    let (code, _stdout, _) = run(&rules, &join_path(&[&dir]));
    assert_eq!(code, 0, "require_catalog = embedded must succeed");
}

#[test]
fn require_catalog_above_embedded_fails_with_exit_2() {
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("d");
    fs::create_dir_all(&dir).unwrap();
    let body = r#"
require_catalog = 9999
"#;
    let rules = write_rules(tmp.path(), body);
    let (code, _stdout, stderr) = run(&rules, &join_path(&[&dir]));
    assert_eq!(code, 2);
    assert!(
        stderr.contains("catalog_version") && stderr.contains("9999"),
        "stderr was: {stderr}"
    );
}

#[test]
fn explain_flag_expands_ng_into_multiline_diagnosis_and_hint() {
    // `pathlint check --explain` swaps the one-line NG detail for a
    // multi-line breakdown. The new rows must include `diagnosis:`,
    // `hint:`, and a `pathlint trace ...` follow-up suggestion so the
    // user knows what to do next.
    let tmp = tempfile::tempdir().unwrap();
    let bad_dir = tmp.path().join("bad");
    stub(&bad_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run_with_args(&rules, &join_path(&[&bad_dir]), &["check", "--explain"]);
    assert_eq!(code, 1, "stdout was: {stdout}");
    assert!(stdout.contains("diagnosis:"), "stdout was: {stdout}");
    assert!(stdout.contains("hint:"), "stdout was: {stdout}");
    assert!(
        stdout.contains("pathlint trace tooly"),
        "stdout was: {stdout}"
    );
}

#[test]
fn check_json_emits_array_with_status_and_diagnosis_for_ng() {
    let tmp = tempfile::tempdir().unwrap();
    let bad_dir = tmp.path().join("bad");
    stub(&bad_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run_with_args(&rules, &join_path(&[&bad_dir]), &["check", "--json"]);
    assert_eq!(code, 1, "stdout: {stdout}");
    let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect(&stdout);
    assert!(v.is_array(), "stdout: {stdout}");
    assert_eq!(v[0]["command"], "tooly");
    assert_eq!(v[0]["kind"], "ng_wrong_source");
    assert_eq!(v[0]["diagnosis"]["kind"], "wrong_source");
    assert_eq!(v[0]["diagnosis"]["matched"][0], "bad");
}

#[test]
fn severity_warn_keeps_run_passing_with_warn_tag() {
    // CI scenario: a `prefer = ["cargo"]` rule with severity = "warn"
    // surfaces the diagnostic but does not block the build.
    let tmp = tempfile::tempdir().unwrap();
    let bad_dir = tmp.path().join("bad");
    stub(&bad_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command  = "tooly"
prefer   = ["good"]
severity = "warn"

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&bad_dir]));
    assert_eq!(code, 0, "warn severity must keep exit 0; stdout: {stdout}");
    assert!(stdout.contains("warn"), "warn tag missing: {stdout}");
    assert!(stdout.contains("tooly"), "command missing: {stdout}");
}

#[test]
fn severity_warn_visible_in_check_json() {
    // The JSON view must surface severity so CI gates can be
    // built on it (e.g. "warn lines OK, error lines fail").
    let tmp = tempfile::tempdir().unwrap();
    let bad_dir = tmp.path().join("bad");
    stub(&bad_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command  = "tooly"
prefer   = ["good"]
severity = "warn"

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run_with_args(&rules, &join_path(&[&bad_dir]), &["check", "--json"]);
    assert_eq!(
        code, 0,
        "warn severity must keep exit 0 even in JSON; stdout: {stdout}"
    );
    let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect(&stdout);
    assert_eq!(v[0]["severity"], "warn");
    assert_eq!(v[0]["kind"], "ng_wrong_source");
}

#[test]
fn explain_off_keeps_one_line_detail_for_ng() {
    // Sanity: without --explain the detail is still the one-liner.
    let tmp = tempfile::tempdir().unwrap();
    let bad_dir = tmp.path().join("bad");
    stub(&bad_dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]

[source.good]
{key} = "{good}"

[source.bad]
{key} = "{bad}"
"#,
        good = "/this/path/does/not/exist",
        bad = bad_dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (_code, stdout, _) = run(&rules, &join_path(&[&bad_dir]));
    assert!(
        !stdout.contains("diagnosis:"),
        "explain block leaked into default output: {stdout}"
    );
}

#[test]
fn kind_executable_passes_for_real_stub() {
    // The stub() helper writes a real executable file; kind =
    // "executable" should not change OK to NG for it.
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("good");
    stub(&dir, "tooly");

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]
kind    = "executable"

[source.good]
{key} = "{path}"
"#,
        path = dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);

    let (code, stdout, _) = run(&rules, &join_path(&[&dir]));
    assert_eq!(code, 0, "stdout: {stdout}");
    assert!(stdout.contains("OK"), "stdout: {stdout}");
}

#[test]
fn kind_executable_flags_directory_shadow_in_real_run() {
    // PATH entry contains a *directory* named like a binary. On
    // Windows this needs PATHEXT to think it's executable, so we
    // drop a `.cmd` directory which won't actually run; on Unix
    // a plain directory called `tooly` is enough.
    //
    // Skip on Windows because resolve() requires PATHEXT to flag
    // the entry as a candidate, and a directory with `.cmd` on
    // the end is awkward to construct safely. Unix proves the
    // mechanism end-to-end.
    if cfg!(windows) {
        return;
    }
    let tmp = tempfile::tempdir().unwrap();
    let dir = tmp.path().join("path_root");
    fs::create_dir_all(&dir).unwrap();
    // Create a directory that resolve() will pick up because it
    // matches the command name and (on Unix) has the executable
    // bit. Directories normally do have the +x bit set.
    let shadow = dir.join("tooly");
    fs::create_dir_all(&shadow).unwrap();

    let key = key_for_current_os();
    let body = format!(
        r#"
[[expect]]
command = "tooly"
prefer  = ["good"]
kind    = "executable"

[source.good]
{key} = "{path}"
"#,
        path = dir.display().to_string().replace('\\', "/"),
    );
    let rules = write_rules(tmp.path(), &body);
    let (code, stdout, _) = run(&rules, &join_path(&[&dir]));
    // We expect the resolver to either pick up the directory (then
    // R2 escalates to NG) or to skip it (then R1 says
    // not_found -> NG either way). Either outcome should yield
    // exit 1; we additionally check the directory message when
    // it's the shape-check that fired.
    assert_eq!(code, 1, "stdout: {stdout}");
    if stdout.contains("not executable") {
        assert!(stdout.contains("directory"), "stdout: {stdout}");
    }
}
