use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::backend_type::BackendType;
use crate::backend::options::{BackendOptions, is_falsey, is_truthy};
use crate::backend::platform_target::PlatformTarget;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::config::{Config, Settings};
use crate::git::{CloneOptions, Git};
use crate::http::HTTP;
use crate::install_context::InstallContext;
use crate::toolset::{ToolRequest, ToolVersion, ToolVersionOptions};
use crate::{dirs, file, github, gitlab};
use async_trait::async_trait;
use duct::Expression;
use eyre::{Result, WrapErr, bail};
use serde::Deserialize;
use serde::Deserializer;
use serde::de::{MapAccess, Visitor};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::{
    fmt::{self, Debug},
    sync::Arc,
};
use strum::{AsRefStr, EnumString, VariantNames};
use url::Url;
use xx::regex;

pub const EXPERIMENTAL: bool = false;

#[derive(Debug)]
pub struct SPMBackend {
    ba: Arc<BackendArg>,
}

#[derive(Debug, Clone, Copy)]
struct SpmOptions<'a> {
    values: BackendOptions<'a>,
}

impl<'a> SpmOptions<'a> {
    fn new(raw: &'a ToolVersionOptions) -> Self {
        Self {
            values: BackendOptions::new(raw),
        }
    }

    /// Resolves an SPM option for either install-time host use or lockfile target use.
    ///
    /// Lockfiles can be generated for platforms other than the current host, so callers
    /// that are building lock identity must not fall back to the host-specific option.
    fn option_string(&self, key: &str, target: Option<&PlatformTarget>) -> Option<String> {
        match target {
            Some(target) => self.values.platform_string_for_target(key, target),
            None => self.values.platform_string(key),
        }
    }

    fn provider(&self, target: Option<&PlatformTarget>) -> String {
        self.option_string("provider", target)
            .map(|provider| provider.to_lowercase())
            .unwrap_or_else(|| GitProviderKind::GitHub.as_ref().to_string())
    }

    fn api_url(&self, target: Option<&PlatformTarget>) -> Option<String> {
        self.option_string("api_url", target)
            .map(|api_url| api_url.trim_end_matches('/').to_string())
    }

    fn artifactbundle_asset(&self, target: Option<&PlatformTarget>) -> Option<String> {
        self.option_string("artifactbundle_asset", target)
    }

    fn artifactbundle_mode(
        &self,
        target: Option<&PlatformTarget>,
    ) -> eyre::Result<ArtifactBundleMode> {
        let Some(value) = self.option_string("artifactbundle", target) else {
            return Ok(ArtifactBundleMode::Auto);
        };
        if is_truthy(&value) {
            Ok(ArtifactBundleMode::Required)
        } else if is_falsey(&value) {
            Ok(ArtifactBundleMode::SourceOnly)
        } else {
            bail!("artifactbundle must be true, false, 1, or 0, got {value}");
        }
    }

    fn requires_artifactbundle(&self, mode: ArtifactBundleMode) -> bool {
        mode.requires_artifactbundle() || self.artifactbundle_asset(None).is_some()
    }

    fn lockfile_options(&self, target: &PlatformTarget) -> BTreeMap<String, String> {
        let mut opts = BTreeMap::new();
        let provider = self.provider(Some(target));
        if provider != GitProviderKind::GitHub.as_ref() {
            opts.insert("provider".to_string(), provider);
        }
        if let Some(api_url) = self.api_url(Some(target)) {
            opts.insert("api_url".to_string(), api_url);
        }
        match self.artifactbundle_mode(Some(target)) {
            Ok(ArtifactBundleMode::Required) => {
                opts.insert("artifactbundle".to_string(), "true".to_string());
            }
            Ok(ArtifactBundleMode::SourceOnly) => {
                opts.insert("artifactbundle".to_string(), "false".to_string());
            }
            Ok(ArtifactBundleMode::Auto) | Err(_) => {}
        }
        if let Some(asset) = self.artifactbundle_asset(Some(target)) {
            opts.insert("artifactbundle_asset".to_string(), asset);
        }
        opts
    }

    fn filter_bins(&self) -> Option<Vec<String>> {
        let value = self.values.raw().opts.get("filter_bins")?;
        let bins: Vec<String> = match value {
            toml::Value::Array(arr) => arr
                .iter()
                .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
                .filter(|s| !s.is_empty())
                .collect(),
            toml::Value::String(s) => s
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect(),
            _ => return None,
        };
        if bins.is_empty() { None } else { Some(bins) }
    }

    fn install_command(&self) -> eyre::Result<Option<String>> {
        let Some(value) = self.values.raw().opts.get("install_command") else {
            return Ok(None);
        };
        let Some(command) = value.as_str() else {
            bail!("install_command must be a non-empty string");
        };
        let command = command.trim();
        if command.is_empty() {
            bail!("install_command must be a non-empty string");
        }
        Ok(Some(command.to_string()))
    }

    fn source_install_command(&self) -> eyre::Result<Option<String>> {
        let command = self.install_command()?;
        if command.is_some() && self.filter_bins().is_some() {
            bail!("install_command cannot be used with filter_bins");
        }
        Ok(command)
    }
}

#[async_trait]
impl Backend for SPMBackend {
    fn get_type(&self) -> BackendType {
        BackendType::Spm
    }

    fn ba(&self) -> &Arc<BackendArg> {
        &self.ba
    }

    fn get_dependencies(&self) -> eyre::Result<Vec<&str>> {
        Ok(vec!["swift"])
    }

    fn mark_prereleases_from_version_pattern(&self) -> bool {
        true
    }

    fn remote_version_listing_tool_option_keys(&self) -> &'static [&'static str] {
        &["provider", "api_url", "artifactbundle_asset"]
    }

    fn resolve_lockfile_options(
        &self,
        request: &crate::toolset::ToolRequest,
        target: &PlatformTarget,
    ) -> Result<BTreeMap<String, String>> {
        let raw_opts = request.options();
        Ok(SpmOptions::new(&raw_opts).lockfile_options(target))
    }

    async fn _list_remote_versions(&self, config: &Arc<Config>) -> eyre::Result<Vec<VersionInfo>> {
        let raw_opts = config.get_tool_opts_with_overrides(&self.ba).await?;
        let opts = SpmOptions::new(&raw_opts);
        let provider = GitProvider::from_ba_with_opts(&self.ba, &opts);
        let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?;
        let versions = match provider.kind {
            GitProviderKind::GitLab => {
                gitlab::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
                    .await?
                    .into_iter()
                    .map(|r| VersionInfo {
                        version: r.tag_name,
                        created_at: r.released_at,
                        ..Default::default()
                    })
                    .rev()
                    .collect()
            }
            _ => github::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
                .await?
                .into_iter()
                .map(|r| {
                    let created_at = Some(r.released_at().to_string());
                    VersionInfo {
                        version: r.tag_name,
                        created_at,
                        ..Default::default()
                    }
                })
                .rev()
                .collect(),
        };

        Ok(versions)
    }

    async fn install_version_(
        &self,
        ctx: &InstallContext,
        tv: ToolVersion,
    ) -> eyre::Result<ToolVersion> {
        // Check if swift is available
        self.warn_if_dependency_missing(
            &ctx.config,
            "swift",
            &["swift"],
            "To use Swift Package Manager (spm) tools with mise, you need to install Swift first:\n\
              mise use swift@latest\n\n\
            Or install Swift via https://swift.org/download/",
        )
        .await;
        let mut tv = tv;
        let raw_opts = tv.request.options();
        let opts = SpmOptions::new(&raw_opts);
        let install_command = opts.source_install_command()?;
        let provider = GitProvider::from_ba_with_opts(&self.ba, &opts);
        let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?;
        let revision = match package_revision(&tv) {
            Some(revision) => revision,
            None if tv.version == "latest" => PackageRevision::Release(
                self.latest_version(&ctx.config, None, ctx.before_date)
                    .await?
                    .ok_or_else(|| eyre::eyre!("No stable versions found"))?,
            ),
            None => PackageRevision::Release(tv.version.clone()),
        };

        let artifactbundle_mode = opts.artifactbundle_mode(None)?;
        if should_try_artifactbundle(
            &revision,
            &opts,
            artifactbundle_mode,
            Settings::get().spm.artifactbundle_only,
            &tv.version,
        )? {
            let artifactbundle_required = opts.requires_artifactbundle(artifactbundle_mode);
            match self
                .try_install_artifactbundle(
                    ctx,
                    &mut tv,
                    &provider,
                    &repo,
                    revision.as_str(),
                    &opts,
                )
                .await
            {
                Ok(true) => return Ok(tv),
                Ok(false) if artifactbundle_required => {
                    bail!(
                        "No matching SwiftPM artifact bundle found for {}",
                        repo.shorthand
                    );
                }
                Ok(false) if Settings::get().spm.artifactbundle_only => {
                    bail!(
                        "No matching SwiftPM artifact bundle found for {}, but spm.artifactbundle_only is set",
                        repo.shorthand
                    );
                }
                Ok(false) => {}
                Err(err) if artifactbundle_required => return Err(err),
                Err(err) if Settings::get().spm.artifactbundle_only => return Err(err),
                Err(err) => {
                    debug!(
                        "SwiftPM artifact bundle install failed, falling back to source build: {err:?}"
                    );
                }
            }
        }

        self.install_from_source(ctx, &tv, &repo, &revision, install_command.as_deref())
            .await?;

        Ok(tv)
    }
}

