atlassian-cli 0.4.3

Unified CLI for Atlassian Cloud products
use anyhow::{Context, Result};
use atlassian_cli_api::ApiClient;
use serde::{Deserialize, Serialize};
use url::form_urlencoded;

use super::utils::BitbucketContext;
use crate::commands::common::{render_success, MutationResult};

#[derive(Deserialize)]
struct WorkspaceList {
    values: Vec<Workspace>,
}

#[derive(Deserialize)]
struct Workspace {
    slug: String,
    name: String,
    #[serde(default)]
    uuid: Option<String>,
    #[serde(rename = "type", default)]
    workspace_type: Option<String>,
}

#[derive(Deserialize)]
struct ProjectList {
    values: Vec<Project>,
}

#[derive(Deserialize)]
struct Project {
    key: String,
    name: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    is_private: bool,
    #[serde(default)]
    uuid: Option<String>,
}

pub async fn list_workspaces(ctx: &BitbucketContext<'_>, limit: usize) -> Result<()> {
    let query = form_urlencoded::Serializer::new(String::new())
        .append_pair("pagelen", &limit.min(100).to_string())
        .finish();
    let path = format!("/2.0/workspaces?{query}");

    let response: WorkspaceList = ctx
        .client
        .get(&path)
        .await
        .context("Failed to list workspaces")?;

    #[derive(Serialize)]
    struct Row<'a> {
        slug: &'a str,
        name: &'a str,
        workspace_type: &'a str,
    }

    let rows: Vec<Row<'_>> = response
        .values
        .iter()
        .map(|ws| Row {
            slug: ws.slug.as_str(),
            name: ws.name.as_str(),
            workspace_type: ws.workspace_type.as_deref().unwrap_or(""),
        })
        .collect();

    if rows.is_empty() {
        tracing::info!("No workspaces found");
        println!("No workspaces found");
        return Ok(());
    }

    ctx.renderer.render(&rows)
}

pub async fn get_workspace(ctx: &BitbucketContext<'_>, workspace: &str) -> Result<()> {
    let path = format!("/2.0/workspaces/{workspace}");
    let ws: Workspace = ctx
        .client
        .get(&path)
        .await
        .with_context(|| format!("Failed to fetch workspace {workspace}"))?;

    #[derive(Serialize)]
    struct View<'a> {
        slug: &'a str,
        name: &'a str,
        uuid: &'a str,
        workspace_type: &'a str,
    }

    let view = View {
        slug: ws.slug.as_str(),
        name: ws.name.as_str(),
        uuid: ws.uuid.as_deref().unwrap_or(""),
        workspace_type: ws.workspace_type.as_deref().unwrap_or(""),
    };

    ctx.renderer.render(&view)
}

pub async fn list_projects(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    limit: usize,
) -> Result<()> {
    let query = form_urlencoded::Serializer::new(String::new())
        .append_pair("pagelen", &limit.min(100).to_string())
        .finish();
    let path = format!("/2.0/workspaces/{workspace}/projects?{query}");

    let response: ProjectList = ctx
        .client
        .get(&path)
        .await
        .with_context(|| format!("Failed to list projects in workspace {workspace}"))?;

    #[derive(Serialize)]
    struct Row<'a> {
        key: &'a str,
        name: &'a str,
        description: &'a str,
        visibility: &'a str,
    }

    let rows: Vec<Row<'_>> = response
        .values
        .iter()
        .map(|proj| Row {
            key: proj.key.as_str(),
            name: proj.name.as_str(),
            description: proj.description.as_deref().unwrap_or(""),
            visibility: if proj.is_private { "private" } else { "public" },
        })
        .collect();

    if rows.is_empty() {
        tracing::info!(workspace, "No projects found");
        println!("No projects found");
        return Ok(());
    }

    ctx.renderer.render(&rows)
}

pub async fn get_project(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    project_key: &str,
) -> Result<()> {
    let path = format!("/2.0/workspaces/{workspace}/projects/{project_key}");
    let project: Project = ctx.client.get(&path).await.with_context(|| {
        format!("Failed to fetch project {project_key} in workspace {workspace}")
    })?;

    #[derive(Serialize)]
    struct View<'a> {
        key: &'a str,
        name: &'a str,
        description: &'a str,
        visibility: &'a str,
        uuid: &'a str,
    }

    let view = View {
        key: project.key.as_str(),
        name: project.name.as_str(),
        description: project.description.as_deref().unwrap_or(""),
        visibility: if project.is_private {
            "private"
        } else {
            "public"
        },
        uuid: project.uuid.as_deref().unwrap_or(""),
    };

    ctx.renderer.render(&view)
}

