//! Registry authentication support.

use crate::{
    core::features::cargo_docs_link,
    util::{CanonicalUrl, CargoResult, GlobalContext, IntoUrl, context::ConfigKey},
};
use anyhow::{Context as _, bail};
use cargo_credential::{
    Action, CacheControl, Credential, CredentialResponse, LoginOptions, Operation, RegistryInfo,
    Secret,
};

use core::fmt;
use serde::Deserialize;
use std::error::Error;
use time::{Duration, OffsetDateTime};
use url::Url;

use crate::core::SourceId;
use crate::util::context::Value;
use crate::util::credential::adaptor::BasicProcessCredential;
use crate::util::credential::paseto::PasetoCredential;

use super::{
    context::{CredentialCacheValue, OptValue, PathAndArgs},
    credential::process::CredentialProcessCredential,
    credential::token::TokenCredential,
};

/// `[registries.NAME]` tables.
///
/// The values here should be kept in sync with `RegistryConfigExtended`
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct RegistryConfig {
    pub index: Option<String>,
    pub token: OptValue<Secret<String>>,
    pub credential_provider: Option<PathAndArgs>,
    pub secret_key: OptValue<Secret<String>>,
    pub secret_key_subject: Option<String>,
    /// Minimum publish age threshold for RFC 3923
    pub min_publish_age: Option<String>,
    #[serde(rename = "protocol")]
    _protocol: Option<String>,
}

/// The `[registry]` table, which has more keys than the `[registries.NAME]` tables.
///
/// Note: nesting `RegistryConfig` inside this struct and using `serde(flatten)` *should* work
/// but fails with "invalid type: sequence, expected a value" when attempting to deserialize.
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct RegistryConfigExtended {
    pub index: Option<String>,
    pub token: OptValue<Secret<String>>,
    pub credential_provider: Option<PathAndArgs>,
    pub secret_key: OptValue<Secret<String>>,
    pub secret_key_subject: Option<String>,
    /// Minimum publish age threshold for RFC 3923
    pub min_publish_age: Option<String>,
    /// Global default Minimum publish age threshold for RFC 3923
    pub global_min_publish_age: Option<String>,
    #[serde(rename = "default")]
    _default: Option<String>,
    #[serde(rename = "global-credential-providers")]
    _global_credential_providers: Option<Vec<String>>,
}

impl RegistryConfigExtended {
    pub fn to_registry_config(self) -> RegistryConfig {
        RegistryConfig {
            index: self.index,
            token: self.token,
            credential_provider: self.credential_provider,
            secret_key: self.secret_key,
            secret_key_subject: self.secret_key_subject,
            min_publish_age: self.min_publish_age,
            _protocol: None,
        }
    }
}

