use std::{
    env, fs, io,
    path::{Path, PathBuf},
    process::ExitCode,
    time::{Duration, SystemTime},
};

use walkdir::WalkDir;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Profile {
    Debug,
    Release,
}

impl Profile {
    fn as_str(self) -> &'static str {
        match self {
            Profile::Debug => "debug",
            Profile::Release => "release",
        }
    }
}

#[derive(Debug)]
struct Config {
    apply: bool,
    days: u64,
    target_dir: PathBuf,
    profiles: Vec<Profile>,
    include_artifacts: bool,
    keep_incremental: bool,
    verbose: bool,
}

#[derive(Debug)]
struct Candidate {
    path: PathBuf,
    kind: &'static str,
    reason: &'static str,
    bytes: u64,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<(), String> {
    let config = parse_args(env::args().skip(1))?;
    let cutoff = SystemTime::now()
        .checked_sub(Duration::from_secs(config.days * 24 * 60 * 60))
        .ok_or("invalid --days value")?;
    let candidates = collect_candidates(&config, cutoff)?;
    print_report(&config, &candidates);

    if config.apply {
        apply_candidates(&candidates)?;
    }

    Ok(())
}

fn parse_args(args: impl IntoIterator<Item = String>) -> Result<Config, String> {
    let mut config = Config {
        apply: false,
        days: 7,
        target_dir: env::var_os("CARGO_TARGET_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("target")),
        profiles: vec![Profile::Debug],
        include_artifacts: false,
        keep_incremental: false,
        verbose: false,
    };

    let mut args = args.into_iter();
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--apply" => config.apply = true,
            "--include-artifacts" => config.include_artifacts = true,
            "--keep-incremental" => config.keep_incremental = true,
            "--verbose" => config.verbose = true,
            "--days" => {
                let value = args
                    .next()
                    .ok_or_else(|| "--days requires a number".to_string())?;
                config.days = value
                    .parse()
                    .map_err(|_| format!("invalid --days value: {value}"))?;
            }
            "--profile" => {
                let value = args
                    .next()
                    .ok_or_else(|| "--profile requires debug, release, or all".to_string())?;
                config.profiles = match value.as_str() {
                    "debug" | "dev" => vec![Profile::Debug],
                    "release" => vec![Profile::Release],
                    "all" => vec![Profile::Debug, Profile::Release],
                    _ => return Err(format!("invalid --profile value: {value}")),
                };
            }
            "--target-dir" => {
                config.target_dir = args
                    .next()
                    .map(PathBuf::from)
                    .ok_or_else(|| "--target-dir requires a path".to_string())?;
            }
            "-h" | "--help" => {
                print_help();
                std::process::exit(0);
            }
            _ => return Err(format!("unknown argument: {arg}")),
        }
    }

    Ok(config)
}

fn collect_candidates(config: &Config, cutoff: SystemTime) -> Result<Vec<Candidate>, String> {
    let mut candidates = Vec::new();

    if !config.target_dir.exists() {
        return Ok(candidates);
    }

    collect_flycheck_dirs(config, cutoff, &mut candidates)?;

    for profile in &config.profiles {
        let profile_dir = config.target_dir.join(profile.as_str());
        if !profile_dir.exists() {
            continue;
        }

        if !config.keep_incremental {
            let incremental = profile_dir.join("incremental");
            if incremental.exists() {
                push_candidate(
                    &mut candidates,
                    incremental,
                    "incremental",
                    "safe Cargo incremental cache",
                )?;
            }
        }

        if config.include_artifacts {
            collect_stale_children(
                &profile_dir.join("deps"),
                cutoff,
                "deps",
                "stale hashed dependency artifact",
                &mut candidates,
            )?;
            collect_stale_children(
                &profile_dir.join("build"),
                cutoff,
                "build",
                "stale build-script output",
                &mut candidates,
            )?;
            collect_stale_children(
                &profile_dir.join(".fingerprint"),
                cutoff,
                "fingerprint",
                "stale Cargo fingerprint",
                &mut candidates,
            )?;
            collect_stale_children(
                &profile_dir.join("examples"),
                cutoff,
                "examples",
                "stale example artifact",
                &mut candidates,
            )?;
        }
    }

    candidates.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(candidates)
}

fn collect_flycheck_dirs(
    config: &Config,
    cutoff: SystemTime,
    candidates: &mut Vec<Candidate>,
) -> Result<(), String> {
    let entries = fs::read_dir(&config.target_dir)
        .map_err(|error| format!("failed to read {}: {error}", config.target_dir.display()))?;
    for entry in entries {
        let entry = entry.map_err(|error| format!("failed to read target entry: {error}"))?;
        let file_name = entry.file_name();
        let file_name = file_name.to_string_lossy();
        if file_name.starts_with("flycheck") && is_stale(&entry.path(), cutoff).unwrap_or(false) {
            push_candidate(
                candidates,
                entry.path(),
                "flycheck",
                "stale rust-analyzer flycheck target dir",
            )?;
        }
    }
    Ok(())
}

fn collect_stale_children(
    dir: &Path,
    cutoff: SystemTime,
    kind: &'static str,
    reason: &'static str,
    candidates: &mut Vec<Candidate>,
) -> Result<(), String> {
    if !dir.exists() {
        return Ok(());
    }

    let entries =
        fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
    for entry in entries {
        let entry =
            entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
        let path = entry.path();
        if is_stale(&path, cutoff).unwrap_or(false) {
            push_candidate(candidates, path, kind, reason)?;
        }
    }
    Ok(())
}

