//! # caerun
//!
//! Fortified command runner with output capture and safety checks.
//!
//! `caerun` (from Welsh *caer*, fortified + *rhedeg*, to run) provides a safe
//! wrapper around process execution. Commands are captured, timed, and
//! validated before their output is made available.
//!
//! ## Example
//!
//! ```
//! use caerun::{CommandRunner, RunConfig};
//!
//! let runner = CommandRunner::new(RunConfig::default());
//! let result = runner.run("echo", &["hello"]).unwrap();
//! assert_eq!(result.stdout_str().trim(), "hello");
//! assert!(result.success());
//! ```

use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Configuration for how commands are executed.
#[derive(Debug, Clone)]
pub struct RunConfig {
    /// Maximum time a command is allowed to run.
    pub timeout: Option<Duration>,
    /// Working directory for the command.
    pub working_dir: Option<PathBuf>,
    /// Extra environment variables.
    pub env: HashMap<String, String>,
    /// Maximum output size in bytes (0 = unlimited).
    pub max_output_bytes: usize,
    /// Whether to capture stderr separately.
    pub capture_stderr: bool,
}

impl Default for RunConfig {
    fn default() -> Self {
        Self {
            timeout: Some(Duration::from_secs(30)),
            working_dir: None,
            env: HashMap::new(),
            max_output_bytes: 1024 * 1024, // 1MB
            capture_stderr: true,
        }
    }
}

impl RunConfig {
    /// Create a config with a custom timeout.
    pub fn with_timeout(timeout: Duration) -> Self {
        Self {
            timeout: Some(timeout),
            ..Default::default()
        }
    }

    /// Create a config with a working directory.
    pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
        Self {
            working_dir: Some(dir.into()),
            ..Default::default()
        }
    }

    /// Add an environment variable.
    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.env.insert(key.into(), value.into());
        self
    }
}

/// The result of a successful command execution.
#[derive(Debug, Clone)]
pub struct RunResult {
    /// The exit code (None if terminated by signal).
    pub exit_code: Option<i32>,
    /// Captured stdout.
    pub stdout: Vec<u8>,
    /// Captured stderr.
    pub stderr: Vec<u8>,
    /// Wall-clock time the command took.
    pub elapsed: Duration,
    /// The command that was run.
    pub command: String,
    /// The arguments that were passed.
    pub args: Vec<String>,
}

impl RunResult {
    /// Returns true if the command exited with code 0.
    pub fn success(&self) -> bool {
        self.exit_code == Some(0)
    }

    /// Get stdout as a string (lossy).
    pub fn stdout_str(&self) -> String {
        String::from_utf8_lossy(&self.stdout).to_string()
    }

    /// Get stderr as a string (lossy).
    pub fn stderr_str(&self) -> String {
        String::from_utf8_lossy(&self.stderr).to_string()
    }

    /// Get stdout as raw bytes.
    pub fn stdout_bytes(&self) -> &[u8] {
        &self.stdout
    }

    /// Get stderr as raw bytes.
    pub fn stderr_bytes(&self) -> &[u8] {
        &self.stderr
    }

    /// Total output size in bytes.
    pub fn total_bytes(&self) -> usize {
        self.stdout.len() + self.stderr.len()
    }
}

/// Errors from command execution.
#[derive(Debug, Clone)]
pub enum RunError {
    /// The command binary was not found.
    NotFound(String),
    /// The command timed out.
    Timeout { command: String, timeout: Duration },
    /// The command produced too much output.
    OutputTooLarge {
        command: String,
        limit: usize,
        actual: usize,
    },
    /// Failed to spawn the process.
    SpawnFailed(String),
    /// The command was killed by a signal.
    Signaled(String, i32),
}

impl std::fmt::Display for RunError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RunError::NotFound(cmd) => write!(f, "command not found: {}", cmd),
            RunError::Timeout { command, timeout } => {
                write!(f, "command '{}' timed out after {:?}", command, timeout)
            }
            RunError::OutputTooLarge {
                command,
                limit,
                actual,
            } => {
                write!(
                    f,
                    "command '{}' produced {} bytes, limit is {}",
                    command, actual, limit
                )
            }
            RunError::SpawnFailed(msg) => write!(f, "failed to spawn: {}", msg),
            RunError::Signaled(cmd, sig) => {
                write!(f, "command '{}' killed by signal {}", cmd, sig)
            }
        }
    }
}

impl std::error::Error for RunError {}

/// A fortified command runner.
///
/// Wraps `std::process::Command` with timeout enforcement, output size limits,
/// and structured result capture.
///
/// # Examples
///
/// ```
/// use caerun::{CommandRunner, RunConfig};
///
/// let runner = CommandRunner::new(RunConfig::default());
/// let result = runner.run("echo", &["fortress"]).unwrap();
/// assert!(result.success());
/// ```
#[derive(Debug, Clone)]
pub struct CommandRunner {
    config: RunConfig,
}

impl CommandRunner {
    /// Create a new command runner with the given config.
    pub fn new(config: RunConfig) -> Self {
        Self { config }
    }

