// SPDX-License-Identifier: Apache-2.0 OR MIT

use std::path::PathBuf;

use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::io::IsTerminal;

use hs_relmon::cbs;
use hs_relmon::check_latest::{self, Distros, TrackRef};
use hs_relmon::config;
use hs_relmon::dupe_subpkgs;
use hs_relmon::file_conflicts;
use hs_relmon::gitlab;
use hs_relmon::list_issues;
use hs_relmon::manifest;
use hs_relmon::prune_archived;
use hs_relmon::prune_tags;
use hs_relmon::repology;
use hs_relmon::review;

#[derive(Parser)]
#[command(
    name = "hs-relmon",
    version,
    about,
    long_about = None,
    before_help = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"))
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Check the latest version of a package across distributions.
    CheckLatest {
        /// Source package name (e.g. ethtool).
        package: String,

        /// Comma-separated list of distros to check.
        #[arg(
            short,
            long,
            long_help = "\
Comma-separated list of distros to check.

Valid names:
  upstream         Newest version across all repos
  fedora           Fedora Rawhide + latest stable
  fedora-rawhide   Fedora Rawhide only
  fedora-stable    Latest stable Fedora only
  centos           Latest CentOS Stream
  centos-stream    Latest CentOS Stream
  hyperscale / hs  Hyperscale EL9 + EL10
  hs9              Hyperscale EL9 only
  hs10             Hyperscale EL10 only"
        )]
        distros: Option<String>,

        /// Override Repology project name.
        #[arg(
            long,
            value_name = "PROJECT",
            long_help = "\
Override Repology project name when it differs
from the package (e.g. linux for perf)."
        )]
        repology_name: Option<String>,

        /// Reference distribution.
        #[arg(
            long,
            default_value = "upstream",
            long_help = "\
Distribution to compare Hyperscale builds against.

