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

use crate::{cli, http::HttpClient};
use ecow::EcoString;
use gleam_core::{
    Error, Result, encryption,
    error::{FileIoAction, FileKind},
    io::HttpClient as _,
    paths,
};
use hexpm::OAuthTokens;
use sha2::Digest as _;

pub const HEX_OAUTH_CLIENT_ID: &str = "877731e8-cb88-45e1-9b84-9214de7da421";

pub const LOCAL_PASS_PROMPT: &str = "Local password";
pub const API_ENV_NAME: &str = "HEXPM_API_KEY";
pub const READONLY_API_ENV_NAME: &str = "HEXPM_READ_API_KEY";

#[derive(Debug)]
pub struct EncryptedLegacyApiKey {
    pub name: String,
}

pub struct HexAuthentication<'runtime> {
    runtime: &'runtime tokio::runtime::Runtime,
    http: &'runtime HttpClient,
    local_password: Option<EcoString>,
    hex_config: hexpm::Config,
}

impl<'runtime> HexAuthentication<'runtime> {
    /// Reads the stored API key from disc, if it exists.
    ///
    pub fn new(
        runtime: &'runtime tokio::runtime::Runtime,
        http: &'runtime HttpClient,
        hex_config: hexpm::Config,
    ) -> Self {
        Self {
            runtime,
            http,
            local_password: None,
            hex_config,
        }
    }

    /// Create new OAuth tokens by sending the user through the OAuth flow.
    ///
    /// Any already-existing tokens stored on the file system are revoked.
    ///
    pub fn create_and_store_new_credentials_via_oauth(&mut self) -> Result<hexpm::Credentials> {
        let previous_oauth_token = self.read_stored_encrypted_oauth_refresh_token()?;
        let previous_legacy_key = self.read_stored_legacy_api_key()?;

        // Create a device authorisation with Hex, starting the oauth flow.
        let mut device_authorisation = self.create_oauth_device_authorisation()?;

        // Show the user their code, and send them to Hex to log in.
        send_user_to_oauth_verification_url(&device_authorisation);

        // The user has been sent to the Hex website to authenticate.
        // Poll the Hex API until they accept or reject the request, or it times out.
        let tokens = loop {
            let next = self.poll_for_oauth_next_step(&mut device_authorisation)?;
            match next {
                hexpm::PollStep::Done(tokens) => break tokens,
                hexpm::PollStep::SleepThenPollAgain(duration) => std::thread::sleep(duration),
            }
        };

        // We are creating a new API key, so we need a new local password
        // to encrypt it with.
        self.ask_for_new_local_password()?;
        self.encrypt_and_store_oauth_refresh_token(&tokens)?;

        let credentials = tokens.as_credentials();

        // Dispose of old tokens, if there was any.
        self.revoke_old_tokens(&credentials, previous_oauth_token, previous_legacy_key)?;
        self.delete_legacy_api_key_from_filesystem()?;

        Ok(credentials)
    }

    fn poll_for_oauth_next_step(
        &mut self,
        device_authorisation: &mut hexpm::OAuthDeviceAuthorisation,
    ) -> Result<hexpm::PollStep, Error> {
        let request = device_authorisation.poll_token_request(&self.hex_config);
        let response = self.runtime.block_on(self.http.send(request))?;
        let next = device_authorisation
            .poll_token_response(response)
            .map_err(Error::hex)?;
        Ok(next)
    }

    fn create_oauth_device_authorisation(
        &mut self,
    ) -> Result<hexpm::OAuthDeviceAuthorisation, Error> {
        // Create a recognisable name for the client, so folks can more easily understand which
        // session is which in the Hex console.
        // It is expected that we can always get the hostname.
        let hostname = hostname::get().expect("hostname");
        let client_name = format!("Gleam ({})", hostname.to_string_lossy());

        let request = hexpm::oauth_device_authorisation_request(
            HEX_OAUTH_CLIENT_ID,
            &client_name,
            &self.hex_config,
        );
        let response = self.runtime.block_on(self.http.send(request))?;
        hexpm::oauth_device_authorisation_response(HEX_OAUTH_CLIENT_ID.to_string(), response)
            .map_err(Error::hex)
    }

