//! HTTP transport. Wraps `reqwest::Client` with bearer auth, timeouts, and
//! HTTP-error translation matching `sdk-ts/src/client.ts`.

use std::sync::Arc;
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url;

use crate::errors::HeyoError;

const DEFAULT_BASE_URL: &str = "https://server.heyo.computer";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);

/// Default base URL for a local heyvm API (the `heyvmd` daemon's `--api-port`).
/// Used by [`HeyoClient::local`] so desktop apps can drive a same-machine
/// sandbox without the cloud in the data path.
pub const DEFAULT_LOCAL_BASE_URL: &str = "http://127.0.0.1:34099";

/// Construction options for [`HeyoClient`]. Mirrors `HeyoClientOptions` in
/// `sdk-ts/src/client.ts`.
#[derive(Debug, Default, Clone)]
pub struct HeyoClientOptions {
    /// Bearer token. Falls back to `HEYO_API_KEY` env var when `None`.
    pub api_key: Option<String>,
    /// Cloud base URL. Default: `https://server.heyo.computer`.
    pub base_url: Option<String>,
    /// Per-request timeout. Default: 60 seconds.
    pub timeout: Option<Duration>,
}

/// Optional per-request knobs.
#[derive(Debug, Default, Clone)]
pub struct RequestOptions {
    pub timeout: Option<Duration>,
    /// Query string parameters appended verbatim.
    pub query: Vec<(String, String)>,
}

#[derive(Clone)]
pub struct HeyoClient {
    inner: Arc<Inner>,
}

struct Inner {
    /// Bearer token, or `None` for an unauthenticated (e.g. local) client.
    api_key: Option<String>,
    base_url: String,
    http: reqwest::Client,
    default_timeout: Duration,
    /// Keeps the iroh P2P tunnel task alive for the client's lifetime when the
    /// client was built via [`HeyoClient::connect_p2p`]. Aborted on Drop so the
    /// tunnel tears down when the last clone of the client goes away.
    _tunnel: Option<TunnelGuard>,
}

/// Owns the background task pumping the iroh tunnel. Dropping it aborts the
/// task (and so closes the local TCP listener the requests were pointed at).
struct TunnelGuard(tokio::task::JoinHandle<()>);

impl Drop for TunnelGuard {
    fn drop(&mut self) {
        self.0.abort();
    }
}

impl HeyoClient {
    pub fn new(opts: HeyoClientOptions) -> Result<Self, HeyoError> {
        // api_key is optional: a cloud client without one will simply get 401s,
        // while a local client (custom base_url pointed at a heyvm daemon with
        // no JWT_SECRET) needs no auth at all. We therefore no longer fail
        // construction when the key is absent — auth is enforced server-side.
        let api_key = opts
            .api_key
            .or_else(|| std::env::var("HEYO_API_KEY").ok())
            .filter(|k| !k.is_empty());
        let base_url = opts
            .base_url
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
            .trim_end_matches('/')
            .to_string();
        let http = reqwest::Client::builder()
            .build()
            .map_err(|e| HeyoError::Connection(e.to_string()))?;
        Ok(Self {
            inner: Arc::new(Inner {
                api_key,
                base_url,
                http,
                default_timeout: opts.timeout.unwrap_or(DEFAULT_TIMEOUT),
                _tunnel: None,
            }),
        })
    }

    /// Build a client targeting a local heyvm API at [`DEFAULT_LOCAL_BASE_URL`]
    /// (`http://127.0.0.1:34099`). No auth is sent — a same-machine daemon runs
    /// without `JWT_SECRET` and skips authentication. Use [`HeyoClient::local_at`]
    /// for a non-default address. The local API understands the same cloud HTTP
    /// dialect via its compatibility routes, so the full SDK surface works.
    pub fn local() -> Result<Self, HeyoError> {
        Self::local_at(DEFAULT_LOCAL_BASE_URL)
    }

    /// Like [`HeyoClient::local`] but targets an explicit base URL — e.g. a port
    /// some other component bound for you (an iroh tunnel, an SSH forward).
    pub fn local_at(base_url: impl Into<String>) -> Result<Self, HeyoError> {
        Self::new(HeyoClientOptions {
            base_url: Some(base_url.into()),
            api_key: None,
            timeout: None,
        })
    }

