//! HTTP client for the cockpit daemon.
//!
//! One `HttpClient` per `DaemonEndpoint`; methods map 1:1 to the
//! per-session cockpit REST surface (`/api/sessions/{id}/cockpit/*`).
//! Auth: the endpoint's optional `token` is sent as
//! `Authorization: Bearer <token>` on every request, never as a
//! query string, so it doesn't leak via logs or `ps`.

use std::time::Duration;

use reqwest::{header, StatusCode};
use thiserror::Error;

use super::discovery::DaemonEndpoint;
use crate::cockpit::protocol::{
    ApprovalDecisionWire, ContextPrimerResponse, PromptRequest, ReplayResponse,
    ResolveApprovalRequest,
};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);

/// Cockpit daemon HTTP client. Cheap to clone; the underlying
/// `reqwest::Client` is reference-counted.
#[derive(Debug, Clone)]
pub struct HttpClient {
    http: reqwest::Client,
    endpoint: DaemonEndpoint,
}

#[derive(Debug, Error)]
pub enum HttpError {
    #[error("transport error: {0}")]
    Transport(#[from] reqwest::Error),
    #[error("cockpit session {0} not found on the daemon")]
    SessionNotFound(String),
    #[error("daemon is read-only (started with --read-only); request refused")]
    ReadOnly,
    #[error("authentication failed; check AOE_DAEMON_TOKEN or restart `aoe serve`")]
    Unauthorized,
    #[error("daemon returned HTTP {status}: {body}")]
    Server { status: StatusCode, body: String },
}

impl HttpClient {
    pub fn new(endpoint: DaemonEndpoint) -> Result<Self, HttpError> {
        let http = reqwest::Client::builder()
            .timeout(DEFAULT_TIMEOUT)
            .user_agent(concat!("aoe-cockpit-client/", env!("CARGO_PKG_VERSION")))
            .build()?;
        Ok(Self { http, endpoint })
    }

    pub fn endpoint(&self) -> &DaemonEndpoint {
        &self.endpoint
    }

    /// `GET /api/sessions/{id}/cockpit/replay?since=N`.
    pub async fn replay(&self, session_id: &str, since: u64) -> Result<ReplayResponse, HttpError> {
        let url = format!(
            "{}/api/sessions/{}/cockpit/replay?since={}",
            self.endpoint.base_url, session_id, since
        );
        let res = self.auth(self.http.get(&url)).send().await?;
        let res = check_status(res, session_id).await?;
        Ok(res.json::<ReplayResponse>().await?)
    }

    /// `GET /api/sessions/{id}/cockpit/context-primer?before_seq=N`.
    pub async fn context_primer(
        &self,
        session_id: &str,
        before_seq: u64,
    ) -> Result<ContextPrimerResponse, HttpError> {
        let url = format!(
            "{}/api/sessions/{}/cockpit/context-primer?before_seq={}",
            self.endpoint.base_url, session_id, before_seq
        );
        let res = self.auth(self.http.get(&url)).send().await?;
        let res = check_status(res, session_id).await?;
        Ok(res.json::<ContextPrimerResponse>().await?)
    }

    /// `POST /api/sessions/{id}/cockpit/prompt`.
    pub async fn prompt(&self, session_id: &str, text: &str) -> Result<(), HttpError> {
        let url = format!(
            "{}/api/sessions/{}/cockpit/prompt",
            self.endpoint.base_url, session_id
        );
        let body = PromptRequest {
            text: text.to_string(),
        };
        let res = self.auth(self.http.post(&url)).json(&body).send().await?;
        check_status(res, session_id).await?;
        Ok(())
    }

    /// `POST /api/sessions/{id}/cockpit/cancel`.
    pub async fn cancel(&self, session_id: &str) -> Result<(), HttpError> {
        let url = format!(
            "{}/api/sessions/{}/cockpit/cancel",
            self.endpoint.base_url, session_id
        );
        let res = self.auth(self.http.post(&url)).send().await?;
        check_status(res, session_id).await?;
        Ok(())
    }