    fn encrypt_and_store_oauth_refresh_token(&mut self, tokens: &OAuthTokens) -> Result<(), Error> {
        let path = paths::global_hexpm_oauth_credentials_path();
        let local_password = self.get_local_password()?;
        let encrypted_refresh_token =
            encryption::encrypt_with_passphrase(tokens.refresh_token.as_bytes(), &local_password)
                .map_err(|e| Error::FailedToEncryptLocalHexApiKey {
                detail: e.to_string(),
            })?;

        let credentials = StoredOAuthCredentials {
            hexpm: StoredOAuthRepoCredentials {
                api: self.hex_config.api_base.clone(),
                repository: self.hex_config.repository_base.clone(),
                refresh_token: encrypted_refresh_token,
                refresh_token_hash: {
                    let mut hasher = sha2::Sha256::new();
                    hasher.update(tokens.refresh_token.as_bytes());
                    base16::encode_lower(&hasher.finalize())
                },
            },
        };
        let toml = toml::to_string(&credentials).expect("OAuth credentials TOML encoding");
        crate::fs::write(&path, &toml)?;
        Ok(())
    }

    /// Create a new local password.
    ///
    /// The password must be long enough.
    ///
    /// The old password will be discarded, and the new one will be both
    /// returned and stored in `self.local_password`
    ///
    fn ask_for_new_local_password(&mut self) -> Result<()> {
        let required_length = 8;
        self.local_password = None;
        println!(
            "Please enter a new unique password, at least {required_length} characters long.
It will be used to locally encrypt your Hex API tokens.
"
        );

        loop {
            let password = cli::ask_password(LOCAL_PASS_PROMPT)?;
            if password.chars().count() < required_length {
                println!("\nPlease use a password at least {required_length} characters long.\n");
            } else {
                self.local_password = Some(password);
                return Ok(());
            }
        }
    }

    fn get_local_password(&mut self) -> Result<EcoString> {
        if let Some(password) = self.local_password.as_ref() {
            return Ok(password.clone());
        }

        let password = cli::ask_password(LOCAL_PASS_PROMPT)?;
        self.local_password = Some(password.clone());
        Ok(password)
    }

    /// Get a token that can be used to authenticate with the Hex API.
    /// In order, it will try these sources:
    ///
    /// 1. An API key from the HEXPM_API_KEY environment variable.
    /// 2. An OAuth refresh token from the file system, which is then exchanged for
    ///    an access token.
    /// 3. The OAuth flow.
    pub fn get_or_create_api_credentials(&mut self) -> Result<hexpm::Credentials> {
        if let Some(key) = read_env_api_key() {
            return Ok(key);
        }

        match self.read_and_decrypt_and_refresh_stored_tokens() {
            Ok(Some(tokens)) => return Ok(tokens.as_credentials()),
            Ok(None) => {}
            Err(Error::HexSessionRevoked) => {
                // This error occurs when the refresh token is no longer valid, typically
                // because the user manually revoked the session in the Hex console.
                // This is a recoverable error so instead of erroring out with a
                // non-actionable message, we catch this and proceed to the OAuth flow
                // to create a fresh set of credentials.
                // Since the old token is already invalid, there is no need to revoke it.
            }
            Err(e) => return Err(e),
        }

        self.create_and_store_new_credentials_via_oauth()
    }

    /// Read, decrypt, and refresh OAuth keys stored on the filesystem.
    ///
    /// The new refresh is encrypted and stored on the file system for next use.
    ///
    /// If there is no stored key then this return `Ok(None)`, and you'll
    /// need to create a new one.
    ///
    fn read_and_decrypt_and_refresh_stored_tokens(&mut self) -> Result<Option<OAuthTokens>> {
        let Some(encrypted_credentials) = self.read_stored_encrypted_oauth_refresh_token()? else {
            return Ok(None);
        };

        let local_password = self.get_local_password()?;
        let refresh_token = encryption::decrypt_with_passphrase(
            encrypted_credentials.refresh_token.as_bytes(),
            &local_password,
        )
        .map_err(|_| Error::FailedToDecryptLocalHexApiKey)?;

        // Use a refresh token, consuming it to get a new set of tokens.
        let request = hexpm::oauth_refresh_token_request(
            HEX_OAUTH_CLIENT_ID,
            &refresh_token,
            &self.hex_config,
        );
        let response = self.runtime.block_on(self.http.send(request))?;
        let tokens = hexpm::oauth_refresh_token_response(response).map_err(Error::hex)?;

        // Store the refresh token for future use.
        self.encrypt_and_store_oauth_refresh_token(&tokens)?;

        Ok(Some(tokens))
    }

    fn read_stored_encrypted_oauth_refresh_token(
        &self,
    ) -> Result<Option<StoredOAuthRepoCredentials>> {
        let path = paths::global_hexpm_oauth_credentials_path();
        if !path.exists() {
            return Ok(None);
        }
        let toml = crate::fs::read(&path)?;
        let credentials: StoredOAuthCredentials =
            toml::from_str(&toml).map_err(|e| Error::FileIo {
                action: FileIoAction::Parse,
                kind: FileKind::File,
                path,
                err: Some(e.to_string()),
            })?;
        Ok(Some(credentials.hexpm))
    }

