// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2021 The Gleam contributors

use camino::Utf8Path;
use clap::ValueEnum;
use debug_ignore::DebugIgnore;
use flate2::read::GzDecoder;
use futures::future;
use hexpm::{ApiError, WriteActionCredentials, version::Version};
use strum::{EnumString, VariantNames};
use tar::Archive;

use crate::{
    Error, Result,
    io::{FileSystemReader, FileSystemWriter, HttpClient, TarUnpacker},
    manifest::{ManifestPackage, ManifestPackageSource},
    paths::{self, ProjectPaths},
};

pub const HEXPM_PUBLIC_KEY: &[u8] = b"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApqREcFDt5vV21JVe2QNB
Edvzk6w36aNFhVGWN5toNJRjRJ6m4hIuG4KaXtDWVLjnvct6MYMfqhC79HAGwyF+
IqR6Q6a5bbFSsImgBJwz1oadoVKD6ZNetAuCIK84cjMrEFRkELtEIPNHblCzUkkM
3rS9+DPlnfG8hBvGi6tvQIuZmXGCxF/73hU0/MyGhbmEjIKRtG6b0sJYKelRLTPW
XgK7s5pESgiwf2YC/2MGDXjAJfpfCd0RpLdvd4eRiXtVlE9qO9bND94E7PgQ/xqZ
J1i2xWFndWa6nfFnRxZmCStCOZWYYPlaxr+FZceFbpMwzTNs4g3d4tLNUcbKAIH4
0wIDAQAB
-----END PUBLIC KEY-----
";

fn key_name(hostname: &str) -> String {
    format!("gleam-{hostname}")
}

pub async fn publish_package<Http: HttpClient>(
    release_tarball: Vec<u8>,
    version: String,
    name: &str,
    api_key: &WriteActionCredentials,
    config: &hexpm::Config,
    replace: bool,
    http: &Http,
) -> Result<()> {
    tracing::info!("Publishing package, replace: {}", replace);
    let request = hexpm::api_publish_package_request(release_tarball, api_key, config, replace);
    let response = http.send(request).await?;
    hexpm::api_publish_package_response(response).map_err(|e| match e {
        ApiError::NotReplacing => Error::HexPublishReplaceRequired { version },
        ApiError::Forbidden => Error::HexPublishAccessDenied {
            name: name.into(),
            version,
        },
        ApiError::Json(_)
        | ApiError::Io(_)
        | ApiError::RateLimited
        | ApiError::InvalidCredentials
        | ApiError::UnexpectedResponse(..)
        | ApiError::InvalidPackageNameFormat(_)
        | ApiError::IncorrectPayloadSignature
        | ApiError::InvalidProtobuf(_)
        | ApiError::InvalidVersionFormat(_)
        | ApiError::NotFound
        | ApiError::InvalidVersionRequirementFormat(_)
        | ApiError::IncorrectChecksum
        | ApiError::OAuthTimeout
        | ApiError::OAuthAccessDenied
        | ApiError::ExpiredToken
        | ApiError::OAuthRefreshTokenRejected
        | ApiError::IncorrectOneTimePassword
        | ApiError::LateDeletion
        | ApiError::LateModification => Error::hex(e),
    })
}

pub async fn add_owner<Http: HttpClient>(
    api_key: &WriteActionCredentials,
    package_name: String,
    new_owner_username_or_email: String,
    level: hexpm::OwnerLevel,
    config: &hexpm::Config,
    http: &Http,
) -> Result<()> {
    tracing::info!(
        "Adding {} as owner of `{}`",
        new_owner_username_or_email,
        package_name
    );
    let request = hexpm::api_add_owner_request(
        &package_name,
        &new_owner_username_or_email,
        level,
        api_key,
        config,
    );
    let response = http.send(request).await?;
    hexpm::api_add_owner_response(response).map_err(Error::hex)
}

pub async fn transfer_owner<Http: HttpClient>(
    api_key: &WriteActionCredentials,
    package_name: String,
    new_owner_username_or_email: String,
    config: &hexpm::Config,
    http: &Http,
) -> Result<()> {
    tracing::info!(
        "Transferring ownership of `{}` to {}",
        package_name,
        new_owner_username_or_email
    );
    let request = hexpm::api_transfer_owner_request(
        &package_name,
        &new_owner_username_or_email,
        api_key,
        config,
    );
    let response = http.send(request).await?;
    hexpm::api_transfer_owner_response(response).map_err(Error::hex)
}

#[derive(Debug, EnumString, VariantNames, ValueEnum, Clone, Copy, PartialEq, Eq)]
#[strum(serialize_all = "lowercase")]
pub enum RetirementReason {
    Other,
    Invalid,
    Security,
    Deprecated,
    Renamed,
}

impl RetirementReason {
    pub fn to_library_enum(&self) -> hexpm::RetirementReason {
        match self {
            RetirementReason::Other => hexpm::RetirementReason::Other,
            RetirementReason::Invalid => hexpm::RetirementReason::Invalid,
            RetirementReason::Security => hexpm::RetirementReason::Security,
            RetirementReason::Deprecated => hexpm::RetirementReason::Deprecated,
            RetirementReason::Renamed => hexpm::RetirementReason::Renamed,
        }
    }
}