/// Get the list of credential providers for a registry source.
fn credential_provider(
    gctx: &GlobalContext,
    sid: &SourceId,
    require_cred_provider_config: bool,
    show_warnings: bool,
) -> CargoResult<Vec<Vec<String>>> {
    let warn = |message: String| {
        if show_warnings {
            gctx.shell().warn(message)
        } else {
            Ok(())
        }
    };

    let cfg = registry_credential_config_raw(gctx, sid)?;
    let mut global_provider_defined = true;
    let default_providers = || {
        global_provider_defined = false;
        if gctx.cli_unstable().asymmetric_token {
            // Enable the PASETO provider
            vec![
                vec!["cargo:token".to_string()],
                vec!["cargo:paseto".to_string()],
            ]
        } else {
            vec![vec!["cargo:token".to_string()]]
        }
    };
    let global_providers = gctx
        .get::<Option<Vec<Value<String>>>>("registry.global-credential-providers")?
        .filter(|p| !p.is_empty())
        .map(|p| {
            p.iter()
                .rev()
                .map(PathAndArgs::from_whitespace_separated_string)
                .map(|p| resolve_credential_alias(gctx, p))
                .collect()
        })
        .unwrap_or_else(default_providers);
    tracing::debug!(?global_providers);

    match cfg {
        // If there's a specific provider configured for this registry, use it.
        Some(RegistryConfig {
            credential_provider: Some(provider),
            token,
            secret_key,
            ..
        }) => {
            let provider = resolve_credential_alias(gctx, provider);
            if let Some(token) = token {
                if provider[0] != "cargo:token" {
                    warn(format!(
                        "{sid} has a token configured in {} that will be ignored \
                        because this registry is configured to use credential-provider `{}`",
                        token.definition, provider[0],
                    ))?;
                }
            }
            if let Some(secret_key) = secret_key {
                if provider[0] != "cargo:paseto" {
                    warn(format!(
                        "{sid} has a secret-key configured in {} that will be ignored \
                        because this registry is configured to use credential-provider `{}`",
                        secret_key.definition, provider[0],
                    ))?;
                }
            }
            return Ok(vec![provider]);
        }

        // Warning for both `token` and `secret-key`, stating which will be ignored
        Some(RegistryConfig {
            token: Some(token),
            secret_key: Some(secret_key),
            ..
        }) if gctx.cli_unstable().asymmetric_token => {
            let token_pos = global_providers
                .iter()
                .position(|p| p.first().map(String::as_str) == Some("cargo:token"));
            let paseto_pos = global_providers
                .iter()
                .position(|p| p.first().map(String::as_str) == Some("cargo:paseto"));
            match (token_pos, paseto_pos) {
                (Some(token_pos), Some(paseto_pos)) => {
                    if token_pos < paseto_pos {
                        warn(format!(
                            "{sid} has a `secret_key` configured in {} that will be ignored \
                        because a `token` is also configured, and the `cargo:token` provider is \
                        configured with higher precedence",
                            secret_key.definition
                        ))?;
                    } else {
                        warn(format!(
                            "{sid} has a `token` configured in {} that will be ignored \
                        because a `secret_key` is also configured, and the `cargo:paseto` provider is \
                        configured with higher precedence",
                            token.definition
                        ))?;
                    }
                }
                (_, _) => {
                    // One or both of the below individual warnings will trigger
                }
            }
        }

        // Check if a `token` is configured that will be ignored.
        Some(RegistryConfig {
            token: Some(token), ..
        }) => {
            if !global_providers
                .iter()
                .any(|p| p.first().map(String::as_str) == Some("cargo:token"))
            {
                warn(format!(
                    "{sid} has a token configured in {} that will be ignored \
                    because the `cargo:token` credential provider is not listed in \
                    `registry.global-credential-providers`",
                    token.definition
                ))?;
            }
        }

        // Check if a asymmetric token is configured that will be ignored.
        Some(RegistryConfig {
            secret_key: Some(token),
            ..
        }) if gctx.cli_unstable().asymmetric_token => {
            if !global_providers
                .iter()
                .any(|p| p.first().map(String::as_str) == Some("cargo:paseto"))
            {
                warn(format!(
                    "{sid} has a secret-key configured in {} that will be ignored \
                    because the `cargo:paseto` credential provider is not listed in \
                    `registry.global-credential-providers`",
                    token.definition
                ))?;
            }
        }

        // If we couldn't find a registry-specific provider, use the fallback provider list.
        None | Some(RegistryConfig { .. }) => {}
    };
    if !global_provider_defined && require_cred_provider_config {
        bail!(
            "authenticated registries require a credential-provider to be configured\n\
        see {} for details",
            cargo_docs_link("reference/registry-authentication.html")
        );
    }
    Ok(global_providers)
}

/// Get the credential configuration for a `SourceId`.
pub fn registry_credential_config_raw(
    gctx: &GlobalContext,
    sid: &SourceId,
) -> CargoResult<Option<RegistryConfig>> {
    let mut cache = gctx.registry_config();
    if let Some(cfg) = cache.get(&sid) {
        return Ok(cfg.clone());
    }
    let cfg = registry_credential_config_raw_uncached(gctx, sid)?;
    cache.insert(*sid, cfg.clone());
    return Ok(cfg);
}

