use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const ASPKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const REGISTRY_URL: &str =
    "https://raw.githubusercontent.com/ayaan511/AyaanScript--Packages/main/packages/";
const REGISTRY_INDEX: &str =
    "https://raw.githubusercontent.com/ayaan511/AyaanScript--Packages/main/index.txt";
// GitHub API endpoints for the recent feature
const GITHUB_API_CONTENTS: &str =
    "https://api.github.com/repos/ayaan511/AyaanScript--Packages/contents/packages";
const GITHUB_API_COMMITS: &str =
    "https://api.github.com/repos/ayaan511/AyaanScript--Packages/commits";

// Platform-appropriate location for installed packages.
//   Windows: %APPDATA%\aspkg\packages           (e.g. C:\Users\Alice\AppData\Roaming\aspkg\packages)
//   macOS:   ~/Library/Application Support/aspkg/packages
//   Linux:   $XDG_CONFIG_HOME/aspkg/packages, or ~/.config/aspkg/packages
fn package_dir() -> PathBuf {
    #[cfg(windows)]
    {
        if let Ok(p) = env::var("APPDATA") {
            return PathBuf::from(p).join("aspkg").join("packages");
        }
    }
    let home = env::var("HOME")
        .or_else(|_| env::var("USERPROFILE"))
        .unwrap_or_else(|_| ".".to_string());
    #[cfg(target_os = "macos")]
    {
        return Path::new(&home)
            .join("Library").join("Application Support")
            .join("aspkg").join("packages");
    }
    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
        return PathBuf::from(xdg).join("aspkg").join("packages");
    }
    Path::new(&home).join(".config").join("aspkg").join("packages")
}

// Legacy location used before v1.55: ~/.config/<user>/aspkg/packages
fn legacy_package_dir() -> PathBuf {
    let home = env::var("HOME")
        .or_else(|_| env::var("USERPROFILE"))
        .unwrap_or_else(|_| ".".to_string());
    let user = env::var("USER")
        .or_else(|_| env::var("USERNAME"))
        .unwrap_or_else(|_| "user".to_string());
    Path::new(&home).join(".config").join(user).join("aspkg").join("packages")
}

fn ensure_dirs() {
    let dir = package_dir();
    fs::create_dir_all(&dir).expect("Failed to create aspkg directory");

    // One-time migration: if we just created an empty `dir` but the legacy
    // location has packages, move them over silently.
    let legacy = legacy_package_dir();
    if legacy != dir && legacy.exists() && legacy.is_dir() {
        // only migrate if new dir has no packages yet
        let empty = fs::read_dir(&dir)
            .map(|i| i.flatten().next().is_none())
            .unwrap_or(true);
        if empty {
            let mut migrated = 0;
            if let Ok(entries) = fs::read_dir(&legacy) {
                for entry in entries.flatten() {
                    let from = entry.path();
                    let to = dir.join(entry.file_name());
                    if fs::rename(&from, &to).is_ok() {
                        migrated += 1;
                    } else if from.is_file() {
                        // cross-FS rename can fail — fall back to copy
                        if fs::copy(&from, &to).is_ok() {
                            let _ = fs::remove_file(&from);
                            migrated += 1;
                        }
                    }
                }
            }
            if migrated > 0 {
                eprintln!("ASPkg: migrated {} package(s) from legacy {} -> {}",
                    migrated, legacy.display(), dir.display());
            }
        }
    }
}

fn package_path(name: &str) -> PathBuf {
    package_dir().join(format!("{}.aspkg", name))
}

fn package_dir_path(name: &str) -> PathBuf {
    package_dir().join(name)
}

fn package_config_path(name: &str) -> PathBuf {
    package_dir_path(name).join("package.aspkg")
}

fn extract_imports(content: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in content.lines() {
        let trimmed = line.trim();
        if let Some(rest) = trimmed.strip_prefix("importayaan ") {
            let raw = rest.trim().trim_matches('"').to_string();
            if !raw.is_empty() && !raw.contains('{') {
                out.push(raw);
            }
        }
    }
    out
}

fn fetch_recursive(name: &str, entry: &str, pkg_dir: &Path) -> usize {
    use std::collections::HashSet;
    let mut visited: HashSet<String> = HashSet::new();
    let mut queue: Vec<String> = vec![entry.to_string()];
    let mut count = 0;

    while let Some(file) = queue.pop() {
        if !visited.insert(file.clone()) {
            continue;
        }

        if !safe_pkg_filename(&file) {
            println!("ASPkg WARN: skipping unsafe filename '{}'", file);
            continue;
        }

        let url = format!("{}{}/{}", REGISTRY_URL, name, file);
        let bytes = match fetch_file(&url) {
            Some(b) => b,
            None => {
                println!("ASPkg WARN: failed to fetch '{}'", file);
                continue;
            }
        };

        let dest = pkg_dir.join(&file);
        if let Some(parent) = dest.parent() {
            let _ = fs::create_dir_all(parent);
        }
        if let Err(e) = fs::write(&dest, &bytes) {
            println!("ASPkg WARN: failed to write '{}': {}", file, e);
            continue;
        }
        count += 1;

        let content = String::from_utf8_lossy(&bytes);
        for imp in extract_imports(&content) {
            if !visited.contains(&imp) {
                queue.push(imp);
            }
        }
    }

    count
}

fn normalize_name(name: &str) -> String {
    name.trim().trim_end_matches(".aspkg").to_string()
}

fn parse_package_meta(content: &str) -> HashMap<String, String> {
    let mut meta = HashMap::new();
    let mut current_key: Option<String> = None;

    for line in content.lines() {
        let trimmed = line.trim();

        if trimmed.eq_ignore_ascii_case("---code---") {
            break;
        }
        if trimmed.starts_with("---file:") {
            break;
        }

        if let Some(rest) = trimmed.strip_prefix('@') {
            if let Some((key, value)) = rest.split_once(' ') {
                let k = key.trim().to_lowercase();
                meta.insert(k.clone(), value.trim().to_string());
                current_key = Some(k);
            } else {
                // bare @key with no value — still register
                let k = rest.trim().to_lowercase();
                meta.insert(k.clone(), String::new());
                current_key = Some(k);
            }
        } else if trimmed.is_empty() {
            // Blank line ends a multi-line block.
            current_key = None;
        } else {
            // Continuation line — append to the most recent @key, preserving
            // the user's indentation so help-text formatting survives.
            if let Some(ref k) = current_key {
                if let Some(existing) = meta.get_mut(k) {
                    if !existing.is_empty() {
                        existing.push('\n');
                    }
                    existing.push_str(line);
                }
            }
        }
    }

    meta
}

fn safe_pkg_filename(name: &str) -> bool {
    !name.is_empty()
        && !name.contains("..")
        && !name.starts_with('/')
        && !name.contains('\\')
        && !name.contains(':')
}

fn fetch_file(url: &str) -> Option<Vec<u8>> {
    // Use a client with no-cache headers so registry queries are always live.
    let client = reqwest::blocking::Client::builder()
        .user_agent(format!("aspkg/{}", ASPKG_VERSION))
        .build().ok()?;
    let resp = client.get(url)
        .header("Cache-Control", "no-cache")
        .header("Pragma", "no-cache")
        .send().ok()?;
    if !resp.status().is_success() {
        return None;
    }
    resp.bytes().ok().map(|b| b.to_vec())
}

fn install(name: &str) {
    let name = normalize_name(name);
    let single_path = package_path(&name);
    let dir_path = package_dir_path(&name);

    if single_path.exists() || dir_path.exists() {
        println!("ASPkg: '{}' already installed.", name);
        return;
    }

    println!("ASPkg: downloading '{}'", name);

    let cfg_url = format!("{}{}/package.aspkg", REGISTRY_URL, name);
    if let Some(cfg_bytes) = fetch_file(&cfg_url) {
        let cfg_text = String::from_utf8_lossy(&cfg_bytes).to_string();
        let meta = parse_package_meta(&cfg_text);

        let main_file = meta
            .get("main")
            .cloned()
            .unwrap_or_else(|| "main.ayaan".to_string());

        if !safe_pkg_filename(&main_file) {
            println!("ASPkg ERROR: unsafe @main filename '{}'", main_file);
            return;
        }

        if let Err(e) = fs::create_dir_all(&dir_path) {
            println!("ASPkg ERROR: failed to create package dir: {}", e);
            return;
        }

        if let Err(e) = fs::write(package_config_path(&name), &cfg_bytes) {
            println!("ASPkg ERROR: failed to write package.aspkg: {}", e);
            return;
        }

        let count = fetch_recursive(&name, &main_file, &dir_path);
        let total = count + 1;
        println!(
            "ASPkg: installed '{}' ({} file{})",
            name,
            total,
            if total == 1 { "" } else { "s" }
        );
        return;
    }

    let url = format!("{}{}.aspkg", REGISTRY_URL, name);
    let bytes = match fetch_file(&url) {
        Some(b) => b,
        None => {
            println!("ASPkg ERROR: package not found");
            return;
        }
    };

    if let Err(e) = fs::write(&single_path, bytes) {
        println!("ASPkg ERROR: failed to write package: {}", e);
        return;
    }

    println!("ASPkg: installed '{}'", name);
}

