use std::time::{Duration, Instant};

use serde::Deserialize;
use tokio::io::AsyncWriteExt;

use crate::config::HookSpec;
use crate::event::HookEventEnvelope;
use crate::result::HookDecision;

use super::{HookRunnerResult, RunContext};

/// Maximum bytes to capture from hook stdout or stderr (64 KB).
const MAX_OUTPUT_BYTES: usize = 64 * 1024;

/// Exit code that a blocking hook uses to signal an explicit deny.
const DENY_EXIT_CODE: i32 = 2;

/// The JSON result structure expected from blocking hooks.
#[derive(Debug, Deserialize)]
struct HookOutput {
    decision: String,
    #[serde(default)]
    reason: Option<String>,
}

/// Run a single hook command.
///
/// Spawns the command as a child process, writes the envelope JSON on stdin,
/// reads stdout/stderr with buffer limits, enforces the timeout, and parses
/// the result.
pub async fn run_command_hook(
    spec: &HookSpec,
    envelope: &HookEventEnvelope,
    ctx: &RunContext<'_>,
    is_blocking: bool,
) -> (HookRunnerResult, Duration) {
    let start = Instant::now();

    let Some(ref command) = spec.command else {
        return (
            HookRunnerResult::Failed("command hook has no 'command' field".into()),
            start.elapsed(),
        );
    };
    let command_str = command.to_string_lossy();

    // Serialize envelope to JSON.
    let stdin_json = match serde_json::to_string(envelope) {
        Ok(j) => j,
        Err(e) => {
            let elapsed = start.elapsed();
            return (
                HookRunnerResult::Failed(format!("failed to serialize envelope: {e}")),
                elapsed,
            );
        }
    };

    // Check opt-in debug logging.
    let debug_payloads = std::env::var("KIGI_HOOK_DEBUG").is_ok_and(|v| v == "1");
    if debug_payloads {
        tracing::trace!(
            hook_name = %spec.name,
            stdin_bytes = stdin_json.len(),
            "hook stdin payload"
        );
    }

    // Determine how to spawn the command.
    //
    // If the command contains shell metacharacters (spaces, pipes, &&, ||,
    // redirects, semicolons, env-var refs) or starts with `~` (tilde
    // expansion), run it through `sh -c` so that shell command strings
    // from compatible configs work correctly.
    //
    // Otherwise, treat it as a direct executable path (resolve relative
    // paths from the hook file's directory).
    let is_shell_command = command_str.contains(' ')
        || command_str.contains('|')
        || command_str.contains('&')
        || command_str.contains(';')
        || command_str.contains('>')
        || command_str.contains('<')
        || command_str.contains('$')
        || command_str.starts_with('~');

    let mut cmd = if is_shell_command {
        // Refuse to spawn when the command interpolates an env var that
        // we can't resolve from any of: the runner's always-set vars, the
        // per-hook extra_env (plugin vars), or Kigi's own process env. The
        // alternative is letting sh expand the var to empty -- which then
        // produces a broken command, exits 127, and (for PreToolUse hooks)
        // fails closed with an opaque "exit code 127" reason. Catching it
        // here gives the model a clear actionable error and skips the
        // wasted fork+exec.
        //
        // Vars with a parameter-expansion modifier (`${VAR:-default}`,
        // `${VAR-x}`, `${VAR:=x}`, `${VAR:?msg}`, `${VAR:+x}`, etc.) are
        // NOT flagged: the user has explicitly handled the unset case.
        let unresolved = find_unresolved_env_vars(&command_str, &spec.extra_env);
        if !unresolved.is_empty() {
            let elapsed = start.elapsed();
            let list = unresolved
                .iter()
                .map(|v| format!("${{{v}}}"))
                .collect::<Vec<_>>()
                .join(", ");
            return (
                HookRunnerResult::Failed(format!(
                    "hook not executed: required env var(s) not set: {list}"
                )),
                elapsed,
            );
        }
        #[cfg(unix)]
        {
            let mut c = tokio::process::Command::new("sh");
            c.arg("-c").arg(command_str.as_ref());
            c
        }
        #[cfg(not(unix))]
        {
            let inv = kigi_config::shell::shell_command_argv(&command_str);
            let mut c = tokio::process::Command::new(&inv.program);
            c.args(&inv.args).envs(inv.env);
            c
        }
    } else {
        // Direct executable: resolve relative paths from source_dir.
        let command_path = if command.is_absolute() {
            command.clone()
        } else {
            spec.source_dir.join(command)
        };
        if !command_path.exists() {
            let elapsed = start.elapsed();
            return (
                HookRunnerResult::Failed(format!("command not found: {}", command_path.display())),
                elapsed,
            );
        }
        tokio::process::Command::new(command_path)
    };

    // Detach from controlling terminal so child processes (e.g. GPG pinentry)
    // cannot open /dev/tty and corrupt the TUI display. Delegates to
    // `kigi_tools::util::detach_command`: Unix uses the same setsid /
    // EPERM→setpgid pre_exec path as before; Windows sets CREATE_NO_WINDOW only
    // (DETACHED_PROCESS is intentionally omitted — it breaks stdio inheritance).
    kigi_tools::util::detach_command(&mut cmd);

    // Spawn the child process.
    //
    // SECURITY: env-var precedence at spawn time. `Command::envs(&map)` runs
    // AFTER any preceding `.env(...)` calls and silently overrides them, so
    // the order matters: we MUST apply user/plugin `extra_env` FIRST and
    // the runner-injected vars LAST. Otherwise a user JSON hook (or a
    // plugin) can spoof `KIGI_HOOK_EVENT`, `KIGI_HOOK_NAME`, `KIGI_SESSION_ID`,
    // `KIGI_WORKSPACE_ROOT`, or `CLAUDE_PROJECT_DIR` -- which are the
    // identity/event signals a hook script consumes for policy and audit.
    // See the `runner_injected_vars_override_extra_env_at_spawn`
    // regression test in `tests/integration.rs` and the rustdoc on
    // `HookSpec::extra_env`.
    let mut child = match cmd
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .current_dir(ctx.workspace_root)
        // 1. user/plugin extra_env first (lowest precedence).
        .envs(&spec.extra_env)
        // 2. runner-injected vars last (highest precedence -- always win).
        .env("KIGI_HOOK_EVENT", envelope.hook_event_name.to_string())
        .env("KIGI_HOOK_NAME", &spec.name)
        .env("KIGI_SESSION_ID", ctx.session_id)
        .env("KIGI_WORKSPACE_ROOT", ctx.workspace_root)
        // Compatibility alias for external hooks that read this env name.
        // Same value as `KIGI_WORKSPACE_ROOT`; native `.kigi` hooks should use
        // `KIGI_WORKSPACE_ROOT`.
        .env("CLAUDE_PROJECT_DIR", ctx.workspace_root)
        .kill_on_drop(true)
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            let elapsed = start.elapsed();
            return (
                HookRunnerResult::Failed(format!("failed to spawn command: {e}")),
                elapsed,
            );
        }
    };

    // Write stdin and close.
    if let Some(mut stdin) = child.stdin.take() {
        let _ = stdin.write_all(stdin_json.as_bytes()).await;
        drop(stdin);
    }

    // Wait with timeout.
    let timeout = Duration::from_millis(spec.timeout_ms);
    let result = tokio::time::timeout(timeout, child.wait_with_output()).await;

    let elapsed = start.elapsed();

    match result {
        Err(_) => {
            // Timeout — kill_on_drop handles cleanup.
            (
                HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)),
                elapsed,
            )
        }
        Ok(Err(e)) => (
            HookRunnerResult::Failed(format!("command execution failed: {e}")),
            elapsed,
        ),
        Ok(Ok(output)) => {
            let exit_code = output.status.code().unwrap_or(-1);

            // Truncate stdout/stderr to buffer limits.
            let stdout = truncate_output(&output.stdout);
            let stderr = truncate_output(&output.stderr);

            if !stderr.is_empty() {
                tracing::debug!(
                    hook_name = %spec.name,
                    stderr_bytes = stderr.len(),
                    "hook stderr output captured"
                );
            }

            if debug_payloads {
                tracing::trace!(
                    hook_name = %spec.name,
                    stdout_bytes = stdout.len(),
                    "hook stdout payload"
                );
            }

            tracing::debug!(
                hook_name = %spec.name,
                exit_code,
                stdout_bytes = stdout.len(),
                stderr_bytes = stderr.len(),
                elapsed_ms = elapsed.as_millis() as u64,
                "hook command completed"
            );

            if !is_blocking {
                if exit_code == 0 {
                    return (HookRunnerResult::Success, elapsed);
                }
                return (
                    HookRunnerResult::Failed(format!("exit code {exit_code}")),
                    elapsed,
                );
            }

            // Blocking hook: parse decision from stdout.
            parse_blocking_result(&stdout, exit_code, &spec.name, elapsed)
        }
    }
}