fn registry_credential_config_raw_uncached(
    gctx: &GlobalContext,
    sid: &SourceId,
) -> CargoResult<Option<RegistryConfig>> {
    tracing::trace!("loading credential config for {}", sid);
    gctx.load_credentials()?;
    if !sid.is_remote_registry() {
        bail!(
            "{} does not support API commands.\n\
             Check for a source-replacement in .cargo/config.",
            sid
        );
    }

    // Handle crates.io specially, since it uses different configuration keys.
    if sid.is_crates_io() {
        gctx.check_registry_index_not_set()?;
        return Ok(gctx
            .get::<Option<RegistryConfigExtended>>("registry")?
            .map(|c| c.to_registry_config()));
    }

    // Find the SourceId's name by its index URL. If environment variables
    // are available they will be preferred over configuration values.
    //
    // The fundamental problem is that we only know the index url of the registry
    // for certain. For example, an unnamed registry source can come from the `--index`
    // command line argument, or from a Cargo.lock file. For this reason, we always
    // attempt to discover the name by looking it up by the index URL.
    //
    // This also allows the authorization token for a registry to be set
    // without knowing the registry name by using the _INDEX and _TOKEN
    // environment variables.

    let name = {
        // Discover names from environment variables.
        let index = sid.canonical_url();
        let mut names: Vec<_> = gctx
            .env()
            .filter_map(|(k, v)| {
                Some((
                    k.strip_prefix("CARGO_REGISTRIES_")?
                        .strip_suffix("_INDEX")?,
                    v,
                ))
            })
            .filter_map(|(k, v)| Some((k, CanonicalUrl::new(&v.into_url().ok()?).ok()?)))
            .filter(|(_, v)| v == index)
            .map(|(k, _)| k.to_lowercase())
            .collect();

        // Discover names from the configuration only if none were found in the environment.
        if names.len() == 0 {
            if let Some(registries) = gctx.values()?.get("registries") {
                let (registries, _) = registries.table("registries")?;
                for (name, value) in registries {
                    if let Some(v) = value.table(&format!("registries.{name}"))?.0.get("index") {
                        let (v, _) = v.string(&format!("registries.{name}.index"))?;
                        if index == &CanonicalUrl::new(&v.into_url()?)? {
                            names.push(name.clone());
                        }
                    }
                }
            }
        }
        names.sort();
        match names.len() {
            0 => None,
            1 => Some(std::mem::take(&mut names[0])),
            _ => anyhow::bail!(
                "multiple registries are configured with the same index url '{}': {}",
                &sid.as_url(),
                names.join(", ")
            ),
        }
    };

    // It's possible to have a registry configured in a Cargo config file,
    // then override it with configuration from environment variables.
    // If the name doesn't match, leave a note to help the user understand
    // the potentially confusing situation.
    if let Some(name) = name.as_deref() {
        if Some(name) != sid.alt_registry_key() {
            gctx.shell().note(format!(
                "name of alternative registry `{}` set to `{name}`",
                sid.url()
            ))?
        }
    }

    if let Some(name) = &name {
        tracing::debug!("found alternative registry name `{name}` for {sid}");
        gctx.get::<Option<RegistryConfig>>(&format!("registries.{name}"))
    } else {
        tracing::debug!("no registry name found for {sid}");
        Ok(None)
    }
}

/// Use the `[credential-alias]` table to see if the provider name has been aliased.
fn resolve_credential_alias(gctx: &GlobalContext, mut provider: PathAndArgs) -> Vec<String> {
    if provider.args.is_empty() {
        let name = provider.path.raw_value();
        let key = format!("credential-alias.{name}");
        if let Ok(alias) = gctx.get::<Value<PathAndArgs>>(&key) {
            tracing::debug!("resolving credential alias '{key}' -> '{alias:?}'");
            if BUILT_IN_PROVIDERS.contains(&name) {
                let _ = gctx.shell().warn(format!(
                    "credential-alias `{name}` (defined in `{}`) will be \
                    ignored because it would shadow a built-in credential-provider",
                    alias.definition
                ));
            } else {
                provider = alias.val;
            }
        }
    }
    provider.args.insert(
        0,
        provider
            .path
            .resolve_program(gctx)
            .to_str()
            .unwrap()
            .to_string(),
    );
    provider.args
}