fn remove(name: &str) {
    let name = normalize_name(name);
    let single_path = package_path(&name);
    let dir_path = package_dir_path(&name);

    if dir_path.exists() && dir_path.is_dir() {
        match fs::remove_dir_all(&dir_path) {
            Ok(_) => println!("ASPkg: removed '{}'", name),
            Err(e) => println!("ASPkg ERROR: failed to remove '{}': {}", name, e),
        }
        return;
    }

    if single_path.exists() {
        match fs::remove_file(&single_path) {
            Ok(_) => println!("ASPkg: removed '{}'", name),
            Err(e) => println!("ASPkg ERROR: failed to remove '{}': {}", name, e),
        }
        return;
    }

    println!("ASPkg: '{}' not installed.", name);
}

fn compile(input: &str, output: Option<&str>) {
    use std::collections::HashSet;

    let dir = PathBuf::from(input);
    if !dir.exists() || !dir.is_dir() {
        println!("ASPkg ERROR: '{}' is not a directory", input);
        return;
    }

    let cfg_path = dir.join("package.aspkg");
    if !cfg_path.exists() {
        println!("ASPkg ERROR: missing package.aspkg in '{}'", input);
        return;
    }

    let cfg = match fs::read_to_string(&cfg_path) {
        Ok(c) => c,
        Err(e) => {
            println!("ASPkg ERROR: failed to read package.aspkg: {}", e);
            return;
        }
    };
    let meta = parse_package_meta(&cfg);

    let pkg_name = meta
        .get("name")
        .cloned()
        .unwrap_or_else(|| "package".to_string());
    let main_file = meta
        .get("main")
        .cloned()
        .unwrap_or_else(|| "main.ayaan".to_string());

    // REQUIRED: @help must be present and non-empty. Authors document
    // their commands so importers know what's available.
    match meta.get("help") {
        Some(h) if !h.trim().is_empty() => {}
        _ => {
            println!("ASPkg ERROR: package '{}' is missing the required @help directive.", pkg_name);
            println!("  Add to {}:", cfg_path.display());
            println!("    @help <one-line summary>");
            println!("      <continuation line 1>");
            println!("      <continuation line 2>");
            println!("      ...");
            println!("  Each command in your package should be documented.");
            return;
        }
    }

    if !safe_pkg_filename(&main_file) {
        println!("ASPkg ERROR: unsafe @main filename '{}'", main_file);
        return;
    }

    if !dir.join(&main_file).exists() {
        println!("ASPkg ERROR: main file '{}' not found", main_file);
        return;
    }

    let mut visited: HashSet<String> = HashSet::new();
    let mut order: Vec<String> = Vec::new();
    let mut queue: Vec<String> = vec![main_file.clone()];

    while let Some(file) = queue.pop() {
        if !visited.insert(file.clone()) {
            continue;
        }
        if !safe_pkg_filename(&file) {
            println!("ASPkg WARN: skipping unsafe '{}'", file);
            continue;
        }
        let file_path = dir.join(&file);
        if !file_path.exists() {
            println!("ASPkg WARN: '{}' not found, skipping", file);
            continue;
        }
        order.push(file.clone());
        let content = fs::read_to_string(&file_path).unwrap_or_default();
        for imp in extract_imports(&content) {
            if !visited.contains(&imp) {
                queue.push(imp);
            }
        }
    }

    if let Some(pos) = order.iter().position(|f| *f == main_file) {
        let m = order.remove(pos);
        order.insert(0, m);
    }

    let mut out = String::new();
    // Emit metadata fields in a predictable order first
    for key in &["name", "version", "author", "description"] {
        if let Some(v) = meta.get(*key) {
            out.push_str(&format!("@{} {}\n", key, v));
        }
    }
    // Plus any other custom @-keys the user added
    for (k, v) in &meta {
        if matches!(k.as_str(), "name" | "version" | "author" | "description" | "main") {
            continue;
        }
        out.push_str(&format!("@{} {}\n", k, v));
    }
    out.push_str(&format!("@main {}\n", main_file));

    for file in &order {
        let file_path = dir.join(file);
        let content = fs::read_to_string(&file_path).unwrap_or_default();
        out.push_str(&format!("---file: {}---\n", file));
        out.push_str(&content);
        if !content.ends_with('\n') {
            out.push('\n');
        }
    }

    let output_path = match output {
        Some(o) => PathBuf::from(o),
        None => PathBuf::from(format!("{}.aspkg", pkg_name)),
    };

    match fs::write(&output_path, &out) {
        Ok(_) => println!(
            "ASPkg: compiled '{}' -> {} ({} file{})",
            pkg_name,
            output_path.display(),
            order.len(),
            if order.len() == 1 { "" } else { "s" }
        ),
        Err(e) => println!("ASPkg ERROR: failed to write output: {}", e),
    }
}

// Escape a string for a JSON string literal.
fn json_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

// Extract a "key":"value" string field from a JSON blob. Same naive approach
// the rest of this file uses for the registry API responses.
fn json_get_str(json: &str, key: &str) -> Option<String> {
    let pat = format!("\"{}\":\"", key);
    let start = json.find(&pat)? + pat.len();
    let end_rel = json[start..].find('"')?;
    Some(json[start..start + end_rel].to_string())
}

// Where AS+ users POST their bundles. The server holds the GitHub token and
// commits on the user's behalf — no GitHub account needed on the client.
const PUBLISH_ENDPOINT: &str = "https://aspkg.ayaan.info/publish.php";

// Publish a package to the registry. POSTs the compiled bundle to the
// aspkg.ayaan.info backend, which validates it and commits to the registry
// repo using its own GitHub token. No client-side auth required.
fn publish_pkg(arg: &str) {
    // Accept either a directory or a path to a package.aspkg file.
    //   aspkg publish                  → current dir
    //   aspkg publish mypkg            → ./mypkg/ dir
    //   aspkg publish mypkg/package.aspkg → use mypkg/ as the source dir
    //   aspkg publish package.aspkg    → use ./ as the source dir
    let path = PathBuf::from(arg);
    let dir: PathBuf = if path.is_file() && path.file_name().and_then(|s| s.to_str()) == Some("package.aspkg") {
        let parent = path.parent().unwrap_or_else(|| std::path::Path::new(""));
        if parent.as_os_str().is_empty() { PathBuf::from(".") } else { parent.to_path_buf() }
    } else if path.is_dir() {
        path
    } else {
        println!("ASPkg ERROR: '{}' is not a package directory or package.aspkg file", arg);
        return;
    };
    let dir_str = dir.to_string_lossy().into_owned();
    let dir_arg: &str = &dir_str;
    let cfg_path = dir.join("package.aspkg");
    if !cfg_path.exists() {
        println!("ASPkg ERROR: missing package.aspkg in '{}'", dir_arg);
        return;
    }
    let cfg = match fs::read_to_string(&cfg_path) {
        Ok(c) => c,
        Err(e) => { println!("ASPkg ERROR: failed to read package.aspkg: {}", e); return; }
    };
    let meta = parse_package_meta(&cfg);
    let pkg_name = match meta.get("name") {
        Some(n) if !n.trim().is_empty() => n.clone(),
        _ => { println!("ASPkg ERROR: package.aspkg missing @name"); return; }
    };
    let version = meta.get("version").cloned().unwrap_or_else(|| "0.1.0".to_string());

    // Compile into the OS temp dir so the bundle never sits next to the
    // source files and gets mistaken for the manifest. compile() validates
    // required metadata (@help, etc) — if it fails the file won't exist.
    let bundle_path = std::env::temp_dir()
        .join(format!("aspkg_publish_{}_{}.aspkg", pkg_name, std::process::id()));
    let bundle_path_str = bundle_path.to_string_lossy().into_owned();
    compile(dir_arg, Some(&bundle_path_str));
    if !bundle_path.exists() {
        println!("ASPkg ERROR: compile produced no output — nothing to publish");
        return;
    }
    let bundle_bytes = match fs::read(&bundle_path) {
        Ok(b) => b,
        Err(e) => { println!("ASPkg ERROR: couldn't read compiled bundle: {}", e); return; }
    };
    // Drop the temp bundle now — we've got the bytes in memory.
    let _ = fs::remove_file(&bundle_path);

    // Author defaults to the package's @author, or system username, or unknown.
    let author = meta.get("author").cloned().unwrap_or_else(|| {
        std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "unknown".to_string())
    });

    publish_via_server(&pkg_name, &version, &author, &bundle_bytes);
}

