//! ASCII art generation at build time.
//!
//! Reads `ascii/distros/*.txt` and emits a generated Rust module with one
//! constant per logo plus a `get_ascii_art` lookup function.
//!
//! `${c1}`..`${c6}` colour placeholders are kept verbatim in the emitted art
//! and expanded at *runtime* (see `src/ascii/colors.rs`) so `ascii_colors`
//! remapping can recolour a logo. Each logo also gets a `_PALETTE` constant:
//! either from a leading `# set_colors N N ...` line (neofetch format) or the
//! built-in table below. Width is computed treating markers as zero-width.

use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn main() {
    println!("cargo:rerun-if-changed=ascii/distros/");

    emit_build_metadata();

    let out_dir = env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("ascii_art.rs");

    let ascii_dir = Path::new("ascii/distros");

    let mut code = String::new();
    code.push_str("// Auto-generated ASCII art lookup module.\n");
    code.push_str("// Generated by build.rs from ascii/distros/*.txt files.\n\n");

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

    if ascii_dir.exists() {
        for entry in fs::read_dir(ascii_dir).expect("Failed to read ascii/distros directory") {
            let entry = entry.expect("Failed to read directory entry");
            let path = entry.path();
            if path.extension().is_some_and(|e| e == "txt") {
                let distro_name = path
                    .file_stem()
                    .expect("No file stem")
                    .to_string_lossy()
                    .to_lowercase();
                let content = fs::read_to_string(&path)
                    .unwrap_or_else(|_| panic!("Failed to read {:?}", path));
                distros.push((distro_name, content));
            }
        }
    }

    for (name, content) in &distros {
        let const_name = name.to_uppercase().replace('-', "_");

        // An optional leading `# set_colors N N ...` line (neofetch-style) sets
        // the palette; otherwise fall back to the built-in table.
        let mut lines: Vec<&str> = content.lines().collect();
        let palette = match lines.first().and_then(|l| parse_set_colors(l)) {
            Some(p) => {
                lines.remove(0);
                p
            }
            None => distro_palette(name),
        };

        // Keep the `${c1}`..`${c6}` markers in the emitted art; they are expanded
        // at runtime so `ascii_colors` remapping can recolour. Width is the
        // visible width with markers treated as zero-width.
        let max_width = lines.iter().map(|l| visible_width(l)).max().unwrap_or(0);

        code.push_str(&format!("/// ASCII art for {}\n", name));
        code.push_str(&format!(
            "pub const ASCII_ART_{}: &[&str] = &[\n",
            const_name
        ));
        for line in &lines {
            let pad = max_width.saturating_sub(visible_width(line));
            let padded = format!("{line}{}", " ".repeat(pad));
            code.push_str(&format!("    {:?},\n", padded));
        }
        code.push_str("];\n\n");
        code.push_str(&format!(
            "pub const ASCII_ART_{}_WIDTH: usize = {};\n",
            const_name, max_width
        ));
        code.push_str(&format!(
            "pub const ASCII_ART_{}_PALETTE: [u8; 6] = {:?};\n\n",
            const_name, palette
        ));
    }

    code.push_str("/// Get ASCII art for a given distro name.\n");
    code.push_str("/// Returns the art lines and the maximum display width.\n");
    code.push_str(
        "pub fn get_ascii_art(distro: &str) -> (&'static [&'static str], usize, [u8; 6]) {\n",
    );
    code.push_str("    let distro_lower = distro.to_lowercase();\n");

    let generic_names = ["linux", "macos", "windows"];
    let mut specific_distros: Vec<&(String, String)> = distros
        .iter()
        .filter(|(name, _)| !generic_names.contains(&name.as_str()))
        .collect();
    let mut generic_distros: Vec<&(String, String)> = distros
        .iter()
        .filter(|(name, _)| generic_names.contains(&name.as_str()))
        .collect();
    // Longest names first so e.g. "ubuntu-mate" matches before "ubuntu".
    specific_distros.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0)));
    generic_distros.sort_by(|a, b| a.0.cmp(&b.0));

    for (name, _) in specific_distros {
        let const_name = name.to_uppercase().replace('-', "_");
        // Match against the original name with - in it too, e.g. "ubuntu-mate".
        let match_key = name.replace('_', "-");
        code.push_str(&format!(
            "    if distro_lower.contains(\"{}\") {{\n",
            match_key
        ));
        code.push_str(&format!(
            "        return (ASCII_ART_{0}, ASCII_ART_{0}_WIDTH, ASCII_ART_{0}_PALETTE);\n",
            const_name
        ));
        code.push_str("    }\n");
    }
    for (name, _) in generic_distros {
        let const_name = name.to_uppercase().replace('-', "_");
        code.push_str(&format!("    if distro_lower.contains(\"{}\") {{\n", name));
        code.push_str(&format!(
            "        return (ASCII_ART_{0}, ASCII_ART_{0}_WIDTH, ASCII_ART_{0}_PALETTE);\n",
            const_name
        ));
        code.push_str("    }\n");
    }

    code.push_str("    #[cfg(target_os = \"linux\")]\n");
    code.push_str(
        "    return (ASCII_ART_LINUX, ASCII_ART_LINUX_WIDTH, ASCII_ART_LINUX_PALETTE);\n",
    );
    code.push_str("    #[cfg(target_os = \"macos\")]\n");
    code.push_str(
        "    return (ASCII_ART_MACOS, ASCII_ART_MACOS_WIDTH, ASCII_ART_MACOS_PALETTE);\n",
    );
    code.push_str("    #[cfg(target_os = \"windows\")]\n");
    code.push_str(
        "    return (ASCII_ART_WINDOWS, ASCII_ART_WINDOWS_WIDTH, ASCII_ART_WINDOWS_PALETTE);\n",
    );
    code.push_str("    #[cfg(not(any(target_os = \"linux\", target_os = \"macos\", target_os = \"windows\")))]\n");
    code.push_str(
        "    return (ASCII_ART_LINUX, ASCII_ART_LINUX_WIDTH, ASCII_ART_LINUX_PALETTE);\n",
    );
    code.push_str("}\n");

    fs::write(&dest_path, code).expect("Failed to write generated ascii_art.rs");
}