/// Env vars the runner sets unconditionally on every spawned hook.
///
/// Used by:
///
/// * [`find_unresolved_env_vars`] to avoid flagging vars that *are* set
///   by the runner itself,
/// * [`crate::config::parse_hook_file`] and the plugin adapter to strip
///   user-supplied attempts to override these keys via the JSON `env`
///   map (those attempts would be silently ignored by the spawn-time
///   precedence ordering anyway, but stripping them at load time gives
///   users a clear "ignored, reserved key" warning).
pub(crate) const RUNNER_ALWAYS_SET_ENV: &[&str] = &[
    "KIGI_HOOK_EVENT",
    "KIGI_HOOK_NAME",
    "KIGI_SESSION_ID",
    "KIGI_WORKSPACE_ROOT",
    "CLAUDE_PROJECT_DIR",
];

/// Parse `command_str` for `${VAR}` and `$VAR` references and return the
/// names that aren't resolvable from any of:
///
/// * the runner's always-set env vars (see [`RUNNER_ALWAYS_SET_ENV`]),
/// * the per-hook `extra_env` map (set by the plugin adapter for plugin
///   hooks),
/// * the Kigi process's own environment (which is inherited by the child),
/// * local shell assignments inside the command itself (e.g. an
///   `INPUT=$(cat)` earlier in the string defines `INPUT` for the rest of
///   the command).
///
/// Names that appear inside a parameter-expansion form with a default,
/// fallback, or substitution modifier (`${VAR:-x}`, `${VAR-x}`, `${VAR:=x}`,
/// `${VAR:?msg}`, `${VAR:+x}`, `${VAR%pat}`, `${VAR#pat}`, `${VAR/pat/repl}`,
/// `${VAR:offset}`) are deliberately NOT flagged: the user has explicitly
/// handled the unset case in the shell expression, so the runner shouldn't
/// second-guess them.
///
/// The returned list is sorted and de-duplicated. Names are bare (no `$` or
/// `{}`).
fn find_unresolved_env_vars(
    command_str: &str,
    extra_env: &std::collections::HashMap<String, String>,
) -> Vec<String> {
    let locally_assigned = find_local_shell_assignments(command_str);
    let mut out: Vec<String> = Vec::new();
    // Delegate the byte-walking parser to the shared
    // `iter_env_var_references` helper in `env_expand`. We only need to
    // consider references where the user did NOT supply a parameter-
    // expansion modifier (those explicitly handle the unset case).
    for r in crate::env_expand::iter_env_var_references(command_str) {
        if r.name.is_empty() || r.has_modifier {
            continue;
        }
        if RUNNER_ALWAYS_SET_ENV.contains(&r.name) {
            continue;
        }
        if extra_env.contains_key(r.name) {
            continue;
        }
        if std::env::var_os(r.name).is_some() {
            continue;
        }
        if locally_assigned.contains(r.name) {
            continue;
        }
        out.push(r.name.to_string());
    }
    out.sort();
    out.dedup();
    out
}

