//! The `FinProxy` client and the shared request helper.

use std::time::Duration;

use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url;

use crate::envelope::{Envelope, ErrBody};
use crate::error::SdkError;

/// A client for a fin-proxy instance.
#[derive(Debug, Clone)]
pub struct FinProxy {
    pub(crate) http: reqwest::Client,
    pub(crate) base_url: Url,
    pub(crate) api_key: Option<String>,
}

impl FinProxy {
    /// Create a client pointing at `base_url` (e.g. `https://api.example.com`).
    pub fn new(base_url: impl AsRef<str>) -> Result<Self, SdkError> {
        // Ensure the base URL ends with `/` so `Url::join` keeps the host path.
        let raw = base_url.as_ref();
        let normalized = if raw.ends_with('/') {
            raw.to_string()
        } else {
            format!("{raw}/")
        };
        Ok(Self {
            http: reqwest::Client::new(),
            base_url: Url::parse(&normalized)?,
            api_key: None,
        })
    }

    /// Attach a Bearer API key (Unkey) sent on every request.
    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    /// Set the per-request timeout. Rebuilds the inner reqwest client.
    pub fn timeout(mut self, dur: Duration) -> Self {
        self.http = reqwest::Client::builder()
            .timeout(dur)
            .build()
            .expect("reqwest client builder with timeout");
        self
    }

    /// GET `path` (a leading-slash path like `/v1/fmp/...`) with `params` as the
    /// query string, then unwrap the fin-proxy envelope into `T`.
    pub(crate) async fn get<T: DeserializeOwned>(
        &self,
        path: &str,
        params: &impl Serialize,
    ) -> Result<T, SdkError> {
        // `Url::join` treats a leading `/` as absolute-from-host, so strip it.
        let url = self.base_url.join(path.trim_start_matches('/'))?;
        let mut req = self.http.get(url).query(params);
        if let Some(key) = &self.api_key {
            req = req.bearer_auth(key);
        }
        let resp = req.send().await?;
        let status = resp.status();
        let bytes = resp.bytes().await?;

        if !status.is_success() {
            let message = serde_json::from_slice::<ErrBody>(&bytes)
                .ok()
                .and_then(|e| e.error);
            return Err(SdkError::Status { code: status.as_u16(), message });
        }

        let env: Envelope<T> = serde_json::from_slice(&bytes).map_err(SdkError::Decode)?;
        if env.success {
            env.data.ok_or(SdkError::EmptyData)
        } else {
            Err(SdkError::Status { code: status.as_u16(), message: env.error })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;
    use wiremock::matchers::{method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[derive(Debug, Deserialize, PartialEq)]
    struct Row {
        symbol: String,
    }

    #[derive(Serialize)]
    struct SymOnly {
        symbol: String,
    }

    #[tokio::test]
    async fn get_unwraps_success_envelope_and_sends_query() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/test"))
            .and(query_param("symbol", "AAPL"))
            .respond_with(ResponseTemplate::new(200).set_body_string(
                r#"{"success":true,"data":[{"symbol":"AAPL"}],"meta":{}}"#,
            ))
            .mount(&server)
            .await;

        let client = FinProxy::new(server.uri()).unwrap();
        let out: Vec<Row> = client
            .get("/v1/test", &SymOnly { symbol: "AAPL".into() })
            .await
            .unwrap();
        assert_eq!(out, vec![Row { symbol: "AAPL".into() }]);
    }

    #[tokio::test]
    async fn get_maps_non_2xx_to_status_with_message() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/test"))
            .respond_with(ResponseTemplate::new(400).set_body_string(
                r#"{"success":false,"error":"bad request: nope","meta":{}}"#,
            ))
            .mount(&server)
            .await;

        let client = FinProxy::new(server.uri()).unwrap();
        let err = client
            .get::<Vec<Row>>("/v1/test", &SymOnly { symbol: "X".into() })
            .await
            .unwrap_err();
        match err {
            SdkError::Status { code, message } => {
                assert_eq!(code, 400);
                assert_eq!(message.as_deref(), Some("bad request: nope"));
            }
            other => panic!("expected Status, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn get_sends_bearer_when_key_set() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/test"))
            .and(wiremock::matchers::header("authorization", "Bearer k123"))
            .respond_with(ResponseTemplate::new(200).set_body_string(
                r#"{"success":true,"data":[],"meta":{}}"#,
            ))
            .mount(&server)
            .await;

        let client = FinProxy::new(server.uri()).unwrap().with_api_key("k123");
        let out: Vec<Row> = client
            .get("/v1/test", &SymOnly { symbol: "X".into() })
            .await
            .unwrap();
        assert!(out.is_empty());
    }

    #[tokio::test]
    async fn timeout_builder_aborts_slow_requests() {
        use std::time::Duration;
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/v1/test"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_delay(Duration::from_millis(300))
                    .set_body_string(r#"{"success":true,"data":[],"meta":{}}"#),
            )
            .mount(&server)
            .await;

        let client = FinProxy::new(server.uri()).unwrap().timeout(Duration::from_millis(50));
        let err = client
            .get::<Vec<Row>>("/v1/test", &SymOnly { symbol: "X".into() })
            .await
            .unwrap_err();
        assert!(matches!(err, SdkError::Transport(ref e) if e.is_timeout()), "got {err:?}");
    }
}
