//! HTTP client for the Sail REST API.
//!
//! Auto-retries transient failures (transport errors synthesized as 503,
//! upstream 502/503/504, and 429 with Retry-After) using exponential
//! backoff with full jitter. POST requests automatically carry an
//! `Idempotency-Key` header so the backend can dedupe a retried request
//! instead of double-creating the underlying resource.
//!
//! Transport failures are synthesized as a 503 response carrying a standard
//! error envelope so the retry loop and the caller's status-based handling
//! treat them uniformly. Error message text is for humans/logs, not a
//! cross-language contract.

use std::time::Duration;

use serde_json::{json, Value};

use crate::error::SailError;
use crate::retry::{effective_delay, is_retryable_status, RetryPolicy};

/// HTTP method for a Sail REST request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
    /// HTTP GET (idempotent read).
    Get,
    /// HTTP POST (mutation; carries an `Idempotency-Key` and a JSON body).
    Post,
    /// HTTP DELETE.
    Delete,
}

/// Idempotency-Key behavior for POST requests.
#[derive(Debug, Clone)]
pub enum IdempotencyKey {
    /// Generate a fresh uuid4 hex unless the caller already set the header.
    Auto,
    /// Strip the header entirely.
    Suppress,
    /// Pin this exact value (replacing any caller-supplied spelling).
    Explicit(String),
}

/// HTTP client bound to a single API base URL and bearer key.
pub struct HttpCore {
    client: reqwest::Client,
    base_url: String,
    api_key: String,
}

/// A fully described HTTP request: target, payload, and retry/idempotency
/// behavior.
pub struct RequestSpec {
    /// HTTP method for the request.
    pub method: Method,
    /// Request path appended to the client's base URL (e.g. `/v1/sailboxes`).
    pub path: String,
    /// Plain query pairs (values already stringified); the client percent-encodes
    /// them when building the URL.
    pub query: Vec<(String, String)>,
    /// Canonical JSON bytes serialized by the wrapper (sort_keys + compact
    /// separators) so Idempotency-Key body fingerprints stay stable.
    pub body: Option<Vec<u8>>,
    /// Additional request headers. `Authorization` and (on POST)
    /// `Content-Type` are set by the client and cannot be overridden here.
    pub extra_headers: Vec<(String, String)>,
    /// Per-request timeout in seconds. `None` or a non-finite value means no
    /// per-request timeout.
    pub timeout: Option<f64>,
    /// Retry policy governing automatic retries of transient failures.
    pub policy: RetryPolicy,
    /// Idempotency-Key behavior for POST requests (ignored for GET/DELETE).
    pub idempotency_key: IdempotencyKey,
}

