//! # mcias-client
//!
//! Async Rust client for the MCIAS (Metacircular Identity and Access System)
//! REST API.
//!
//! ## Usage
//!
//! ```rust,no_run
//! use mcias_client::{Client, ClientOptions};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//!     let client = Client::new("https://auth.example.com", ClientOptions::default())?;
//!
//!     let (token, expires_at) = client.login("alice", "s3cret", None).await?;
//!     println!("Logged in, token expires at {expires_at}");
//!
//!     client.logout().await?;
//!     Ok(())
//! }
//! ```
//!
//! ## Thread Safety
//!
//! [`Client`] is `Clone + Send + Sync`. The internally stored bearer token is
//! protected by an `Arc<tokio::sync::RwLock<...>>` so concurrent async tasks
//! may share a single client safely.

use std::sync::Arc;

use reqwest::{header, Certificate, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;

// ---- Error types ----

/// All errors returned by the MCIAS client.
#[derive(Debug, thiserror::Error)]
pub enum MciasError {
    /// HTTP 401 — authentication required or credentials invalid.
    #[error("authentication error: {0}")]
    Auth(String),

    /// HTTP 403 — caller lacks required role.
    #[error("permission denied: {0}")]
    Forbidden(String),

    /// HTTP 404 — requested resource does not exist.
    #[error("not found: {0}")]
    NotFound(String),

    /// HTTP 400 — the request payload was invalid.
    #[error("invalid input: {0}")]
    InvalidInput(String),

    /// HTTP 409 — resource conflict (e.g. duplicate username).
    #[error("conflict: {0}")]
    Conflict(String),

    /// HTTP 5xx — the server returned an internal error.
    #[error("server error ({status}): {message}")]
    Server { status: u16, message: String },

    /// Transport-level error (DNS failure, connection refused, timeout, etc.).
    #[error("transport error: {0}")]
    Transport(#[from] reqwest::Error),

    /// Response body could not be decoded.
    #[error("decode error: {0}")]
    Decode(String),
}

// ---- Public data types ----

/// Account information returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct Account {
    pub id: String,
    pub username: String,
    pub account_type: String,
    pub status: String,
    pub created_at: String,
    pub updated_at: String,
    pub totp_enabled: bool,
}

/// Result of a token validation request.
#[derive(Debug, Clone, Deserialize)]
pub struct TokenClaims {
    pub valid: bool,
    #[serde(default)]
    pub sub: String,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub expires_at: String,
}

/// The server's Ed25519 public key in JWK format.
#[derive(Debug, Clone, Deserialize)]
pub struct PublicKey {
    pub kty: String,
    pub crv: String,
    /// Key use — always `"sig"` for the MCIAS signing key.
    #[serde(rename = "use")]
    pub key_use: Option<String>,
    /// Algorithm — always `"EdDSA"`. Validate this before trusting the key.
    pub alg: Option<String>,
    pub x: String,
}

/// Postgres credentials returned by the server.
#[derive(Debug, Clone, Deserialize)]
pub struct PgCreds {
    pub host: String,
    pub port: u16,
    pub database: String,
    pub username: String,
    pub password: String,
}

/// Audit log entry returned by `GET /v1/audit`.
#[derive(Debug, Clone, Deserialize)]
pub struct AuditEvent {
    pub id: i64,
    pub event_type: String,
    pub event_time: String,
    pub ip_address: String,
    pub actor_id: Option<String>,
    pub target_id: Option<String>,
    pub details: Option<String>,
}

/// Paginated response from `GET /v1/audit`.
#[derive(Debug, Clone, Deserialize)]
pub struct AuditPage {
    pub events: Vec<AuditEvent>,
    pub total: i64,
    pub limit: i64,
    pub offset: i64,
}

/// Query parameters for `GET /v1/audit`.
#[derive(Debug, Clone, Default)]
pub struct AuditQuery {
    pub limit: Option<u32>,
    pub offset: Option<u32>,
    pub event_type: Option<String>,
    pub actor_id: Option<String>,
}

/// A single operator-defined policy rule.
#[derive(Debug, Clone, Deserialize)]
pub struct PolicyRule {
    pub id: i64,
    pub priority: i64,
    pub description: String,
    pub rule: RuleBody,
    pub enabled: bool,
    pub not_before: Option<String>,
    pub expires_at: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

/// The match conditions and effect of a policy rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleBody {
    pub effect: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub roles: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub account_types: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subject_uuid: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actions: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resource_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner_matches_subject: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub service_names: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub required_tags: Option<Vec<String>>,
}

/// Request body for `POST /v1/policy/rules`.
#[derive(Debug, Clone, Serialize)]
pub struct CreatePolicyRuleRequest {
    pub description: String,
    pub rule: RuleBody,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub not_before: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<String>,
}

/// Request body for `PATCH /v1/policy/rules/{id}`.
#[derive(Debug, Clone, Serialize, Default)]
pub struct UpdatePolicyRuleRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rule: Option<RuleBody>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub not_before: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub clear_not_before: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub clear_expires_at: Option<bool>,
}