pub fn install_time_option_keys() -> Vec<String> {
    vec![
        "provider".into(),
        "api_url".into(),
        "artifactbundle".into(),
        "artifactbundle_asset".into(),
        "filter_bins".into(),
        "install_command".into(),
    ]
}

impl SPMBackend {
    pub fn from_arg(ba: BackendArg) -> Self {
        Self { ba: Arc::new(ba) }
    }

    async fn install_from_source(
        &self,
        ctx: &InstallContext,
        tv: &ToolVersion,
        repo: &SwiftPackageRepo,
        revision: &PackageRevision,
        install_command: Option<&str>,
    ) -> eyre::Result<()> {
        let repo_dir = self.clone_package_repo(ctx, tv, repo, revision)?;

        if let Some(install_command) = install_command {
            self.run_install_command(ctx, tv, &repo_dir, install_command)
                .await?;
        } else {
            let executables = self.get_executable_names(ctx, &repo_dir, tv).await?;
            if executables.is_empty() {
                return Err(eyre::eyre!("No executables found in the package"));
            }
            let executables = self.apply_filter_bins(tv, executables)?;
            let bin_path = tv.install_path().join("bin");
            file::create_dir_all(&bin_path)?;
            for executable in executables {
                let exe_path = self
                    .build_executable(&executable, &repo_dir, ctx, tv)
                    .await?;
                file::make_symlink(&exe_path, &bin_path.join(executable))?;
            }
        }

        // delete (huge) intermediate artifacts
        file::remove_all(tv.install_path().join("repositories"))?;
        file::remove_all(tv.cache_path())?;

        Ok(())
    }

    async fn run_install_command(
        &self,
        ctx: &InstallContext,
        tv: &ToolVersion,
        repo_dir: &Path,
        install_command: &str,
    ) -> eyre::Result<()> {
        let shell = Settings::get().default_inline_shell()?;
        let (program, shell_args) = shell.split_first().ok_or_else(|| {
            eyre::eyre!(
                "default inline shell is empty; check unix_default_inline_shell_args / windows_default_inline_shell_args"
            )
        })?;

        CmdLineRunner::new(program)
            .current_dir(repo_dir)
            .with_pr(ctx.pr.as_ref())
            .cmd_body_args(shell_args, install_command)
            .env_values(tv.install_env())
            .env("PREFIX", tv.install_path())
            .env("MISE_TOOL_INSTALL_PATH", tv.install_path())
            .prepend_path(
                self.dependency_toolset(&ctx.config)
                    .await?
                    .list_paths(&ctx.config)
                    .await,
            )?
            .execute()?;

        verify_install_command_output(install_command, &tv.install_path().join("bin"))
    }

    fn clone_package_repo(
        &self,
        ctx: &InstallContext,
        tv: &ToolVersion,
        package_repo: &SwiftPackageRepo,
        revision: &PackageRevision,
    ) -> Result<PathBuf, eyre::Error> {
        let repo = Git::new(tv.cache_path().join("repo"));
        if revision.is_git_revision() && repo.exists() {
            let requested = revision.as_str().to_ascii_lowercase();
            let matches = repo
                .current_sha()
                .map(|sha| sha.to_ascii_lowercase().starts_with(&requested))
                .unwrap_or(false);
            if !matches {
                file::remove_all(&repo.dir)?;
            }
        }
        if !repo.exists() {
            debug!(
                "Cloning swift package repo {} to {}",
                package_repo.url.as_str(),
                repo.dir.display(),
            );
            let mut clone_options = CloneOptions::default().pr(ctx.pr.as_ref());
            if revision.is_git_revision() {
                clone_options = clone_options.revision(revision.as_str());
            }
            repo.clone(package_repo.url.as_str(), clone_options)?;
        }
        if !revision.is_git_revision() {
            debug!("Checking out release tag: {}", revision.as_str());
            repo.update_tag(revision.as_str().to_string())?;
        }

        // Updates submodules ensuring they match the checked-out revision
        repo.update_submodules()?;

        Ok(repo.dir)
    }

    fn apply_filter_bins(
        &self,
        tv: &ToolVersion,
        executables: Vec<String>,
    ) -> eyre::Result<Vec<String>> {
        let raw_opts = tv.request.options();
        let opts = SpmOptions::new(&raw_opts);
        filter_executables(&opts, executables)
    }