/// Find shell variable assignments within `command_str` so that subsequent
/// `${VAR}` references to those names aren't flagged as undefined.
///
/// Detects two patterns common in inline hook commands:
///
/// * Plain assignments at the start of a command position: `VAR=value`,
///   `VAR=$(cmd)`, `VAR="..."`. The identifier must follow either the
///   start of the string, whitespace, or a statement separator (`;`, `&`,
///   `|`, `\n`).
/// * `read VAR1 VAR2 ...` statements (very common pattern for consuming
///   stdin in hooks).
///
/// This is a deliberately small heuristic, not a full shell parser. It
/// errs on the side of treating an identifier as locally set; the
/// consequence of a false negative here is a false positive in
/// [`find_unresolved_env_vars`] (which is precisely what we're trying to
/// avoid). Callers who need to be sure can always use the parameter-
/// expansion default form (`${VAR:-}`).
fn find_local_shell_assignments(command_str: &str) -> std::collections::HashSet<String> {
    let mut names = std::collections::HashSet::new();
    let bytes = command_str.as_bytes();
    let mut i = 0;
    let is_statement_start = |idx: usize| -> bool {
        if idx == 0 {
            return true;
        }
        // Walk left over whitespace.
        let mut j = idx;
        while j > 0 {
            let c = bytes[j - 1];
            if c == b' ' || c == b'\t' {
                j -= 1;
                continue;
            }
            return matches!(c, b';' | b'&' | b'|' | b'\n' | b'(' | b'{');
        }
        true
    };
    while i < bytes.len() {
        let c = bytes[i];
        if !(c.is_ascii_alphabetic() || c == b'_') {
            i += 1;
            continue;
        }
        // Read the identifier.
        let start = i;
        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
            i += 1;
        }
        let ident = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
        if ident.is_empty() {
            continue;
        }
        // VAR= (assignment): identifier must be at a statement-start
        // boundary and immediately followed by '=' (no space).
        if i < bytes.len() && bytes[i] == b'=' && is_statement_start(start) {
            names.insert(ident.to_string());
            continue;
        }
        // `read VAR1 VAR2 ...`: collect every whitespace-separated bare
        // identifier following a `read` keyword on the same statement.
        if ident == "read" && is_statement_start(start) {
            // Skip whitespace, then read identifiers until we hit a
            // statement separator or end of string.
            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
                i += 1;
            }
            while i < bytes.len() {
                let c2 = bytes[i];
                if matches!(c2, b';' | b'&' | b'|' | b'\n' | b'<' | b'>') {
                    break;
                }
                if c2 == b' ' || c2 == b'\t' {
                    i += 1;
                    continue;
                }
                if c2 == b'-' {
                    // `read -r VAR` etc.: skip the option flag.
                    while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'\t' {
                        i += 1;
                    }
                    continue;
                }
                if !(c2.is_ascii_alphabetic() || c2 == b'_') {
                    break;
                }
                let s = i;
                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
                    i += 1;
                }
                let read_ident = std::str::from_utf8(&bytes[s..i]).unwrap_or("");
                if !read_ident.is_empty() {
                    names.insert(read_ident.to_string());
                }
            }
        }
    }
    names
}

