//! No-shell command execution runtime.

pub mod safe;
pub use safe::{SafeCommand, SafeError, ScopedPath};

use chrono::{DateTime, Utc};
use mcpact_core::AuthorityClass;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
    collections::BTreeMap,
    env,
    path::{Path, PathBuf},
    process::Stdio,
    time::Duration,
};
use thiserror::Error;
use tokio::{process::Command, time::timeout};

/// Output decoding mode.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutputMode {
    /// Plain text stdout.
    #[default]
    Text,
    /// JSON stdout.
    Json,
    /// SARIF stdout.
    Sarif,
}

/// Environment policy for command execution.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvPlan {
    /// Inherit parent env wholesale.
    #[serde(default)]
    pub inherit: bool,
    /// When inheriting, only pass these variable names from the parent environment.
    #[serde(default)]
    pub allow: Vec<String>,
    /// Explicit key/values to set.
    #[serde(default)]
    pub set: BTreeMap<String, String>,
}

/// Executable command plan. This is binary + argv, never shell text.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPlan {
    /// Binary path/name.
    pub binary: String,
    /// Argument vector. No shell interpolation.
    pub argv: Vec<String>,
    /// Working directory.
    pub cwd: Option<PathBuf>,
    /// Env policy.
    pub env: EnvPlan,
    /// Timeout.
    pub timeout: Duration,
    /// Max combined stdout/stderr bytes returned.
    pub max_output_bytes: usize,
    /// Output mode.
    pub output_mode: OutputMode,
    /// Authority class.
    pub authority: AuthorityClass,
    /// Arg indexes to redact in evidence.
    pub redacted_arg_indexes: Vec<usize>,
}

impl ExecutionPlan {
    /// Construct plan with safe defaults.
    #[must_use]
    pub fn new(binary: impl Into<String>) -> Self {
        Self {
            binary: binary.into(),
            argv: Vec::new(),
            cwd: None,
            env: EnvPlan::default(),
            timeout: Duration::from_secs(60),
            max_output_bytes: 1_048_576,
            output_mode: OutputMode::Text,
            authority: AuthorityClass::Observe,
            redacted_arg_indexes: Vec::new(),
        }
    }

    /// Append an argv token.
    #[must_use]
    pub fn arg(mut self, arg: impl Into<String>) -> Self {
        self.argv.push(arg.into());
        self
    }

    /// Append multiple argv tokens.
    #[must_use]
    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.argv.extend(args.into_iter().map(Into::into));
        self
    }

    /// Redacted argv for audit.
    #[must_use]
    pub fn redacted_argv(&self) -> Vec<String> {
        self.argv
            .iter()
            .enumerate()
            .map(|(i, v)| {
                if self.redacted_arg_indexes.contains(&i) {
                    "<redacted>".into()
                } else {
                    v.clone()
                }
            })
            .collect()
    }
}

/// Execution status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExecutionStatus {
    /// Process exited successfully.
    Success,
    /// Process exited with non-zero status.
    Failed,
    /// Process exceeded timeout.
    TimedOut,
    /// Combined output exceeded cap.
    OutputTooLarge,
}

/// Execution result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
    /// Exit code if process exited normally.
    pub exit_code: Option<i32>,
    /// Status classification.
    pub status: ExecutionStatus,
    /// Stdout text, capped by policy.
    pub stdout: String,
    /// Stderr text, capped by policy.
    pub stderr: String,
    /// Parsed structured output when output_mode is json/sarif and parsing succeeds.
    pub structured: Option<Value>,
    /// Start time.
    pub started_at: DateTime<Utc>,
    /// Duration in milliseconds.
    pub duration_ms: u128,
}

