wme-cli 0.1.3

CLI tool for the Wikimedia Enterprise API
use anyhow::{Context, Result};
use tracing::info;

use crate::client::build_client_with_credentials;
use crate::commands::GlobalOpts;
use crate::config::Config;
use crate::util::{prompt_password, prompt_username};

/// Login and store tokens
pub async fn login(
    username: Option<String>,
    password: Option<String>,
    opts: &GlobalOpts,
) -> Result<()> {
    let username = match username {
        Some(u) => u.to_lowercase(),
        None => prompt_username("Username: ")
            .context("Failed to read username")?
            .to_lowercase(),
    };

    let password = match password {
        Some(p) => p,
        None => prompt_password("Password: ").context("Failed to read password")?,
    };

    if password.is_empty() {
        anyhow::bail!("Password cannot be empty");
    }

    info!("Authenticating as: {}", username);
    println!("Logging in as: {}", username);

    // Create client with credentials
    let client = build_client_with_credentials(&username, &password).await?;

    // Trigger authentication - this will perform login and store tokens
    let token_manager = client
        .token_manager()
        .context("Client not configured for authentication")?;

    token_manager
        .authenticate()
        .await
        .map_err(|e| anyhow::anyhow!("Login failed: {}", e))?;

    // Retrieve the tokens from the token manager
    let tokens = token_manager
        .get_tokens()
        .context("Failed to retrieve tokens after login")?;

    // Calculate expiration time from the token's expires_at
    use std::time::Instant;
    let expires_in = tokens.expires_at.duration_since(Instant::now()).as_secs();
    let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);

    // Store tokens in config
    let mut config = opts.config.clone();
    config.set_tokens(
        &username,
        &tokens.access_token,
        &tokens.refresh_token,
        expires_in,
    );
    config.save(None)?;

    println!("✓ Login successful!");
    println!("  Token stored at: {}", Config::default_path()?.display());
    println!("  Token expires at: {}", expires_at.to_rfc3339());

    Ok(())
}

/// Refresh access token
pub async fn refresh(
    username: Option<String>,
    refresh_token: Option<String>,
    opts: &GlobalOpts,
) -> Result<()> {
    let refresh_token = refresh_token
        .or_else(|| opts.config.refresh_token.clone())
        .context("No refresh token available. Please login first.")?;

    let username = username
        .or_else(|| opts.config.username.clone())
        .context("No username available. Please login first.")?
        .to_lowercase();

    info!("Refreshing token for: {}", username);
    println!("Refreshing token...");

    // Build a client with the stored credentials and tokens
    // The password is not needed for token refresh, but we need to provide something
    // to create the TokenManager. We use "unused" since we're providing tokens directly.
    let client = build_client_with_credentials(&username, "unused").await?;

    let token_manager = client
        .token_manager()
        .context("Client not configured for authentication")?;

    // Set the existing tokens so TokenManager can use the refresh token
    use std::time::Instant;
    use wme_client::Tokens;
    let tokens = Tokens {
        id_token: String::new(),
        access_token: opts.config.access_token.clone().unwrap_or_default(),
        refresh_token,
        expires_at: Instant::now(), // Expire immediately to force refresh
    };
    token_manager.set_tokens(tokens);

    // Use TokenManager's refresh method
    token_manager
        .refresh()
        .await
        .map_err(|e| anyhow::anyhow!("Token refresh failed: {}", e))?;

    // Retrieve the new tokens
    let new_tokens = token_manager
        .get_tokens()
        .context("Failed to retrieve tokens after refresh")?;

    // Calculate expiration time based on API response
    let expires_in = new_tokens
        .expires_at
        .duration_since(Instant::now())
        .as_secs();

    // Update stored tokens
    let mut config = opts.config.clone();
    config.set_tokens(
        &username,
        &new_tokens.access_token,
        &new_tokens.refresh_token,
        expires_in,
    );
    config.save(None)?;

    println!("✓ Token refreshed successfully!");
    println!("  Token expires in: {} seconds", expires_in);

    Ok(())
}

/// Revoke the refresh token
pub async fn revoke(refresh_token: Option<String>, opts: &GlobalOpts) -> Result<()> {
    let refresh_token = refresh_token.or_else(|| opts.config.refresh_token.clone());

    if let Some(token) = refresh_token {
        info!("Revoking token");
        println!("Revoking token...");

        // Use the token revoke endpoint directly
        let transport = std::sync::Arc::new(
            wme_client::ReqwestTransport::new().context("Failed to create HTTP transport")?,
        );

        let url = "https://auth.enterprise.wikimedia.com/v1/token-revoke";
        let body = serde_json::json!({
            "refresh_token": token,
        });

        use wme_client::HttpTransport;
        let response = transport
            .request(reqwest::Method::POST, url, None, Some(body.to_string()))
            .await;

        match response {
            Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
                println!("✓ Token revoked successfully");
            }
            Ok(resp) => {
                println!("⚠ Token revoke returned status: {}", resp.status());
            }
            Err(e) => {
                println!("⚠ Failed to contact revoke endpoint: {}", e);
            }
        }
    }

    // Always remove local token file regardless of server response.
    // This is intentional UX: we want the user to be logged out locally
    // even if the server revoke call fails (e.g., network issues, already
    // expired token). The alternative would leave the user in a confusing
    // state where they appear logged in but their token doesn't work.
    let mut config = opts.config.clone();
    config.clear_tokens();
    config.save(None)?;

    println!("✓ Local tokens cleared");

    Ok(())
}