// ---- Internal request/response types ----

#[derive(Serialize)]
struct LoginRequest<'a> {
    username: &'a str,
    password: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    totp_code: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    service_name: Option<&'a str>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

#[derive(Deserialize)]
struct TokenResponse {
    token: String,
    expires_at: String,
}

#[derive(Deserialize)]
struct ErrorResponse {
    #[serde(default)]
    error: String,
}

#[derive(Deserialize)]
struct RolesResponse {
    roles: Vec<String>,
}

#[derive(Deserialize)]
struct TagsResponse {
    tags: Vec<String>,
}

#[derive(Deserialize)]
struct TotpEnrollResponse {
    secret: String,
    otpauth_uri: String,
}

// ---- Client options ----

/// Configuration options for the MCIAS client.
#[derive(Debug, Default, Clone)]
pub struct ClientOptions {
    /// Optional PEM-encoded CA certificate for TLS verification.
    /// Use when connecting to a server with a self-signed or private-CA cert.
    pub ca_cert_pem: Option<Vec<u8>>,

    /// Optional pre-existing bearer token.
    pub token: Option<String>,

    /// This service's name as registered in MCIAS. Sent with every login
    /// request so MCIAS can evaluate service-context policy rules.
    /// Populate from `[mcias] service_name` in the service config.
    pub service_name: Option<String>,

    /// Service-level tags sent with every login request. MCIAS evaluates
    /// `auth:login` policy against these tags.
    /// Populate from `[mcias] tags` in the service config.
    pub tags: Vec<String>,
}

// ---- Client ----

/// Async MCIAS REST API client.
///
/// `Client` is cheaply cloneable — the internal HTTP client and token storage
/// are reference-counted. All clones share the same token.
#[derive(Clone)]
pub struct Client {
    base_url: String,
    http: reqwest::Client,
    service_name: Option<String>,
    tags: Vec<String>,
    /// Bearer token storage. `Arc<RwLock<...>>` so clones share the token.
    /// Security: the token is never logged or included in error messages.
    token: Arc<RwLock<Option<String>>>,
}

impl Client {
    /// Create a new client.
    ///
    /// `base_url` must be an HTTPS URL (e.g. `"https://auth.example.com"`).
    /// TLS 1.2+ is enforced by the underlying `reqwest` / `rustls` stack.
    pub fn new(base_url: &str, opts: ClientOptions) -> Result<Self, MciasError> {
        let mut builder = reqwest::ClientBuilder::new()
            // Security: enforce TLS 1.2+ minimum.
            .min_tls_version(reqwest::tls::Version::TLS_1_2)
            .use_rustls_tls();

        if let Some(pem) = opts.ca_cert_pem {
            let cert = Certificate::from_pem(&pem)
                .map_err(|e| MciasError::Decode(format!("parse CA cert: {e}")))?;
            builder = builder.add_root_certificate(cert);
        }

        let http = builder.build()?;
        Ok(Self {
            base_url: base_url.trim_end_matches('/').to_owned(),
            http,
            service_name: opts.service_name,
            tags: opts.tags,
            token: Arc::new(RwLock::new(opts.token)),
        })
    }

    /// Return the currently stored bearer token, if any.
    pub async fn token(&self) -> Option<String> {
        self.token.read().await.clone()
    }

    /// Replace the stored bearer token.
    pub async fn set_token(&self, tok: Option<String>) {
        *self.token.write().await = tok;
    }

    // ---- Authentication ----

    /// Login with username and password. On success stores the returned token
    /// and returns `(token, expires_at)`.
    ///
    /// `totp_code` may be `None` when TOTP is not enrolled.
    pub async fn login(
        &self,
        username: &str,
        password: &str,
        totp_code: Option<&str>,
    ) -> Result<(String, String), MciasError> {
        let body = LoginRequest {
            username,
            password,
            totp_code,
            service_name: self.service_name.as_deref(),
            tags: self.tags.clone(),
        };
        let resp: TokenResponse = self.post("/v1/auth/login", &body).await?;
        *self.token.write().await = Some(resp.token.clone());
        Ok((resp.token, resp.expires_at))
    }

