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

//! Reverse-dependency breakage checker for Koji side tags and Bodhi updates.
//!
//! Given a set of updated packages (from a side tag or Bodhi update),
//! compares old vs new subpackage Provides to detect removed capabilities,
//! then finds reverse dependencies in the stable repo that would break.

use std::collections::{BTreeMap, BTreeSet};

use rayon::prelude::*;
use serde::Serialize;

// ---- Public types ----

/// What kind of input the user provided.
pub enum InputKind {
    /// A Koji side tag name (e.g. "epel9-build-side-133287").
    SideTag(String),
    /// A Bodhi update alias (e.g. "FEDORA-EPEL-2026-f9eaa11e18").
    BodhiAlias(String),
}

/// Options for the check-update command.
pub struct CheckUpdateOptions {
    pub branch: Option<String>,
    pub repo: Option<String>,
    /// Override branch for @testing queries (defaults to branch).
    pub testing_branch: Option<String>,
    pub koji_profile: Option<String>,
    pub verbose: bool,
    /// Offer to fix stale side-tag repodata interactively (regen
    /// the repo, or confirm continuing with stale data). Off for
    /// `--json` and when stdin isn't a terminal.
    pub interactive: bool,
}

/// A Provide that changed between old and new versions.
#[derive(Debug, Clone, Serialize)]
pub struct ChangedProvide {
    /// The old Provide string (e.g. "tor = 0.4.9.5-1.el9").
    pub old: String,
    /// The new Provide string, if the name still exists.
    pub new: Option<String>,
}

impl ChangedProvide {
    /// Whether this is a version update (name still provided)
    /// vs a true removal.
    pub fn is_updated(&self) -> bool {
        self.new.is_some()
    }
}

/// A single broken Requires entry.
#[derive(Debug, Clone, Serialize)]
pub struct BrokenRequires {
    /// The Requires string that would break.
    pub dep: String,
    /// The Provide that was removed or updated.
    pub changed_provide: String,
}

/// An unsatisfied Requires in an updated package's subpackages.
#[derive(Debug, Clone, Serialize)]
pub struct UnsatisfiedDep {
    /// The source package.
    pub package: String,
    /// The unsatisfied Requires string.
    pub dep: String,
}

/// A side tag repo whose metadata still serves a stale V-R for
/// a build that koji says is tagged at a newer NVR.
///
/// When present, the provides comparison may have missed changes
/// from this source, so the report's reverse-dep list is
/// incomplete. The user-side remedy is `koji regen-repo <side-tag>`.
#[derive(Debug, Clone, Serialize)]
pub struct StaleSideTag {
    /// Source package name.
    pub package: String,
    /// Expected NVR per koji (`name-version-release`).
    pub expected_nvr: String,
    /// V-R actually served by the side tag repo, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub actual_vr: Option<String>,
}

/// Result of checking a single reverse dependency.
#[derive(Debug, Serialize)]
pub struct RevDepResult {
    /// "ok" or "broken".
    pub status: String,
    /// Broken Requires, if any.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub issues: Vec<BrokenRequires>,
}

/// Full result of the check-update command.
#[derive(Debug, Serialize)]
pub struct CheckUpdateReport {
    pub input: String,
    pub branch: String,
    /// Repository class (e.g. "@epel"), if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    pub updated_packages: Vec<String>,
    /// Whether full provides comparison was performed.
    pub full_analysis: bool,
    /// Provides that changed (updated version or truly removed).
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub changed_provides: Vec<ChangedProvide>,
    /// Requires of updated packages not satisfiable on the target.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub installability_issues: Vec<UnsatisfiedDep>,
    /// Side-tag staleness warnings. When non-empty the
    /// reverse-dep analysis is incomplete.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub stale_side_tag: Vec<StaleSideTag>,
    pub reverse_deps: BTreeMap<String, RevDepResult>,
}

// ---- Public functions ----

/// Detect what kind of input the user provided.
pub fn detect_input_type(input: &str) -> InputKind {
    // Strip URL prefix to get the alias.
    let candidate = if let Some(rest) = input
        .strip_prefix("https://")
        .or_else(|| input.strip_prefix("http://"))
    {
        // Last path segment is the alias.
        rest.rsplit('/').next().unwrap_or(rest)
    } else {
        input
    };

    // Bodhi aliases look like FEDORA-2026-abc123 or FEDORA-EPEL-2026-abc123.
    if candidate.starts_with("FEDORA-") && candidate.contains("-20") {
        InputKind::BodhiAlias(candidate.to_string())
    } else {
        InputKind::SideTag(input.to_string())
    }
}

/// Extract the source package name from an NVR string.
///
/// E.g. "rust-uucore-0.0.28-2.el9" → "rust-uucore"
pub fn parse_nvr(nvr: &str) -> Option<&str> {
    sandogasa_koji::parse_nvr_name(nvr)
}

/// List NVRs in a Koji tag via `koji list-tagged --quiet`.
pub fn koji_list_tagged(tag: &str, profile: Option<&str>) -> Result<Vec<String>, String> {
    sandogasa_koji::list_tagged_nvrs(tag, profile)
}

/// List binary RPM names for a build via `koji buildinfo`.
///
/// Parses the RPMs section, returning binary package names
/// (excluding `.src.rpm` entries).
pub fn koji_build_rpms(nvr: &str, profile: Option<&str>) -> Result<Vec<String>, String> {
    sandogasa_koji::build_rpms(nvr, profile)
}

