//! [`ZeroIDClient`] — async HTTP client for the ZeroID NHI server.

use std::{fmt, sync::Arc, time::Duration};
use tokio::sync::Mutex;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT};

use crate::zeroid::{
    error::ZeroIDError,
    resources::{
        AgentsResource, ApiKeysResource, IdentitiesResource, OAuthClientsResource,
        PoliciesResource, SignalsResource, TokensResource,
    },
};

// ── Constants ─────────────────────────────────────────────────────────────────

const DEFAULT_BASE_URL: &str = "http://localhost:8899";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_RETRIES: u32 = 2;
/// HTTP status codes that trigger a retry.
const RETRY_STATUS_CODES: &[u16] = &[429, 500, 502, 503, 504];

// ── Options ───────────────────────────────────────────────────────────────────

/// Configuration for [`ZeroIDClient`].
///
/// ```no_run
/// use highflame::zeroid::ZeroIDOptions;
/// use std::time::Duration;
///
/// let opts = ZeroIDOptions::new("http://localhost:8899")
///     .account_id("acc_123")
///     .project_id("proj_456")
///     .timeout(Duration::from_secs(10))
///     .max_retries(1);
/// ```
#[derive(Debug, Clone)]
pub struct ZeroIDOptions {
    pub(crate) base_url: String,
    pub(crate) api_key: Option<String>,
    pub(crate) account_id: Option<String>,
    pub(crate) project_id: Option<String>,
    pub(crate) timeout: Option<Duration>,
    pub(crate) max_retries: Option<u32>,
}

impl ZeroIDOptions {
    /// Create options from a base URL; all other fields take defaults.
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into(),
            api_key: None,
            account_id: None,
            project_id: None,
            timeout: None,
            max_retries: None,
        }
    }

    /// Set the API key (zid_sk_*) for agent authentication.
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    /// Set the tenant account ID (sent as `X-Account-ID` header).
    pub fn account_id(mut self, id: impl Into<String>) -> Self {
        self.account_id = Some(id.into());
        self
    }

    /// Set the tenant project ID (sent as `X-Project-ID` header).
    pub fn project_id(mut self, id: impl Into<String>) -> Self {
        self.project_id = Some(id.into());
        self
    }

    /// Request timeout (default: 30 s).
    pub fn timeout(mut self, t: Duration) -> Self {
        self.timeout = Some(t);
        self
    }

    /// Number of retries on 429 / 5xx (default: 2).
    pub fn max_retries(mut self, n: u32) -> Self {
        self.max_retries = Some(n);
        self
    }
}

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

// ── Inner shared state ────────────────────────────────────────────────────────

pub(crate) struct CachedToken {
    pub(crate) access_token: String,
    pub(crate) expires_at: u64, // unix millis
}

const TOKEN_REFRESH_BUFFER_MS: u64 = 60_000;

pub(crate) struct Inner {
    pub(crate) base_url: String,
    pub(crate) timeout: Duration,
    pub(crate) max_retries: u32,
    pub(crate) account_id: Option<String>,
    pub(crate) project_id: Option<String>,
    pub(crate) api_key: Option<String>,
    pub(crate) http: reqwest::Client,
    pub(crate) token_cache: Mutex<Option<CachedToken>>,
}

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

/// Async HTTP client for the ZeroID NHI server.
///
/// Cheap to clone — all clones share the underlying connection pool via [`Arc`].
///
/// # Resource-based API
///
/// ```no_run
/// use highflame::zeroid::{ZeroIDClient, ZeroIDOptions};
///
/// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = ZeroIDClient::new(
///     ZeroIDOptions::new("http://localhost:8899")
///         .account_id("acc_123")
///         .project_id("proj_456"),
/// );
///
/// let identities = client.identities().list().await?;
/// println!("{} identities", identities.total);
///
/// let token = client.tokens().issue(&highflame::zeroid::types::TokenRequest {
///     grant_type: "client_credentials".to_string(),
///     client_id: Some("my-agent".to_string()),
///     client_secret: Some("secret".to_string()),
///     ..Default::default()
/// }).await?;
/// println!("access_token: {}", token.access_token);
/// # Ok(()) }
/// ```
#[derive(Clone)]
pub struct ZeroIDClient {
    pub(crate) inner: Arc<Inner>,
}

impl ZeroIDClient {
    /// Create a new [`ZeroIDClient`].
    pub fn new(options: ZeroIDOptions) -> Self {
        let base_url = options.base_url.trim_end_matches('/').to_string();
        let timeout = options.timeout.unwrap_or(DEFAULT_TIMEOUT);
        let max_retries = options.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);