// POST the bundle to aspkg.ayaan.info, which commits to the registry repo
// using its own GitHub token. Users don't need any credentials.
fn publish_via_server(pkg_name: &str, version: &str, author: &str, bundle: &[u8]) {
    use base64::Engine;

    // If the user's version already starts with v/V (e.g. "v2"), don't
    // prepend another one — would print "vv2".
    let v_prefix = if version.starts_with('v') || version.starts_with('V') { "" } else { "v" };
    print!("ASPkg: publishing {} {}{}... ", pkg_name, v_prefix, version);
    let _ = std::io::Write::flush(&mut std::io::stdout());

    let content_b64 = base64::engine::general_purpose::STANDARD.encode(bundle);
    let body = format!(
        "{{\"name\":{},\"version\":{},\"author\":{},\"content_b64\":{}}}",
        json_escape(pkg_name),
        json_escape(version),
        json_escape(author),
        json_escape(&content_b64),
    );

    let client = match reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()
    {
        Ok(c) => c,
        Err(e) => { println!("HTTP client init failed: {}", e); return; }
    };

    let resp = client
        .post(PUBLISH_ENDPOINT)
        .header("Content-Type", "application/json")
        .header("User-Agent", format!("aspkg/{}", ASPKG_VERSION))
        .body(body)
        .send();

    match resp {
        Ok(r) => {
            let status = r.status();
            let body = r.text().unwrap_or_default();
            if status.is_success() {
                println!("\x1b[32m✓\x1b[0m");
                println!("  Available via: \x1b[36maspkg install {}\x1b[0m", pkg_name);
            } else {
                println!("failed (HTTP {})", status.as_u16());
                // Try to surface the server's "error" field nicely.
                if let Some(err) = json_get_str(&body, "error") {
                    println!("  {}", err);
                } else {
                    println!("  {}", body.trim());
                }
                if status.as_u16() == 429 {
                    println!("  (server rate-limited you — try again in a minute)");
                }
            }
        }
        Err(e) => {
            println!("network error");
            println!("  {}", e);
            println!("  Is {} reachable?", PUBLISH_ENDPOINT);
        }
    }
}

fn reinstall(name: &str) {
    let name = normalize_name(name);
    let single_path = package_path(&name);
    let dir_path = package_dir_path(&name);

    if dir_path.exists() {
        let _ = fs::remove_dir_all(&dir_path);
    }
    if single_path.exists() {
        let _ = fs::remove_file(&single_path);
    }

    install(&name);
}

fn update(name: &str) {
    reinstall(name);
}

fn list() {
    let dir = package_dir();

    let entries = match fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) => {
            println!("ASPkg ERROR: failed to read package directory: {}", e);
            return;
        }
    };

    let mut rows: Vec<String> = Vec::new();

    for entry in entries.flatten() {
        let path = entry.path();

        if path.is_dir() {
            let cfg_path = path.join("package.aspkg");
            if !cfg_path.exists() {
                continue;
            }
            let pkg_name = path
                .file_name()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_else(|| "unknown".into());

            let content = fs::read_to_string(&cfg_path).unwrap_or_default();
            let meta = parse_package_meta(&content);
            let version = meta
                .get("version")
                .cloned()
                .unwrap_or_else(|| "unknown".into());

            rows.push(format!(" - {} (v{}) [multi-file]", pkg_name, version));
            continue;
        }

        if path.extension().and_then(|e| e.to_str()) == Some("aspkg") {
            let pkg_name = path
                .file_stem()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_else(|| "unknown".into());

            let content = fs::read_to_string(&path).unwrap_or_default();
            let meta = parse_package_meta(&content);
            let version = meta.get("version").cloned().unwrap_or_else(|| "unknown".into());

            rows.push(format!(" - {} (v{})", pkg_name, version));
        }
    }

    rows.sort();

    println!("Installed packages:");
    if rows.is_empty() {
        println!(" (none)");
    } else {
        for row in rows {
            println!("{}", row);
        }
    }
}

fn info(name: &str) {
    let name = normalize_name(name);
    let single_path = package_path(&name);
    let dir_path = package_dir_path(&name);
    let cfg_path = package_config_path(&name);

    let (read_from, display_path, is_multi) = if cfg_path.exists() {
        (cfg_path.clone(), dir_path.clone(), true)
    } else if single_path.exists() {
        (single_path.clone(), single_path.clone(), false)
    } else {
        println!("ASPkg: '{}' not installed.", name);
        return;
    };

    let content = match fs::read_to_string(&read_from) {
        Ok(c) => c,
        Err(e) => {
            println!("ASPkg ERROR: failed to read '{}': {}", name, e);
            return;
        }
    };

    let meta = parse_package_meta(&content);

    println!("Package info: {}", name);
    println!("Path: {}", display_path.display());
    println!("Layout: {}", if is_multi { "multi-file" } else { "single-file" });

    if meta.is_empty() {
        println!("Metadata: none (legacy package format)");
    } else {
        println!(
            "Name: {}",
            meta.get("name").cloned().unwrap_or_else(|| name.clone())
        );
        println!(
            "Version: {}",
            meta.get("version")
                .cloned()
                .unwrap_or_else(|| "unknown".into())
        );
        println!(
            "Author: {}",
            meta.get("author")
                .cloned()
                .unwrap_or_else(|| "unknown".into())
        );
        println!(
            "Description: {}",
            meta.get("description")
                .cloned()
                .unwrap_or_else(|| "none".into())
        );
        if let Some(main) = meta.get("main") {
            println!("Main: {}", main);
        }
    }
}

// Show a package's metadata and @help block without installing. Prefers the
// locally-installed copy if present (faster, offline-friendly); otherwise
// fetches the bundle from the registry. Like `info` but presentational, and
// works for not-yet-installed packages.
fn view_pkg(name: &str) {
    let name = normalize_name(name);

    // 1. Look for a local copy first.
    let single = package_path(&name);
    let cfg = package_config_path(&name);
    let (content, source): (String, &str) = if cfg.exists() {
        match fs::read_to_string(&cfg) {
            Ok(c) => (c, "local (installed)"),
            Err(_) => (String::new(), ""),
        }
    } else if single.exists() {
        match fs::read_to_string(&single) {
            Ok(c) => (c, "local (installed)"),
            Err(_) => (String::new(), ""),
        }
    } else {
        // 2. Fall back to the registry. Use cache-bust so we get the latest.
        let url = cache_bust(&format!("{}{}.aspkg", REGISTRY_URL, name));
        print!("Fetching from registry... ");
        let _ = std::io::Write::flush(&mut std::io::stdout());
        match fetch_with_ua(&url) {
            Some(bytes) => {
                println!("ok");
                (String::from_utf8_lossy(&bytes).into_owned(), "registry")
            }
            None => {
                println!("not found");
                println!("  '{}' is not installed and not in the registry.", name);
                println!("  Try: \x1b[36maspkg search {}\x1b[0m", name);
                return;
            }
        }
    };

    let meta = parse_package_meta(&content);

    // Header bar
    println!();
    let title_name = meta.get("name").cloned().unwrap_or_else(|| name.clone());
    let title_version = meta.get("version").cloned().unwrap_or_else(|| "?".into());
    let v_prefix = if title_version.starts_with('v') || title_version.starts_with('V') { "" } else { "v" };
    println!(
        "\x1b[1;36m{} \x1b[0;36m{}{}\x1b[0m  \x1b[2m({})\x1b[0m",
        title_name, v_prefix, title_version, source
    );

    if let Some(a) = meta.get("author") {
        println!("\x1b[2mby\x1b[0m {}", a);
    }
    println!();

    if let Some(d) = meta.get("description") {
        if !d.trim().is_empty() {
            println!("{}", d);
            println!();
        }
    }

    if let Some(l) = meta.get("license") {
        println!("\x1b[2mLicense:\x1b[0m {}", l);
    }
    if let Some(m) = meta.get("main") {
        println!("\x1b[2mMain:\x1b[0m    {}", m);
    }

    if let Some(h) = meta.get("help") {
        if !h.trim().is_empty() {
            println!();
            println!("\x1b[33m── Commands ─────────────────────────\x1b[0m");
            for line in h.lines() {
                println!("  {}", line);
            }
        }
    }

    println!();
    println!("\x1b[2mInstall:\x1b[0m \x1b[36maspkg install {}\x1b[0m", title_name);
}