#[derive(Debug, PartialEq)]
pub enum AuthorizationErrorReason {
    TokenMissing,
    TokenRejected,
}

impl fmt::Display for AuthorizationErrorReason {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AuthorizationErrorReason::TokenMissing => write!(f, "no token found"),
            AuthorizationErrorReason::TokenRejected => write!(f, "token rejected"),
        }
    }
}

/// An authorization error from accessing a registry.
#[derive(Debug)]
pub struct AuthorizationError {
    /// Url that was attempted
    sid: SourceId,
    /// The `registry.default` config value.
    default_registry: Option<String>,
    /// Url where the user could log in.
    pub login_url: Option<Url>,
    /// Specific reason indicating what failed
    reason: AuthorizationErrorReason,
    /// Should `cargo login` and the `_TOKEN` env var be included when displaying this error?
    supports_cargo_token_credential_provider: bool,
    /// Whether the cached token appears to lack an authentication scheme (no space found).
    token_lacks_scheme: Option<bool>,
}

impl AuthorizationError {
    pub fn new(
        gctx: &GlobalContext,
        sid: SourceId,
        login_url: Option<Url>,
        reason: AuthorizationErrorReason,
    ) -> CargoResult<Self> {
        // Only display the _TOKEN environment variable suggestion if the `cargo:token` credential
        // provider is available for the source. Otherwise setting the environment variable will
        // have no effect.
        let supports_cargo_token_credential_provider =
            credential_provider(gctx, &sid, false, false)?
                .iter()
                .any(|p| p.first().map(String::as_str) == Some("cargo:token"));
        let cache = gctx.credential_cache();
        let token_lacks_scheme = cache
            .get(sid.canonical_url())
            .map(|entry| !entry.token_value.as_deref().expose().contains(' '));
        Ok(AuthorizationError {
            sid,
            default_registry: gctx.default_registry()?,
            login_url,
            reason,
            supports_cargo_token_credential_provider,
            token_lacks_scheme,
        })
    }
}

impl Error for AuthorizationError {}
impl fmt::Display for AuthorizationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.sid.is_crates_io() {
            let args = if self.default_registry.is_some() {
                " --registry crates-io"
            } else {
                ""
            };
            write!(f, "{}, please run `cargo login{args}`", self.reason)?;
            if self.supports_cargo_token_credential_provider {
                write!(f, "\nor use environment variable CARGO_REGISTRY_TOKEN")?;
            }
            Ok(())
        } else if let Some(name) = self.sid.alt_registry_key() {
            write!(
                f,
                "{} for `{}`",
                self.reason,
                self.sid.display_registry_name()
            )?;
            if self.supports_cargo_token_credential_provider {
                let key = ConfigKey::from_str(&format!("registries.{name}.token"));
                write!(
                    f,
                    ", please run `cargo login --registry {name}`\n\
                    or use environment variable {}",
                    key.as_env_key()
                )?;
            } else {
                write!(
                    f,
                    "\nYou may need to log in using this registry's credential provider"
                )?;
            }

            if self.reason == AuthorizationErrorReason::TokenRejected {
                if self.token_lacks_scheme == Some(true) {
                    write!(
                        f,
                        "\nnote: the token does not include an authentication scheme"
                    )?;
                }
            }
            Ok(())
        } else if self.reason == AuthorizationErrorReason::TokenMissing {
            write!(
                f,
                r#"{} for `{}`
consider setting up an alternate registry in Cargo's configuration
as described by https://doc.rust-lang.org/cargo/reference/registries.html

[registries]
my-registry = {{ index = "{}" }}
"#,
                self.reason,
                self.sid.display_registry_name(),
                self.sid.url()
            )
        } else {
            write!(
                f,
                r#"{} for `{}`"#,
                self.reason,
                self.sid.display_registry_name(),
            )
        }
    }
}

/// Store a token in the cache for future calls.
pub fn cache_token_from_commandline(gctx: &GlobalContext, sid: &SourceId, token: Secret<&str>) {
    let url = sid.canonical_url();
    gctx.credential_cache().insert(
        url.clone(),
        CredentialCacheValue {
            token_value: token.to_owned(),
            expiration: None,
            operation_independent: true,
        },
    );
}