    /// Logout — revoke the current token on the server. Clears the stored token.
    pub async fn logout(&self) -> Result<(), MciasError> {
        self.post_empty("/v1/auth/logout").await?;
        *self.token.write().await = None;
        Ok(())
    }

    /// Renew the current token. The old token is revoked server-side; the new
    /// token is stored and returned as `(token, expires_at)`.
    pub async fn renew_token(&self) -> Result<(String, String), MciasError> {
        let resp: TokenResponse = self.post("/v1/auth/renew", &serde_json::json!({})).await?;
        *self.token.write().await = Some(resp.token.clone());
        Ok((resp.token, resp.expires_at))
    }

    /// Validate a token. Returns [`TokenClaims`] with `valid: false` (no error)
    /// if the token is invalid or revoked.
    pub async fn validate_token(&self, token: &str) -> Result<TokenClaims, MciasError> {
        let body = serde_json::json!({ "token": token });
        self.post("/v1/token/validate", &body).await
    }

    // ---- Server information ----

    /// Call the health endpoint. Returns `Ok(())` on HTTP 200.
    pub async fn health(&self) -> Result<(), MciasError> {
        self.get_empty("/v1/health").await
    }

    /// Return the server's Ed25519 public key in JWK format.
    pub async fn get_public_key(&self) -> Result<PublicKey, MciasError> {
        self.get("/v1/keys/public").await
    }

    // ---- Account management (admin only) ----

    /// Create a new account. `account_type` must be `"human"` or `"system"`.
    pub async fn create_account(
        &self,
        username: &str,
        password: Option<&str>,
        account_type: &str,
    ) -> Result<Account, MciasError> {
        let mut body = serde_json::json!({
            "username": username,
            "account_type": account_type,
        });
        if let Some(pw) = password {
            body["password"] = serde_json::Value::String(pw.to_owned());
        }
        self.post_expect_status("/v1/accounts", &body, StatusCode::CREATED)
            .await
    }

    /// List all accounts.
    pub async fn list_accounts(&self) -> Result<Vec<Account>, MciasError> {
        self.get("/v1/accounts").await
    }

    /// Get a single account by UUID.
    pub async fn get_account(&self, id: &str) -> Result<Account, MciasError> {
        self.get(&format!("/v1/accounts/{id}")).await
    }

    /// Update an account's status. Allowed values: `"active"`, `"inactive"`.
    pub async fn update_account(&self, id: &str, status: &str) -> Result<(), MciasError> {
        let body = serde_json::json!({ "status": status });
        self.patch_no_content(&format!("/v1/accounts/{id}"), &body).await
    }

    /// Soft-delete an account and revoke all its tokens.
    pub async fn delete_account(&self, id: &str) -> Result<(), MciasError> {
        self.delete(&format!("/v1/accounts/{id}")).await
    }

    // ---- Role management (admin only) ----

    /// Get all roles assigned to an account.
    pub async fn get_roles(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
        // Security: spec wraps roles in {"roles": [...]}, unwrap before returning.
        let resp: RolesResponse = self.get(&format!("/v1/accounts/{account_id}/roles")).await?;
        Ok(resp.roles)
    }

    /// Replace the complete role set for an account.
    pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> {
        let url = format!("/v1/accounts/{account_id}/roles");
        // Spec requires {"roles": [...]} wrapper.
        let body = serde_json::json!({ "roles": roles });
        self.put_no_content(&url, &body).await
    }

    // ---- Token management (admin only) ----

    /// Issue a long-lived token for a system account.
    pub async fn issue_service_token(
        &self,
        account_id: &str,
    ) -> Result<(String, String), MciasError> {
        let body = serde_json::json!({ "account_id": account_id });
        let resp: TokenResponse = self.post("/v1/token/issue", &body).await?;
        Ok((resp.token, resp.expires_at))
    }

    /// Revoke a token by JTI.
    pub async fn revoke_token(&self, jti: &str) -> Result<(), MciasError> {
        self.delete(&format!("/v1/token/{jti}")).await
    }

    // ---- PG credentials (admin only) ----

    /// Get decrypted Postgres credentials for an account.
    pub async fn get_pg_creds(&self, account_id: &str) -> Result<PgCreds, MciasError> {
        self.get(&format!("/v1/accounts/{account_id}/pgcreds"))
            .await
    }

    /// Store Postgres credentials for an account.
    pub async fn set_pg_creds(
        &self,
        account_id: &str,
        host: &str,
        port: u16,
        database: &str,
        username: &str,
        password: &str,
    ) -> Result<(), MciasError> {
        let body = serde_json::json!({
            "host": host,
            "port": port,
            "database": database,
            "username": username,
            "password": password,
        });
        self.put_no_content(&format!("/v1/accounts/{account_id}/pgcreds"), &body)
            .await
    }