        let http = reqwest::Client::builder()
            .build()
            .expect("failed to build reqwest client");

        // Auto-generate tenant context for quickstart / local dev.
        let account_id = options
            .account_id
            .unwrap_or_else(|| format!("acct-{}", &uuid::Uuid::new_v4().to_string()[..8]));
        let project_id = options
            .project_id
            .unwrap_or_else(|| format!("proj-{}", &uuid::Uuid::new_v4().to_string()[..8]));

        Self {
            inner: Arc::new(Inner {
                base_url,
                timeout,
                max_retries,
                account_id: Some(account_id),
                project_id: Some(project_id),
                api_key: options.api_key,
                http,
                token_cache: Mutex::new(None),
            }),
        }
    }

    /// The account ID used for admin API calls.
    pub fn account_id(&self) -> Option<&str> {
        self.inner.account_id.as_deref()
    }

    /// The project ID used for admin API calls.
    pub fn project_id(&self) -> Option<&str> {
        self.inner.project_id.as_deref()
    }

    // ── Resource accessors ────────────────────────────────────────────────────

    /// Access identity CRUD operations.
    pub fn identities(&self) -> IdentitiesResource<'_> {
        IdentitiesResource { client: self }
    }

    /// Access agent registration and lifecycle operations.
    pub fn agents(&self) -> AgentsResource<'_> {
        AgentsResource { client: self }
    }

    /// Access OAuth2 client management operations.
    pub fn oauth_clients(&self) -> OAuthClientsResource<'_> {
        OAuthClientsResource { client: self }
    }

    /// Access credential policy operations.
    pub fn policies(&self) -> PoliciesResource<'_> {
        PoliciesResource { client: self }
    }

    /// Access API key operations.
    pub fn api_keys(&self) -> ApiKeysResource<'_> {
        ApiKeysResource { client: self }
    }

    /// Access public OAuth2 token endpoints (issue, introspect, revoke).
    pub fn tokens(&self) -> TokensResource<'_> {
        TokensResource { client: self }
    }

    /// Access CAE signal operations.
    pub fn signals(&self) -> SignalsResource<'_> {
        SignalsResource { client: self }
    }

    /// Whether the client was initialized with an API key.
    pub fn has_api_key(&self) -> bool {
        self.inner.api_key.is_some()
    }

    /// Return a valid access token, exchanging the API key if needed.
    pub(crate) async fn get_token(&self) -> Result<String, ZeroIDError> {
        let api_key = self
            .inner
            .api_key
            .as_ref()
            .ok_or_else(|| ZeroIDError::ApiError {
                status: 0,
                title: "Configuration error".to_string(),
                detail: "get_token() requires the client to be initialized with an api_key"
                    .to_string(),
                code: None,
            })?;

        let now_ms = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis() as u64;

        // Check cache.
        {
            let cache = self.inner.token_cache.lock().await;
            if let Some(ref cached) = *cache {
                if now_ms < cached.expires_at {
                    return Ok(cached.access_token.clone());
                }
            }
        }

        // Exchange.
        let body = serde_json::json!({
            "grant_type": "api_key",
            "api_key": api_key,
        });
        let resp = self
            .send_with_retry(
                reqwest::Method::POST,
                "/oauth2/token",
                self.public_headers(),
                Some(&body),
            )
            .await?;

        if !resp.status().is_success() {
            return Err(parse_api_error(resp).await);
        }

        let data: serde_json::Value = resp.json().await.map_err(ZeroIDError::HttpError)?;
        let access_token = data["access_token"]
            .as_str()
            .ok_or_else(|| ZeroIDError::ApiError {
                status: 0,
                title: "Parse error".to_string(),
                detail: "missing access_token in response".to_string(),
                code: None,
            })?
            .to_string();
        let expires_in = data["expires_in"].as_u64().unwrap_or(3600);

        let mut cache = self.inner.token_cache.lock().await;
        *cache = Some(CachedToken {
            access_token: access_token.clone(),
            expires_at: now_ms + expires_in * 1000 - TOKEN_REFRESH_BUFFER_MS,
        });

        Ok(access_token)
    }

    // ── Header builders ───────────────────────────────────────────────────────

    /// Build headers for admin endpoints (with tenant context).
    pub(crate) fn admin_headers(&self) -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        headers.insert(
            USER_AGENT,
            HeaderValue::from_static(concat!("zeroid-rust-sdk/", env!("CARGO_PKG_VERSION"))),
        );
        if let Some(ref id) = self.inner.account_id {
            headers.insert(
                reqwest::header::HeaderName::from_static("x-account-id"),
                HeaderValue::from_str(id).expect("account_id contains invalid header characters"),
            );
        }
        if let Some(ref id) = self.inner.project_id {
            headers.insert(
                reqwest::header::HeaderName::from_static("x-project-id"),
                HeaderValue::from_str(id).expect("project_id contains invalid header characters"),
            );
        }
        headers
    }

    /// Build headers for public endpoints (no tenant context).
    pub(crate) fn public_headers(&self) -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        headers.insert(
            USER_AGENT,
            HeaderValue::from_static(concat!("zeroid-rust-sdk/", env!("CARGO_PKG_VERSION"))),
        );
        headers
    }

    // ── Request helpers ───────────────────────────────────────────────────────

    /// Send a request with retry on 429 / 5xx and exponential backoff.
    pub(crate) async fn send_with_retry(
        &self,
        method: reqwest::Method,
        path: &str,
        headers: HeaderMap,
        json_body: Option<&serde_json::Value>,
    ) -> Result<reqwest::Response, ZeroIDError> {
        let url = format!("{}{}", self.inner.base_url, path);
        let mut last_err: ZeroIDError = ZeroIDError::ApiError {
            status: 0,
            title: "Error".to_string(),
            detail: "request failed after all retries".to_string(),
            code: None,
        };

        for attempt in 0..=self.inner.max_retries {
            if attempt > 0 {
                let base_ms = 1_000 * 2u64.pow(attempt - 1);
                let jitter_range = base_ms / 4;
                let jitter = if jitter_range > 0 {
                    let seed = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap_or_default()
                        .subsec_nanos() as u64;
                    (seed % (jitter_range * 2)).saturating_sub(jitter_range)
                } else {
                    0
                };
                let delay_ms = (base_ms as i64 + jitter as i64).max(0) as u64;
                log::debug!(
                    "Retrying {} {} (attempt {}/{}) after {}ms",
                    method,
                    path,
                    attempt + 1,
                    self.inner.max_retries + 1,
                    delay_ms,
                );
                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
            }

            log::debug!("{} {}", method, path);

            let mut req = self
                .inner
                .http
                .request(method.clone(), &url)
                .headers(headers.clone())
                .timeout(self.inner.timeout);

            if let Some(body) = json_body {
                req = req.json(body);
            }

            let resp = match req.send().await {
                Ok(r) => r,
                Err(e) if e.is_timeout() => {
                    log::debug!("Request timed out: {} {}", method, path);
                    last_err = ZeroIDError::HttpError(e);
                    continue;
                }
                Err(e) => return Err(ZeroIDError::HttpError(e)),
            };

            log::debug!("{} {} -> {}", method, path, resp.status().as_u16());

            if !RETRY_STATUS_CODES.contains(&resp.status().as_u16()) {
                return Ok(resp);
            }

            last_err = parse_api_error(resp).await;
        }

        Err(last_err)
    }
}