    /// `POST /api/sessions/{id}/cockpit/approvals/{nonce}`.
    pub async fn resolve_approval(
        &self,
        session_id: &str,
        nonce: &str,
        decision: ApprovalDecisionWire,
    ) -> Result<(), HttpError> {
        let url = format!(
            "{}/api/sessions/{}/cockpit/approvals/{}",
            self.endpoint.base_url, session_id, nonce
        );
        let body = ResolveApprovalRequest { decision };
        let res = self.auth(self.http.post(&url)).json(&body).send().await?;
        check_status(res, session_id).await?;
        Ok(())
    }

    /// `GET /api/sessions`. Returns the daemon's session list as
    /// whatever shape the caller deserialises into. Used by the
    /// remote-cockpit picker so the bespoke `reqwest::Client` it used
    /// to keep can be retired in favour of the shared auth/header
    /// plumbing.
    pub async fn list_sessions<T: serde::de::DeserializeOwned>(&self) -> Result<Vec<T>, HttpError> {
        let url = format!("{}/api/sessions", self.endpoint.base_url);
        let res = self.auth(self.http.get(&url)).send().await?;
        let res = check_status(res, "<sessions>").await?;
        Ok(res.json::<Vec<T>>().await?)
    }

    /// Lightweight reachability probe used by `require_daemon` (when
    /// `AOE_DAEMON_URL` is set, we fail loud before falling into raw
    /// reqwest transport errors) and `aoe serve --status` (renders
    /// remote daemon info instead of "Daemon: not running").
    ///
    /// Hits `GET /api/sessions`, the cheapest authenticated endpoint
    /// in the surface; succeeds with 200 when the daemon is up *and*
    /// the token is valid, separates "host is down" (transport error)
    /// from "auth misconfigured" (401).
    pub async fn health_check(&self) -> Result<(), HttpError> {
        let url = format!("{}/api/sessions", self.endpoint.base_url);
        let res = self.auth(self.http.get(&url)).send().await?;
        let status = res.status();
        if status.is_success() {
            return Ok(());
        }
        let body = res.text().await.unwrap_or_default();
        match status {
            StatusCode::UNAUTHORIZED => Err(HttpError::Unauthorized),
            _ => Err(HttpError::Server { status, body }),
        }
    }

    fn auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        match &self.endpoint.token {
            Some(token) => builder.header(header::AUTHORIZATION, format!("Bearer {token}")),
            None => builder,
        }
    }
}

async fn check_status(
    res: reqwest::Response,
    session_id: &str,
) -> Result<reqwest::Response, HttpError> {
    let status = res.status();
    if status.is_success() {
        return Ok(res);
    }
    let body = res.text().await.unwrap_or_default();
    match status {
        StatusCode::UNAUTHORIZED => Err(HttpError::Unauthorized),
        StatusCode::FORBIDDEN if body.contains("read-only") || body.contains("read_only") => {
            Err(HttpError::ReadOnly)
        }
        StatusCode::NOT_FOUND => Err(HttpError::SessionNotFound(session_id.to_string())),
        _ => Err(HttpError::Server { status, body }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cockpit::client::discovery::Source;

    fn endpoint(base: &str, token: Option<&str>) -> DaemonEndpoint {
        DaemonEndpoint {
            base_url: base.to_string(),
            token: token.map(str::to_string),
            source: Source::Env,
        }
    }

    #[test]
    fn auth_sets_bearer_when_token_present() {
        let client = HttpClient::new(endpoint("http://127.0.0.1:8080", Some("tok"))).unwrap();
        // Smoke-check by reading endpoint back; full header inspection
        // requires a live request and lives in the integration tests
        // alongside the axum mock.
        assert_eq!(client.endpoint().token.as_deref(), Some("tok"));
    }

    #[test]
    fn auth_skips_bearer_when_no_token() {
        let client = HttpClient::new(endpoint("http://127.0.0.1:8080", None)).unwrap();
        assert!(client.endpoint().token.is_none());
    }
}