pub async fn retire_release<Http: HttpClient>(
    package: &str,
    version: &str,
    reason: RetirementReason,
    message: Option<&str>,
    api_key: &WriteActionCredentials,
    config: &hexpm::Config,
    http: &Http,
) -> Result<()> {
    tracing::info!(package=%package, version=%version, "retiring_hex_release");
    let request = hexpm::api_retire_release_request(
        package,
        version,
        reason.to_library_enum(),
        message,
        api_key,
        config,
    );
    let response = http.send(request).await?;
    hexpm::api_retire_release_response(response).map_err(Error::hex)
}

pub async fn unretire_release<Http: HttpClient>(
    package: &str,
    version: &str,
    api_key: &WriteActionCredentials,
    config: &hexpm::Config,
    http: &Http,
) -> Result<()> {
    tracing::info!(package=%package, version=%version, "retiring_hex_release");
    let request = hexpm::api_unretire_release_request(package, version, api_key, config);
    let response = http.send(request).await?;
    hexpm::api_unretire_release_response(response).map_err(Error::hex)
}

pub async fn remove_api_key<Http: HttpClient>(
    hostname: &str,
    config: &hexpm::Config,
    auth_key: &WriteActionCredentials,
    http: &Http,
) -> Result<()> {
    tracing::info!("Deleting API key from Hex");
    let request = hexpm::api_remove_api_key_request(&key_name(hostname), auth_key, config);
    let response = http.send(request).await?;
    hexpm::api_remove_api_key_response(response).map_err(Error::hex)
}

#[derive(Debug)]
pub struct Downloader {
    fs_reader: DebugIgnore<Box<dyn FileSystemReader>>,
    fs_writer: DebugIgnore<Box<dyn FileSystemWriter>>,
    http: DebugIgnore<Box<dyn HttpClient>>,
    untar: DebugIgnore<Box<dyn TarUnpacker>>,
    hex_config: hexpm::Config,
    credentials: DebugIgnore<Option<hexpm::Credentials>>,
    paths: ProjectPaths,
}

impl Downloader {
    pub fn new(
        fs_reader: Box<dyn FileSystemReader>,
        fs_writer: Box<dyn FileSystemWriter>,
        http: Box<dyn HttpClient>,
        untar: Box<dyn TarUnpacker>,
        credentials: Option<hexpm::Credentials>,
        paths: ProjectPaths,
    ) -> Self {
        Self {
            fs_reader: DebugIgnore(fs_reader),
            fs_writer: DebugIgnore(fs_writer),
            http: DebugIgnore(http),
            untar: DebugIgnore(untar),
            hex_config: hexpm::Config::new(),
            credentials: DebugIgnore(credentials),
            paths,
        }
    }

    pub async fn ensure_package_downloaded(
        &self,
        package: &ManifestPackage,
    ) -> Result<bool, Error> {
        let outer_checksum = match &package.source {
            ManifestPackageSource::Hex { outer_checksum } => outer_checksum,
            ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => {
                panic!("Attempt to download non-hex package from hex")
            }
        };

        let tarball_path = paths::global_package_cache_package_tarball(outer_checksum);
        if self.fs_reader.is_file(&tarball_path) {
            tracing::info!(
                package = package.name.as_str(),
                version = %package.version,
                "package_in_cache"
            );
            return Ok(false);
        }
        tracing::info!(
            package = &package.name.as_str(),
            version = %package.version,
            "downloading_package_to_cache"
        );

        let request = hexpm::repository_get_package_tarball_request(
            &package.name,
            &package.version.to_string(),
            self.credentials.as_ref(),
            &self.hex_config,
        );
        let response = self.http.send(request).await?;

        let tarball = hexpm::repository_get_package_tarball_response(response, &outer_checksum.0)
            .map_err(|error| Error::DownloadPackageError {
            package_name: package.name.to_string(),
            package_version: package.version.to_string(),
            error: error.to_string(),
        })?;
        self.fs_writer.write_bytes(&tarball_path, &tarball)?;
        Ok(true)
    }

    pub async fn ensure_package_in_build_directory(
        &self,
        package: &ManifestPackage,
    ) -> Result<bool> {
        let _ = self.ensure_package_downloaded(package).await?;
        self.extract_package_from_cache(package)
    }

