augent 0.6.4

Lean package manager for various AI coding platforms
use crate::error::Result;
use crate::platform::Platform;
use crate::resolver::DiscoveredBundle;
use console::Style;
use inquire::MultiSelect;
use std::collections::HashSet;

/// Strip ANSI escape codes from a string
fn strip_ansi_codes(s: &str) -> String {
    // Simple ANSI code removal - removes escape sequences like \x1b[0m, \x1b[2m, etc.
    let mut result = String::new();
    let mut chars = s.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch == '\x1b' {
            // Skip until 'm' (end of ANSI code)
            while let Some(&next) = chars.peek() {
                chars.next();
                if next == 'm' {
                    break;
                }
            }
        } else {
            result.push(ch);
        }
    }

    result.trim().to_string()
}

/// Scorer that matches only the bundle name (before " (" or " · "), so filtering
/// by typing does not match words in resource counts or descriptions.
fn score_by_name(input: &str, _opt: &String, string_value: &str, _idx: usize) -> Option<i64> {
    // Remove ANSI codes before extracting name
    let clean = strip_ansi_codes(string_value);
    let name = clean
        .split(" (")
        .next()
        .unwrap_or(&clean)
        .split(" · ")
        .next()
        .unwrap_or(&clean)
        .trim();
    if input.is_empty() {
        return Some(0);
    }
    if name.to_lowercase().contains(&input.to_lowercase()) {
        Some(0)
    } else {
        None
    }
}

/// Result of bundle selection - contains selected bundles and bundles that were deselected
pub struct BundleSelection {
    pub selected: Vec<DiscoveredBundle>,
    pub deselected: Vec<String>, // Names of bundles that were preselected but deselected
}

pub fn select_bundles_interactively(
    discovered: &[DiscoveredBundle],
    installed_bundle_names: Option<&HashSet<String>>,
) -> Result<BundleSelection> {
    if discovered.is_empty() {
        return Ok(BundleSelection {
            selected: vec![],
            deselected: vec![],
        });
    }

    // Sort bundles alphabetically by name for display only
    let mut sorted_bundles = discovered.to_vec();
    sorted_bundles.sort_by(|a, b| a.name.cmp(&b.name));

    // Create a map from bundle name to bundle for quick lookup while preserving original order
    let bundle_map: std::collections::HashMap<String, DiscoveredBundle> = discovered
        .iter()
        .map(|b| (b.name.clone(), b.clone()))
        .collect();

    // Track which bundles are installed
    let installed = installed_bundle_names.as_ref();

    // Style for installed bundles (dimmed/gray)
    let installed_style = Style::new().dim();

    // Build list of default selections (indices of installed bundles) first
    let default_selections: Vec<usize> = sorted_bundles
        .iter()
        .enumerate()
        .filter_map(|(idx, b)| {
            if installed.map(|set| set.contains(&b.name)).unwrap_or(false) {
                Some(idx)
            } else {
                None
            }
        })
        .collect();

    // Single-line items: "name (1 command)" or "name · desc..." or "name (installed)".
    // Multi-line content breaks inquire's list layout and causes the filter to match descriptions.
    let items: Vec<String> = sorted_bundles
        .iter()
        .map(|b| {
            let mut s = b.name.clone();
            // Mark installed bundles with styled text
            if installed.map(|set| set.contains(&b.name)).unwrap_or(false) {
                s.push(' ');
                s.push_str(&installed_style.apply_to("(installed)").to_string());
            } else if let Some(formatted) = b.resource_counts.format() {
                s.push_str(" (");
                s.push_str(&formatted);
                s.push(')');
            }
            if let Some(desc) = &b.description {
                let trunc: String = if desc.chars().count() > 40 {
                    desc.chars().take(37).chain("...".chars()).collect()
                } else {
                    desc.clone()
                };
                s.push_str(" · ");
                s.push_str(&trunc);
            }
            s
        })
        .collect();

    println!();

    let mut multiselect = MultiSelect::new("Select bundles to install", items)
        .with_page_size(10)
        .with_help_message(
            "  ↑↓ navigate  space select  enter confirm  type to filter  q/esc cancel",
        )
        .with_scorer(&score_by_name);

    // Preselect installed bundles if any exist
    if !default_selections.is_empty() {
        multiselect = multiselect.with_default(&default_selections);
    }

    let selection = match multiselect.prompt_skippable()? {
        Some(sel) => sel,
        None => {
            return Ok(BundleSelection {
                selected: vec![],
                deselected: vec![],
            });
        }
    };

    // Map display strings back to DiscoveredBundle preserving selection order
    // Note: We allow reinstalling already-installed bundles, they're just shown in different color
    // IMPORTANT: Use bundle_map to preserve original discovery order, not sorted_bundles which is alphabetical
    let selected_bundles: Vec<DiscoveredBundle> = selection
        .iter()
        .filter_map(|s| {
            // Extract bundle name from display string
            // The string might contain ANSI codes and "(installed)" marker
            // Remove ANSI escape sequences first
            let clean = strip_ansi_codes(s);

            // Extract name part (before first " (" or " · ")
            let name = clean
                .split(" (")
                .next()
                .unwrap_or(&clean)
                .split(" · ")
                .next()
                .unwrap_or(&clean)
                .trim();

            // Find matching bundle by name from the map (preserves original order)
            bundle_map.get(name).cloned()
        })
        .collect();

    // Find bundles that were preselected but deselected
    // Note: installed_bundle_names contains discovered bundle names that are installed,
    // not the full installed bundle names from lockfile
    let selected_names: HashSet<String> = selected_bundles.iter().map(|b| b.name.clone()).collect();
    let deselected: Vec<String> = if let Some(installed) = installed_bundle_names {
        installed
            .iter()
            .filter(|name| {
                // Check if this installed bundle was in the discovered list and is now deselected
                !selected_names.contains(*name)
            })
            .cloned()
            .collect()
    } else {
        Vec::new()
    };

    Ok(BundleSelection {
        selected: selected_bundles,
        deselected,
    })
}

pub fn select_platforms_interactively(available_platforms: &[Platform]) -> Result<Vec<Platform>> {
    if available_platforms.is_empty() {
        return Ok(vec![]);
    }

    // Sort platforms alphabetically by name
    let mut sorted_platforms = available_platforms.to_vec();
    sorted_platforms.sort_by(|a, b| a.name.cmp(&b.name));

    // Single-line items: "name (id)" format
    let items: Vec<String> = sorted_platforms
        .iter()
        .map(|p| format!("{} ({})", p.name, p.id))
        .collect();

    println!();

    let selection = match MultiSelect::new("Select platforms to install for", items)
        .with_page_size(10)
        .with_help_message(
            "  ↑↓ navigate  space select  enter confirm  type to filter  q/esc cancel",
        )
        .prompt_skippable()?
    {
        Some(sel) => sel,
        None => return Ok(vec![]),
    };

    // Map display strings back to Platform
    let selected_platforms: Vec<Platform> = selection
        .iter()
        .filter_map(|s| {
            // Extract platform ID from "name (id)" format
            if let Some(start) = s.rfind(" (") {
                if let Some(end) = s.rfind(')') {
                    let id = &s[start + 2..end];
                    sorted_platforms.iter().find(|p| p.id == id).cloned()
                } else {
                    None
                }
            } else {
                None
            }
        })
        .collect();

    Ok(selected_platforms)
}