    fn delete_legacy_api_key_from_filesystem(&self) -> Result<()> {
        let path = paths::global_hexpm_legacy_credentials_path();
        if !path.exists() {
            return Ok(());
        }
        crate::fs::delete_file(&path)
    }

    fn read_stored_legacy_api_key(&self) -> Result<Option<EncryptedLegacyApiKey>> {
        let path = paths::global_hexpm_legacy_credentials_path();
        if !path.exists() {
            return Ok(None);
        }
        let text = crate::fs::read(&path)?;
        let mut chunks = text.splitn(2, '\n');
        let Some(name) = chunks.next() else {
            return Ok(None);
        };
        // We no longer use this encrypted key, but we still parse it to ensure that
        // the file is the correct format. If it is not then we cannot safely use it.
        let Some(_encrypted) = chunks.next() else {
            return Ok(None);
        };
        Ok(Some(EncryptedLegacyApiKey {
            name: name.to_string(),
        }))
    }

    fn revoke_old_tokens(
        &self,
        credentials: &hexpm::Credentials,
        previous_oauth_token: Option<StoredOAuthRepoCredentials>,
        previous_legacy_key: Option<EncryptedLegacyApiKey>,
    ) -> Result<()> {
        if previous_oauth_token.is_none() && previous_legacy_key.is_none() {
            return Ok(());
        }

        println!("\nRevoking old authentication credentials");
        let credentials = crate::hex::write_credentials(credentials)?;

        if let Some(token) = previous_oauth_token {
            let hash = token.refresh_token_hash;
            let request =
                hexpm::revoke_oauth_token_by_hash_request(&hash, &credentials, &self.hex_config);
            let response = self.runtime.block_on(self.http.send(request))?;
            hexpm::revoke_oauth_token_by_hash_response(response).map_err(Error::hex)?;
        }

        if let Some(key) = previous_legacy_key {
            let request =
                hexpm::api_remove_api_key_request(&key.name, &credentials, &self.hex_config);
            let response = self.runtime.block_on(self.http.send(request))?;
            hexpm::api_remove_api_key_response(response).map_err(Error::hex)?;
        }

        Ok(())
    }
}

fn send_user_to_oauth_verification_url(device_authorisation: &hexpm::OAuthDeviceAuthorisation) {
    // We make them press Enter to open the browser instead of going there immediately,
    // to make sure they see the code before the browser window potentially hides the
    // terminal window.
    let uri = &device_authorisation.verification_uri;
    println!(
        "Your Hex verification code:

    {code}

Verify this code matches what is shown in your browser.

Press Enter to open {uri}",
        code = device_authorisation.user_code,
    );

    let _ = std::io::stdin()
        .read_line(&mut String::new())
        .expect("stdin read_line");
    if opener::open_browser(uri).is_err() {
        println!("\nFailed to open the browser, please navigate to {uri}");
    }
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct StoredOAuthRepoCredentials {
    #[serde(with = "http_serde::uri")]
    api: http::Uri,
    #[serde(with = "http_serde::uri")]
    repository: http::Uri,
    /// An encrypted refresh token.
    refresh_token: String,
    /// The hash of the token, so it can be revoked even if it cannot be encrypted.
    refresh_token_hash: String,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct StoredOAuthCredentials {
    hexpm: StoredOAuthRepoCredentials,
}

/// Read a Hex API key from the `HEXPM_API_KEY` environment variable, if one is set.
///
/// This authenticates write commands such as `gleam publish`.
fn read_env_api_key() -> Option<hexpm::Credentials> {
    api_key_credentials(&std::env::var(API_ENV_NAME).unwrap_or_default())
}

/// Read a Hex API key from the `HEXPM_READ_API_KEY` environment variable, if one
/// is set.
///
/// This is used to authenticate otherwise anonymous read requests (dependency
/// resolution and package downloads) so that they are subject to Hex's higher
/// per-user rate limits rather than the stricter per-IP limits.
pub fn read_env_readonly_api_key() -> Option<hexpm::Credentials> {
    api_key_credentials(&std::env::var(READONLY_API_ENV_NAME).unwrap_or_default())
}

fn api_key_credentials(api_key: &str) -> Option<hexpm::Credentials> {
    let api_key = api_key.trim();
    if api_key.is_empty() {
        None
    } else {
        Some(hexpm::Credentials::ApiKey(EcoString::from(api_key)))
    }
}

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

    #[test]
    fn api_key_credentials_trims_surrounding_whitespace() {
        assert_eq!(
            api_key_credentials("  secret\n"),
            Some(hexpm::Credentials::ApiKey("secret".into())),
        );
    }

    #[test]
    fn api_key_credentials_blank_is_none() {
        assert_eq!(api_key_credentials("  \n"), None);
    }
}
