/// GitHub client.
#[cfg(feature = "github")]
pub mod github;

/// GitLab client.
#[cfg(feature = "gitlab")]
pub mod gitlab;

/// Bitbucket client.
#[cfg(feature = "bitbucket")]
pub mod bitbucket;

/// Gitea client.
#[cfg(feature = "gitea")]
pub mod gitea;

/// Azure DevOps client.
#[cfg(feature = "azure_devops")]
pub mod azure_devops;

use std::env;
use std::fmt::Debug;
use std::time::Duration;

use cacache::RemoveOpts;
use dyn_clone::DynClone;
use etcetera::{BaseStrategy, choose_base_strategy};
use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use secrecy::ExposeSecret;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;

use crate::config::Remote;
use crate::contributor::RemoteContributor;
use crate::error::{Error, Result};

/// User agent for interacting with the GitHub API.
///
/// This is needed since GitHub API does not accept empty user agent.
pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));

/// TCP keepalive value in seconds.
pub(crate) const REQUEST_KEEP_ALIVE: u64 = 60;

/// Maximum number of entries to fetch in a single page.
pub(crate) const MAX_PAGE_SIZE: i32 = 100;

/// Trait for handling remote commits.
pub trait RemoteCommit: DynClone {
    /// Commit SHA.
    fn id(&self) -> String;
    /// Commit author.
    fn username(&self) -> Option<String>;
    /// Timestamp.
    fn timestamp(&self) -> Option<i64>;
    /// Convert date in RFC3339 format to unix timestamp
    fn convert_to_unix_timestamp(&self, date: &str) -> i64 {
        OffsetDateTime::parse(date, &Rfc3339)
            .expect("failed to parse date")
            .unix_timestamp()
    }
}

dyn_clone::clone_trait_object!(RemoteCommit);

/// Trait for handling remote pull requests.
pub trait RemotePullRequest: DynClone {
    /// Number.
    fn number(&self) -> i64;
    /// Title.
    fn title(&self) -> Option<String>;
    /// Account that opened the pull request.
    ///
    /// Defaults to `None` for backends that cannot provide it.
    fn author(&self) -> Option<String> {
        None
    }
    /// Labels of the pull request.
    fn labels(&self) -> Vec<String>;
    /// Merge commit SHA.
    fn merge_commit(&self) -> Option<String>;
}

dyn_clone::clone_trait_object!(RemotePullRequest);

/// Result of a remote metadata.
pub type RemoteMetadata = (Vec<Box<dyn RemoteCommit>>, Vec<Box<dyn RemotePullRequest>>);

/// Metadata of a remote release.
#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
pub struct RemoteReleaseMetadata {
    /// Contributors.
    pub contributors: Vec<RemoteContributor>,
}

impl Remote {
    /// Creates a HTTP client for the remote.
    fn create_client(&self, accept_header: &str) -> Result<ClientWithMiddleware> {
        if !self.is_set() {
            return Err(Error::RemoteNotSetError);
        }
        // cannot panic - see https://github.com/lunacookies/etcetera/issues/42
        let strategy = choose_base_strategy()
            .expect("cannot determine current OS's default strategy (layout)");
        let mut headers = HeaderMap::new();
        headers.insert(
            reqwest::header::ACCEPT,
            HeaderValue::from_str(accept_header)?,
        );
        if let Some(token) = &self.token {
            headers.insert(
                reqwest::header::AUTHORIZATION,
                format!("Bearer {}", token.expose_secret()).parse()?,
            );
        }
        headers.insert(reqwest::header::USER_AGENT, USER_AGENT.parse()?);
        let client_builder = Client::builder()
            .timeout(self.http_timeout)
            .tcp_keepalive(Duration::from_secs(REQUEST_KEEP_ALIVE))
            .default_headers(headers)
            .tls_built_in_root_certs(false);
        let client_builder = if self.native_tls.unwrap_or(false) {
            client_builder.tls_built_in_native_certs(true)
        } else {
            client_builder.tls_built_in_webpki_certs(true)
        };
        let client = client_builder.build()?;
        let client = ClientBuilder::new(client)
            .with(Cache(HttpCache {
                mode: CacheMode::Default,
                manager: CACacheManager {
                    path: strategy.cache_dir().join(env!("CARGO_PKG_NAME")),
                    remove_opts: RemoveOpts::default(),
                },
                options: HttpCacheOptions::default(),
            }))
            .build();
        Ok(client)
    }
}

/// Trait for handling the API connection and fetching.
pub trait RemoteClient {
    /// API URL for a particular client
    const API_URL: &'static str;

    /// Name of the environment variable used to set the API URL to a
    /// self-hosted instance (if applicable).
    const API_URL_ENV: &'static str;

