//! ```text
//! NAME
//!         xtask-bump-check
//!
//! SYNOPSIS
//!         xtask-bump-check --base-rev <REV> --head-rev <REV>
//!
//! DESCRIPTION
//!         Checks if there is any member got changed since a base commit
//!         but forgot to bump its version.
//! ```

#![allow(clippy::print_stdout)] // Fine for build utilities

use std::collections::HashMap;
use std::fmt::Write;
use std::fs;

use cargo::CargoResult;
use cargo::core::Package;
use cargo::core::Registry;
use cargo::core::SourceId;
use cargo::core::Workspace;
use cargo::core::dependency::Dependency;
use cargo::sources::IndexSummary;
use cargo::sources::source::QueryKind;
use cargo::util::cache_lock::CacheLockMode;
use cargo::util::command_prelude::*;
use cargo_util::ProcessBuilder;

const UPSTREAM_BRANCH: &str = "master";
const STATUS: &str = "BumpCheck";

pub fn cli() -> clap::Command {
    clap::Command::new("xtask-bump-check")
        .arg(
            opt(
                "verbose",
                "Use verbose output (-vv very verbose/build.rs output)",
            )
            .short('v')
            .action(ArgAction::Count)
            .global(true),
        )
        .arg(
            flag("quiet", "Do not print cargo log messages")
                .short('q')
                .global(true),
        )
        .arg(
            opt("color", "Coloring: auto, always, never")
                .value_name("WHEN")
                .global(true),
        )
        .arg(opt("base-rev", "Git revision to lookup for a baseline"))
        .arg(opt("head-rev", "Git revision with changes"))
        .arg(flag("frozen", "Require Cargo.lock and cache to be up-to-date").global(true))
        .arg(flag("locked", "Require Cargo.lock to be up-to-date").global(true))
        .arg(flag("offline", "Run without accessing the network").global(true))
        .arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true))
        .arg(flag("github", "Group output using GitHub's syntax"))
        .arg(
            Arg::new("unstable-features")
                .help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details")
                .short('Z')
                .value_name("FLAG")
                .action(ArgAction::Append)
                .global(true),
        )
}

pub fn exec(args: &clap::ArgMatches, gctx: &mut cargo::util::GlobalContext) -> cargo::CliResult {
    global_context_configure(gctx, args)?;

    bump_check(args, gctx)?;

    Ok(())
}

fn global_context_configure(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
    let verbose = args.verbose();
    // quiet is unusual because it is redefined in some subcommands in order
    // to provide custom help text.
    let quiet = args.flag("quiet");
    let color = args.get_one::<String>("color").map(String::as_str);
    let frozen = args.flag("frozen");
    let locked = args.flag("locked");
    let offline = args.flag("offline");
    let mut unstable_flags = vec![];
    if let Some(values) = args.get_many::<String>("unstable-features") {
        unstable_flags.extend(values.cloned());
    }
    let mut config_args = vec![];
    if let Some(values) = args.get_many::<String>("config") {
        config_args.extend(values.cloned());
    }
    gctx.configure(
        verbose,
        quiet,
        color,
        frozen,
        locked,
        offline,
        &None,
        &unstable_flags,
        &config_args,
    )?;
    Ok(())
}