Valid names:
  upstream         Newest version across all repos (default)
  fedora-rawhide   Fedora Rawhide
  fedora-stable    Latest stable Fedora
  centos           Latest CentOS Stream
  centos-stream    Latest CentOS Stream"
        )]
        track: String,

        /// Output as JSON instead of a table.
        #[arg(long)]
        json: bool,

        /// File a GitLab issue if outdated.
        #[arg(long, num_args = 0..=1, default_missing_value = "",
            value_name = "URL", long_help = "\
File or update a GitLab issue if the package is
outdated. Searches for an open issue labeled
rfe::new-version and updates its title, or creates
a new one. Defaults to
https://gitlab.com/CentOS/Hyperscale/rpms/PKG.
Uses the token from 'hs-relmon config', or
GITLAB_TOKEN env var as an override.")]
        file_issue: Option<String>,
    },

    /// Check all packages listed in a manifest file.
    CheckManifest {
        /// Path to the TOML manifest file.
        manifest: PathBuf,

        /// Output as JSON instead of tables.
        #[arg(long)]
        json: bool,

        /// Only show packages whose issue matches this status.
        #[arg(
            long,
            value_name = "STATUS",
            long_help = "\
Only show packages whose GitLab issue matches this
work-item status. Packages without an issue are
excluded. Issues that do not match are not updated.

Default statuses:
  To do          Planned but not started
  In progress    Currently being worked on
  Done           Completed
  Canceled       Will not be done"
        )]
        issue_status: Option<String>,

        /// Only show packages whose issue is assigned to this user.
        #[arg(
            long,
            value_name = "USERNAME",
            long_help = "\
Only show packages whose GitLab issue is assigned
to this username. Use \"none\" to match unassigned
issues. Packages without an issue or without a
matching assignee are excluded."
        )]
        issue_assignee: Option<String>,
    },

    /// Find binary RPMs shipped by 2+ source packages in a tag.
    DupeSubpkgs {
        /// Comma-separated Hyperscale repositories to scan.
        #[arg(
            long,
            default_value = prune_tags::DEFAULT_REPOSITORY,
            long_help = "\
Comma-separated Hyperscale repositories to scan.
The repository is the segment between `-packages-`
and the stage suffix, e.g. `main` in
`hyperscale10s-packages-main-release`. Each
repository's `-release` and `-testing` tags across
EL9/EL10 (and the Stream variants) are scanned."
        )]
        repositories: String,

        /// Hyperscale release(s) to scan (repeatable or CSV).
        #[arg(
            long,
            value_delimiter = ',',
            value_name = "RELEASE",
            long_help = "\
Hyperscale release(s) to scan, repeatable or comma-
separated. Each value is a short EL token (`9`, `9s`,
`10`, `10s`) or the equivalent full release name
(`hyperscale9s`, `hyperscale10s`, ...) — both forms
mean the same release. Defaults to all four."
        )]
        release: Vec<String>,

        /// Source package(s) to limit the report to (repeatable
        /// or CSV).
        #[arg(
            long,
            value_delimiter = ',',
            value_name = "PACKAGE",
            long_help = "\
Source package name(s), repeatable or comma-separated.
Only collisions that involve one of these sources are
reported (the whole tag is still scanned to find what
they collide with). Defaults to all packages."
        )]
        package: Vec<String>,

        /// Interactively untag the redundant build of each
        /// collision.
        #[arg(
            long,
            long_help = "\
For each collision, show the candidate builds and the
binaries only each one provides (which would vanish
from the tag if it were untagged), then prompt for
which build to untag. Defaults to skipping. Requires
CBS write authentication. In --json mode or without a
terminal the plan is printed and nothing is untagged."
        )]
        fix: bool,

        /// Output as JSON instead of a table.
        #[arg(long)]
        json: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// Find files shipped by 2+ sources across enabled repos.
    FileConflicts {
        /// Comma-separated repositories enabled together to scan.
        #[arg(
            long,
            long_help = "\
Comma-separated repositories that are enabled together
on a host, compared per EL version. Overrides the
per-EL defaults (main + kernel on EL10/10s; main only
on EL9/9s, which has no kernel repo). The `-release`
tag of each is combined and files owned by two or more
distinct source packages are flagged."
        )]
        repositories: Option<String>,

        /// Hyperscale release(s) to scan (repeatable or CSV).
        #[arg(
            long,
            value_delimiter = ',',
            value_name = "RELEASE",
            long_help = "\
Hyperscale release(s) to scan, repeatable or comma-
separated. Each value is a short EL token (`9`, `9s`,
`10`, `10s`) or the equivalent full release name
(`hyperscale9s`, `hyperscale10s`, ...) — both forms
mean the same release. Defaults to all four."
        )]
        release: Vec<String>,

        /// Source package(s) to limit the report to (repeatable
        /// or CSV).
        #[arg(
            long,
            value_delimiter = ',',
            value_name = "PACKAGE",
            long_help = "\
Source package name(s), repeatable or comma-separated.
Only conflicts that involve one of these sources are
reported (the whole repo set is still scanned to find
what they conflict with). Defaults to all packages."
        )]
        package: Vec<String>,

        /// Output as JSON instead of a table.
        #[arg(long)]
        json: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// List GitLab issues labeled rfe::new-version.
    ListIssues {
        /// GitLab group URL to search.
        #[arg(long, default_value = "https://gitlab.com/CentOS/Hyperscale/rpms")]
        group: String,

        /// Only show issues matching this status.
        #[arg(
            long,
            value_name = "STATUS",
            long_help = "\
Only show issues matching this work-item status.

Default statuses:
  To do          Planned but not started
  In progress    Currently being worked on
  Done           Completed
  Canceled       Will not be done"
        )]
        issue_status: Option<String>,

        /// Only show issues assigned to this user.
        #[arg(
            long,
            value_name = "USERNAME",
            long_help = "\
Only show issues assigned to this username.
Use \"none\" to match unassigned issues."
        )]
        issue_assignee: Option<String>,

        /// Output as JSON instead of a table.
        #[arg(long)]
        json: bool,

        /// TOML manifest to compare against.
        #[arg(
            long,
            value_name = "PATH",
            long_help = "\
Path to a TOML manifest file. When provided, the
output shows which packages with rfe::new-version
issues are missing from the manifest."
        )]
        manifest: Option<PathBuf>,

        /// Add missing packages to the manifest.
        #[arg(
            long,
            requires = "manifest",
            long_help = "\
Add packages that have rfe::new-version issues but
are missing from the manifest. Requires --manifest.
Packages are inserted in sorted order."
        )]
        add_missing: bool,
    },

    /// Untag CBS builds for archived-upstream packages stock caught up to.
    PruneArchived {
        /// Path to the TOML manifest file.
        manifest: PathBuf,

        /// Comma-separated Hyperscale repositories to manage.
        #[arg(
            long,
            default_value = prune_tags::DEFAULT_REPOSITORY,
            long_help = "\
Comma-separated Hyperscale repositories to manage.
The repository is the segment between `-packages-`
and the stage suffix, e.g. `main` in
`hyperscale10s-packages-main-release`. Tags whose
repository is not in this list are not touched."
        )]
        repositories: String,

        /// Comma-separated packages to skip.
        #[arg(long, value_name = "LIST")]
        skip: Option<String>,

        /// Preview operations without applying them.
        #[arg(long)]
        dry_run: bool,

        /// Untag at/behind-stock without prompting; skip
        /// ahead-of-stock.
        #[arg(short, long)]
        yes: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// Untag old hyperscale builds for every package in a
    /// manifest, keeping N newest in each `-release`/`-testing`
    /// tag.
    PruneManifest {
        /// Path to the TOML manifest file.
        manifest: PathBuf,

        /// Builds to keep per `-release` tag.
        #[arg(long, default_value_t = prune_tags::DEFAULT_RELEASE_KEEP)]
        release_keep: usize,

        /// Builds to keep per `-testing` tag.
        #[arg(long, default_value_t = prune_tags::DEFAULT_TESTING_KEEP)]
        testing_keep: usize,

        /// Comma-separated Hyperscale repositories to manage.
        #[arg(
            long,
            default_value = prune_tags::DEFAULT_REPOSITORY,
            long_help = "\
Comma-separated Hyperscale repositories to manage.
The repository is the segment between `-packages-`
and the stage suffix, e.g. `main` in
`hyperscale10s-packages-main-release`. Tags whose
repository is not in this list are not touched."
        )]
        repositories: String,

        /// Comma-separated packages to skip.
        #[arg(
            long,
            value_name = "LIST",
            long_help = "\
Comma-separated package names to skip during the
batch run. Useful when one package manages its own
tag cleanup (e.g. systemd) and you don't want this
tool to touch it."
        )]
        skip: Option<String>,

        /// Preview operations without applying them.
        #[arg(long)]
        dry_run: bool,

        /// Skip the per-package confirmation prompt.
        #[arg(short, long)]
        yes: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// Untag old hyperscale builds for one package, keeping N
    /// newest in each `-release`/`-testing` tag.
    PruneTags {
        /// Source package name (e.g. ethtool).
        package: String,

        /// Builds to keep per `-release` tag.
        #[arg(long, default_value_t = prune_tags::DEFAULT_RELEASE_KEEP)]
        release_keep: usize,

        /// Builds to keep per `-testing` tag.
        #[arg(long, default_value_t = prune_tags::DEFAULT_TESTING_KEEP)]
        testing_keep: usize,

        /// Comma-separated Hyperscale repositories to manage.
        #[arg(
            long,
            default_value = prune_tags::DEFAULT_REPOSITORY,
            long_help = "\
Comma-separated Hyperscale repositories to manage.
The repository is the segment between `-packages-`
and the stage suffix, e.g. `main` in
`hyperscale10s-packages-main-release`. Tags whose
repository is not in this list are not touched."
        )]
        repositories: String,

        /// Preview operations without applying them.
        #[arg(long)]
        dry_run: bool,

        /// Skip the confirmation prompt.
        #[arg(short, long)]
        yes: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// Interactively review builds in hyperscale `-testing` tags
    /// and promote (+1), reject (-1), or skip (0) each one.
    Review {
        /// Optional package name or build NVR. With a package
        /// name, reviews its latest build in each testing tag;
        /// with an NVR, reviews that specific build; omitted,
        /// reviews everything in testing.
        target: Option<String>,

        /// Comma-separated Hyperscale repositories to scan.
        #[arg(
            long,
            default_value = prune_tags::DEFAULT_REPOSITORY,
            long_help = "\
Comma-separated Hyperscale repositories to scan.
The repository is the segment between `-packages-`
and the stage suffix, e.g. `main` in
`hyperscale10s-packages-main-testing`."
        )]
        repositories: String,

        /// Package(s) to never review (repeatable or CSV).
        #[arg(
            long,
            value_delimiter = ',',
            long_help = "\
Package name(s) to exclude from review, repeatable
or comma-separated. Use for packages with their own
release pipeline (e.g. systemd) that should never be
promoted through this tool."
        )]
        skip: Vec<String>,

        /// Changelog lines to show for a brand-new package.
        #[arg(
            long,
            default_value_t = review::DEFAULT_CHANGELOG_LINES,
            long_help = "\
For a package with nothing yet in release, show at
most this many lines of its changelog (a new package
has no prior version to diff against). Packages that
are already in release always show just the entries
newer than the released build."
        )]
        changelog_lines: usize,

        /// List the builds that would be reviewed, then exit.
        #[arg(long)]
        dry_run: bool,

        /// Print progress to stderr.
        #[arg(short, long)]
        verbose: bool,
    },

    /// Configure GitLab authentication.
    Config,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    sandogasa_cli::init();
    let cli = Cli::parse();

    match cli.command {
        Command::CheckLatest {
            package,
            distros,
            repology_name,
            track,
            json,
            file_issue,
        } => {
            let distros = match distros {
                Some(s) => Distros::parse(&s)?,
                None => Distros::all(),
            };
            let track = TrackRef::parse(&track)?;
            let repology_name = repology_name.as_deref().unwrap_or(&package);

            let repology_client = repology::Client::new();
            let cbs_client = cbs::Client::new();
            let mut result = check_latest::check(
                &repology_client,
                &cbs_client,
                &package,
                repology_name,
                &distros,
                &track,
            )?;

            if let Some(url_override) = &file_issue {
                let project_url = if url_override.is_empty() {
                    default_issue_url(&package)
                } else {
                    url_override.clone()
                };
                if let Some(reason) = project_issue_block_reason(&project_url) {
                    eprintln!("{package}: skipping issue filing ({reason})");
                } else {
                    if result.is_outdated() {
                        let issue_ref = maybe_file_issue(&package, &result, &project_url)?;
                        result.issue = Some(issue_ref);
                    } else {
                        result.issue = lookup_issue(&project_url)?;
                    }
                    if result.is_in_testing() {
                        let mut errors = 0u32;
                        maybe_set_in_progress(&mut result, &project_url, &package, &mut errors);
                        if errors > 0 {
                            return Err("failed to set issue status".into());
                        }
                    }
                    if result.is_released() {
                        let mut errors = 0u32;
                        maybe_close_issue(&mut result, &project_url, &package, &mut errors);
                        if errors > 0 {
                            return Err("failed to close issue".into());
                        }
                    }
                }
            }

            if json {
                check_latest::print_json(&result)?;
            } else {
                check_latest::print_table(&result);
            }
        }
        Command::CheckManifest {
            manifest,
            json,
            issue_status,
            issue_assignee,
        } => {
            let m = manifest::Manifest::load(&manifest)?;
            let packages = m.resolve()?;
            let filtering = issue_status.is_some() || issue_assignee.is_some();

            let repology_client = repology::Client::new();
            let cbs_client = cbs::Client::new();
            let mut results = Vec::new();
            let mut errors = 0u32;

            for pkg in &packages {
                let repology_name = pkg.repology_name.as_deref().unwrap_or(&pkg.name);

                let result = check_latest::check(
                    &repology_client,
                    &cbs_client,
                    &pkg.name,
                    repology_name,
                    &pkg.distros,
                    &pkg.track,
                );
                let mut result = match result {
                    Ok(r) => r,
                    Err(e) => {
                        eprintln!("{}: {e}", pkg.name);
                        errors += 1;
                        continue;
                    }
                };

                if pkg.file_issue {
                    let project_url = pkg
                        .issue_url
                        .as_deref()
                        .map(String::from)
                        .unwrap_or_else(|| default_issue_url(&pkg.name));
                    if let Some(reason) = project_issue_block_reason(&project_url) {
                        eprintln!("{}: skipping issue filing ({reason})", pkg.name);
                    } else {
                        if result.is_outdated() && !filtering {
                            match maybe_file_issue(&pkg.name, &result, &project_url) {
                                Ok(issue_ref) => {
                                    result.issue = Some(issue_ref);
                                }
                                Err(e) => {
                                    eprintln!("{}: filing issue: {e}", pkg.name);
                                    errors += 1;
                                }
                            }
                        } else {
                            // Look up existing issue first so
                            // we can check filters before
                            // deciding whether to file/update.
                            match lookup_issue(&project_url) {
                                Ok(found) => {
                                    result.issue = found;
                                }
                                Err(e) => {
                                    eprintln!(
                                        "{}: looking up issue: \
                                    {e}",
                                        pkg.name
                                    );
                                    errors += 1;
                                }
                            }
                            if result.is_outdated()
                                && result.matches_issue_filter(
                                    issue_status.as_deref(),
                                    issue_assignee.as_deref(),
                                )
                            {
                                match maybe_file_issue(&pkg.name, &result, &project_url) {
                                    Ok(issue_ref) => {
                                        result.issue = Some(issue_ref);
                                    }
                                    Err(e) => {
                                        eprintln!(
                                            "{}: filing issue: \
                                        {e}",
                                            pkg.name
                                        );
                                        errors += 1;
                                    }
                                }
                            }
                        }
                        // When the build is in testing,
                        // transition the issue to
                        // "In progress" so it is not
                        // mistaken for un-started work.
                        if result.is_in_testing() {
                            maybe_set_in_progress(
                                &mut result,
                                &project_url,
                                &pkg.name,
                                &mut errors,
                            );
                        }
                        // When every distro has an
                        // up-to-date release build, close
                        // the issue with a comment.
                        if result.is_released() {
                            maybe_close_issue(&mut result, &project_url, &pkg.name, &mut errors);
                        }
                    }
                }

                results.push(result);
            }

            let results: Vec<_> = if filtering {
                results
                    .into_iter()
                    .filter(|r| {
                        r.matches_issue_filter(issue_status.as_deref(), issue_assignee.as_deref())
                    })
                    .collect()
            } else {
                results
            };

            if json {
                check_latest::print_json_array(&results)?;
            } else {
                for (i, r) in results.iter().enumerate() {
                    if i > 0 {
                        println!();
                    }
                    check_latest::print_table(r);
                }
            }

            if errors > 0 {
                return Err(format!("{errors} package(s) had errors").into());
            }
        }
        Command::DupeSubpkgs {
            repositories,
            release,
            package,
            fix,
            json,
            verbose,
        } => {
            let repos = prune_tags::parse_repositories(&repositories);
            let els = resolve_releases(&release)?;
            let els: Vec<&str> = els.iter().map(String::as_str).collect();
            let packages = package;
            // Only the interactive fix path untags, so it's the only
            // one that needs CBS auth — checked before the scan so a
            // missing session fails in seconds, not minutes.
            let interactive_fix = fix && !json && std::io::stdin().is_terminal();
            if interactive_fix {
                ensure_cbs_auth(false)?;
            }
            let client = cbs::Client::new();
            let results = dupe_subpkgs::scan(
                &repos,
                &els,
                |tag| client.list_tagged_binaries(tag),
                verbose,
            );
            let results = dupe_subpkgs::filter_by_packages(results, &packages);

            if fix {
                let plans: Vec<dupe_subpkgs::TagFixPlan> = results
                    .iter()
                    .map(dupe_subpkgs::build_fix_plan)
                    .map(|p| dupe_subpkgs::filter_plan_by_packages(p, &packages))
                    .filter(|p| !p.clusters.is_empty())
                    .collect();
                if json {
                    println!("{}", serde_json::to_string_pretty(&plans)?);
                } else if interactive_fix {
                    let outcome = dupe_subpkgs::apply_fix(&plans, verbose);
                    eprintln!(
                        "\nUntagged {}, skipped {}, {} error(s).",
                        outcome.untagged, outcome.skipped, outcome.errors
                    );
                    if outcome.errors > 0 {
                        std::process::exit(1);
                    }
                } else {
                    // No terminal: print the plan, untag nothing.
                    print!("{}", dupe_subpkgs::render_fix_plans(&plans));
                    eprintln!("Not a terminal; nothing untagged. Run with a TTY to act.");
                    if !plans.is_empty() {
                        std::process::exit(1);
                    }
                }
            } else {
                if json {
                    println!(
                        "{}",
                        serde_json::to_string_pretty(&dupe_subpkgs::collision_report(&results))?
                    );
                } else {
                    print!("{}", dupe_subpkgs::render(&results));
                }
                // Non-zero exit flags collisions for CI / scripting.
                if !results.is_empty() {
                    std::process::exit(1);
                }
            }
        }
        Command::FileConflicts {
            repositories,
            release,
            package,
            json,
            verbose,
        } => {
            let repo_override = repositories.as_deref().map(prune_tags::parse_repositories);
            let els = resolve_releases(&release)?;
            let els: Vec<&str> = els.iter().map(String::as_str).collect();
            let packages = package;
            let client = cbs::Client::new();
            let results = file_conflicts::scan(
                repo_override.as_deref(),
                &els,
                |tag| client.list_tagged_binaries(tag),
                |ids| client.list_rpm_files_multi(ids),
                verbose,
            );
            let results = file_conflicts::filter_by_packages(results, &packages);
            if json {
                println!("{}", serde_json::to_string_pretty(&results)?);
            } else {
                print!("{}", file_conflicts::render(&results));
            }
            // Non-zero exit flags conflicts for CI / scripting.
            if !results.is_empty() {
                std::process::exit(1);
            }
        }
        Command::ListIssues {
            group,
            issue_status,
            issue_assignee,
            json,
            manifest,
            add_missing,
        } => {
            let client = gitlab::GroupClient::from_group_url(&group)?;
            let issues = client.list_issues(ISSUE_LABEL, None)?;

            let manifest_names = match &manifest {
                Some(path) => {
                    let m = manifest::Manifest::load(path)?;
                    let names: HashSet<String> =
                        m.packages.iter().map(|p| p.name.clone()).collect();
                    Some(names)
                }
                None => None,
            };

            let entries = list_issues::build_entries(
                &client,
                &issues,
                issue_status.as_deref(),
                issue_assignee.as_deref(),
                manifest_names.as_ref(),
            );

            if json {
                list_issues::print_json(&entries)?;
            } else {
                list_issues::print_table(&entries);
            }

            if add_missing {
                let path = manifest.as_ref().unwrap();
                let missing: Vec<String> = entries
                    .iter()
                    .filter(|e| e.in_manifest == Some(false))
                    .map(|e| e.package.clone())
                    .collect();
                if missing.is_empty() {
                    eprintln!("No missing packages to add.");
                } else {
                    manifest::add_packages_to_file(path, &missing)?;
                    eprintln!("Added {} package(s) to {}", missing.len(), path.display());
                }
            }
        }
        Command::PruneTags {
            package,
            release_keep,
            testing_keep,
            repositories,
            dry_run,
            yes,
            verbose,
        } => {
            ensure_cbs_auth(dry_run)?;
            let opts = prune_tags::PruneOptions {
                release_keep,
                testing_keep,
                repositories: prune_tags::parse_repositories(&repositories),
            };
            let client = cbs::Client::new();
            run_prune_one(&client, &package, &opts, dry_run, yes, verbose)?;
        }
        Command::PruneArchived {
            manifest: path,
            repositories,
            skip,
            dry_run,
            yes,
            verbose,
        } => {
            ensure_cbs_auth(dry_run)?;
            let opts = prune_tags::PruneOptions {
                repositories: prune_tags::parse_repositories(&repositories),
                ..prune_tags::PruneOptions::default()
            };
            let skip_set: HashSet<String> = skip
                .as_deref()
                .unwrap_or("")
                .split(',')
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(String::from)
                .collect();
            let m = manifest::Manifest::load(&path)?;
            let cbs_client = cbs::Client::new();
            let repology_client = repology::Client::new();
            let mut total_failures = 0usize;
            for pkg in &m.packages {
                // Only archived-upstream packages are in scope.
                if pkg.archived != Some(true) {
                    continue;
                }
                if skip_set.contains(&pkg.name) {
                    if verbose {
                        eprintln!("{}: skipped (--skip)", pkg.name);
                    }
                    continue;
                }
                let plan = match prune_archived::plan_for_package(
                    &cbs_client,
                    &pkg.name,
                    &opts,
                    |n| repology_client.get_project(n),
                    verbose,
                ) {
                    Ok(p) => p,
                    Err(e) => {
                        eprintln!("{}: {e}", pkg.name);
                        total_failures += 1;
                        continue;
                    }
                };
                print!("{}", prune_archived::render_plan(&plan));
                if dry_run {
                    continue;
                }
                if plan.total_untag() == 0 && plan.total_ahead() == 0 {
                    continue;
                }
                let outcome = prune_archived::apply_plan(&plan, yes, verbose);
                total_failures += outcome.errors;
            }
            if total_failures > 0 {
                std::process::exit(1);
            }
        }
        Command::PruneManifest {
            manifest: path,
            release_keep,
            testing_keep,
            repositories,
            skip,
            dry_run,
            yes,
            verbose,
        } => {
            ensure_cbs_auth(dry_run)?;
            let opts = prune_tags::PruneOptions {
                release_keep,
                testing_keep,
                repositories: prune_tags::parse_repositories(&repositories),
            };
            let skip_set: HashSet<String> = skip
                .as_deref()
                .unwrap_or("")
                .split(',')
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(String::from)
                .collect();
            let m = manifest::Manifest::load(&path)?;
            let client = cbs::Client::new();
            let mut total_failures = 0usize;
            for pkg in &m.packages {
                if skip_set.contains(&pkg.name) {
                    if verbose {
                        eprintln!("{}: skipped (--skip)", pkg.name);
                    }
                    continue;
                }
                if let Err(e) = run_prune_one(&client, &pkg.name, &opts, dry_run, yes, verbose) {
                    eprintln!("{}: {e}", pkg.name);
                    total_failures += 1;
                }
            }
            if total_failures > 0 {
                std::process::exit(1);
            }
        }
        Command::Review {
            target,
            repositories,
            skip,
            changelog_lines,
            dry_run,
            verbose,
        } => {
            let repos = prune_tags::parse_repositories(&repositories);
            review::run(
                &repos,
                target.as_deref(),
                &skip,
                changelog_lines,
                dry_run,
                verbose,
            )?;
        }
        Command::Config => {
            configure_gitlab()?;
        }
    }

    Ok(())
}

