//! A client for interacting with the FW API.
//! See the [`FWClientBuilder`] for more information on how to create a client.
//!
//! See the [`FWClient`] documentation for more information on how to use the client.
use hyper::header::HeaderValue;
use reqwest::{header, Client, Method};
use reqwest_middleware::{
    ClientBuilder, ClientWithMiddleware, Middleware, RequestBuilder, RequestInitialiser,
};
use reqwest_retry::{
    policies::ExponentialBackoff, RetryPolicy, RetryTransientMiddleware, RetryableStrategy,
};
use reqwest_tracing::TracingMiddleware;
use std::{str::FromStr, sync::Arc, time::Duration};
use thiserror::Error;

pub use crate::middleware::FWOptions;
use crate::{
    headers::{get_user_agent, ApiKey},
    middleware::FWRetryTransientMiddleware,
};

pub mod headers;
pub mod middleware;
#[cfg(test)]
mod test;

#[derive(Debug, Error)]
pub enum FWClientError {
    #[error(
        "Invalid API key format, expected format: [<scheme>://]<host>[:<port>]:<key>, found: {0}"
    )]
    InvalidApiKey(String),
    #[error("Missing component: {0}")]
    MissingComponent(String),
    #[error("Could not construct HTTP client")]
    HttpClientError(#[from] reqwest::Error),
    #[error("Failed to parse header value")]
    HeaderValueError(#[from] reqwest::header::InvalidHeaderValue),
}

enum InnerKey {
    ApiKey(ApiKey),
    String(String),
}

/// Builder for the `FWClient`.
///
/// This builder allows you to configure the client with an API key, client name,
/// client version.
///
/// For further control you can also add custom middleware and request initializers
/// using the [`FWClientBuilder::with`] and [`FWClientBuilder::with_init`] methods.
/// For more information on how to write a Middleware or Request Initialiser, see the
/// [`reqwest_middleware::Middleware`] and [`reqwest_middleware::RequestInitialiser`]
/// traits.
///
/// Example:
///
/// ```rust
/// use fw_client::{FWClientBuilder, FWClient};
/// let client = FWClientBuilder::from("scitran-user fw_instance:test_api_key")
///    .client_name("MyClient".to_string())
///    .client_version("1.0.0".to_string())
///    .build();
/// ```
pub struct FWClientBuilder {
    api_key: InnerKey,
    client_name: Option<String>,
    client_version: Option<String>,
    middleware_stack: Vec<Arc<dyn Middleware>>,
    initialiser_stack: Vec<Arc<dyn RequestInitialiser>>,
    retry: Arc<dyn Middleware>,
    tracing: Option<Arc<dyn Middleware>>,
}

impl Default for FWClientBuilder {
    /// Create a new `FWClientBuilder` with default values.
    ///
    /// This also includes a default retry policy with an exponential backoff strategy
    /// and a tracing middleware.
    ///
    /// The default values are:
    /// - `api_key`: Empty string
    /// - `client_name`: None
    /// - `client_version`: None
    /// - `timeout`: 60 seconds
    /// - `connect_timeout`: 10 seconds
    /// - `read_timeout`: 60 seconds
    fn default() -> Self {
        Self {
            api_key: InnerKey::String(String::new()),
            client_name: None,
            client_version: None,
            middleware_stack: vec![],
            initialiser_stack: vec![],
            tracing: Some(Arc::new(TracingMiddleware::default())),
            retry: Arc::new(FWRetryTransientMiddleware::new(Some(
                RetryTransientMiddleware::new_with_policy(
                    ExponentialBackoff::builder().build_with_max_retries(3),
                ),
            ))),
        }
    }
}

impl<T: AsRef<str>> From<T> for FWClientBuilder {
    fn from(api_key: T) -> Self {
        Self {
            api_key: InnerKey::String(api_key.as_ref().to_string()),
            ..Default::default()
        }
    }
}

#[allow(dead_code)]
impl FWClientBuilder {
    /// Create a new `FWClientBuilder` with the provided API key.
    ///
    /// See the [`ApiKey`] documentation for the expected format.
    ///
    /// You can also use the [`From`] trait to create a builder from a string:
    ///
    /// ```rust
    /// use fw_client::FWClientBuilder;
    /// let builder = FWClientBuilder::from("scitran-user fw_instance:test_api_key");
    /// // or
    /// let builder = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap());;
    /// ```
    pub fn new(api_key: ApiKey) -> Self {
        Self {
            api_key: InnerKey::ApiKey(api_key),
            ..Default::default()
        }
    }

    /// Set the client name. Used in "User-Agent" header.
    pub fn client_name(mut self, name: String) -> Self {
        self.client_name = Some(name);
        self
    }

    /// Set client version. Used in "User-Agent" header.
    pub fn client_version(mut self, version: String) -> Self {
        self.client_version = Some(version);
        self
    }

    /// Sets whether the client should use tracing middleware, defaults to `true`.
    pub fn tracing(mut self, tracing: bool) -> Self {
        if tracing {
            self
        } else {
            self.tracing = None;
            self
        }
    }

    /// Set the retry policy and strategy for the client,
    ///
    /// By default an exponential backoff policy with 3 retries is used.
    ///
    /// For example you can create a simple retry policy that retries 5 times with
    /// 1 second delay between retries:
    ///
    /// ```rust
    /// use reqwest_retry::{RetryPolicy, RetryTransientMiddleware, RetryDecision, RetryableStrategy};
    /// use fw_client::{FWClientBuilder, FWClient};
    /// use std::time::{Duration, SystemTime};
    /// struct SimpleRetryPolicy(u32);
    ///
    /// impl RetryPolicy for SimpleRetryPolicy {
    ///    fn should_retry(&self, _: SystemTime, attempt: u32) -> RetryDecision {
    ///        if attempt >= self.0 {
    ///            RetryDecision::DoNotRetry
    ///        } else {
    ///            RetryDecision::Retry{
    ///                execute_after: SystemTime::now() + Duration::from_secs(1)
    ///            }
    ///        }
    ///    }
    /// }
    /// let client = FWClientBuilder::from("fw_instance:test_api_key")
    ///    .with_retry(Some(RetryTransientMiddleware::new_with_policy(
    ///        SimpleRetryPolicy(3)
    ///     )))
    ///    .build()
    ///    .expect("Failed to create FWClient");
    /// ```
    pub fn with_retry<
        T: RetryPolicy + Send + Sync + 'static,
        R: RetryableStrategy + Send + Sync + 'static,
    >(
        mut self,
        retry: Option<RetryTransientMiddleware<T, R>>,
    ) -> Self {
        self.retry = Arc::new(FWRetryTransientMiddleware::new(retry));
        self
    }

    /// Convenience method to attach middleware.
    ///
    /// If you need to keep a reference to the middleware after attaching, use [`with_arc`].
    ///
    /// [`with_arc`]: Self::with_arc
    pub fn with<M>(self, middleware: M) -> Self
    where
        M: Middleware,
    {
        self.with_arc(Arc::new(middleware))
    }

    /// Add middleware to the chain. [`with`] is more ergonomic if you don't need the `Arc`.
    ///
    /// [`with`]: Self::with
    pub fn with_arc(mut self, middleware: Arc<dyn Middleware>) -> Self {
        self.middleware_stack.push(middleware);
        self
    }

    /// Convenience method to attach a request initialiser.
    ///
    /// If you need to keep a reference to the initialiser after attaching, use [`with_arc_init`].
    ///
    /// [`with_arc_init`]: Self::with_arc_init
    pub fn with_init<I>(self, initialiser: I) -> Self
    where
        I: RequestInitialiser,
    {
        self.with_arc_init(Arc::new(initialiser))
    }

    /// Add a request initialiser to the chain. [`with_init`] is more ergonomic if you don't need the `Arc`.
    ///
    /// [`with_init`]: Self::with_init
    pub fn with_arc_init(mut self, initialiser: Arc<dyn RequestInitialiser>) -> Self {
        self.initialiser_stack.push(initialiser);
        self
    }

    /// Build the `FWClient` with the provided configuration
    ///
    /// Uses default configuration for the underlying `reqwest` client.
    /// * Timeouts: 60 seconds for read and write, 10 seconds for connect
    /// * Redirect policy: limited to 10 redirects
    ///
    /// If you need to customise the `reqwest::Client`, use [`FWClientBuilder::build_with_client`].
    pub fn build(self) -> Result<FWClient, FWClientError> {
        let client_builder = Client::builder()
            .timeout(Duration::from_secs(60))
            .read_timeout(Duration::from_secs(60))
            .connect_timeout(Duration::from_secs(10))
            .redirect(reqwest::redirect::Policy::limited(10));

        let client: Client = client_builder.build()?;
        self.build_with_client(client)
    }

    /// Build the `FWClient` with the provided `reqwest::Client`.
    ///
    /// ```rust,ignore
    /// use reqwest::Client;
    /// use fw_client::FWClientBuilder;
    /// use reqwest::Certificate;
    /// let client = reqwest::Client::builder()
    ///     // Override default timeouts
    ///    .timeout(std::time::Duration::from_secs(120))
    ///    .connect_timeout(std::time::Duration::from_secs(30))
    ///     // Add custom root certificate
    ///    .add_root_certificate(Certificate::from_pem(include_bytes!("path/to/cert.pem")).unwrap())
    ///    .build()
    ///    .unwrap();
    ///
    /// let fw_client = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap())
    ///    .build_with_client(client)
    ///    .unwrap();
    /// ```
    pub fn build_with_client(self, client: reqwest::Client) -> Result<FWClient, FWClientError> {
        let api_key = match self.api_key {
            InnerKey::ApiKey(api_key) => api_key,
            InnerKey::String(api_key) => ApiKey::from_str(api_key.as_ref())?,
        };
        let mut new_client = ClientBuilder::new(client.clone());
        // Add tracing and retry middleware if they are set
        if let Some(tracing) = self.tracing {
            new_client = new_client.with_arc(tracing);
        }
        new_client = new_client.with_arc(self.retry);
        // Add the other middlewares
        for middleware in self.middleware_stack {
            new_client = new_client.with_arc(middleware);
        }
        for initialiser in self.initialiser_stack {
            new_client = new_client.with_arc_init(initialiser);
        }

        Ok(FWClient {
            scheme: api_key.scheme.clone().unwrap_or("https://".to_string()),
            client: new_client.build(),
            base_url: match &api_key.port {
                Some(port) => format!("{}{}", &api_key.host, port),
                None => api_key.host.clone(),
            },
            user_agent: get_user_agent(self.client_name, self.client_version).parse()?,
            api_key: api_key.to_string().parse()?,
        })
    }
}

/// A client for interacting with the FW API.
///
/// Wraps the `reqwest` crates `Client` and `RequestBuilder`, but includes middleware for retrying all requests
/// are authenticated with the provided API key and set the user agent.
///
/// ```rust,no_run
/// use serde_json::Value;
/// use fw_client::{FWClientBuilder, FWClient};
/// #[tokio::main]
/// pub async fn main() {
/// // Initiate client
/// let client = FWClientBuilder::from("scitran-user httpbin.org:test_api_key")
///     .build()
///     .expect("Failed to create FWClient");
///
/// // Send a POST request with headers, query parameters, and a body
/// let resp = client.post("/post")
///     .header("Content-Type", "application/json")
///     .query(&[("param", "value1")])
///     .body(r#"{"key": "value2"}"#)
///     .send()
///     .await
///     .unwrap()
///     .json::<Value>()
///     .await
///     .unwrap();
///
/// assert_eq!(resp["json"]["key"], "value2");
/// assert_eq!(resp["args"]["param"], "value1");
/// assert_eq!(resp["headers"]["Authorization"], "scitran-user httpbin.org:test_api_key");
/// }
/// ```
///
/// If you need to request a full URL instead of an endpoint, you can use the
/// [`FWClient::request`] method.
/// ```rust,no_run
/// use reqwest::Method;
/// use serde_json::Value;
/// use fw_client::{FWClientBuilder, FWClient};
/// #[tokio::main]
/// pub async fn main() {
/// // Initiate client
/// let client = FWClientBuilder::new("scitran-user fw_instance:test_api_key".parse().unwrap())
///     .build()
///     .expect("Failed to create FWClient");
///
/// // Send a PUT request with an absolute URL and no authentication
/// let resp = client.request(Method::PUT, "https://httpbin.org/put")
///     .header("Content-Type", "application/json")
///     .body(r#"{"key": "value"}"#)
///     .send()
///     .await
///     .unwrap()
///     .json::<Value>()
///     .await;
/// }
/// ```
///
/// If you need to skip authentication or retry logic for a specific request, you can use the
/// [`FWOptions`] extension. This is useful for requests that can't have authorization, or for
/// requests that can't be retried (e.g. POST requests that create resources).
///
/// ```rust,no_run
/// use reqwest::Method;
/// use serde_json::Value;
/// use fw_client::{FWClientBuilder, FWClient, FWOptions};
/// # #[tokio::main]
/// # pub async fn main() {
/// // Initiate client
/// let client = FWClientBuilder::new("scitran-user httpbin.org:test_api_key".parse().unwrap())
///     .build()
///     .expect("Failed to create FWClient");
///
/// // Send a PUT request with an absolute URL and no authentication
/// let resp = client.post("/post")
///     .header("Content-Type", "application/json")
///     .body(r#"{"key": "value"}"#)
///     .with_extension(fw_client::FWOptions {
///         skip_retry: true, // Skip retry logic for this request
///         no_auth: true, // Skip authentication for this request
///     })
///     .send()
///     .await
///     .unwrap()
///     .json::<Value>()
///     .await
///     .unwrap();
/// assert!(resp["headers"]["Authorization"].is_null());
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct FWClient {
    scheme: String,
    base_url: String,
    client: ClientWithMiddleware,
    user_agent: HeaderValue,
    api_key: HeaderValue,
}

#[allow(dead_code)]
impl FWClient {
    fn url(&self, endpoint: &str) -> String {
        format!("{}{}{}", self.scheme, self.base_url, endpoint)
    }

    /// Create a new get request with the given endpoint
    pub fn get(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .get(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new post request with the given endpoint
    pub fn post(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .post(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new put request with the given endpoint
    pub fn put(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .put(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new delete request with the given endpoint
    pub fn delete(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .delete(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new head request with the given endpoint
    pub fn head(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .head(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new patch request with the given endpoint
    pub fn patch(&self, endpoint: &str) -> RequestBuilder {
        self.client
            .patch(self.url(endpoint))
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }

    /// Create a new request with a given method, and absolute URL
    pub fn request(&self, method: Method, url: &str) -> RequestBuilder {
        self.client
            .request(method, url)
            .header(header::USER_AGENT, self.user_agent.clone())
            .header(header::AUTHORIZATION, self.api_key.clone())
    }
}