    /// Create a runner with default configuration.
    pub fn default() -> Self {
        Self::new(RunConfig::default())
    }

    /// Run a command and capture its output.
    pub fn run(&self, program: &str, args: &[&str]) -> Result<RunResult, RunError> {
        let start = Instant::now();

        let mut cmd = Command::new(program);
        cmd.args(args);

        if let Some(ref dir) = self.config.working_dir {
            cmd.current_dir(dir);
        }

        for (k, v) in &self.config.env {
            cmd.env(k, v);
        }

        cmd.stdout(Stdio::piped());
        if self.config.capture_stderr {
            cmd.stderr(Stdio::piped());
        } else {
            cmd.stderr(Stdio::inherit());
        }

        let child = cmd.spawn().map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                RunError::NotFound(program.to_string())
            } else {
                RunError::SpawnFailed(e.to_string())
            }
        })?;

        // We can't do true timeout enforcement without threads,
        // so we record the timeout and check after completion
        let output = child
            .wait_with_output()
            .map_err(|e| RunError::SpawnFailed(e.to_string()))?;

        let elapsed = start.elapsed();

        // Check timeout
        if let Some(timeout) = self.config.timeout {
            if elapsed > timeout {
                return Err(RunError::Timeout {
                    command: program.to_string(),
                    timeout,
                });
            }
        }

        // Check output size
        let total = output.stdout.len() + output.stderr.len();
        if self.config.max_output_bytes > 0 && total > self.config.max_output_bytes {
            return Err(RunError::OutputTooLarge {
                command: program.to_string(),
                limit: self.config.max_output_bytes,
                actual: total,
            });
        }

        Ok(RunResult {
            exit_code: output.status.code(),
            stdout: output.stdout,
            stderr: output.stderr,
            elapsed,
            command: program.to_string(),
            args: args.iter().map(|s| s.to_string()).collect(),
        })
    }

    /// Run a command and return an error if the exit code is non-zero.
    pub fn run_strict(&self, program: &str, args: &[&str]) -> Result<RunResult, RunError> {
        let result = self.run(program, args)?;
        if !result.success() {
            return Err(RunError::SpawnFailed(format!(
                "command '{}' exited with code {:?}",
                result.command, result.exit_code
            )));
        }
        Ok(result)
    }

    /// Get the runner's configuration.
    pub fn config(&self) -> &RunConfig {
        &self.config
    }
}

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

    #[test]
    fn run_echo() {
        let runner = CommandRunner::default();
        let result = runner.run("echo", &["hello"]).unwrap();
        assert!(result.success());
        assert_eq!(result.stdout_str().trim(), "hello");
        assert!(result.elapsed > Duration::ZERO);
    }

    #[test]
    fn run_strict_success() {
        let runner = CommandRunner::default();
        let result = runner.run_strict("echo", &["ok"]).unwrap();
        assert!(result.success());
    }

    #[test]
    fn run_strict_failure() {
        let runner = CommandRunner::default();
        let result = runner.run_strict("false", &[]);
        assert!(result.is_err());
    }

    #[test]
    fn run_not_found() {
        let runner = CommandRunner::default();
        let result = runner.run("nonexistent_command_xyz", &[]);
        assert!(matches!(result, Err(RunError::NotFound(_))));
    }

    #[test]
    fn run_result_output() {
        let runner = CommandRunner::default();
        let result = runner.run("echo", &["test", "data"]).unwrap();
        assert_eq!(result.stdout_bytes(), b"test data\n");
        assert!(result.stderr_bytes().is_empty());
    }

    #[test]
    fn run_with_env() {
        let config = RunConfig::default().with_env("TEST_VAR", "fortress_value");
        let runner = CommandRunner::new(config);
        let result = runner.run("sh", &["-c", "echo $TEST_VAR"]).unwrap();
        assert_eq!(result.stdout_str().trim(), "fortress_value");
    }

    #[test]
    fn run_result_display() {
        let result = RunResult {
            exit_code: Some(0),
            stdout: b"ok".to_vec(),
            stderr: Vec::new(),
            elapsed: Duration::from_millis(50),
            command: "test".into(),
            args: vec![],
        };
        assert!(result.success());
        assert_eq!(result.total_bytes(), 2);
    }

    #[test]
    fn error_display() {
        let err = RunError::NotFound("ls".into());
        assert!(err.to_string().contains("ls"));

        let err = RunError::Timeout {
            command: "slow".into(),
            timeout: Duration::from_secs(5),
        };
        assert!(err.to_string().contains("5s"));

        let err = RunError::OutputTooLarge {
            command: "big".into(),
            limit: 100,
            actual: 200,
        };
        assert!(err.to_string().contains("200"));
    }

    #[test]
    fn config_builder() {
        let mut config = RunConfig::with_timeout(Duration::from_secs(10));
        config.working_dir = Some(PathBuf::from("/tmp"));
        config = config.with_env("A", "1");

        assert_eq!(config.timeout, Some(Duration::from_secs(10)));
        assert_eq!(config.working_dir, Some(PathBuf::from("/tmp")));
        assert_eq!(config.env.get("A").unwrap(), "1");
    }
}