/// Resolve the `--release` selector to validated EL tokens,
/// defaulting to all Hyperscale releases when none are given.
fn resolve_releases(release: &[String]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let normalized = prune_tags::normalize_releases(release)?;
    if normalized.is_empty() {
        Ok(prune_tags::EL_TOKENS
            .iter()
            .map(|s| s.to_string())
            .collect())
    } else {
        Ok(normalized)
    }
}

/// Reason a project can't accept new issues — archived or Issues
/// feature disabled — or `None` when filing is allowed. On any
/// lookup failure returns `None` (assume allowed) so a transient
/// error doesn't silently suppress issue filing; the filing attempt
/// itself will then surface the real error.
fn project_issue_block_reason(project_url: &str) -> Option<&'static str> {
    let client = gitlab::Client::from_project_url(project_url).ok()?;
    let status = client.project_status().ok()?;
    status.issue_block_reason()
}

/// Fail fast if CBS write authentication isn't set up. Skipped for
/// dry runs (read-only) so previewing never needs credentials.
fn ensure_cbs_auth(dry_run: bool) -> Result<(), Box<dyn std::error::Error>> {
    if !dry_run {
        sandogasa_koji::check_auth(Some(prune_tags::KOJI_PROFILE))?;
    }
    Ok(())
}

fn run_prune_one(
    client: &cbs::Client,
    package: &str,
    opts: &prune_tags::PruneOptions,
    dry_run: bool,
    yes: bool,
    verbose: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let tag_builds = prune_tags::fetch_managed_tags(client, package, opts, verbose);
    let plan = prune_tags::build_plan(package, &tag_builds, opts);
    print!("{}", prune_tags::render_plan(&plan));
    let total = plan.total_untags();
    if total == 0 {
        return Ok(());
    }
    if dry_run {
        return Ok(());
    }
    if !yes && !prune_tags::confirm(&format!("Untag {total} build(s) for {package}?"))? {
        eprintln!("{package}: aborted.");
        return Ok(());
    }
    let errors = prune_tags::apply_plan(&plan, verbose);
    if errors > 0 {
        return Err(format!("{errors} untag operation(s) failed for {package}").into());
    }
    Ok(())
}