    /// Returns the API url.
    fn api_url(&self) -> String {
        env::var(Self::API_URL_ENV)
            .ok()
            .or(self.remote().api_url)
            .unwrap_or_else(|| Self::API_URL.to_string())
    }

    /// Returns the remote repository information.
    fn remote(&self) -> Remote;

    /// Returns the HTTP client for making requests.
    fn client(&self) -> ClientWithMiddleware;

    /// Performs a HTTP GET request, deserializes the JSON response, and returns the result.
    /// This is the core HTTP request and JSON parsing logic shared by all API methods.
    /// Callers are responsible for any additional processing of the deserialized data.
    async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
        tracing::debug!("Sending request to: {url}");
        let response = self.client().get(url).send().await?.error_for_status()?;
        let response_text = if response.status().is_success() {
            let text = response.text().await?;
            tracing::trace!("Response: {text:?}");
            text
        } else {
            let text = response.text().await?;
            tracing::error!("Request error: {text}");
            text
        };
        Ok(serde_json::from_str::<T>(&response_text)?)
    }
}

/// Generates a function for updating the release metadata for a remote.
#[doc(hidden)]
#[macro_export]
macro_rules! update_release_metadata {
    ($remote: ident, $fn: ident) => {
        impl<'a> Release<'a> {
            /// Updates the remote metadata that is contained in the release.
            ///
            /// This function takes two arguments:
            ///
            /// - Commits: needed for associating the Git user with the GitHub username.
            /// - Pull requests: needed for generating the contributor list for the release.
            #[allow(deprecated)]
            pub fn $fn(
                &mut self,
                mut commits: Vec<Box<dyn RemoteCommit>>,
                pull_requests: Vec<Box<dyn RemotePullRequest>>,
            ) -> Result<()> {
                let mut contributors: Vec<RemoteContributor> = Vec::new();
                let mut release_commit_timestamp: Option<i64> = None;
                // retain the commits that are not a part of this release for later
                // on checking the first contributors.
                commits.retain(|v| {
                    if let Some(commit) = self.commits.iter_mut().find(|commit| commit.id == v.id())
                    {
                        let sha_short = Some(v.id().clone().chars().take(12).collect());
                        let pull_request = pull_requests.iter().find(|pr| {
                            pr.merge_commit() == Some(v.id().clone()) ||
                                pr.merge_commit() == sha_short
                        });
                        commit.$remote.username = v.username();
                        commit.$remote.pr_author = pull_request.and_then(|v| v.author());
                        commit.$remote.pr_number = pull_request.map(|v| v.number());
                        commit.$remote.pr_title = pull_request.and_then(|v| v.title().clone());
                        commit.$remote.pr_labels =
                            pull_request.map(|v| v.labels().clone()).unwrap_or_default();
                        if let Some(existing) = contributors
                            .iter_mut()
                            .find(|v| commit.$remote.username == v.username)
                        {
                            if let Some(pr_num) = commit.$remote.pr_number {
                                if !existing.pr_numbers.contains(&pr_num) {
                                    existing.pr_numbers.push(pr_num);
                                }
                            }
                        } else {
                            contributors.push(RemoteContributor {
                                username: commit.$remote.username.clone(),
                                // Left empty: contributors are deduplicated by
                                // username, so one entry can cover pull requests
                                // opened by different people. `pr_author` is only
                                // unambiguous per commit.
                                pr_author: None,
                                pr_title: commit.$remote.pr_title.clone(),
                                pr_number: commit.$remote.pr_number,
                                pr_numbers: commit.$remote.pr_number.into_iter().collect(),
                                pr_labels: commit.$remote.pr_labels.clone(),
                                is_first_time: false,
                            });
                        }
                        commit.remote = Some(commit.$remote.clone());
                        // if remote commit is the release commit store timestamp for
                        // use in calculation of first time
                        if Some(v.id().clone()) == self.commit_id {
                            release_commit_timestamp = v.timestamp().clone();
                        }
                        false
                    } else {
                        true
                    }
                });
                // mark contributors as first-time
                self.$remote.contributors = contributors
                    .into_iter()
                    .map(|mut v| {
                        v.pr_numbers.sort_unstable();
                        v.is_first_time = !commits
                            .iter()
                            .filter(|commit| {
                                // If the current release is unreleased or we cannot
                                // resolve the release commit timestamp, skip filtering
                                // to avoid false positives.
                                self.timestamp.is_none() ||
                                    release_commit_timestamp.is_none() ||
                                    commit.timestamp() < release_commit_timestamp
                            })
                            .map(|v| v.username())
                            .any(|login| login == v.username);
                        v
                    })
                    .collect();
                Ok(())
            }
        }
    };
}