impl fmt::Display for ZeroIDClient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "ZeroIDClient(base_url={:?})", self.inner.base_url)
    }
}

impl fmt::Debug for ZeroIDClient {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ZeroIDClient")
            .field("base_url", &self.inner.base_url)
            .finish()
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Parse a non-2xx response body into a [`ZeroIDError`].
///
/// Handles two formats:
/// - OAuth2 token endpoint: `{"error": "...", "error_description": "..."}`
/// - Admin endpoints (RFC 9457): `{"title": "...", "detail": "..."}`
pub(crate) async fn parse_api_error(resp: reqwest::Response) -> ZeroIDError {
    let status = resp.status().as_u16();
    let body = match resp.json::<serde_json::Value>().await {
        Ok(v) => v,
        Err(_) => serde_json::Value::Null,
    };

    // OAuth2 token endpoint format: {"error": "...", "error_description": "..."}
    if let Some(oauth_code) = body["error"].as_str() {
        let detail = body["error_description"].as_str().unwrap_or("").to_string();
        return ZeroIDError::ApiError {
            status,
            title: oauth_code.to_string(),
            detail,
            code: Some(oauth_code.to_string()),
        };
    }

    // RFC 9457 Problem Details format: {"title": "...", "detail": "..."}
    let title = body["title"].as_str().unwrap_or("Error").to_string();
    let detail = body["detail"].as_str().unwrap_or("").to_string();

    match status {
        404 => ZeroIDError::NotFound(if detail.is_empty() {
            "Resource not found".to_string()
        } else {
            detail
        }),
        409 => ZeroIDError::Conflict(if detail.is_empty() {
            "Resource already exists".to_string()
        } else {
            detail
        }),
        _ => ZeroIDError::ApiError {
            status,
            title,
            detail,
            code: None,
        },
    }
}