const GITLAB_BASE: &str = "https://gitlab.com";

fn configure_gitlab() -> Result<(), Box<dyn std::error::Error>> {
    let existing_token = config::load()
        .ok()
        .and_then(|c| c.gitlab.map(|g| g.access_token));

    if let Some(token) = &existing_token {
        eprint!("Validating existing token... ");
        if gitlab::validate_token(GITLAB_BASE, token)? {
            eprintln!("valid.");
            return Ok(());
        }
        eprintln!("invalid.");
    }

    let token = rpassword::prompt_password(
        "Paste a GitLab personal access token \
        with 'api' scope: ",
    )?;
    if token.is_empty() {
        return Err("no token provided".into());
    }

    eprint!("Validating token... ");
    if !gitlab::validate_token(GITLAB_BASE, &token)? {
        return Err("token is invalid".into());
    }
    eprintln!("valid.");

    let cfg = config::Config {
        gitlab: Some(config::GitlabConfig {
            access_token: token,
        }),
    };
    let path = config::config_path()?;
    config::save(&cfg)?;
    eprintln!("Saved to {}", path.display());

    Ok(())
}

fn default_issue_url(package: &str) -> String {
    format!(
        "https://gitlab.com/CentOS/\
        Hyperscale/rpms/{package}"
    )
}

