//! Shell command execution — runs commands in the project directory and captures output.
use crate::state::SessionState;
use serde::Serialize;
use std::path::PathBuf;
use std::process::Command;

/// Output captured from a shell command: stdout, stderr, and exit code.
#[derive(Serialize, Debug)]
pub struct CommandOutput {
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
}

/// Execute shell command logic (pure function for testing)
async fn exec_shell_impl(
    command: String,
    args: Vec<String>,
    root: PathBuf,
) -> Result<CommandOutput, String> {
    // Security Allowlist
    let allowed_commands = [
        "git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm",
        "mv", "cp", "touch", "rustc", "rustfmt",
    ];

    if !allowed_commands.contains(&command.as_str()) {
        return Err(format!("Command '{}' is not in the allowlist.", command));
    }

    let output = tokio::task::spawn_blocking(move || {
        Command::new(&command)
            .args(&args)
            .current_dir(root)
            .output()
    })
    .await
    .map_err(|e| format!("Task join error: {}", e))?
    .map_err(|e| format!("Failed to execute command: {}", e))?;

    Ok(CommandOutput {
        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
        exit_code: output.status.code().unwrap_or(-1),
    })
}

pub async fn exec_shell(
    command: String,
    args: Vec<String>,
    state: &SessionState,
) -> Result<CommandOutput, String> {
    let root = state.get_project_root()?;
    exec_shell_impl(command, args, root).await
}

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

    #[tokio::test]
    async fn exec_shell_impl_rejects_disallowed_command() {
        let dir = tempdir().unwrap();
        let result = exec_shell_impl(
            "curl".to_string(),
            vec!["https://example.com".to_string()],
            dir.path().to_path_buf(),
        )
        .await;

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("not in the allowlist"));
    }

    #[tokio::test]
    async fn exec_shell_impl_runs_allowed_command() {
        let dir = tempdir().unwrap();
        let result = exec_shell_impl("ls".to_string(), Vec::new(), dir.path().to_path_buf()).await;

        assert!(result.is_ok());
        let output = result.unwrap();
        assert_eq!(output.exit_code, 0);
    }

    #[tokio::test]
    async fn exec_shell_impl_captures_stdout() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join("hello.txt"), "").unwrap();

        let result = exec_shell_impl("ls".to_string(), Vec::new(), dir.path().to_path_buf())
            .await
            .unwrap();

        assert!(result.stdout.contains("hello.txt"));
    }

    #[tokio::test]
    async fn exec_shell_impl_returns_nonzero_exit_code() {
        let dir = tempdir().unwrap();
        let result = exec_shell_impl(
            "ls".to_string(),
            vec!["nonexistent_file_xyz".to_string()],
            dir.path().to_path_buf(),
        )
        .await
        .unwrap();

        assert_ne!(result.exit_code, 0);
        assert!(!result.stderr.is_empty());
    }

    #[tokio::test]
    async fn exec_shell_delegates_to_impl_via_state() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join("marker.txt"), "hello").unwrap();

        let state = SessionState::default();
        *state.project_root.lock().unwrap() = Some(dir.path().to_path_buf());

        let result = exec_shell("ls".to_string(), Vec::new(), &state)
            .await
            .unwrap();

        assert_eq!(result.exit_code, 0);
        assert!(result.stdout.contains("marker.txt"));
    }

    #[tokio::test]
    async fn exec_shell_errors_when_no_project_root() {
        let state = SessionState::default();

        let result = exec_shell("ls".to_string(), Vec::new(), &state).await;

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("No project"));
    }

    #[tokio::test]
    async fn exec_shell_impl_errors_on_nonexistent_cwd() {
        let result = exec_shell_impl(
            "ls".to_string(),
            Vec::new(),
            PathBuf::from("/nonexistent_dir_that_does_not_exist_xyz"),
        )
        .await;

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to execute command"));
    }

    #[test]
    fn command_output_serializes_to_json() {
        let output = CommandOutput {
            stdout: "hello".to_string(),
            stderr: "".to_string(),
            exit_code: 0,
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"stdout\":\"hello\""));
        assert!(json.contains("\"exit_code\":0"));
    }

    #[test]
    fn command_output_debug_format() {
        let output = CommandOutput {
            stdout: "out".to_string(),
            stderr: "err".to_string(),
            exit_code: 1,
        };

        let debug = format!("{:?}", output);
        assert!(debug.contains("CommandOutput"));
        assert!(debug.contains("out"));
    }
}