/// List of credential providers built-in to Cargo.
/// Keep in sync with the `match` in `credential_action`.
static BUILT_IN_PROVIDERS: &[&'static str] = &[
    "cargo:token",
    "cargo:paseto",
    "cargo:token-from-stdout",
    "cargo:wincred",
    "cargo:macos-keychain",
    "cargo:libsecret",
];

/// Retrieves a cached instance of `LibSecretCredential`.
/// Must be cached to avoid repeated load/unload cycles, which are not supported by `glib`.
#[cfg(target_os = "linux")]
fn get_credential_libsecret()
-> CargoResult<&'static cargo_credential_libsecret::LibSecretCredential> {
    static CARGO_CREDENTIAL_LIBSECRET: std::sync::OnceLock<
        cargo_credential_libsecret::LibSecretCredential,
    > = std::sync::OnceLock::new();
    // Unfortunately `get_or_try_init` is not yet stable. This workaround is not threadsafe but
    // loading libsecret twice will only temporary increment the ref counter, which is decrement
    // again when `drop` is called.
    match CARGO_CREDENTIAL_LIBSECRET.get() {
        Some(lib) => Ok(lib),
        None => {
            let _ = CARGO_CREDENTIAL_LIBSECRET
                .set(cargo_credential_libsecret::LibSecretCredential::new()?);
            Ok(CARGO_CREDENTIAL_LIBSECRET.get().unwrap())
        }
    }
}

fn credential_action(
    gctx: &GlobalContext,
    sid: &SourceId,
    action: Action<'_>,
    headers: Vec<String>,
    args: &[&str],
    require_cred_provider_config: bool,
) -> CargoResult<CredentialResponse> {
    let name = sid.alt_registry_key();
    let registry = RegistryInfo {
        index_url: sid.url().as_str(),
        name,
        headers,
    };
    let providers = credential_provider(gctx, sid, require_cred_provider_config, true)?;
    let mut any_not_found = false;
    for provider in providers {
        let args: Vec<&str> = provider
            .iter()
            .map(String::as_str)
            .chain(args.iter().copied())
            .collect();
        let process = args[0];
        tracing::debug!("attempting credential provider: {args:?}");
        // If the available built-in providers are changed, update the `BUILT_IN_PROVIDERS` list.
        let provider: Box<dyn Credential> = match process {
            "cargo:token" => Box::new(TokenCredential::new(gctx)),
            "cargo:paseto" if gctx.cli_unstable().asymmetric_token => {
                Box::new(PasetoCredential::new(gctx))
            }
            "cargo:paseto" => bail!("cargo:paseto requires -Zasymmetric-token"),
            "cargo:token-from-stdout" => Box::new(BasicProcessCredential {}),
            #[cfg(windows)]
            "cargo:wincred" => Box::new(cargo_credential_wincred::WindowsCredential {}),
            #[cfg(target_os = "macos")]
            "cargo:macos-keychain" => Box::new(cargo_credential_macos_keychain::MacKeychain {}),
            #[cfg(target_os = "linux")]
            "cargo:libsecret" => Box::new(get_credential_libsecret()?),
            name if BUILT_IN_PROVIDERS.contains(&name) => {
                Box::new(cargo_credential::UnsupportedCredential {})
            }
            process => Box::new(CredentialProcessCredential::new(process)),
        };
        gctx.shell().verbose(|c| {
            c.status(
                "Credential",
                format!(
                    "{} {action} {}",
                    args.join(" "),
                    sid.display_registry_name()
                ),
            )
        })?;
        match provider.perform(&registry, &action, &args[1..]) {
            Ok(response) => return Ok(response),
            Err(cargo_credential::Error::UrlNotSupported) => {}
            Err(cargo_credential::Error::NotFound) => any_not_found = true,
            e => {
                return e.with_context(|| {
                    format!(
                        "credential provider `{}` failed action `{action}`",
                        args.join(" ")
                    )
                });
            }
        }
    }
    if any_not_found {
        Err(cargo_credential::Error::NotFound.into())
    } else {
        anyhow::bail!("no credential providers could handle the request")
    }
}

