//! MCP shell command execution: `tool_run_command`.

use serde_json::{Value, json};
use std::path::PathBuf;

use crate::http::context::AppContext;
#[allow(unused_imports)]
use crate::service::shell::is_dangerous;

const DEFAULT_TIMEOUT_SECS: u64 = 120;
const MAX_TIMEOUT_SECS: u64 = 600;

/// Validates that `working_dir` exists and is inside the project's
/// `.huskies/worktrees/` directory. Returns the canonicalized path.
///
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::shell::io::validate_working_dir`.
pub(super) fn validate_working_dir(working_dir: &str, ctx: &AppContext) -> Result<PathBuf, String> {
    let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
    crate::service::shell::io::validate_working_dir(working_dir, &project_root)
        .map_err(|e| e.to_string())
}

/// Regular (non-SSE) run_command: runs the bash command to completion and
/// returns stdout, stderr, exit_code, and whether it timed out.
pub(crate) async fn tool_run_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
    let command = args
        .get("command")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: command")?
        .to_string();

    let working_dir = args
        .get("working_dir")
        .and_then(|v| v.as_str())
        .ok_or("Missing required argument: working_dir")?;

    let timeout_secs = args
        .get("timeout")
        .and_then(|v| v.as_u64())
        .unwrap_or(DEFAULT_TIMEOUT_SECS)
        .min(MAX_TIMEOUT_SECS);

    if let Some(reason) = is_dangerous(&command) {
        return Err(reason);
    }

    let canonical_dir = validate_working_dir(working_dir, ctx)?;

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(timeout_secs),
        tokio::task::spawn_blocking({
            let cmd = command.clone();
            let dir = canonical_dir.clone();
            move || {
                std::process::Command::new("bash")
                    .arg("-c")
                    .arg(&cmd)
                    .current_dir(&dir)
                    .output()
            }
        }),
    )
    .await;

    match result {
        Err(_) => {
            // timed out
            serde_json::to_string_pretty(&json!({
                "stdout": "",
                "stderr": format!("Command timed out after {timeout_secs}s"),
                "exit_code": -1,
                "timed_out": true,
            }))
            .map_err(|e| format!("Serialization error: {e}"))
        }
        Ok(Err(e)) => Err(format!("Task join error: {e}")),
        Ok(Ok(Err(e))) => Err(format!("Failed to execute command: {e}")),
        Ok(Ok(Ok(output))) => serde_json::to_string_pretty(&json!({
            "stdout": String::from_utf8_lossy(&output.stdout),
            "stderr": String::from_utf8_lossy(&output.stderr),
            "exit_code": output.status.code().unwrap_or(-1),
            "timed_out": false,
        }))
        .map_err(|e| format!("Serialization error: {e}")),
    }
}

/// Run the project's test suite (`script/test`) and block until complete.
///
/// Spawns the test process, then polls every second server-side until the
/// child exits or the timeout is reached. Returns the full test result in
/// a single MCP call — no polling needed from the agent.
///
#[cfg(test)]
mod tests {
    use super::*;
    use crate::http::test_helpers::test_ctx;

    #[test]
    fn is_dangerous_blocks_rm_rf_root() {
        assert!(is_dangerous("rm -rf /").is_some());
        assert!(is_dangerous("  rm -rf /  ").is_some());
    }

    #[test]
    fn is_dangerous_blocks_rm_fr_root() {
        assert!(is_dangerous("rm -fr /").is_some());
    }

    #[test]
    fn is_dangerous_blocks_rm_rf_star() {
        assert!(is_dangerous("rm -rf /*").is_some());
        assert!(is_dangerous("rm -fr /*").is_some());
    }

    #[test]
    fn is_dangerous_blocks_sudo() {
        assert!(is_dangerous("sudo ls").is_some());
    }

    #[test]
    fn is_dangerous_blocks_shutdown() {
        assert!(is_dangerous("shutdown -h now").is_some());
    }

    #[test]
    fn is_dangerous_blocks_mkfs() {
        assert!(is_dangerous("mkfs /dev/sda1").is_some());
    }

    #[test]
    fn is_dangerous_blocks_fork_bomb() {
        assert!(is_dangerous(":(){ :|:& };:").is_some());
    }

    #[test]
    fn is_dangerous_allows_safe_commands() {
        assert!(is_dangerous("cargo build").is_none());
        assert!(is_dangerous("npm test").is_none());
        assert!(is_dangerous("git status").is_none());
        assert!(is_dangerous("ls -la").is_none());
        assert!(is_dangerous("rm -rf target/").is_none());
    }

    // ── validate_working_dir ──────────────────────────────────────────

    #[test]
    fn validate_working_dir_rejects_relative_path() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = validate_working_dir("relative/path", &ctx);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("absolute"));
    }

    #[test]
    fn validate_working_dir_rejects_nonexistent_path() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = validate_working_dir("/nonexistent_path_xyz_abc", &ctx);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("does not exist"));
    }

    #[test]
    fn validate_working_dir_rejects_path_outside_worktrees() {
        let tmp = tempfile::tempdir().unwrap();
        // Create the worktrees dir so it exists
        let wt_dir = tmp.path().join(".huskies").join("worktrees");
        std::fs::create_dir_all(&wt_dir).unwrap();
        let ctx = test_ctx(tmp.path());
        // Try to use /tmp (outside worktrees)
        let result = validate_working_dir(tmp.path().to_str().unwrap(), &ctx);
        assert!(result.is_err());
        assert!(
            result.unwrap_err().contains("inside .huskies/worktrees"),
            "expected sandbox error"
        );
    }

    #[test]
    fn validate_working_dir_accepts_path_inside_worktrees() {
        let tmp = tempfile::tempdir().unwrap();
        let story_wt = tmp
            .path()
            .join(".huskies")
            .join("worktrees")
            .join("42_test_story");
        std::fs::create_dir_all(&story_wt).unwrap();
        let ctx = test_ctx(tmp.path());
        let result = validate_working_dir(story_wt.to_str().unwrap(), &ctx);
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
    }

    #[test]
    fn validate_working_dir_rejects_no_worktrees_dir() {
        let tmp = tempfile::tempdir().unwrap();
        // Do NOT create worktrees dir
        let ctx = test_ctx(tmp.path());
        let result = validate_working_dir(tmp.path().to_str().unwrap(), &ctx);
        assert!(result.is_err());
    }

    // ── tool_run_command ───────────────────────────────────────────────

    #[tokio::test]
    async fn tool_run_command_missing_command() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(&json!({"working_dir": "/tmp"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("command"));
    }

    #[tokio::test]
    async fn tool_run_command_missing_working_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(&json!({"command": "ls"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("working_dir"));
    }

    #[tokio::test]
    async fn tool_run_command_blocks_dangerous_command() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        let result =
            tool_run_command(&json!({"command": "rm -rf /", "working_dir": "/tmp"}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("blocked"));
    }

    #[tokio::test]
    async fn tool_run_command_rejects_path_outside_worktrees() {
        let tmp = tempfile::tempdir().unwrap();
        let wt_dir = tmp.path().join(".huskies").join("worktrees");
        std::fs::create_dir_all(&wt_dir).unwrap();
        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(
            &json!({
                "command": "ls",
                "working_dir": tmp.path().to_str().unwrap()
            }),
            &ctx,
        )
        .await;
        assert!(result.is_err());
        assert!(
            result.unwrap_err().contains("worktrees"),
            "expected sandbox error"
        );
    }

    #[tokio::test]
    async fn tool_run_command_runs_in_worktree_and_returns_output() {
        let tmp = tempfile::tempdir().unwrap();
        let story_wt = tmp
            .path()
            .join(".huskies")
            .join("worktrees")
            .join("42_test");
        std::fs::create_dir_all(&story_wt).unwrap();
        std::fs::write(story_wt.join("canary.txt"), "hello").unwrap();

        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(
            &json!({
                "command": "ls",
                "working_dir": story_wt.to_str().unwrap()
            }),
            &ctx,
        )
        .await
        .unwrap();

        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["exit_code"], 0);
        assert!(parsed["stdout"].as_str().unwrap().contains("canary.txt"));
        assert_eq!(parsed["timed_out"], false);
    }

    #[tokio::test]
    async fn tool_run_command_captures_nonzero_exit_code() {
        let tmp = tempfile::tempdir().unwrap();
        let story_wt = tmp
            .path()
            .join(".huskies")
            .join("worktrees")
            .join("43_test");
        std::fs::create_dir_all(&story_wt).unwrap();

        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(
            &json!({
                "command": "exit 42",
                "working_dir": story_wt.to_str().unwrap()
            }),
            &ctx,
        )
        .await
        .unwrap();

        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["exit_code"], 42);
        assert_eq!(parsed["timed_out"], false);
    }

    #[tokio::test]
    async fn tool_run_command_timeout_returns_timed_out_true() {
        let tmp = tempfile::tempdir().unwrap();
        let story_wt = tmp
            .path()
            .join(".huskies")
            .join("worktrees")
            .join("44_test");
        std::fs::create_dir_all(&story_wt).unwrap();

        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(
            &json!({
                "command": "sleep 10",
                "working_dir": story_wt.to_str().unwrap(),
                "timeout": 1
            }),
            &ctx,
        )
        .await
        .unwrap();

        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["timed_out"], true);
    }

    #[tokio::test]
    async fn tool_run_command_captures_stderr() {
        let tmp = tempfile::tempdir().unwrap();
        let story_wt = tmp
            .path()
            .join(".huskies")
            .join("worktrees")
            .join("45_test");
        std::fs::create_dir_all(&story_wt).unwrap();

        let ctx = test_ctx(tmp.path());
        let result = tool_run_command(
            &json!({
                "command": "echo 'error msg' >&2",
                "working_dir": story_wt.to_str().unwrap()
            }),
            &ctx,
        )
        .await
        .unwrap();

        let parsed: Value = serde_json::from_str(&result).unwrap();
        assert!(
            parsed["stderr"].as_str().unwrap().contains("error msg"),
            "expected stderr: {parsed}"
        );
    }

    #[tokio::test]
    async fn tool_run_command_clamps_timeout_to_max() {
        // Verify timeout > 600 is clamped to 600. We don't run a 600s sleep;
        // just confirm the tool accepts the arg without error (sandbox check will
        // fail first in a different test, here we test the arg parsing path).
        let tmp = tempfile::tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        // Will fail at working_dir validation, not timeout parsing — that's fine
        let result = tool_run_command(
            &json!({"command": "ls", "working_dir": "/tmp", "timeout": 9999}),
            &ctx,
        )
        .await;
        // Just ensure it doesn't panic and returns an Err about sandbox (not timeout)
        assert!(result.is_err());
    }

    // ── tool_run_tests ────────────────────────────────────────────────
}