/// Runtime errors.
#[derive(Debug, Error)]
pub enum RuntimeError {
    /// Command execution timed out.
    #[error("command timed out")]
    TimedOut,
    /// Process spawn or wait failed.
    #[error("command I/O failed: {0}")]
    Io(#[from] std::io::Error),
    /// Output was larger than allowed.
    #[error("output exceeded max_output_bytes")]
    OutputTooLarge,
}

/// No-shell executor.
#[derive(Debug, Clone, Default)]
pub struct Executor;

impl Executor {
    /// Execute a plan without invoking a shell.
    pub async fn execute(&self, plan: ExecutionPlan) -> Result<ExecutionResult, RuntimeError> {
        reject_shell_tokens(&plan)?;

        let started_at = Utc::now();
        let start = std::time::Instant::now();
        let mut cmd = Command::new(&plan.binary);
        cmd.args(&plan.argv);
        if let Some(cwd) = &plan.cwd {
            cmd.current_dir(cwd);
        }
        apply_env_plan(&mut cmd, &plan.env);
        cmd.stdin(Stdio::null());
        cmd.stdout(Stdio::piped());
        cmd.stderr(Stdio::piped());

        let output = match timeout(plan.timeout, cmd.output()).await {
            Ok(output) => output?,
            Err(_) => {
                return Ok(ExecutionResult {
                    exit_code: None,
                    status: ExecutionStatus::TimedOut,
                    stdout: String::new(),
                    stderr: String::new(),
                    structured: None,
                    started_at,
                    duration_ms: start.elapsed().as_millis(),
                })
            }
        };

        let stdout = cap_to_string(&output.stdout, plan.max_output_bytes)?;
        let remaining = plan.max_output_bytes.saturating_sub(stdout.len());
        let stderr = cap_to_string(&output.stderr, remaining)?;
        // For structured modes, parse from the RAW stdout bytes with strict
        // UTF-8 (not the lossy `stdout` string). Invalid UTF-8 yields no
        // structured value rather than silently corrupting it with U+FFFD.
        let structured = match plan.output_mode {
            OutputMode::Json | OutputMode::Sarif => std::str::from_utf8(&output.stdout)
                .ok()
                .and_then(|text| serde_json::from_str(text).ok()),
            OutputMode::Text => None,
        };
        let status = if output.status.success() {
            ExecutionStatus::Success
        } else {
            ExecutionStatus::Failed
        };

        Ok(ExecutionResult {
            exit_code: output.status.code(),
            status,
            stdout,
            stderr,
            structured,
            started_at,
            duration_ms: start.elapsed().as_millis(),
        })
    }
}

/// Resolve a CLI binary without invoking a shell.
///
/// Resolution order, hardened against PATH binary-shadowing:
/// 1. If `MCPACT_BINARY_PIN` is set to an absolute path, it is used verbatim
///    (after an existence check). This lets operators pin the exact wrapped CLI
///    and bypass PATH lookup entirely.
/// 2. If `binary` itself contains a path separator, it is treated as an explicit
///    (absolute or relative) path and used directly — no PATH search.
/// 3. Otherwise the bare name is resolved against the process `PATH`. NOTE: a
///    writable directory earlier on `PATH` can shadow the intended CLI; prefer
///    an absolute pin (1 or 2) in untrusted environments.
pub fn resolve_binary(binary: &str) -> Result<PathBuf, RuntimeError> {
    // (1) Absolute pin via environment overrides PATH lookup entirely.
    if let Some(pin) = env::var_os("MCPACT_BINARY_PIN") {
        let pin_path = PathBuf::from(&pin);
        if pin_path.is_absolute() {
            if pin_path.is_file() {
                return Ok(pin_path);
            }
            return Err(RuntimeError::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("MCPACT_BINARY_PIN points to a missing file: {}", pin_path.display()),
            )));
        }
        return Err(RuntimeError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "MCPACT_BINARY_PIN must be an absolute path",
        )));
    }

    let path = Path::new(binary);
    if binary.contains('/') || binary.contains('\\') {
        if path.is_file() {
            return Ok(path.to_path_buf());
        }
        return Err(RuntimeError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("binary not found: {binary}"),
        )));
    }
    let paths = env::var_os("PATH")
        .map(|raw| env::split_paths(&raw).collect::<Vec<_>>())
        .unwrap_or_default();
    if let Some(candidate) = resolve_binary_in_paths(binary, &paths) {
        return Ok(candidate);
    }
    Err(RuntimeError::Io(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!("binary `{binary}` not found on PATH"),
    )))
}