    async fn get_executable_names(
        &self,
        ctx: &InstallContext,
        repo_dir: &PathBuf,
        tv: &ToolVersion,
    ) -> Result<Vec<String>, eyre::Error> {
        let swift = self
            .spawn_program(&ctx.config, Some(&ctx.ts), "swift")
            .await;
        let package_json = with_install_env(
            cmd!(
                swift,
                "package",
                "dump-package",
                "--package-path",
                &repo_dir,
                "--scratch-path",
                tv.install_path(),
                "--cache-path",
                dirs::CACHE.join("spm"),
            )
            .full_env(self.dependency_env(&ctx.config).await?),
            tv,
        )
        .read()?;
        let executables = serde_json::from_str::<PackageDescription>(&package_json)
            .wrap_err("Failed to parse package description")?
            .products
            .iter()
            .filter(|p| p.r#type.is_executable())
            .map(|p| p.name.clone())
            .collect::<Vec<String>>();
        debug!("Found executables: {:?}", executables);
        Ok(executables)
    }

    async fn build_executable(
        &self,
        executable: &str,
        repo_dir: &PathBuf,
        ctx: &InstallContext,
        tv: &ToolVersion,
    ) -> Result<PathBuf, eyre::Error> {
        debug!("Building swift package");
        // Resolved once: both the build and the --show-bin-path query below use it.
        let swift = self
            .spawn_program(&ctx.config, Some(&ctx.ts), "swift")
            .await;
        CmdLineRunner::new(&swift)
            .arg("build")
            .arg("--configuration")
            .arg("release")
            .arg("--product")
            .arg(executable)
            .arg("--scratch-path")
            .arg(tv.install_path())
            .arg("--package-path")
            .arg(repo_dir)
            .arg("--cache-path")
            .arg(dirs::CACHE.join("spm"))
            .with_pr(ctx.pr.as_ref())
            .env_values(tv.install_env())
            .prepend_path(
                self.dependency_toolset(&ctx.config)
                    .await?
                    .list_paths(&ctx.config)
                    .await,
            )?
            .execute()?;

        let bin_path = with_install_env(
            cmd!(
                &swift,
                "build",
                "--configuration",
                "release",
                "--product",
                &executable,
                "--package-path",
                &repo_dir,
                "--scratch-path",
                tv.install_path(),
                "--cache-path",
                dirs::CACHE.join("spm"),
                "--show-bin-path"
            )
            .full_env(self.dependency_env(&ctx.config).await?),
            tv,
        )
        .read()?;
        Ok(PathBuf::from(bin_path.trim().to_string()).join(executable))
    }

    async fn try_install_artifactbundle(
        &self,
        ctx: &InstallContext,
        tv: &mut ToolVersion,
        provider: &GitProvider,
        repo: &SwiftPackageRepo,
        revision: &str,
        opts: &SpmOptions<'_>,
    ) -> eyre::Result<bool> {
        let Some(asset) = resolve_artifactbundle_asset(provider, repo, revision, opts).await?
        else {
            return Ok(false);
        };

        let bundle_dir = tv.cache_path().join("artifactbundle");
        file::remove_all(&bundle_dir)?;
        file::create_dir_all(&bundle_dir)?;
        let download_path = tv.download_path().join(&asset.name);
        let headers = match provider.kind {
            GitProviderKind::GitLab => gitlab::get_headers(&asset.url),
            GitProviderKind::GitHub => github::get_headers(&asset.url)?,
        };
        ctx.pr.set_message(format!("download {}", asset.name));
        HTTP.download_file_with_headers(
            &asset.url,
            &download_path,
            &headers,
            Some(ctx.pr.as_ref()),
        )
        .await?;

        let platform_key = self.get_platform_key();
        let platform_info = tv.lock_platforms.entry(platform_key).or_default();
        platform_info.url = Some(asset.url.clone());
        platform_info.url_api = asset.url_api.clone();
        if platform_info.checksum.is_none() {
            platform_info.checksum = asset.digest.clone();
        }
        self.verify_checksum(ctx, tv, &download_path)?;

        ctx.pr.set_message(format!("extract {}", asset.name));
        file::unzip(&download_path, &bundle_dir, &Default::default())?;

        let triples = swift_target_triples(ctx, self, tv).await?;
        let binaries = artifactbundle_binaries(&bundle_dir, &triples)?;
        if binaries.is_empty() {
            file::remove_all(tv.cache_path())?;
            return Ok(false);
        }
        let binaries = filter_artifactbundle_binaries(opts, binaries)?;
        if binaries.is_empty() {
            file::remove_all(tv.cache_path())?;
            return Ok(false);
        }

        let bin_path = tv.install_path().join("bin");
        let artifact_bin_path = tv.install_path().join("artifactbundle").join("bin");
        file::create_dir_all(&bin_path)?;
        file::create_dir_all(&artifact_bin_path)?;
        for binary in binaries {
            let installed_binary = artifact_bin_path.join(&binary.name);
            file::copy(&binary.path, &installed_binary)?;
            file::make_executable(&installed_binary)?;
            file::make_symlink(&installed_binary, &bin_path.join(&binary.name))?;
        }
        file::remove_all(tv.cache_path())?;
        Ok(true)
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
enum PackageRevision {
    Release(String),
    Git(String),
}

impl PackageRevision {
    fn as_str(&self) -> &str {
        match self {
            Self::Release(revision) | Self::Git(revision) => revision,
        }
    }

    fn is_git_revision(&self) -> bool {
        matches!(self, Self::Git(_))
    }
}

fn package_revision(tv: &ToolVersion) -> Option<PackageRevision> {
    match &tv.request {
        ToolRequest::Ref { ref_, ref_type, .. } if matches!(ref_type.as_str(), "ref" | "rev") => {
            Some(PackageRevision::Git(ref_.clone()))
        }
        _ => None,
    }
}

fn should_try_artifactbundle(
    revision: &PackageRevision,
    opts: &SpmOptions<'_>,
    mode: ArtifactBundleMode,
    artifactbundle_only: bool,
    display_version: &str,
) -> eyre::Result<bool> {
    if mode == ArtifactBundleMode::SourceOnly && artifactbundle_only {
        bail!("artifactbundle = false conflicts with spm.artifactbundle_only");
    }
    if revision.is_git_revision() && (opts.requires_artifactbundle(mode) || artifactbundle_only) {
        bail!(
            "SwiftPM artifact bundles require a release version; {display_version} selects a Git revision and must be built from source"
        );
    }
    Ok(!revision.is_git_revision() && mode != ArtifactBundleMode::SourceOnly)
}

fn with_install_env(mut command: Expression, tv: &ToolVersion) -> Expression {
    for (key, value) in tv.install_env() {
        command = match value.into_string() {
            Some(value) => command.env(key, value),
            None => command.env_remove(key),
        };
    }
    command
}

/// Restricts `executables` to those listed in `filter_bins`, preserving the
/// original declaration order from `Package.swift` rather than the order in
/// `filter_bins`. Returns an error if any name in `filter_bins` does not match
/// an available executable product.
fn filter_executables(
    opts: &SpmOptions<'_>,
    executables: Vec<String>,
) -> eyre::Result<Vec<String>> {
    let Some(filter) = opts.filter_bins() else {
        return Ok(executables);
    };
    let missing: Vec<&str> = filter
        .iter()
        .filter(|b| !executables.contains(b))
        .map(|s| s.as_str())
        .collect();
    if !missing.is_empty() {
        return Err(eyre::eyre!(
            "filter_bins references executable(s) not found in the package: {}. Available: {}",
            missing.join(", "),
            executables.join(", ")
        ));
    }
    Ok(executables
        .into_iter()
        .filter(|e| filter.contains(e))
        .collect())
}

/// Verifies that `install_command` actually installed an executable into `bin/`.
///
/// A package's install script may exit 0 even when the underlying `swift build`
/// failed (for example when the script does not use `set -e`), which would
/// otherwise leave mise reporting a successful install for a directory that has
/// no runnable tool in it.
fn verify_install_command_output(install_command: &str, bin_path: &Path) -> eyre::Result<()> {
    if !file::ls(bin_path)?
        .iter()
        .any(|entry| entry.is_file() && file::is_executable(entry))
    {
        bail!(
            "install_command `{install_command}` exited successfully but installed no executable into {}; check that it installs into $MISE_TOOL_INSTALL_PATH and that it fails when the build fails",
            bin_path.display()
        );
    }
    Ok(())
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitProvider {
    pub api_url: String,
    pub kind: GitProviderKind,
}

impl Default for GitProvider {
    fn default() -> Self {
        Self {
            api_url: github::API_URL.to_string(),
            kind: GitProviderKind::GitHub,
        }
    }
}

#[derive(AsRefStr, Clone, Debug, Eq, PartialEq, EnumString, VariantNames)]
pub enum GitProviderKind {
    #[strum(serialize = "github")]
    GitHub,
    #[strum(serialize = "gitlab")]
    GitLab,
}

impl GitProvider {
    #[cfg(test)]
    fn from_ba(ba: &BackendArg) -> Self {
        let raw_opts = ba.opts();
        let opts = SpmOptions::new(&raw_opts);
        Self::from_ba_with_opts(ba, &opts)
    }

    fn from_ba_with_opts(ba: &BackendArg, opts: &SpmOptions<'_>) -> Self {
        let provider = opts.provider(None);
        let kind = if ba.tool_name.contains("gitlab.com") {
            GitProviderKind::GitLab
        } else {
            match provider.to_lowercase().as_str() {
                "gitlab" => GitProviderKind::GitLab,
                _ => GitProviderKind::GitHub,
            }
        };

        let api_url = match opts.api_url(None) {
            Some(api_url) => api_url,
            None => {
                Self::derive_api_url_from_tool_name(&ba.tool_name, &kind).unwrap_or_else(|| {
                    match kind {
                        GitProviderKind::GitHub => github::API_URL.to_string(),
                        GitProviderKind::GitLab => gitlab::API_URL.to_string(),
                    }
                })
            }
        };

        Self { api_url, kind }
    }

    /// When the tool name is a full URL pointing to a self-hosted instance,
    /// derive the API URL from the host instead of falling back to the public API.
    fn derive_api_url_from_tool_name(tool_name: &str, kind: &GitProviderKind) -> Option<String> {
        let name = tool_name.strip_prefix("spm:").unwrap_or(tool_name);
        let url = Url::parse(name).ok()?;
        let host = url.host_str()?;
        match host {
            "github.com" | "gitlab.com" => None,
            _ => {
                let api_path = match kind {
                    GitProviderKind::GitHub => github::API_PATH,
                    GitProviderKind::GitLab => gitlab::API_PATH,
                };
                let mut api_url = url.clone();
                api_url.set_path(api_path);
                Some(api_url.as_str().trim_end_matches('/').to_string())
            }
        }
    }
}

#[derive(Debug)]
struct SwiftPackageRepo {
    /// https://github.com/owner/repo.git
    url: Url,
    /// owner/repo_name
    shorthand: String,
}

impl SwiftPackageRepo {
    /// Parse the slug or the full URL of a GitHub package repository.
    fn new(name: &str, provider: &GitProvider) -> Result<Self, eyre::Error> {
        let name = name.strip_prefix("spm:").unwrap_or(name);
        let shorthand_regex = regex!(r"^(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+$");
        let shorthand_in_url_regex = regex!(
            r"^https://(?P<domain>[^/]+)/(?P<shorthand>(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+)\.git"
        );

        let (shorthand, url) = if let Some(caps) = shorthand_in_url_regex.captures(name) {
            let shorthand = caps.name("shorthand").unwrap().as_str();
            let url = Url::parse(name)?;
            (shorthand, url)
        } else if shorthand_regex.is_match(name) {
            let host = match provider.kind {
                GitProviderKind::GitHub => "github.com",
                GitProviderKind::GitLab => "gitlab.com",
            };
            let url_str = format!("https://{}/{}.git", host, name);
            let url = Url::parse(&url_str)?;
            (name, url)
        } else {
            Err(eyre::eyre!(
                "Invalid Swift package repository: {}. The repository should either be a repository slug (owner/name), or the complete URL (e.g. https://github.com/owner/name.git).",
                name
            ))?
        };

        Ok(Self {
            url,
            shorthand: shorthand.to_string(),
        })
    }
}

async fn swift_target_triples(
    ctx: &InstallContext,
    backend: &SPMBackend,
    tv: &ToolVersion,
) -> eyre::Result<Vec<String>> {
    let swift = backend
        .spawn_program(&ctx.config, Some(&ctx.ts), "swift")
        .await;
    let target_info = with_install_env(
        cmd!(swift, "-print-target-info").full_env(backend.dependency_env(&ctx.config).await?),
        tv,
    )
    .read()?;
    parse_swift_target_triples(&target_info)
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ArtifactBundleMode {
    Auto,
    Required,
    SourceOnly,
}

impl ArtifactBundleMode {
    fn requires_artifactbundle(self) -> bool {
        matches!(self, Self::Required)
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct ArtifactBundleReleaseAsset {
    name: String,
    url: String,
    url_api: Option<String>,
    digest: Option<String>,
}

async fn resolve_artifactbundle_asset(
    provider: &GitProvider,
    repo: &SwiftPackageRepo,
    revision: &str,
    opts: &SpmOptions<'_>,
) -> eyre::Result<Option<ArtifactBundleReleaseAsset>> {
    let assets = match provider.kind {
        GitProviderKind::GitLab => {
            gitlab::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
                .await?
                .into_iter()
                .find(|r| r.tag_name == revision)
                .map(|r| {
                    r.assets
                        .links
                        .into_iter()
                        .map(|a| ArtifactBundleReleaseAsset {
                            name: a.name,
                            url: a.direct_asset_url,
                            url_api: Some(a.url),
                            digest: None,
                        })
                        .collect()
                })
        }
        GitProviderKind::GitHub => {
            github::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
                .await?
                .into_iter()
                .find(|r| r.tag_name == revision)
                .map(|r| {
                    r.assets
                        .into_iter()
                        .map(|a| ArtifactBundleReleaseAsset {
                            name: a.name,
                            url: a.browser_download_url,
                            url_api: Some(a.url),
                            digest: a.digest,
                        })
                        .collect()
                })
        }
    };
    let Some(assets) = assets else {
        return Ok(None);
    };
    select_artifactbundle_asset(assets, opts)
}

fn select_artifactbundle_asset(
    assets: Vec<ArtifactBundleReleaseAsset>,
    opts: &SpmOptions<'_>,
) -> eyre::Result<Option<ArtifactBundleReleaseAsset>> {
    let artifactbundle_asset = opts.artifactbundle_asset(None);
    if let Some(name) = artifactbundle_asset {
        if !is_artifactbundle_zip(&name) {
            bail!("artifactbundle_asset must end with .artifactbundle.zip, got {name}");
        }
        return assets
            .into_iter()
            .find(|a| a.name == name)
            .map(Some)
            .ok_or_else(|| {
                eyre::eyre!("artifactbundle_asset not found in release assets: {name}")
            });
    }

    let candidates = assets
        .into_iter()
        .filter(|a| is_artifactbundle_zip(&a.name))
        .collect::<Vec<_>>();
    match candidates.len() {
        0 => Ok(None),
        1 => Ok(candidates.into_iter().next()),
        _ => bail!(
            "multiple SwiftPM artifact bundles found: {}. Set artifactbundle_asset to choose one",
            candidates
                .into_iter()
                .map(|a| a.name)
                .collect::<Vec<_>>()
                .join(", ")
        ),
    }
}

fn is_artifactbundle_zip(name: &str) -> bool {
    name.to_lowercase().ends_with(".artifactbundle.zip")
}

#[derive(Debug, Deserialize)]
struct SwiftTargetInfo {
    target: SwiftTarget,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SwiftTarget {
    triple: String,
    unversioned_triple: String,
    module_triple: String,
}

fn parse_swift_target_triples(json: &str) -> eyre::Result<Vec<String>> {
    let info = serde_json::from_str::<SwiftTargetInfo>(json)
        .wrap_err("Failed to parse swift target info")?;
    let mut seen = std::collections::HashSet::new();
    let triples = vec![
        info.target.unversioned_triple,
        info.target.triple,
        info.target.module_triple,
    ]
    .into_iter()
    .filter(|triple| seen.insert(triple.clone()))
    .collect();
    Ok(triples)
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct ArtifactBundleBinary {
    name: String,
    path: PathBuf,
}

fn artifactbundle_binaries(
    extract_dir: &Path,
    target_triples: &[String],
) -> eyre::Result<Vec<ArtifactBundleBinary>> {
    let mut binaries = vec![];
    for bundle in artifactbundle_dirs(extract_dir)? {
        let info = file::read_to_string(bundle.join("info.json"))?;
        let manifest = serde_json::from_str::<ArtifactBundleInfo>(&info)
            .wrap_err("Failed to parse artifact bundle info.json")?;
        for (name, artifact) in manifest.artifacts {
            if artifact.r#type != "executable" {
                continue;
            }
            for variant in artifact.variants {
                if variant
                    .supported_triples
                    .iter()
                    .any(|triple| target_triples.contains(triple))
                {
                    let path = bundle.join(&variant.path);
                    if !path.is_file() {
                        bail!(
                            "artifact bundle executable does not exist: {}",
                            path.display()
                        );
                    }
                    binaries.push(ArtifactBundleBinary {
                        name: name.clone(),
                        path,
                    });
                    break;
                }
            }
        }
    }
    Ok(binaries)
}

fn artifactbundle_dirs(extract_dir: &Path) -> eyre::Result<Vec<PathBuf>> {
    let mut bundles = vec![];
    if extract_dir.join("info.json").is_file() {
        bundles.push(extract_dir.to_path_buf());
    }

    for entry in std::fs::read_dir(extract_dir)
        .wrap_err_with(|| format!("failed to read_dir: {}", extract_dir.display()))?
    {
        let path = entry?.path();
        if !path.is_dir() {
            continue;
        }
        if is_artifactbundle_dir(&path) {
            bundles.push(path);
        } else {
            bundles.extend(direct_artifactbundle_dirs(&path)?);
        }
    }
    bundles.sort();
    Ok(bundles)
}

fn direct_artifactbundle_dirs(dir: &Path) -> eyre::Result<Vec<PathBuf>> {
    let mut bundles = vec![];
    for entry in
        std::fs::read_dir(dir).wrap_err_with(|| format!("failed to read_dir: {}", dir.display()))?
    {
        let path = entry?.path();
        if path.is_dir() && is_artifactbundle_dir(&path) {
            bundles.push(path);
        }
    }
    bundles.sort();
    Ok(bundles)
}

fn is_artifactbundle_dir(path: &Path) -> bool {
    path.extension().is_some_and(|ext| ext == "artifactbundle") && path.join("info.json").is_file()
}

fn filter_artifactbundle_binaries(
    opts: &SpmOptions<'_>,
    binaries: Vec<ArtifactBundleBinary>,
) -> eyre::Result<Vec<ArtifactBundleBinary>> {
    let names = binaries.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
    let filtered = filter_executables(opts, names)?;
    Ok(binaries
        .into_iter()
        .filter(|b| filtered.contains(&b.name))
        .collect())
}

#[cfg(test)]
mod tests {
    use crate::cli::args::BackendResolution;
    use crate::{
        config::Config,
        toolset::{ToolSource, ToolVersionOptions},
    };

    use super::*;
    use indexmap::indexmap;
    use pretty_assertions::assert_str_eq;

    #[tokio::test]
    async fn test_git_provider_from_ba() {
        // Example of defining a capture (closure) in Rust:
        let get_ba = |tool: String, opts: Option<ToolVersionOptions>| {
            BackendArg::new_raw(
                "spm".to_string(),
                Some(tool.clone()),
                tool,
                opts,
                BackendResolution::new(true),
            )
        };

        assert_eq!(
            GitProvider::from_ba(&get_ba("tool".to_string(), None)),
            GitProvider {
                api_url: github::API_URL.to_string(),
                kind: GitProviderKind::GitHub
            }
        );

        assert_eq!(
            GitProvider::from_ba(&get_ba(
                "tool".to_string(),
                Some(ToolVersionOptions {
                    opts: indexmap![
                        "provider".to_string() => toml::Value::String("gitlab".to_string())
                    ]
                    .into(),
                    ..Default::default()
                })
            )),
            GitProvider {
                api_url: gitlab::API_URL.to_string(),
                kind: GitProviderKind::GitLab
            }
        );

        assert_eq!(
            GitProvider::from_ba(&get_ba(
                "tool".to_string(),
                Some(ToolVersionOptions {
                    opts: indexmap![
                        "api_url".to_string() => toml::Value::String("https://gitlab.acme.com/api/v4".to_string()),
                        "provider".to_string() => toml::Value::String("gitlab".to_string()),
                    ]
                    .into(),
                    ..Default::default()
                })
            )),
            GitProvider {
                api_url: "https://gitlab.acme.com/api/v4".to_string(),
                kind: GitProviderKind::GitLab
            }
        );

        // Self-hosted GitHub Enterprise URL without api_url -> should derive from host
        assert_eq!(
            GitProvider::from_ba(&get_ba(
                "https://github.acme.com/org/Tool.git".to_string(),
                None
            )),
            GitProvider {
                api_url: "https://github.acme.com/api/v3".to_string(),
                kind: GitProviderKind::GitHub
            }
        );

        // Self-hosted GitLab URL without api_url -> should derive from host
        assert_eq!(
            GitProvider::from_ba(&get_ba(
                "https://gitlab.acme.com/org/Tool.git".to_string(),
                Some(ToolVersionOptions {
                    opts: indexmap![
                        "provider".to_string() => toml::Value::String("gitlab".to_string())
                    ]
                    .into(),
                    ..Default::default()
                })
            )),
            GitProvider {
                api_url: "https://gitlab.acme.com/api/v4".to_string(),
                kind: GitProviderKind::GitLab
            }
        );

        // github.com URL without api_url -> should use default
        assert_eq!(
            GitProvider::from_ba(&get_ba("https://github.com/org/Tool.git".to_string(), None)),
            GitProvider {
                api_url: github::API_URL.to_string(),
                kind: GitProviderKind::GitHub
            }
        );

        // Explicit api_url should take precedence over derived URL
        assert_eq!(
            GitProvider::from_ba(&get_ba(
                "https://github.acme.com/org/Tool.git".to_string(),
                Some(ToolVersionOptions {
                    opts: indexmap![
                        "api_url".to_string() => toml::Value::String("https://custom-api.acme.com/v3".to_string())
                    ]
                    .into(),
                    ..Default::default()
                })
            )),
            GitProvider {
                api_url: "https://custom-api.acme.com/v3".to_string(),
                kind: GitProviderKind::GitHub
            }
        );
    }

    #[tokio::test]
    async fn test_spm_repo_init_by_shorthand() {
        let _config = Config::get().await.unwrap();
        let package_repo =
            SwiftPackageRepo::new("nicklockwood/SwiftFormat", &GitProvider::default()).unwrap();
        assert_str_eq!(
            package_repo.url.as_str(),
            "https://github.com/nicklockwood/SwiftFormat.git"
        );
        assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat");

        let package_repo = SwiftPackageRepo::new(
            "acme/nicklockwood/SwiftFormat",
            &GitProvider {
                api_url: gitlab::API_URL.to_string(),
                kind: GitProviderKind::GitLab,
            },
        )
        .unwrap();
        assert_str_eq!(
            package_repo.url.as_str(),
            "https://gitlab.com/acme/nicklockwood/SwiftFormat.git"
        );
        assert_str_eq!(package_repo.shorthand, "acme/nicklockwood/SwiftFormat");
    }

    #[tokio::test]
    async fn test_spm_repo_init_name() {
        let _config = Config::get().await.unwrap();
        assert!(
            SwiftPackageRepo::new("owner/name.swift", &GitProvider::default()).is_ok(),
            "name part can contain ."
        );
        assert!(
            SwiftPackageRepo::new("owner/name_swift", &GitProvider::default()).is_ok(),
            "name part can contain _"
        );
        assert!(
            SwiftPackageRepo::new("owner/name-swift", &GitProvider::default()).is_ok(),
            "name part can contain -"
        );
        assert!(
            SwiftPackageRepo::new("owner/name$swift", &GitProvider::default()).is_err(),
            "name part cannot contain characters other than a-zA-Z0-9._-"
        );
    }

    #[tokio::test]
    async fn test_spm_repo_init_by_url() {
        let package_repo = SwiftPackageRepo::new(
            "https://github.com/nicklockwood/SwiftFormat.git",
            &GitProvider::default(),
        )
        .unwrap();
        assert_str_eq!(
            package_repo.url.as_str(),
            "https://github.com/nicklockwood/SwiftFormat.git"
        );
        assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat");

        let package_repo = SwiftPackageRepo::new(
            "https://gitlab.acme.com/acme/someuser/SwiftTool.git",
            &GitProvider {
                api_url: "https://api.gitlab.acme.com/api/v4".to_string(),
                kind: GitProviderKind::GitLab,
            },
        )
        .unwrap();
        assert_str_eq!(
            package_repo.url.as_str(),
            "https://gitlab.acme.com/acme/someuser/SwiftTool.git"
        );
        assert_str_eq!(package_repo.shorthand, "acme/someuser/SwiftTool");
    }

    fn opts_with_filter_bins(value: toml::Value) -> ToolVersionOptions {
        ToolVersionOptions {
            opts: indexmap!["filter_bins".to_string() => value].into(),
            ..Default::default()
        }
    }

    fn opts_with(key: &str, value: toml::Value) -> ToolVersionOptions {
        ToolVersionOptions {
            opts: indexmap![key.to_string() => value].into(),
            ..Default::default()
        }
    }

    fn tool_version(version: &str) -> ToolVersion {
        let ba = Arc::new(BackendArg::new_raw(
            "spm:owner/repo".to_string(),
            Some("owner/repo".to_string()),
            "owner/repo".to_string(),
            None,
            BackendResolution::new(true),
        ));
        let request = ToolRequest::new(ba, version, ToolSource::Argument).unwrap();
        ToolVersion::new(request, version.to_string())
    }

    #[test]
    fn package_revision_distinguishes_releases_from_git_revisions() {
        assert_eq!(package_revision(&tool_version("1.2.3")), None);
        assert_eq!(
            package_revision(&tool_version("rev:0123456789abcdef")),
            Some(PackageRevision::Git("0123456789abcdef".to_string()))
        );
        assert_eq!(
            package_revision(&tool_version("ref:0123456789abcdef")),
            Some(PackageRevision::Git("0123456789abcdef".to_string()))
        );
        assert_eq!(package_revision(&tool_version("branch:main")), None);
    }

    #[test]
    fn git_revisions_skip_or_reject_artifact_bundles() {
        let revision = PackageRevision::Git("0123456789abcdef".to_string());
        let default_opts = ToolVersionOptions::default();
        let default_opts = SpmOptions::new(&default_opts);
        assert!(
            !should_try_artifactbundle(
                &revision,
                &default_opts,
                ArtifactBundleMode::Auto,
                false,
                "rev:0123456789abcdef"
            )
            .unwrap()
        );

        let required = opts_with("artifactbundle", toml::Value::Boolean(true));
        let required = SpmOptions::new(&required);
        let err = should_try_artifactbundle(
            &revision,
            &required,
            ArtifactBundleMode::Required,
            false,
            "rev:0123456789abcdef",
        )
        .unwrap_err();
        assert!(format!("{err:#}").contains("must be built from source"));

        let err = should_try_artifactbundle(
            &revision,
            &default_opts,
            ArtifactBundleMode::Auto,
            true,
            "ref:0123456789abcdef",
        )
        .unwrap_err();
        assert!(format!("{err:#}").contains("must be built from source"));
    }

    #[test]
    fn test_lockfile_options_include_artifact_inputs_not_filter_bins() {
        let mut opts = ToolVersionOptions::default();
        opts.opts.insert(
            "provider".to_string(),
            toml::Value::String("GitLab".to_string()),
        );
        opts.opts.insert(
            "api_url".to_string(),
            toml::Value::String("https://gitlab.example.com/api/v4/".to_string()),
        );
        opts.opts
            .insert("artifactbundle".to_string(), toml::Value::Boolean(true));
        opts.opts.insert(
            "artifactbundle_asset".to_string(),
            toml::Value::String("tool.artifactbundle.zip".to_string()),
        );
        opts.opts.insert(
            "filter_bins".to_string(),
            toml::Value::String("tool".to_string()),
        );

        assert_eq!(
            SpmOptions::new(&opts).lockfile_options(&PlatformTarget::from_current()),
            BTreeMap::from([
                (
                    "api_url".to_string(),
                    "https://gitlab.example.com/api/v4".to_string()
                ),
                ("artifactbundle".to_string(), "true".to_string()),
                (
                    "artifactbundle_asset".to_string(),
                    "tool.artifactbundle.zip".to_string()
                ),
                ("provider".to_string(), "gitlab".to_string()),
            ])
        );
    }

    #[test]
    fn test_lockfile_options_use_target_platform_artifact_inputs() {
        let mut opts = ToolVersionOptions::default();
        let mut platforms = toml::Table::new();
        let mut linux = toml::Table::new();
        let mut windows = toml::Table::new();
        linux.insert(
            "artifactbundle_asset".to_string(),
            toml::Value::String("linux.artifactbundle.zip".to_string()),
        );
        windows.insert(
            "artifactbundle_asset".to_string(),
            toml::Value::String("windows.artifactbundle.zip".to_string()),
        );
        platforms.insert("linux-x64".to_string(), toml::Value::Table(linux));
        platforms.insert("windows-x64".to_string(), toml::Value::Table(windows));
        opts.opts
            .insert("platforms".to_string(), toml::Value::Table(platforms));

        let linux = PlatformTarget::new(crate::platform::Platform::parse("linux-x64").unwrap());
        let windows = PlatformTarget::new(crate::platform::Platform::parse("windows-x64").unwrap());

        assert_eq!(
            SpmOptions::new(&opts).lockfile_options(&linux),
            BTreeMap::from([(
                "artifactbundle_asset".to_string(),
                "linux.artifactbundle.zip".to_string()
            )])
        );
        assert_eq!(
            SpmOptions::new(&opts).lockfile_options(&windows),
            BTreeMap::from([(
                "artifactbundle_asset".to_string(),
                "windows.artifactbundle.zip".to_string()
            )])
        );

        let mut current_host_only_opts = ToolVersionOptions::default();
        let mut platforms = toml::Table::new();
        let mut linux = toml::Table::new();
        linux.insert(
            "artifactbundle_asset".to_string(),
            toml::Value::String("linux.artifactbundle.zip".to_string()),
        );
        platforms.insert("linux-x64".to_string(), toml::Value::Table(linux));
        current_host_only_opts
            .opts
            .insert("platforms".to_string(), toml::Value::Table(platforms));

        assert!(
            SpmOptions::new(&current_host_only_opts)
                .lockfile_options(&windows)
                .is_empty()
        );
    }

    #[test]
    fn test_install_time_options_include_layout_and_artifact_inputs() {
        assert_eq!(
            install_time_option_keys(),
            vec![
                "provider".to_string(),
                "api_url".to_string(),
                "artifactbundle".to_string(),
                "artifactbundle_asset".to_string(),
                "filter_bins".to_string(),
                "install_command".to_string(),
            ]
        );
    }

    #[test]
    fn test_install_command() {
        let opts = ToolVersionOptions::default();
        assert_eq!(SpmOptions::new(&opts).install_command().unwrap(), None);

        let opts = opts_with(
            "install_command",
            toml::Value::String("  make install  ".to_string()),
        );
        assert_eq!(
            SpmOptions::new(&opts).install_command().unwrap(),
            Some("make install".to_string())
        );
    }

    #[test]
    fn test_install_command_rejects_empty_or_invalid_values() {
        for value in [
            toml::Value::String(" \t ".to_string()),
            toml::Value::Boolean(true),
            toml::Value::Array(vec![toml::Value::String("make install".to_string())]),
        ] {
            let opts = opts_with("install_command", value);
            assert_str_eq!(
                SpmOptions::new(&opts)
                    .install_command()
                    .unwrap_err()
                    .to_string(),
                "install_command must be a non-empty string"
            );
        }
    }

    #[test]
    fn test_install_command_conflicts_with_filter_bins() {
        let mut opts = ToolVersionOptions::default();
        opts.opts.insert(
            "install_command".to_string(),
            toml::Value::String("make install".to_string()),
        );
        opts.opts.insert(
            "filter_bins".to_string(),
            toml::Value::String("danger-swift".to_string()),
        );

        assert_str_eq!(
            SpmOptions::new(&opts)
                .source_install_command()
                .unwrap_err()
                .to_string(),
            "install_command cannot be used with filter_bins"
        );
    }

    #[test]
    fn test_verify_install_command_output() {
        let temp = tempfile::tempdir().unwrap();
        let bin_path = temp.path().join("bin");

        // missing bin/ or an empty bin/ means the command installed nothing
        let err = verify_install_command_output("make install", &bin_path)
            .unwrap_err()
            .to_string();
        assert!(err.contains("installed no executable into"), "{err}");
        std::fs::create_dir_all(&bin_path).unwrap();
        assert!(verify_install_command_output("make install", &bin_path).is_err());

        // a directory alone is not an installed executable
        std::fs::create_dir(bin_path.join("placeholder")).unwrap();
        assert!(verify_install_command_output("make install", &bin_path).is_err());

        let executable = bin_path.join(if cfg!(windows) {
            "danger-swift.exe"
        } else {
            "danger-swift"
        });
        std::fs::write(&executable, "").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;

            // a regular file that cannot be run is not an installed executable
            assert!(verify_install_command_output("make install", &bin_path).is_err());
            std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        assert!(verify_install_command_output("make install", &bin_path).is_ok());
    }

    fn release_asset(name: &str) -> ArtifactBundleReleaseAsset {
        ArtifactBundleReleaseAsset {
            name: name.to_string(),
            url: format!("https://example.com/{name}"),
            url_api: None,
            digest: None,
        }
    }

    fn filter_bins(raw: &ToolVersionOptions) -> Option<Vec<String>> {
        SpmOptions::new(raw).filter_bins()
    }

    fn filter_executables_with_raw(
        raw: &ToolVersionOptions,
        executables: Vec<String>,
    ) -> eyre::Result<Vec<String>> {
        let opts = SpmOptions::new(raw);
        filter_executables(&opts, executables)
    }

    #[test]
    fn test_resolve_artifactbundle_mode() {
        let default_opts = ToolVersionOptions::default();
        assert_eq!(
            SpmOptions::new(&default_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::Auto
        );
        let required_opts = opts_with("artifactbundle", toml::Value::Boolean(true));
        assert_eq!(
            SpmOptions::new(&required_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::Required
        );
        let source_only_opts = opts_with("artifactbundle", toml::Value::Boolean(false));
        assert_eq!(
            SpmOptions::new(&source_only_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::SourceOnly
        );
        let required_opts = opts_with("artifactbundle", toml::Value::String("TRUE".to_string()));
        assert_eq!(
            SpmOptions::new(&required_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::Required
        );
        let required_opts = opts_with("artifactbundle", toml::Value::String("1".to_string()));
        assert_eq!(
            SpmOptions::new(&required_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::Required
        );
        let source_only_opts =
            opts_with("artifactbundle", toml::Value::String("FALSE".to_string()));
        assert_eq!(
            SpmOptions::new(&source_only_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::SourceOnly
        );
        let source_only_opts = opts_with("artifactbundle", toml::Value::String("0".to_string()));
        assert_eq!(
            SpmOptions::new(&source_only_opts)
                .artifactbundle_mode(None)
                .unwrap(),
            ArtifactBundleMode::SourceOnly
        );
        let invalid_opts = opts_with("artifactbundle", toml::Value::String("00".to_string()));
        assert!(
            SpmOptions::new(&invalid_opts)
                .artifactbundle_mode(None)
                .is_err()
        );
        let invalid_opts = opts_with(
            "artifactbundle",
            toml::Value::String("sometimes".to_string()),
        );
        assert!(
            SpmOptions::new(&invalid_opts)
                .artifactbundle_mode(None)
                .is_err()
        );
    }

    #[test]
    fn test_requires_artifactbundle() {
        let default_opts = ToolVersionOptions::default();
        let opts = SpmOptions::new(&default_opts);
        assert!(!opts.requires_artifactbundle(ArtifactBundleMode::Auto));
        assert!(opts.requires_artifactbundle(ArtifactBundleMode::Required));

        let asset_opts = opts_with(
            "artifactbundle_asset",
            toml::Value::String("tool.artifactbundle.zip".to_string()),
        );
        let opts = SpmOptions::new(&asset_opts);
        assert!(opts.requires_artifactbundle(ArtifactBundleMode::Auto));
    }

    #[test]
    fn test_select_artifactbundle_asset() {
        let default_opts = ToolVersionOptions::default();
        let opts = SpmOptions::new(&default_opts);
        let selected =
            select_artifactbundle_asset(vec![release_asset("tool.artifactbundle.zip")], &opts)
                .unwrap()
                .unwrap();
        assert_eq!(selected.name, "tool.artifactbundle.zip");

        let selected_opts = opts_with(
            "artifactbundle_asset",
            toml::Value::String("tool.artifactbundle.zip".to_string()),
        );
        let opts = SpmOptions::new(&selected_opts);
        let selected = select_artifactbundle_asset(
            vec![
                release_asset("tool.artifactbundle.zip"),
                release_asset("tool.tar.gz"),
            ],
            &opts,
        )
        .unwrap()
        .unwrap();
        assert_eq!(selected.name, "tool.artifactbundle.zip");

        let non_bundle_opts = opts_with(
            "artifactbundle_asset",
            toml::Value::String("tool.tar.gz".to_string()),
        );
        let opts = SpmOptions::new(&non_bundle_opts);
        assert!(select_artifactbundle_asset(vec![release_asset("tool.tar.gz")], &opts).is_err());
        let missing_opts = opts_with(
            "artifactbundle_asset",
            toml::Value::String("missing.artifactbundle.zip".to_string()),
        );
        let opts = SpmOptions::new(&missing_opts);
        assert!(
            select_artifactbundle_asset(vec![release_asset("tool.artifactbundle.zip")], &opts,)
                .is_err()
        );
        let opts = SpmOptions::new(&default_opts);
        assert!(
            select_artifactbundle_asset(
                vec![
                    release_asset("a.artifactbundle.zip"),
                    release_asset("b.artifactbundle.zip"),
                ],
                &opts,
            )
            .is_err()
        );
        assert!(
            select_artifactbundle_asset(vec![release_asset("tool.tar.gz")], &opts,)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn test_parse_swift_target_triples() {
        let triples = parse_swift_target_triples(
            r#"{
              "target": {
                "triple": "arm64-apple-macosx26.0",
                "unversionedTriple": "arm64-apple-macosx",
                "moduleTriple": "arm64-apple-macos"
              }
            }"#,
        )
        .unwrap();
        assert_eq!(
            triples,
            vec![
                "arm64-apple-macosx".to_string(),
                "arm64-apple-macosx26.0".to_string(),
                "arm64-apple-macos".to_string(),
            ]
        );
    }

    #[test]
    fn test_parse_swift_target_triples_deduplicates_non_consecutive_values() {
        let triples = parse_swift_target_triples(
            r#"{
              "target": {
                "triple": "arm64-apple-macosx26.0",
                "unversionedTriple": "arm64-apple-macosx",
                "moduleTriple": "arm64-apple-macosx"
              }
            }"#,
        )
        .unwrap();
        assert_eq!(
            triples,
            vec![
                "arm64-apple-macosx".to_string(),
                "arm64-apple-macosx26.0".to_string(),
            ]
        );
    }

    #[test]
    fn test_artifactbundle_binaries() {
        let tmp = tempfile::tempdir().unwrap();
        let bundle = tmp.path().join("tool.artifactbundle");
        let bin = bundle.join("tool-1.0.0-macosx/bin");
        file::create_dir_all(&bin).unwrap();
        file::write(bin.join("tool"), "").unwrap();
        file::write(
            bundle.join("info.json"),
            r#"{
              "schemaVersion": "1.0",
              "artifacts": {
                "tool": {
                  "version": "1.0.0",
                  "type": "executable",
                  "variants": [
                    {
                      "path": "tool-1.0.0-macosx/bin/tool",
                      "supportedTriples": ["arm64-apple-macosx"]
                    }
                  ]
                },
                "library": {
                  "version": "1.0.0",
                  "type": "library",
                  "variants": [
                    {
                      "path": "lib",
                      "supportedTriples": ["arm64-apple-macosx"]
                    }
                  ]
                }
              }
            }"#,
        )
        .unwrap();

        let binaries =
            artifactbundle_binaries(tmp.path(), &["arm64-apple-macosx".to_string()]).unwrap();
        assert_eq!(binaries.len(), 1);
        assert_eq!(binaries[0].name, "tool");
        assert_eq!(binaries[0].path, bin.join("tool"));

        let binaries =
            artifactbundle_binaries(tmp.path(), &["x86_64-unknown-linux-gnu".to_string()]).unwrap();
        assert!(binaries.is_empty());
    }

    #[test]
    fn test_artifactbundle_binaries_uses_first_matching_variant() {
        let tmp = tempfile::tempdir().unwrap();
        let bundle = tmp.path().join("tool.artifactbundle");
        let first_bin = bundle.join("tool-1.0.0-macosx/bin");
        let second_bin = bundle.join("tool-1.0.0-macos/bin");
        file::create_dir_all(&first_bin).unwrap();
        file::create_dir_all(&second_bin).unwrap();
        file::write(first_bin.join("tool"), "").unwrap();
        file::write(second_bin.join("tool"), "").unwrap();
        file::write(
            bundle.join("info.json"),
            r#"{
              "schemaVersion": "1.0",
              "artifacts": {
                "tool": {
                  "version": "1.0.0",
                  "type": "executable",
                  "variants": [
                    {
                      "path": "tool-1.0.0-macosx/bin/tool",
                      "supportedTriples": ["arm64-apple-macosx"]
                    },
                    {
                      "path": "tool-1.0.0-macos/bin/tool",
                      "supportedTriples": ["arm64-apple-macos"]
                    }
                  ]
                }
              }
            }"#,
        )
        .unwrap();

        let binaries = artifactbundle_binaries(
            tmp.path(),
            &[
                "arm64-apple-macosx".to_string(),
                "arm64-apple-macos".to_string(),
            ],
        )
        .unwrap();
        assert_eq!(binaries.len(), 1);
        assert_eq!(binaries[0].path, first_bin.join("tool"));
    }

    #[test]
    fn test_artifactbundle_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        file::write(tmp.path().join("info.json"), "{}").unwrap();

        let direct = tmp.path().join("direct.artifactbundle");
        file::create_dir_all(&direct).unwrap();
        file::write(direct.join("info.json"), "{}").unwrap();

        let wrapped = tmp.path().join("wrapper").join("wrapped.artifactbundle");
        file::create_dir_all(&wrapped).unwrap();
        file::write(wrapped.join("info.json"), "{}").unwrap();

        let deeper = tmp
            .path()
            .join("wrapper")
            .join("too-deep")
            .join("deep.artifactbundle");
        file::create_dir_all(&deeper).unwrap();
        file::write(deeper.join("info.json"), "{}").unwrap();

        let dirs = artifactbundle_dirs(tmp.path()).unwrap();
        assert_eq!(
            dirs,
            vec![tmp.path().to_path_buf(), direct, wrapped],
            "search should be limited to the bundle root, direct children, and one wrapper directory"
        );
    }

    #[test]
    fn test_filter_artifactbundle_binaries() {
        let binaries = vec![
            ArtifactBundleBinary {
                name: "a".to_string(),
                path: PathBuf::from("/tmp/a"),
            },
            ArtifactBundleBinary {
                name: "b".to_string(),
                path: PathBuf::from("/tmp/b"),
            },
        ];
        let opts = opts_with_filter_bins(toml::Value::String("b".to_string()));
        let opts = SpmOptions::new(&opts);
        let filtered = filter_artifactbundle_binaries(&opts, binaries).unwrap();
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].name, "b");
    }

    #[test]
    fn test_parse_filter_bins() {
        assert_eq!(filter_bins(&ToolVersionOptions::default()), None);

        assert_eq!(
            filter_bins(&opts_with_filter_bins(toml::Value::String(
                "swiftly".to_string()
            ))),
            Some(vec!["swiftly".to_string()])
        );

        assert_eq!(
            filter_bins(&opts_with_filter_bins(toml::Value::String(
                " foo , bar , ".to_string()
            ))),
            Some(vec!["foo".to_string(), "bar".to_string()])
        );

        assert_eq!(
            filter_bins(&opts_with_filter_bins(toml::Value::Array(vec![
                toml::Value::String("foo".to_string()),
                toml::Value::String(" bar".to_string()),
                toml::Value::String("".to_string()),
            ]))),
            Some(vec!["foo".to_string(), "bar".to_string()])
        );

        assert_eq!(
            filter_bins(&opts_with_filter_bins(toml::Value::String(
                " , ".to_string()
            ))),
            None,
            "whitespace-only entries should yield None"
        );
    }

    #[test]
    fn test_filter_executables_passthrough_when_unset() {
        let executables = vec!["a".to_string(), "b".to_string()];
        let result =
            filter_executables_with_raw(&ToolVersionOptions::default(), executables.clone())
                .unwrap();
        assert_eq!(result, executables);
    }

    #[test]
    fn test_filter_executables_restricts_and_preserves_order() {
        let executables = vec![
            "swiftly".to_string(),
            "test-swiftly".to_string(),
            "helper".to_string(),
        ];
        let opts = opts_with_filter_bins(toml::Value::Array(vec![
            toml::Value::String("helper".to_string()),
            toml::Value::String("swiftly".to_string()),
        ]));
        let result = filter_executables_with_raw(&opts, executables).unwrap();
        assert_eq!(result, vec!["swiftly".to_string(), "helper".to_string()]);
    }

    #[test]
    fn test_filter_executables_errors_on_missing_name() {
        let executables = vec!["swiftly".to_string(), "test-swiftly".to_string()];
        let opts = opts_with_filter_bins(toml::Value::String("does-not-exist".to_string()));
        let err = filter_executables_with_raw(&opts, executables).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("does-not-exist"), "got: {msg}");
        assert!(msg.contains("swiftly"), "got: {msg}");
    }
}

/// https://developer.apple.com/documentation/packagedescription
#[derive(Deserialize)]
struct PackageDescription {
    products: Vec<PackageDescriptionProduct>,
}

#[derive(Deserialize)]
struct PackageDescriptionProduct {
    name: String,
    #[serde(deserialize_with = "PackageDescriptionProductType::deserialize_product_type_field")]
    r#type: PackageDescriptionProductType,
}

#[derive(Deserialize)]
enum PackageDescriptionProductType {
    Executable,
    Other,
}

impl PackageDescriptionProductType {
    fn is_executable(&self) -> bool {
        matches!(self, Self::Executable)
    }

    /// Swift determines the toolchain to use with a given package using a comment in the Package.swift file at the top.
    /// For example:
    ///   // swift-tools-version: 6.0
    ///
    /// The version of the toolchain can be older than the Swift version used to build the package. This versioning gives
    /// Apple the flexibility to introduce and flag breaking changes in the toolchain.
    ///
    /// How to determine the product type is something that might change across different versions of Swift.
    ///
    /// ## Swift 5.x
    ///
    /// Product type is a key in the map with an undocumented value that we are not interested in and can be easily skipped.
    ///
    /// Example:
    /// ```json
    /// "type" : {
    ///     "executable" : null
    /// }
    /// ```
    /// or
    /// ```json
    /// "type" : {
    ///     "library" : [
    ///       "automatic"
    ///     ]
    /// }
    /// ```
    ///
    /// ## Swift 6.x
    ///
    /// The product type is directly the value under the key "type"
    ///
    /// Example:
    ///
    /// ```json
    /// "type": "executable"
    /// ```
    ///
    fn deserialize_product_type_field<'de, D>(
        deserializer: D,
    ) -> Result<PackageDescriptionProductType, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct TypeFieldVisitor;

        impl<'de> Visitor<'de> for TypeFieldVisitor {
            type Value = PackageDescriptionProductType;

            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("a map with a key 'executable' or other types")
            }

            fn visit_map<V>(self, mut map: V) -> Result<Self::Value, V::Error>
            where
                V: MapAccess<'de>,
            {
                if let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "type" => {
                            let value: String = map.next_value()?;
                            if value == "executable" {
                                Ok(PackageDescriptionProductType::Executable)
                            } else {
                                Ok(PackageDescriptionProductType::Other)
                            }
                        }
                        "executable" => {
                            // Skip the value by reading it into a dummy serde_json::Value
                            let _value: serde_json::Value = map.next_value()?;
                            Ok(PackageDescriptionProductType::Executable)
                        }
                        _ => {
                            let _value: serde_json::Value = map.next_value()?;
                            Ok(PackageDescriptionProductType::Other)
                        }
                    }
                } else {
                    Err(serde::de::Error::custom("missing key"))
                }
            }
        }

        deserializer.deserialize_map(TypeFieldVisitor)
    }
}

/// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0305-swiftpm-binary-target-improvements.md
#[derive(Deserialize)]
struct ArtifactBundleInfo {
    artifacts: std::collections::BTreeMap<String, ArtifactBundleArtifact>,
}

#[derive(Deserialize)]
struct ArtifactBundleArtifact {
    r#type: String,
    variants: Vec<ArtifactBundleVariant>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ArtifactBundleVariant {
    path: String,
    supported_triples: Vec<String>,
}