/// Parse the result of a blocking hook from stdout and exit code.
fn parse_blocking_result(
    stdout: &str,
    exit_code: i32,
    hook_name: &str,
    elapsed: Duration,
) -> (HookRunnerResult, Duration) {
    // Try to parse JSON output first.
    let json_decision = if !stdout.trim().is_empty() {
        serde_json::from_str::<HookOutput>(stdout.trim()).ok()
    } else {
        None
    };

    // If we have valid JSON with a deny, prefer that over exit code.
    if let Some(ref output) = json_decision {
        if output.decision == "deny" {
            let reason = output
                .reason
                .clone()
                .unwrap_or_else(|| format!("denied by hook '{hook_name}'"));

            if exit_code != DENY_EXIT_CODE && exit_code != 0 {
                tracing::warn!(
                    hook_name,
                    exit_code,
                    "JSON decision is 'deny' but exit code is not 0 or 2 — using JSON decision"
                );
            }

            return (
                HookRunnerResult::Decision(HookDecision::Deny {
                    reason,
                    hook_name: hook_name.to_string(),
                }),
                elapsed,
            );
        }

        if output.decision == "allow" {
            if exit_code == DENY_EXIT_CODE {
                tracing::warn!(
                    hook_name,
                    "JSON decision is 'allow' but exit code is 2 — using JSON decision"
                );
            }
            return (HookRunnerResult::Decision(HookDecision::Allow), elapsed);
        }

        // Unknown decision value — treat as failure.
        return (
            HookRunnerResult::Failed(format!(
                "unknown decision value '{}' from hook '{hook_name}'",
                output.decision
            )),
            elapsed,
        );
    }

    // No valid JSON — fall back to exit code.
    match exit_code {
        0 => (HookRunnerResult::Decision(HookDecision::Allow), elapsed),
        DENY_EXIT_CODE => (
            HookRunnerResult::Decision(HookDecision::Deny {
                reason: format!("denied by hook '{hook_name}' (exit code {DENY_EXIT_CODE})"),
                hook_name: hook_name.to_string(),
            }),
            elapsed,
        ),
        _ => (
            HookRunnerResult::Failed(format!(
                "hook '{hook_name}' failed with exit code {exit_code}"
            )),
            elapsed,
        ),
    }
}

/// Truncate output bytes to MAX_OUTPUT_BYTES and convert to a lossy UTF-8 string.
fn truncate_output(bytes: &[u8]) -> String {
    if bytes.len() <= MAX_OUTPUT_BYTES {
        String::from_utf8_lossy(bytes).into_owned()
    } else {
        let mut truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_BYTES]).into_owned();
        truncated.push_str(" [truncated]");
        tracing::warn!(
            total_bytes = bytes.len(),
            max_bytes = MAX_OUTPUT_BYTES,
            "hook output truncated"
        );
        truncated
    }
}

