cnctd_cli 0.3.0

CLI for scaffolding and managing projects
use std::env;
use std::env::current_dir;
use std::path::PathBuf;

use anyhow::{anyhow, Result};
use cnctd_appstore::AppStoreConnectClient;
use cnctd_xcode::{
    archive, export, upload, ArchiveOptions, ExportArchiveOptions, ExportOptionsPlistBuilder,
    XcodeProject,
};
use colored::Colorize;

/// Discover the Xcode project from the current directory
fn discover_project() -> Result<XcodeProject> {
    let cwd = current_dir()?;
    let base_path = cwd.to_string_lossy();
    XcodeProject::discover(&base_path)
}

/// Get the team ID from env var
fn get_team_id() -> Result<String> {
    env::var("APPLE_TEAM_ID").map_err(|_| anyhow!("APPLE_TEAM_ID env var not set"))
}

/// Get the ASC app ID from arg or env var
fn get_app_id(app_id_arg: Option<&str>) -> Result<String> {
    match app_id_arg {
        Some(id) => Ok(id.to_string()),
        None => env::var("ASC_APP_ID").map_err(|_| {
            anyhow!("App ID not provided. Use --app-id or set ASC_APP_ID env var")
        }),
    }
}

/// Archive the app with the given configuration
pub async fn archive_app(configuration: &str) -> Result<()> {
    let project = discover_project()?;
    println!(
        "{} {} ({})",
        "Archiving".blue(),
        project.scheme,
        configuration
    );

    let opts = ArchiveOptions::app_store(project).configuration(configuration);
    let archive_path = archive(&opts).await?;
    println!(
        "{} Archive at {}",
        "Done!".green(),
        archive_path.display()
    );
    Ok(())
}

/// Export IPA from the most recent archive
pub async fn export_app(method: &str) -> Result<()> {
    let project = discover_project()?;
    let team_id = get_team_id()?;

    // Look for archive in temp dir
    let archive_path = std::env::temp_dir().join(format!("{}.xcarchive", project.scheme));
    if !archive_path.exists() {
        return Err(anyhow!(
            "No archive found at {}. Run `cnctd ios archive` first.",
            archive_path.display()
        ));
    }

    println!("{} IPA (method: {})", "Exporting".blue(), method);

    let plist_builder = match method {
        "app-store" => ExportOptionsPlistBuilder::app_store(&team_id),
        "ad-hoc" => ExportOptionsPlistBuilder::ad_hoc(&team_id),
        "development" => ExportOptionsPlistBuilder::development(&team_id),
        _ => return Err(anyhow!("Invalid export method '{}'. Use: app-store, ad-hoc, development", method)),
    };
    let plist_path = plist_builder.build()?;

    let export_path = std::env::temp_dir().join(format!("{}-export", project.scheme));
    let opts = ExportArchiveOptions {
        archive_path,
        export_path,
        export_options_plist: plist_path,
    };

    let ipa_path = export(&opts).await?;
    println!("{} IPA at {}", "Done!".green(), ipa_path.display());
    Ok(())
}

/// Upload the most recently exported IPA
pub async fn upload_app() -> Result<()> {
    let project = discover_project()?;
    let key_id = env::var("ASC_KEY_ID").map_err(|_| anyhow!("ASC_KEY_ID env var not set"))?;
    let issuer_id =
        env::var("ASC_ISSUER_ID").map_err(|_| anyhow!("ASC_ISSUER_ID env var not set"))?;

    let export_dir = std::env::temp_dir().join(format!("{}-export", project.scheme));
    if !export_dir.exists() {
        return Err(anyhow!(
            "No export directory found at {}. Run `cnctd ios export` first.",
            export_dir.display()
        ));
    }

    // Find the IPA
    let ipa_path = find_ipa_in_dir(&export_dir)?;
    println!("{} {}", "Uploading".blue(), ipa_path.display());

    upload(&ipa_path, &key_id, &issuer_id).await?;
    println!("{} Uploaded to App Store Connect", "Done!".green());
    Ok(())
}