const IN_PROGRESS: &str = "In progress";

/// Transition an existing issue to "In progress" if it is not already.
///
/// When there is no issue yet this is a no-op.
fn maybe_set_in_progress(
    result: &mut check_latest::CheckResult,
    project_url: &str,
    package: &str,
    errors: &mut u32,
) {
    let issue = match &result.issue {
        Some(i) => i,
        None => return,
    };
    if issue.status == IN_PROGRESS {
        return;
    }
    let client = match gitlab::Client::from_project_url(project_url) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("{package}: setting status: {e}");
            *errors += 1;
            return;
        }
    };
    match client.set_work_item_status(issue.iid, IN_PROGRESS) {
        Ok(()) => {
            eprintln!("Set issue #{} to {IN_PROGRESS}: {}", issue.iid, issue.url);
            if let Some(ref mut issue) = result.issue {
                issue.status = IN_PROGRESS.to_string();
            }
        }
        Err(e) => {
            eprintln!("{package}: setting status: {e}");
            *errors += 1;
        }
    }
}

/// Close an open issue with a comment linking to the
/// CBS release build(s).
///
/// No-op when there is no issue or the issue is already
/// closed.
fn maybe_close_issue(
    result: &mut check_latest::CheckResult,
    project_url: &str,
    package: &str,
    errors: &mut u32,
) {
    let issue = match &result.issue {
        Some(i) => i,
        None => return,
    };
    if issue.state != "opened" {
        return;
    }
    let client = match gitlab::Client::from_project_url(project_url) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("{package}: closing issue: {e}");
            *errors += 1;
            return;
        }
    };
    let comment = result.close_comment();
    if let Err(e) = client.add_note(issue.iid, &comment) {
        eprintln!("{package}: adding comment: {e}");
        *errors += 1;
        return;
    }
    let updates = gitlab::IssueUpdate {
        state_event: Some("close".to_string()),
        ..Default::default()
    };
    match client.edit_issue(issue.iid, &updates) {
        Ok(closed) => {
            eprintln!("Closed issue #{}: {}", closed.iid, closed.web_url);
            if let Some(ref mut issue) = result.issue {
                issue.status = "closed".to_string();
            }
        }
        Err(e) => {
            eprintln!("{package}: closing issue: {e}");
            *errors += 1;
        }
    }
}