/// 256-colour palette for `${c1}`..`${c6}` placeholders, per distro.
///
/// Mostly mirrors neofetch's `set_colors` defaults. Pick something readable
/// if the distro isn't listed.
fn distro_palette(name: &str) -> [u8; 6] {
    // (c1, c2, c3, c4, c5, c6) — 256-colour codes.
    match name {
        // Existing logos in repo:
        "ubuntu" => [208, 15, 7, 7, 7, 7],
        "arch" => [6, 6, 7, 1, 7, 7], // neofetch `set_colors 6 6 7 1`
        "fedora" | "fedora_silverblue" | "fedora-silverblue" => [12, 7, 7, 7, 7, 7],
        "debian" => [1, 7, 3, 7, 7, 7],
        "nixos" => [4, 6, 7, 7, 7, 7],
        "macos" => [2, 3, 1, 1, 5, 4], // neofetch `set_colors 2 3 1 1 5 4`
        "windows" => [1, 2, 4, 3, 7, 7], // neofetch `set_colors 1 2 4 3` (retro flag)
        "linux" => [7, 8, 3, 7, 7, 7],

        // Ported logos:
        "manjaro" => [2, 7, 2, 2, 2, 2],
        "mint" | "linuxmint" => [2, 7, 2, 2, 2, 2],
        "popos" | "pop_os" | "pop-os" => [6, 7, 6, 6, 6, 6],
        "kali" => [4, 8, 4, 4, 4, 4],
        "gentoo" => [5, 7, 5, 5, 5, 5],
        "void" => [2, 0, 2, 2, 2, 2],
        "alpine" => [4, 7, 4, 4, 4, 4],
        "opensuse" | "opensuse_tumbleweed" | "opensuse_leap" => [2, 7, 2, 2, 2, 2],
        "raspbian" => [1, 2, 1, 1, 1, 1],
        "elementary" => [7, 6, 7, 7, 7, 7],
        "rocky" => [2, 7, 2, 2, 2, 2],
        "alma" | "almalinux" => [1, 7, 3, 4, 5, 6],
        "endeavouros" | "endeavour" => [5, 4, 1, 5, 5, 5],
        "garuda" => [3, 7, 3, 3, 3, 3],
        "asahi" => [5, 7, 5, 5, 5, 5],
        "artix" => [6, 7, 6, 6, 6, 6],
        "centos" => [3, 5, 4, 2, 7, 7],
        "deepin" => [6, 7, 6, 6, 6, 6],
        "devuan" => [5, 7, 5, 5, 5, 5],
        "freebsd" => [1, 7, 3, 7, 7, 7],
        "openbsd" => [3, 7, 6, 1, 8, 7], // neofetch `set_colors 3 7 6 1 8`
        "netbsd" => [5, 7, 7, 7, 7, 7],
        "kde_neon" | "kde-neon" | "neon" => [6, 7, 6, 6, 6, 6],
        "kubuntu" => [4, 7, 4, 4, 4, 4],
        "lubuntu" => [4, 7, 4, 4, 4, 4],
        "xubuntu" => [4, 7, 4, 4, 4, 4],
        "ubuntu_mate" | "ubuntu-mate" => [2, 7, 2, 2, 2, 2],
        "ubuntu_budgie" | "ubuntu-budgie" => [6, 7, 6, 6, 6, 6],
        "mx" | "mxlinux" => [7, 0, 7, 7, 7, 7],
        "parrot" => [6, 7, 6, 6, 6, 6],
        "peppermint" => [1, 7, 1, 1, 1, 1],
        "qubes" => [4, 7, 4, 4, 4, 4],
        "slackware" => [4, 7, 4, 4, 4, 4],
        "solus" => [4, 7, 4, 4, 4, 4],
        "steamos" => [5, 7, 5, 5, 5, 5],
        "tails" => [5, 7, 5, 5, 5, 5],
        "zorin" => [4, 7, 4, 4, 4, 4],
        "arcolinux" | "arco" => [4, 6, 4, 4, 4, 4],
        "archcraft" => [6, 7, 6, 6, 6, 6],
        "archlabs" => [4, 6, 4, 4, 4, 4],
        "blackarch" => [1, 7, 1, 1, 1, 1],
        "cachyos" => [6, 7, 6, 6, 6, 6],
        "crystal" => [6, 7, 6, 6, 6, 6],
        "guix" => [3, 5, 3, 3, 3, 3],
        "haiku" => [3, 7, 3, 3, 3, 3],
        "lite" | "linuxlite" => [3, 7, 3, 3, 3, 3],
        "mageia" => [4, 7, 4, 4, 4, 4],
        "oracle" => [1, 7, 1, 1, 1, 1],
        "redhat" | "rhel" => [1, 7, 1, 1, 1, 1],
        "ubuntu_studio" | "ubuntu-studio" => [4, 7, 4, 4, 4, 4],
        "vanilla" | "vanillaos" => [3, 7, 3, 3, 3, 3],

        _ => [4, 7, 4, 4, 4, 4], // default blue + white
    }
}