/// Full release pipeline: bump build -> archive -> export -> upload
pub async fn release(version_bump: Option<&str>) -> Result<()> {
    let project = discover_project()?;
    let team_id = get_team_id()?;
    let key_id = env::var("ASC_KEY_ID").map_err(|_| anyhow!("ASC_KEY_ID env var not set"))?;
    let issuer_id =
        env::var("ASC_ISSUER_ID").map_err(|_| anyhow!("ASC_ISSUER_ID env var not set"))?;

    let scheme_name = project.scheme.clone();

    // Step 1: Bump version if requested
    if let Some(part) = version_bump {
        let old = project.get_version()?;
        let new = project.bump_version(part)?;
        println!(
            "{} {} version: {} -> {}",
            "1/5".dimmed(),
            "Bumped".green(),
            old.yellow(),
            new.green()
        );
    }

    // Step 2: Bump build number (always)
    let old_build = project.get_build_number()?;
    let new_build = project.bump_build_number()?;
    let step = if version_bump.is_some() { "2/5" } else { "1/4" };
    println!(
        "{} {} build: {} -> {}",
        step.dimmed(),
        "Bumped".green(),
        old_build.yellow(),
        new_build.green()
    );

    // Step 3: Archive
    let total = if version_bump.is_some() { 5 } else { 4 };
    let archive_step = if version_bump.is_some() { 3 } else { 2 };
    println!(
        "{} {} {}",
        format!("{}/{}", archive_step, total).dimmed(),
        "Archiving".blue(),
        scheme_name
    );

    let archive_opts = ArchiveOptions::app_store(project);
    let archive_path = archive(&archive_opts).await?;

    // Step 4: Export
    let export_step = archive_step + 1;
    println!(
        "{} {} IPA",
        format!("{}/{}", export_step, total).dimmed(),
        "Exporting".blue()
    );

    let plist_path = ExportOptionsPlistBuilder::app_store(&team_id).build()?;
    let export_path = std::env::temp_dir().join(format!("{}-export", scheme_name));
    let export_opts = ExportArchiveOptions {
        archive_path,
        export_path: export_path.clone(),
        export_options_plist: plist_path,
    };
    let ipa_path = export(&export_opts).await?;

    // Step 5: Upload
    let upload_step = export_step + 1;
    println!(
        "{} {} to App Store Connect",
        format!("{}/{}", upload_step, total).dimmed(),
        "Uploading".blue()
    );

    upload(&ipa_path, &key_id, &issuer_id).await?;

    println!("\n{} Release complete!", "Done!".green().bold());
    println!("  Build {} uploaded to App Store Connect", new_build.green());
    println!(
        "  Check processing status: {}",
        "cnctd ios status".yellow()
    );

    Ok(())
}

/// Check build processing status on App Store Connect
pub async fn check_status(app_id: Option<&str>) -> Result<()> {
    let app_id = get_app_id(app_id)?;
    let client = AppStoreConnectClient::new()?;

    println!("{} builds for app {}", "Fetching".blue(), app_id);

    let builds = client.list_builds(&app_id, Some(5)).await?;

    if builds.is_empty() {
        println!("No builds found");
        return Ok(());
    }

    for build in &builds {
        let version = build
            .attributes
            .version
            .as_deref()
            .unwrap_or("unknown");
        let state = build
            .attributes
            .processing_state
            .as_deref()
            .unwrap_or("unknown");
        let uploaded = build
            .attributes
            .uploaded_date
            .as_deref()
            .unwrap_or("unknown");

        let state_colored = match state {
            "VALID" => state.green().to_string(),
            "PROCESSING" => state.yellow().to_string(),
            "FAILED" | "INVALID" => state.red().to_string(),
            _ => state.dimmed().to_string(),
        };

        println!(
            "  Build {} - {} (uploaded {})",
            version.blue(),
            state_colored,
            uploaded.dimmed()
        );
    }

    Ok(())
}

/// Find an IPA file in a directory
fn find_ipa_in_dir(dir: &PathBuf) -> Result<PathBuf> {
    for entry in std::fs::read_dir(dir)?.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("ipa") {
            return Ok(path);
        }
    }
    Err(anyhow!("No .ipa file found in {}", dir.display()))
}