//! Async CLI runner — subprocess with timeout, stdout/stderr capture, JSON parse.
//!
//! All provider wrappers (hcloud, aws, kubectl, …) delegate here rather than
//! invoking `tokio::process::Command` directly.

use crate::protocol::ToolError;
use std::collections::HashMap;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;

/// Default per-invocation timeout. Provider wrappers may pass a shorter value
/// for read-only list/describe calls and a longer one for mutations.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Result of a subprocess invocation.
#[derive(Debug, Clone)]
pub struct CliOutput {
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
}

impl CliOutput {
    pub fn success(&self) -> bool {
        self.exit_code == 0
    }

    /// Parse stdout as JSON. Call after verifying `success()`.
    pub fn parse_json(&self) -> Result<serde_json::Value, ToolError> {
        serde_json::from_str(&self.stdout).map_err(|e| {
            ToolError::invocation(format!("CLI output is not valid JSON: {e}\nstdout: {}", self.stdout.chars().take(200).collect::<String>()))
        })
    }
}

/// Run `binary` with `args` and optional extra environment variables.
///
/// Returns `ToolError::Invocation` on timeout, spawn failure, or non-zero exit.
/// Returns `ToolError::NotFound` when the binary is absent from PATH.
pub async fn run(
    binary: &str,
    args: &[&str],
    env: &HashMap<String, String>,
    deadline: Duration,
) -> Result<CliOutput, ToolError> {
    let mut cmd = Command::new(binary);
    cmd.args(args).envs(env).kill_on_drop(true);

    let task = cmd.output();
    let result = timeout(deadline, task).await.map_err(|_| {
        ToolError::invocation(format!(
            "{binary} timed out after {}s (args: {})",
            deadline.as_secs(),
            args.join(" ")
        ))
    })?;

    let output = result.map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            ToolError::not_found(format!("binary '{binary}' not found in PATH"))
        } else {
            ToolError::invocation(format!("failed to spawn '{binary}': {e}"))
        }
    })?;

    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    let exit_code = output.status.code().unwrap_or(-1);

    if !output.status.success() {
        return Err(ToolError::invocation(format!(
            "{binary} {}: exit {exit_code}\n{stderr}",
            args.join(" "),
        )));
    }

    Ok(CliOutput { stdout, stderr, exit_code })
}

/// Convenience wrapper: run and parse stdout as JSON in one call.
pub async fn run_json(
    binary: &str,
    args: &[&str],
    env: &HashMap<String, String>,
    deadline: Duration,
) -> Result<serde_json::Value, ToolError> {
    run(binary, args, env, deadline).await?.parse_json()
}

/// Build args list from a base + optional flag pairs. Skips `None` values.
///
/// Example: `args_with_flags("hcloud", &["server", "create"], &[("--name", Some("web1")), ("--type", None)])`
/// → `["server", "create", "--name", "web1"]`
pub fn args_with_flags<'a>(
    base: &[&'a str],
    flags: &[(&'a str, Option<&'a str>)],
) -> Vec<&'a str> {
    let mut out: Vec<&str> = base.to_vec();
    for (flag, val) in flags {
        if let Some(v) = val {
            out.push(flag);
            out.push(v);
        }
    }
    out
}

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

    fn no_env() -> HashMap<String, String> {
        HashMap::new()
    }

    #[tokio::test]
    async fn run_echo_returns_output() {
        let out = run("echo", &["hello"], &no_env(), DEFAULT_TIMEOUT).await.unwrap();
        assert!(out.success());
        assert!(out.stdout.contains("hello"));
    }

    #[tokio::test]
    async fn missing_binary_returns_not_found() {
        let err = run("__nonexistent_binary__", &[], &no_env(), DEFAULT_TIMEOUT)
            .await
            .unwrap_err();
        assert!(matches!(err, ToolError::NotFound(_)));
    }

    #[tokio::test]
    async fn nonzero_exit_returns_invocation_error() {
        let err = run("false", &[], &no_env(), DEFAULT_TIMEOUT).await.unwrap_err();
        assert!(matches!(err, ToolError::Invocation(_)));
    }

    #[tokio::test]
    async fn timeout_returns_invocation_error() {
        // `sleep 5` will be killed after 50ms
        let err = run("sleep", &["5"], &no_env(), Duration::from_millis(50))
            .await
            .unwrap_err();
        assert!(matches!(err, ToolError::Invocation(_)));
    }

    #[tokio::test]
    async fn run_json_parses_echo_json() {
        let val = run_json(
            "echo",
            &[r#"{"ok":true}"#],
            &no_env(),
            DEFAULT_TIMEOUT,
        )
        .await
        .unwrap();
        assert_eq!(val["ok"], true);
    }

    #[test]
    fn args_with_flags_skips_none() {
        let base = &["server", "create"];
        let flags = &[("--name", Some("web1")), ("--image", None), ("--type", Some("cx21"))];
        let result = args_with_flags(base, flags);
        assert_eq!(result, &["server", "create", "--name", "web1", "--type", "cx21"]);
    }
}