fn resolve_binary_in_paths(binary: &str, paths: &[PathBuf]) -> Option<PathBuf> {
    let mut candidates = vec![binary.to_string()];
    if cfg!(windows) && Path::new(binary).extension().is_none() {
        candidates.push(format!("{binary}.exe"));
    }
    for dir in paths {
        for name in &candidates {
            let candidate = dir.join(name);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
    }
    None
}

fn apply_env_plan(cmd: &mut Command, env_plan: &EnvPlan) {
    if env_plan.inherit {
        if !env_plan.allow.is_empty() {
            let snapshot: BTreeMap<String, String> = env::vars().collect();
            cmd.env_clear();
            for key in &env_plan.allow {
                if let Some(value) = snapshot.get(key) {
                    cmd.env(key, value);
                }
            }
        }
    } else {
        cmd.env_clear();
    }
    for (key, value) in &env_plan.set {
        cmd.env(key, value);
    }
}

/// Shell binary stems rejected as the program. Matched against the file stem so
/// `/usr/bin/bash`, `bash.exe`, etc. are all caught — parity with
/// `SafeCommand::validate`.
const SHELL_BINARY_STEMS: &[&str] = &[
    "sh", "bash", "zsh", "fish", "ksh", "dash", "cmd", "command", "powershell", "pwsh",
];

/// Shell-injection metacharacters rejected in any argv token (and the program).
/// Mirrors `safe::SHELL_INJECT_PATTERNS` so the production `Executor` path has
/// the same hardening as the by-construction `SafeCommand` primitive.
const SHELL_INJECT_PATTERNS: &[&str] = &[
    ";", "&&", "||", "|", "`", "$(", "${", ">", "<", "&", "\n", "\r",
];

fn reject_shell_tokens(plan: &ExecutionPlan) -> Result<(), RuntimeError> {
    // Reject shell programs by file stem (handles absolute paths and .exe).
    let program_stem = Path::new(&plan.binary)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(plan.binary.as_str())
        .to_ascii_lowercase();
    if SHELL_BINARY_STEMS.contains(&program_stem.as_str()) {
        return Err(RuntimeError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "shell binaries are not allowed in McPact execution plans",
        )));
    }
    if plan.argv.iter().any(|a| a == "-c" || a == "/c") {
        return Err(RuntimeError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "shell command flags are not allowed in McPact execution plans",
        )));
    }
    // Per-token shell-metacharacter scan over program + argv. argv is passed
    // directly to the OS (no shell), but a token containing these patterns is a
    // strong signal of an injection attempt and is rejected to keep parity with
    // SafeCommand and the adversarial corpus.
    for token in std::iter::once(&plan.binary).chain(plan.argv.iter()) {
        for pat in SHELL_INJECT_PATTERNS {
            if token.contains(pat) {
                return Err(RuntimeError::Io(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    format!("shell injection pattern detected in token: {token:?}"),
                )));
            }
        }
    }
    Ok(())
}