    /// Connect directly to a remote heyvm daemon over iroh P2P, bypassing the
    /// cloud data path. `ticket` is the daemon's `connection_url` (fetch it from
    /// the cloud via `GET /me/daemons/{id}/connection-ticket`); `relay` is an
    /// optional iroh relay override for NAT traversal. A background task pumps
    /// the tunnel for the client's lifetime and is torn down when the client
    /// (and all its clones) drop.
    ///
    /// The client's `base_url` is set to the local TCP port the tunnel binds, so
    /// every subsequent SDK call rides the P2P link. `api_key` is forwarded as
    /// the bearer to the daemon (which may run with auth enabled); pass `None`
    /// when the daemon is unauthenticated.
    pub async fn connect_p2p(
        ticket: &str,
        relay: Option<&str>,
        api_key: Option<String>,
    ) -> Result<Self, HeyoError> {
        let proxy = crate::proxy::Client::connect(ticket, 0, relay)
            .await
            .map_err(|e| HeyoError::Connection(format!("iroh P2P connect failed: {e}")))?;
        let local = proxy
            .local_addr()
            .map_err(|e| HeyoError::Connection(format!("tunnel local_addr: {e}")))?;
        let base_url = format!("http://{}", local);
        let handle = tokio::spawn(async move {
            // The tunnel runs until the listener errors or the task is aborted
            // on Drop. A failure here just means later requests get a
            // connection error, which surfaces to the caller naturally.
            let _ = proxy.run().await;
        });
        let http = reqwest::Client::builder()
            .build()
            .map_err(|e| HeyoError::Connection(e.to_string()))?;
        Ok(Self {
            inner: Arc::new(Inner {
                api_key: api_key.filter(|k| !k.is_empty()),
                base_url,
                http,
                default_timeout: DEFAULT_TIMEOUT,
                _tunnel: Some(TunnelGuard(handle)),
            }),
        })
    }

    pub fn base_url(&self) -> &str {
        &self.inner.base_url
    }

    #[allow(dead_code)]
    pub(crate) fn api_key(&self) -> Option<&str> {
        self.inner.api_key.as_deref()
    }

    /// Issue a request and deserialize the JSON response.
    pub async fn request<T: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<T, HeyoError> {
        let bytes = self.request_bytes(method, path, body, opts).await?;
        if bytes.is_empty() {
            // Caller asked for T but the server returned no body. Try to
            // deserialize "null" so types like `()` (via serde_json::Value)
            // or Option<T> still work.
            return serde_json::from_slice::<T>(b"null").map_err(|e| {
                HeyoError::api(0, format!("empty response body could not be parsed: {}", e))
            });
        }
        serde_json::from_slice::<T>(&bytes)
            .map_err(|e| HeyoError::api(0, format!("invalid JSON response: {}", e)))
    }

