//! HTTP client for the provisioning orchestrator API (default port 9011).
//!
//! Wire types are defined locally — provisioning-core must not depend on the
//! orchestrator crate to avoid pulling in its `#![allow(...)]` blanket and its
//! heavyweight dependency tree.

use crate::protocol::ToolError;
use serde::{Deserialize, Serialize};

// ── Wire types ────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
pub enum TaskState {
    Pending,
    Running,
    Completed,
    Failed,
    Cancelled,
}

impl std::fmt::Display for TaskState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            Self::Pending => "Pending",
            Self::Running => "Running",
            Self::Completed => "Completed",
            Self::Failed => "Failed",
            Self::Cancelled => "Cancelled",
        };
        f.write_str(s)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSummary {
    pub id: String,
    pub name: String,
    pub status: TaskState,
    pub created_at: chrono::DateTime<chrono::Utc>,
    #[serde(default)]
    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(default)]
    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
    #[serde(default)]
    pub output: Option<String>,
    #[serde(default)]
    pub error: Option<String>,
}

/// Envelope returned by every orchestrator endpoint.
#[derive(Debug, Deserialize)]
struct ApiResponse<T> {
    #[allow(dead_code)]
    success: bool,
    data: Option<T>,
    error: Option<String>,
}

impl<T> ApiResponse<T> {
    fn into_data(self) -> Result<T, ToolError> {
        match (self.data, self.error) {
            (Some(d), _) => Ok(d),
            (None, Some(e)) => Err(ToolError::invocation(format!("orchestrator: {e}"))),
            (None, None) => Err(ToolError::invocation("orchestrator: empty response")),
        }
    }
}

// ── Client ────────────────────────────────────────────────────────────────────

/// Thin async HTTP client scoped to the provisioning orchestrator.
///
/// Construct once and share via `Arc<OrchestratorClient>`. All methods return
/// `ToolError` so tool implementations can use `?` directly.
#[derive(Debug, Clone)]
pub struct OrchestratorClient {
    client: reqwest::Client,
    base_url: String,
}

impl OrchestratorClient {
    /// `base_url` should be e.g. `"http://localhost:9011"` (no trailing slash).
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            client: reqwest::Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("reqwest client init cannot fail with these options"),
            base_url: base_url.into(),
        }
    }

    /// `GET /health` — returns `Ok(())` when the orchestrator is reachable and healthy.
    pub async fn health(&self) -> Result<(), ToolError> {
        let url = format!("{}/health", self.base_url);
        let resp = self
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| ToolError::invocation(format!("orchestrator unreachable: {e}")))?;
        if resp.status().is_success() {
            Ok(())
        } else {
            Err(ToolError::invocation(format!("orchestrator /health → {}", resp.status())))
        }
    }

    /// `GET /tasks` — all tasks, most recent first.
    pub async fn list_tasks(&self) -> Result<Vec<TaskSummary>, ToolError> {
        let url = format!("{}/tasks", self.base_url);
        self.get_json::<Vec<TaskSummary>>(&url).await
    }

    /// `GET /tasks/{id}`.
    pub async fn get_task(&self, id: &str) -> Result<TaskSummary, ToolError> {
        let url = format!("{}/tasks/{}", self.base_url, id);
        self.get_json::<TaskSummary>(&url).await
    }

    /// `GET /state/system/metrics` — raw JSON forwarded to callers.
    pub async fn system_metrics(&self) -> Result<serde_json::Value, ToolError> {
        let url = format!("{}/state/system/metrics", self.base_url);
        self.get_json::<serde_json::Value>(&url).await
    }

    /// `GET /state/system/health` — structured health status forwarded as raw JSON.
    pub async fn system_health(&self) -> Result<serde_json::Value, ToolError> {
        let url = format!("{}/state/system/health", self.base_url);
        self.get_json::<serde_json::Value>(&url).await
    }

    // ── internal ──────────────────────────────────────────────────────────────

    async fn get_json<T: serde::de::DeserializeOwned>(
        &self,
        url: &str,
    ) -> Result<T, ToolError> {
        let resp = self
            .client
            .get(url)
            .send()
            .await
            .map_err(|e| ToolError::invocation(format!("orchestrator request failed: {e}")))?;

        let status = resp.status();
        if status == reqwest::StatusCode::NOT_FOUND {
            return Err(ToolError::not_found(url.to_string()));
        }
        if !status.is_success() {
            return Err(ToolError::invocation(format!("orchestrator {url} → {status}")));
        }

        let envelope: ApiResponse<T> = resp.json().await.map_err(|e| {
            ToolError::invocation(format!("orchestrator response deserialisation: {e}"))
        })?;
        envelope.into_data()
    }
}

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

    fn make_task() -> serde_json::Value {
        serde_json::json!({
            "success": true,
            "data": [{
                "id": "task-1",
                "name": "create_server",
                "status": "Pending",
                "created_at": "2026-04-18T10:00:00Z"
            }]
        })
    }

    #[tokio::test]
    async fn list_tasks_parses_response() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("GET", "/tasks")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(make_task().to_string())
            .create_async()
            .await;

        let client = OrchestratorClient::new(server.url());
        let tasks = client.list_tasks().await.unwrap();
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0].id, "task-1");
        assert_eq!(tasks[0].status, TaskState::Pending);

        mock.assert_async().await;
    }

    #[tokio::test]
    async fn get_task_not_found_returns_not_found_error() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("GET", "/tasks/missing")
            .with_status(404)
            .create_async()
            .await;

        let client = OrchestratorClient::new(server.url());
        let err = client.get_task("missing").await.unwrap_err();
        assert!(matches!(err, ToolError::NotFound(_)));

        mock.assert_async().await;
    }

    #[tokio::test]
    async fn health_ok_returns_unit() {
        let mut server = Server::new_async().await;
        let mock = server
            .mock("GET", "/health")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"success":true,"data":"Orchestrator is healthy"}"#)
            .create_async()
            .await;

        let client = OrchestratorClient::new(server.url());
        client.health().await.unwrap();

        mock.assert_async().await;
    }

    #[tokio::test]
    async fn unreachable_host_returns_invocation_error() {
        let client = OrchestratorClient::new("http://127.0.0.1:1"); // nothing listening
        let err = client.health().await.unwrap_err();
        assert!(matches!(err, ToolError::Invocation(_)));
    }
}
