use crate::file_ext::{append_str_ext, file_ext, file_ext_is_empty};
use expr::{Context, Environment, Program, Value};
use eyre::{Result, eyre};
use indexmap::IndexSet;
use itertools::Itertools;
use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Deserializer};
use std::cmp::PartialEq;
use std::collections::HashMap;
use versions::Versioning;

/// Type of Aqua package
#[derive(
    Debug,
    Deserialize,
    Archive,
    RkyvDeserialize,
    RkyvSerialize,
    Default,
    Copy,
    Clone,
    PartialEq,
    strum::Display,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum AquaPackageType {
    GithubArchive,
    GithubContent,
    #[default]
    GithubRelease,
    Http,
    GoInstall,
    GoBuild,
    Cargo,
}

/// Main Aqua package definition
///
/// rkyv archives parsed package data only. Runtime-only fields mirror serde's
/// skipped behavior with `rkyv::with::Skip`.
#[derive(Debug, Default, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
#[rkyv(serialize_bounds(
    __S: rkyv::ser::Writer + rkyv::ser::Allocator,
    __S::Error: rkyv::rancor::Source,
))]
#[rkyv(deserialize_bounds(__D::Error: rkyv::rancor::Source))]
#[rkyv(bytecheck(
    bounds(
        __C: rkyv::validation::ArchiveContext,
        __C::Error: rkyv::rancor::Source,
    )
))]
#[serde(default)]
pub struct AquaPackage {
    pub r#type: Option<AquaPackageType>,
    pub repo_owner: String,
    pub repo_name: String,
    pub name: Option<String>,
    pub asset: String,
    pub url: String,
    pub description: Option<String>,
    pub format: String,
    pub rosetta2: Option<bool>,
    pub windows_arm_emulation: Option<bool>,
    pub complete_windows_ext: Option<bool>,
    pub windows_ext: String,
    pub append_ext: Option<bool>,
    pub supported_envs: Vec<String>,
    pub files: Vec<AquaFile>,
    pub vars: Vec<AquaVar>,
    #[serde(default, deserialize_with = "deserialize_string_map")]
    pub replacements: HashMap<String, String>,
    pub version_prefix: Option<String>,
    version_filter: Option<String>,
    #[serde(skip)]
    #[rkyv(with = rkyv::with::Skip)]
    version_filter_expr: Option<Program>,
    pub version_source: Option<String>,
    pub cosign: Option<AquaCosign>,
    pub checksum: Option<AquaChecksum>,
    pub slsa_provenance: Option<AquaSlsaProvenance>,
    pub minisign: Option<AquaMinisign>,
    pub github_artifact_attestations: Option<AquaGithubArtifactAttestations>,
    format_overrides: Vec<AquaFormatOverride>,
    #[rkyv(omit_bounds)]
    overrides: Vec<AquaOverride>,
    version_constraint: String,
    #[rkyv(omit_bounds)]
    pub version_overrides: Vec<AquaPackage>,
    pub no_asset: Option<bool>,
    pub private: bool,
    pub error_message: Option<String>,
    pub path: Option<String>,
    #[serde(skip)]
    #[rkyv(with = rkyv::with::Skip)]
    var_values: HashMap<String, String>,
}

/// Override configuration for specific OS/architecture combinations
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
struct AquaOverride {
    #[serde(flatten)]
    pkg: AquaPackage,
    goos: Option<String>,
    goarch: Option<String>,
    #[serde(default)]
    envs: Vec<String>,
    #[serde(default)]
    variants: Vec<AquaVariant>,
}

/// Format override for a specific GOOS.
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
struct AquaFormatOverride {
    goos: String,
    format: String,
}

/// Runtime variant selector for an override.
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
struct AquaVariant {
    key: String,
    value: String,
}

#[derive(Debug, Clone, Copy, Default)]
struct AquaRuntime<'a> {
    libc: Option<&'a str>,
}

/// Variable definition for Aqua templates
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone, Default)]
pub struct AquaVar {
    pub name: String,
    /// Aqua's schema allows arbitrary YAML defaults, but mise intentionally
    /// supports only string defaults to keep variable resolution simple.
    #[serde(default, deserialize_with = "deserialize_optional_scalar_string")]
    pub default: Option<String>,
    #[serde(default)]
    pub required: bool,
}

/// File definition within a package
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone, Default)]
pub struct AquaFile {
    pub name: String,
    pub src: Option<String>,
    pub link: Option<String>,
    #[serde(default)]
    pub hard: bool,
}

/// Checksum algorithm options
#[derive(
    Debug,
    Deserialize,
    Archive,
    RkyvDeserialize,
    RkyvSerialize,
    Clone,
    strum::AsRefStr,
    strum::Display,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AquaChecksumAlgorithm {
    Sha1,
    Sha256,
    Sha512,
    Md5,
}

/// Type of checksum source
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum AquaChecksumType {
    GithubRelease,
    Http,
}

/// Type of minisign source
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum AquaMinisignType {
    GithubRelease,
    Http,
}

/// Cosign signature configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaCosignSignature {
    pub r#type: Option<String>,
    pub repo_owner: Option<String>,
    pub repo_name: Option<String>,
    pub url: Option<String>,
    pub asset: Option<String>,
}

/// Cosign verification configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaCosign {
    pub enabled: Option<bool>,
    pub signature: Option<AquaCosignSignature>,
    pub key: Option<AquaCosignSignature>,
    pub certificate: Option<AquaCosignSignature>,
    pub bundle: Option<AquaCosignSignature>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    opts: Vec<String>,
}

/// SLSA provenance configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaSlsaProvenance {
    pub enabled: Option<bool>,
    pub r#type: Option<String>,
    pub repo_owner: Option<String>,
    pub repo_name: Option<String>,
    pub url: Option<String>,
    pub asset: Option<String>,
    pub source_uri: Option<String>,
    pub source_tag: Option<String>,
}

/// Minisign verification configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaMinisign {
    pub enabled: Option<bool>,
    pub r#type: Option<AquaMinisignType>,
    pub repo_owner: Option<String>,
    pub repo_name: Option<String>,
    pub url: Option<String>,
    pub asset: Option<String>,
    pub public_key: Option<String>,
}

/// GitHub artifact attestations configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaGithubArtifactAttestations {
    pub enabled: Option<bool>,
    pub predicate_type: Option<String>,
    pub signer_workflow: Option<String>,
}

/// Checksum verification configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaChecksum {
    pub r#type: Option<AquaChecksumType>,
    pub algorithm: Option<AquaChecksumAlgorithm>,
    pub pattern: Option<AquaChecksumPattern>,
    pub cosign: Option<AquaCosign>,
    pub minisign: Option<AquaMinisign>,
    pub github_artifact_attestations: Option<AquaGithubArtifactAttestations>,
    #[serde(default, deserialize_with = "deserialize_optional_string_map")]
    replacements: Option<HashMap<String, String>>,
    file_format: Option<String>,
    enabled: Option<bool>,
    asset: Option<String>,
    url: Option<String>,
}

/// Checksum pattern configuration
#[derive(Debug, Deserialize, Archive, RkyvDeserialize, RkyvSerialize, Clone)]
pub struct AquaChecksumPattern {
    pub checksum: String,
    pub file: Option<String>,
}

/// Registry YAML file structure
#[derive(Debug, Deserialize)]
pub struct RegistryYaml {
    pub packages: Vec<RegistryPackageRow>,
}

/// Top-level package row in a merged aqua registry YAML file.
#[derive(Debug, Deserialize)]
pub struct RegistryPackageRow {
    #[serde(flatten)]
    pub package: AquaPackage,
    #[serde(default, deserialize_with = "deserialize_registry_aliases")]
    pub aliases: Vec<String>,
}

fn deserialize_registry_aliases<'de, D>(
    deserializer: D,
) -> std::result::Result<Vec<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let aliases = Option::<serde_yaml::Value>::deserialize(deserializer)?;
    Ok(aliases
        .and_then(|aliases| {
            aliases
                .as_sequence()
                .map(|aliases| aliases.iter().filter_map(registry_alias_name).collect())
        })
        .unwrap_or_default())
}

fn registry_alias_name(alias: &serde_yaml::Value) -> Option<String> {
    alias.get("name")?.as_str().map(str::to_string)
}

fn deserialize_optional_scalar_string<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
    match value {
        None | Some(serde_yaml::Value::Null) => Ok(None),
        Some(value) => yaml_scalar_to_string(value).map(Some).ok_or_else(|| {
            <D::Error as serde::de::Error>::custom("invalid type: expected a scalar string default")
        }),
    }
}

fn yaml_mapping_to_string_map<D: serde::de::Error>(
    value: serde_yaml::Value,
) -> std::result::Result<HashMap<String, String>, D> {
    let serde_yaml::Value::Mapping(mapping) = value else {
        return Err(D::custom("invalid type: expected a scalar string map"));
    };

    mapping
        .into_iter()
        .map(|(key, value)| {
            let key = yaml_scalar_to_string(key)
                .ok_or_else(|| D::custom("invalid type: expected a scalar string map key"))?;
            let value = yaml_scalar_to_string(value)
                .ok_or_else(|| D::custom("invalid type: expected a scalar string map value"))?;
            Ok((key, value))
        })
        .collect()
}

fn deserialize_string_map<'de, D>(
    deserializer: D,
) -> std::result::Result<HashMap<String, String>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
    let Some(value) = value else {
        return Ok(HashMap::new());
    };
    yaml_mapping_to_string_map(value)
}

fn deserialize_optional_string_map<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<HashMap<String, String>>, D::Error>
where
    D: Deserializer<'de>,
{
    let Some(value) = Option::<serde_yaml::Value>::deserialize(deserializer)? else {
        return Ok(None);
    };
    if matches!(value, serde_yaml::Value::Null) {
        return Ok(None);
    }
    yaml_mapping_to_string_map(value).map(Some)
}

fn yaml_scalar_to_string(value: serde_yaml::Value) -> Option<String> {
    match value {
        serde_yaml::Value::String(value) => Some(value),
        serde_yaml::Value::Bool(value) => Some(value.to_string()),
        serde_yaml::Value::Number(value) => Some(value.to_string()),
        _ => None,
    }
}

impl AquaPackage {
    /// Return the package type, preserving aqua's default of `github_release`
    /// when the field is omitted.
    pub fn package_type(&self) -> AquaPackageType {
        self.r#type.unwrap_or_default()
    }

    /// Apply version-specific configurations and overrides
    pub fn with_version(self, versions: &[&str], os: &str, arch: &str) -> AquaPackage {
        self.with_version_runtime(versions, os, arch, AquaRuntime::default())
    }

    /// Apply version-specific configurations and overrides for a libc runtime variant.
    pub fn with_version_libc(
        self,
        versions: &[&str],
        os: &str,
        arch: &str,
        libc: Option<&str>,
    ) -> AquaPackage {
        self.with_version_runtime(versions, os, arch, AquaRuntime { libc })
    }