    /// Like `request` but returns the raw response bytes (used by binary
    /// endpoints like `/sqlite-databases/:id/file`).
    pub async fn request_bytes(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<Vec<u8>, HeyoError> {
        let response = self.raw_request(method, path, body, opts).await?;
        self.consume_response(response, path).await
    }

    /// Issue the request and return the raw `reqwest::Response`. Use this
    /// when you need response headers (e.g. checkout's
    /// `X-Heyo-Data-Version`) or want to stream the body.
    pub async fn raw_request(
        &self,
        method: Method,
        path: &str,
        body: Option<&(impl Serialize + ?Sized)>,
        opts: RequestOptions,
    ) -> Result<Response, HeyoError> {
        let url = self.build_url(path, &opts.query)?;
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        if let Some(key) = &self.inner.api_key {
            let auth = format!("Bearer {}", key);
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&auth)
                    .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
            );
        }
        let mut builder = self
            .inner
            .http
            .request(method, url)
            .headers(headers)
            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout));
        if let Some(body) = body {
            builder = builder
                .header(CONTENT_TYPE, "application/json")
                .json(body);
        }
        builder
            .send()
            .await
            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
    }

    /// POST raw bytes (Content-Type: application/octet-stream by default) and
    /// return the raw response. Used by `Database::checkin`.
    pub async fn put_bytes(
        &self,
        path: &str,
        body: Vec<u8>,
        content_type: &str,
        opts: RequestOptions,
    ) -> Result<Response, HeyoError> {
        let url = self.build_url(path, &opts.query)?;
        let mut headers = HeaderMap::new();
        if let Some(key) = &self.inner.api_key {
            let auth = format!("Bearer {}", key);
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&auth)
                    .map_err(|e| HeyoError::api(0, format!("invalid api key header: {}", e)))?,
            );
        }
        headers.insert(
            CONTENT_TYPE,
            HeaderValue::from_str(content_type)
                .map_err(|e| HeyoError::api(0, format!("invalid content-type: {}", e)))?,
        );
        self.inner
            .http
            .request(Method::PUT, url)
            .headers(headers)
            .timeout(opts.timeout.unwrap_or(self.inner.default_timeout))
            .body(body)
            .send()
            .await
            .map_err(|e| HeyoError::api(0, format!("network error calling {}: {}", path, e)))
    }

    /// Build the WS URL for a given path. Scheme is swapped (`http`→`ws`,
    /// `https`→`wss`).
    pub(crate) fn ws_url(&self, path: &str) -> Result<String, HeyoError> {
        let http_url = self.build_url(path, &[])?;
        let mut parsed = Url::parse(&http_url)
            .map_err(|e| HeyoError::Connection(format!("bad URL {}: {}", http_url, e)))?;
        let scheme = match parsed.scheme() {
            "https" => "wss",
            "http" => "ws",
            other => return Err(HeyoError::Connection(format!("unsupported scheme {}", other))),
        };
        parsed
            .set_scheme(scheme)
            .map_err(|_| HeyoError::Connection("could not swap to ws scheme".into()))?;
        Ok(parsed.to_string())
    }

    pub(crate) fn ws_authorization(&self) -> String {
        // Empty when unauthenticated (local daemon with no JWT_SECRET ignores
        // the header). Cloud clients always carry a key.
        match &self.inner.api_key {
            Some(key) => format!("Bearer {}", key),
            None => String::new(),
        }
    }

    fn build_url(&self, path: &str, query: &[(String, String)]) -> Result<String, HeyoError> {
        let clean = if path.starts_with('/') {
            path.to_string()
        } else {
            format!("/{}", path)
        };
        let mut url = Url::parse(&format!("{}{}", self.inner.base_url, clean))
            .map_err(|e| HeyoError::api(0, format!("bad URL {}{}: {}", self.inner.base_url, clean, e)))?;
        if !query.is_empty() {
            let mut pairs = url.query_pairs_mut();
            for (k, v) in query {
                pairs.append_pair(k, v);
            }
        }
        Ok(url.to_string())
    }

    async fn consume_response(
        &self,
        response: Response,
        path: &str,
    ) -> Result<Vec<u8>, HeyoError> {
        let status = response.status();
        let bytes = response
            .bytes()
            .await
            .map_err(|e| HeyoError::api(0, format!("read body for {}: {}", path, e)))?;
        if status.is_success() {
            if status == StatusCode::NO_CONTENT || status == StatusCode::RESET_CONTENT {
                return Ok(Vec::new());
            }
            return Ok(bytes.to_vec());
        }

        let mut message = format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
        let mut parsed_body: Option<serde_json::Value> = None;
        if !bytes.is_empty() {
            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
                if let Some(m) = v.get("message").and_then(|x| x.as_str()) {
                    message = m.to_string();
                } else if let Some(e) = v.get("error").and_then(|x| x.as_str()) {
                    message = e.to_string();
                }
                parsed_body = Some(v);
            } else if let Ok(text) = std::str::from_utf8(&bytes) {
                message = text.to_string();
            }
        }

        let with_path = format!("{} (calling {})", message, path);
        Err(match status {
            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => HeyoError::Authentication,
            StatusCode::NOT_FOUND => HeyoError::NotFound(with_path),
            StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
                HeyoError::InvalidArgument(with_path)
            }
            _ => HeyoError::api_with_body(status.as_u16(), with_path, parsed_body),
        })
    }
}

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