//! Hetzner Cloud CLI wrapper — thin typed API over `hcloud --output json`.
//!
//! All reads use a 15s timeout; mutations use 60s.
//! The `HCLOUD_TOKEN` env var is forwarded transparently — callers may override
//! by passing a token explicitly to `HcloudClient::with_token`.

use crate::protocol::ToolError;
use crate::providers::generic_cli::run_json;
use std::collections::HashMap;
use std::time::Duration;

const READ_TIMEOUT: Duration = Duration::from_secs(15);
const MUTATE_TIMEOUT: Duration = Duration::from_secs(60);

/// Client for the `hcloud` CLI. The token is injected via `HCLOUD_TOKEN` env var.
#[derive(Debug, Clone)]
pub struct HcloudClient {
    binary: String,
    env: HashMap<String, String>,
}

impl HcloudClient {
    /// Use `hcloud` from PATH with `HCLOUD_TOKEN` inherited from the process environment.
    pub fn new() -> Self {
        Self { binary: "hcloud".into(), env: HashMap::new() }
    }

    /// Override the token — useful for per-workspace credentials.
    pub fn with_token(mut self, token: impl Into<String>) -> Self {
        self.env.insert("HCLOUD_TOKEN".into(), token.into());
        self
    }

    /// Use a different binary path (e.g. for tests via `HCLOUD_BINARY=echo`).
    pub fn with_binary(mut self, binary: impl Into<String>) -> Self {
        self.binary = binary.into();
        self
    }

    // ── Servers ───────────────────────────────────────────────────────────────

    pub async fn server_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["server", "list", "--output", "json"]).await
    }

    pub async fn server_describe(&self, name: &str) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["server", "describe", name, "--output", "json"]).await
    }

    pub async fn server_poweron(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["server", "poweron", name]).await
    }

    pub async fn server_shutdown(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["server", "shutdown", name]).await
    }

    pub async fn server_delete(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["server", "disable-protection", name, "delete", "rebuild"])
            .await?;
        self.mutate(&["server", "delete", name]).await
    }

    pub async fn server_change_type(
        &self,
        name: &str,
        server_type: &str,
    ) -> Result<(), ToolError> {
        self.mutate(&["server", "change-type", name, server_type]).await
    }

    // ── Volumes ───────────────────────────────────────────────────────────────

    pub async fn volume_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["volume", "list", "--output", "json"]).await
    }

    pub async fn volume_describe(&self, name: &str) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["volume", "describe", name, "--output", "json"]).await
    }

    pub async fn volume_create(
        &self,
        name: &str,
        size_gb: u32,
        location: &str,
        format: Option<&str>,
    ) -> Result<serde_json::Value, ToolError> {
        let size_str = size_gb.to_string();
        let mut args = vec![
            "volume", "create",
            "--name", name,
            "--size", &size_str,
            "--location", location,
            "--output", "json",
        ];
        if let Some(fmt) = format {
            args.push("--format");
            args.push(fmt);
        }
        self.mutate_json(&args).await
    }

    pub async fn volume_attach(&self, volume: &str, server: &str) -> Result<(), ToolError> {
        self.mutate(&["volume", "attach", volume, "--server", server]).await
    }

    pub async fn volume_detach(&self, volume: &str) -> Result<(), ToolError> {
        self.mutate(&["volume", "detach", volume]).await
    }

    pub async fn volume_delete(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["volume", "disable-protection", name, "delete"])
            .await?;
        self.mutate(&["volume", "delete", name]).await
    }

    // ── Floating IPs ──────────────────────────────────────────────────────────

    pub async fn floating_ip_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["floating-ip", "list", "--output", "json"]).await
    }

    pub async fn floating_ip_describe(&self, name: &str) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["floating-ip", "describe", name, "--output", "json"]).await
    }

    pub async fn floating_ip_unassign(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["floating-ip", "unassign", name]).await
    }

    pub async fn floating_ip_delete(&self, name: &str) -> Result<(), ToolError> {
        self.mutate(&["floating-ip", "disable-protection", name, "delete"])
            .await?;
        self.mutate(&["floating-ip", "delete", name]).await
    }

    // ── Networks ──────────────────────────────────────────────────────────────

    pub async fn network_describe(&self, name: &str) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["network", "describe", name, "--output", "json"]).await
    }

    // ── Firewalls ─────────────────────────────────────────────────────────────

    pub async fn firewall_describe(&self, name: &str) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["firewall", "describe", name, "--output", "json"]).await
    }

    // ── Images ────────────────────────────────────────────────────────────────

    pub async fn image_list(&self, image_type: Option<&str>) -> Result<serde_json::Value, ToolError> {
        let mut args = vec!["image", "list", "--output", "json"];
        if let Some(t) = image_type {
            args.push("--type");
            args.push(t);
        }
        self.read_json(&args).await
    }

    pub async fn image_delete(&self, id: &str) -> Result<(), ToolError> {
        self.mutate(&["image", "delete", id]).await
    }

    // ── Locations / Datacenters ───────────────────────────────────────────────

    pub async fn location_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["location", "list", "--output", "json"]).await
    }

    pub async fn datacenter_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["datacenter", "list", "--output", "json"]).await
    }

    // ── Contexts ──────────────────────────────────────────────────────────────

    pub async fn context_list(&self) -> Result<serde_json::Value, ToolError> {
        self.read_json(&["context", "list", "--output", "json"]).await
    }

    // ── Internal ──────────────────────────────────────────────────────────────

    async fn read_json(&self, args: &[&str]) -> Result<serde_json::Value, ToolError> {
        run_json(&self.binary, args, &self.env, READ_TIMEOUT).await
    }

    async fn mutate(&self, args: &[&str]) -> Result<(), ToolError> {
        run_json(&self.binary, args, &self.env, MUTATE_TIMEOUT).await.map(|_| ())
    }

    async fn mutate_json(&self, args: &[&str]) -> Result<serde_json::Value, ToolError> {
        run_json(&self.binary, args, &self.env, MUTATE_TIMEOUT).await
    }
}

impl Default for HcloudClient {
    fn default() -> Self {
        Self::new()
    }
}

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

    /// Return a client that uses `echo` as its binary.
    /// `hcloud server list --output json` becomes `echo server list --output json`.
    /// Echo prints its args as-is — not valid JSON, so we test error paths;
    /// JSON-success paths require integration with a real/mock hcloud.
    fn echo_client() -> HcloudClient {
        HcloudClient::new().with_binary("echo")
    }

    #[tokio::test]
    async fn server_list_with_echo_fails_json_parse() {
        let client = echo_client();
        let err = client.server_list().await.unwrap_err();
        // echo prints plain text → JSON parse fails with Invocation
        assert!(matches!(err, ToolError::Invocation(_)));
    }

    #[tokio::test]
    async fn missing_hcloud_binary_returns_not_found() {
        let client = HcloudClient::new().with_binary("__no_hcloud__");
        let err = client.server_list().await.unwrap_err();
        assert!(matches!(err, ToolError::NotFound(_)));
    }

    #[tokio::test]
    async fn with_token_sets_env() {
        let client = HcloudClient::new().with_token("tok-test");
        assert_eq!(client.env.get("HCLOUD_TOKEN").map(|s| s.as_str()), Some("tok-test"));
    }

    #[test]
    fn default_binary_is_hcloud() {
        let c = HcloudClient::new();
        assert_eq!(c.binary, "hcloud");
    }
}