/// Parse a leading `# set_colors N N ...` directive into a 6-slot palette
/// (unspecified slots default to 7, the foreground), or `None` if absent.
fn parse_set_colors(line: &str) -> Option<[u8; 6]> {
    let rest = line.trim().strip_prefix("# set_colors")?;
    let mut palette = [7u8; 6];
    for (i, tok) in rest.split_whitespace().take(6).enumerate() {
        if let Ok(v) = tok.parse() {
            palette[i] = v;
        }
    }
    Some(palette)
}

/// Emit the `PURR_*` `cargo:rustc-env` vars consumed by `src/version.rs` to
/// build the verbose `purr -V` / `purr --version` output: git commit + dirty
/// state, build profile, target/host triples, compile timestamp and rustc.
fn emit_build_metadata() {
    // Re-run when HEAD moves or the index changes so the commit hash and dirty
    // marker don't go stale across rebuilds. Working-tree edits can't all be
    // tracked here, so the dirty flag is best-effort.
    println!("cargo:rerun-if-changed=.git/HEAD");
    println!("cargo:rerun-if-changed=.git/index");
    println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");

    let unknown = || "unknown".to_string();

    println!("cargo:rustc-env=PURR_GIT_HASH={}", git_commit_display());
    println!(
        "cargo:rustc-env=PURR_BUILD_PROFILE={}",
        env::var("PROFILE").unwrap_or_else(|_| unknown())
    );
    println!(
        "cargo:rustc-env=PURR_BUILD_TARGET={}",
        env::var("TARGET").unwrap_or_else(|_| unknown())
    );
    println!(
        "cargo:rustc-env=PURR_BUILD_HOST={}",
        env::var("HOST").unwrap_or_else(|_| unknown())
    );
    println!("cargo:rustc-env=PURR_BUILD_TIMESTAMP={}", build_timestamp());
    println!("cargo:rustc-env=PURR_RUSTC_VERSION={}", rustc_version());
}