fn push_candidate(
    candidates: &mut Vec<Candidate>,
    path: PathBuf,
    kind: &'static str,
    reason: &'static str,
) -> Result<(), String> {
    let bytes =
        path_size(&path).map_err(|error| format!("failed to size {}: {error}", path.display()))?;
    candidates.push(Candidate {
        path,
        kind,
        reason,
        bytes,
    });
    Ok(())
}

fn is_stale(path: &Path, cutoff: SystemTime) -> io::Result<bool> {
    let modified = fs::metadata(path)?.modified()?;
    Ok(modified < cutoff)
}

fn path_size(path: &Path) -> io::Result<u64> {
    let metadata = fs::symlink_metadata(path)?;
    if metadata.is_file() {
        return Ok(metadata.len());
    }

    let mut bytes = 0;
    for entry in WalkDir::new(path).follow_links(false) {
        let entry = entry?;
        let metadata = entry.metadata()?;
        if metadata.is_file() {
            bytes += metadata.len();
        }
    }
    Ok(bytes)
}

fn print_report(config: &Config, candidates: &[Candidate]) {
    let total: u64 = candidates.iter().map(|candidate| candidate.bytes).sum();
    let mode = if config.apply { "APPLY" } else { "DRY RUN" };
    println!(
        "{mode}: {} candidate(s), {} reclaimable in {}",
        candidates.len(),
        format_bytes(total),
        config.target_dir.display()
    );
    println!(
        "profiles={}, days={}, include_artifacts={}, incremental={}",
        config
            .profiles
            .iter()
            .map(|profile| profile.as_str())
            .collect::<Vec<_>>()
            .join(","),
        config.days,
        config.include_artifacts,
        !config.keep_incremental
    );

    print_kind_summary(candidates);

    let mut sorted: Vec<_> = candidates.iter().collect();
    sorted.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
    let limit = if config.verbose { sorted.len() } else { 50 };
    for candidate in sorted.iter().take(limit) {
        println!(
            "{:>10}  {:<12}  {}  ({})",
            format_bytes(candidate.bytes),
            candidate.kind,
            candidate.path.display(),
            candidate.reason
        );
    }
    if sorted.len() > limit {
        println!(
            "... {} more candidate(s) omitted; rerun with --verbose to list all.",
            sorted.len() - limit
        );
    }

    if !config.apply {
        println!();
        println!("No files were deleted. Add --apply to delete these candidates.");
        if !config.include_artifacts {
            println!(
                "Add --include-artifacts to also prune stale deps/build/.fingerprint entries."
            );
        }
    }
}

fn print_kind_summary(candidates: &[Candidate]) {
    let mut rows: Vec<(&str, usize, u64)> = Vec::new();
    for candidate in candidates {
        if let Some((_, count, bytes)) =
            rows.iter_mut().find(|(kind, _, _)| *kind == candidate.kind)
        {
            *count += 1;
            *bytes += candidate.bytes;
        } else {
            rows.push((candidate.kind, 1, candidate.bytes));
        }
    }
    rows.sort_by_key(|row| std::cmp::Reverse(row.2));
    for (kind, count, bytes) in rows {
        println!(
            "summary: {:<12} {:>5} item(s), {}",
            kind,
            count,
            format_bytes(bytes)
        );
    }
}

fn apply_candidates(candidates: &[Candidate]) -> Result<(), String> {
    for candidate in candidates {
        let metadata = fs::symlink_metadata(&candidate.path)
            .map_err(|error| format!("failed to inspect {}: {error}", candidate.path.display()))?;
        if metadata.is_dir() {
            fs::remove_dir_all(&candidate.path).map_err(|error| {
                format!("failed to remove {}: {error}", candidate.path.display())
            })?;
        } else {
            fs::remove_file(&candidate.path).map_err(|error| {
                format!("failed to remove {}: {error}", candidate.path.display())
            })?;
        }
    }
    Ok(())
}

fn format_bytes(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
    let mut value = bytes as f64;
    let mut unit = UNITS[0];
    for next in &UNITS[1..] {
        if value < 1024.0 {
            break;
        }
        value /= 1024.0;
        unit = next;
    }
    if unit == "B" {
        format!("{bytes} B")
    } else {
        format!("{value:.1} {unit}")
    }
}

fn print_help() {
    println!(
        "\
Prune Cargo target cache for this workspace.

USAGE:
    cargo run -p xtask --bin clean-target -- [OPTIONS]

OPTIONS:
    --apply                 Delete candidates. Without this, only prints a dry run.
    --days N                Staleness cutoff for artifact caches [default: 7].
    --profile PROFILE       debug, release, or all [default: debug].
    --target-dir PATH       Target directory [default: CARGO_TARGET_DIR or target].
    --include-artifacts     Also prune stale deps/build/.fingerprint/example artifacts.
    --keep-incremental      Do not remove incremental cache.
    --verbose               List every candidate instead of the largest 50.
    -h, --help              Print help.

EXAMPLES:
    cargo run -p xtask --bin clean-target --
    cargo run -p xtask --bin clean-target -- --apply
    cargo run -p xtask --bin clean-target -- --include-artifacts --days 3 --apply
"
    );
}