/// Main entry of `xtask-bump-check`.
///
/// Assumption: version number are incremental. We never have point release for old versions.
fn bump_check(args: &clap::ArgMatches, gctx: &cargo::util::GlobalContext) -> CargoResult<()> {
    let ws = args.workspace(gctx)?;
    let repo = git2::Repository::open(ws.root())?;
    let base_commit = get_base_commit(gctx, args, &repo)?;
    let head_commit = get_head_commit(args, &repo)?;
    let referenced_commit = get_referenced_commit(&repo, &base_commit)?;
    let github = args.get_flag("github");
    let status = |msg: &str| gctx.shell().status(STATUS, msg);

    let crates_not_check_against_channels = [
        // High false positive rate between beta branch and requisite version bump soon after
        //
        // Low risk because we always bump the "major" version after beta branch; we are
        // only losing out on checks for patch releases.
        //
        // Note: this is already skipped in `changed`
        "cargo",
        // Don't check against beta and stable branches,
        // as the publish of these crates are not tied with Rust release process.
        // See `TO_PUBLISH` in publish.py.
        "home",
    ];
    // Skip both the version-bump check and SemVer checks entirely.
    //
    // Use this for crates with no prior published release to baseline against.
    let crates_not_checked: [&str; 0] = [];

    status(&format!("base commit `{}`", base_commit.id()))?;
    status(&format!("head commit `{}`", head_commit.id()))?;

    let mut needs_bump = Vec::new();
    if github {
        println!("::group::Checking for bumps of changed packages");
    }
    let changed_members = changed(&ws, &repo, &base_commit, &head_commit)?;
    check_crates_io(&ws, &changed_members, &mut needs_bump)?;
    if let Some(referenced_commit) = referenced_commit.as_ref() {
        status(&format!("compare against `{}`", referenced_commit.id()))?;
        for referenced_member in checkout_ws(&ws, &repo, referenced_commit)?.members() {
            let pkg_name = referenced_member.name().as_str();

            if crates_not_check_against_channels.contains(&pkg_name) {
                continue;
            }

            if crates_not_checked.contains(&pkg_name) {
                continue;
            }

            let Some(changed_member) = changed_members.get(pkg_name) else {
                tracing::trace!("skipping {pkg_name}, may be removed or not published");
                continue;
            };

            if changed_member.version() <= referenced_member.version() {
                needs_bump.push(*changed_member);
            }
        }
    }
    if !needs_bump.is_empty() {
        needs_bump.sort();
        needs_bump.dedup();
        let mut msg = String::new();
        msg.push_str("Detected changes in these crates but no version bump found:\n");
        for pkg in needs_bump {
            writeln!(&mut msg, "  {}@{}", pkg.name(), pkg.version())?;
        }
        msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml.");
        anyhow::bail!(msg)
    }
    if github {
        println!("::endgroup::");
    }

    if let Some(referenced_commit) = referenced_commit.as_ref() {
        if github {
            println!("::group::SemVer Checks against {}", referenced_commit.id());
        }
        let mut cmd = ProcessBuilder::new("cargo");
        cmd.arg("semver-checks")
            .arg("--workspace")
            .arg("--baseline-rev")
            .arg(referenced_commit.id().to_string());
        for krate in crates_not_checked {
            cmd.args(&["--exclude", krate]);
        }
        for krate in crates_not_check_against_channels {
            cmd.args(&["--exclude", krate]);
        }
        gctx.shell().status("Running", &cmd)?;
        cmd.exec()?;
        if github {
            println!("::endgroup::");
        }
    }

    // Even when we test against baseline-rev, we still need to make sure a
    // change doesn't violate SemVer rules against crates.io releases. The
    // possibility of this happening is nearly zero but no harm to check twice.
    if github {
        println!("::group::SemVer Checks against crates.io");
    }

    let mut cmd = ProcessBuilder::new("cargo");
    cmd.arg("semver-checks")
        .arg("check-release")
        .arg("--workspace")
        .args(&["--exclude", "cargo"]);
    for krate in crates_not_checked {
        cmd.args(&["--exclude", krate]);
    }

    gctx.shell().status("Running", &cmd)?;
    cmd.exec()?;

    // Cargo has mutually exclusive features for different HTTP backends, so
    // pass a specific `--features` instead of including this in the
    // `--all-features` performed by the previous command.
    let mut cmd = ProcessBuilder::new("cargo");
    cmd.arg("semver-checks")
        .arg("check-release")
        .args(&["--package", "cargo"])
        .arg("--default-features")
        .args(&["--features", "all-static"]);

    gctx.shell().status("Running", &cmd)?;
    cmd.exec()?;

    if github {
        println!("::endgroup::");
    }

    status("no version bump needed for member crates.")?;

    Ok(())
}

/// Returns the commit of upstream `master` branch if `base-rev` is missing.
fn get_base_commit<'a>(
    gctx: &GlobalContext,
    args: &clap::ArgMatches,
    repo: &'a git2::Repository,
) -> CargoResult<git2::Commit<'a>> {
    let base_commit = match args.get_one::<String>("base-rev") {
        Some(sha) => {
            let obj = repo.revparse_single(sha)?;
            obj.peel_to_commit()?
        }
        None => {
            let upstream_branches = repo
                .branches(Some(git2::BranchType::Remote))?
                .filter_map(|r| r.ok())
                .filter(|(b, _)| {
                    b.name()
                        .ok()
                        .flatten()
                        .unwrap_or_default()
                        .ends_with(&format!("/{UPSTREAM_BRANCH}"))
                })
                .map(|(b, _)| b)
                .collect::<Vec<_>>();
            if upstream_branches.is_empty() {
                anyhow::bail!(
                    "could not find `base-sha` for `{UPSTREAM_BRANCH}`, pass it in directly"
                );
            }
            let upstream_ref = upstream_branches[0].get();
            if upstream_branches.len() > 1 {
                let name = upstream_ref.name().expect("name is valid UTF-8");
                let _ = gctx.shell().warn(format!(
                    "multiple `{UPSTREAM_BRANCH}` found, picking {name}"
                ));
            }
            upstream_ref.peel_to_commit()?
        }
    };
    Ok(base_commit)
}