/// Short commit hash with a `(clean)`/`(dirty)` suffix, or `unknown` when git
/// isn't available (e.g. building from a published crate tarball).
fn git_commit_display() -> String {
    let Some(hash) = run_git(&["rev-parse", "--short", "HEAD"]) else {
        return "unknown".to_string();
    };
    let dirty = run_git(&["status", "--porcelain"])
        .map(|s| !s.trim().is_empty())
        .unwrap_or(false);
    let state = if dirty { "dirty" } else { "clean" };
    format!("{hash} ({state})")
}

/// Run `git <args>` and return trimmed stdout, or `None` on any failure.
fn run_git(args: &[&str]) -> Option<String> {
    let output = Command::new("git").args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
}

/// Compile timestamp as `YYYY-MM-DD HH:MM:SS UTC`. Honours `SOURCE_DATE_EPOCH`
/// for reproducible builds, otherwise uses the current time.
fn build_timestamp() -> String {
    let secs = env::var("SOURCE_DATE_EPOCH")
        .ok()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or_else(|| {
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0)
        });
    format_utc(secs)
}

/// Format Unix `secs` as `YYYY-MM-DD HH:MM:SS UTC` without pulling in a date
/// crate. Civil-date conversion is Howard Hinnant's `days_from_civil` inverse.
fn format_utc(secs: u64) -> String {
    let days = (secs / 86_400) as i64;
    let rem = secs % 86_400;
    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);

    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
    let y = if m <= 2 { y + 1 } else { y };

    format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02} UTC")
}

/// First line of `rustc --version`, or `unknown` if it can't be determined.
fn rustc_version() -> String {
    let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
    Command::new(rustc)
        .arg("--version")
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "unknown".to_string())
}

/// Display width of a line after stripping ANSI escape sequences. Used in
/// build.rs so we don't have to depend on the unicode-width crate at build
/// time; assumes the ASCII art is all single-cell width characters.
fn visible_width(s: &str) -> usize {
    let mut count = 0usize;
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        // `${...}` colour markers are zero visible width.
        if c == '$' && chars.peek() == Some(&'{') {
            chars.next();
            for n in chars.by_ref() {
                if n == '}' {
                    break;
                }
            }
            continue;
        }
        count += 1;
    }
    count
}
