//! Credential provider that implements PASETO asymmetric tokens stored in Cargo's config.

use anyhow::Context as _;
use cargo_credential::{
    Action, CacheControl, Credential, CredentialResponse, Error, Operation, RegistryInfo, Secret,
};
use clap::Command;
use pasetors::{
    keys::{AsymmetricKeyPair, AsymmetricPublicKey, AsymmetricSecretKey, Generate},
    paserk::FormatAsPaserk,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use url::Url;

use crate::{
    GlobalContext,
    core::SourceId,
    ops::RegistryCredentialConfig,
    util::{auth::registry_credential_config_raw, command_prelude::opt, context},
};

/// The main body of an asymmetric token as describe in RFC 3231.
#[derive(serde::Serialize)]
struct Message<'a> {
    iat: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    sub: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    mutation: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    name: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    vers: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    cksum: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    challenge: Option<&'a str>,
    /// This field is not yet used. This field can be set to a value >1 to indicate a breaking change in the token format.
    #[serde(skip_serializing_if = "Option::is_none")]
    v: Option<u8>,
}
/// The footer of an asymmetric token as describe in RFC 3231.
#[derive(serde::Serialize)]
struct Footer<'a> {
    url: &'a str,
    kip: pasetors::paserk::Id,
}

pub(crate) struct PasetoCredential<'a> {
    gctx: &'a GlobalContext,
}

impl<'a> PasetoCredential<'a> {
    pub fn new(gctx: &'a GlobalContext) -> Self {
        Self { gctx }
    }
}

impl<'a> Credential for PasetoCredential<'a> {
    fn perform(
        &self,
        registry: &RegistryInfo<'_>,
        action: &Action<'_>,
        args: &[&str],
    ) -> Result<CredentialResponse, Error> {
        let index_url = Url::parse(registry.index_url).context("parsing index url")?;
        let sid = if let Some(name) = registry.name {
            SourceId::for_alt_registry(&index_url, name)
        } else {
            SourceId::for_registry(&index_url)
        }?;

        let reg_cfg = registry_credential_config_raw(self.gctx, &sid)?;

        let matches = Command::new("cargo:paseto")
            .no_binary_name(true)
            .arg(opt("key-subject", "Set the key subject for this registry").value_name("SUBJECT"))
            .try_get_matches_from(args)
            .map_err(Box::new)?;
        let key_subject = matches.get_one("key-subject").map(String::as_str);

        match action {
            Action::Get(operation) => {
                let Some(reg_cfg) = reg_cfg else {
                    return Err(Error::NotFound);
                };
                let Some(secret_key) = reg_cfg.secret_key.as_ref() else {
                    return Err(Error::NotFound);
                };

                let secret_key_subject = reg_cfg.secret_key_subject;
                let secret: Secret<AsymmetricSecretKey<pasetors::version3::V3>> = secret_key
                    .val
                    .as_ref()
                    .map(|key| key.as_str().try_into())
                    .transpose()
                    .context("failed to load private key")?;
                let public: AsymmetricPublicKey<pasetors::version3::V3> = secret
                    .as_ref()
                    .map(|key| key.try_into())
                    .transpose()
                    .context("failed to load public key from private key")?
                    .expose();
                let kip: pasetors::paserk::Id = (&public).into();

                let iat = OffsetDateTime::now_utc();

                let message = Message {
                    iat: &iat.format(&Rfc3339).unwrap(),
                    sub: secret_key_subject.as_deref(),
                    mutation: match operation {
                        Operation::Publish { .. } => Some("publish"),
                        Operation::Yank { .. } => Some("yank"),
                        Operation::Unyank { .. } => Some("unyank"),
                        Operation::Owners { .. } => Some("owners"),
                        _ => None,
                    },
                    name: match operation {
                        Operation::Publish { name, .. }
                        | Operation::Yank { name, .. }
                        | Operation::Unyank { name, .. }
                        | Operation::Owners { name, .. } => Some(name),
                        _ => None,
                    },
                    vers: match operation {
                        Operation::Publish { vers, .. }
                        | Operation::Yank { vers, .. }
                        | Operation::Unyank { vers, .. } => Some(vers),
                        _ => None,
                    },
                    cksum: match operation {
                        Operation::Publish { cksum, .. } => Some(cksum),
                        _ => None,
                    },
                    challenge: None, // todo: PASETO with challenges
                    v: None,
                };
                let footer = Footer {
                    url: &registry.index_url,
                    kip,
                };

                // Only read operations can be cached with asymmetric tokens.
                let cache = match operation {
                    Operation::Read => CacheControl::Session,
                    _ => CacheControl::Never,
                };

                let token = secret
                    .map(|secret| {
                        pasetors::version3::PublicToken::sign(
                            &secret,
                            serde_json::to_string(&message)
                                .expect("cannot serialize")
                                .as_bytes(),
                            Some(
                                serde_json::to_string(&footer)
                                    .expect("cannot serialize")
                                    .as_bytes(),
                            ),
                            None,
                        )
                    })
                    .transpose()
                    .context("failed to sign request")?;

                Ok(CredentialResponse::Get {
                    token,
                    cache,
                    operation_independent: false,
                })
            }
            Action::Login(options) => {
                let old_key_subject = reg_cfg.and_then(|cfg| cfg.secret_key_subject);
                let new_token;
                let secret_key: Secret<String>;
                if let Some(key) = &options.token {
                    secret_key = key.clone().map(str::to_string);
                } else {
                    let kp = AsymmetricKeyPair::<pasetors::version3::V3>::generate().unwrap();
                    secret_key = Secret::default().map(|mut key| {
                        FormatAsPaserk::fmt(&kp.secret, &mut key).unwrap();
                        key
                    });
                }

                if let Some(p) = paserk_public_from_paserk_secret(secret_key.as_deref()) {
                    eprintln!("{}", &p);
                } else {
                    return Err("not a validly formatted PASERK secret key".into());
                }
                new_token = RegistryCredentialConfig::AsymmetricKey((
                    secret_key,
                    match key_subject {
                        Some(key_subject) => Some(key_subject.to_string()),
                        None => old_key_subject,
                    },
                ));
                context::save_credentials(self.gctx, Some(new_token), &sid)?;
                Ok(CredentialResponse::Login)
            }
            Action::Logout => {
                if reg_cfg.and_then(|c| c.secret_key).is_some() {
                    context::save_credentials(self.gctx, None, &sid)?;
                    let reg_name = sid.display_registry_name();
                    let _ = self.gctx.shell().status(
                        "Logout",
                        format!("secret-key for `{reg_name}` has been removed from local storage"),
                    );
                    Ok(CredentialResponse::Logout)
                } else {
                    Err(Error::NotFound)
                }
            }
            _ => Err(Error::OperationNotSupported),
        }
    }
}

/// Checks that a secret key is valid, and returns the associated public key in Paserk format.
pub(crate) fn paserk_public_from_paserk_secret(secret_key: Secret<&str>) -> Option<String> {
    let secret: Secret<AsymmetricSecretKey<pasetors::version3::V3>> =
        secret_key.map(|key| key.try_into()).transpose().ok()?;
    let public: AsymmetricPublicKey<pasetors::version3::V3> = secret
        .as_ref()
        .map(|key| key.try_into())
        .transpose()
        .ok()?
        .expose();
    let mut paserk_pub_key = String::new();
    FormatAsPaserk::fmt(&public, &mut paserk_pub_key).unwrap();
    Some(paserk_pub_key)
}