    // ---- TOTP enrollment (authenticated) ----

    /// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`.
    /// The secret is shown once; store it in an authenticator app immediately.
    ///
    /// Security (SEC-01): current password is required to prevent session-theft
    /// escalation to persistent account takeover.
    pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> {
        let resp: TotpEnrollResponse =
            self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?;
        Ok((resp.secret, resp.otpauth_uri))
    }

    /// Confirm TOTP enrollment with the current 6-digit code.
    /// On success, TOTP becomes required for all future logins.
    pub async fn confirm_totp(&self, code: &str) -> Result<(), MciasError> {
        let body = serde_json::json!({ "code": code });
        self.post_empty_body("/v1/auth/totp/confirm", &body).await
    }

    // ---- Password management ----

    /// Change the caller's own password (self-service). Requires the current
    /// password to guard against token-theft attacks.
    pub async fn change_password(
        &self,
        current_password: &str,
        new_password: &str,
    ) -> Result<(), MciasError> {
        let body = serde_json::json!({
            "current_password": current_password,
            "new_password": new_password,
        });
        self.put_no_content("/v1/auth/password", &body).await
    }

    // ---- Admin: TOTP removal ----

    /// Remove TOTP enrollment from an account (admin). Use for recovery when
    /// a user loses their TOTP device.
    pub async fn remove_totp(&self, account_id: &str) -> Result<(), MciasError> {
        let body = serde_json::json!({ "account_id": account_id });
        self.delete_with_body("/v1/auth/totp", &body).await
    }

    // ---- Admin: password reset ----

    /// Reset an account's password without requiring the current password.
    pub async fn admin_set_password(
        &self,
        account_id: &str,
        new_password: &str,
    ) -> Result<(), MciasError> {
        let body = serde_json::json!({ "new_password": new_password });
        self.put_no_content(&format!("/v1/accounts/{account_id}/password"), &body)
            .await
    }

    // ---- Account tags (admin) ----

    /// Get all tags for an account.
    pub async fn get_tags(&self, account_id: &str) -> Result<Vec<String>, MciasError> {
        let resp: TagsResponse =
            self.get(&format!("/v1/accounts/{account_id}/tags")).await?;
        Ok(resp.tags)
    }

    /// Replace the full tag set for an account atomically. Pass an empty slice
    /// to clear all tags. Returns the updated tag list.
    pub async fn set_tags(
        &self,
        account_id: &str,
        tags: &[&str],
    ) -> Result<Vec<String>, MciasError> {
        let body = serde_json::json!({ "tags": tags });
        let resp: TagsResponse =
            self.put_with_response(&format!("/v1/accounts/{account_id}/tags"), &body).await?;
        Ok(resp.tags)
    }

    // ---- Audit log (admin) ----

    /// Query the audit log. Returns a paginated [`AuditPage`].
    pub async fn list_audit(&self, query: AuditQuery) -> Result<AuditPage, MciasError> {
        let mut params: Vec<(&str, String)> = Vec::new();
        if let Some(limit) = query.limit {
            params.push(("limit", limit.to_string()));
        }
        if let Some(offset) = query.offset {
            params.push(("offset", offset.to_string()));
        }
        if let Some(ref et) = query.event_type {
            params.push(("event_type", et.clone()));
        }
        if let Some(ref aid) = query.actor_id {
            params.push(("actor_id", aid.clone()));
        }
        self.get_with_query("/v1/audit", &params).await
    }

    // ---- Policy rules (admin) ----

    /// List all operator-defined policy rules ordered by priority.
    pub async fn list_policy_rules(&self) -> Result<Vec<PolicyRule>, MciasError> {
        self.get("/v1/policy/rules").await
    }

    /// Create a new policy rule.
    pub async fn create_policy_rule(
        &self,
        req: CreatePolicyRuleRequest,
    ) -> Result<PolicyRule, MciasError> {
        self.post_expect_status("/v1/policy/rules", &req, StatusCode::CREATED)
            .await
    }

    /// Get a single policy rule by ID.
    pub async fn get_policy_rule(&self, id: i64) -> Result<PolicyRule, MciasError> {
        self.get(&format!("/v1/policy/rules/{id}")).await
    }

    /// Update a policy rule. Omitted fields are left unchanged.
    pub async fn update_policy_rule(
        &self,
        id: i64,
        req: UpdatePolicyRuleRequest,
    ) -> Result<PolicyRule, MciasError> {
        self.patch(&format!("/v1/policy/rules/{id}"), &req).await
    }