/// Resolve the absolute command path for a hook spec.
///
/// Returns `None` for non-command handler types.
pub fn resolve_command_path(spec: &HookSpec) -> Option<std::path::PathBuf> {
    let command = spec.command.as_ref()?;
    if command.is_absolute() {
        Some(command.clone())
    } else {
        Some(spec.source_dir.join(command))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_allow_json() {
        let (result, _) =
            parse_blocking_result(r#"{"decision":"allow"}"#, 0, "test", Duration::ZERO);
        assert!(matches!(
            result,
            HookRunnerResult::Decision(HookDecision::Allow)
        ));
    }

    #[test]
    fn parse_deny_json() {
        let (result, _) = parse_blocking_result(
            r#"{"decision":"deny","reason":"bad command"}"#,
            2,
            "test",
            Duration::ZERO,
        );
        match result {
            HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => {
                assert_eq!(reason, "bad command");
            }
            other => panic!("expected Deny, got {other:?}"),
        }
    }

    #[test]
    fn parse_deny_json_without_reason() {
        let (result, _) =
            parse_blocking_result(r#"{"decision":"deny"}"#, 2, "my-hook", Duration::ZERO);
        match result {
            HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => {
                assert!(reason.contains("my-hook"));
            }
            other => panic!("expected Deny, got {other:?}"),
        }
    }

    #[test]
    fn fallback_to_exit_code_zero() {
        let (result, _) = parse_blocking_result("", 0, "test", Duration::ZERO);
        assert!(matches!(
            result,
            HookRunnerResult::Decision(HookDecision::Allow)
        ));
    }

    #[test]
    fn fallback_to_exit_code_deny() {
        let (result, _) = parse_blocking_result("", 2, "test", Duration::ZERO);
        assert!(matches!(
            result,
            HookRunnerResult::Decision(HookDecision::Deny { .. })
        ));
    }

    #[test]
    fn fallback_to_exit_code_failure() {
        let (result, _) = parse_blocking_result("", 1, "test", Duration::ZERO);
        assert!(matches!(result, HookRunnerResult::Failed(_)));
    }

    #[test]
    fn json_deny_overrides_exit_code_zero() {
        // JSON says deny, exit code says success — prefer JSON deny.
        let (result, _) = parse_blocking_result(
            r#"{"decision":"deny","reason":"nope"}"#,
            0,
            "test",
            Duration::ZERO,
        );
        assert!(matches!(
            result,
            HookRunnerResult::Decision(HookDecision::Deny { .. })
        ));
    }

    #[test]
    fn invalid_json_falls_back_to_exit_code() {
        let (result, _) = parse_blocking_result("not json at all", 0, "test", Duration::ZERO);
        assert!(matches!(
            result,
            HookRunnerResult::Decision(HookDecision::Allow)
        ));
    }

    #[test]
    fn unknown_decision_value() {
        let (result, _) =
            parse_blocking_result(r#"{"decision":"maybe"}"#, 0, "test", Duration::ZERO);
        assert!(matches!(result, HookRunnerResult::Failed(_)));
    }

    #[test]
    fn truncate_small_output() {
        let small = "hello world".as_bytes();
        let result = truncate_output(small);
        assert_eq!(result, "hello world");
    }

    #[test]
    fn truncate_large_output() {
        let large = vec![b'x'; MAX_OUTPUT_BYTES + 1000];
        let result = truncate_output(&large);
        assert!(result.ends_with(" [truncated]"));
        assert!(result.len() > MAX_OUTPUT_BYTES); // marker appended
    }

    #[test]
    fn resolve_absolute_path() {
        let spec = HookSpec {
            name: "test".into(),
            event: crate::event::HookEventName::PreToolUse,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(std::path::PathBuf::from("/usr/bin/hook")),
            command_raw: Some("/usr/bin/hook".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: std::path::PathBuf::from("/some/dir"),
            extra_env: std::collections::HashMap::new(),
        };
        assert_eq!(
            resolve_command_path(&spec),
            Some(std::path::PathBuf::from("/usr/bin/hook"))
        );
    }

    #[test]
    fn resolve_relative_path() {
        let spec = HookSpec {
            name: "test".into(),
            event: crate::event::HookEventName::PreToolUse,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(std::path::PathBuf::from("bin/check.sh")),
            command_raw: Some("bin/check.sh".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: std::path::PathBuf::from("/project/.kigi/hooks"),
            extra_env: std::collections::HashMap::new(),
        };
        assert_eq!(
            resolve_command_path(&spec),
            Some(std::path::PathBuf::from(
                "/project/.kigi/hooks/bin/check.sh"
            ))
        );
    }

    #[test]
    fn resolve_no_command_for_http() {
        let spec = HookSpec {
            name: "test".into(),
            event: crate::event::HookEventName::PreToolUse,
            handler_type: "http".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: None,
            command_raw: None,
            url: Some("https://hooks.example.com/check".into()),
            url_raw: Some("https://hooks.example.com/check".into()),
            timeout_ms: 5000,
            source_dir: std::path::PathBuf::from("/project"),
            extra_env: std::collections::HashMap::new(),
        };
        assert_eq!(resolve_command_path(&spec), None);
    }

    /// Helper to build a HookSpec that runs a shell command.
    fn make_shell_spec(command: &str) -> HookSpec {
        HookSpec {
            name: "test-hook".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(command.into()),
            command_raw: Some(command.to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: std::env::temp_dir(),
            extra_env: std::collections::HashMap::new(),
        }
    }

    fn make_envelope() -> HookEventEnvelope {
        use crate::event::HookPayload;
        HookEventEnvelope {
            hook_event_name: crate::event::HookEventName::Stop,
            session_id: "test-session".into(),
            cwd: "/tmp".into(),
            workspace_root: "/tmp".into(),
            timestamp: "2026-01-01T00:00:00Z".into(),
            transcript_path: None,
            client_identifier: None,
            prompt_id: None,
            payload: HookPayload::Stop {
                reason: "test".into(),
            },
        }
    }

    fn make_ctx() -> RunContext<'static> {
        RunContext {
            session_id: "test-session",
            workspace_root: "/tmp",
        }
    }

    /// Verify that setsid() prevents hook child processes from opening
    /// `/dev/tty`. This is the core fix for GPG pinentry corruption.
    ///
    /// The hook tries `exec 3>/dev/tty` — if detached, this fails and the
    /// shell exits 1 (caught by `||`), making the overall command exit 0.
    /// If NOT detached, the open succeeds and the command exits 1.
    #[tokio::test]
    #[cfg(unix)]
    async fn test_hook_child_cannot_open_dev_tty() {
        // Skip in CI / environments without a controlling terminal —
        // setsid() gets EPERM when already a session leader and the
        // setpgid fallback doesn't detach /dev/tty.
        if std::fs::OpenOptions::new()
            .write(true)
            .open("/dev/tty")
            .is_err()
        {
            eprintln!("skipping: no controlling terminal");
            return;
        }

        // exit 0 if /dev/tty is inaccessible (DETACHED), exit 1 if accessible
        let spec = make_shell_spec("exec 3>/dev/tty 2>/dev/null && exit 1 || exit 0");
        let envelope = make_envelope();
        let ctx = make_ctx();

        let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await;

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook child should not be able to open /dev/tty after setsid(), got {:?}",
            result
        );
    }

    /// Regression: hook commands still execute successfully.
    #[tokio::test]
    async fn test_hook_basic_execution() {
        let spec = make_shell_spec("exit 0");
        let envelope = make_envelope();
        let ctx = make_ctx();

        let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, false).await;

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook should succeed, got {:?}",
            result
        );
    }

    /// Regression: blocking hooks still parse JSON decisions correctly.
    #[tokio::test]
    async fn test_hook_blocking_allow() {
        let spec = make_shell_spec(r#"echo '{"decision":"allow"}'"#);
        let envelope = make_envelope();
        let ctx = make_ctx();

        let (result, _duration) = run_command_hook(&spec, &envelope, &ctx, true).await;

        assert!(
            matches!(result, HookRunnerResult::Decision(HookDecision::Allow)),
            "blocking hook should return Allow, got {:?}",
            result
        );
    }

    #[test]
    fn shell_command_detection() {
        // Commands with shell metacharacters should be detected.
        assert!("echo hello".contains(' ')); // space
        assert!("a || b".contains('|')); // pipe/or
        assert!("a && b".contains('&')); // and
        assert!("a; b".contains(';')); // semicolon
        assert!("a > out".contains('>')); // redirect
        // Env-var interpolation must also force the sh -c branch so that
        // commands like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` get expanded
        // by the shell rather than treated as a literal executable path.
        assert!("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh".contains('$'));
        assert!("$HOME/bin/hook".contains('$'));

        // Tilde at the start must also force the sh -c branch so that
        // `~/.claude/hook.sh` gets expanded to the user's home directory
        // rather than being joined to source_dir as a literal path.
        assert!("~/.claude/hook.sh".starts_with('~'));
        assert!("~/bin/hook".starts_with('~'));

        // Simple executable paths should not be detected.
        assert!(!"bin/check.sh".contains(' '));
        assert!(!"/usr/bin/hook".contains(' '));
        assert!(!"bin/check.sh".contains('$'));
        assert!(!"bin/check.sh".starts_with('~'));
    }

    /// Regression: a hook command that uses `${VAR}` interpolation
    /// without any other shell metacharacters must still be invoked via
    /// `sh -c` so that the env var supplied via `extra_env` is expanded.
    /// Previously the runner treated `${...}` as part of a literal path
    /// and `command_path.exists()` failed; the hook silently never ran.
    /// Now the env-var pre-spawn check refuses with a clear reason when
    /// the var is unset (and the dispatcher fail-opens, so the tool call
    /// itself is not blocked).
    #[tokio::test]
    async fn test_env_var_interpolation_runs_via_shell() {
        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("hook.sh");
        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }

        let mut extra_env = std::collections::HashMap::new();
        extra_env.insert(
            "GB1183_PLUGIN_ROOT".to_string(),
            tmp.path().to_string_lossy().into_owned(),
        );

        let spec = HookSpec {
            name: "test-env-interp".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(std::path::PathBuf::from("${GB1183_PLUGIN_ROOT}/hook.sh")),
            command_raw: Some("${GB1183_PLUGIN_ROOT}/hook.sh".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: tmp.path().to_path_buf(),
            extra_env,
        };

        let envelope = make_envelope();
        let ctx = make_ctx();
        let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await;

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook with ${{VAR}} interpolation should be expanded via sh -c, got {:?}",
            result
        );
    }

    /// `CLAUDE_PROJECT_DIR` is part of the external hook contract -- it points
    /// to the workspace/project root and is set for ALL hooks (not just
    /// plugin-scoped ones). Plugin hooks frequently reference it as
    /// `"$CLAUDE_PROJECT_DIR/.claude/hooks/foo.sh"`. The runner must export
    /// it on the spawned child so shell expansion via the `sh -c` branch
    /// resolves correctly; otherwise such hooks fail to find the
    /// command.
    #[tokio::test]
    async fn test_claude_project_dir_is_exported() {
        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("hook.sh");
        // Exit 0 only if CLAUDE_PROJECT_DIR matches the workspace root.
        let workspace = tmp.path().to_string_lossy().into_owned();
        std::fs::write(
            &script,
            format!(
                "#!/bin/sh\ntest \"${{CLAUDE_PROJECT_DIR}}\" = \"{workspace}\"\n",
                workspace = workspace
            ),
        )
        .unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }

        let spec = HookSpec {
            name: "test-claude-project-dir".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            // Use ${CLAUDE_PROJECT_DIR} in the path itself so this also exercises
            // the `$` -> sh -c routing.
            command: Some(std::path::PathBuf::from("${CLAUDE_PROJECT_DIR}/hook.sh")),
            command_raw: Some("${CLAUDE_PROJECT_DIR}/hook.sh".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: tmp.path().to_path_buf(),
            extra_env: std::collections::HashMap::new(),
        };

        let envelope = make_envelope();
        let ctx = RunContext {
            session_id: "test-session",
            workspace_root: &workspace,
        };
        let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await;

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook should see CLAUDE_PROJECT_DIR set to the workspace root, got {:?}",
            result
        );
    }

    /// Unit tests for the `find_unresolved_env_vars` parser. We seed
    /// `extra_env` to control what's "set" without depending on the test
    /// process's real environment (other than excluding common vars).
    #[test]
    fn find_unresolved_braced_form() {
        let mut env = std::collections::HashMap::new();
        env.insert("KNOWN".to_string(), "x".to_string());
        let v = find_unresolved_env_vars("${KNOWN}/${SOME_GB1183_UNSET_VAR}/foo", &env);
        assert_eq!(v, vec!["SOME_GB1183_UNSET_VAR".to_string()]);
    }

    #[test]
    fn find_unresolved_bare_form() {
        let env = std::collections::HashMap::new();
        let v = find_unresolved_env_vars("$SOME_GB1183_BARE_UNSET/foo", &env);
        assert_eq!(v, vec!["SOME_GB1183_BARE_UNSET".to_string()]);
    }

    #[test]
    fn find_unresolved_skips_runner_vars() {
        let env = std::collections::HashMap::new();
        let v = find_unresolved_env_vars(
            "${KIGI_HOOK_EVENT}/${CLAUDE_PROJECT_DIR}/${KIGI_SESSION_ID}/foo",
            &env,
        );
        assert!(
            v.is_empty(),
            "runner-set vars should never be flagged, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_skips_extra_env() {
        let mut env = std::collections::HashMap::new();
        env.insert("CLAUDE_PLUGIN_ROOT".to_string(), "/plugins/foo".to_string());
        let v = find_unresolved_env_vars("${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh", &env);
        assert!(
            v.is_empty(),
            "vars present in extra_env should not be flagged, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_dedups() {
        let env = std::collections::HashMap::new();
        let v = find_unresolved_env_vars(
            "${MISSING_GB1183_DUP} && ${MISSING_GB1183_DUP}/foo $MISSING_GB1183_DUP",
            &env,
        );
        assert_eq!(v, vec!["MISSING_GB1183_DUP".to_string()]);
    }

    #[test]
    fn find_unresolved_skips_non_var_dollars() {
        let env = std::collections::HashMap::new();
        // $1 (positional), $$ (pid), $(...) (cmd subst), $? (exit code), $#.
        // None of these are env vars; none should be flagged.
        let v = find_unresolved_env_vars("echo $1 $$ $? $# $(date)", &env);
        assert!(
            v.is_empty(),
            "shell special params should not be flagged, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_skips_locally_assigned_vars() {
        let env = std::collections::HashMap::new();
        // INPUT is set locally inside the same command. The check must
        // recognize that and not flag the subsequent ${INPUT} references.
        let cmd = r#"INPUT=$(cat); echo "$INPUT" | grep -q foo"#;
        let v = find_unresolved_env_vars(cmd, &env);
        assert!(
            v.is_empty(),
            "locally-assigned vars must not be flagged, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_skips_read_assigned_vars() {
        let env = std::collections::HashMap::new();
        let cmd = "read -r LINE; echo $LINE";
        let v = find_unresolved_env_vars(cmd, &env);
        assert!(
            v.is_empty(),
            "vars assigned via `read` must not be flagged, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_skips_assignments_after_separators() {
        let env = std::collections::HashMap::new();
        let cmd = "echo first; X=hello && echo $X | cat";
        let v = find_unresolved_env_vars(cmd, &env);
        assert!(
            v.is_empty(),
            "assignment after `;` should be recognized, got {v:?}"
        );
    }

    #[test]
    fn find_unresolved_skips_parameter_expansion_modifiers() {
        let env = std::collections::HashMap::new();
        // All of these explicitly handle the unset case; the runner must
        // not flag them, otherwise we reject hooks that the user wrote
        // correctly.
        let cases = [
            "${MISSING_GB1183_MOD:-/default/path.sh}",
            "${MISSING_GB1183_MOD-/default/path.sh}",
            "${MISSING_GB1183_MOD:=/assigned/path.sh}",
            "${MISSING_GB1183_MOD:?msg here}",
            "${MISSING_GB1183_MOD:+/used/if/set.sh}",
            "${MISSING_GB1183_MOD%.sh}",
            "${MISSING_GB1183_MOD#prefix/}",
            "${MISSING_GB1183_MOD/foo/bar}",
            "${MISSING_GB1183_MOD:0:5}",
        ];
        for case in cases {
            let v = find_unresolved_env_vars(case, &env);
            assert!(
                v.is_empty(),
                "parameter-expansion form `{case}` should not be flagged, got {v:?}"
            );
        }
    }

    /// Regression follow-up: when a hook command references
    /// an env var that isn't set anywhere we know about, the runner must
    /// refuse to spawn entirely (no fork+exec, no opaque "exit code 127")
    /// and surface a clear failure reason naming the missing var(s).
    #[tokio::test]
    async fn test_undefined_env_var_refuses_to_spawn() {
        let mut extra_env = std::collections::HashMap::new();
        // Intentionally do NOT set NEVER_SET_GB1183 anywhere.
        extra_env.insert("UNRELATED_GB1183".to_string(), "/tmp".to_string());

        let spec = HookSpec {
            name: "test-undef".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(std::path::PathBuf::from(
                "${NEVER_SET_GB1183}/does/not/exist.sh",
            )),
            command_raw: Some("${NEVER_SET_GB1183}/does/not/exist.sh".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: std::env::temp_dir(),
            extra_env,
        };

        let envelope = make_envelope();
        let ctx = make_ctx();
        let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await;

        match result {
            HookRunnerResult::Failed(reason) => {
                assert!(
                    reason.contains("NEVER_SET_GB1183"),
                    "failure reason should name the undefined env var, got: {reason}"
                );
                assert!(
                    reason.contains("hook not executed"),
                    "failure reason should make clear the hook did not run, got: {reason}"
                );
                assert!(
                    !reason.contains("exit code"),
                    "failure reason should not reference an exit code (we never spawned), got: {reason}"
                );
            }
            other => panic!("expected Failed, got {other:?}"),
        }
    }

    /// Regression: a hook command starting with `~` must be
    /// routed through `sh -c` so the shell expands `~` to `$HOME`.
    /// Previously `~/.claude/hook.sh` was treated as a relative path and
    /// joined to `source_dir`, producing a broken path.
    ///
    /// The test injects `HOME` via `extra_env` so it works in sandboxed
    /// CI environments where `HOME` is not set (e.g. hermetic remote exec).
    #[tokio::test]
    #[cfg(unix)]
    async fn test_tilde_expansion_runs_via_shell() {
        let tmp = tempfile::tempdir().unwrap();
        // Create the script at <tmp>/.kigi-test-hooks-gb856/tilde-test.sh
        let hook_dir = tmp.path().join(".kigi-test-hooks-gb856");
        std::fs::create_dir_all(&hook_dir).unwrap();
        let script = hook_dir.join("tilde-test.sh");
        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }

        // Inject HOME via extra_env so `sh -c "~/.kigi-test-hooks-gb856/..."`
        // expands `~` to the temp dir. This avoids depending on the system
        // HOME, which is absent in hermetic sandboxed test runners.
        let mut extra_env = std::collections::HashMap::new();
        extra_env.insert(
            "HOME".to_string(),
            tmp.path().to_string_lossy().into_owned(),
        );

        let spec = HookSpec {
            name: "test-tilde".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            command: Some(std::path::PathBuf::from(
                "~/.kigi-test-hooks-gb856/tilde-test.sh",
            )),
            command_raw: Some("~/.kigi-test-hooks-gb856/tilde-test.sh".to_string()),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: std::env::temp_dir(),
            extra_env,
        };

        let envelope = make_envelope();
        let ctx = make_ctx();

        // Freshly writing the script and exec'ing it via `sh -c` can transiently
        // fail with ETXTBSY ("Text file busy" -> exit 126) when a sibling test in
        // this multi-threaded binary forks while our write fd is still open and
        // its child inherits it. Retry ONLY that exact transient; a real tilde-
        // routing break surfaces as a different result (127/spawn error), so the
        // assertion below keeps its diagnostic power.
        let mut result = run_command_hook(&spec, &envelope, &ctx, false).await.0;
        for _ in 0..8 {
            if !matches!(&result, HookRunnerResult::Failed(msg) if msg == "exit code 126") {
                break;
            }
            result = run_command_hook(&spec, &envelope, &ctx, false).await.0;
        }

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook with ~/... path should be expanded via sh -c, got {:?}",
            result
        );
    }

    /// Hooks that explicitly handle the unset case via parameter expansion
    /// (e.g. `${VAR:-/some/default}`) must NOT be refused -- the user has
    /// expressed intent for what should happen when the var is unset.
    #[tokio::test]
    async fn test_parameter_expansion_default_is_not_refused() {
        let tmp = tempfile::tempdir().unwrap();
        let script = tmp.path().join("default.sh");
        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }

        let spec = HookSpec {
            name: "test-default".into(),
            event: crate::event::HookEventName::Stop,
            handler_type: "command".into(),
            configured_matcher: None,
            matcher: None,
            enabled: true,
            // `MISSING_GB1183_DEFAULT` is intentionally unset; the `:-`
            // modifier supplies a fallback that points at the real script.
            command: Some(std::path::PathBuf::from(format!(
                "${{MISSING_GB1183_DEFAULT:-{}}}",
                script.display()
            ))),
            command_raw: Some(format!("${{MISSING_GB1183_DEFAULT:-{}}}", script.display())),
            url: None,
            url_raw: None,
            timeout_ms: 5000,
            source_dir: tmp.path().to_path_buf(),
            extra_env: std::collections::HashMap::new(),
        };

        let envelope = make_envelope();
        let ctx = make_ctx();
        let (result, _) = run_command_hook(&spec, &envelope, &ctx, false).await;

        assert!(
            matches!(result, HookRunnerResult::Success),
            "hook with parameter-expansion default must run, got {:?}",
            result
        );
    }
}