    fn with_version_runtime(
        mut self,
        versions: &[&str],
        os: &str,
        arch: &str,
        runtime: AquaRuntime<'_>,
    ) -> AquaPackage {
        if let Some(version_override) = self
            .version_override(versions)
            .filter(|version_override| !std::ptr::eq(*version_override, &self))
            .cloned()
        {
            self = apply_override(self, &version_override);
        }
        self.apply_format_override(os);
        if let Some(pkg) = self
            .overrides
            .iter()
            .find(|o| o.matches(os, arch, runtime))
            .map(|o| o.pkg.clone())
        {
            self = apply_override(self, &pkg)
        }
        self
    }

    fn apply_format_override(&mut self, os: &str) {
        if let Some(format_override) = self
            .format_overrides
            .iter()
            .find(|format_override| format_override.matches(os))
        {
            self.format = format_override.format.clone();
        }
    }

    /// Apply user-provided variable values used by aqua `vars` templates.
    pub fn with_var_values(mut self, var_values: HashMap<String, String>) -> Result<AquaPackage> {
        self.var_values = var_values;
        self.validate_vars()?;
        Ok(self)
    }

    pub fn version_constraint_ok(&self, versions: &[&str]) -> bool {
        self.version_override(versions).is_some()
    }

    fn version_override(&self, versions: &[&str]) -> Option<&AquaPackage> {
        // Aqua treats a package without a top-level constraint as unconditional.
        // In that case version overrides are not considered.
        if self.version_constraint.is_empty() {
            return Some(self);
        }
        let expressions = versions
            .iter()
            .map(|v| (*v, self.expr_parser(v), self.expr_ctx(v)))
            .collect_vec();
        vec![self]
            .into_iter()
            .chain(self.version_overrides.iter())
            .find(|vo| {
                expressions.iter().any(|(version, expr, ctx)| {
                    let version_prefix =
                        vo.version_prefix.as_ref().or(self.version_prefix.as_ref());
                    if version_prefix.is_some_and(|prefix| !version.starts_with(prefix)) {
                        return false;
                    }
                    if vo.version_constraint.is_empty() {
                        true
                    } else {
                        expr.eval(&vo.version_constraint, ctx)
                            .map_err(|e| {
                                log::debug!("error parsing {}: {e}", vo.version_constraint)
                            })
                            .unwrap_or(false.into())
                            .as_bool()
                            .unwrap()
                    }
                })
            })
    }

    /// Detect the format of an archive based on its filename
    fn detect_format(&self, asset_name: &str) -> &'static str {
        for &format in AQUA_ASSET_FORMATS {
            if asset_name.ends_with(&format!(".{format}")) {
                return format;
            }
        }
        "raw"
    }

    fn append_ext_enabled(&self) -> bool {
        self.append_ext.unwrap_or(true)
    }

    pub fn windows_ext(&self) -> &str {
        if self.windows_ext.is_empty() {
            match self.package_type() {
                AquaPackageType::GithubArchive | AquaPackageType::GithubContent => ".sh",
                _ => ".exe",
            }
        } else {
            &self.windows_ext
        }
    }

    pub fn complete_windows_ext_enabled(&self) -> bool {
        match self.complete_windows_ext {
            Some(complete) => complete,
            None => !matches!(
                self.package_type(),
                AquaPackageType::GithubArchive | AquaPackageType::GithubContent
            ),
        }
    }

    fn complete_windows_ext(&self, s: &str) -> String {
        if self.complete_windows_ext_enabled() {
            append_str_ext(s, self.windows_ext())
        } else {
            s.to_string()
        }
    }

    fn os_file_ext_is_empty(&self, s: &str, version: &str) -> bool {
        let filename = s.rsplit('/').next().unwrap_or_default();
        file_ext_is_empty(filename, version)
    }

    fn os_file_ext(&self, s: &str, version: &str) -> Option<String> {
        let filename = s.rsplit('/').next().unwrap_or_default();
        file_ext(filename, version)
    }

    fn append_ext(&self, s: String) -> String {
        if !self.append_ext_enabled() || self.format.is_empty() || self.format == "raw" {
            return s;
        }
        if self.detect_format(&s) != "raw" || s.ends_with(&format!(".{}", self.format)) {
            return s;
        }
        format!("{}.{}", s, self.format)
    }

    fn asset_without_appended_ext(
        &self,
        v: &str,
        overrides: &HashMap<String, String>,
        os: &str,
        arch: &str,
    ) -> Result<String> {
        if self.asset.is_empty() && self.url.split('/').count() > "//".len() {
            let asset = self.url.rsplit('/').next().unwrap_or("");
            self.parse_aqua_str(asset, v, overrides, os, arch)
        } else {
            self.parse_aqua_str(&self.asset, v, overrides, os, arch)
        }
    }

    fn finish_asset(&self, asset: String, v: &str, os: &str) -> Result<String> {
        let asset = self.append_ext(asset);
        self.complete_windows_ext_to_asset(&asset, v, os)
    }

    /// Get the format for this package and version
    pub fn format(&self, v: &str, os: &str, arch: &str) -> Result<&str> {
        if self.package_type() == AquaPackageType::GithubArchive {
            return Ok("tar.gz");
        }
        let format = if self.format.is_empty() {
            let asset = if !self.asset.is_empty() {
                self.asset_without_appended_ext(v, &Default::default(), os, arch)?
            } else if !self.url.is_empty() {
                self.parse_aqua_str(&self.url, v, &Default::default(), os, arch)?
            } else {
                log::debug!("no asset or url for {}/{}", self.repo_owner, self.repo_name);
                String::new()
            };
            self.detect_format(&asset)
        } else {
            &self.format
        };
        Ok(format)
    }

    /// Get the asset name for this package and version
    pub fn asset(&self, v: &str, os: &str, arch: &str) -> Result<String> {
        let asset = self.asset_without_appended_ext(v, &Default::default(), os, arch)?;
        self.finish_asset(asset, v, os)
    }

    /// Get all possible asset strings for this package, version and platform
    pub fn asset_strs(&self, v: &str, os: &str, arch: &str) -> Result<IndexSet<String>> {
        let mut strs = IndexSet::new();
        let asset = self.asset_without_appended_ext(v, &Default::default(), os, arch)?;
        strs.insert(self.finish_asset(asset.clone(), v, os)?);
        strs.insert(asset);
        if os == "darwin" {
            let mut ctx = HashMap::default();
            ctx.insert("Arch".to_string(), "universal".to_string());
            let asset = self.asset_without_appended_ext(v, &ctx, os, arch)?;
            strs.insert(self.finish_asset(asset.clone(), v, os)?);
            strs.insert(asset);
        } else if os == "windows" {
            let mut ctx = HashMap::default();
            if arch == "arm64" {
                ctx.insert("Arch".to_string(), "amd64".to_string());
                let asset = self.asset_without_appended_ext(v, &ctx, os, arch)?;
                strs.insert(self.finish_asset(asset.clone(), v, os)?);
                strs.insert(asset);
            }
        }
        Ok(strs)
    }

    /// Apply Windows executable extension to an asset or URL string if appropriate.
    /// Mirrors upstream aqua's `completeWindowsExtToAsset` decision tree.
    fn complete_windows_ext_to_asset(&self, s: &str, v: &str, os: &str) -> Result<String> {
        if os != "windows" || s.ends_with(".exe") || s.ends_with(".jar") {
            return Ok(s.to_string());
        }
        if self.format == "raw" {
            return Ok(self.complete_windows_ext(s));
        }
        if !self.format.is_empty() {
            return Ok(s.to_string());
        }
        if self.os_file_ext_is_empty(s, v) {
            return Ok(self.complete_windows_ext(s));
        }
        Ok(s.to_string())
    }

    /// Apply Windows executable completion to install file source paths.
    /// Mirrors upstream aqua's `completeWindowsExtToFileSrc`.
    pub fn complete_windows_ext_to_file_src(&self, src: &str, v: &str, os: &str) -> String {
        if os != "windows" || !self.complete_windows_ext_enabled() {
            return src.to_string();
        }
        if self.os_file_ext_is_empty(src, v) {
            self.complete_windows_ext(src)
        } else {
            src.to_string()
        }
    }

    /// Apply Windows executable completion to link destinations, preserving an
    /// explicit source extension when the destination omits one.
    pub fn complete_windows_ext_to_file_dst(
        &self,
        src: &str,
        dst: &str,
        v: &str,
        os: &str,
    ) -> String {
        if os != "windows"
            || !self.complete_windows_ext_enabled()
            || !self.os_file_ext_is_empty(dst, v)
        {
            return dst.to_string();
        }
        match self.os_file_ext(src, v) {
            Some(ext) => append_str_ext(dst, &ext),
            None => self.complete_windows_ext(dst),
        }
    }

    /// Get the URL for this package and version
    pub fn url(&self, v: &str, os: &str, arch: &str) -> Result<String> {
        let url = self.parse_aqua_str(&self.url, v, &Default::default(), os, arch)?;
        let url = self.append_ext(url);
        self.complete_windows_ext_to_asset(&url, v, os)
    }

    /// Parse an Aqua template string with variable substitution and platform info
    pub fn parse_aqua_str(
        &self,
        s: &str,
        v: &str,
        overrides: &HashMap<String, String>,
        os: &str,
        arch: &str,
    ) -> Result<String> {
        let mut ctx = self.template_context(&self.replacements, v, os, arch);
        ctx.extend(self.vars_ctx()?);
        ctx.extend(overrides.clone());

        crate::template::render(s, &ctx)
    }

    fn actual_arch<'a>(&self, os: &str, arch: &'a str) -> &'a str {
        if (os == "darwin" && arch == "arm64" && self.rosetta2.unwrap_or(false))
            || (os == "windows" && arch == "arm64" && self.windows_arm_emulation.unwrap_or(false))
        {
            "amd64"
        } else {
            arch
        }
    }

    fn template_context(
        &self,
        replacements: &HashMap<String, String>,
        v: &str,
        os: &str,
        arch: &str,
    ) -> HashMap<String, String> {
        let actual_arch = self.actual_arch(os, arch);
        let replace = |s: &str| {
            replacements
                .get(s)
                .map(|s| s.to_string())
                .unwrap_or_else(|| s.to_string())
        };

        let semver = if let Some(prefix) = &self.version_prefix {
            v.strip_prefix(prefix).unwrap_or(v)
        } else {
            v
        };

        HashMap::from([
            ("Version".to_string(), replace(v)),
            ("SemVer".to_string(), replace(semver)),
            ("OS".to_string(), replace(os)),
            ("GOOS".to_string(), replace(os)),
            ("GOARCH".to_string(), replace(actual_arch)),
            ("Arch".to_string(), replace(actual_arch)),
            ("Format".to_string(), replace(&self.format)),
        ])
    }

    fn vars_ctx(&self) -> Result<HashMap<String, String>> {
        self.validate_vars()?;
        let mut ctx = HashMap::new();
        for var in &self.vars {
            if let Some(value) = self.var_value(var)? {
                ctx.insert(format!("Vars.{}", var.name), value);
            }
        }
        Ok(ctx)
    }

    fn validate_vars(&self) -> Result<()> {
        for var in &self.vars {
            if var.name.is_empty() {
                return Err(eyre!("aqua var name is empty"));
            }
            if var.required && self.var_value(var)?.is_none() {
                return Err(eyre!("required aqua var not set: {}", var.name));
            }
        }
        Ok(())
    }

    fn var_value(&self, var: &AquaVar) -> Result<Option<String>> {
        if let Some(value) = self.var_values.get(&var.name) {
            return Ok(Some(value.clone()));
        }
        Ok(var.default.clone())
    }

    /// Set up version filter expression if configured
    pub fn setup_version_filter(&mut self) -> Result<()> {
        if let Some(version_filter) = &self.version_filter {
            self.version_filter_expr = Some(expr::compile(version_filter)?);
        }
        Ok(())
    }

    /// Check if a version passes the version filter
    pub fn version_filter_ok(&self, v: &str) -> Result<bool> {
        if let Some(filter) = self.version_filter_expr.clone() {
            if let Value::Bool(expr) = self.expr(v, filter)? {
                Ok(expr)
            } else {
                log::warn!(
                    "invalid response from version filter: {}",
                    self.version_filter.as_ref().unwrap()
                );
                Ok(true)
            }
        } else {
            Ok(true)
        }
    }

    fn expr(&self, v: &str, program: Program) -> Result<Value> {
        let expr = self.expr_parser(v);
        expr.run(program, &self.expr_ctx(v)).map_err(|e| eyre!(e))
    }

    fn expr_parser(&self, v: &str) -> Environment<'_> {
        let (_, v) = split_version_prefix(v);
        let ver = Versioning::new(v);
        let mut env = Environment::new();
        env.add_function("semver", move |c| {
            if c.args.len() != 1 {
                return Err("semver() takes exactly one argument".to_string().into());
            }
            let requirements = c.args[0]
                .as_string()
                .unwrap()
                .replace(' ', "")
                .split(',')
                .map(AquaRequirement::parse)
                .collect::<Vec<_>>();
            if requirements.iter().any(|r| r.is_none()) {
                return Err("invalid semver requirement".to_string().into());
            }
            if let Some(ver) = &ver {
                Ok(requirements
                    .iter()
                    .all(|r| r.as_ref().is_some_and(|r| r.matches(ver)))
                    .into())
            } else {
                Err("invalid version".to_string().into())
            }
        });
        env
    }

    fn expr_ctx(&self, v: &str) -> Context {
        let mut ctx = Context::default();
        ctx.insert("Version", v);
        ctx
    }
}