fn print_help() {
    println!("ASPkg v{} - AyaanScriptPlus Package Manager", ASPKG_VERSION);
    println!();
    println!("Package operations:");
    println!("  aspkg install <name>           Install a package from the registry");
    println!("  aspkg add <name>               Alias for install");
    println!("  aspkg remove <name>            Uninstall a package");
    println!("  aspkg reinstall <name>         Force re-download and reinstall");
    println!("  aspkg update <name>            Refresh a package (same as reinstall)");
    println!("  aspkg upgrade-all              Reinstall every installed package");
    println!("  aspkg outdated                 Show installed packages older than registry");
    println!();
    println!("Discovery & inspection:");
    println!("  aspkg search <query>           Search the registry by name");
    println!("  aspkg view <name>              Peek at a package (works without installing)");
    println!("  aspkg recent                   Show 10 most recently updated packages");
    println!("  aspkg list                     List installed packages");
    println!("  aspkg info <name>              Show local package metadata");
    println!("  aspkg deps <name>              Show import dependency tree");
    println!();
    println!("Authoring & running:");
    println!("  aspkg create                   Interactive wizard for a new package");
    println!("  aspkg starter <template>       Scaffold a working project (game / web / 3d / lib)");
    println!("  aspkg build <name> [out]       Scaffold (if missing) OR bundle into a .aspkg file");
    println!("  aspkg link <dir|file.aspkg>    Install a LOCAL package (skip the registry)");
    println!("  aspkg publish [dir|package.aspkg]   Bundle & push your package to the registry");
    println!("  aspkg run <name> [args...]     Execute an installed package via ayaan");
    println!();
    println!("Utilities:");
    println!("  aspkg path                     Print package storage directory");
    println!("  aspkg version                  Print aspkg version");
    println!("  aspkg help                     Run the interactive tutorial");
    println!("  aspkg --help                   Show this brief usage");
}

// ===== aspkg tutorial =====
fn run_tutorial() {
    use std::io::{stdin, stdout, Write};
    let pause = || {
        print!("\n\x1b[2m  ↵ Press Enter to continue (or Ctrl+C to skip)...\x1b[0m");
        let _ = stdout().flush();
        let mut s = String::new();
        let _ = stdin().read_line(&mut s);
    };

    println!();
    println!("\x1b[36m╔═══════════════════════════════════════════════════════════════╗\x1b[0m");
    println!("\x1b[36m║\x1b[0m  \x1b[1mASPkg v{} — the AyaanScript+ package manager\x1b[0m         \x1b[36m║\x1b[0m", ASPKG_VERSION);
    println!("\x1b[36m╚═══════════════════════════════════════════════════════════════╝\x1b[0m");
    println!();
    println!("Packages bundle AyaanScript+ source code into reusable libraries.");
    println!("They install once and are imported by name from any script.");
    pause();

    println!();
    println!("\x1b[33m1.  Creating a new package\x1b[0m");
    println!();
    println!("    \x1b[36m$ aspkg create\x1b[0m");
    println!("    \x1b[2m# Interactive wizard — prompts for name, author, description, etc.\x1b[0m");
    println!();
    println!("    \x1b[36m$ aspkg build mylib\x1b[0m");
    println!("    \x1b[2m# Quick scaffold (or compile if mylib/ already exists).\x1b[0m");
    pause();

    println!();
    println!("\x1b[33m2.  The package.aspkg manifest\x1b[0m");
    println!();
    println!("    \x1b[36m@name      mylib\x1b[0m");
    println!("    \x1b[36m@version   0.1.0\x1b[0m");
    println!("    \x1b[36m@author    you\x1b[0m");
    println!("    \x1b[36m@description Short summary\x1b[0m");
    println!("    \x1b[36m@main      main.ayaan\x1b[0m");
    println!("    \x1b[36m@help      Available commands:\x1b[0m");
    println!("    \x1b[36m  greet name   - prints \"Hello, name\"\x1b[0m");
    println!("    \x1b[36m  double n     - returns n * 2\x1b[0m");
    println!();
    println!("    \x1b[33m@help is REQUIRED\x1b[0m — aspkg refuses to build without it.");
    pause();

    println!();
    println!("\x1b[33m3.  Building + installing\x1b[0m");
    println!();
    println!("    \x1b[36m$ aspkg build mylib\x1b[0m        \x1b[2m# bundles mylib/ -> mylib.aspkg\x1b[0m");
    println!("    \x1b[36m$ aspkg link mylib.aspkg\x1b[0m   \x1b[2m# install locally (skip registry)\x1b[0m");
    println!("    \x1b[36m$ aspkg install mylib\x1b[0m      \x1b[2m# install from the GitHub registry\x1b[0m");
    pause();

    println!();
    println!("\x1b[33m4.  Discovery\x1b[0m");
    println!();
    println!("    \x1b[36m$ aspkg list\x1b[0m              \x1b[2m# what's installed?\x1b[0m");
    println!("    \x1b[36m$ aspkg search game\x1b[0m       \x1b[2m# search registry by name/desc\x1b[0m");
    println!("    \x1b[36m$ aspkg recent\x1b[0m            \x1b[2m# 10 newest packages\x1b[0m");
    println!("    \x1b[36m$ aspkg info mylib\x1b[0m        \x1b[2m# show local metadata\x1b[0m");
    println!("    \x1b[36m$ aspkg deps mylib\x1b[0m        \x1b[2m# show import dependency tree\x1b[0m");
    pause();

    println!();
    println!("\x1b[33m5.  Using packages in scripts\x1b[0m");
    println!();
    println!("    \x1b[36mimport mylib\x1b[0m                \x1b[2m# package by name (no extension)\x1b[0m");
    println!("    \x1b[36mimport \"helpers.ayaan\"\x1b[0m     \x1b[2m# local file path\x1b[0m");
    println!();
    println!("    On every package import, its \x1b[33m@help\x1b[0m text prints as a yellow");
    println!("    warning block so callers see the available commands.");
    pause();

    println!();
    println!("\x1b[32mYou're set. Try \x1b[36maspkg create\x1b[32m to start a new package.\x1b[0m");
    println!();
}

// ===== NEW COMMAND: create =====
// Interactive wizard. Prompts for each metadata field with defaults, then
// scaffolds the package directory with a complete package.aspkg + main.ayaan.
fn create_pkg() {
    use std::io::{stdin, stdout, Write};

    println!();
    println!("\x1b[36m┌──────────────────────────────────────────────┐\x1b[0m");
    println!("\x1b[36m│\x1b[0m  \x1b[1mNew AyaanScript+ Package — Wizard\x1b[0m         \x1b[36m│\x1b[0m");
    println!("\x1b[36m└──────────────────────────────────────────────┘\x1b[0m");
    println!("\x1b[2m  Press Enter to accept the default shown in [brackets].\x1b[0m");
    println!("\x1b[2m  Ctrl+C to abort.\x1b[0m");
    println!();

    let prompt = |label: &str, default: &str| -> String {
        print!("\x1b[33m?\x1b[0m {} \x1b[2m[{}]\x1b[0m: ", label, default);
        let _ = stdout().flush();
        let mut s = String::new();
        if stdin().read_line(&mut s).is_err() { return default.to_string(); }
        let trimmed = s.trim();
        if trimmed.is_empty() { default.to_string() } else { trimmed.to_string() }
    };

    // Sensible defaults
    let default_author = env::var("USER")
        .or_else(|_| env::var("USERNAME"))
        .unwrap_or_else(|_| "you".to_string());

    let name = prompt("Package name", "mylib");
    if name.is_empty() {
        println!("\x1b[31m✗\x1b[0m Cannot have an empty name.");
        return;
    }
    if !safe_pkg_filename(&name) || name.contains(' ') {
        println!("\x1b[31m✗\x1b[0m Invalid name '{}' (no spaces, slashes, '..', or ':' allowed)", name);
        return;
    }

    let dir = PathBuf::from(&name);
    if dir.exists() {
        println!("\x1b[31m✗\x1b[0m Directory '{}' already exists. Aborting.", name);
        return;
    }

    let version = prompt("Version", "0.1.0");
    let author = prompt("Author", &default_author);
    let description = prompt("Description", "A new AyaanScript+ package");
    let license = prompt("License", "MIT");
    let main_file = prompt("Main file", "main.ayaan");

    println!();
    println!("\x1b[33m?\x1b[0m \x1b[1m@help\x1b[0m \x1b[2m(REQUIRED — type a one-line summary, then optional");
    println!("       indented continuation lines. End with an empty line.)\x1b[0m");
    print!("  > ");
    let _ = stdout().flush();
    let mut first = String::new();
    let _ = stdin().read_line(&mut first);
    let mut help_lines = vec![first.trim_end().to_string()];
    loop {
        print!("  > ");
        let _ = stdout().flush();
        let mut s = String::new();
        if stdin().read_line(&mut s).is_err() { break; }
        if s.trim().is_empty() { break; }
        help_lines.push(format!("  {}", s.trim_end()));
    }
    let help_block = help_lines.join("\n");
    if help_block.trim().is_empty() {
        println!("\x1b[31m✗\x1b[0m @help is required — please run aspkg create again.");
        return;
    }

    // Summary + confirm
    println!();
    println!("\x1b[36mAbout to create '{}' with:\x1b[0m", name);
    println!("  name:        {}", name);
    println!("  version:     {}", version);
    println!("  author:      {}", author);
    println!("  description: {}", description);
    println!("  license:     {}", license);
    println!("  main:        {}", main_file);
    println!("  help:        \x1b[2m{} line(s)\x1b[0m", help_block.lines().count());
    print!("\n\x1b[33m?\x1b[0m Create now? [Y/n]: ");
    let _ = stdout().flush();
    let mut confirm = String::new();
    let _ = stdin().read_line(&mut confirm);
    let c = confirm.trim().to_lowercase();
    if !(c.is_empty() || c == "y" || c == "yes") {
        println!("Aborted.");
        return;
    }

    if let Err(e) = fs::create_dir_all(&dir) {
        println!("\x1b[31m✗\x1b[0m create dir: {}", e);
        return;
    }
    let cfg = format!(
        "@name {}\n@version {}\n@author {}\n@description {}\n@license {}\n@main {}\n@help {}\n",
        name, version, author, description, license, main_file, help_block
    );
    let main_src = format!(
        ": {} — entry point\n\
         : Define your funcs here. They'll be available via:\n\
         :    import {}\n\
         \n\
         func hello\n    psyska -/ \"Hello from {}!\"\nendfunc\n",
        main_file, name, name
    );
    let _ = fs::write(dir.join("package.aspkg"), cfg);
    let _ = fs::write(dir.join(&main_file), main_src);
    let _ = fs::write(dir.join(".gitignore"), "*.aspkg\n");

    println!();
    println!("\x1b[32m✓\x1b[0m Created ./{}/", name);
    println!("    edit ./{}/{}", name, main_file);
    println!("    then run \x1b[36maspkg build {}\x1b[0m to bundle it.", name);
}