impl HttpCore {
    /// Build a client for `base_url`, authenticating with `api_key`. Errors if
    /// `base_url` is empty or the underlying HTTP client cannot be built.
    /// Redirects are disabled so an unexpected 3xx surfaces as an error rather
    /// than silently downgrading a POST to GET and dropping the body.
    pub fn new(base_url: &str, api_key: &str) -> Result<HttpCore, SailError> {
        if base_url.is_empty() {
            return Err(SailError::Config {
                message:
                    "API base URL is empty; set SAIL_API_URL e.g. https://api.sailresearch.com"
                        .to_string(),
            });
        }
        // The Sail API does not issue 3xx on /v1/* and auto-following a
        // redirect on POST is unsafe: RFC 7231 downgrades POST→GET on
        // 301/302, which would silently drop the body and the
        // Idempotency-Key minted against the original (method, url, body)
        // triple. Surface an unexpected 3xx as an error instead.
        let client = reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|e| SailError::Internal {
                message: format!("failed to build HTTP client: {e}"),
            })?;
        Ok(HttpCore {
            client,
            base_url: base_url.to_string(),
            api_key: api_key.to_string(),
        })
    }

    /// Send a request, auto-retrying transient failures per `spec.policy`, and
    /// return the final `(status_code, parsed_json_body)`. The body is `null`
    /// for empty responses (e.g. 204). Transport failures are surfaced as a
    /// synthesized 503 with a standard error envelope.
    pub async fn request(&self, spec: &RequestSpec) -> Result<(u16, Value), SailError> {
        let url = self.url(&spec.path, &spec.query);
        let headers = self.build_headers(spec);
        let max_attempts = spec.policy.max_attempts.max(1);
        for attempt in 1..=max_attempts {
            let (status, body, resp_headers) = self
                .send_once(
                    spec.method,
                    &url,
                    spec.body.as_deref(),
                    &headers,
                    spec.timeout,
                )
                .await;
            if !is_retryable_status(status, &resp_headers) || attempt == max_attempts {
                return Ok((status, body));
            }
            let delay = effective_delay(attempt, &spec.policy, &resp_headers);
            tracing::warn!(
                attempt,
                max_attempts,
                status,
                delay_secs = delay,
                path = %spec.path,
                "retrying transient HTTP failure"
            );
            if delay > 0.0 {
                tokio::time::sleep(Duration::from_secs_f64(delay)).await;
            }
        }
        unreachable!("retry loop exited without returning")
    }

    fn url(&self, path: &str, query: &[(String, String)]) -> String {
        let mut url = format!("{}{}", self.base_url.trim_end_matches('/'), path);
        if !query.is_empty() {
            let encoded: String = form_urlencoded::Serializer::new(String::new())
                .extend_pairs(query.iter().map(|(k, v)| (k.as_str(), v.as_str())))
                .finish();
            if !encoded.is_empty() {
                let separator = if url.contains('?') { '&' } else { '?' };
                url.push(separator);
                url.push_str(&encoded);
            }
        }
        url
    }

    fn build_headers(&self, spec: &RequestSpec) -> Vec<(String, String)> {
        let is_post = spec.method == Method::Post;
        let mut headers: Vec<(String, String)> = Vec::new();
        if !self.api_key.is_empty() {
            headers.push((
                "Authorization".to_string(),
                format!("Bearer {}", self.api_key),
            ));
        }
        if is_post {
            headers.push(("Content-Type".to_string(), "application/json".to_string()));
        }
        for (key, value) in &spec.extra_headers {
            if key.eq_ignore_ascii_case("authorization")
                || (is_post && key.eq_ignore_ascii_case("content-type"))
            {
                continue;
            }
            headers.push((key.clone(), value.clone()));
        }
        if spec.method == Method::Post {
            let existing = headers
                .iter()
                .position(|(k, _)| k.eq_ignore_ascii_case("Idempotency-Key"));
            match (&spec.idempotency_key, existing) {
                (IdempotencyKey::Auto, None) => headers.push((
                    "Idempotency-Key".to_string(),
                    uuid::Uuid::new_v4().simple().to_string(),
                )),
                (IdempotencyKey::Auto, Some(_)) => {}
                (IdempotencyKey::Suppress, Some(i)) => {
                    headers.remove(i);
                }
                (IdempotencyKey::Suppress, None) => {}
                (IdempotencyKey::Explicit(value), existing) => {
                    if let Some(i) = existing {
                        headers.remove(i);
                    }
                    headers.push(("Idempotency-Key".to_string(), value.clone()));
                }
            }
        }
        headers
    }

    async fn send_once(
        &self,
        method: Method,
        url: &str,
        body: Option<&[u8]>,
        headers: &[(String, String)],
        timeout: Option<f64>,
    ) -> (u16, Value, Vec<(String, String)>) {
        let mut req = self.client.request(
            match method {
                Method::Get => reqwest::Method::GET,
                Method::Post => reqwest::Method::POST,
                Method::Delete => reqwest::Method::DELETE,
            },
            url,
        );
        for (key, value) in headers {
            req = req.header(key, value);
        }
        if let Some(body) = body {
            req = req.body(body.to_vec());
        }
        // A non-finite timeout (e.g. `float("inf")` from Python) means no
        // per-request timeout at all.
        if let Some(timeout) = timeout {
            if let Ok(timeout) = Duration::try_from_secs_f64(timeout.max(0.0)) {
                req = req.timeout(timeout);
            }
        }

        // Transport-level failures (DNS, refused connection, TLS, socket
        // timeout, mid-response disconnects): synthesize a 503 so callers'
        // status-based error mapping produces a normal SDK exception.
        let resp = match req.send().await {
            Ok(resp) => resp,
            Err(e) => return (503, self.error_body(&transport_error_message(&e)), vec![]),
        };
        let status = resp.status().as_u16();
        let resp_headers: Vec<(String, String)> = resp
            .headers()
            .iter()
            .map(|(k, v)| {
                (
                    k.as_str().to_string(),
                    String::from_utf8_lossy(v.as_bytes()).to_string(),
                )
            })
            .collect();
        let body_bytes = match resp.bytes().await {
            Ok(bytes) => bytes,
            Err(e) => return (503, self.error_body(&transport_error_message(&e)), vec![]),
        };

        // An empty body (e.g. 204 No Content on DELETE) carries no JSON;
        // surface it as null rather than a malformed-JSON failure.
        if body_bytes.is_empty() {
            return (status, Value::Null, resp_headers);
        }

        let Ok(decoded) = serde_json::from_slice::<Value>(&body_bytes) else {
            // Malformed JSON on a 2xx (truncated proxy, content-length
            // mismatch) is treated as a transport failure. On a
            // 3xx/4xx/5xx the upstream body may legitimately be
            // non-JSON (HTML error page); wrap it in the standard
            // envelope and pass the real status through so a 3xx is
            // not misclassified as retryable.
            let text = String::from_utf8_lossy(&body_bytes);
            if status >= 300 {
                return (status, self.error_body(&text), resp_headers);
            }
            return (
                503,
                self.error_body(&format!(
                    "malformed JSON in {status} response: {}",
                    truncate_chars(&text, 200)
                )),
                resp_headers,
            );
        };
        let decoded = if status >= 400 {
            sanitize_json(decoded, &self.api_key)
        } else if status >= 300 {
            // The Sail API does not emit 3xx on /v1/*, so a 3xx body (even
            // valid JSON) is whatever a misbehaving intermediary returned
            // and won't carry the {"error":{"message":...}} envelope
            // callers introspect. Synthesize the envelope here.
            self.error_body(&format!(
                "unexpected HTTP {status} response: {}",
                truncate_chars(&String::from_utf8_lossy(&body_bytes), 200)
            ))
        } else {
            decoded
        };
        (status, decoded, resp_headers)
    }

    /// Wrap an error string in the SDK's standard error envelope so a
    /// synthesized failure has the same shape as a real API error body.
    fn error_body(&self, message: &str) -> Value {
        json!({"error": {"message": sanitize_error_message(message, &self.api_key)}})
    }
}