    // It would be really nice if this was async but the library is sync
    pub fn extract_package_from_cache(&self, package: &ManifestPackage) -> Result<bool> {
        let contents_path = Utf8Path::new("contents.tar.gz");
        let destination = self.paths.build_packages_package(&package.name);

        let outer_checksum = match &package.source {
            ManifestPackageSource::Hex { outer_checksum } => outer_checksum,
            ManifestPackageSource::Git { .. } | ManifestPackageSource::Local { .. } => {
                panic!("Attempt to download non-hex package from hex")
            }
        };

        // If the directory already exists then there's nothing for us to do
        if self.fs_reader.is_directory(&destination) {
            tracing::info!(
                package = package.name.as_str(),
                "Package already in build directory"
            );
            return Ok(false);
        }

        tracing::info!(package = package.name.as_str(), "writing_package_to_target");
        let tarball = paths::global_package_cache_package_tarball(outer_checksum);
        let reader = self.fs_reader.reader(&tarball)?;
        let mut archive = Archive::new(reader);

        // Find the source code from within the outer tarball
        for entry in self.untar.entries(&mut archive)? {
            let file = entry.map_err(Error::expand_tar)?;

            let path = file.header().path().map_err(Error::expand_tar)?;
            if path.as_ref() == contents_path {
                // Expand this inner source code and write to the file system
                let archive = Archive::new(GzDecoder::new(file));
                let result = self.untar.unpack(&destination, archive);

                // If we failed to expand the tarball remove any source code
                // that was partially written so that we don't mistakenly think
                // the operation succeeded next time we run.
                return match result {
                    Ok(()) => Ok(true),
                    Err(err) => {
                        self.fs_writer.delete_directory(&destination)?;
                        Err(err)
                    }
                };
            }
        }

        Err(Error::ExpandTar {
            error: "Unable to locate Hex package contents.tar.gz".into(),
        })
    }

    pub async fn download_hex_packages<'a, Packages: Iterator<Item = &'a ManifestPackage>>(
        &self,
        packages: Packages,
        project_name: &str,
    ) -> Result<()> {
        let futures = packages
            .filter(|package| project_name != package.name)
            .map(|package| self.ensure_package_in_build_directory(package));

        // Run the futures to download the packages concurrently
        let results = future::join_all(futures).await;

        // Count the number of packages downloaded while checking for errors
        for result in results {
            let _ = result?;
        }
        Ok(())
    }
}

pub async fn publish_documentation<Http: HttpClient>(
    name: &str,
    version: &Version,
    archive: Vec<u8>,
    api_key: &WriteActionCredentials,
    config: &hexpm::Config,
    http: &Http,
) -> Result<()> {
    tracing::info!("publishing_documentation");
    let request =
        hexpm::api_publish_docs_request(name, &version.to_string(), archive, api_key, config)
            .map_err(Error::hex)?;
    let response = http.send(request).await?;
    hexpm::api_publish_docs_response(response).map_err(Error::hex)
}

pub async fn get_package_release<Http: HttpClient>(
    name: &str,
    version: &Version,
    credentials: Option<&hexpm::Credentials>,
    config: &hexpm::Config,
    http: &Http,
) -> Result<hexpm::Release<hexpm::ReleaseMeta>> {
    let version = version.to_string();
    tracing::info!(
        name = name,
        version = version.as_str(),
        "looking_up_package_release"
    );
    let request = hexpm::api_get_package_release_request(name, &version, credentials, config);
    let response = http.send(request).await?;
    hexpm::api_get_package_release_response(response).map_err(Error::hex)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    /// A fake `HttpClient` that records the `authorization` header of the last
    /// request it was sent and replies with a canned package release.
    #[derive(Default)]
    struct AuthorizationCapturingHttpClient {
        last_authorization: Mutex<Option<String>>,
    }

    #[async_trait::async_trait]
    impl HttpClient for AuthorizationCapturingHttpClient {
        async fn send(
            &self,
            request: http::Request<Vec<u8>>,
        ) -> Result<http::Response<Vec<u8>>, Error> {
            let authorization = request
                .headers()
                .get("authorization")
                .map(|value| value.to_str().unwrap().to_string());
            *self.last_authorization.lock().unwrap() = authorization;

            let body = serde_json::json!({
                "version": "1.0.0",
                "checksum": "960090c2fb391784bb34267b099dc9315cc1b1f6013e7415bc763cef1905d7d3",
                "requirements": {},
                "meta": { "app": "gleam_stdlib", "build_tools": ["gleam"] }
            })
            .to_string()
            .into_bytes();

            Ok(http::Response::builder().status(200).body(body).unwrap())
        }
    }

    #[test]
    fn get_package_release_authenticates_with_api_key() {
        let http = AuthorizationCapturingHttpClient::default();
        let credentials = hexpm::Credentials::ApiKey("secret-key".into());

        let _ = futures::executor::block_on(get_package_release(
            "gleam_stdlib",
            &Version::new(1, 0, 0),
            Some(&credentials),
            &hexpm::Config::new(),
            &http,
        ))
        .unwrap();

        assert_eq!(
            http.last_authorization.lock().unwrap().as_deref(),
            Some("secret-key")
        );
    }

    #[test]
    fn get_package_release_is_anonymous_without_api_key() {
        let http = AuthorizationCapturingHttpClient::default();

        let _ = futures::executor::block_on(get_package_release(
            "gleam_stdlib",
            &Version::new(1, 0, 0),
            None,
            &hexpm::Config::new(),
            &http,
        ))
        .unwrap();

        assert_eq!(http.last_authorization.lock().unwrap().as_deref(), None);
    }
}