/// Trigger forgot password email
pub async fn forgot_password(username: &str, _opts: &GlobalOpts) -> Result<()> {
    println!("Sending forgot password request for: {}", username);

    // Use the forgot password endpoint directly
    let transport = std::sync::Arc::new(
        wme_client::ReqwestTransport::new().context("Failed to create HTTP transport")?,
    );

    let url = "https://auth.enterprise.wikimedia.com/v1/forgot-password";
    let body = serde_json::json!({
        "username": username,
    });

    use wme_client::HttpTransport;
    let response = transport
        .request(reqwest::Method::POST, url, None, Some(body.to_string()))
        .await
        .map_err(|e| anyhow::anyhow!("Forgot password request failed: {}", e))?;

    if response.status().is_success() {
        println!("✓ Forgot password email sent. Check your inbox for the confirmation code.");
    } else {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!("Forgot password request failed ({}): {}", status, text);
    }

    Ok(())
}

/// Confirm forgot password with code
pub async fn forgot_password_confirm(
    username: &str,
    password: &str,
    code: &str,
    _opts: &GlobalOpts,
) -> Result<()> {
    println!("Confirming forgot password for: {}", username);

    // Use the forgot password confirm endpoint directly
    let transport = std::sync::Arc::new(
        wme_client::ReqwestTransport::new().context("Failed to create HTTP transport")?,
    );

    let url = "https://auth.enterprise.wikimedia.com/v1/forgot-password-confirm";
    let body = serde_json::json!({
        "username": username,
        "password": password,
        "code": code,
    });

    use wme_client::HttpTransport;
    let response = transport
        .request(reqwest::Method::POST, url, None, Some(body.to_string()))
        .await
        .map_err(|e| anyhow::anyhow!("Forgot password confirm request failed: {}", e))?;

    if response.status().is_success() {
        println!("✓ Password reset successful! You can now login with your new password.");
    } else {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!("Password reset failed ({}): {}", status, text);
    }

    Ok(())
}

/// Change password (requires authentication)
pub async fn change_password(
    previous_password: &str,
    proposed_password: &str,
    opts: &GlobalOpts,
) -> Result<()> {
    let token = opts
        .get_token()
        .context("Not authenticated. Please run 'wme auth login' first.")?;

    println!("Changing password...");

    // Use the change password endpoint directly
    let transport = std::sync::Arc::new(
        wme_client::ReqwestTransport::new().context("Failed to create HTTP transport")?,
    );

    let url = "https://auth.enterprise.wikimedia.com/v1/change-password";
    let body = serde_json::json!({
        "previous_password": previous_password,
        "proposed_password": proposed_password,
    });

    let mut headers = std::collections::HashMap::new();
    headers.insert("Authorization".to_string(), format!("Bearer {}", token));

    use wme_client::HttpTransport;
    let response = transport
        .request(
            reqwest::Method::POST,
            url,
            Some(headers),
            Some(body.to_string()),
        )
        .await
        .map_err(|e| anyhow::anyhow!("Change password request failed: {}", e))?;

    if response.status().is_success() {
        println!("✓ Password changed successfully!");
    } else {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!("Password change failed ({}): {}", status, text);
    }

    Ok(())
}

/// Set new password for NEW_PASSWORD_REQUIRED challenge
pub async fn set_new_password(
    username: &str,
    session: &str,
    new_password: &str,
    _opts: &GlobalOpts,
) -> Result<()> {
    println!("Setting new password for: {}", username);

    // Use the set new password endpoint directly
    let transport = std::sync::Arc::new(
        wme_client::ReqwestTransport::new().context("Failed to create HTTP transport")?,
    );

    let url = "https://auth.enterprise.wikimedia.com/v1/set-new-password";
    let body = serde_json::json!({
        "username": username,
        "session": session,
        "new_password": new_password,
    });

    use wme_client::HttpTransport;
    let response = transport
        .request(reqwest::Method::POST, url, None, Some(body.to_string()))
        .await
        .map_err(|e| anyhow::anyhow!("Set new password request failed: {}", e))?;

    if response.status().is_success() {
        println!("✓ New password set successfully! You can now login.");
    } else {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!("Set new password failed ({}): {}", status, text);
    }

    Ok(())
}