/// Extract `error.message` from a Sail API error envelope, or `default` when it
/// is missing or empty. Shared by the lifecycle/app error ladders, which parse
/// the same `{"error": {"message": ...}}` shape `HttpCore` returns.
pub(crate) fn api_error_message(data: &Value, default: &str) -> String {
    data.get("error")
        .and_then(|e| e.get("message"))
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or(default)
        .to_string()
}

/// A human-readable message for a transport failure, with no language's
/// exception-class names baked in. Bindings classify via the synthesized
/// 503 status (and, in the typed surface, [`crate::error::TransportKind`]);
/// this string is for humans and logs.
fn transport_error_message(e: &reqwest::Error) -> String {
    let kind = if e.is_timeout() {
        "request timed out"
    } else if e.is_connect() {
        "connection failed"
    } else {
        "transport error"
    };
    // Include the full source chain: reqwest's Display is often just
    // "error sending request" with the cause buried in source().
    let mut message = e.to_string();
    let mut source = std::error::Error::source(e);
    while let Some(cause) = source {
        message = format!("{message}: {cause}");
        source = cause.source();
    }
    format!("{kind}: {message}")
}

fn truncate_chars(text: &str, max_chars: usize) -> String {
    text.chars().take(max_chars).collect()
}

fn sanitize_json(value: Value, api_key: &str) -> Value {
    match value {
        Value::String(s) => Value::String(sanitize_error_message(&s, api_key)),
        Value::Array(items) => Value::Array(
            items
                .into_iter()
                .map(|item| sanitize_json(item, api_key))
                .collect(),
        ),
        Value::Object(map) => Value::Object(
            map.into_iter()
                .map(|(key, item)| (key, sanitize_json(item, api_key)))
                .collect(),
        ),
        other => other,
    }
}