/// Returns `HEAD` of the Git repository if `head-rev` is missing.
fn get_head_commit<'a>(
    args: &clap::ArgMatches,
    repo: &'a git2::Repository,
) -> CargoResult<git2::Commit<'a>> {
    let head_commit = match args.get_one::<String>("head-rev") {
        Some(sha) => {
            let head_obj = repo.revparse_single(sha)?;
            head_obj.peel_to_commit()?
        }
        None => {
            let head_ref = repo.head()?;
            head_ref.peel_to_commit()?
        }
    };
    Ok(head_commit)
}

/// Gets the referenced commit to compare if version bump needed.
///
/// * When merging into nightly, check the version with beta branch
/// * When merging into beta, check the version with stable branch
/// * When merging into stable, check against crates.io registry directly
fn get_referenced_commit<'a>(
    repo: &'a git2::Repository,
    base: &git2::Commit<'a>,
) -> CargoResult<Option<git2::Commit<'a>>> {
    let [beta, stable] = beta_and_stable_branch(repo)?;
    let rev_id = base.id();
    let stable_commit = stable.get().peel_to_commit()?;
    let beta_commit = beta.get().peel_to_commit()?;

    let referenced_commit = if rev_id == stable_commit.id() {
        None
    } else if rev_id == beta_commit.id() {
        tracing::trace!("stable branch from `{}`", stable.name().unwrap().unwrap());
        Some(stable_commit)
    } else {
        tracing::trace!("beta branch from `{}`", beta.name().unwrap().unwrap());
        Some(beta_commit)
    };

    Ok(referenced_commit)
}

/// Get the current beta and stable branch in cargo repository.
///
/// Assumptions:
///
/// * The repository contains the full history of `<remote>/rust-1.*.0` branches.
/// * The version part of `<remote>/rust-1.*.0` always ends with a zero.
/// * The maximum version is for beta channel, and the second one is for stable.
fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> {
    let mut release_branches = Vec::new();
    for branch in repo.branches(Some(git2::BranchType::Remote))? {
        let (branch, _) = branch?;
        let name = branch.name()?.unwrap();
        let Some((_, version)) = name.split_once("/rust-") else {
            tracing::trace!("branch `{name}` is not in the format of `<remote>/rust-<semver>`");
            continue;
        };
        let Ok(version) = version.parse::<semver::Version>() else {
            tracing::trace!("branch `{name}` is not a valid semver: `{version}`");
            continue;
        };
        release_branches.push((version, branch));
    }
    release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0));
    release_branches.dedup_by(|a, b| a.0 == b.0);

    let beta = release_branches.pop().unwrap();
    let stable = release_branches.pop().unwrap();

    assert_eq!(beta.0.major, 1);
    assert_eq!(beta.0.patch, 0);
    assert_eq!(stable.0.major, 1);
    assert_eq!(stable.0.patch, 0);
    assert_ne!(beta.0.minor, stable.0.minor);

    Ok([beta.1, stable.1])
}