fn cap_to_string(bytes: &[u8], max: usize) -> Result<String, RuntimeError> {
    if bytes.len() > max {
        return Err(RuntimeError::OutputTooLarge);
    }
    Ok(String::from_utf8_lossy(bytes).to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::process::Command as StdCommand;
    use std::sync::OnceLock;

    fn helper_binary() -> &'static PathBuf {
        static HELPER: OnceLock<PathBuf> = OnceLock::new();
        HELPER.get_or_init(|| {
            let dir =
                std::env::temp_dir().join(format!("mcpact-runtime-helper-{}", std::process::id()));
            fs::create_dir_all(&dir).unwrap();
            let source = dir.join("helper.rs");
            let binary = dir.join(if cfg!(windows) {
                "mcpact-runtime-helper.exe"
            } else {
                "mcpact-runtime-helper"
            });
            fs::write(
                &source,
                r#"use std::{env, process::ExitCode, thread, time::Duration};

fn main() -> ExitCode {
    let mut args = env::args().skip(1);
    match args.next().as_deref() {
        Some("echo") => {
            println!("{}", args.next().unwrap_or_default());
            ExitCode::SUCCESS
        }
        Some("sleep-ms") => {
            let ms: u64 = args
                .next()
                .expect("missing milliseconds")
                .parse()
                .expect("invalid milliseconds");
            thread::sleep(Duration::from_millis(ms));
            ExitCode::SUCCESS
        }
        other => {
            eprintln!("unknown helper mode: {:?}", other);
            ExitCode::from(2)
        }
    }
}
"#,
            )
            .unwrap();
            let status = StdCommand::new("rustc")
                .arg("--edition=2021")
                .arg(&source)
                .arg("-o")
                .arg(&binary)
                .status()
                .expect("compile runtime helper");
            assert!(status.success(), "runtime helper should compile");
            binary
        })
    }

    #[test]
    fn redacts_selected_argv() {
        let mut plan = ExecutionPlan::new("tool").args(["--token", "secret"]);
        plan.redacted_arg_indexes.push(1);
        assert_eq!(plan.redacted_argv(), vec!["--token", "<redacted>"]);
    }

    #[tokio::test]
    async fn rejects_shell_binary() {
        let plan = ExecutionPlan::new("sh").args(["-c", "echo no"]);
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell"));
    }

    #[tokio::test]
    async fn rejects_shell_c_flag_on_safe_binary() {
        let plan = ExecutionPlan::new("cargo").args(["-c", "no"]);
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell command flags"));
    }

    #[tokio::test]
    async fn rejects_powershell_binary() {
        let plan = ExecutionPlan::new("pwsh").arg("Get-ChildItem");
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell binaries"));
    }

    #[tokio::test]
    async fn rejects_shell_binary_by_absolute_path() {
        let plan = ExecutionPlan::new("/usr/bin/bash").arg("script.sh");
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell binaries"));
    }

    #[tokio::test]
    async fn rejects_shell_metachar_in_argv() {
        let plan = ExecutionPlan::new("cargo").args(["foo;rm -rf /"]);
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell injection"));
    }

    #[tokio::test]
    async fn rejects_command_substitution_in_argv() {
        let plan = ExecutionPlan::new("cargo").args(["$(id)"]);
        let err = Executor.execute(plan).await.unwrap_err();
        assert!(format!("{err}").contains("shell injection"));
    }

    #[tokio::test]
    async fn executes_echo_successfully() {
        let plan =
            ExecutionPlan::new(helper_binary().to_string_lossy().into_owned()).args(["echo", "ok"]);
        let result = Executor.execute(plan).await.unwrap();
        assert_eq!(result.status, ExecutionStatus::Success);
        assert!(result.stdout.contains("ok"));
    }

    #[test]
    fn resolves_cargo_on_path() {
        let resolved = resolve_binary("cargo").expect("cargo should exist on PATH");
        assert!(resolved.is_file());
    }

    #[test]
    fn resolves_windows_exe_suffix_from_path() {
        let dir = tempfile::tempdir().unwrap();
        let binary = if cfg!(windows) {
            "fixture-tool.exe"
        } else {
            "fixture-tool"
        };
        let path = dir.path().join(binary);
        std::fs::write(&path, b"fixture").unwrap();
        let resolved =
            resolve_binary_in_paths("fixture-tool", &[dir.path().to_path_buf()]).unwrap();
        assert_eq!(resolved, path);
    }

    #[tokio::test]
    async fn times_out_long_running_command() {
        let mut plan = ExecutionPlan::new(helper_binary().to_string_lossy().into_owned())
            .args(["sleep-ms", "2000"]);
        plan.timeout = std::time::Duration::from_millis(100);
        let result = Executor.execute(plan).await.unwrap();
        assert_eq!(result.status, ExecutionStatus::TimedOut);
    }

    // -----------------------------------------------------------------------
    // Failure-mode test M-1: env_clear runtime enforcement
    //
    // Documents and locks in the verdict from
    // value-sheet/18-cross-product-test/v2/results/per-tool-failure-mode-tests-results/composite.md
    // (test M-1, 2026-05-19): apply_env_plan MUST call cmd.env_clear() on the
    // non-inherit branch. This is the runtime half of the M-1 invariant
    // (manifest half: env_spec_default_does_not_inherit_env in
    // mcpact-manifest::tests).
    // -----------------------------------------------------------------------

    /// Build a small Rust helper that writes its observed env (one VAR=VAL
    /// per line) to stdout. Returns its absolute path. Cached per-test-process
    /// so we don't recompile on every test.
    fn env_print_helper() -> &'static PathBuf {
        static HELPER: OnceLock<PathBuf> = OnceLock::new();
        HELPER.get_or_init(|| {
            let dir = std::env::temp_dir()
                .join(format!("mcpact-env-print-helper-{}", std::process::id()));
            fs::create_dir_all(&dir).unwrap();
            let source = dir.join("env_print_helper.rs");
            let binary = dir.join(if cfg!(windows) {
                "mcpact-env-print-helper.exe"
            } else {
                "mcpact-env-print-helper"
            });
            fs::write(
                &source,
                r#"use std::process::ExitCode;
fn main() -> ExitCode {
    for (k, v) in std::env::vars() {
        println!("{k}={v}");
    }
    ExitCode::SUCCESS
}
"#,
            )
            .unwrap();
            let status = StdCommand::new("rustc")
                .arg("--edition=2021")
                .arg(&source)
                .arg("-o")
                .arg(&binary)
                .status()
                .expect("compile env-print helper");
            assert!(status.success(), "env-print helper should compile");
            binary
        })
    }

    #[test]
    fn env_plan_default_does_not_inherit_env() {
        // Failure-mode test M-1 — see value-sheet/18-cross-product-test/v2/results/per-tool-failure-mode-tests-results/composite.md
        let plan = EnvPlan::default();
        assert!(
            !plan.inherit,
            "EnvPlan::default().inherit MUST be false; the runtime non-inherit branch is what calls cmd.env_clear()"
        );
    }

    #[tokio::test]
    async fn apply_env_plan_non_inherit_clears_parent_env() {
        // Failure-mode test M-1 — runtime env_clear enforcement check.
        //
        // Set a canary env var in this process; spawn the env-print helper
        // via ExecutionPlan with the default (non-inherit) EnvPlan; assert
        // the canary is absent from the subprocess's env. This catches
        // structural regressions in `Command::env_clear` invocation — it's
        // a real os-level check, not just a type-level one.
        let canary_key = format!("MCPACT_TEST_CANARY_{}", std::process::id());
        let canary_val = "must-not-leak-to-child";
        // SAFETY: env::set_var is safe in test context; concurrent tests
        // could race but the canary key is process-id-scoped.
        std::env::set_var(&canary_key, canary_val);

        let plan = ExecutionPlan::new(env_print_helper().to_string_lossy().into_owned());
        let result = Executor.execute(plan).await.expect("execute env-print");
        assert_eq!(result.status, ExecutionStatus::Success);

        // The canary MUST NOT appear in subprocess env.
        assert!(
            !result.stdout.contains(&canary_key),
            "M-1 regression: parent env leaked to subprocess! Looking for {canary_key}, got stdout:\n{}",
            result.stdout
        );

        std::env::remove_var(&canary_key);
    }

    #[tokio::test]
    async fn apply_env_plan_inherit_with_allow_list_filters_env() {
        // Failure-mode test M-1 — verifies the inherit=true, allow-list path
        // also clears env first and then only passes the allow-listed vars.
        let canary_key = format!("MCPACT_TEST_CANARY_INHERIT_{}", std::process::id());
        let allowed_key = format!("MCPACT_TEST_ALLOWED_INHERIT_{}", std::process::id());
        std::env::set_var(&canary_key, "must-not-leak");
        std::env::set_var(&allowed_key, "allowed-value");

        let mut plan = ExecutionPlan::new(env_print_helper().to_string_lossy().into_owned());
        plan.env.inherit = true;
        plan.env.allow = vec![allowed_key.clone()];

        let result = Executor.execute(plan).await.expect("execute env-print");
        assert_eq!(result.status, ExecutionStatus::Success);

        assert!(
            !result.stdout.contains(&canary_key),
            "M-1 regression: non-allow-listed env var leaked under inherit+allow: looking for {canary_key}"
        );
        assert!(
            result
                .stdout
                .contains(&format!("{allowed_key}=allowed-value")),
            "M-1 regression: allow-listed env var did not reach child; stdout:\n{}",
            result.stdout
        );

        std::env::remove_var(&canary_key);
        std::env::remove_var(&allowed_key);
    }
}