/// Run the check-update analysis.
pub fn check_update(input: &str, opts: &CheckUpdateOptions) -> Result<CheckUpdateReport, String> {
    // Phase 0: Determine side tag, NVRs, branch, Bodhi release branch,
    // and Bodhi update status (None for side-tag input).
    let (side_tag, nvrs, branch, bodhi_branch, bodhi_status) = match detect_input_type(input) {
        InputKind::SideTag(tag) => {
            let branch = opts
                .branch
                .clone()
                .ok_or("--branch is required for side tag input")?;
            let nvrs = koji_list_tagged(&tag, opts.koji_profile.as_deref())?;
            (Some(tag), nvrs, branch, None, None)
        }
        InputKind::BodhiAlias(alias) => {
            let info = fetch_bodhi_update(&alias)?;

            let branch = opts.branch.clone().or_else(|| {
                info.release_name
                    .as_ref()
                    .map(|r| r.to_lowercase().replace('-', ""))
            });
            let branch =
                branch.ok_or("could not determine branch from Bodhi release; use --branch")?;

            // If backed by a side tag, use koji to get the full list.
            let (side_tag, nvrs) = if let Some(tag) = info.side_tag {
                let koji_nvrs = koji_list_tagged(&tag, opts.koji_profile.as_deref())?;
                (Some(tag), koji_nvrs)
            } else {
                (None, info.nvrs)
            };

            (
                side_tag,
                nvrs,
                branch,
                info.release_branch,
                Some(info.status),
            )
        }
    };

    // Extract unique source package names from NVRs.
    let updated_packages: Vec<String> = nvrs
        .iter()
        .filter_map(|nvr| parse_nvr(nvr))
        .map(|s| s.to_string())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    if opts.verbose {
        eprintln!(
            "[check-update] updated packages: {}",
            updated_packages.join(", ")
        );
    }

    if updated_packages.is_empty() {
        return Ok(CheckUpdateReport {
            input: input.to_string(),
            branch: branch.clone(),
            repo: opts.repo.clone(),
            updated_packages: vec![],
            full_analysis: side_tag.is_some(),
            changed_provides: vec![],
            installability_issues: vec![],
            stale_side_tag: vec![],
            reverse_deps: BTreeMap::new(),
        });
    }

    // Set up fedrq for the stable repo.
    let stable_fedrq = sandogasa_fedrq::Fedrq {
        branch: Some(branch.clone()),
        repo: opts.repo.clone(),
    };

    // Get subpackage names for the updated source packages.
    if opts.verbose {
        eprintln!("[check-update] querying subpackage names");
    }

    let all_subpkg_names: Vec<String> = updated_packages
        .par_iter()
        .flat_map(|srpm| filter_none(stable_fedrq.subpkgs_names(srpm).unwrap_or_default()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    if opts.verbose {
        eprintln!(
            "[check-update] {} subpackages: {}",
            all_subpkg_names.len(),
            all_subpkg_names.join(", "),
        );
    }

    // Determine the branch for querying new provides (@testing or side tag).
    let new_branch = opts
        .testing_branch
        .clone()
        .or_else(|| testing_branch_from_side_tag(side_tag.as_deref()))
        .or(bodhi_branch)
        .unwrap_or_else(|| branch.clone());

    let side_tag_fedrq = side_tag.as_ref().map(|tag| sandogasa_fedrq::Fedrq {
        branch: None,
        repo: Some(format!("@koji:{tag}")),
    });

    // Prefer @testing when the update has been pushed to testing,
    // since it has authoritative repo metadata (no staleness issue).
    let testing_fedrq = sandogasa_fedrq::Fedrq {
        branch: Some(new_branch),
        repo: Some("@testing".to_string()),
    };

    // Two gates: Bodhi must say the update is in testing (when we know),
    // and @testing must actually carry one of the expected NVRs (covers
    // both side-tag input and a stale @testing metadata snapshot).
    let bodhi_in_testing = match bodhi_status.as_deref() {
        Some("testing") => true,
        Some(other) => {
            if opts.verbose {
                eprintln!(
                    "[check-update] Bodhi status is '{other}', \
                     not 'testing'; skipping @testing"
                );
            }
            false
        }
        None => true,
    };
    let has_testing = bodhi_in_testing
        && testing_has_update_nvrs(&nvrs, |src| {
            testing_fedrq.subpkgs_nvrs(src).unwrap_or_default()
        });

    if has_testing {
        if opts.verbose {
            eprintln!("[check-update] using @testing for new provides");
        }
        // Get binary RPM names for installability checking.
        let testing_bins: Vec<String> = updated_packages
            .iter()
            .flat_map(|pkg| filter_none(testing_fedrq.subpkgs_names(pkg).unwrap_or_default()))
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect();
        let changed =
            compute_changed_provides_via_subpkgs(&updated_packages, &stable_fedrq, &testing_fedrq);
        return run_provides_analysis(
            input,
            &branch,
            &updated_packages,
            &testing_bins,
            changed,
            vec![],
            &stable_fedrq,
            &testing_fedrq,
            opts,
        );
    }

    if let Some(ref side_fq) = side_tag_fedrq {
        let tag = side_tag
            .as_deref()
            .expect("side_tag_fedrq implies side_tag");
        if opts.verbose {
            eprintln!("[check-update] comparing provides via koji + side tag");
        }
        // Get binary RPM names from koji for installability checking.
        let koji_bins: Vec<String> = nvrs
            .iter()
            .flat_map(|nvr| koji_build_rpms(nvr, opts.koji_profile.as_deref()).unwrap_or_default())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect();

        // Detect side-tag repodata that lags koji (V-R mismatch)
        // before the expensive provides comparison, so a regen can
        // fix the data the comparison runs on.
        let mut stale_side_tag = check_side_tag_staleness(
            &nvrs,
            &koji_bins,
            |bin| side_fq.pkg_nvrs(bin).unwrap_or_default(),
            opts.verbose,
        );

        if !stale_side_tag.is_empty() && opts.interactive {
            stale_side_tag = resolve_stale_side_tag(
                stale_side_tag,
                tag,
                opts.koji_profile.as_deref(),
                &nvrs,
                &koji_bins,
                |bin| side_fq.pkg_nvrs(bin).unwrap_or_default(),
                opts.verbose,
            )?;
        }

        let changed = compute_changed_provides_via_koji(
            &nvrs,
            &updated_packages,
            &stable_fedrq,
            side_fq,
            opts.koji_profile.as_deref(),
            opts.verbose,
        );

        return run_provides_analysis(
            input,
            &branch,
            &updated_packages,
            &koji_bins,
            changed,
            stale_side_tag,
            &stable_fedrq,
            side_fq,
            opts,
        );
    }

    // No side tag and not in @testing: just list reverse deps.
    if opts.verbose {
        eprintln!(
            "[check-update] no side tag or @testing available; \
             listing reverse deps only"
        );
    }

    let rev_dep_sources = filter_none(
        stable_fedrq
            .whatrequires(&all_subpkg_names)
            .map_err(|e| format!("whatrequires failed: {e}"))?,
    );

    let updated_set: BTreeSet<&str> = updated_packages.iter().map(|s| s.as_str()).collect();
    let rev_deps: Vec<String> = rev_dep_sources
        .into_iter()
        .filter(|pkg| !updated_set.contains(pkg.as_str()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    let reverse_deps: BTreeMap<String, RevDepResult> = rev_deps
        .into_iter()
        .map(|pkg| {
            (
                pkg,
                RevDepResult {
                    status: "unknown".to_string(),
                    issues: vec![],
                },
            )
        })
        .collect();

    Ok(CheckUpdateReport {
        input: input.to_string(),
        branch,
        repo: opts.repo.clone(),
        updated_packages,
        full_analysis: false,
        changed_provides: vec![],
        installability_issues: vec![],
        stale_side_tag: vec![],
        reverse_deps,
    })
}

/// Print a human-readable report to stdout.
pub fn print_report(report: &CheckUpdateReport) {
    print!("{}", render_report(report));
}

/// Render the report as Markdown — the same text `print_report`
/// writes to stdout, reusable as a Bodhi comment body.
pub fn render_report(report: &CheckUpdateReport) -> String {
    use std::fmt::Write as _;
    let mut o = String::new();
    let _ = writeln!(o, "# Checking update: {}\n", report.input);
    if let Some(ref repo) = report.repo {
        let _ = writeln!(o, "**Branch:** {} ({})\n", report.branch, repo);
    } else {
        let _ = writeln!(o, "**Branch:** {}\n", report.branch);
    }
    let _ = writeln!(
        o,
        "**Updated packages:** {}\n",
        report.updated_packages.join(", ")
    );

    if !report.stale_side_tag.is_empty() {
        let _ = writeln!(
            o,
            "> **Warning:** side tag repodata is stale for {} \
             source package(s); the reverse-dep analysis below \
             may be incomplete. Run `koji regen-repo` on the side \
             tag, then rerun this check with `--refresh` to drop \
             the cached metadata.",
            report.stale_side_tag.len()
        );
        for w in &report.stale_side_tag {
            match &w.actual_vr {
                Some(actual) => {
                    let _ = writeln!(
                        o,
                        "> - **{}**: expected `{}`, found `{}` in repodata",
                        w.package, w.expected_nvr, actual
                    );
                }
                None => {
                    let _ = writeln!(
                        o,
                        "> - **{}**: expected `{}`, no matching binary RPMs in repodata",
                        w.package, w.expected_nvr
                    );
                }
            }
        }
        let _ = writeln!(o);
    }

    if !report.full_analysis {
        // No side tag available — informational mode.
        let _ = writeln!(
            o,
            "> **Note:** no side tag available; cannot compare \
             Provides. Listing reverse dependencies for manual \
             review.\n"
        );
        if report.reverse_deps.is_empty() {
            let _ = writeln!(o, "No reverse dependencies found.");
        } else {
            let _ = writeln!(o, "## Reverse dependencies\n");
            for pkg in report.reverse_deps.keys() {
                let _ = writeln!(o, "- {pkg}");
            }
            let _ = writeln!(
                o,
                "\n**Total:** {} reverse dependencies.",
                report.reverse_deps.len()
            );
        }
        return o;
    }

    if report.changed_provides.is_empty() && report.installability_issues.is_empty() {
        let _ = writeln!(o, "No changed Provides. No breakage expected.");
        return o;
    }

    if report.changed_provides.is_empty() {
        let _ = writeln!(o, "No changed Provides.");
    }

    let updated: Vec<&ChangedProvide> = report
        .changed_provides
        .iter()
        .filter(|c| c.is_updated())
        .collect();
    let removed: Vec<&ChangedProvide> = report
        .changed_provides
        .iter()
        .filter(|c| !c.is_updated())
        .collect();

    if !updated.is_empty() {
        let _ = writeln!(o, "## Updated Provides ({})\n", updated.len());
        for c in &updated {
            let name = provide_name(&c.old);
            let old_ver = c
                .old
                .strip_prefix(name)
                .unwrap_or("")
                .trim()
                .trim_start_matches("= ");
            let new_ver = c
                .new
                .as_deref()
                .and_then(|n| n.strip_prefix(name))
                .unwrap_or("")
                .trim()
                .trim_start_matches("= ");
            let _ = writeln!(o, "- `{name}` ({old_ver} → {new_ver})");
        }
    }
    if !removed.is_empty() {
        let _ = writeln!(o, "\n## Removed Provides ({})\n", removed.len());
        for c in &removed {
            let _ = writeln!(o, "- `{}`", c.old);
        }
    }

    if !report.installability_issues.is_empty() {
        let _ = writeln!(
            o,
            "\n## Installability issues ({})\n",
            report.installability_issues.len()
        );
        for issue in &report.installability_issues {
            let _ = writeln!(o, "- **{}**: `{}`", issue.package, issue.dep);
        }
    }

    if !report.reverse_deps.is_empty() {
        let _ = writeln!(o, "\n## Reverse dependencies\n");
        let mut broken_count = 0;
        for (pkg, result) in &report.reverse_deps {
            if result.status == "ok" {
                let _ = writeln!(o, "- **{pkg}:** OK");
            } else {
                broken_count += 1;
                let _ = writeln!(o, "- **{pkg}:** BROKEN");
                for issue in &result.issues {
                    let _ = writeln!(
                        o,
                        "  - `{}` (changed: `{}`)",
                        issue.dep, issue.changed_provide
                    );
                }
            }
        }

        let total = report.reverse_deps.len();
        if broken_count > 0 {
            let _ = writeln!(
                o,
                "\n**Summary:** {broken_count} of {total} reverse \
                 dependencies would break."
            );
        } else {
            let _ = writeln!(o, "\n**Summary:** all {total} reverse dependencies OK.");
        }
    }

    if report.reverse_deps.is_empty() && report.installability_issues.is_empty() {
        let _ = writeln!(o, "\nNo breakage expected.");
    } else if !report.installability_issues.is_empty() {
        let _ = writeln!(
            o,
            "\n**Warning:** {} installability issue(s) found.",
            report.installability_issues.len()
        );
    }
    o
}

// ---- Private helpers ----

/// Filter out fedrq's "(none)" placeholder from results.
fn filter_none(items: Vec<String>) -> Vec<String> {
    items
        .into_iter()
        .filter(|s| !s.is_empty() && s != "(none)")
        .collect()
}

/// Extract bare capability names from an RPM dependency string.
///
/// Handles rich deps like `(crate(foo) >= 1.0 with crate(foo) < 2.0~)`
/// by splitting on `with`/`or`/`and`/`if`/`unless` and stripping
/// version constraints and parentheses.
fn extract_capability_names(dep: &str) -> Vec<String> {
    // Strip wrapping parens ONLY when the entire dep is a
    // rich/boolean expression — i.e. it starts with `(` and ends
    // with `)`. Plain caps like `libc.so.6(GLIBC_2.34)(64bit)`
    // or `rtld(GNU_HASH)` end in `)` but the `)` is part of the
    // name; trimming it unconditionally produces a broken
    // capability that fedrq won't resolve, which then surfaces
    // as a bogus "installability issue" for every system lib.
    let dep = dep.trim();
    let dep = if dep.starts_with('(') && dep.ends_with(')') {
        &dep[1..dep.len() - 1]
    } else {
        dep
    };
    dep.split_whitespace()
        .filter(|token| {
            // Skip version operators and boolean operators.
            !matches!(
                *token,
                ">=" | "<=" | ">" | "<" | "=" | "with" | "or" | "and" | "if" | "unless" | "else"
            ) && !token.starts_with(|c: char| c.is_ascii_digit())
                && !token.ends_with('~')
        })
        // Only strip leading '(' from tokens (nested rich dep grouping).
        // Do NOT strip trailing ')' — it's part of capability names
        // like crate(foo), config(bar), pkgconfig(baz).
        .map(|s| s.trim_start_matches('(').to_string())
        .filter(|s| !s.is_empty())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

/// Extract the name part of a Provide string.
///
/// E.g. "tor = 0.4.9.5-1.el9" → "tor",
///      "config(tor) = 0.4.9.5-1.el9" → "config(tor)",
///      "libthing.so.1()(64bit)" → "libthing.so.1()(64bit)"
fn provide_name(provide: &str) -> &str {
    // Split on version operators: " = ", " < ", " > ", " <= ", " >= "
    provide
        .split_once(" = ")
        .or_else(|| provide.split_once(" < "))
        .or_else(|| provide.split_once(" > "))
        .or_else(|| provide.split_once(" <= "))
        .or_else(|| provide.split_once(" >= "))
        .map(|(name, _)| name)
        .unwrap_or(provide)
}

/// Compare old and new provides, classifying each as updated or removed.
fn compare_provides(old: &BTreeSet<String>, new: &BTreeSet<String>) -> Vec<ChangedProvide> {
    // Build a map from provide name → full provide string for new provides.
    let new_by_name: BTreeMap<&str, &str> =
        new.iter().map(|p| (provide_name(p), p.as_str())).collect();

    let mut changed = Vec::new();
    for old_provide in old.difference(new) {
        let name = provide_name(old_provide);
        let new_match = new_by_name.get(name).map(|s| s.to_string());
        changed.push(ChangedProvide {
            old: old_provide.clone(),
            new: new_match,
        });
    }
    changed
}

/// Check whether `@testing` actually carries the expected new
/// content for at least one of the input NVRs.
///
/// Bodhi can claim an update is being routed to testing while the
/// updates-testing repo metadata still reflects the previous V-R,
/// and side-tag inputs have no Bodhi status to consult — so we
/// confirm by querying via `lookup` directly. Returns true as soon
/// as one subpackage matches an expected `(version, release)`.
///
/// `lookup(src)` returns `(name, version, release)` tuples for all
/// subpackages of `src` in `@testing`; the caller is responsible for
/// wiring it to `Fedrq::subpkgs_nvrs` (extracted as a closure so the
/// matcher can be unit-tested without invoking fedrq).
fn testing_has_update_nvrs<F>(nvrs: &[String], lookup: F) -> bool
where
    F: Fn(&str) -> Vec<(String, String, String)>,
{
    let expected: BTreeMap<&str, (&str, &str)> = nvrs
        .iter()
        .filter_map(|nvr| sandogasa_koji::parse_nvr(nvr).map(|(n, v, r)| (n, (v, r))))
        .collect();

    expected
        .iter()
        .any(|(src, (exp_v, exp_r))| lookup(src).iter().any(|(_, v, r)| v == exp_v && r == exp_r))
}

/// Extract a testing branch from a side tag name.
///
/// E.g. "epel9-build-side-133287" → "epel9",
///      "epel10.0-build-side-12345" → "epel10.0"
fn testing_branch_from_side_tag(tag: Option<&str>) -> Option<String> {
    let tag = tag?;
    let rest = tag.strip_prefix("epel")?;
    let release_end = rest.find("-build-side-")?;
    Some(format!("epel{}", &rest[..release_end]))
}

struct BodhiUpdateInfo {
    side_tag: Option<String>,
    nvrs: Vec<String>,
    release_name: Option<String>,
    /// Branch from the Bodhi release (e.g. "epel9", "f44").
    release_branch: Option<String>,
    /// Update status ("pending", "testing", "stable", ...).
    status: String,
}

/// Fetch a Bodhi update and extract its key fields.
fn fetch_bodhi_update(alias: &str) -> Result<BodhiUpdateInfo, String> {
    let rt = tokio::runtime::Runtime::new()
        .map_err(|e| format!("failed to create async runtime: {e}"))?;
    let update = rt
        .block_on(async {
            let client = sandogasa_bodhi::BodhiClient::new();
            client.update_by_alias(alias).await
        })
        .map_err(|e| format!("failed to fetch Bodhi update {alias}: {e}"))?;

    let release_name = update.release.as_ref().map(|r| r.name.clone());
    let release_branch = update.release.as_ref().and_then(|r| r.branch.clone());
    let nvrs: Vec<String> = update.builds.iter().map(|b| b.nvr.clone()).collect();
    let side_tag = update.from_tag.filter(|t| t.contains("-side-"));
    Ok(BodhiUpdateInfo {
        side_tag,
        nvrs,
        release_name,
        release_branch,
        status: update.status,
    })
}

/// Compute changed provides using `subpkgs_provides` on both repos.
///
/// Works for @testing and any repo where source RPM queries work.
///
/// Provides are unioned across all updated packages on *both*
/// sides before comparing (matching the koji path): an update can
/// move a capability between its packages — e.g. a version bump
/// paired with a new compat package that keeps shipping the old
/// version's provides. A per-package comparison would falsely
/// report such provides as removed.
fn compute_changed_provides_via_subpkgs(
    updated_packages: &[String],
    stable_fedrq: &sandogasa_fedrq::Fedrq,
    new_fedrq: &sandogasa_fedrq::Fedrq,
) -> Vec<ChangedProvide> {
    let old_provides: BTreeSet<String> = updated_packages
        .par_iter()
        .flat_map(|srpm| filter_none(stable_fedrq.subpkgs_provides(srpm).unwrap_or_default()))
        .collect();

    let new_provides: BTreeSet<String> = updated_packages
        .par_iter()
        .flat_map(|srpm| filter_none(new_fedrq.subpkgs_provides(srpm).unwrap_or_default()))
        .collect();

    compare_provides(&old_provides, &new_provides)
}

/// Compute changed provides for side tags using `koji buildinfo`
/// + `fedrq pkg_provides`.
///
/// Side tags don't index source RPMs, so we get binary RPM names
/// from koji and query their provides individually.
fn compute_changed_provides_via_koji(
    nvrs: &[String],
    updated_packages: &[String],
    stable_fedrq: &sandogasa_fedrq::Fedrq,
    side_tag_fedrq: &sandogasa_fedrq::Fedrq,
    koji_profile: Option<&str>,
    verbose: bool,
) -> Vec<ChangedProvide> {
    // Get old provides from stable repo (source-based query works here).
    let old_provides: BTreeSet<String> = updated_packages
        .par_iter()
        .flat_map(|srpm| filter_none(stable_fedrq.subpkgs_provides(srpm).unwrap_or_default()))
        .collect();

    // Get new provides: binary RPM names via koji, then query
    // each one's provides from the side tag repo.
    let binary_names: Vec<String> = nvrs
        .iter()
        .flat_map(|nvr| {
            koji_build_rpms(nvr, koji_profile).unwrap_or_else(|e| {
                if verbose {
                    eprintln!(
                        "[check-update] warning: \
                         koji buildinfo {nvr} failed: {e}"
                    );
                }
                vec![]
            })
        })
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    if verbose {
        eprintln!(
            "[check-update] {} binary packages from koji: {}",
            binary_names.len(),
            binary_names.join(", "),
        );
    }

    let new_provides: BTreeSet<String> = binary_names
        .par_iter()
        .flat_map(|name| filter_none(side_tag_fedrq.pkg_provides(name).unwrap_or_default()))
        .collect();

    compare_provides(&old_provides, &new_provides)
}

/// Check that the updated packages' subpackage Requires are
/// satisfiable on the stable repo. Returns deps that can't be
/// resolved (indicating the update would be uninstallable).
///
/// Queries provides and requires per binary RPM name (not per
/// source package) so this works on Koji repos that lack source
/// RPMs.
fn check_update_installability(
    updated_packages: &[String],
    binary_names: &[String],
    new_fedrq: &sandogasa_fedrq::Fedrq,
    stable_fedrq: &sandogasa_fedrq::Fedrq,
    verbose: bool,
) -> Vec<UnsatisfiedDep> {
    if verbose {
        eprintln!(
            "[check-update] checking installability of {} binary packages",
            binary_names.len()
        );
    }

    // Collect new provides from updated packages (they satisfy each
    // other's deps). Query per binary package so it works on koji.
    if verbose {
        eprintln!("[check-update] collecting provides from updated packages");
    }
    let new_provides: BTreeSet<String> = binary_names
        .par_iter()
        .flat_map_iter(|name| {
            new_fedrq
                .pkg_provides(name)
                .unwrap_or_default()
                .into_iter()
                .map(|p| provide_name(&p).to_string())
        })
        .collect();

    // Map binary RPM names back to source packages for reporting.
    let bin_to_src: BTreeMap<&str, &str> = binary_names
        .iter()
        .map(|bin| {
            let src = updated_packages
                .iter()
                .find(|src| bin.starts_with(src.as_str()) && bin[src.len()..].starts_with('-'))
                .map(|s| s.as_str())
                .unwrap_or(bin.as_str());
            (bin.as_str(), src)
        })
        .collect();

    if verbose {
        eprintln!(
            "[check-update] {} new provides collected, checking requires",
            new_provides.len()
        );
    }

    binary_names
        .par_iter()
        .flat_map_iter(|name| {
            let requires = new_fedrq.pkg_requires(name).unwrap_or_default();
            let src = bin_to_src.get(name.as_str()).copied().unwrap_or(name);
            requires
                .into_iter()
                .filter(|dep| {
                    let dep = dep.trim();
                    if dep.is_empty() || sandogasa_depfilter::is_rpm_internal_dep(dep) {
                        return false;
                    }
                    // Extract bare capability names (stripping version
                    // constraints and rich dep syntax).
                    let caps = extract_capability_names(dep);
                    // Satisfied by other packages in the update?
                    if caps.iter().all(|cap| new_provides.contains(cap.as_str())) {
                        return false;
                    }
                    // Satisfied by the stable repo?
                    let satisfied = caps.iter().all(|cap| {
                        let resolved = stable_fedrq.provides_of_provider(cap).unwrap_or_default();
                        resolved.iter().any(|s| !s.is_empty() && s != "(none)")
                    });
                    !satisfied
                })
                .map(|dep| UnsatisfiedDep {
                    package: src.to_string(),
                    dep: dep.trim().to_string(),
                })
                .collect::<Vec<_>>()
        })
        .collect()
}

/// Given changed provides, find affected reverse deps and check
/// their Requires.
#[allow(clippy::too_many_arguments)]
fn run_provides_analysis(
    input: &str,
    branch: &str,
    updated_packages: &[String],
    binary_names: &[String],
    changed_provides: Vec<ChangedProvide>,
    stale_side_tag: Vec<StaleSideTag>,
    stable_fedrq: &sandogasa_fedrq::Fedrq,
    new_fedrq: &sandogasa_fedrq::Fedrq,
    opts: &CheckUpdateOptions,
) -> Result<CheckUpdateReport, String> {
    if opts.verbose {
        let updated_count = changed_provides.iter().filter(|c| c.is_updated()).count();
        let removed_count = changed_provides.iter().filter(|c| !c.is_updated()).count();
        eprintln!(
            "[check-update] {} changed provides \
             ({updated_count} updated, {removed_count} removed)",
            changed_provides.len()
        );
    }

    let installability_issues = check_update_installability(
        updated_packages,
        binary_names,
        new_fedrq,
        stable_fedrq,
        opts.verbose,
    );

    if changed_provides.is_empty() {
        return Ok(CheckUpdateReport {
            input: input.to_string(),
            branch: branch.to_string(),
            repo: opts.repo.clone(),
            updated_packages: updated_packages.to_vec(),
            full_analysis: true,
            changed_provides: vec![],
            installability_issues,
            stale_side_tag,
            reverse_deps: BTreeMap::new(),
        });
    }

    // Use old provide strings for whatrequires lookup.
    let old_provide_strings: Vec<String> = changed_provides.iter().map(|c| c.old.clone()).collect();
    let old_provide_set: BTreeSet<&str> = old_provide_strings.iter().map(|s| s.as_str()).collect();

    if opts.verbose {
        eprintln!("[check-update] finding reverse dependencies");
    }

    let rev_dep_sources = filter_none(
        stable_fedrq
            .whatrequires(&old_provide_strings)
            .map_err(|e| format!("whatrequires failed: {e}"))?,
    );

    let updated_set: BTreeSet<&str> = updated_packages.iter().map(|s| s.as_str()).collect();
    let rev_deps: Vec<String> = rev_dep_sources
        .into_iter()
        .filter(|pkg| !updated_set.contains(pkg.as_str()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();

    if opts.verbose {
        eprintln!(
            "[check-update] {} reverse dependencies to check: {}",
            rev_deps.len(),
            rev_deps.join(", "),
        );
    }

    let results: Vec<(String, RevDepResult)> = rev_deps
        .par_iter()
        .map(|pkg| {
            let requires = stable_fedrq.subpkgs_requires(pkg).unwrap_or_default();

            let issues: Vec<BrokenRequires> = requires
                .iter()
                .filter_map(|dep| {
                    let dep_str = dep.trim();
                    if old_provide_set.contains(dep_str) {
                        Some(BrokenRequires {
                            dep: dep_str.to_string(),
                            changed_provide: dep_str.to_string(),
                        })
                    } else {
                        None
                    }
                })
                .collect();

            let status = if issues.is_empty() { "ok" } else { "broken" };
            (
                pkg.clone(),
                RevDepResult {
                    status: status.to_string(),
                    issues,
                },
            )
        })
        .collect();

    let reverse_deps: BTreeMap<String, RevDepResult> = results.into_iter().collect();

    Ok(CheckUpdateReport {
        input: input.to_string(),
        branch: branch.to_string(),
        repo: opts.repo.clone(),
        updated_packages: updated_packages.to_vec(),
        full_analysis: true,
        changed_provides,
        installability_issues,
        stale_side_tag,
        reverse_deps,
    })
}

/// Detect side-tag repos whose metadata still serves a stale V-R
/// for a build that koji has at a newer NVR.
///
/// koji list-tagged is authoritative for what builds are in the
/// side tag, but the rendered repodata can lag — leaving fedrq
/// queries returning the previous V-R inherited from the parent
/// tag. When that happens, `compute_changed_provides_via_koji`
/// compares stable's provides against the same old V-R, finds no
/// diff, and silently drops affected reverse deps.
///
/// For each koji NVR we look up the binary RPMs' V-R in the side
/// tag and flag any source where none of the binaries match the
/// expected `(version, release)`.
fn check_side_tag_staleness<F>(
    nvrs: &[String],
    binary_names: &[String],
    lookup: F,
    verbose: bool,
) -> Vec<StaleSideTag>
where
    F: Fn(&str) -> Vec<(String, String, String)>,
{
    let mut warnings = Vec::new();
    for nvr in nvrs {
        let Some((name, exp_v, exp_r)) = sandogasa_koji::parse_nvr(nvr) else {
            continue;
        };

        let matching_bins: Vec<&String> = binary_names
            .iter()
            .filter(|bin| bin.starts_with(name))
            .collect();
        if matching_bins.is_empty() {
            continue;
        }

        let mut expected_found = false;
        let mut actual_vr: Option<String> = None;
        for bin in &matching_bins {
            let rows = lookup(bin);
            for (_, v, r) in &rows {
                if v == exp_v && r == exp_r {
                    expected_found = true;
                    break;
                }
                if actual_vr.is_none() {
                    actual_vr = Some(format!("{v}-{r}"));
                }
            }
            if expected_found {
                break;
            }
        }

        if !expected_found {
            let warning = StaleSideTag {
                package: name.to_string(),
                expected_nvr: nvr.clone(),
                actual_vr: actual_vr.clone(),
            };
            if verbose {
                match &warning.actual_vr {
                    Some(actual) => eprintln!(
                        "warning: {name}: side tag repodata is stale \
                         (expected {exp_v}-{exp_r} from {nvr}, found \
                         {actual}); run 'koji regen-repo' on the side \
                         tag, then rerun with --refresh"
                    ),
                    None => eprintln!(
                        "warning: {name}: side tag has no binary RPMs \
                         in repodata for expected {exp_v}-{exp_r} from \
                         {nvr}; run 'koji regen-repo' on the side tag, \
                         then rerun with --refresh"
                    ),
                }
            }
            warnings.push(warning);
        }
    }
    warnings
}

/// Interpret a y/n answer line, falling back to `default_yes` on
/// empty input.
fn parse_confirm(line: &str, default_yes: bool) -> bool {
    match line.trim() {
        "" => default_yes,
        s => s.eq_ignore_ascii_case("y") || s.eq_ignore_ascii_case("yes"),
    }
}

/// Prompt on stderr and read a y/n answer from stdin.
fn confirm(prompt: &str, default_yes: bool) -> Result<bool, String> {
    use std::io::{BufRead, Write};
    let hint = if default_yes { "Y/n" } else { "y/N" };
    eprint!("{prompt} [{hint}]: ");
    std::io::stderr().flush().map_err(|e| e.to_string())?;
    let mut line = String::new();
    std::io::stdin()
        .lock()
        .read_line(&mut line)
        .map_err(|e| e.to_string())?;
    Ok(parse_confirm(&line, default_yes))
}

/// Interactively resolve stale side-tag repodata: offer to run
/// `koji regen-repo` on the user's behalf (default yes) and
/// re-check freshness afterwards; if declined, ask whether to
/// continue with stale data (default no — abort).
///
/// Returns the (possibly now empty) staleness warnings to carry
/// into the report.
fn resolve_stale_side_tag<F>(
    stale: Vec<StaleSideTag>,
    tag: &str,
    profile: Option<&str>,
    nvrs: &[String],
    koji_bins: &[String],
    lookup: F,
    verbose: bool,
) -> Result<Vec<StaleSideTag>, String>
where
    F: Fn(&str) -> Vec<(String, String, String)>,
{
    eprintln!(
        "side tag repodata is stale for {} source package(s):",
        stale.len()
    );
    for w in &stale {
        match &w.actual_vr {
            Some(actual) => eprintln!(
                "  - {}: expected {}, found {} in repodata",
                w.package, w.expected_nvr, actual
            ),
            None => eprintln!(
                "  - {}: expected {}, no matching binary RPMs in repodata",
                w.package, w.expected_nvr
            ),
        }
    }
    eprintln!("without a repo regen the reverse-dep analysis may be incomplete.");

    if confirm(
        &format!("Run `koji regen-repo --wait {tag}` now? (may take several minutes)"),
        true,
    )? {
        eprintln!("waiting for koji to regenerate the {tag} repo...");
        sandogasa_koji::regen_repo(tag, profile)?;
        sandogasa_fedrq::clear_cache()
            .map_err(|e| format!("failed to clear fedrq metadata cache: {e}"))?;
        let still_stale = check_side_tag_staleness(nvrs, koji_bins, lookup, verbose);
        if !still_stale.is_empty() {
            eprintln!(
                "side tag repodata is still stale for {} source \
                 package(s) after the regen; continuing with \
                 warnings in the report.",
                still_stale.len()
            );
        }
        Ok(still_stale)
    } else if confirm("Continue the check with stale data?", false)? {
        Ok(stale)
    } else {
        Err(format!(
            "aborted: side tag repodata is stale; run \
             `koji regen-repo {tag}`, then rerun this check with \
             --refresh to drop the cached metadata"
        ))
    }
}

// ---- Tests ----

#[cfg(test)]
mod tests {
    use super::*;

    // --- parse_confirm ---

    #[test]
    fn parse_confirm_empty_uses_default() {
        assert!(parse_confirm("", true));
        assert!(parse_confirm("\n", true));
        assert!(!parse_confirm("", false));
        assert!(!parse_confirm("  \n", false));
    }

    #[test]
    fn parse_confirm_explicit_yes() {
        for answer in ["y", "Y", "yes", "YES", " y \n"] {
            assert!(parse_confirm(answer, false), "{answer:?}");
        }
    }

    #[test]
    fn parse_confirm_explicit_no() {
        for answer in ["n", "N", "no", "anything-else"] {
            assert!(!parse_confirm(answer, true), "{answer:?}");
        }
    }

    // --- parse_nvr ---

    #[test]
    fn parse_nvr_standard() {
        assert_eq!(parse_nvr("foo-1.0-1.fc42"), Some("foo"));
    }

    #[test]
    fn parse_nvr_hyphenated_name() {
        assert_eq!(parse_nvr("rust-uucore-0.0.28-2.el9"), Some("rust-uucore"));
    }

    #[test]
    fn parse_nvr_many_hyphens() {
        assert_eq!(parse_nvr("python-a-b-c-1.2.3-4.fc42"), Some("python-a-b-c"));
    }

    #[test]
    fn parse_nvr_too_short() {
        assert_eq!(parse_nvr("foo-1.0"), None);
    }

    #[test]
    fn parse_nvr_empty() {
        assert_eq!(parse_nvr(""), None);
    }

    #[test]
    fn parse_nvr_no_hyphens() {
        assert_eq!(parse_nvr("foobar"), None);
    }

    // --- provide_name ---

    #[test]
    fn provide_name_versioned() {
        assert_eq!(provide_name("tor = 0.4.9.5-1.el9"), "tor");
    }

    #[test]
    fn provide_name_with_parens() {
        assert_eq!(provide_name("config(tor) = 0.4.9.5-1.el9"), "config(tor)");
    }

    #[test]
    fn provide_name_unversioned() {
        assert_eq!(
            provide_name("libthing.so.1()(64bit)"),
            "libthing.so.1()(64bit)"
        );
    }

    #[test]
    fn provide_name_ge() {
        assert_eq!(
            provide_name("crate(foo/default) >= 1.0"),
            "crate(foo/default)"
        );
    }

    // --- extract_capability_names ---

    #[test]
    fn extract_capability_names_keeps_paren_in_lib_caps() {
        // The trailing ')' on these caps is part of the name —
        // dropping it produces a corrupted cap that fedrq can't
        // resolve, surfacing as a false-positive installability
        // issue for every system library.
        assert_eq!(
            extract_capability_names("libc.so.6(GLIBC_2.34)(64bit)"),
            vec!["libc.so.6(GLIBC_2.34)(64bit)".to_string()]
        );
        assert_eq!(
            extract_capability_names("ld-linux-aarch64.so.1()(64bit)"),
            vec!["ld-linux-aarch64.so.1()(64bit)".to_string()]
        );
        assert_eq!(
            extract_capability_names("rtld(GNU_HASH)"),
            vec!["rtld(GNU_HASH)".to_string()]
        );
        assert_eq!(
            extract_capability_names("nginx(abi)"),
            vec!["nginx(abi)".to_string()]
        );
    }

    #[test]
    fn extract_capability_names_strips_outer_rich_dep_parens() {
        // A true rich/boolean dep wrapped in `(...)` should have
        // those wrapping parens stripped, but the inner cap
        // names with their own parens stay intact.
        let caps = extract_capability_names("(crate(foo) >= 1.0 with crate(foo) < 2.0~)");
        assert!(caps.contains(&"crate(foo)".to_string()));
        assert!(!caps.iter().any(|c| c.contains(">=") || c.contains("<")));
    }

    #[test]
    fn extract_capability_names_drops_version_operators() {
        let caps = extract_capability_names("nginx(abi) = 2:1.30.0-1.fc44");
        assert_eq!(caps, vec!["nginx(abi)".to_string()]);
    }

    // --- compare_provides ---

    #[test]
    fn compare_provides_updated() {
        let old: BTreeSet<String> = ["tor = 0.4.9.5-1.el9".to_string()].into();
        let new: BTreeSet<String> = ["tor = 0.4.9.6-1.el9".to_string()].into();
        let changed = compare_provides(&old, &new);
        assert_eq!(changed.len(), 1);
        assert!(changed[0].is_updated());
        assert_eq!(changed[0].new.as_deref(), Some("tor = 0.4.9.6-1.el9"));
    }

    #[test]
    fn compare_provides_removed() {
        let old: BTreeSet<String> = ["libfoo.so.1()(64bit)".to_string()].into();
        let new: BTreeSet<String> = BTreeSet::new();
        let changed = compare_provides(&old, &new);
        assert_eq!(changed.len(), 1);
        assert!(!changed[0].is_updated());
        assert!(changed[0].new.is_none());
    }

    #[test]
    fn compare_provides_unchanged() {
        let old: BTreeSet<String> = ["bash".to_string()].into();
        let new: BTreeSet<String> = ["bash".to_string()].into();
        let changed = compare_provides(&old, &new);
        assert!(changed.is_empty());
    }

    #[test]
    fn compare_provides_credits_compat_package_in_same_update() {
        // const-oid 0.9 -> 0.10 drops crate(const-oid/std), but the
        // same update introduces the compat package rust-const-oid0.9
        // which still ships it. The callers union provides across
        // all packages in the update on both sides, so the provide
        // must come out unchanged — not "removed" (the bug behind
        // the wrong FEDORA-EPEL-2026-b89b964abe report).
        let old: BTreeSet<String> = ["crate(const-oid/std) = 0.9.6".to_string()].into();
        let new: BTreeSet<String> = [
            "crate(const-oid) = 0.10.2".to_string(),
            // From the compat package, same version string as old.
            "crate(const-oid/std) = 0.9.6".to_string(),
        ]
        .into();
        assert!(compare_provides(&old, &new).is_empty());
    }

    // --- testing_has_update_nvrs ---

    fn nvr_row(name: &str, version: &str, release: &str) -> (String, String, String) {
        (name.to_string(), version.to_string(), release.to_string())
    }

    #[test]
    fn testing_has_update_nvrs_matches_when_vr_present() {
        let nvrs = vec!["rust-libmimalloc-sys-0.1.47-1.fc44".to_string()];
        // @testing returns subpackages at the matching V-R.
        let found = testing_has_update_nvrs(&nvrs, |_src| {
            vec![nvr_row(
                "rust-libmimalloc-sys+default-devel",
                "0.1.47",
                "1.fc44",
            )]
        });
        assert!(found);
    }

    #[test]
    fn testing_has_update_nvrs_rejects_stale_testing() {
        let nvrs = vec!["rust-libmimalloc-sys-0.1.47-1.fc44".to_string()];
        // @testing still has the previous V-R — this is the bug case.
        let found = testing_has_update_nvrs(&nvrs, |_src| {
            vec![nvr_row(
                "rust-libmimalloc-sys+default-devel",
                "0.1.44",
                "2.fc44",
            )]
        });
        assert!(!found);
    }

    #[test]
    fn testing_has_update_nvrs_any_match_wins() {
        // Multiple NVRs in the update; one matches, one doesn't.
        let nvrs = vec![
            "rust-libmimalloc-sys-0.1.47-1.fc44".to_string(),
            "rust-mimalloc-0.1.50-1.fc44".to_string(),
        ];
        let found = testing_has_update_nvrs(&nvrs, |src| match src {
            "rust-libmimalloc-sys" => {
                vec![nvr_row("rust-libmimalloc-sys-devel", "0.1.44", "2.fc44")]
            }
            "rust-mimalloc" => vec![nvr_row("rust-mimalloc-devel", "0.1.50", "1.fc44")],
            _ => vec![],
        });
        assert!(found);
    }

    #[test]
    fn testing_has_update_nvrs_empty_input_false() {
        assert!(!testing_has_update_nvrs(&[], |_| vec![]));
    }

    #[test]
    fn testing_has_update_nvrs_malformed_nvr_skipped() {
        // No valid NVRs → nothing to check → false (not a panic).
        let found = testing_has_update_nvrs(&["not-an-nvr".to_string()], |_| {
            vec![nvr_row("anything", "1", "1")]
        });
        assert!(!found);
    }

    // --- check_side_tag_staleness ---

    #[test]
    fn check_side_tag_staleness_matching_vr_no_warning() {
        let nvrs = vec!["rust-mimalloc-0.1.50-1.fc44".to_string()];
        let bins = vec!["rust-mimalloc-devel".to_string()];
        let warnings = check_side_tag_staleness(
            &nvrs,
            &bins,
            |_| vec![nvr_row("rust-mimalloc-devel", "0.1.50", "1.fc44")],
            false,
        );
        assert!(warnings.is_empty());
    }

    #[test]
    fn check_side_tag_staleness_detects_mismatched_vr() {
        // The exact case from the f44 / FEDORA-2026-7db4114930 report:
        // koji says rust-mimalloc-0.1.50-1.fc44 but the side tag repo
        // still serves 0.1.48-2.fc44.
        let nvrs = vec!["rust-mimalloc-0.1.50-1.fc44".to_string()];
        let bins = vec!["rust-mimalloc-devel".to_string()];
        let warnings = check_side_tag_staleness(
            &nvrs,
            &bins,
            |_| vec![nvr_row("rust-mimalloc-devel", "0.1.48", "2.fc44")],
            false,
        );
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].package, "rust-mimalloc");
        assert_eq!(warnings[0].expected_nvr, "rust-mimalloc-0.1.50-1.fc44");
        assert_eq!(warnings[0].actual_vr.as_deref(), Some("0.1.48-2.fc44"));
    }

    #[test]
    fn check_side_tag_staleness_detects_missing_binaries() {
        let nvrs = vec!["rust-mimalloc-0.1.50-1.fc44".to_string()];
        let bins = vec!["rust-mimalloc-devel".to_string()];
        let warnings = check_side_tag_staleness(&nvrs, &bins, |_| vec![], false);
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].package, "rust-mimalloc");
        assert!(warnings[0].actual_vr.is_none());
    }

    #[test]
    fn check_side_tag_staleness_skips_unmatched_source() {
        // No binary RPMs match this NVR's source name → nothing to check,
        // no warning (avoids false positives when koji buildinfo and
        // koji list-tagged are out of sync for some other reason).
        let nvrs = vec!["rust-mimalloc-0.1.50-1.fc44".to_string()];
        let bins = vec!["unrelated-pkg-devel".to_string()];
        let warnings = check_side_tag_staleness(
            &nvrs,
            &bins,
            |_| vec![nvr_row("unrelated-pkg-devel", "1", "1.fc44")],
            false,
        );
        assert!(warnings.is_empty());
    }

    #[test]
    fn check_side_tag_staleness_mixed_sources() {
        // rust-libmimalloc-sys is fresh, rust-mimalloc is stale —
        // we should warn only for rust-mimalloc.
        let nvrs = vec![
            "rust-libmimalloc-sys-0.1.47-1.fc44".to_string(),
            "rust-mimalloc-0.1.50-1.fc44".to_string(),
        ];
        let bins = vec![
            "rust-libmimalloc-sys-devel".to_string(),
            "rust-mimalloc-devel".to_string(),
        ];
        let warnings = check_side_tag_staleness(
            &nvrs,
            &bins,
            |bin| match bin {
                "rust-libmimalloc-sys-devel" => {
                    vec![nvr_row("rust-libmimalloc-sys-devel", "0.1.47", "1.fc44")]
                }
                "rust-mimalloc-devel" => {
                    vec![nvr_row("rust-mimalloc-devel", "0.1.48", "2.fc44")]
                }
                _ => vec![],
            },
            false,
        );
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].package, "rust-mimalloc");
    }

    // --- testing_branch_from_side_tag ---

    #[test]
    fn testing_branch_epel9() {
        assert_eq!(
            testing_branch_from_side_tag(Some("epel9-build-side-133287")),
            Some("epel9".to_string())
        );
    }

    #[test]
    fn testing_branch_epel10() {
        assert_eq!(
            testing_branch_from_side_tag(Some("epel10.0-build-side-12345")),
            Some("epel10.0".to_string())
        );
    }

    #[test]
    fn testing_branch_not_epel() {
        assert_eq!(
            testing_branch_from_side_tag(Some("f44-build-side-99999")),
            None
        );
    }

    #[test]
    fn testing_branch_none() {
        assert_eq!(testing_branch_from_side_tag(None), None);
    }

    // --- detect_input_type ---

    #[test]
    fn detect_side_tag() {
        match detect_input_type("epel9-build-side-133287") {
            InputKind::SideTag(tag) => {
                assert_eq!(tag, "epel9-build-side-133287");
            }
            _ => panic!("expected SideTag"),
        }
    }

    #[test]
    fn detect_bodhi_alias() {
        match detect_input_type("FEDORA-EPEL-2026-f9eaa11e18") {
            InputKind::BodhiAlias(alias) => {
                assert_eq!(alias, "FEDORA-EPEL-2026-f9eaa11e18");
            }
            _ => panic!("expected BodhiAlias"),
        }
    }

    #[test]
    fn detect_bodhi_alias_fedora() {
        match detect_input_type("FEDORA-2026-abc123") {
            InputKind::BodhiAlias(alias) => {
                assert_eq!(alias, "FEDORA-2026-abc123");
            }
            _ => panic!("expected BodhiAlias"),
        }
    }

    #[test]
    fn detect_bodhi_url() {
        let url = "https://bodhi.fedoraproject.org/updates/FEDORA-EPEL-2026-f9eaa11e18";
        match detect_input_type(url) {
            InputKind::BodhiAlias(alias) => {
                assert_eq!(alias, "FEDORA-EPEL-2026-f9eaa11e18");
            }
            _ => panic!("expected BodhiAlias"),
        }
    }

    #[test]
    fn detect_bodhi_url_http() {
        let url = "http://bodhi.fedoraproject.org/updates/FEDORA-2026-xyz";
        match detect_input_type(url) {
            InputKind::BodhiAlias(alias) => {
                assert_eq!(alias, "FEDORA-2026-xyz");
            }
            _ => panic!("expected BodhiAlias"),
        }
    }
}