fn maybe_file_issue(
    package: &str,
    result: &check_latest::CheckResult,
    project_url: &str,
) -> Result<check_latest::IssueRef, Box<dyn std::error::Error>> {
    let ref_ver = result
        .ref_version()
        .ok_or("no reference version available")?;
    let title = format!("{package}-{ref_ver} is available");
    let description = format!("```\n{}```", check_latest::format_table(result));
    file_or_update_issue(project_url, &title, &description)
}

const ISSUE_LABEL: &str = "rfe::new-version";

fn resolve_issue_ref(
    client: &gitlab::Client,
    issue: &gitlab::Issue,
) -> Result<check_latest::IssueRef, Box<dyn std::error::Error>> {
    let status = client.get_work_item_status(issue.iid)?;
    Ok(check_latest::IssueRef::from_gitlab_issue(issue, status))
}

fn lookup_issue(
    project_url: &str,
) -> Result<Option<check_latest::IssueRef>, Box<dyn std::error::Error>> {
    let client = gitlab::Client::from_project_url(project_url)?;
    let issues = client.list_issues(ISSUE_LABEL, None)?;
    match issues.first() {
        Some(issue) => Ok(Some(resolve_issue_ref(&client, issue)?)),
        None => Ok(None),
    }
}

fn file_or_update_issue(
    project_url: &str,
    title: &str,
    description: &str,
) -> Result<check_latest::IssueRef, Box<dyn std::error::Error>> {
    let client = gitlab::Client::from_project_url(project_url)?;

    // Check for an existing open issue first.
    let open_issues = client.list_issues(ISSUE_LABEL, Some("opened"))?;
    if let Some(existing) = open_issues.first() {
        return update_existing_issue(&client, existing, title, description);
    }

    // Check for a closed issue with the same title that we can reopen.
    let closed_issues = client.list_issues(ISSUE_LABEL, Some("closed"))?;
    if let Some(existing) = closed_issues.iter().find(|i| i.title == title) {
        return reopen_issue(&client, existing, title, description);
    }

    // No existing issue — create a new one.
    let issue = client.create_issue(title, Some(description), Some(ISSUE_LABEL))?;
    eprintln!("Created issue #{}: {}", issue.iid, issue.web_url);
    resolve_issue_ref(&client, &issue)
}