// ===== NEW COMMAND: init =====
// Resolve a template name (or "1"/"2"/"3"/"4") to (default_dir, files).
fn resolve_starter_template(input: &str) -> Option<(&'static str, Vec<(&'static str, &'static str)>)> {
    match input.trim().to_lowercase().as_str() {
        "game" | "2d"  | "1" => Some(("game-starter", starter_game_files())),
        "web"  | "http" | "2" => Some(("web-starter",  starter_web_files())),
        "3d"   | "three" | "3" => Some(("3d-starter",   starter_3d_files())),
        "lib"  | "library" | "package" | "4" => Some(("lib-starter", starter_lib_files())),
        _ => None,
    }
}

fn print_starter_menu() {
    println!("Available templates:");
    println!("  \x1b[36mgame\x1b[0m  2D game with paddle + ball + keyboard input");
    println!("  \x1b[36mweb\x1b[0m   HTTP server with route table");
    println!("  \x1b[36m3d\x1b[0m    3D scene with camera + rotating cube");
    println!("  \x1b[36mlib\x1b[0m   Publishable package skeleton");
    println!();
    println!("Or run \x1b[36maspkg starter\x1b[0m with no args for an interactive picker.");
}

// aspkg starter [template]  — scaffold a working starter project.
//
// Without an argument, runs an interactive picker that also prompts for the
// output directory name. With an argument, jumps straight to that template
// using its default directory (backwards-compatible).
fn starter_pkg(template: Option<&str>) {
    use std::io::{stdin, stdout, Write};

    // 1. Resolve template — either from CLI arg or interactive prompt.
    let (default_dir, files) = match template {
        Some(t) => match resolve_starter_template(t) {
            Some(pair) => pair,
            None => {
                println!("ASPkg: unknown template '{}'.", t);
                print_starter_menu();
                return;
            }
        },
        None => {
            println!();
            println!("\x1b[36m╔═══════════════════════════════════════════════════════════╗\x1b[0m");
            println!("\x1b[36m║\x1b[0m  \x1b[1mAyaanScript+ starter templates\x1b[0m                          \x1b[36m║\x1b[0m");
            println!("\x1b[36m╚═══════════════════════════════════════════════════════════╝\x1b[0m");
            println!();
            println!("  \x1b[1m1)\x1b[0m \x1b[36mgame\x1b[0m  2D game - paddle, ball, keyboard input, score");
            println!("  \x1b[1m2)\x1b[0m \x1b[36mweb\x1b[0m   HTTP server with a route table");
            println!("  \x1b[1m3)\x1b[0m \x1b[36m3d\x1b[0m    3D scene - rotating cube, orbiting sphere, fog");
            println!("  \x1b[1m4)\x1b[0m \x1b[36mlib\x1b[0m   Publishable package skeleton");
            println!();
            print!("Pick one [1-4 or name]: ");
            let _ = stdout().flush();
            let mut choice = String::new();
            if stdin().read_line(&mut choice).is_err() || choice.trim().is_empty() {
                println!("ASPkg: nothing selected, aborted.");
                return;
            }
            match resolve_starter_template(&choice) {
                Some(pair) => pair,
                None => {
                    println!("ASPkg: '{}' is not a valid choice.", choice.trim());
                    return;
                }
            }
        }
    };

    // 2. Directory name — prompted only in interactive mode.
    let dir_name: String = if template.is_none() {
        print!("Directory name [\x1b[2m{}\x1b[0m]: ", default_dir);
        let _ = stdout().flush();
        let mut input = String::new();
        let _ = stdin().read_line(&mut input);
        let trimmed = input.trim();
        if trimmed.is_empty() { default_dir.to_string() } else { trimmed.to_string() }
    } else {
        default_dir.to_string()
    };

    // 3. Write the files.
    let dir = PathBuf::from(&dir_name);
    if dir.exists() {
        println!("ASPkg ERROR: directory '{}' already exists", dir_name);
        return;
    }
    if let Err(e) = fs::create_dir_all(&dir) {
        println!("ASPkg ERROR: failed to create dir: {}", e);
        return;
    }
    for (filename, contents) in &files {
        let path = dir.join(filename);
        if let Err(e) = fs::write(&path, contents) {
            println!("ASPkg ERROR: failed to write {}: {}", filename, e);
            return;
        }
    }

    println!();
    println!("\x1b[32m✓\x1b[0m Created \x1b[1m{}/\x1b[0m ({} files)", dir_name, files.len());
    for (filename, _) in &files {
        println!("    \x1b[2m{}/{}\x1b[0m", dir_name, filename);
    }
    println!();
    println!("Try it now:");
    println!("    \x1b[36mcd {}\x1b[0m", dir_name);
    println!("    \x1b[36mayaan main.ayaan\x1b[0m");
}

