use crate::authentication::Authentication;
use crate::data::AccessTokenResult;
use async_trait::async_trait;
use std::collections::HashMap;
use std::convert::TryInto;
use std::time::{Duration, Instant};
use url::Url;

type BoxedError = Box<dyn std::error::Error>;

const AUTH_ENDPOINT: &'static str = "/apiv2/oauth/token";

#[derive(Default)]
pub struct ClientCredentialsAuthentication {
    pub access_token: Option<String>,
    pub valid_to: Option<Instant>,
    pub impersonate_as: Option<String>,
    pub roles: Option<Vec<String>>,
}

impl ClientCredentialsAuthentication {
    /// Creates a new ClientCredentialAuthentication
    ///
    /// ## Arguments
    ///
    /// * `impersonate_as` - The user (if any) the client should impersonate
    /// * `roles` - A list of roles (if any) that the client should impersonate
    ///
    /// ## Example
    ///
    /// ```
    /// use imagevault::authentication::ClientCredentialsAuthentication;
    ///
    /// // No impersonation
    /// // Can also use ClientCredentialsAuthentication::default()
    /// let auth = ClientCredentialsAuthentication::new(None, None);
    ///
    /// // Impersonates user JohnD
    /// let auth = ClientCredentialsAuthentication::new(
    ///     Some("JohnD"),
    ///     None
    /// );
    ///
    /// // Impersonates user JohnD and roles Admin and SuperAdmin
    /// let auth = ClientCredentialsAuthentication::new(
    ///     Some("JohnD"),
    ///     Some(vec!["Admin", "SuperAdmin"])
    /// );
    /// ```
    pub fn new(impersonate_as: Option<&str>, roles: Option<Vec<&str>>) -> Self {
        ClientCredentialsAuthentication {
            access_token: None,
            valid_to: None,
            impersonate_as: impersonate_as.map(|x| x.to_owned()),
            roles: roles.map(|x| x.iter().map(|y| y.to_string()).collect()),
        }
    }
}

#[async_trait]
impl Authentication for ClientCredentialsAuthentication {
    async fn authenticate(
        &mut self,
        client_identity: &str,
        client_secret: &str,
        base_url: &Url,
        reqwest_client: &reqwest::Client,
    ) -> Result<String, BoxedError> {
        if let Some(token) = &self.access_token {
            // We have a token - is it valid still?
            if let Some(validity) = self.valid_to {
                if validity > Instant::now() {
                    // We are good - return token
                    return Ok(token.to_string());
                }
            }
        }
        // Full authentication
        let auth_url = base_url.join(AUTH_ENDPOINT)?;
        let mut params = HashMap::new();
        params.insert("grant_type", "client_credentials");

        // Optional parameters
        if let Some(imp) = &self.impersonate_as {
            params.insert("impersonate_as", imp);
        }

        let joined_roles = match &self.roles {
            Some(r) => Some(r.join(",")),
            None => None,
        };

        if let Some(r) = &joined_roles {
            params.insert("roles", &r);
        }

        let response = reqwest_client
            .post(auth_url)
            .basic_auth(client_identity, Some(client_secret))
            .form(&params)
            .send()
            .await?;
        if let Err(err) = response.error_for_status_ref() {
            return Err(Box::new(err));
        }
        let result = response.json::<AccessTokenResult>().await?;
        self.access_token = Some(result.access_token.clone());
        self.valid_to =
            Some(Instant::now() + Duration::from_secs(result.expires_in.try_into().unwrap()));
        Ok(result.access_token)
    }
}

#[cfg(test)]
mod tests {
    use super::{ClientCredentialsAuthentication, AUTH_ENDPOINT};
    use crate::testutil::get_test_data;
    use crate::Client;
    use mockito;
    #[test]
    fn client_credentials_authentication() {
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            // setup mock HTTP response
            let mock = mockito::mock("POST", AUTH_ENDPOINT)
                .expect(1)
                .with_status(200)
                .with_header("content-type", "application/json")
                .with_body(get_test_data("client_credentials_auth_response"))
                .create();

            let auth = ClientCredentialsAuthentication::default();
            let client = Client::new("client_identity", "client_secret", &mockito::server_url())
                .unwrap()
                .with_authentication(auth);

            // First call - auth should not be set
            let auth_header = client.test_authenticate().await;
            assert!(auth_header.is_ok());
            // Second call - should not request a new auth, it should still be valid
            let auth_header = client.test_authenticate().await;
            assert!(auth_header.is_ok());
            mock.assert();
        });
    }

    #[test]
    fn expired_token_test() {
        let mut rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            // setup mock HTTP response
            let mock = mockito::mock("POST", AUTH_ENDPOINT)
                .expect(2)
                .with_status(200)
                .with_header("content-type", "application/json")
                .with_body(get_test_data("client_credentials_auth_response_no_expiry"))
                .create();

            let auth = ClientCredentialsAuthentication::default();
            let client = Client::new("client_identity", "client_secret", &mockito::server_url())
                .unwrap()
                .with_authentication(auth);

            // First call - auth should not be set
            let auth_header = client.test_authenticate().await;
            assert!(auth_header.is_ok());
            // Second call - auth should have timed out, we should request again
            let auth_header = client.test_authenticate().await;
            assert!(auth_header.is_ok());
            mock.assert();
        });
    }
}