    /// Delete a policy rule permanently.
    pub async fn delete_policy_rule(&self, id: i64) -> Result<(), MciasError> {
        self.delete(&format!("/v1/policy/rules/{id}")).await
    }

    // ---- HTTP helpers ----

    /// Build the Authorization header value from the stored token.
    /// Security: the token is read under a read-lock and is never logged.
    async fn auth_header(&self) -> Option<header::HeaderValue> {
        let guard = self.token.read().await;
        guard.as_deref().and_then(|tok| {
            header::HeaderValue::from_str(&format!("Bearer {tok}")).ok()
        })
    }

    async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, MciasError> {
        let mut req = self.http.get(format!("{}{path}", self.base_url));
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.decode(resp).await
    }

    async fn get_empty(&self, path: &str) -> Result<(), MciasError> {
        let mut req = self.http.get(format!("{}{path}", self.base_url));
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    async fn get_with_query<T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        params: &[(&str, String)],
    ) -> Result<T, MciasError> {
        let mut req = self
            .http
            .get(format!("{}{path}", self.base_url))
            .query(params);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.decode(resp).await
    }

    async fn post<B: Serialize, T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T, MciasError> {
        let mut req = self
            .http
            .post(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.decode(resp).await
    }

    async fn post_expect_status<B: Serialize, T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        body: &B,
        expected: StatusCode,
    ) -> Result<T, MciasError> {
        let mut req = self
            .http
            .post(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        if resp.status() == expected {
            return resp
                .json::<T>()
                .await
                .map_err(|e| MciasError::Decode(e.to_string()));
        }
        Err(self.error_from_response(resp).await)
    }

    async fn post_empty(&self, path: &str) -> Result<(), MciasError> {
        let mut req = self
            .http
            .post(format!("{}{path}", self.base_url))
            .header(header::CONTENT_LENGTH, "0");
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    /// POST with a JSON body that expects a 2xx (no body) response.
    async fn post_empty_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
        let mut req = self
            .http
            .post(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    async fn patch<B: Serialize, T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T, MciasError> {
        let mut req = self
            .http
            .patch(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.decode(resp).await
    }

    async fn patch_no_content<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
        let mut req = self
            .http
            .patch(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    async fn put_no_content<B: Serialize + ?Sized>(&self, path: &str, body: &B) -> Result<(), MciasError> {
        let mut req = self
            .http
            .put(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    async fn put_with_response<B: Serialize, T: for<'de> Deserialize<'de>>(
        &self,
        path: &str,
        body: &B,
    ) -> Result<T, MciasError> {
        let mut req = self
            .http
            .put(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.decode(resp).await
    }

    async fn delete(&self, path: &str) -> Result<(), MciasError> {
        let mut req = self.http.delete(format!("{}{path}", self.base_url));
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    /// DELETE with a JSON request body (used by `DELETE /v1/auth/totp`).
    async fn delete_with_body<B: Serialize>(&self, path: &str, body: &B) -> Result<(), MciasError> {
        let mut req = self
            .http
            .delete(format!("{}{path}", self.base_url))
            .json(body);
        if let Some(auth) = self.auth_header().await {
            req = req.header(header::AUTHORIZATION, auth);
        }
        let resp = req.send().await?;
        self.expect_success(resp).await
    }

    async fn decode<T: for<'de> Deserialize<'de>>(
        &self,
        resp: reqwest::Response,
    ) -> Result<T, MciasError> {
        if resp.status().is_success() {
            return resp
                .json::<T>()
                .await
                .map_err(|e| MciasError::Decode(e.to_string()));
        }
        Err(self.error_from_response(resp).await)
    }

    async fn expect_success(&self, resp: reqwest::Response) -> Result<(), MciasError> {
        if resp.status().is_success() {
            return Ok(());
        }
        Err(self.error_from_response(resp).await)
    }

    async fn error_from_response(&self, resp: reqwest::Response) -> MciasError {
        let status = resp.status();
        let message = resp
            .json::<ErrorResponse>()
            .await
            .map(|e| if e.error.is_empty() { status.to_string() } else { e.error })
            .unwrap_or_else(|_| status.to_string());

        match status {
            StatusCode::UNAUTHORIZED => MciasError::Auth(message),
            StatusCode::FORBIDDEN => MciasError::Forbidden(message),
            StatusCode::NOT_FOUND => MciasError::NotFound(message),
            StatusCode::BAD_REQUEST => MciasError::InvalidInput(message),
            StatusCode::CONFLICT => MciasError::Conflict(message),
            s => MciasError::Server {
                status: s.as_u16(),
                message,
            },
        }
    }
}