// Working bouncing-ball game with keyboard control + score.
fn starter_game_files() -> Vec<(&'static str, &'static str)> {
    vec![
        ("main.ayaan", r##": Bouncing-ball starter. Arrow keys / WASD to nudge the paddle.
: Ctrl-C or close the window to quit.

gui_native "AS+ Starter Game" 800 600
gui_fps 60

set W = 800
set H = 600

set ball_x = 400
set ball_y = 300
set vx = 4
set vy = 3

set paddle_x = 340
set paddle_w = 120
set paddle_y = 560
set paddle_h = 12

set score = 0
set running = 1

while running == 1
    gui_open running
    gui_clear "#0f0d1e"

    : Move paddle: A/left = -, D/right = +
    gui_key_down "a" -> l1
    gui_key_down "d" -> r1
    gui_key_down "Left" -> l2
    gui_key_down "Right" -> r2
    set l = {l1 + l2}
    set r = {r1 + r2}
    if l >= 1
        set paddle_x = {paddle_x - 8}
    endif
    if r >= 1
        set paddle_x = {paddle_x + 8}
    endif
    if paddle_x < 0
        set paddle_x = 0
    endif
    set edge = {W - paddle_w}
    if paddle_x > edge
        set paddle_x = edge
    endif

    : Move ball
    set ball_x = {ball_x + vx}
    set ball_y = {ball_y + vy}
    if ball_x < 12
        set vx = {vx * -1}
    endif
    set right_edge = {W - 12}
    if ball_x > right_edge
        set vx = {vx * -1}
    endif
    if ball_y < 12
        set vy = {vy * -1}
    endif

    : Paddle collision (only when descending)
    if vy > 0
        if ball_y >= paddle_y
            set right = {paddle_x + paddle_w}
            if ball_x >= paddle_x
                if ball_x <= right
                    set vy = {vy * -1}
                    set score = {score + 1}
                endif
            endif
        endif
    endif

    : Game over - reset
    if ball_y > H
        set ball_x = 400
        set ball_y = 300
        set score = 0
    endif

    : Draw
    gui_fill_rect paddle_x paddle_y paddle_w paddle_h "#c4b8ff"
    gui_fill_circle ball_x ball_y 12 "#82d2ff"
    gui_text 10 10 "Score: {score}" "white"
    gui_text 10 30 "Move: A/D or Left/Right" "#aaa1d0"

    gui_update
endwhile

gui_close
"##),
        ("README.md", r##"# game-starter

A minimal 2D game in AyaanScript+.

## Run
```
ayaan main.ayaan
```

Move the paddle with **A/D** or **Left/Right**. Bounce the ball, score points.
"##),
    ]
}

// HTTP server with a couple of routes.
fn starter_web_files() -> Vec<(&'static str, &'static str)> {
    vec![
        ("main.ayaan", r##": Web starter. Runs a tiny HTTP server on port 8080.
: Open http://localhost:8080/ in your browser.

serve 8080

route GET / -> home
route GET /hello -> hello
route GET /echo/:name -> echo

func home
    set response = """<!doctype html>
<html><head><title>AS+ Web</title>
<style>body{font-family:sans-serif;background:#0f0d1e;color:#eee;padding:2rem;text-align:center;}
a{color:#82d2ff;}</style></head>
<body>
<h1>hello from AyaanScript+</h1>
<p>Try:</p>
<ul style="display:inline-block;text-align:left;">
  <li><a href="/hello">/hello</a></li>
  <li><a href="/echo/world">/echo/world</a></li>
  <li><a href="/echo/friend">/echo/friend</a></li>
</ul>
</body></html>"""
endfunc

func hello
    set response = "hello!"
endfunc

func echo
    set response = "you said: {name}"
endfunc
"##),
        ("README.md", r##"# web-starter

A tiny HTTP server in AyaanScript+.

## Run
```
ayaan main.ayaan
```

Then open http://localhost:8080/ in your browser.
Routes: `/`, `/hello`, `/echo/:name`.
"##),
    ]
}

// 3D scene with rotating cube.
fn starter_3d_files() -> Vec<(&'static str, &'static str)> {
    vec![
        ("main.ayaan", r##": 3D starter - rotating cube with lighting and fog.
: Close the window to quit.

gui_native "AS+ Starter 3D" 800 600
gui_fps 60
gui_3d_setup 800 600 300

gui_3d_camera_pos 0 2 -8
gui_3d_camera_look 0 0 0
gui_3d_light -0.3 -0.6 -0.5
gui_3d_fog 8 20 "#0a0a14"

set angle = 0
set running = 1

while running == 1
    gui_open running
    gui_clear "#050510"

    set angle = {angle + 1}

    : Rotating cube
    gui_3d_push
    gui_3d_rotate_y angle
    gui_3d_cube 0 0 0 2 "#ff5577"
    gui_3d_pop

    : Sphere orbiting the cube
    set r = 4
    set orbit = {angle * 2}
    set sx = {r * cos(orbit * 0.017)}
    set sz = {r * sin(orbit * 0.017)}
    gui_3d_sphere sx 0 sz 0.6 16 "#44aaff"

    gui_3d_render

    gui_text 10 10 "AS+ 3D - angle = {angle}" "white"

    gui_update
endwhile

gui_close
"##),
        ("README.md", r##"# 3d-starter

A 3D scene with a rotating cube and orbiting sphere.

## Run
```
ayaan main.ayaan
```

Uses the AS+ 3D engine: perspective camera, lambertian shading, fog.
"##),
    ]
}

// A publishable package skeleton (matches `aspkg build` requirements).
fn starter_lib_files() -> Vec<(&'static str, &'static str)> {
    vec![
        ("package.aspkg", r##"@name mylib
@version 0.1.0
@author your-name
@description A starter AyaanScript+ library.
@license MIT
@main main.ayaan
@help Available commands:
  greet name          - prints a greeting for the given name
  double x -> var     - doubles the given number
"##),
        ("main.ayaan", r##": mylib - starter library. Rename @name in package.aspkg before publishing.

func greet name
    psyska -/ "Hello, {name}!"
endfunc

func double x
    set result = {x * 2}
endfunc
"##),
        ("README.md", r##"# mylib

A starter AyaanScript+ library.

## Local test
```
aspkg link .
ayaan -e 'import mylib; call greet "world"'
```

## Publish
```
aspkg publish
```

Bump `@version` in `package.aspkg` before each publish (the registry rejects
same-or-lower versions).
"##),
    ]
}

fn init_pkg(name: &str) {
    let dir = PathBuf::from(name);
    if dir.exists() {
        println!("ASPkg ERROR: directory '{}' already exists", name);
        return;
    }
    if let Err(e) = fs::create_dir_all(&dir) {
        println!("ASPkg ERROR: failed to create dir: {}", e);
        return;
    }
    let cfg = format!(
        "@name {}\n\
         @version 0.1.0\n\
         @author you\n\
         @description Brand new AyaanScript+ package.\n\
         @main main.ayaan\n\
         @help Commands provided by this package:\n  \
            (document each command on its own indented line — required)\n",
        name
    );
    let main_src = format!(
        ": main.ayaan — entry point for the '{}' package.\n\
         : Edit me to do something useful.\n\
         \n\
         psyska -/ \"Hello from {}!\"\n",
        name, name
    );
    let _ = fs::write(dir.join("package.aspkg"), cfg);
    let _ = fs::write(dir.join("main.ayaan"), main_src);
    let _ = fs::write(dir.join(".gitignore"), "*.aspkg\n");
    println!("ASPkg: scaffolded package '{}' in ./{}", name, name);
    println!("       edit ./{}/main.ayaan, then 'aspkg build {}' to bundle it.", name, name);
}

// ===== NEW COMMAND: search =====
// Queries the GitHub contents API to enumerate every package, then matches by
// name AND description (case-insensitive). No index.txt file needed.
fn search(query: &str) {
    let q = query.to_lowercase();
    let listing = match fetch_with_ua(GITHUB_API_CONTENTS) {
        Some(b) => b,
        None => {
            println!("ASPkg: couldn't query GitHub API. Try later.");
            return;
        }
    };
    let names = extract_pkg_names(&String::from_utf8_lossy(&listing));
    if names.is_empty() {
        println!("ASPkg: registry is empty.");
        return;
    }

    // For each package, fetch its metadata once, then test name + description.
    let mut matches: Vec<(String, String, String)> = Vec::new();
    for name in &names {
        let multi_url = format!("{}{}/package.aspkg", REGISTRY_URL, name);
        let single_url = format!("{}{}.aspkg", REGISTRY_URL, name);
        let cfg = fetch_file(&multi_url)
            .or_else(|| fetch_file(&single_url))
            .map(|b| String::from_utf8_lossy(&b).to_string());

        let (author, desc) = match cfg {
            Some(c) => {
                let m = parse_package_meta(&c);
                (m.get("author").cloned().unwrap_or_else(|| "unknown".into()),
                 m.get("description").cloned().unwrap_or_else(|| "(no description)".into()))
            }
            None => ("unknown".into(), String::new()),
        };

        let hay = format!("{} {}", name, desc).to_lowercase();
        if hay.contains(&q) {
            matches.push((name.clone(), author, desc));
        }
    }

    if matches.is_empty() {
        println!("ASPkg: no packages match '{}'", query);
        return;
    }

    println!("\x1b[36m{} match{} for '{}':\x1b[0m",
        matches.len(),
        if matches.len() == 1 { "" } else { "es" },
        query);
    println!();
    for (name, author, desc) in &matches {
        println!("  \x1b[1m{}\x1b[0m  by \x1b[32m{}\x1b[0m", name, author);
        println!("    {}", desc);
        println!();
    }
    println!("\x1b[2m(install with: aspkg install <name>)\x1b[0m");
}

// ===== NEW COMMAND: recent =====
// Lists the 10 most recently committed packages from the GitHub registry.
// If fewer than 10 exist, shows however many there are.
//
// Algorithm:
//   1. Hit GitHub's contents API to enumerate package directories
//   2. For each, query the commits API to get the latest commit timestamp
//      affecting that directory
//   3. Sort by commit date descending
//   4. Take top 10 (or all if fewer)
//   5. Fetch package.aspkg from raw.githubusercontent for author + description
fn recent() {
    let listing = match fetch_with_ua(&cache_bust(GITHUB_API_CONTENTS)) {
        Some(b) => b,
        None => {
            println!("ASPkg: couldn't query GitHub API. Try later.");
            return;
        }
    };
    let listing_text = String::from_utf8_lossy(&listing).to_string();

    let names = extract_pkg_names(&listing_text);
    if names.is_empty() {
        println!("ASPkg: no packages in the registry yet.");
        return;
    }

    // For each package, get the latest commit timestamp. Try the dir path first,
    // then the single-file path. ISO 8601 dates sort lexicographically.
    let mut dated: Vec<(String, String)> = Vec::new();
    for name in &names {
        let dir_url = format!("{}?path=packages/{}&per_page=1", GITHUB_API_COMMITS, name);
        let mut date = fetch_with_ua(&cache_bust(&dir_url))
            .map(|b| String::from_utf8_lossy(&b).to_string())
            .and_then(|s| extract_first_commit_date(&s));
        if date.is_none() {
            let file_url = format!("{}?path=packages/{}.aspkg&per_page=1", GITHUB_API_COMMITS, name);
            date = fetch_with_ua(&cache_bust(&file_url))
                .map(|b| String::from_utf8_lossy(&b).to_string())
                .and_then(|s| extract_first_commit_date(&s));
        }
        dated.push((date.unwrap_or_else(|| "0000-00-00".to_string()), name.clone()));
    }

    // Sort by date descending. ISO 8601 strings sort lexicographically.
    dated.sort_by(|a, b| b.0.cmp(&a.0));

    let take = dated.len().min(10);
    let total = names.len();
    println!(
        "\x1b[36mShowing {} most recent package{} (registry has {})\x1b[0m",
        take,
        if take == 1 { "" } else { "s" },
        total
    );
    println!();

    // Fetch metadata for the chosen packages. Try multi-file format first
    // (packages/<name>/package.aspkg), then fall back to single-file bundle
    // (packages/<name>.aspkg, which has @meta at the top).
    // Cache-busted to always get the live registry data.
    let mut rows: Vec<(String, String, String, String)> = Vec::new();
    for (date, name) in dated.iter().take(take) {
        let multi_url = cache_bust(&format!("{}{}/package.aspkg", REGISTRY_URL, name));
        let single_url = cache_bust(&format!("{}{}.aspkg", REGISTRY_URL, name));

        let cfg_text = fetch_file(&multi_url)
            .or_else(|| fetch_file(&single_url))
            .map(|b| String::from_utf8_lossy(&b).to_string());

        let (author, desc) = match cfg_text {
            Some(c) => {
                let m = parse_package_meta(&c);
                (m.get("author").cloned().unwrap_or_else(|| "unknown".into()),
                 m.get("description").cloned().unwrap_or_else(|| "(no description)".into()))
            }
            None => ("unknown".into(), "(metadata unavailable)".into()),
        };
        rows.push((name.clone(), author, desc, date.clone()));
    }
    print_recent_dated(&rows);
}

// HTTP GET with a User-Agent header (GitHub API rejects requests without one).
// Forces no-cache so each `recent` / `search` invocation fetches live data.
fn fetch_with_ua(url: &str) -> Option<Vec<u8>> {
    let client = reqwest::blocking::Client::builder()
        .user_agent(format!("aspkg/{}", ASPKG_VERSION))
        .build().ok()?;
    let resp = client.get(url)
        .header("Cache-Control", "no-cache")
        .header("Pragma", "no-cache")
        .send().ok()?;
    if !resp.status().is_success() {
        return None;
    }
    resp.bytes().ok().map(|b| b.to_vec())
}

// Cache-buster: append a unique timestamp param so CDN caches don't serve stale.
fn cache_bust(url: &str) -> String {
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    if url.contains('?') {
        format!("{}&_t={}", url, ts)
    } else {
        format!("{}?_t={}", url, ts)
    }
}

// Naive JSON parser for the GitHub contents response. A package is either:
//   (a) a directory (multi-file package)
//   (b) a *.aspkg file in the packages/ root (single-file bundle)
// We return canonical package names (no .aspkg extension).
fn extract_pkg_names(json: &str) -> Vec<String> {
    let mut names = Vec::new();
    for chunk in json.split("},") {
        let is_dir = chunk.contains("\"type\":\"dir\"") || chunk.contains("\"type\": \"dir\"");
        let is_file = chunk.contains("\"type\":\"file\"") || chunk.contains("\"type\": \"file\"");
        if !is_dir && !is_file { continue; }

        let raw_name = match find_json_string_value(chunk, "name") {
            Some(s) => s,
            None => continue,
        };

        if is_dir {
            // skip hidden / metadata dirs
            if raw_name.starts_with('.') { continue; }
            names.push(raw_name);
        } else if is_file {
            // single-file packages must end in .aspkg
            if let Some(stem) = raw_name.strip_suffix(".aspkg") {
                if !stem.is_empty() && !stem.starts_with('.') {
                    names.push(stem.to_string());
                }
            }
        }
    }
    names
}

// Extract a JSON string value for a given key from a chunk like
// "...\"name\":\"foo\",...". Returns the string contents (without quotes).
fn find_json_string_value(chunk: &str, key: &str) -> Option<String> {
    let needle = format!("\"{}\"", key);
    let pos = chunk.find(&needle)?;
    let after_key = &chunk[pos + needle.len()..];
    // Skip ":" and any whitespace
    let after_colon = after_key.trim_start_matches([' ', '\t', ':']);
    let after_quote = after_colon.strip_prefix('"')?;
    let end = after_quote.find('"')?;
    Some(after_quote[..end].to_string())
}

// Extract the first commit's date from /repos/.../commits?... JSON output.
// Looks for the first "committer":{ "date":"YYYY-MM-DD..." } occurrence.
fn extract_first_commit_date(json: &str) -> Option<String> {
    // Find first "committer" then the next "date" inside it.
    let mut search = json;
    while let Some(pos) = search.find("\"committer\"") {
        let rest = &search[pos..];
        if let Some(date_pos) = rest.find("\"date\"") {
            let after = &rest[date_pos + 6..];
            let after_quote = after.trim_start_matches([' ', '\t', ':', '"']);
            if let Some(end) = after_quote.find('"') {
                return Some(after_quote[..end].to_string());
            }
        }
        // No date found inside this committer — advance and try the next one
        if pos + 1 >= search.len() { break; }
        search = &search[pos + 1..];
    }
    None
}

fn print_recent_dated(rows: &[(String, String, String, String)]) {
    for (i, (name, author, desc, date)) in rows.iter().enumerate() {
        // Trim the date down to YYYY-MM-DD for display
        let date_short: String = date.chars().take(10).collect();
        println!(
            "\x1b[33m{:>2}.\x1b[0m \x1b[1m{}\x1b[0m  \x1b[2m({})\x1b[0m  by \x1b[32m{}\x1b[0m",
            i + 1, name, date_short, author
        );
        let mut col = 0;
        print!("    ");
        for word in desc.split_whitespace() {
            if col + word.len() + 1 > 70 {
                println!();
                print!("    ");
                col = 0;
            }
            print!("{} ", word);
            col += word.len() + 1;
        }
        println!();
        println!();
    }
    println!("\x1b[2m(install with: aspkg install <name>)\x1b[0m");
}

// ===== NEW COMMAND: outdated =====
fn outdated() {
    let dir = package_dir();
    let entries = match fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) => { println!("ASPkg ERROR: {}", e); return; }
    };
    let mut any = false;
    for entry in entries.flatten() {
        let path = entry.path();
        let (pkg_name, local_ver) = if path.is_dir() {
            let cfg = path.join("package.aspkg");
            if !cfg.exists() { continue; }
            let n = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
            let c = fs::read_to_string(&cfg).unwrap_or_default();
            let m = parse_package_meta(&c);
            (n, m.get("version").cloned().unwrap_or_else(|| "unknown".into()))
        } else if path.extension().and_then(|e| e.to_str()) == Some("aspkg") {
            let n = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
            let c = fs::read_to_string(&path).unwrap_or_default();
            let m = parse_package_meta(&c);
            (n, m.get("version").cloned().unwrap_or_else(|| "unknown".into()))
        } else {
            continue;
        };
        // fetch remote version
        let url = format!("{}{}/package.aspkg", REGISTRY_URL, pkg_name);
        let remote_ver = fetch_file(&url)
            .map(|b| String::from_utf8_lossy(&b).to_string())
            .map(|s| parse_package_meta(&s).get("version").cloned().unwrap_or_else(|| "?".into()))
            .unwrap_or_else(|| "(not in registry)".into());
        if remote_ver != local_ver && remote_ver != "(not in registry)" {
            println!("  {} : local v{} -> remote v{}", pkg_name, local_ver, remote_ver);
            any = true;
        }
    }
    if !any {
        println!("ASPkg: all packages up to date.");
    }
}

// ===== NEW COMMAND: upgrade-all =====
fn upgrade_all() {
    let dir = package_dir();
    let entries = match fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) => { println!("ASPkg ERROR: {}", e); return; }
    };
    let mut names: Vec<String> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            if path.join("package.aspkg").exists() {
                if let Some(n) = path.file_name() { names.push(n.to_string_lossy().to_string()); }
            }
        } else if path.extension().and_then(|e| e.to_str()) == Some("aspkg") {
            if let Some(n) = path.file_stem() { names.push(n.to_string_lossy().to_string()); }
        }
    }
    if names.is_empty() {
        println!("ASPkg: no packages installed.");
        return;
    }
    println!("ASPkg: upgrading {} package(s)...", names.len());
    for n in &names {
        reinstall(n);
    }
}

// ===== NEW COMMAND: deps =====
fn deps(name: &str) {
    let name = normalize_name(name);
    let dir_path = package_dir_path(&name);
    let single_path = package_path(&name);

    if dir_path.exists() {
        let cfg_path = package_config_path(&name);
        let cfg_content = fs::read_to_string(&cfg_path).unwrap_or_default();
        let meta = parse_package_meta(&cfg_content);
        let main = meta.get("main").cloned().unwrap_or_else(|| "main.ayaan".into());
        println!("Dependencies for '{}' (entry: {}):", name, main);
        let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
        walk_deps(&dir_path, &main, &mut visited, 0);
    } else if single_path.exists() {
        println!("Package '{}' is single-file (no separate dependencies).", name);
    } else {
        println!("ASPkg: '{}' not installed.", name);
    }
}

fn walk_deps(dir: &Path, file: &str, visited: &mut std::collections::HashSet<String>, depth: usize) {
    let indent = "  ".repeat(depth);
    if !visited.insert(file.to_string()) {
        println!("{}- {} (already shown)", indent, file);
        return;
    }
    println!("{}- {}", indent, file);
    let content = fs::read_to_string(dir.join(file)).unwrap_or_default();
    for imp in extract_imports(&content) {
        walk_deps(dir, &imp, visited, depth + 1);
    }
}

// ===== NEW COMMAND: run =====
fn run_pkg(name: &str, extra_args: &[String]) {
    let name = normalize_name(name);
    let dir_path = package_dir_path(&name);
    let single_path = package_path(&name);

    let target = if dir_path.exists() {
        let cfg = package_config_path(&name);
        let content = fs::read_to_string(&cfg).unwrap_or_default();
        let meta = parse_package_meta(&content);
        let main = meta.get("main").cloned().unwrap_or_else(|| "main.ayaan".into());
        dir_path.join(main)
    } else if single_path.exists() {
        single_path.clone()
    } else {
        println!("ASPkg: '{}' not installed.", name);
        return;
    };

    if !target.exists() {
        println!("ASPkg ERROR: entry point '{}' missing", target.display());
        return;
    }

    // Try to find ayaan binary next to ourselves first; fall back to PATH lookup
    let ayaan = locate_ayaan();
    let mut cmd = Command::new(ayaan);
    cmd.arg(&target);
    for a in extra_args { cmd.arg(a); }
    match cmd.status() {
        Ok(s) if s.success() => {}
        Ok(s) => println!("ASPkg: exited with status {:?}", s.code()),
        Err(e) => println!("ASPkg ERROR: failed to launch ayaan: {}", e),
    }
}

// Install a LOCAL package — copy a directory or .aspkg file into the package
// store so `import <name>` finds it. Skips the registry entirely.
fn link_local(source: &str) {
    let src = PathBuf::from(source);
    if !src.exists() {
        println!("ASPkg ERROR: '{}' does not exist", source);
        return;
    }

    // Case 1: directory containing package.aspkg
    if src.is_dir() {
        let cfg = src.join("package.aspkg");
        if !cfg.exists() {
            println!("ASPkg ERROR: '{}/package.aspkg' missing", source);
            return;
        }
        let content = fs::read_to_string(&cfg).unwrap_or_default();
        let meta = parse_package_meta(&content);
        let name = meta.get("name")
            .cloned()
            .or_else(|| src.file_name().map(|s| s.to_string_lossy().to_string()))
            .unwrap_or_else(|| "unknown".to_string());
        let dst = package_dir().join(&name);

        // wipe any previous install
        if dst.exists() { let _ = fs::remove_dir_all(&dst); }
        let single = package_dir().join(format!("{}.aspkg", name));
        if single.exists() { let _ = fs::remove_file(&single); }

        if let Err(e) = fs::create_dir_all(&dst) {
            println!("ASPkg ERROR: {}", e);
            return;
        }
        let mut copied = 0;
        if let Ok(entries) = fs::read_dir(&src) {
            for entry in entries.flatten() {
                let p = entry.path();
                if p.is_file() {
                    let name = match p.file_name() { Some(n) => n, None => continue };
                    if let Err(e) = fs::copy(&p, dst.join(name)) {
                        println!("ASPkg WARN: failed to copy {}: {}", p.display(), e);
                    } else {
                        copied += 1;
                    }
                }
            }
        }
        println!("ASPkg: linked '{}' from {} ({} file{})", name, src.display(), copied,
                 if copied == 1 { "" } else { "s" });
        println!("       use it from scripts with: import {}", name);
        return;
    }

    // Case 2: a .aspkg bundle file
    if src.is_file() {
        let name = src.file_stem()
            .map(|s| s.to_string_lossy().to_string())
            .unwrap_or_else(|| "unknown".into());
        let dst = package_dir().join(format!("{}.aspkg", name));

        // wipe any previous install
        if dst.exists() { let _ = fs::remove_file(&dst); }
        let dir = package_dir().join(&name);
        if dir.exists() { let _ = fs::remove_dir_all(&dir); }

        match fs::copy(&src, &dst) {
            Ok(_) => {
                println!("ASPkg: linked '{}' from {}", name, src.display());
                println!("       use it from scripts with: import {}", name);
            }
            Err(e) => println!("ASPkg ERROR: {}", e),
        }
    }
}

fn locate_ayaan() -> PathBuf {
    // 1. neighbor of current exe
    if let Ok(this_exe) = env::current_exe() {
        if let Some(parent) = this_exe.parent() {
            let candidate = parent.join(if cfg!(windows) { "ayaan.exe" } else { "ayaan" });
            if candidate.exists() { return candidate; }
        }
    }
    // 2. fall back to PATH lookup
    PathBuf::from(if cfg!(windows) { "ayaan.exe" } else { "ayaan" })
}

#[cfg(windows)]
fn enable_ansi_on_windows() {
    extern "system" {
        fn GetStdHandle(handle: u32) -> *mut std::ffi::c_void;
        fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
        fn SetConsoleMode(handle: *mut std::ffi::c_void, mode: u32) -> i32;
    }
    const STD_OUTPUT_HANDLE: u32 = 0xfffffff5;
    const STD_ERROR_HANDLE: u32 = 0xfffffff4;
    const ENABLE_VT: u32 = 0x0004;
    unsafe {
        for h_id in [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(h_id);
            if !h.is_null() {
                let mut mode = 0u32;
                if GetConsoleMode(h, &mut mode) != 0 {
                    SetConsoleMode(h, mode | ENABLE_VT);
                }
            }
        }
    }
}
#[cfg(not(windows))]
fn enable_ansi_on_windows() {}

fn main() {
    enable_ansi_on_windows();
    ensure_dirs();

    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        print_help();
        return;
    }

    match args[1].as_str() {
        "install" | "add" | "i" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            install(&args[2]);
        }
        "remove" | "uninstall" | "rm" | "delete" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            remove(&args[2]);
        }
        "reinstall" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            reinstall(&args[2]);
        }
        "update" | "upgrade" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            update(&args[2]);
        }
        "list" | "ls" => list(),
        "info" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            info(&args[2]);
        }
        "view" | "show" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                println!("Usage: aspkg view <name>   (peek at a package without installing)");
                return;
            }
            view_pkg(&args[2]);
        }
        "path" => {
            println!("{}", package_dir().display());
        }
        "link" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package directory or .aspkg file");
                println!("Usage:");
                println!("  aspkg link <dir>          install a local package dir into ~/.config/.../aspkg/packages");
                println!("  aspkg link <file.aspkg>   install a compiled .aspkg bundle");
                return;
            }
            link_local(&args[2]);
        }
        "build" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                println!("Usage:");
                println!("  aspkg build <name>            scaffold (if ./<name>/ missing) OR compile (if exists)");
                println!("  aspkg build <name> <out>      compile to a specific output file");
                return;
            }
            let name = &args[2];
            let dir = PathBuf::from(name);
            if !dir.exists() {
                init_pkg(name);
                return;
            }
            if !dir.is_dir() {
                println!("ASPkg ERROR: '{}' exists but is not a directory", name);
                return;
            }
            if !dir.join("package.aspkg").exists() {
                println!("ASPkg ERROR: '{}/package.aspkg' missing — not a valid package directory", name);
                return;
            }
            let output = args.get(3).map(|s| s.as_str());
            compile(name, output);
        }
        "search" | "find" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing search query");
                return;
            }
            search(&args[2]);
        }
        "outdated" => outdated(),
        "recent" | "latest" => recent(),
        "upgrade-all" | "upgrade_all" => upgrade_all(),
        "deps" | "dependencies" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            deps(&args[2]);
        }
        "run" | "exec" => {
            if args.len() < 3 {
                println!("ASPkg ERROR: missing package name");
                return;
            }
            let extra: Vec<String> = args.iter().skip(3).cloned().collect();
            run_pkg(&args[2], &extra);
        }
        "create" => create_pkg(),
        "starter" => {
            starter_pkg(args.get(2).map(|s| s.as_str()));
        }
        "publish" => {
            // `aspkg publish` (no arg) defaults to the current dir; explicit
            // arg lets you publish from elsewhere.
            let dir_arg = args.get(2).map(|s| s.as_str()).unwrap_or(".");
            publish_pkg(dir_arg);
        }
        "version" | "--version" | "-V" | "-v" => {
            println!("aspkg {}", ASPKG_VERSION);
        }
        // `help` (alone) runs the interactive tutorial; `--help` / `-h` print
        // the brief usage as before for quick reference.
        "help" => run_tutorial(),
        "--help" | "-h" => print_help(),
        _ => {
            println!("ASPkg ERROR: unknown command '{}'", args[1]);
            println!("Run: aspkg help");
        }
    }
}