/// Returns the token to use for the given registry.
/// If a `login_url` is provided and a token is not available, the
/// `login_url` will be included in the returned error.
pub fn auth_token(
    gctx: &GlobalContext,
    sid: &SourceId,
    login_url: Option<&Url>,
    operation: Operation<'_>,
    headers: Vec<String>,
    require_cred_provider_config: bool,
) -> CargoResult<String> {
    match auth_token_optional(gctx, sid, operation, headers, require_cred_provider_config)? {
        Some(token) => Ok(token.expose()),
        None => Err(AuthorizationError::new(
            gctx,
            *sid,
            login_url.cloned(),
            AuthorizationErrorReason::TokenMissing,
        )?
        .into()),
    }
}

/// Returns the token to use for the given registry.
fn auth_token_optional(
    gctx: &GlobalContext,
    sid: &SourceId,
    operation: Operation<'_>,
    headers: Vec<String>,
    require_cred_provider_config: bool,
) -> CargoResult<Option<Secret<String>>> {
    tracing::trace!("token requested for {}", sid.display_registry_name());
    let mut cache = gctx.credential_cache();
    let url = sid.canonical_url();
    if let Some(cached_token) = cache.get(url) {
        if cached_token
            .expiration
            .map(|exp| OffsetDateTime::now_utc() + Duration::minutes(1) < exp)
            .unwrap_or(true)
        {
            if cached_token.operation_independent || matches!(operation, Operation::Read) {
                tracing::trace!("using token from in-memory cache");
                return Ok(Some(cached_token.token_value.clone()));
            }
        } else {
            // Remove expired token from the cache
            cache.remove(url);
        }
    }

    let credential_response = credential_action(
        gctx,
        sid,
        Action::Get(operation),
        headers,
        &[],
        require_cred_provider_config,
    );
    if let Some(e) = credential_response.as_ref().err() {
        if let Some(e) = e.downcast_ref::<cargo_credential::Error>() {
            if matches!(e, cargo_credential::Error::NotFound) {
                return Ok(None);
            }
        }
    }
    let credential_response = credential_response?;

    let CredentialResponse::Get {
        token,
        cache: cache_control,
        operation_independent,
    } = credential_response
    else {
        bail!(
            "credential provider produced unexpected response for `get` request: {credential_response:?}"
        )
    };
    let token = Secret::from(token);
    tracing::trace!("found token");
    let expiration = match cache_control {
        CacheControl::Expires { expiration } => Some(expiration),
        CacheControl::Session => None,
        CacheControl::Never | _ => return Ok(Some(token)),
    };

    cache.insert(
        url.clone(),
        CredentialCacheValue {
            token_value: token.clone(),
            expiration,
            operation_independent,
        },
    );
    Ok(Some(token))
}

/// Log out from the given registry.
pub fn logout(gctx: &GlobalContext, sid: &SourceId) -> CargoResult<()> {
    let credential_response = credential_action(gctx, sid, Action::Logout, vec![], &[], false);
    if let Some(e) = credential_response.as_ref().err() {
        if let Some(e) = e.downcast_ref::<cargo_credential::Error>() {
            if matches!(e, cargo_credential::Error::NotFound) {
                gctx.shell().status(
                    "Logout",
                    format!(
                        "not currently logged in to `{}`",
                        sid.display_registry_name()
                    ),
                )?;
                return Ok(());
            }
        }
    }
    let credential_response = credential_response?;
    let CredentialResponse::Logout = credential_response else {
        bail!(
            "credential provider produced unexpected response for `logout` request: {credential_response:?}"
        )
    };
    Ok(())
}

/// Log in to the given registry.
pub fn login(
    gctx: &GlobalContext,
    sid: &SourceId,
    options: LoginOptions<'_>,
    args: &[&str],
) -> CargoResult<()> {
    let credential_response =
        credential_action(gctx, sid, Action::Login(options), vec![], args, false)?;
    let CredentialResponse::Login = credential_response else {
        bail!(
            "credential provider produced unexpected response for `login` request: {credential_response:?}"
        )
    };
    Ok(())
}