/// One comma-separated term of an aqua `semver()` constraint, e.g. `<=5.34.1.0` or `!=0.0.45`.
///
/// `versions::Requirement::new` cannot express these. It requires the whole string to parse,
/// and the `Versioning::parse` inside it tries `SemVer` first — so a bound like `5.34.1.0` is
/// consumed as `5.34.1` with `.0` left over and the requirement is rejected outright. It also
/// has no operator for `!=`. Either way the caller only sees `None`, which
/// `AquaPackage::version_override` turns into a silently false constraint.
///
/// aqua evaluates these with `hashicorp/go-version`, which accepts any number of components and
/// supports `!=`, so the operator is split off here and the bound read with `Versioning::new`,
/// which does fall back to `Version` for such strings. The comparison itself is still
/// `versions::Requirement::matches`, so ordering, tilde and caret semantics are unchanged.
struct AquaRequirement {
    inner: versions::Requirement,
    /// `!=` has no `versions::Op`, so it is `Exact` read backwards.
    negated: bool,
}

impl AquaRequirement {
    fn parse(s: &str) -> Option<Self> {
        if s == "*" {
            return Some(Self {
                inner: versions::Requirement {
                    op: versions::Op::Wildcard,
                    version: None,
                },
                negated: false,
            });
        }
        // Longest operators first, or `>=` would be read as `>` with a leading `=` on the bound.
        let (op, negated, bound) = [
            (">=", versions::Op::GreaterEq, false),
            ("<=", versions::Op::LessEq, false),
            ("!=", versions::Op::Exact, true),
            (">", versions::Op::Greater, false),
            ("<", versions::Op::Less, false),
            ("=", versions::Op::Exact, false),
            ("~", versions::Op::Tilde, false),
            ("^", versions::Op::Caret, false),
        ]
        .into_iter()
        .find_map(|(prefix, op, negated)| {
            s.strip_prefix(prefix).map(|bound| (op, negated, bound))
        })?;
        Some(Self {
            inner: versions::Requirement {
                op,
                version: Some(Versioning::new(bound)?),
            },
            negated,
        })
    }

    fn matches(&self, v: &Versioning) -> bool {
        self.inner.matches(v) != self.negated
    }
}

impl AquaOverride {
    fn matches(&self, os: &str, arch: &str, runtime: AquaRuntime<'_>) -> bool {
        self.goos.as_ref().is_none_or(|goos| goos == os)
            && self.goarch.as_ref().is_none_or(|goarch| goarch == arch)
            && (self.envs.is_empty() || envs_match(&self.envs, os, arch))
            && self
                .variants
                .iter()
                .all(|variant| variant.matches(os, runtime))
    }
}

impl AquaFormatOverride {
    fn matches(&self, os: &str) -> bool {
        self.goos == os
    }
}

fn envs_match(envs: &[String], os: &str, arch: &str) -> bool {
    let os_arch = format!("{os}/{arch}");
    // Aqua env selectors accept GOOS, GOARCH, GOOS/GOARCH, or the wildcard "all".
    envs.iter()
        .any(|env| env == "all" || env == os || env == arch || env == &os_arch)
}

impl AquaVariant {
    fn matches(&self, os: &str, runtime: AquaRuntime<'_>) -> bool {
        match self.key.as_str() {
            "libc" => match (
                normalize_libc(runtime.libc),
                normalize_libc(Some(&self.value)),
            ) {
                (Some(actual), Some(expected)) => os == "linux" && actual == expected,
                _ => false,
            },
            key => {
                log::debug!("unsupported aqua override variant key: {key}");
                false
            }
        }
    }
}

fn normalize_libc(libc: Option<&str>) -> Option<&str> {
    match libc? {
        "glibc" | "gnu" => Some("gnu"),
        "musl" => Some("musl"),
        _ => None,
    }
}
/// splits a version number into an optional prefix and the remaining version string
fn split_version_prefix(version: &str) -> (String, String) {
    version
        .char_indices()
        .find_map(|(i, c)| {
            if c.is_ascii_digit() {
                if i == 0 {
                    return Some(i);
                }
                // If the previous char is a delimiter or 'v', we found a split point.
                let prev_char = version.chars().nth(i - 1).unwrap();
                if ['-', '_', '/', '.', 'v', 'V'].contains(&prev_char) {
                    return Some(i);
                }
            }
            None
        })
        .map_or_else(
            || ("".into(), version.into()),
            |i| {
                let (prefix, version) = version.split_at(i);
                (prefix.into(), version.into())
            },
        )
}

const AQUA_ASSET_FORMATS: &[&str] = &[
    "tar.br", "tar.bz2", "tar.gz", "tar.lz4", "tar.sz", "tar.xz", "tbr", "tbz", "tbz2", "tgz",
    "tlz4", "tsz", "txz", "tar.zst", "zip", "7z", "gz", "bz2", "lz4", "sz", "xz", "zst", "dmg",
    "pkg", "rar", "tar",
];

fn asset_without_ext(asset: &str) -> &str {
    AQUA_ASSET_FORMATS
        .iter()
        .find_map(|format| asset.strip_suffix(format)?.strip_suffix('.'))
        .unwrap_or(asset)
}