/// Lists all changed workspace members between two commits.
fn changed<'r, 'ws>(
    ws: &'ws Workspace<'_>,
    repo: &'r git2::Repository,
    base_commit: &git2::Commit<'r>,
    head: &git2::Commit<'r>,
) -> CargoResult<HashMap<&'ws str, &'ws Package>> {
    let root_pkg_name = ws.current()?.name(); // `cargo` crate.
    let ws_members = ws
        .members()
        .filter(|pkg| pkg.name() != root_pkg_name) // Only take care of sub crates here.
        .filter(|pkg| pkg.publish() != &Some(vec![])) // filter out `publish = false`
        .map(|pkg| {
            // Having relative package root path so that we can compare with
            // paths of changed files to determine which package has changed.
            let relative_pkg_root = pkg.root().strip_prefix(ws.root()).unwrap();
            (relative_pkg_root, pkg)
        })
        .collect::<Vec<_>>();

    let diff = symmetric_diff(repo, base_commit, head)?;
    let mut changed_members = HashMap::new();

    for delta in diff.deltas() {
        let old = delta.old_file().path().unwrap();
        let new = delta.new_file().path().unwrap();
        for (pkg_root, pkg) in ws_members.iter() {
            if old.starts_with(pkg_root) || new.starts_with(pkg_root) {
                changed_members.insert(pkg.name().as_str(), *pkg);
                break;
            }
        }
    }

    tracing::trace!("changed_members: {:?}", changed_members.keys());
    Ok(changed_members)
}

/// Using a "symmetric difference" between base and head.
fn symmetric_diff<'a>(
    repo: &'a git2::Repository,
    base: &'a git2::Commit<'a>,
    head: &'a git2::Commit<'a>,
) -> CargoResult<git2::Diff<'a>> {
    let ancestor_oid = repo.merge_base(base.id(), head.id())?;
    let ancestor_commit = repo.find_commit(ancestor_oid)?;
    let ancestor_tree = ancestor_commit.as_object().peel_to_tree()?;
    let head_tree = head.as_object().peel_to_tree()?;
    let diff =
        repo.diff_tree_to_tree(Some(&ancestor_tree), Some(&head_tree), Default::default())?;
    tracing::info!(merge_base = %ancestor_commit.id(), base = %base.id(), head = %head.id(), "git diff base...head");
    Ok(diff)
}

/// Compares version against published crates on crates.io.
///
/// Assumption: We always release a version larger than all existing versions.
fn check_crates_io<'a>(
    ws: &Workspace<'a>,
    changed_members: &HashMap<&'a str, &'a Package>,
    needs_bump: &mut Vec<&'a Package>,
) -> CargoResult<()> {
    let gctx = ws.gctx();
    let source_id = SourceId::crates_io(gctx)?;
    let mut registry = ws.package_registry()?;
    let _lock = gctx.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
    registry.lock_patches();
    gctx.shell().status(
        STATUS,
        format_args!("compare against `{}`", source_id.display_registry_name()),
    )?;
    for (name, member) in changed_members {
        let current = member.version();
        let version_req = format!(">={current}");
        let query = Dependency::parse(*name, Some(&version_req), source_id)?;
        // Exact to avoid returning all for path/git
        let possibilities =
            futures::executor::block_on(registry.query_vec(&query, QueryKind::Exact))?
                .into_iter()
                .filter_map(|s| match s {
                    IndexSummary::Candidate(s) => Some(s),
                    _ => None,
                })
                .collect::<Vec<_>>();
        if possibilities.is_empty() {
            tracing::trace!("dep `{name}` has no version greater than or equal to `{current}`");
        } else {
            tracing::trace!(
                "`{name}@{current}` needs a bump because its should have a version newer than crates.io: {:?}`",
                possibilities
                    .iter()
                    .map(|s| format!("{}@{}", s.name(), s.version()))
                    .collect::<Vec<_>>(),
            );
            needs_bump.push(member);
        }
    }

    Ok(())
}

/// Checkouts a temporary workspace to do further version comparisons.
fn checkout_ws<'gctx, 'a>(
    ws: &Workspace<'gctx>,
    repo: &'a git2::Repository,
    referenced_commit: &git2::Commit<'a>,
) -> CargoResult<Workspace<'gctx>> {
    let repo_path = repo.path().as_os_str().to_str().unwrap();
    // Put it under `target/cargo-<short-id>`
    let short_id = &referenced_commit.id().to_string()[..7];
    let checkout_path = ws.target_dir().join(format!("cargo-{short_id}"));
    let checkout_path = checkout_path.as_path_unlocked();
    let _ = fs::remove_dir_all(checkout_path);
    let new_repo = git2::build::RepoBuilder::new()
        .clone_local(git2::build::CloneLocal::Local)
        .clone(repo_path, checkout_path)?;
    let obj = new_repo.find_object(referenced_commit.id(), None)?;
    new_repo.reset(&obj, git2::ResetType::Hard, None)?;
    Workspace::new(&checkout_path.join("Cargo.toml"), ws.gctx())
}

#[test]
fn verify_cli() {
    cli().debug_assert();
}