fn update_existing_issue(
    client: &gitlab::Client,
    existing: &gitlab::Issue,
    title: &str,
    description: &str,
) -> Result<check_latest::IssueRef, Box<dyn std::error::Error>> {
    let title_changed = existing.title != title;
    let desc_changed = existing.description.as_deref() != Some(description);
    if title_changed || desc_changed {
        let updates = gitlab::IssueUpdate {
            title: if title_changed {
                Some(title.to_string())
            } else {
                None
            },
            description: if desc_changed {
                Some(description.to_string())
            } else {
                None
            },
            ..Default::default()
        };
        let updated = client.edit_issue(existing.iid, &updates)?;
        eprintln!("Updated issue #{}: {}", updated.iid, updated.web_url);
        resolve_issue_ref(client, &updated)
    } else {
        eprintln!(
            "Issue #{} already up to date: {}",
            existing.iid, existing.web_url
        );
        resolve_issue_ref(client, existing)
    }
}

fn reopen_issue(
    client: &gitlab::Client,
    existing: &gitlab::Issue,
    title: &str,
    description: &str,
) -> Result<check_latest::IssueRef, Box<dyn std::error::Error>> {
    let updates = gitlab::IssueUpdate {
        title: Some(title.to_string()),
        description: Some(description.to_string()),
        add_labels: Some("reopened".to_string()),
        state_event: Some("reopen".to_string()),
        ..Default::default()
    };
    let updated = client.edit_issue(existing.iid, &updates)?;
    eprintln!("Reopened issue #{}: {}", updated.iid, updated.web_url);
    resolve_issue_ref(client, &updated)
}
