use std::io::Write;

use log::{debug, trace};
use reqwest::StatusCode;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use semver::{Version, VersionReq};
use serde::Deserialize;
use tempfile::NamedTempFile;

use crate::error::Error;
use crate::krate::Crate;
use crate::metadata::get_crate_metadata;
use crate::utils;
use crate::vcsinfo::vcs_info_from_root;

const CRATES_IO_BASE_URL: &str = "https://crates.io/api/v1";

#[derive(Deserialize)]
struct CratesIoVersions {
    versions: Vec<CratesIoVersion>,
}

#[derive(Deserialize)]
struct CratesIoVersion {
    #[serde(rename = "num")]
    version: Version,
    yanked: bool,
    // incomplete
}

/// Client for crate registries.
pub struct RegistryClient {
    client: reqwest::Client,
    base_url: reqwest::Url,
}

impl RegistryClient {
    /// Initialize client for the registry at <https://crates.io>.
    ///
    /// # Errors
    ///
    /// This function returns an error in case the HTTP client cannot be initialized.
    pub fn crates_io(user_agent: &str) -> Result<Self, Error> {
        RegistryClient::custom(CRATES_IO_BASE_URL, user_agent)
    }

    /// Initialize client for a custom crate registry.
    ///
    /// # Errors
    ///
    /// This function returns an error in case the HTTP client cannot be initialized,
    /// or if the custom user agent is not valid as an HTTP header value.
    pub fn custom(base_url: &str, user_agent: &str) -> Result<Self, Error> {
        let mut headers = HeaderMap::new();
        headers.insert(
            USER_AGENT,
            HeaderValue::from_str(user_agent).map_err(|e| Error::Client {
                inner: format!("Invalid USER_AGENT: {e}"),
            })?,
        );

        debug!("Initializing HTTP client");
        // this is not an error that can reasonably be handled
        let client = reqwest::ClientBuilder::new().default_headers(headers).build()?;

        let url = base_url.parse().map_err(|e| Error::Client {
            inner: format!("Invalid base URL: {e}"),
        })?;

        Ok(RegistryClient { client, base_url: url })
    }

    /// Download crate sources from crates.io.
    ///
    /// In `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::RegistryClient;
    /// # use tokio::runtime::Runtime;
    ///
    /// # let rt = Runtime::new().unwrap();
    /// let registry = RegistryClient::crates_io("ingredients").unwrap();
    /// # rt.block_on(async {
    /// let _krate = registry.download("ingredients", "0.1.0").await.unwrap();
    /// # })
    /// ```
    ///
    /// Outside of `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::RegistryClient;
    /// use tokio::runtime::Runtime;
    ///
    /// let rt = Runtime::new().unwrap();
    /// let registry = RegistryClient::crates_io("ingredients").unwrap();
    ///
    /// let _krate = rt
    ///     .block_on(registry.download("ingredients", "0.1.0"))
    ///     .unwrap();
    /// ```
    ///
    /// # Errors
    ///
    /// * The network connection to crates.io fails.
    /// * The specified crate does not exist.
    /// * The specified version does not exist.
    /// * The downloaded file is not a crate archive (.tar.gz format).
    /// * The crate cannot be unpacked in a temporary location.
    /// * The crate does not contain valid crate metadata.
    pub async fn download(&self, name: &str, version: &str) -> Result<Crate, Error> {
        let dl = self.download_tarball(name, version).await?;
        let temp = utils::unpack_tar_gz(dl)?;

        let root = temp.path().join(format!("{name}-{version}"));
        let metadata = get_crate_metadata(root.clone())?;
        let vcs_info = vcs_info_from_root(&root)?;

        Ok(Crate {
            metadata,
            root,
            vcs_info,
            _temp: Some(temp),
        })
    }

    /// Query crates.io API for available versions of a crate.
    ///
    /// The `range` argument can be used to restrict versions to a specified range.
    ///
    /// The `include_yanked` and `include_prerelease` arguments can be used to filter out yanked
    /// versions and pre-release versions from the results.
    ///
    /// In `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::RegistryClient;
    /// # use tokio::runtime::Runtime;
    ///
    /// # let rt = Runtime::new().unwrap();
    /// let registry = RegistryClient::crates_io("ingredients").unwrap();
    /// # rt.block_on(async {
    /// let _versions = registry
    ///     .query_versions("ingredients", None, false, false)
    ///     .await
    ///     .unwrap();
    /// # })
    /// ```
    ///
    /// Outside of `async` contexts:
    ///
    /// ```rust,no_run
    /// use ingredients::RegistryClient;
    /// use tokio::runtime::Runtime;
    ///
    /// let rt = Runtime::new().unwrap();
    /// let registry = RegistryClient::crates_io("ingredients").unwrap();
    ///
    /// let _krate = rt
    ///     .block_on(registry.query_versions("ingredients", None, false, false))
    ///     .unwrap();
    /// ```
    ///
    /// # Errors
    ///
    /// * The network connection to crates.io fails.
    /// * The specified crate does not exist.
    ///
    /// # Panics
    ///
    /// * Unexpected API response from crates.io.
    pub async fn query_versions(
        &self,
        name: &str,
        range: Option<&VersionReq>,
        include_prereleases: bool,
        include_yanked: bool,
    ) -> Result<Vec<Version>, Error> {
        let url = format!("{}/crates/{name}/versions", self.base_url);

        debug!("Querying crates.io API for available versions: {name}");
        let response = self.client.get(url).send().await?;

        if response.status() == StatusCode::NOT_FOUND {
            // 404 NOT FOUND is returned if the name is unknown
            return Err(Error::CrateNotFound {
                name: String::from(name),
            });
        }

        let contents = response.text().await?;
        // if this fails, something is seriously wrong - just panic
        #[expect(clippy::expect_used)]
        let json: CratesIoVersions =
            serde_json::from_str(&contents).expect("Failed to deserialize JSON from crates.io API response.");

        Ok(json
            .versions
            .into_iter()
            .filter_map(|v| {
                if (v.yanked && !include_yanked) || (!v.version.pre.is_empty() && !include_prereleases) {
                    None
                } else if let Some(req) = range {
                    if req.matches(&v.version) { Some(v.version) } else { None }
                } else {
                    Some(v.version)
                }
            })
            .collect())
    }

    pub(crate) async fn download_tarball(&self, name: &str, version: &str) -> Result<NamedTempFile, Error> {
        let url = format!("{}/crates/{name}/{version}/download", self.base_url);

        let versions = self.query_versions(name, None, true, true).await?;

        if !versions.iter().any(|v| v.to_string() == version) {
            return Err(Error::VersionNotFound {
                name: String::from(name),
                version: version.to_string(),
            });
        }

        let mut temp = NamedTempFile::new()?;

        debug!("Downloading crate from crates.io: {name}@{version}");
        let mut response = self.client.get(url).send().await?;
        response.error_for_status_ref()?;

        // streaming download to avoid having the entire file contents in memory
        trace!(
            "Writing crate download to temporary file: {}",
            temp.path().to_string_lossy()
        );
        while let Some(bytes) = response.chunk().await? {
            temp.write_all(&bytes)?;
        }

        Ok(temp)
    }
}