fn sanitize_error_message(message: &str, api_key: &str) -> String {
    if api_key.is_empty() {
        return message.to_string();
    }
    message
        .replace(&format!("Bearer {api_key}"), "Bearer [redacted]")
        .replace(api_key, "[redacted]")
}

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

    #[test]
    fn sanitizes_api_key_everywhere() {
        let body = sanitize_json(
            json!({"error": {"message": "bad key Bearer sk_123", "extra": ["sk_123", 7]}}),
            "sk_123",
        );
        assert_eq!(
            body,
            json!({"error": {"message": "bad key Bearer [redacted]", "extra": ["[redacted]", 7]}})
        );
    }

    #[test]
    fn empty_api_key_leaves_message() {
        assert_eq!(sanitize_error_message("hello sk_123", ""), "hello sk_123");
    }

    #[test]
    fn truncates_by_chars_not_bytes() {
        let text = "é".repeat(300);
        assert_eq!(truncate_chars(&text, 200).chars().count(), 200);
    }

    fn core() -> HttpCore {
        HttpCore::new("https://api.example.com", "sk_secret").unwrap()
    }

    fn spec(method: Method, idempotency_key: IdempotencyKey) -> RequestSpec {
        RequestSpec {
            method,
            path: "/v1/sailboxes".to_string(),
            query: Vec::new(),
            body: None,
            extra_headers: Vec::new(),
            timeout: None,
            policy: crate::retry::NO_RETRY,
            idempotency_key,
        }
    }

    fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
        headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }

    #[test]
    fn empty_base_url_is_config_error() {
        assert!(matches!(
            HttpCore::new("", "sk"),
            Err(SailError::Config { .. })
        ));
    }

    #[test]
    fn post_auto_mints_idempotency_key_once() {
        let headers = core().build_headers(&spec(Method::Post, IdempotencyKey::Auto));
        let key = header(&headers, "Idempotency-Key").expect("auto mints a key");
        assert_eq!(key.len(), 32, "uuid4 simple form is 32 hex chars");
        assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
        assert_eq!(header(&headers, "Content-Type"), Some("application/json"));
    }

    #[test]
    fn post_auto_preserves_caller_supplied_key() {
        let mut request = spec(Method::Post, IdempotencyKey::Auto);
        request.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
        let headers = core().build_headers(&request);
        assert_eq!(header(&headers, "Idempotency-Key"), Some("caller-key"));
    }

    #[test]
    fn suppress_strips_and_explicit_overrides_the_key() {
        let mut suppressed = spec(Method::Post, IdempotencyKey::Suppress);
        suppressed.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
        assert_eq!(
            header(&core().build_headers(&suppressed), "Idempotency-Key"),
            None
        );

        let mut explicit = spec(Method::Post, IdempotencyKey::Explicit("pinned".to_string()));
        explicit.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
        assert_eq!(
            header(&core().build_headers(&explicit), "Idempotency-Key"),
            Some("pinned")
        );
    }

    #[test]
    fn get_carries_no_idempotency_key_or_content_type() {
        let headers = core().build_headers(&spec(Method::Get, IdempotencyKey::Auto));
        assert_eq!(header(&headers, "Idempotency-Key"), None);
        assert_eq!(header(&headers, "Content-Type"), None);
        assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
    }

    #[test]
    fn caller_cannot_override_authorization_or_content_type() {
        let mut request = spec(Method::Post, IdempotencyKey::Suppress);
        request.extra_headers = vec![
            ("Authorization".to_string(), "Bearer attacker".to_string()),
            ("content-type".to_string(), "text/plain".to_string()),
            ("X-Trace".to_string(), "keep-me".to_string()),
        ];
        let headers = core().build_headers(&request);
        assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
        assert_eq!(header(&headers, "Content-Type"), Some("application/json"));
        assert_eq!(header(&headers, "X-Trace"), Some("keep-me"));
    }

    #[test]
    fn url_encodes_query_and_merges_with_existing_separator() {
        let core = core();
        assert_eq!(
            core.url("/v1/sailboxes", &[("a b".to_string(), "x&y".to_string())]),
            "https://api.example.com/v1/sailboxes?a+b=x%26y"
        );
        // A path that already has a query string gets an `&` join, not `?`.
        assert_eq!(
            core.url("/v1/x?first=1", &[("second".to_string(), "2".to_string())]),
            "https://api.example.com/v1/x?first=1&second=2"
        );
        // No query pairs leaves the path untouched (no trailing `?`).
        assert_eq!(core.url("/v1/x", &[]), "https://api.example.com/v1/x");
    }
}