impl AquaFile {
    fn template_ctx(
        &self,
        pkg: &AquaPackage,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<HashMap<String, String>> {
        let asset = pkg.asset(v, os, arch)?;
        let asset = asset_without_ext(&asset);

        let mut ctx = HashMap::new();
        ctx.insert("AssetWithoutExt".to_string(), asset.to_string());
        ctx.insert("FileName".to_string(), self.name.to_string());
        Ok(ctx)
    }

    /// Get the source path for this file within the package
    pub fn src(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<Option<String>> {
        let ctx = self.template_ctx(pkg, v, os, arch)?;
        self.src
            .as_ref()
            .map(|src| pkg.parse_aqua_str(src, v, &ctx, os, arch))
            .transpose()
    }

    /// Get the link path for this file.
    pub fn link(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<Option<String>> {
        let ctx = self.template_ctx(pkg, v, os, arch)?;
        self.link
            .as_ref()
            .map(|link| pkg.parse_aqua_str(link, v, &ctx, os, arch))
            .transpose()
    }
}

fn apply_override(mut orig: AquaPackage, avo: &AquaPackage) -> AquaPackage {
    if let Some(r#type) = avo.r#type {
        orig.r#type = Some(r#type);
    }
    if !avo.repo_owner.is_empty() {
        orig.repo_owner = avo.repo_owner.clone();
    }
    if !avo.repo_name.is_empty() {
        orig.repo_name = avo.repo_name.clone();
    }
    if !avo.asset.is_empty() {
        orig.asset = avo.asset.clone();
    }
    if !avo.url.is_empty() {
        orig.url = avo.url.clone();
    }
    if !avo.format.is_empty() {
        orig.format = avo.format.clone();
    }
    if avo.rosetta2.is_some() {
        orig.rosetta2 = avo.rosetta2;
    }
    if avo.windows_arm_emulation.is_some() {
        orig.windows_arm_emulation = avo.windows_arm_emulation;
    }
    if avo.complete_windows_ext.is_some() {
        orig.complete_windows_ext = avo.complete_windows_ext;
    }
    if !avo.windows_ext.is_empty() {
        orig.windows_ext = avo.windows_ext.clone();
    }
    if avo.append_ext.is_some() {
        orig.append_ext = avo.append_ext;
    }
    if !avo.supported_envs.is_empty() {
        orig.supported_envs = avo.supported_envs.clone();
    }
    if !avo.files.is_empty() {
        orig.files = avo.files.clone();
    }
    if !avo.vars.is_empty() {
        orig.vars = avo.vars.clone();
    }
    orig.replacements.extend(avo.replacements.clone());
    if let Some(avo_version_prefix) = avo.version_prefix.clone() {
        orig.version_prefix = Some(avo_version_prefix);
    }
    if !avo.format_overrides.is_empty() {
        orig.format_overrides = avo.format_overrides.clone();
    }
    if !avo.overrides.is_empty() {
        orig.overrides = avo.overrides.clone();
    }

    if let Some(avo_checksum) = avo.checksum.clone() {
        match &mut orig.checksum {
            Some(checksum) => {
                checksum.merge(avo_checksum.clone());
            }
            None => {
                orig.checksum = Some(avo_checksum.clone());
            }
        }
    }

    if let Some(avo_cosign) = &avo.cosign {
        match &mut orig.cosign {
            Some(cosign) => {
                cosign.merge(avo_cosign.clone());
            }
            None => {
                orig.cosign = Some(avo_cosign.clone());
            }
        }
    }

    if let Some(avo_slsa_provenance) = avo.slsa_provenance.clone() {
        match &mut orig.slsa_provenance {
            Some(slsa_provenance) => {
                slsa_provenance.merge(avo_slsa_provenance.clone());
            }
            None => {
                orig.slsa_provenance = Some(avo_slsa_provenance.clone());
            }
        }
    }

    if let Some(avo_minisign) = avo.minisign.clone() {
        match &mut orig.minisign {
            Some(minisign) => {
                minisign.merge(avo_minisign.clone());
            }
            None => {
                orig.minisign = Some(avo_minisign.clone());
            }
        }
    }

    if let Some(avo_attestations) = avo.github_artifact_attestations.clone() {
        match &mut orig.github_artifact_attestations {
            Some(orig_attestations) => {
                orig_attestations.merge(avo_attestations.clone());
            }
            None => {
                orig.github_artifact_attestations = Some(avo_attestations.clone());
            }
        }
    }

    if avo.no_asset.is_some() {
        orig.no_asset = avo.no_asset;
    }
    if let Some(error_message) = avo.error_message.clone() {
        orig.error_message = Some(error_message);
    }
    if let Some(path) = avo.path.clone() {
        orig.path = Some(path);
    }
    orig
}

// Implementation of merge methods for various types
impl AquaChecksum {
    pub fn _type(&self) -> &AquaChecksumType {
        self.r#type.as_ref().unwrap()
    }

    pub fn algorithm(&self) -> &AquaChecksumAlgorithm {
        self.algorithm.as_ref().unwrap()
    }

    pub fn asset_strs(
        &self,
        pkg: &AquaPackage,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<IndexSet<String>> {
        let mut asset_strs = IndexSet::new();
        for asset in pkg.asset_strs(v, os, arch)? {
            let checksum_asset = self.asset.as_ref().unwrap();
            let mut ctx = self.template_ctx(pkg, v, os, arch)?;
            ctx.insert("Asset".to_string(), asset.to_string());
            asset_strs.insert(pkg.parse_aqua_str(checksum_asset, v, &ctx, os, arch)?);
        }
        Ok(asset_strs)
    }

    pub fn pattern(&self) -> &AquaChecksumPattern {
        self.pattern.as_ref().unwrap()
    }

    pub fn enabled(&self) -> bool {
        self.enabled.unwrap_or(true)
    }

    pub fn file_format(&self) -> &str {
        self.file_format.as_deref().unwrap_or("raw")
    }

    pub fn url(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<String> {
        pkg.parse_aqua_str(
            self.url.as_ref().unwrap(),
            v,
            &self.template_ctx(pkg, v, os, arch)?,
            os,
            arch,
        )
    }

    fn merge(&mut self, other: Self) {
        if let Some(r#type) = other.r#type {
            self.r#type = Some(r#type);
        }
        if let Some(algorithm) = other.algorithm {
            self.algorithm = Some(algorithm);
        }
        if let Some(pattern) = other.pattern {
            self.pattern = Some(pattern);
        }
        if let Some(enabled) = other.enabled {
            self.enabled = Some(enabled);
        }
        if let Some(asset) = other.asset {
            self.asset = Some(asset);
        }
        if let Some(url) = other.url {
            self.url = Some(url);
        }
        if let Some(file_format) = other.file_format {
            self.file_format = Some(file_format);
        }
        if let Some(replacements) = other.replacements {
            if replacements.is_empty() {
                self.replacements = Some(HashMap::new());
            } else {
                self.replacements
                    .get_or_insert_with(HashMap::new)
                    .extend(replacements);
            }
        }
        if let Some(cosign) = other.cosign {
            if self.cosign.is_none() {
                self.cosign = Some(cosign.clone());
            }
            self.cosign.as_mut().unwrap().merge(cosign);
        }
        if let Some(minisign) = other.minisign {
            if self.minisign.is_none() {
                self.minisign = Some(minisign.clone());
            }
            self.minisign.as_mut().unwrap().merge(minisign);
        }
        if let Some(attestations) = other.github_artifact_attestations {
            if self.github_artifact_attestations.is_none() {
                self.github_artifact_attestations = Some(attestations.clone());
            }
            self.github_artifact_attestations
                .as_mut()
                .unwrap()
                .merge(attestations);
        }
    }

    pub fn template_ctx(
        &self,
        pkg: &AquaPackage,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<HashMap<String, String>> {
        let mut ctx = pkg.template_context(&self.effective_replacements(pkg), v, os, arch);
        if pkg.package_type() == AquaPackageType::Http {
            ctx.insert("AssetURL".to_string(), pkg.url(v, os, arch)?);
        }
        Ok(ctx)
    }

    fn effective_replacements(&self, pkg: &AquaPackage) -> HashMap<String, String> {
        match &self.replacements {
            None => pkg.replacements.clone(),
            Some(replacements) if replacements.is_empty() => HashMap::new(),
            Some(replacements) => {
                let mut merged = pkg.replacements.clone();
                merged.extend(replacements.clone());
                merged
            }
        }
    }
}

impl AquaCosign {
    // TODO: This does not support `{{.Asset}}`.
    pub fn opts(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<Vec<String>> {
        self.opts
            .iter()
            .map(|opt| pkg.parse_aqua_str(opt, v, &Default::default(), os, arch))
            .collect()
    }

    fn merge(&mut self, other: Self) {
        if let Some(enabled) = other.enabled {
            self.enabled = Some(enabled);
        }
        if let Some(signature) = other.signature.clone() {
            if self.signature.is_none() {
                self.signature = Some(signature.clone());
            }
            self.signature.as_mut().unwrap().merge(signature);
        }
        if let Some(key) = other.key.clone() {
            if self.key.is_none() {
                self.key = Some(key.clone());
            }
            self.key.as_mut().unwrap().merge(key);
        }
        if let Some(certificate) = other.certificate.clone() {
            if self.certificate.is_none() {
                self.certificate = Some(certificate.clone());
            }
            self.certificate.as_mut().unwrap().merge(certificate);
        }
        if let Some(bundle) = other.bundle.clone() {
            if self.bundle.is_none() {
                self.bundle = Some(bundle.clone());
            }
            self.bundle.as_mut().unwrap().merge(bundle);
        }
        if !other.opts.is_empty() {
            self.opts = other.opts.clone();
        }
    }
}

impl AquaCosignSignature {
    pub fn url(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<String> {
        pkg.parse_aqua_str(self.url.as_ref().unwrap(), v, &Default::default(), os, arch)
    }

    pub fn asset_strs(
        &self,
        pkg: &AquaPackage,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<IndexSet<String>> {
        let mut asset_strs = IndexSet::new();
        if let Some(cosign_asset_template) = &self.asset {
            for asset in pkg.asset_strs(v, os, arch)? {
                let mut ctx = HashMap::new();
                ctx.insert("Asset".to_string(), asset.to_string());
                asset_strs.insert(pkg.parse_aqua_str(cosign_asset_template, v, &ctx, os, arch)?);
            }
        }
        Ok(asset_strs)
    }

    fn merge(&mut self, other: Self) {
        if let Some(r#type) = other.r#type {
            self.r#type = Some(r#type);
        }
        if let Some(repo_owner) = other.repo_owner {
            self.repo_owner = Some(repo_owner);
        }
        if let Some(repo_name) = other.repo_name {
            self.repo_name = Some(repo_name);
        }
        if let Some(url) = other.url {
            self.url = Some(url);
        }
        if let Some(asset) = other.asset {
            self.asset = Some(asset);
        }
    }
}

impl AquaSlsaProvenance {
    pub fn asset_strs(
        &self,
        pkg: &AquaPackage,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<IndexSet<String>> {
        let mut asset_strs = IndexSet::new();
        if let Some(slsa_asset_template) = &self.asset {
            for asset in pkg.asset_strs(v, os, arch)? {
                let mut ctx = HashMap::new();
                ctx.insert("Asset".to_string(), asset.to_string());
                asset_strs.insert(pkg.parse_aqua_str(slsa_asset_template, v, &ctx, os, arch)?);
            }
        }
        Ok(asset_strs)
    }

    pub fn url(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<String> {
        pkg.parse_aqua_str(self.url.as_ref().unwrap(), v, &Default::default(), os, arch)
    }

    fn merge(&mut self, other: Self) {
        if let Some(enabled) = other.enabled {
            self.enabled = Some(enabled);
        }
        if let Some(r#type) = other.r#type {
            self.r#type = Some(r#type);
        }
        if let Some(repo_owner) = other.repo_owner {
            self.repo_owner = Some(repo_owner);
        }
        if let Some(repo_name) = other.repo_name {
            self.repo_name = Some(repo_name);
        }
        if let Some(url) = other.url {
            self.url = Some(url);
        }
        if let Some(asset) = other.asset {
            self.asset = Some(asset);
        }
        if let Some(source_uri) = other.source_uri {
            self.source_uri = Some(source_uri);
        }
        if let Some(source_tag) = other.source_tag {
            self.source_tag = Some(source_tag);
        }
    }
}

impl AquaMinisign {
    pub fn _type(&self) -> &AquaMinisignType {
        self.r#type.as_ref().unwrap()
    }

    pub fn url(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<String> {
        pkg.parse_aqua_str(self.url.as_ref().unwrap(), v, &Default::default(), os, arch)
    }

    pub fn asset(
        &self,
        pkg: &AquaPackage,
        package_asset: &str,
        v: &str,
        os: &str,
        arch: &str,
    ) -> Result<String> {
        let mut ctx = HashMap::new();
        ctx.insert("Asset".to_string(), package_asset.to_string());
        pkg.parse_aqua_str(self.asset.as_ref().unwrap(), v, &ctx, os, arch)
    }

    pub fn public_key(&self, pkg: &AquaPackage, v: &str, os: &str, arch: &str) -> Result<String> {
        pkg.parse_aqua_str(
            self.public_key.as_ref().unwrap(),
            v,
            &Default::default(),
            os,
            arch,
        )
    }

    fn merge(&mut self, other: Self) {
        if let Some(enabled) = other.enabled {
            self.enabled = Some(enabled);
        }
        if let Some(r#type) = other.r#type {
            self.r#type = Some(r#type);
        }
        if let Some(repo_owner) = other.repo_owner {
            self.repo_owner = Some(repo_owner);
        }
        if let Some(repo_name) = other.repo_name {
            self.repo_name = Some(repo_name);
        }
        if let Some(url) = other.url {
            self.url = Some(url);
        }
        if let Some(asset) = other.asset {
            self.asset = Some(asset);
        }
        if let Some(public_key) = other.public_key {
            self.public_key = Some(public_key);
        }
    }
}

impl AquaGithubArtifactAttestations {
    fn merge(&mut self, other: Self) {
        if let Some(enabled) = other.enabled {
            self.enabled = Some(enabled);
        }
        if let Some(predicate_type) = other.predicate_type {
            self.predicate_type = Some(predicate_type);
        }
        if let Some(signer_workflow) = other.signer_workflow {
            self.signer_workflow = Some(signer_workflow);
        }
    }
}

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

    fn default_str(value: &str) -> Option<String> {
        Some(value.to_string())
    }

    fn first_registry_package(yml: &str) -> AquaPackage {
        serde_yaml::from_str::<RegistryYaml>(yml)
            .unwrap()
            .packages
            .into_iter()
            .next()
            .unwrap()
            .package
    }

    #[test]
    fn test_registry_package_row_aliases_are_top_level_only() {
        let yml = r#"
packages:
  - name: example/canonical
    aliases:
      - name: example/alias
      - name: 123
      - other: ignored
    unsupported_field: ignored
    version_overrides:
      - aliases:
          - name: example/nested-alias
"#;
        let registry = serde_yaml::from_str::<RegistryYaml>(yml).unwrap();
        let row = registry.packages.into_iter().next().unwrap();

        assert_eq!(row.package.name.as_deref(), Some("example/canonical"));
        assert_eq!(row.aliases, vec!["example/alias"]);
        assert_eq!(row.package.version_overrides.len(), 1);
    }

    #[test]
    fn test_registry_package_row_preserves_yaml_scalar_coercions() {
        let yml = r#"
packages:
  - replacements:
      386: i686
    vars:
      - name: enabled
        default: true
"#;
        let pkg = first_registry_package(yml);

        assert_eq!(pkg.replacements.get("386"), Some(&"i686".to_string()));
        assert_eq!(pkg.vars[0].default.as_deref(), Some("true"));
    }

    #[test]
    fn test_registry_package_private_defaults_to_false() {
        let pkg = first_registry_package("packages:\n  - type: github_release\n");

        assert!(!pkg.private);
    }

    #[test]
    fn test_registry_package_preserves_private() {
        let pkg =
            first_registry_package("packages:\n  - type: github_release\n    private: true\n");

        assert!(pkg.private);
    }

    #[test]
    fn test_registry_package_private_survives_version_override() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: github_release
    private: true
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        asset: tool.tar.gz
"#,
        )
        .with_version(&["1.0.0"], "linux", "amd64");

        assert!(pkg.private);
    }

    #[test]
    fn test_package_type_defaults_to_github_release_when_omitted() {
        let pkg = first_registry_package("packages:\n  - name: example/tool\n");

        assert_eq!(pkg.r#type, None);
        assert_eq!(pkg.package_type(), AquaPackageType::GithubRelease);
    }

    #[test]
    fn test_version_override_can_explicitly_select_github_release() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: http
    repo_owner: anthropics
    repo_name: claude-code
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        type: github_release
        asset: claude-{{.OS}}-{{.Arch}}
        format: tar.gz
        replacements:
          amd64: x64
"#,
        )
        .with_version(&["2.1.226"], "linux", "amd64");

        assert_eq!(pkg.r#type, Some(AquaPackageType::GithubRelease));
        assert_eq!(pkg.package_type(), AquaPackageType::GithubRelease);
        assert_eq!(pkg.format("2.1.226", "linux", "amd64").unwrap(), "tar.gz");
        assert_eq!(
            pkg.asset("2.1.226", "linux", "amd64").unwrap(),
            "claude-linux-x64.tar.gz"
        );
    }

    #[test]
    fn test_omitted_version_override_type_preserves_http() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: http
    url: https://example.com/tool
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        format: raw
"#,
        )
        .with_version(&["1.0.0"], "linux", "amd64");

        assert_eq!(pkg.r#type, Some(AquaPackageType::Http));
        assert_eq!(pkg.package_type(), AquaPackageType::Http);
    }

    #[test]
    fn test_version_override_can_select_http() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: github_release
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        type: http
        url: https://example.com/tool
"#,
        )
        .with_version(&["1.0.0"], "linux", "amd64");

        assert_eq!(pkg.r#type, Some(AquaPackageType::Http));
        assert_eq!(pkg.package_type(), AquaPackageType::Http);
    }

    #[test]
    fn test_platform_override_can_explicitly_select_github_release() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: http
    url: https://example.com/tool
    overrides:
      - goos: linux
        type: github_release
        asset: tool-{{.OS}}-{{.Arch}}
"#,
        );

        let linux = pkg.clone().with_version(&["1.0.0"], "linux", "amd64");
        let darwin = pkg.with_version(&["1.0.0"], "darwin", "arm64");

        assert_eq!(linux.r#type, Some(AquaPackageType::GithubRelease));
        assert_eq!(linux.package_type(), AquaPackageType::GithubRelease);
        assert_eq!(darwin.r#type, Some(AquaPackageType::Http));
        assert_eq!(darwin.package_type(), AquaPackageType::Http);
    }

    #[test]
    fn test_emulation_flags_can_be_disabled_by_version_override() {
        let pkg = first_registry_package(
            r#"
packages:
  - asset: tool-{{.OS}}-{{.Arch}}
    rosetta2: true
    windows_arm_emulation: true
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        rosetta2: false
        windows_arm_emulation: false
"#,
        )
        .with_version(&["1.0.0"], "darwin", "arm64");

        assert_eq!(pkg.rosetta2, Some(false));
        assert_eq!(pkg.windows_arm_emulation, Some(false));
        assert_eq!(
            pkg.asset("1.0.0", "darwin", "arm64").unwrap(),
            "tool-darwin-arm64"
        );
        assert_eq!(
            pkg.asset("1.0.0", "windows", "arm64").unwrap(),
            "tool-windows-arm64.exe"
        );
    }

    #[test]
    fn test_emulation_flags_can_be_disabled_by_platform_override() {
        let pkg = first_registry_package(
            r#"
packages:
  - asset: tool-{{.OS}}-{{.Arch}}
    rosetta2: true
    overrides:
      - goos: darwin
        rosetta2: false
"#,
        )
        .with_version(&["1.0.0"], "darwin", "arm64");

        assert_eq!(pkg.rosetta2, Some(false));
        assert_eq!(
            pkg.asset("1.0.0", "darwin", "arm64").unwrap(),
            "tool-darwin-arm64"
        );
    }

    #[test]
    fn test_no_asset_can_be_disabled_by_version_override() {
        let pkg = first_registry_package(
            r#"
packages:
  - no_asset: true
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        no_asset: false
"#,
        )
        .with_version(&["1.0.0"], "linux", "amd64");

        assert_eq!(pkg.no_asset, Some(false));
    }

    #[test]
    fn test_omitted_emulation_flags_preserve_base_values() {
        let pkg = first_registry_package(
            r#"
packages:
  - asset: tool-{{.OS}}-{{.Arch}}
    rosetta2: true
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        format: raw
"#,
        )
        .with_version(&["1.0.0"], "darwin", "arm64");

        assert_eq!(pkg.rosetta2, Some(true));
        assert_eq!(
            pkg.asset("1.0.0", "darwin", "arm64").unwrap(),
            "tool-darwin-amd64"
        );
    }

    #[test]
    fn test_omitted_no_asset_preserves_base_value() {
        let pkg = first_registry_package(
            r#"
packages:
  - no_asset: true
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        format: raw
"#,
        )
        .with_version(&["1.0.0"], "linux", "amd64");

        assert_eq!(pkg.no_asset, Some(true));
    }

    #[test]
    fn test_github_artifact_attestations_predicate_type() {
        let pkg = first_registry_package(
            r#"
packages:
  - github_artifact_attestations:
      enabled: true
      predicate_type: https://slsa.dev/provenance/v1
      signer_workflow: canonical-workflow.yml
"#,
        );

        let attestations = pkg.github_artifact_attestations.unwrap();
        assert_eq!(attestations.enabled, Some(true));
        assert_eq!(
            attestations.predicate_type.as_deref(),
            Some("https://slsa.dev/provenance/v1")
        );
        assert_eq!(
            attestations.signer_workflow.as_deref(),
            Some("canonical-workflow.yml")
        );
    }

    #[test]
    fn test_checksum_nested_verification_fields_are_deserialized() {
        let pkg = first_registry_package(
            r#"
packages:
  - checksum:
      type: github_release
      asset: checksums.txt
      algorithm: sha256
      replacements:
        darwin: mac
      minisign:
        type: github_release
        asset: checksums.txt.minisig
        public_key: minisign-public-key
      github_artifact_attestations:
        enabled: true
        predicate_type: https://slsa.dev/provenance/v1
        signer_workflow: checksum-workflow.yml
"#,
        );

        let checksum = pkg.checksum.unwrap();
        assert_eq!(
            checksum.replacements.as_ref().and_then(|r| r.get("darwin")),
            Some(&"mac".to_string())
        );
        assert_eq!(
            checksum
                .minisign
                .as_ref()
                .and_then(|m| m.public_key.as_deref()),
            Some("minisign-public-key")
        );
        let attestations = checksum.github_artifact_attestations.unwrap();
        assert_eq!(attestations.enabled, Some(true));
        assert_eq!(
            attestations.predicate_type.as_deref(),
            Some("https://slsa.dev/provenance/v1")
        );
        assert_eq!(
            attestations.signer_workflow.as_deref(),
            Some("checksum-workflow.yml")
        );
    }

    #[test]
    fn test_checksum_replacements_override_package_replacements() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: http
    url: https://example.com/tool-{{.OS}}-{{.Arch}}.tar.gz
    replacements:
      darwin: macOS
      arm64: aarch64
    checksum:
      type: http
      url: "{{.AssetURL}}.{{.OS}}-{{.Arch}}.checksums"
      replacements:
        darwin: mac
        arm64: arm64
"#,
        );

        assert_eq!(
            pkg.url("1.0.0", "darwin", "arm64").unwrap(),
            "https://example.com/tool-macOS-aarch64.tar.gz"
        );
        assert_eq!(
            pkg.checksum
                .as_ref()
                .unwrap()
                .url(&pkg, "1.0.0", "darwin", "arm64")
                .unwrap(),
            "https://example.com/tool-macOS-aarch64.tar.gz.mac-arm64.checksums"
        );
    }

    #[test]
    fn test_checksum_replacements_merge_on_version_override() {
        let mut checksum = first_registry_package(
            r#"
packages:
  - checksum:
      type: http
      url: https://example.com/{{.OS}}.checksums
      replacements:
        darwin: mac
"#,
        )
        .checksum
        .unwrap();
        let override_checksum = first_registry_package(
            r#"
packages:
  - checksum:
      replacements:
        arm64: aarch64
"#,
        )
        .checksum
        .unwrap();
        checksum.merge(override_checksum);
        assert_eq!(
            checksum.replacements,
            Some(HashMap::from([
                ("darwin".to_string(), "mac".to_string()),
                ("arm64".to_string(), "aarch64".to_string()),
            ]))
        );
    }

    #[test]
    fn test_checksum_replacements_null_deserializes_to_none() {
        let pkg = first_registry_package(
            r#"
packages:
  - checksum:
      type: github_release
      asset: checksums.txt
      algorithm: sha256
      replacements: null
"#,
        );
        assert_eq!(pkg.checksum.unwrap().replacements, None);
    }

    #[test]
    fn test_checksum_url_applies_replacements_to_goos_and_goarch() {
        let pkg = first_registry_package(
            r#"
packages:
  - type: http
    url: https://example.com/tool.tar.gz
    checksum:
      type: http
      url: https://example.com/{{.GOOS}}-{{.GOARCH}}.checksums
      replacements:
        darwin: mac
        arm64: aarch64
"#,
        );
        assert_eq!(
            pkg.checksum
                .as_ref()
                .unwrap()
                .url(&pkg, "1.0.0", "darwin", "arm64")
                .unwrap(),
            "https://example.com/mac-aarch64.checksums"
        );
    }

    #[test]
    fn test_aqua_file_src_gradle() {
        // Test the gradle package src template: {{.AssetWithoutExt | trimSuffix "-bin"}}/bin/gradle
        let pkg = AquaPackage {
            repo_owner: "gradle".to_string(),
            repo_name: "gradle-distributions".to_string(),
            asset: "gradle-{{trimV .Version}}-bin.zip".to_string(),
            ..Default::default()
        };
        let file = AquaFile {
            name: "gradle".to_string(),
            src: Some("{{.AssetWithoutExt | trimSuffix \"-bin\"}}/bin/gradle".to_string()),
            ..Default::default()
        };

        let result = file.src(&pkg, "8.14.3", "darwin", "arm64").unwrap();
        assert_eq!(result, Some("gradle-8.14.3/bin/gradle".to_string()));
    }

    #[test]
    fn test_aqua_file_src_asset_without_ext_strips_zst() {
        let pkg = AquaPackage {
            repo_owner: "openai".to_string(),
            repo_name: "codex".to_string(),
            asset: "codex-{{.Arch}}-{{.OS}}.exe.{{.Format}}".to_string(),
            format: "zst".to_string(),
            replacements: HashMap::from([
                ("amd64".to_string(), "x86_64".to_string()),
                ("windows".to_string(), "pc-windows-msvc".to_string()),
            ]),
            ..Default::default()
        };
        let file = AquaFile {
            name: "codex".to_string(),
            src: Some("{{.AssetWithoutExt}}".to_string()),
            ..Default::default()
        };

        let result = file.src(&pkg, "0.133.0", "windows", "amd64").unwrap();

        assert_eq!(result, Some("codex-x86_64-pc-windows-msvc.exe".to_string()));
    }

    #[test]
    fn test_asset_without_ext_uses_aqua_asset_formats() {
        assert_eq!(
            asset_without_ext("tfcmt_linux_amd64.tar.gz"),
            "tfcmt_linux_amd64"
        );
        assert_eq!(
            asset_without_ext("tfcmt_linux_amd64.tgz"),
            "tfcmt_linux_amd64"
        );
        assert_eq!(
            asset_without_ext("tfcmt_linux_amd64.tbz"),
            "tfcmt_linux_amd64"
        );
        assert_eq!(asset_without_ext("tool.tar.br"), "tool");
        assert_eq!(
            asset_without_ext("codex-x86_64-pc-windows-msvc.exe.zst"),
            "codex-x86_64-pc-windows-msvc.exe"
        );
        assert_eq!(asset_without_ext("tfcmt.js"), "tfcmt.js");
        assert_eq!(
            asset_without_ext("tfcmt_windows_amd64.exe"),
            "tfcmt_windows_amd64.exe"
        );
    }

    #[test]
    fn test_aqua_file_src_empty_asset_produces_absolute_path() {
        // When a linked version name like "brew" matches a wrong version_override
        // that has no asset field, the package ends up with an empty asset.
        // The src template "{{.AssetWithoutExt}}/name" then renders to "/name"
        // which is an absolute path — this caused a StripPrefixError panic
        // in the aqua backend's list_bin_paths.
        let pkg = AquaPackage {
            repo_owner: "mozilla".to_string(),
            repo_name: "sccache".to_string(),
            asset: String::new(),
            ..Default::default()
        };
        let file = AquaFile {
            name: "sccache".to_string(),
            src: Some("{{.AssetWithoutExt}}/sccache".to_string()),
            ..Default::default()
        };

        let result = file.src(&pkg, "brew", "darwin", "arm64").unwrap();
        assert_eq!(result, Some("/sccache".to_string()));
    }

    #[test]
    fn test_version_override_non_version_string_matches_semver() {
        // Non-version strings like "brew" are parsed as valid General versions
        // by the versions crate, and can match semver constraints unexpectedly.
        // This documents the root cause of the linked-version panic.
        let pkg = AquaPackage {
            version_constraint: "false".to_string(),
            version_overrides: vec![
                AquaPackage {
                    version_constraint: "semver(\"<= 0.2.13\")".to_string(),
                    error_message: Some("too old".to_string()),
                    ..Default::default()
                },
                AquaPackage {
                    version_constraint: "true".to_string(),
                    asset: "tool-{{.Version}}.tar.gz".to_string(),
                    format: "tar.gz".to_string(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        };

        let result = pkg.version_override(&["brew"]).unwrap();
        // "brew" matches semver("<= 0.2.13") instead of "true",
        // because Versioning::new("brew") parses as General(Alphanum("brew"))
        // which sorts before numeric versions.
        assert!(result.error_message.is_some());
        assert!(result.asset.is_empty());
    }

    /// Two overrides that differ only in which branch is picked, so `version_override`
    /// reports the answer: `Some("first")` when the constraint held, `Some("fallback")` otherwise.
    fn constraint_probe(constraint: &str) -> AquaPackage {
        AquaPackage {
            version_constraint: "false".to_string(),
            version_overrides: vec![
                AquaPackage {
                    version_constraint: constraint.to_string(),
                    asset: "first".to_string(),
                    ..Default::default()
                },
                AquaPackage {
                    version_constraint: "true".to_string(),
                    asset: "fallback".to_string(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        }
    }

    fn constraint_holds(constraint: &str, version: &str) -> bool {
        constraint_probe(constraint)
            .version_override(&[version])
            .map(|pkg| pkg.asset == "first")
            .expect("a version_override always matches, since the last one is unconditional")
    }

    #[test]
    fn test_semver_constraint_accepts_four_component_bounds() {
        // https://github.com/jdx/mise/discussions/4813: relocatable-perl selects the linux asset
        // name for old releases with semver("<= 5.34.1.0"). Rejecting that bound sent 5.32.1.0
        // to the fallback branch, which asks for an asset the release does not have.
        assert!(constraint_holds("semver(\"<= 5.34.1.0\")", "5.32.1.0"));
        assert!(constraint_holds("semver(\"<= 5.34.1.0\")", "5.34.1.0"));
        assert!(!constraint_holds("semver(\"<= 5.34.1.0\")", "5.36.0.0"));

        // Both directions, so "the bound is rejected and everything is false" cannot pass.
        assert!(constraint_holds("semver(\">= 1.0.0.0\")", "5.32.1.0"));
        assert!(!constraint_holds("semver(\"< 1.0.0.0\")", "5.32.1.0"));
    }

    #[test]
    fn test_semver_constraint_compares_bounds_against_shorter_versions() {
        // haskell/cabal pins 4-component bounds while its releases are 3-component.
        assert!(constraint_holds("semver(\"<= 3.16.1.0\")", "3.16.1"));
        assert!(!constraint_holds("semver(\"<= 3.16.1.0\")", "3.17.0"));
    }

    #[test]
    fn test_semver_constraint_supports_not_equal() {
        // mattn/efm-langserver and sheepla/qiitaz exclude a single bad release this way.
        assert!(constraint_holds("semver(\"!= 0.0.45\")", "0.0.46"));
        assert!(!constraint_holds("semver(\"!= 0.0.45\")", "0.0.45"));
    }

    #[test]
    fn test_semver_constraint_still_ands_comma_separated_terms() {
        assert!(constraint_holds(
            "semver(\">= 1.0.0, <= 9.0.0\")",
            "5.32.1.0"
        ));
        assert!(!constraint_holds(
            "semver(\">= 1.0.0, <= 5.0.0\")",
            "5.32.1.0"
        ));
    }

    #[test]
    fn test_semver_constraint_without_an_operator_is_not_satisfied() {
        // aqua requires an operator; an unparseable term errors, and the caller reads that
        // as "does not apply" rather than propagating it.
        assert!(!constraint_holds("semver(\"5.32.1.0\")", "5.32.1.0"));
        assert!(!constraint_holds("semver(\"<= \")", "5.32.1.0"));
    }

    #[test]
    fn test_version_override_matches_version_prefix() {
        let pkg = AquaPackage {
            version_constraint: "false".to_string(),
            version_overrides: vec![
                AquaPackage {
                    version_constraint: "semver(\">= 1.17.0\")".to_string(),
                    version_prefix: Some("oxlint_v".to_string()),
                    error_message: Some("unavailable".to_string()),
                    ..Default::default()
                },
                AquaPackage {
                    version_constraint: "true".to_string(),
                    version_prefix: Some("apps_v".to_string()),
                    asset: "oxlint.tar.gz".to_string(),
                    format: "tar.gz".to_string(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        };

        let apps = pkg.version_override(&["apps_v1.76.0"]).unwrap();
        assert_eq!(apps.version_prefix.as_deref(), Some("apps_v"));
        assert!(apps.error_message.is_none());
        assert_eq!(apps.asset, "oxlint.tar.gz");

        let oxlint = pkg.version_override(&["oxlint_v1.76.0"]).unwrap();
        assert_eq!(oxlint.version_prefix.as_deref(), Some("oxlint_v"));
        assert_eq!(oxlint.error_message.as_deref(), Some("unavailable"));
    }

    #[test]
    fn test_unconditional_version_override_matches_version_prefix() {
        let pkg = AquaPackage {
            version_constraint: "false".to_string(),
            version_overrides: vec![
                AquaPackage {
                    version_prefix: Some("old_v".to_string()),
                    error_message: Some("unavailable".to_string()),
                    ..Default::default()
                },
                AquaPackage {
                    version_prefix: Some("new_v".to_string()),
                    asset: "tool.tar.gz".to_string(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        };

        let old = pkg.version_override(&["old_v1.0.0"]).unwrap();
        assert_eq!(old.version_prefix.as_deref(), Some("old_v"));
        assert_eq!(old.error_message.as_deref(), Some("unavailable"));

        let new = pkg.version_override(&["new_v1.0.0"]).unwrap();
        assert_eq!(new.version_prefix.as_deref(), Some("new_v"));
        assert_eq!(new.asset, "tool.tar.gz");
    }

    #[test]
    fn test_unconditional_package_ignores_version_prefix() {
        let pkg = AquaPackage {
            version_prefix: Some("jq-".to_string()),
            asset: "jq-{{.OS}}-{{.Arch}}".to_string(),
            ..Default::default()
        };

        let resolved = pkg.version_override(&["1.8.1"]).unwrap();
        assert_eq!(resolved.version_prefix.as_deref(), Some("jq-"));
        assert_eq!(resolved.asset, "jq-{{.OS}}-{{.Arch}}");
    }

    #[test]
    fn test_url_no_double_exe_extension() {
        // When a Windows override URL already ends in .exe, complete_windows_ext
        // should not append another .exe (which would produce .exe.exe).
        let pkg = AquaPackage {
            url: "https://example.com/tool/{{.Version}}/tool.exe".to_string(),
            format: "raw".to_string(),
            complete_windows_ext: Some(true),
            ..Default::default()
        };

        let url = pkg.url("1.0.0", "windows", "amd64").unwrap();
        assert!(
            !url.ends_with(".exe.exe"),
            "URL should not have double .exe extension, got: {url}"
        );
        assert!(url.ends_with(".exe"));
    }

    #[test]
    fn test_url_adds_exe_when_missing() {
        // When a Windows URL does not end in .exe and format is raw,
        // complete_windows_ext should append .exe.
        let pkg = AquaPackage {
            url: "https://example.com/tool/{{.Version}}/tool".to_string(),
            format: "raw".to_string(),
            complete_windows_ext: Some(true),
            ..Default::default()
        };

        let url = pkg.url("1.0.0", "windows", "amd64").unwrap();
        assert!(
            url.ends_with(".exe"),
            "URL should end with .exe, got: {url}"
        );
    }

    #[test]
    fn test_url_preserves_jar_when_completing_windows_ext() {
        let pkg = AquaPackage {
            url: "https://example.com/tool/{{.Version}}/tool.jar".to_string(),
            format: "raw".to_string(),
            complete_windows_ext: Some(true),
            ..Default::default()
        };

        let url = pkg.url("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(url, "https://example.com/tool/1.0.0/tool.jar");
    }

    #[test]
    fn test_asset_appends_format_ext_by_default() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}-{{.OS}}-{{.Arch}}".to_string(),
            format: "tar.gz".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap();

        assert_eq!(asset, "tool-1.0.0-linux-amd64.tar.gz");
    }

    #[test]
    fn test_asset_respects_append_ext_false() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}-{{.OS}}-{{.Arch}}".to_string(),
            format: "tar.gz".to_string(),
            append_ext: Some(false),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap();

        assert_eq!(asset, "tool-1.0.0-linux-amd64");
    }

    #[test]
    fn test_asset_uses_custom_windows_ext() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}-{{.OS}}-{{.Arch}}".to_string(),
            format: "raw".to_string(),
            windows_ext: ".bat".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "tool-1.0.0-windows-amd64.bat");
    }

    #[test]
    fn test_asset_normalizes_custom_windows_ext() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}-{{.OS}}-{{.Arch}}".to_string(),
            format: "raw".to_string(),
            windows_ext: "bat".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "tool-1.0.0-windows-amd64.bat");
    }

    #[test]
    fn test_asset_omitted_format_preserves_existing_extension() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}.ps1".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "tool-1.0.0.ps1");
    }

    #[test]
    fn test_format_detects_7z_asset() {
        let pkg = AquaPackage {
            asset: "tool-{{.Version}}-{{.OS}}-{{.Arch}}.7z".to_string(),
            ..Default::default()
        };

        let format = pkg.format("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(format, "7z");
    }

    #[test]
    fn test_format_detects_literal_aqua_aliases() {
        let cases = [
            ("tool-{{.Version}}.tgz", "tgz"),
            ("tool-{{.Version}}.txz", "txz"),
            ("tool-{{.Version}}.tbz", "tbz"),
            ("tool-{{.Version}}.tbz2", "tbz2"),
        ];

        for (asset, expected) in cases {
            let pkg = AquaPackage {
                asset: asset.to_string(),
                ..Default::default()
            };

            let format = pkg.format("1.0.0", "linux", "amd64").unwrap();

            assert_eq!(format, expected);
        }
    }

    #[test]
    fn test_format_preserves_explicit_aqua_aliases() {
        for format in ["tgz", "txz", "tbz", "tbz2"] {
            let pkg = AquaPackage {
                format: format.to_string(),
                ..Default::default()
            };

            let detected = pkg.format("1.0.0", "linux", "amd64").unwrap();

            assert_eq!(detected, format);
        }
    }

    #[test]
    fn test_asset_completion_strips_version_from_filename_only() {
        let pkg = AquaPackage {
            asset: "https://example.com/1.0.0/tool-{{.Version}}".to_string(),
            format: "raw".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "https://example.com/1.0.0/tool-1.0.0.exe");
    }

    #[test]
    fn test_asset_completion_preserves_prefix_before_non_boundary_version() {
        let pkg = AquaPackage {
            asset: "x1.8atool_{{.Version}}_win".to_string(),
            format: "raw".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.8", "windows", "amd64").unwrap();

        assert_eq!(asset, "x1.8atool_1.8_win.exe");
    }

    #[test]
    fn test_asset_completion_treats_version_dot_as_empty_extension() {
        let pkg = AquaPackage {
            asset: "tool.{{.Version}}".to_string(),
            format: "raw".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "tool.1.0.0.exe");
    }

    #[test]
    fn test_asset_completion_does_not_corrupt_version_prefixes() {
        let pkg = AquaPackage {
            asset: "tool-1.1.1".to_string(),
            format: "raw".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.1", "windows", "amd64").unwrap();

        assert_eq!(asset, "tool-1.1.1.exe");
    }

    #[test]
    fn test_github_content_does_not_complete_windows_ext_by_default() {
        let pkg = AquaPackage {
            r#type: Some(AquaPackageType::GithubContent),
            path: Some("install".to_string()),
            asset: "install".to_string(),
            format: "raw".to_string(),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "install");
    }

    #[test]
    fn test_github_content_complete_windows_ext_defaults_to_sh() {
        let pkg = AquaPackage {
            r#type: Some(AquaPackageType::GithubContent),
            path: Some("install".to_string()),
            asset: "install".to_string(),
            format: "raw".to_string(),
            complete_windows_ext: Some(true),
            ..Default::default()
        };

        let asset = pkg.asset("1.0.0", "windows", "amd64").unwrap();

        assert_eq!(asset, "install.sh");
    }

    #[test]
    fn test_file_src_and_dst_complete_windows_ext() {
        let pkg = AquaPackage::default();

        assert_eq!(
            pkg.complete_windows_ext_to_file_src("tool_1.0.0", "v1.0.0", "windows"),
            "tool_1.0.0.exe"
        );
        assert_eq!(
            pkg.complete_windows_ext_to_file_src("tool_1.0.0.bat", "v1.0.0", "windows"),
            "tool_1.0.0.bat"
        );
        assert_eq!(
            pkg.complete_windows_ext_to_file_dst(
                "tool_1.0.0.bat",
                "tool_1.0.0",
                "v1.0.0",
                "windows"
            ),
            "tool_1.0.0.bat"
        );
        assert_eq!(
            pkg.complete_windows_ext_to_file_dst("tool_1.0.0", "tool_1.0.0", "v1.0.0", "windows"),
            "tool_1.0.0.exe"
        );
    }

    #[test]
    fn test_asset_strs_no_double_exe_extension() {
        // asset_strs should also not double .exe when asset already ends in .exe.
        let pkg = AquaPackage {
            asset: "tool.exe".to_string(),
            format: "raw".to_string(),
            complete_windows_ext: Some(true),
            ..Default::default()
        };

        let strs = pkg.asset_strs("1.0.0", "windows", "amd64").unwrap();
        for s in &strs {
            assert!(
                !s.ends_with(".exe.exe"),
                "Asset string should not have double .exe, got: {s}"
            );
        }
    }

    #[test]
    fn test_aqua_file_link_template() {
        let pkg = AquaPackage {
            repo_owner: "example".to_string(),
            repo_name: "tool".to_string(),
            asset: "tool-{{.Version}}.tar.gz".to_string(),
            ..Default::default()
        };
        let file = AquaFile {
            name: "tool".to_string(),
            link: Some("{{.FileName}}-alias".to_string()),
            ..Default::default()
        };

        let result = file.link(&pkg, "1.0.0", "linux", "amd64").unwrap();
        assert_eq!(result, Some("tool-alias".to_string()));
    }

    #[test]
    fn test_vars_default_value() {
        let pkg = AquaPackage {
            asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(),
            vars: vec![AquaVar {
                name: "channel".to_string(),
                default: default_str("stable"),
                required: false,
            }],
            ..Default::default()
        };
        let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap();
        assert_eq!(asset, "tool-stable-1.0.0.tar.gz");
    }

    #[test]
    fn test_vars_override_value() {
        let mut var_values = HashMap::new();
        var_values.insert("channel".to_string(), "beta".to_string());
        let pkg = AquaPackage {
            asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(),
            vars: vec![AquaVar {
                name: "channel".to_string(),
                default: default_str("stable"),
                required: false,
            }],
            ..Default::default()
        }
        .with_var_values(var_values)
        .unwrap();
        let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap();
        assert_eq!(asset, "tool-beta-1.0.0.tar.gz");
    }

    #[test]
    fn test_vars_default_unquoted_yaml_string() {
        let yml = r#"
packages:
  - asset: tool-{{.Vars.channel}}-{{.Version}}.tar.gz
    vars:
      - name: channel
        default: stable
"#;
        let pkg = first_registry_package(yml);
        let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap();
        assert_eq!(asset, "tool-stable-1.0.0.tar.gz");
    }

    #[test]
    fn test_vars_scalar_defaults_deserialize_as_strings() {
        for (yaml_default, expected) in [("true", "true"), ("123", "123")] {
            let yml = format!(
                r#"
packages:
  - vars:
      - name: channel
        default: {yaml_default}
"#
            );
            let pkg = first_registry_package(&yml);
            assert_eq!(pkg.vars[0].default.as_deref(), Some(expected));
        }
    }

    #[test]
    fn test_vars_null_default_deserializes_as_none() {
        let yml = r#"
packages:
  - vars:
      - name: channel
        default: null
"#;
        let pkg = first_registry_package(yml);
        assert_eq!(pkg.vars[0].default, None);
    }

    #[test]
    fn test_vars_sequence_and_mapping_defaults_fail_yaml_parse() {
        for yaml_default in ["[stable, beta]", "{channel: stable}"] {
            let yml = format!(
                r#"
packages:
  - vars:
      - name: channel
        default: {yaml_default}
"#
            );
            let err = serde_yaml::from_str::<RegistryYaml>(&yml).unwrap_err();
            assert!(
                err.to_string().contains("invalid type"),
                "unexpected error for {yaml_default}: {err}"
            );
        }
    }

    #[test]
    fn test_vars_required_missing() {
        let pkg = AquaPackage {
            asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(),
            vars: vec![AquaVar {
                name: "channel".to_string(),
                default: None,
                required: true,
            }],
            ..Default::default()
        };
        let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err();
        assert!(
            err.to_string()
                .contains("required aqua var not set: channel"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn test_vars_required_missing_with_var_values() {
        let pkg = AquaPackage {
            vars: vec![AquaVar {
                name: "go_version".to_string(),
                default: None,
                required: true,
            }],
            ..Default::default()
        };
        let err = pkg.with_var_values(HashMap::new()).unwrap_err();
        assert!(
            err.to_string()
                .contains("required aqua var not set: go_version"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn test_vars_empty_name() {
        let pkg = AquaPackage {
            vars: vec![AquaVar {
                name: String::new(),
                default: None,
                required: false,
            }],
            ..Default::default()
        };
        let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err();
        assert!(
            err.to_string().contains("aqua var name is empty"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn test_top_level_cosign_is_deserialized() {
        let yml = r#"
packages:
  - cosign:
      bundle:
        type: github_release
        asset: "{{.Asset}}.sigstore.json"
"#;
        let pkg = first_registry_package(yml);
        assert!(pkg.cosign.is_some());
        assert!(pkg.checksum.is_none());
    }

    #[test]
    fn test_top_level_cosign_is_merged_from_version_override() {
        let yml = r#"
packages:
  - asset: tool-{{.Version}}-{{.OS}}-{{.Arch}}
    format: raw
    cosign:
      bundle:
        type: github_release
        asset: "{{.Asset}}.sigstore.json"
    version_constraint: "false"
    version_overrides:
      - version_constraint: "true"
        cosign:
          key:
            type: github_release
            asset: cosign.pub
"#;
        let pkg = first_registry_package(yml).with_version(&["v1.0.0"], "linux", "amd64");
        let cosign = pkg.cosign.unwrap();
        assert!(cosign.bundle.is_some());
        assert!(cosign.key.is_some());
    }

    #[test]
    fn test_minisign_asset_template_uses_package_asset() {
        let yml = r#"
packages:
  - asset: minisign-{{.Version}}-{{.OS}}.tar.gz
    minisign:
      type: github_release
      asset: "{{.Asset}}.minisig"
      public_key: RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3
"#;
        let pkg = first_registry_package(yml);
        let package_asset = pkg.asset("0.12", "linux", "amd64").unwrap();
        let minisign_asset = pkg
            .minisign
            .as_ref()
            .unwrap()
            .asset(&pkg, &package_asset, "0.12", "linux", "amd64")
            .unwrap();

        assert_eq!(package_asset, "minisign-0.12-linux.tar.gz");
        assert_eq!(minisign_asset, "minisign-0.12-linux.tar.gz.minisig");
    }

    #[test]
    fn test_override_variants_match_linux_libc() {
        let yml = r#"
packages:
  - url: https://example.com/tool-{{.Version}}-{{.OS}}-{{.Arch}}-gnu
    format: raw
    overrides:
      - goos: linux
        variants:
          - key: libc
            value: gnu
      - goos: linux
        url: https://example.com/tool-{{.Version}}-{{.OS}}-{{.Arch}}-musl
        variants:
          - key: libc
            value: musl
"#;
        let pkg = first_registry_package(yml);

        let gnu = pkg
            .clone()
            .with_version_libc(&["1.0.0"], "linux", "amd64", Some("gnu"));
        let musl = pkg.with_version_libc(&["1.0.0"], "linux", "amd64", Some("musl"));

        assert_eq!(
            gnu.url("1.0.0", "linux", "amd64").unwrap(),
            "https://example.com/tool-1.0.0-linux-amd64-gnu"
        );
        assert_eq!(
            musl.url("1.0.0", "linux", "amd64").unwrap(),
            "https://example.com/tool-1.0.0-linux-amd64-musl"
        );
    }

    #[test]
    fn test_override_envs_match_os() {
        let yml = r#"
packages:
  - files:
      - name: catalina.sh
        src: apache-tomcat-{{.SemVer}}/bin/catalina.sh
    overrides:
      - envs:
          - windows
        files:
          - name: catalina.bat
            src: apache-tomcat-{{.SemVer}}/bin/catalina.bat
"#;
        let pkg = first_registry_package(yml);

        let linux = pkg.clone().with_version(&["10.1.0"], "linux", "amd64");
        let windows = pkg.with_version(&["10.1.0"], "windows", "amd64");

        assert_eq!(linux.files[0].name, "catalina.sh");
        assert_eq!(windows.files[0].name, "catalina.bat");
    }

    #[test]
    fn test_override_envs_match_os_arch() {
        let yml = r#"
packages:
  - url: https://example.com/tool-default
    format: raw
    complete_windows_ext: false
    overrides:
      - envs:
          - darwin
          - windows/arm64
        url: https://example.com/tool-cargo
"#;
        let pkg = first_registry_package(yml);

        let darwin = pkg.clone().with_version(&["1.0.0"], "darwin", "amd64");
        let windows_arm64 = pkg.clone().with_version(&["1.0.0"], "windows", "arm64");
        let windows_amd64 = pkg.with_version(&["1.0.0"], "windows", "amd64");

        assert_eq!(
            darwin.url("1.0.0", "darwin", "amd64").unwrap(),
            "https://example.com/tool-cargo"
        );
        assert_eq!(
            windows_arm64.url("1.0.0", "windows", "arm64").unwrap(),
            "https://example.com/tool-cargo"
        );
        assert_eq!(
            windows_amd64.url("1.0.0", "windows", "amd64").unwrap(),
            "https://example.com/tool-default"
        );
    }

    #[test]
    fn test_override_envs_all_matches_any_platform() {
        let yml = r#"
packages:
  - url: https://example.com/tool-default
    format: raw
    overrides:
      - envs:
          - all
        url: https://example.com/tool-all
"#;
        let pkg = first_registry_package(yml);

        // What is under test is that the `envs: [all]` override supplied the URL on every
        // platform. A raw-format URL additionally picks up the Windows executable extension,
        // which `test_url_adds_exe_when_missing` covers on its own.
        for (os, arch, expected) in [
            ("linux", "amd64", "https://example.com/tool-all"),
            ("darwin", "arm64", "https://example.com/tool-all"),
            ("windows", "amd64", "https://example.com/tool-all.exe"),
        ] {
            let resolved = pkg.clone().with_version(&["1.0.0"], os, arch);

            assert_eq!(resolved.url("1.0.0", os, arch).unwrap(), expected);
        }
    }

    #[test]
    fn test_override_envs_combine_with_goarch() {
        let yml = r#"
packages:
  - url: https://example.com/tool-default
    format: raw
    overrides:
      - goarch: arm64
        envs:
          - linux
        url: https://example.com/tool-linux-arm64
"#;
        let pkg = first_registry_package(yml);

        let linux_amd64 = pkg.clone().with_version(&["1.0.0"], "linux", "amd64");
        let linux_arm64 = pkg.clone().with_version(&["1.0.0"], "linux", "arm64");
        let darwin_arm64 = pkg.with_version(&["1.0.0"], "darwin", "arm64");

        assert_eq!(
            linux_amd64.url("1.0.0", "linux", "amd64").unwrap(),
            "https://example.com/tool-default"
        );
        assert_eq!(
            linux_arm64.url("1.0.0", "linux", "arm64").unwrap(),
            "https://example.com/tool-linux-arm64"
        );
        assert_eq!(
            darwin_arm64.url("1.0.0", "darwin", "arm64").unwrap(),
            "https://example.com/tool-default"
        );
    }

    #[test]
    fn test_format_overrides_apply_by_os_before_rendering() {
        let yml = r#"
packages:
  - asset: tool-{{.Version}}-{{.OS}}-{{.Arch}}.{{.Format}}
    format: tar.gz
    format_overrides:
      - goos: windows
        format: zip
"#;
        let pkg = first_registry_package(yml);

        let linux = pkg.clone().with_version(&["1.0.0"], "linux", "amd64");
        let windows = pkg.with_version(&["1.0.0"], "windows", "amd64");

        assert_eq!(linux.format("1.0.0", "linux", "amd64").unwrap(), "tar.gz");
        assert_eq!(
            linux.asset("1.0.0", "linux", "amd64").unwrap(),
            "tool-1.0.0-linux-amd64.tar.gz"
        );
        assert_eq!(windows.format("1.0.0", "windows", "amd64").unwrap(), "zip");
        assert_eq!(
            windows.asset("1.0.0", "windows", "amd64").unwrap(),
            "tool-1.0.0-windows-amd64.zip"
        );
    }

    #[test]
    fn test_platform_override_can_override_format_override() {
        let yml = r#"
packages:
  - asset: tool-{{.Version}}-{{.OS}}-{{.Arch}}.{{.Format}}
    format: tar.gz
    format_overrides:
      - goos: windows
        format: zip
    overrides:
      - goos: windows
        asset: tool.exe
        format: raw
"#;
        let pkg = first_registry_package(yml).with_version(&["1.0.0"], "windows", "amd64");

        assert_eq!(pkg.format("1.0.0", "windows", "amd64").unwrap(), "raw");
        assert_eq!(pkg.asset("1.0.0", "windows", "amd64").unwrap(), "tool.exe");
    }

    #[test]
    fn test_override_variants_skip_unknown_keys() {
        let yml = r#"
packages:
  - url: https://example.com/tool-default
    format: raw
    overrides:
      - goos: linux
        url: https://example.com/tool-avx2
        variants:
          - key: cpu
            value: avx2
      - goos: linux
        url: https://example.com/tool-musl
        variants:
          - key: libc
            value: musl
"#;
        let pkg = first_registry_package(yml).with_version_libc(
            &["1.0.0"],
            "linux",
            "amd64",
            Some("musl"),
        );

        assert_eq!(
            pkg.url("1.0.0", "linux", "amd64").unwrap(),
            "https://example.com/tool-musl"
        );
    }

    #[test]
    fn test_override_variants_do_not_match_without_runtime_libc() {
        let yml = r#"
packages:
  - url: https://example.com/tool-default
    format: raw
    overrides:
      - goos: linux
        url: https://example.com/tool-musl
        variants:
          - key: libc
            value: musl
"#;
        let pkg = first_registry_package(yml).with_version(&["1.0.0"], "linux", "amd64");

        assert_eq!(
            pkg.url("1.0.0", "linux", "amd64").unwrap(),
            "https://example.com/tool-default"
        );
    }
}