pub async fn create_project(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    key: &str,
    name: &str,
    description: Option<&str>,
    is_private: bool,
) -> Result<()> {
    let mut payload = serde_json::json!({
        "key": key,
        "name": name,
        "is_private": is_private
    });

    if let Some(desc) = description {
        payload["description"] = serde_json::json!(desc);
    }

    let path = format!("/2.0/workspaces/{workspace}/projects");
    let project: Project = ctx
        .client
        .post(&path, &payload)
        .await
        .with_context(|| format!("Failed to create project in workspace {workspace}"))?;

    tracing::info!(
        project_key = project.key.as_str(),
        workspace,
        "Project created successfully"
    );

    #[derive(Serialize)]
    struct Created<'a> {
        key: &'a str,
        name: &'a str,
        visibility: &'a str,
    }

    let created = Created {
        key: project.key.as_str(),
        name: project.name.as_str(),
        visibility: if project.is_private {
            "private"
        } else {
            "public"
        },
    };

    ctx.renderer.render(&created)
}

pub async fn update_project(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    project_key: &str,
    name: Option<&str>,
    description: Option<&str>,
) -> Result<()> {
    let mut payload = serde_json::json!({});

    if let Some(n) = name {
        payload["name"] = serde_json::json!(n);
    }

    if let Some(d) = description {
        payload["description"] = serde_json::json!(d);
    }

    let path = format!("/2.0/workspaces/{workspace}/projects/{project_key}");
    let project: Project = ctx.client.put(&path, &payload).await.with_context(|| {
        format!("Failed to update project {project_key} in workspace {workspace}")
    })?;

    tracing::info!(
        project_key = project.key.as_str(),
        workspace,
        "Project updated successfully"
    );

    #[derive(Serialize)]
    struct Updated<'a> {
        key: &'a str,
        name: &'a str,
        description: &'a str,
    }

    let updated = Updated {
        key: project.key.as_str(),
        name: project.name.as_str(),
        description: project.description.as_deref().unwrap_or(""),
    };

    ctx.renderer.render(&updated)
}

pub async fn delete_project(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    project_key: &str,
    force: bool,
) -> Result<()> {
    if !force {
        use std::io::{self, Write};
        print!("Are you sure you want to delete project {project_key} from {workspace}? [y/N]: ");
        io::stdout().flush()?;
        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        if !input.trim().eq_ignore_ascii_case("y") {
            tracing::info!("Project deletion cancelled");
            return Ok(());
        }
    }

    let path = format!("/2.0/workspaces/{workspace}/projects/{project_key}");
    let _: serde_json::Value = ctx.client.delete(&path).await.with_context(|| {
        format!("Failed to delete project {project_key} from workspace {workspace}")
    })?;

    tracing::info!(project_key, workspace, "Project deleted successfully");
    render_success(
        ctx.renderer,
        &format!("✅ Project {project_key} deleted from workspace {workspace}"),
        &MutationResult::with_id(
            format!("Project {project_key} deleted from workspace {workspace}"),
            project_key,
        ),
    )
}

#[derive(Deserialize)]
struct BitbucketUser {
    username: String,
    display_name: String,
    account_id: String,
    uuid: String,
}

pub async fn whoami(client: &ApiClient, is_bearer: bool) -> Result<()> {
    if is_bearer {
        // Bearer tokens (access tokens) can't use /2.0/user — show accessible workspaces
        let data: serde_json::Value = client
            .get("/2.0/workspaces")
            .await
            .context("Failed to fetch workspaces from Bitbucket API (Bearer auth)")?;

        println!("Auth type: Bearer (access token)");
        println!("/2.0/user is not available for access tokens.");
        if let Some(workspaces) = data["values"].as_array() {
            println!("Accessible workspaces:");
            for ws in workspaces {
                println!(
                    "  {} ({})",
                    ws["slug"].as_str().unwrap_or("?"),
                    ws["name"].as_str().unwrap_or("?")
                );
            }
        }
        Ok(())
    } else {
        let user: BitbucketUser = client
            .get("/2.0/user")
            .await
            .context("Failed to fetch current user from Bitbucket API")?;

        println!("Username: {}", user.username);
        println!("Display Name: {}", user.display_name);
        println!("Account ID: {}", user.account_id);
        println!("UUID: {}", user.uuid);

        Ok(())
    }
}