//! Testing a package produced by Rattler-Build (or conda-build)
//!
//! Tests are part of the final package (under the `info/test` directory).
//! There are multiple test types:
//!
//! * `commands` - run a list of commands and check their exit code
//! * `imports` - import a list of modules and check if they can be imported
//! * `files` - check if a list of files exist
use fs_err as fs;
use miette::WrapErr;
use rattler_build_jinja::Variable;
use rattler_build_recipe::stage1::{
    TestType,
    tests::{CommandsTest, DownstreamTest, PerlTest, PythonTest, PythonVersion, RTest, RubyTest},
};
use rattler_build_script::{
    EnvironmentIsolation, ExecutionContext, RuntimeEnv, Script, ScriptContent,
};
use rattler_build_types::NormalizedKey;
use rattler_conda_types::{
    Channel, ChannelUrl, MatchSpec, PackageName, PackageNameMatcher, ParseStrictness, Platform,
    StringMatcher, Version, VersionSpec,
    compression_level::CompressionLevel,
    package::{ArchiveIdentifier, CondaArchiveIdentifier, IndexJson, PackageFile},
    version_spec::EqualityOperator,
};
use rattler_index::{IndexFsConfig, index_fs};
use rattler_package_streaming::write::write_conda_package;
use rattler_shell::{
    activation::ActivationError,
    shell::{Shell, ShellEnum},
};
use rattler_solve::{ChannelPriority, SolveStrategy};
use std::collections::BTreeMap;
use std::fmt::Write;
use std::{
    collections::HashMap,
    io::Write as _,
    path::{Path, PathBuf},
    str::FromStr,
};
use tempfile::TempDir;
use walkdir::WalkDir;

use rattler::package_cache::PackageCache;
use rattler_cache::validation::ValidationMode;

use crate::{
    env_vars, metadata::PlatformWithVirtualPackages, render::solver::create_environment,
    source::copy_dir::CopyDir, tool_configuration,
};

/// Builds a `MatchSpec` that exactly matches the just-built artifact from its
/// archive identifier (name, version, build string).
fn match_spec_for_identifier(identifier: &ArchiveIdentifier) -> Result<MatchSpec, TestError> {
    // Parse the name and version as typed values to avoid any ambiguity that a
    // string re-parse could introduce.
    let name = PackageName::try_from(identifier.name.as_str())
        .map_err(|e| TestError::MatchSpecParse(e.to_string()))?;
    let version = Version::from_str(&identifier.version)
        .map_err(|e| TestError::MatchSpecParse(e.to_string()))?;

    Ok(MatchSpec {
        name: PackageNameMatcher::Exact(name),
        // Match the version exactly (==); VersionOrder already supports the
        // trailing-underscore convention.
        version: Some(VersionSpec::Exact(EqualityOperator::Equals, version)),
        // Match the build string literally, without glob/regex interpretation.
        build: Some(StringMatcher::Exact(identifier.build_string.clone())),
        ..Default::default()
    })
}

#[allow(missing_docs)]
#[derive(thiserror::Error, Debug)]
pub enum TestError {
    #[error("failed package content tests: {0}")]
    PackageContentTestFailed(String),

    #[error("failed package content tests: {0}")]
    PackageContentTestFailedStr(&'static str),

    #[error("failed to get environment `PREFIX` variable")]
    PrefixEnvironmentVariableNotFound,

    #[error("failed to build glob from pattern")]
    GlobError(#[from] globset::Error),

    #[error("failed to run test: {0}")]
    TestFailed(String),

    #[error(transparent)]
    IoError(#[from] std::io::Error),

    #[error("failed to write testing script: {0}")]
    FailedToWriteScript(#[from] std::fmt::Error),

    #[error("failed to parse MatchSpec: {0}")]
    MatchSpecParse(String),

    #[error("{0}")]
    TestEnvironmentSetup(miette::Report),

    #[error("failed to setup test environment: {0}")]
    TestEnvironmentActivation(#[from] ActivationError),

    #[error("failed to parse tests from `info/tests/tests.yaml`: {0}")]
    TestYamlParseError(#[from] serde_yaml::Error),

    #[error("failed to parse JSON from test files: {0}")]
    TestJSONParseError(#[from] serde_json::Error),

    #[error("failed to parse MatchSpec from test files: {0}")]
    TestMatchSpecParseError(#[from] rattler_conda_types::ParseMatchSpecError),

    #[error("missing package file name")]
    MissingPackageFileName,

    #[error("archive type not supported")]
    ArchiveTypeNotSupported,

    #[error("could not determine target platform from package file (no index.json?)")]
    CouldNotDetermineTargetPlatform,

    #[error(
        "no tests found in package. Expected `info/test/` (conda-build format) or `info/tests/tests.yaml` (rattler-build format)"
    )]
    NoTestsFound,
}

impl TestError {
    /// Converts this error into a report without rendering nested reports.
    pub fn into_report(self) -> miette::Report {
        match self {
            Self::TestEnvironmentSetup(report) => report,
            error => miette::Report::from_err(error),
        }
    }
}

#[test]
fn test_environment_setup_preserves_diagnostic() {
    let diagnostic = miette::MietteDiagnostic::new("solver failed");
    let report = Err::<(), _>(miette::Report::new(diagnostic))
        .wrap_err("failed to setup test environment")
        .unwrap_err();
    let report = TestError::TestEnvironmentSetup(report).into_report();

    assert!(report.downcast_ref::<miette::MietteDiagnostic>().is_some());
}

/// Create a `.conda` archive from an extracted package directory.
///
/// This allows `rattler-build test` to work with already-extracted package
/// folders by repackaging them into a temporary archive that can be indexed
/// and installed into a test environment.
fn create_conda_from_directory(
    package_dir: &Path,
    output_dir: &Path,
) -> Result<PathBuf, TestError> {
    let index_json = IndexJson::from_package_directory(package_dir)?;
    let identifier = format!(
        "{}-{}-{}",
        index_json.name.as_normalized(),
        index_json.version,
        index_json.build
    );
    let archive_name = format!("{}.conda", identifier);
    let archive_path = output_dir.join(&archive_name);

    // Collect all files in the directory
    let files: Vec<PathBuf> = WalkDir::new(package_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
        .map(|e| e.into_path())
        .collect();

    let mut file = fs::File::create(&archive_path)?;
    write_conda_package(
        &mut file,
        package_dir,
        &files,
        CompressionLevel::default(),
        None,
        &identifier,
        None,
        None,
    )
    .map_err(|e| TestError::TestFailed(format!("failed to create .conda from directory: {}", e)))?;

    Ok(archive_path)
}

#[derive(Debug)]
enum Tests {
    Commands(PathBuf),
    Python(PathBuf),
}

impl Tests {
    async fn run(
        &self,
        environment: &Path,
        cwd: &Path,
        pkg_vars: &HashMap<String, String>,
        resolved_records: &[rattler_conda_types::RepoDataRecord],
        config: &TestConfiguration,
    ) -> Result<(), TestError> {
        tracing::info!("Testing commands:");

        let target_platform = config.target_platform.unwrap_or(Platform::current());
        let build_platform = config.current_platform.platform;
        let host_platform = config
            .host_platform
            .as_ref()
            .map(|p| p.platform)
            .unwrap_or(target_platform);

        let tmp_dir = tempfile::tempdir()?;

        // copy all test files to a temporary directory and set it as the working
        // directory
        CopyDir::new(cwd, tmp_dir.path()).run().map_err(|e| {
            TestError::IoError(std::io::Error::other(format!(
                "Failed to copy test files: {}",
                e
            )))
        })?;

        let mut env_vars = env_vars::os_vars(
            environment,
            &target_platform,
            &host_platform,
            &build_platform,
            config.env_isolation,
            tmp_dir.path(),
            &RuntimeEnv::current(),
        );
        if config.env_isolation == EnvironmentIsolation::None {
            env_vars.retain(|key, _| key != ShellEnum::default().path_var(&build_platform));
        }
        env_vars.extend(env_vars::test_vars(
            target_platform,
            build_platform,
            host_platform,
        ));
        env_vars.extend(env_vars::python_vars_from_records(
            resolved_records,
            environment,
            host_platform,
        ));
        env_vars.extend(pkg_vars.iter().map(|(k, v)| (k.clone(), Some(v.clone()))));
        env_vars.insert(
            "PREFIX".to_string(),
            Some(environment.to_string_lossy().to_string()),
        );

        let context = shared_test_context(environment, host_platform);

        match self {
            Tests::Commands(path) => {
                let script = Script {
                    content: ScriptContent::Path(path.clone()),
                    ..Script::default()
                };

                script
                    .run_script(
                        env_vars,
                        tmp_dir.path(),
                        cwd,
                        context,
                        None::<fn(&str) -> Result<String, String>>,
                        None,
                        config.env_isolation,
                    )
                    .await
                    .map_err(|e| TestError::TestFailed(e.to_string()))?;
            }
            Tests::Python(path) => {
                let script = Script {
                    content: ScriptContent::Path(path.clone()),
                    interpreter: Some("python".into()),
                    ..Script::default()
                };

                script
                    .run_script(
                        env_vars,
                        tmp_dir.path(),
                        cwd,
                        context,
                        None::<fn(&str) -> Result<String, String>>,
                        None,
                        config.env_isolation,
                    )
                    .await
                    .map_err(|e| TestError::TestFailed(e.to_string()))?;
            }
        }
        Ok(())
    }
}

async fn legacy_tests_from_folder(pkg: &Path) -> Result<(PathBuf, Vec<Tests>), std::io::Error> {
    let mut tests = Vec::new();

    let test_folder = pkg.join("info/test");

    if !test_folder.exists() {
        return Ok((test_folder, tests));
    }

    let mut read_dir = tokio::fs::read_dir(&test_folder).await?;

    while let Some(entry) = read_dir.next_entry().await? {
        let path = entry.path();
        if path.is_dir() {
            continue;
        }
        let Some(file_name) = path.file_name() else {
            continue;
        };
        if file_name.eq("run_test.sh") || file_name.eq("run_test.bat") {
            tracing::info!("test {}", file_name.to_string_lossy());
            tests.push(Tests::Commands(path));
        } else if file_name.eq("run_test.py") {
            tracing::info!("test {}", file_name.to_string_lossy());
            tests.push(Tests::Python(path));
        }
    }

    Ok((test_folder, tests))
}

/// The configuration for a test
#[derive(Clone)]
pub struct TestConfiguration {
    /// The test prefix directory (will be created)
    pub test_prefix: PathBuf,
    /// The target platform. If not set it will be discovered from the
    /// index.json metadata.
    pub target_platform: Option<Platform>,
    /// The host platform for run-time dependencies. If not set it will be
    /// discovered from the index.json metadata.
    pub host_platform: Option<PlatformWithVirtualPackages>,
    /// The platform and virtual packages of the current platform.
    pub current_platform: PlatformWithVirtualPackages,
    /// If true, the test prefix will not be deleted after the test is run
    pub keep_test_prefix: bool,
    /// The index of the test to execute. If not set, all tests will be executed.
    pub test_index: Option<usize>,
    /// The channels to use for the test – do not forget to add the local build
    /// outputs channel if desired
    pub channels: Vec<ChannelUrl>,
    /// The channel priority that is used to resolve dependencies
    pub channel_priority: ChannelPriority,
    /// The solve strategy to use when resolving dependencies
    pub solve_strategy: SolveStrategy,
    /// The tool configuration
    pub tool_configuration: tool_configuration::Configuration,
    /// The output directory to create the test prefixes in (will be `output_dir/test`)
    pub output_dir: PathBuf,
    /// Exclude packages newer than this date from the solver
    pub exclude_newer: Option<jiff::Timestamp>,
    /// The environment isolation mode for test scripts
    pub env_isolation: EnvironmentIsolation,
}

fn configured_test_platforms(config: &TestConfiguration) -> (Platform, Platform, Platform) {
    let target_platform = config.target_platform.unwrap_or(Platform::current());
    let build_platform = config.current_platform.platform;
    let host_platform = config
        .host_platform
        .as_ref()
        .map(|platform| platform.platform)
        .unwrap_or(target_platform);
    (target_platform, build_platform, host_platform)
}

/// Tests without a separate build environment execute programs from the test
/// prefix, so their wrapper architecture must match the package host platform.
fn shared_test_context(prefix: &Path, host_platform: Platform) -> ExecutionContext {
    ExecutionContext::shared(RuntimeEnv::current(), prefix, host_platform, host_platform)
}

fn env_vars_from_package(index_json: &IndexJson) -> HashMap<String, String> {
    let mut res = HashMap::new();

    res.insert(
        "PKG_NAME".to_string(),
        index_json.name.as_normalized().to_string(),
    );
    res.insert("PKG_VERSION".to_string(), index_json.version.to_string());
    res.insert("PKG_BUILD_STRING".to_string(), index_json.build.clone());
    res.insert(
        "PKG_BUILDNUM".to_string(),
        index_json.build_number.to_string(),
    );
    res.insert(
        "PKG_BUILD_NUMBER".to_string(),
        index_json.build_number.to_string(),
    );

    res
}

/// Read variant environment variables from `info/hash_input.json` in the
/// package directory.  This file contains the full variant map that was used
/// to build the package.  We expose every key as an environment variable
/// (matching the behaviour of build scripts) except for language-specific
/// keys which are already handled via `python_vars_from_records` and friends.
fn env_vars_from_hash_input(package_folder: &Path) -> HashMap<String, Option<String>> {
    let hash_input_path = package_folder.join("info/hash_input.json");
    let content = fs::read_to_string(&hash_input_path).unwrap_or_default();
    let variant: BTreeMap<NormalizedKey, Variable> =
        serde_json::from_str(&content).unwrap_or_default();
    env_vars::env_vars_from_variant(&variant)
}

/// Run a test for a single package
///
/// This function creates a temporary directory, copies the package file into
/// it, and then runs the indexing. It then creates a test environment that
/// installs the package and any extra dependencies specified in the package
/// test dependencies file.
///
/// With the activated test environment, the packaged test files are run:
///
/// * `info/test/run_test.sh` or `info/test/run_test.bat` on Windows
/// * `info/test/run_test.py`
///
/// These test files are written at "package creation time" and are part of the
/// package.
///
/// # Arguments
///
/// * `package_file` - The path to the package file
/// * `config` - The test configuration
///
/// # Returns
///
/// * `Ok(())` if the test was successful
/// * `Err(TestError::TestFailed)` if the test failed
#[async_recursion::async_recursion]
pub async fn run_test(
    package_file: &Path,
    config: &TestConfiguration,
    downstream_package: Option<PathBuf>,
) -> Result<(), TestError> {
    let tmp_repo = tempfile::tempdir()?;

    // create the test prefix
    fs::create_dir_all(&config.test_prefix)?;

    // If the input is a directory (already-extracted package), create a
    // temporary .conda archive so it can be indexed and installed into the
    // test environment.
    let _temp_archive_dir;
    let package_file = if package_file.is_dir() {
        tracing::info!(
            "Input is a directory, creating temporary .conda archive from '{}'",
            package_file.display()
        );
        _temp_archive_dir = tempfile::tempdir()?;
        create_conda_from_directory(package_file, _temp_archive_dir.path())?
    } else {
        package_file.to_path_buf()
    };
    let package_file = package_file.as_path();

    let target_platform = if let Some(tp) = config.target_platform {
        tp
    } else {
        let index_json: IndexJson =
            rattler_package_streaming::seek::read_package_file(package_file)
                .map_err(|_| TestError::CouldNotDetermineTargetPlatform)?;
        let subdir = index_json
            .subdir
            .ok_or(TestError::CouldNotDetermineTargetPlatform)?;
        Platform::from_str(&subdir).map_err(|_| TestError::CouldNotDetermineTargetPlatform)?
    };

    let subdir = tmp_repo.path().join(target_platform.to_string());
    fs::create_dir_all(&subdir)?;

    fs::copy(
        package_file,
        subdir.join(
            package_file
                .file_name()
                .ok_or(TestError::MissingPackageFileName)?,
        ),
    )?;

    // Also copy the downstream package if it exists
    if let Some(ref downstream_package) = downstream_package {
        fs::copy(
            downstream_package,
            subdir.join(
                downstream_package
                    .file_name()
                    .ok_or(TestError::MissingPackageFileName)?,
            ),
        )?;
    }

    // if there is a downstream package, that's the one we actually want to test
    let package_file = downstream_package.as_deref().unwrap_or(package_file);

    let index_config = IndexFsConfig {
        channel: tmp_repo.path().to_path_buf(),
        target_platform: Some(target_platform),
        repodata_patch: None,
        write_zst: false,
        write_shards: false,
        repodata_revisions: Vec::new(),
        package_revision_assignment: Default::default(),
        force: false,
        max_parallel: num_cpus::get_physical(),
        multi_progress: None,
    };

    // index the temporary channel
    index_fs(index_config)
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    let pkg = CondaArchiveIdentifier::try_from_path(package_file)
        .ok_or_else(|| TestError::TestFailed("could not get archive identifier".to_string()))?;

    // Create a layered package cache for test environments:
    // - Layer 1 (temp dir): writable target for newly downloaded packages
    // - Layer 2 (global cache): read-only fallback for already-cached packages
    // This avoids cache key conflicts with the global package cache while
    // still reusing packages that have already been downloaded (issue #2236).
    let temp_cache_dir = tempfile::tempdir()?;
    let global_cache_dir = rattler_cache::default_cache_dir()
        .map_err(|e| TestError::TestFailed(format!("failed to determine cache directory: {e}")))?
        .join(rattler_cache::PACKAGE_CACHE_DIR);
    let temp_package_cache = PackageCache::new_layered(
        [temp_cache_dir.path().to_path_buf(), global_cache_dir],
        false,
        ValidationMode::default(),
    );

    // Use the package cache to extract the package for reading test metadata.
    // This avoids manual extraction and reuses the cache properly.
    let cache_metadata = temp_package_cache
        .get_or_fetch_from_path(package_file, None, None)
        .await
        .map_err(|e| TestError::TestFailed(format!("failed to cache package: {e}")))?;
    let package_folder = cache_metadata.path().to_path_buf();

    let mut channels = config.channels.clone();
    channels.insert(
        0,
        Channel::try_from_directory(tmp_repo.path())
            .expect("could not create channel from directory")
            .base_url,
    );

    let host_platform = config.host_platform.clone().unwrap_or_else(|| {
        if target_platform == Platform::NoArch {
            config.current_platform.clone()
        } else {
            PlatformWithVirtualPackages {
                platform: target_platform,
                virtual_packages: config.current_platform.virtual_packages.clone(),
            }
        }
    });

    let config = TestConfiguration {
        target_platform: Some(target_platform),
        host_platform: Some(host_platform.clone()),
        channels,
        tool_configuration: tool_configuration::Configuration {
            package_cache: temp_package_cache,
            ..config.tool_configuration.clone()
        },
        ..config.clone()
    };

    tracing::info!("Collecting tests from '{}'", package_folder.display());

    let index_json = IndexJson::from_package_directory(&package_folder)?;
    let mut env = env_vars_from_package(&index_json);
    env.extend(
        env_vars_from_hash_input(&package_folder)
            .into_iter()
            .filter_map(|(k, v)| v.map(|v| (k, v))),
    );

    let has_legacy_tests = package_folder.join("info/test").exists();
    let has_modern_tests = package_folder.join("info/tests/tests.yaml").exists();

    // Run legacy tests (conda-build v0 format: info/test/)
    if has_legacy_tests {
        let prefix =
            TempDir::with_prefix_in(format!("test_{}", pkg.identifier.name), &config.test_prefix)?
                .keep();

        tracing::info!("Creating test environment in '{}'", prefix.display());

        let test_dep_json = PathBuf::from("info/test/test_time_dependencies.json");
        let test_dependencies: Vec<String> = if package_folder.join(&test_dep_json).exists() {
            serde_json::from_str(&fs::read_to_string(package_folder.join(&test_dep_json))?)?
        } else {
            Vec::new()
        };

        let mut dependencies: Vec<MatchSpec> = test_dependencies
            .iter()
            .map(|s| MatchSpec::from_str(s, ParseStrictness::Lenient))
            .collect::<Result<Vec<_>, _>>()?;

        let match_spec = match_spec_for_identifier(&pkg.identifier)?;
        dependencies.push(match_spec);

        let resolved_records = create_environment(
            "test",
            &dependencies,
            &host_platform,
            &prefix,
            &config.channels,
            &config.tool_configuration,
            config.channel_priority,
            config.solve_strategy,
            config.exclude_newer,
        )
        .await
        .wrap_err("failed to setup test environment")
        .map_err(TestError::TestEnvironmentSetup)?;

        // These are the legacy tests
        let (test_folder, tests) = legacy_tests_from_folder(&package_folder).await?;

        for test in tests {
            test.run(&prefix, &test_folder, &env, &resolved_records, &config)
                .await?;
        }

        tracing::info!(
            "{} all tests passed!",
            console::style(console::Emoji("✔", "")).green()
        );

        if prefix.exists() {
            fs::remove_dir_all(prefix)?;
        }
    }

    if has_modern_tests {
        let tests = fs::read_to_string(package_folder.join("info/tests/tests.yaml"))?;
        let tests: Vec<TestType> = serde_yaml::from_str(&tests)?;

        if let Some(test_index) = config.test_index
            && test_index >= tests.len()
        {
            return Err(TestError::TestFailed(format!(
                "Test index {} out of range (0..{})",
                test_index,
                tests.len()
            )));
        }

        let tests = if let Some(test_index) = config.test_index {
            vec![tests[test_index].clone()]
        } else {
            tests
        };

        for test in tests {
            let test_prefix = TempDir::with_prefix_in(
                format!("test_{}", pkg.identifier.name),
                &config.test_prefix,
            )?
            .keep();
            match test {
                TestType::Commands(c) => {
                    run_commands_test(&c, &pkg, &package_folder, &test_prefix, &config, &env)
                        .await?
                }
                TestType::Python { python } => {
                    // A downstream package's Python test matrix describes the Python
                    // versions supported by that package in isolation. When testing it
                    // against a specific upstream artifact, let that artifact's
                    // dependencies select the compatible Python version instead.
                    let apply_python_version = downstream_package.is_none();
                    run_python_test(
                        &python,
                        &pkg,
                        &package_folder,
                        &test_prefix,
                        &config,
                        apply_python_version,
                    )
                    .await?
                }
                TestType::Perl { perl } => {
                    run_perl_test(&perl, &pkg, &package_folder, &test_prefix, &config).await?
                }
                TestType::R { r } => {
                    run_r_test(&r, &pkg, &package_folder, &test_prefix, &config).await?
                }
                TestType::Ruby { ruby } => {
                    run_ruby_test(&ruby, &pkg, &package_folder, &test_prefix, &config).await?
                }
                TestType::Downstream(downstream) if downstream_package.is_none() => {
                    run_downstream_test(&downstream, &pkg, package_file, &test_prefix, &config)
                        .await?
                }
                TestType::Downstream(_) => {
                    tracing::info!(
                        "Skipping downstream test as we are already testing a downstream package"
                    )
                }
                // This test already runs during the build process and we don't need to run it again
                TestType::PackageContents { .. } => {}
            }

            if !config.keep_test_prefix {
                fs::remove_dir_all(test_prefix)?;
            }
        }

        tracing::info!(
            "{} all tests passed!",
            console::style(console::Emoji("✔", "")).green()
        );
    }

    Ok(())
}

/// Execute the Python test
async fn run_python_test(
    python_test: &PythonTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
    apply_python_version: bool,
) -> Result<(), TestError> {
    let pkg_id = format!(
        "{}-{}-{}",
        pkg.identifier.name, pkg.identifier.version, pkg.identifier.build_string
    );
    let span = tracing::info_span!("Running python test(s)", span_color = pkg_id);
    let _guard = span.enter();

    // The version spec of the package being built
    let match_spec = match_spec_for_identifier(&pkg.identifier)?;

    // The dependencies for the test environment
    // - python_version: null -> { "": ["mypackage=xx=xx"]}
    // - python_version: 3.12 -> { "3.12": ["python=3.12", "mypackage=xx=xx"]}
    // - python_version: [3.12, 3.13] -> { "3.12": ["python=3.12", "mypackage=xx=xx"], "3.13": ["python=3.13", "mypackage=xx=xx"]}
    let python_version = if apply_python_version {
        &python_test.python_version
    } else {
        &PythonVersion::None
    };
    let mut dependencies_map: HashMap<String, Vec<MatchSpec>> = match python_version {
        PythonVersion::Multiple(versions) => versions
            .iter()
            .map(|version| {
                (
                    version.clone(),
                    vec![
                        MatchSpec::from_str(
                            &format!("python={}", version),
                            ParseStrictness::Lenient,
                        )
                        .unwrap(),
                        match_spec.clone(),
                    ],
                )
            })
            .collect(),
        PythonVersion::Single(version) => HashMap::from([(
            version.clone(),
            vec![
                MatchSpec::from_str(&format!("python={}", version), ParseStrictness::Lenient)
                    .unwrap(),
                match_spec,
            ],
        )]),
        PythonVersion::None => HashMap::from([("".to_string(), vec![match_spec])]),
    };

    // Add `pip` if pip_check is set to true
    if python_test.pip_check {
        dependencies_map
            .iter_mut()
            .for_each(|(_, v)| v.push("pip".parse().unwrap()));
    }

    // Run tests for each python version
    for (python_version, dependencies) in dependencies_map {
        run_python_test_inner(
            python_test,
            python_version,
            dependencies,
            path,
            prefix,
            config,
        )
        .await?;
    }

    Ok(())
}

async fn run_python_test_inner(
    python_test: &PythonTest,
    python_version: String,
    dependencies: Vec<MatchSpec>,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
) -> Result<(), TestError> {
    let span_message = match python_version.as_str() {
        "" => "Testing with default python version".to_string(),
        _ => format!("Testing with python {}", python_version),
    };
    let span = tracing::info_span!("", message = %span_message);
    let _guard = span.enter();

    let test_prefix = prefix.join("test_env");
    create_environment(
        "test",
        &dependencies,
        config
            .host_platform
            .as_ref()
            .unwrap_or(&config.current_platform),
        &test_prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await
    .wrap_err("failed to setup test environment")
    .map_err(TestError::TestEnvironmentSetup)?;

    let mut imports = String::new();
    for import in &python_test.imports {
        writeln!(imports, "import {}", import)?;
    }

    let script = Script {
        content: ScriptContent::Command(imports),
        interpreter: Some("python".into()),
        ..Script::default()
    };

    let (target_platform, build_platform, host_platform) = configured_test_platforms(config);
    let test_dir = prefix.join("test");
    fs::create_dir_all(&test_dir)?;
    let test_env_vars = env_vars::os_vars(
        &test_prefix,
        &target_platform,
        &host_platform,
        &build_platform,
        config.env_isolation,
        &test_dir,
        &RuntimeEnv::current(),
    );

    let context = shared_test_context(&test_prefix, host_platform);

    script
        .run_script(
            test_env_vars.clone(),
            &test_dir,
            path,
            context.clone(),
            None::<fn(&str) -> Result<String, String>>,
            None,
            config.env_isolation,
        )
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    tracing::info!(
        "{} python imports test passed!",
        console::style(console::Emoji("✔", "")).green()
    );

    if python_test.pip_check {
        let script = Script {
            content: ScriptContent::Command("pip check".into()),
            ..Script::default()
        };
        script
            .run_script(
                test_env_vars,
                path,
                path,
                context,
                None::<fn(&str) -> Result<String, String>>,
                None,
                config.env_isolation,
            )
            .await
            .map_err(|e| TestError::TestFailed(e.to_string()))?;

        tracing::info!(
            "{} pip check passed!",
            console::style(console::Emoji("✔", "")).green()
        );
    }
    Ok(())
}

/// Execute the Perl test
async fn run_perl_test(
    perl_test: &PerlTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
) -> Result<(), TestError> {
    let pkg_id = format!(
        "{}-{}-{}",
        pkg.identifier.name, pkg.identifier.version, pkg.identifier.build_string
    );
    let span = tracing::info_span!("Running perl test", span_color = pkg_id);
    let _guard = span.enter();

    let match_spec = match_spec_for_identifier(&pkg.identifier)?;

    let dependencies = vec!["perl".parse().unwrap(), match_spec];

    let test_prefix = prefix.join("test_env");
    create_environment(
        "test",
        &dependencies,
        config
            .host_platform
            .as_ref()
            .unwrap_or(&config.current_platform),
        &test_prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await
    .wrap_err("failed to setup test environment")
    .map_err(TestError::TestEnvironmentSetup)?;

    let mut imports = String::new();
    tracing::info!("Testing perl imports:\n");

    for module in &perl_test.uses {
        writeln!(imports, "use {};", module)?;
        tracing::info!("  use {};", module);
    }
    tracing::info!("\n");

    let script = Script {
        content: ScriptContent::Command(imports.clone()),
        interpreter: Some("perl".into()),
        ..Script::default()
    };

    let test_folder = prefix.join("test_files");
    fs::create_dir_all(&test_folder)?;

    let (target_platform, build_platform, host_platform) = configured_test_platforms(config);
    let test_env_vars = env_vars::os_vars(
        &test_prefix,
        &target_platform,
        &host_platform,
        &build_platform,
        config.env_isolation,
        &test_folder,
        &RuntimeEnv::current(),
    );
    let context = shared_test_context(&test_prefix, host_platform);

    script
        .run_script(
            test_env_vars,
            &test_folder,
            path,
            context,
            None::<fn(&str) -> Result<String, String>>,
            None,
            config.env_isolation,
        )
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    Ok(())
}

/// Execute the command test
async fn run_commands_test(
    commands_test: &CommandsTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    test_directory: &Path,
    config: &TestConfiguration,
    pkg_vars: &HashMap<String, String>,
) -> Result<(), TestError> {
    let deps = commands_test.requirements.clone();

    let pkg_str = pkg.to_string();
    let span =
        tracing::info_span!("Running script test for", recipe = %pkg_str, span_color = pkg_str);
    let _guard = span.enter();

    let build_prefix = if !deps.build.is_empty() {
        tracing::info!("Installing build dependencies");
        let build_prefix = test_directory.join("test_build_env");
        let build_dependencies: Vec<MatchSpec> = deps
            .build
            .iter()
            .map(|d| d.as_match_spec().clone())
            .collect();

        create_environment(
            "test",
            &build_dependencies,
            &config.current_platform,
            &build_prefix,
            &config.channels,
            &config.tool_configuration,
            config.channel_priority,
            config.solve_strategy,
            config.exclude_newer,
        )
        .await
        .wrap_err("failed to setup test environment")
        .map_err(TestError::TestEnvironmentSetup)?;
        Some(build_prefix)
    } else {
        None
    };

    let mut dependencies: Vec<MatchSpec> =
        deps.run.iter().map(|d| d.as_match_spec().clone()).collect();

    // create environment with the test dependencies
    dependencies.push(match_spec_for_identifier(&pkg.identifier)?);

    let platform = config
        .host_platform
        .as_ref()
        .unwrap_or(&config.current_platform);

    let run_prefix = test_directory.join("test_run_env");
    let resolved_records = create_environment(
        "test",
        &dependencies,
        platform,
        &run_prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await
    .wrap_err("failed to setup test environment")
    .map_err(TestError::TestEnvironmentSetup)?;

    let target_platform = config.target_platform.unwrap_or(Platform::current());
    let build_platform = config.current_platform.platform;
    let host_platform = config
        .host_platform
        .as_ref()
        .map(|p| p.platform)
        .unwrap_or(target_platform);

    // copy all test files to a temporary directory and set it as the working
    // directory
    let test_dir = test_directory.join("test");
    CopyDir::new(path, &test_dir).run().map_err(|e| {
        TestError::IoError(std::io::Error::other(format!(
            "Failed to copy test files: {}",
            e
        )))
    })?;

    let mut env_vars = env_vars::os_vars(
        &run_prefix,
        &target_platform,
        &host_platform,
        &build_platform,
        config.env_isolation,
        &test_dir,
        &RuntimeEnv::current(),
    );
    if config.env_isolation == EnvironmentIsolation::None {
        env_vars.retain(|key, _| key != ShellEnum::default().path_var(&build_platform));
    }
    env_vars.extend(env_vars::test_vars(
        target_platform,
        build_platform,
        host_platform,
    ));
    env_vars.extend(env_vars::python_vars_from_records(
        &resolved_records,
        &run_prefix,
        host_platform,
    ));
    env_vars.extend(pkg_vars.iter().map(|(k, v)| (k.clone(), Some(v.clone()))));
    env_vars.insert(
        "PREFIX".to_string(),
        Some(run_prefix.to_string_lossy().to_string()),
    );

    let context = if let Some(build_prefix) = build_prefix {
        ExecutionContext::separate(
            RuntimeEnv::current(),
            build_prefix,
            build_platform,
            &run_prefix,
            host_platform,
        )
    } else {
        shared_test_context(&run_prefix, host_platform)
    };

    tracing::info!("Testing commands:");
    commands_test
        .script
        .run_script(
            env_vars,
            &test_dir,
            path,
            context,
            None::<fn(&str) -> Result<String, String>>,
            None,
            config.env_isolation,
        )
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    Ok(())
}

/// Execute the downstream test
async fn run_downstream_test(
    downstream_test: &DownstreamTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
) -> Result<(), TestError> {
    let downstream_spec = downstream_test.downstream.clone();
    let pkg_id = format!(
        "{}-{}-{}",
        pkg.identifier.name, pkg.identifier.version, pkg.identifier.build_string
    );

    let span = tracing::info_span!(
        "Running downstream test for",
        package = downstream_spec,
        span_color = pkg_id
    );
    let _guard = span.enter();

    // first try to resolve an environment with the downstream spec and our
    // current package
    let match_specs = [
        MatchSpec::from_str(&downstream_spec, ParseStrictness::Lenient)?,
        match_spec_for_identifier(&pkg.identifier)?,
    ];

    let resolved = create_environment(
        "test",
        &match_specs,
        &config.current_platform,
        prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await;

    match resolved {
        Ok(solution) => {
            let spec_name = match match_specs[0].name.clone().into_exact() {
                Some(name) => name,
                None => {
                    return Err(TestError::TestFailed(
                        "Expected exact package name in matchspec".to_string(),
                    ));
                }
            };
            // we found a solution, so let's run the downstream test with that particular
            // package!
            let downstream_package = solution
                .iter()
                .find(|s| s.package_record.name == spec_name)
                .ok_or_else(|| {
                    TestError::TestFailed(
                        "Could not find package in the resolved environment".to_string(),
                    )
                })?;

            let temp_dir = tempfile::tempdir()?;
            let package_file = temp_dir
                .path()
                .join(downstream_package.identifier.to_file_name());

            if downstream_package.url.scheme() == "file" {
                fs::copy(
                    downstream_package.url.to_file_path().unwrap(),
                    &package_file,
                )?;
            } else {
                let package_dl = reqwest::get(downstream_package.url.clone()).await.unwrap();
                // write out the package to a temporary directory
                let mut file = fs::File::create(&package_file)?;
                let bytes = package_dl.bytes().await.unwrap();
                file.write_all(&bytes)?;
            }

            // run the test with the downstream package
            tracing::info!("Running downstream test with {:?}", &package_file);
            run_test(path, config, Some(package_file.clone()))
                .await
                .inspect_err(|_| {
                    tracing::error!("Downstream test with {:?} failed", &package_file);
                })?;
        }
        Err(e) => {
            // ignore the error
            tracing::warn!(
                "Downstream test could not run. Environment might be unsolvable: {:?}",
                e
            );
        }
    }

    Ok(())
}

/// Execute the R test
async fn run_r_test(
    r_test: &RTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
) -> Result<(), TestError> {
    let pkg_id = format!(
        "{}-{}-{}",
        pkg.identifier.name, pkg.identifier.version, pkg.identifier.build_string
    );
    let span = tracing::info_span!("Running R test", span_color = pkg_id);
    let _guard = span.enter();

    let match_spec = match_spec_for_identifier(&pkg.identifier)?;

    let dependencies = vec!["r-base".parse().unwrap(), match_spec];
    let test_prefix = prefix.join("test_env");
    create_environment(
        "test",
        &dependencies,
        config
            .host_platform
            .as_ref()
            .unwrap_or(&config.current_platform),
        &test_prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await
    .wrap_err("failed to setup test environment")
    .map_err(TestError::TestEnvironmentSetup)?;

    let mut libraries = String::new();
    tracing::info!("Testing R libraries:\n");

    for library in &r_test.libraries {
        writeln!(libraries, "library({})", library)?;
        tracing::info!("  library({})", library);
    }
    tracing::info!("\n");

    let script = Script {
        content: ScriptContent::Command(libraries.clone()),
        interpreter: Some("rscript".into()),
        ..Script::default()
    };

    let test_folder = prefix.join("test_files");
    fs::create_dir_all(&test_folder)?;

    let (target_platform, build_platform, host_platform) = configured_test_platforms(config);
    let test_env_vars = env_vars::os_vars(
        &test_prefix,
        &target_platform,
        &host_platform,
        &build_platform,
        config.env_isolation,
        &test_folder,
        &RuntimeEnv::current(),
    );
    let context = shared_test_context(&test_prefix, host_platform);

    script
        .run_script(
            test_env_vars,
            &test_folder,
            path,
            context,
            None::<fn(&str) -> Result<String, String>>,
            None,
            config.env_isolation,
        )
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    Ok(())
}

/// Execute the Ruby test
async fn run_ruby_test(
    ruby_test: &RubyTest,
    pkg: &CondaArchiveIdentifier,
    path: &Path,
    prefix: &Path,
    config: &TestConfiguration,
) -> Result<(), TestError> {
    let pkg_id = format!(
        "{}-{}-{}",
        pkg.identifier.name, pkg.identifier.version, pkg.identifier.build_string
    );
    let span = tracing::info_span!("Running Ruby test", span_color = pkg_id);
    let _guard = span.enter();

    let match_spec = match_spec_for_identifier(&pkg.identifier)?;

    let dependencies = vec!["ruby".parse().unwrap(), match_spec];

    let test_prefix = prefix.join("test_env");
    create_environment(
        "test",
        &dependencies,
        config
            .host_platform
            .as_ref()
            .unwrap_or(&config.current_platform),
        &test_prefix,
        &config.channels,
        &config.tool_configuration,
        config.channel_priority,
        config.solve_strategy,
        config.exclude_newer,
    )
    .await
    .wrap_err("failed to setup test environment")
    .map_err(TestError::TestEnvironmentSetup)?;

    let mut requires = String::new();
    tracing::info!("Testing Ruby requires:\n");

    for module in &ruby_test.requires {
        writeln!(requires, "require '{}'", module)?;
        tracing::info!("  require '{}'", module);
    }
    tracing::info!("\n");

    let script = Script {
        content: ScriptContent::Command(requires.clone()),
        interpreter: Some("ruby".into()),
        ..Script::default()
    };

    let test_folder = prefix.join("test_files");
    fs::create_dir_all(&test_folder)?;

    let (target_platform, build_platform, host_platform) = configured_test_platforms(config);
    let test_env_vars = env_vars::os_vars(
        &test_prefix,
        &target_platform,
        &host_platform,
        &build_platform,
        config.env_isolation,
        &test_folder,
        &RuntimeEnv::current(),
    );
    let context = shared_test_context(&test_prefix, host_platform);

    script
        .run_script(
            test_env_vars,
            &test_folder,
            path,
            context,
            None::<fn(&str) -> Result<String, String>>,
            None,
            config.env_isolation,
        )
        .await
        .map_err(|e| TestError::TestFailed(e.to_string()))?;

    Ok(())
}

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

    #[test]
    fn shared_test_context_executes_the_host_prefix_platform() {
        let context = shared_test_context(Path::new("test-prefix"), Platform::WinArm64);
        assert_eq!(context.build().path(), Path::new("test-prefix"));
        assert_eq!(context.host().path(), Path::new("test-prefix"));
        assert_eq!(context.build().platform(), Platform::WinArm64);
        assert_eq!(context.host().platform(), Platform::WinArm64);
    }

    /// Verifies that a trailing-underscore version (the openssl ordering
    /// convention, e.g. `3.7_`) is assembled into a `MatchSpec` that matches the
    /// exact version, without degrading into the broken string re-parse result.
    /// Reproduces and guards <https://github.com/prefix-dev/rattler-build/issues/2590>.
    #[test]
    fn match_spec_for_trailing_underscore_version() {
        let identifier = ArchiveIdentifier {
            name: "tmux".to_string(),
            version: "3.7_".to_string(),
            build_string: "hd811a6c_0".to_string(),
        };

        let spec = match_spec_for_identifier(&identifier).expect("should build a MatchSpec");

        // Name matches exactly.
        assert_eq!(
            spec.name,
            PackageNameMatcher::Exact(PackageName::try_from("tmux").unwrap())
        );

        // The version must equal `3.7_` exactly, not be truncated to `3.7`.
        let expected_version = Version::from_str("3.7_").unwrap();
        assert_eq!(
            spec.version,
            Some(VersionSpec::Exact(
                EqualityOperator::Equals,
                expected_version.clone()
            ))
        );

        // The build string must be preserved fully, without swallowing the `_`.
        assert_eq!(
            spec.build,
            Some(StringMatcher::Exact("hd811a6c_0".to_string()))
        );

        // Key regression point: `3.7_` and `3.7` are different versions; the
        // trailing underscore must not be dropped.
        assert_ne!(expected_version, Version::from_str("3.7").unwrap());
    }

    /// Same trailing-underscore bug, but with a letter before the underscore
    /// (e.g. `3.7a_`). The old string round-trip truncates the version to `3.7a`
    /// and swallows `_=` into the build string; the typed path must keep the
    /// full `3.7a_` version and the intact build string.
    /// Reproduces and guards <https://github.com/prefix-dev/rattler-build/issues/2590>.
    #[test]
    fn match_spec_for_trailing_underscore_after_letter_version() {
        let identifier = ArchiveIdentifier {
            name: "tmux".to_string(),
            version: "3.7a_".to_string(),
            build_string: "hd811a6c_0".to_string(),
        };

        let spec = match_spec_for_identifier(&identifier).expect("should build a MatchSpec");

        // The version must equal `3.7a_` exactly, not be truncated to `3.7a`.
        let expected_version = Version::from_str("3.7a_").unwrap();
        assert_eq!(
            spec.version,
            Some(VersionSpec::Exact(
                EqualityOperator::Equals,
                expected_version.clone()
            ))
        );

        // The build string must be preserved fully, without swallowing the `_`.
        assert_eq!(
            spec.build,
            Some(StringMatcher::Exact("hd811a6c_0".to_string()))
        );

        // `3.7a_` and `3.7a` are different versions; the trailing underscore
        // must not be dropped.
        assert_ne!(expected_version, Version::from_str("3.7a").unwrap());
    }

    /// Directly compares the old "string round-trip parse" path against the new
    /// typed-assembly path.
    ///
    /// The old path `MatchSpec::from_str("tmux=3.7_=hd811a6c_0")` used to
    /// tokenize the `_=` incorrectly, yielding `version=3.7` (underscore
    /// dropped) and `build="_=hd811a6c_0"` (the `_=` swallowed), which failed to
    /// match the real artifact `version=3.7_`, `build=hd811a6c_0` and made the
    /// solver report "No candidates were found". `rattler_conda_types` 0.49 fixed
    /// that tokenization, so both paths now agree; we keep asserting on both so a
    /// regression on either side is caught.
    /// See <https://github.com/prefix-dev/rattler-build/issues/2590>.
    #[test]
    fn string_roundtrip_and_typed_path_agree() {
        let (name, version, build) = ("tmux", "3.7_", "hd811a6c_0");

        let legacy = MatchSpec::from_str(
            &format!("{name}={version}={build}"),
            ParseStrictness::Lenient,
        )
        .expect("string round-trip parses");
        assert_eq!(
            legacy.version,
            Some(VersionSpec::Exact(
                EqualityOperator::Equals,
                Version::from_str("3.7_").unwrap()
            )),
            "the trailing underscore must be kept as part of the version"
        );
        assert_eq!(
            legacy.build,
            Some(StringMatcher::Exact(build.to_string())),
            "the build string must not swallow the `_=`"
        );

        // New path: assembles the spec from typed fields, no parsing involved.
        let fixed = match_spec_for_identifier(&ArchiveIdentifier {
            name: name.to_string(),
            version: version.to_string(),
            build_string: build.to_string(),
        })
        .expect("new path builds a MatchSpec");
        assert_eq!(
            fixed.version,
            Some(VersionSpec::Exact(
                EqualityOperator::Equals,
                Version::from_str("3.7_").unwrap()
            ))
        );
        assert_eq!(fixed.build, Some(StringMatcher::Exact(build.to_string())));

        assert_eq!(legacy.version, fixed.version);
        assert_eq!(legacy.build, fixed.build);
    }

    /// A regular version (no trailing underscore) must also assemble correctly,
    /// ensuring the fix introduces no regression.
    #[test]
    fn match_spec_for_regular_version() {
        let identifier = ArchiveIdentifier {
            name: "python".to_string(),
            version: "3.12.1".to_string(),
            build_string: "h1234567_0".to_string(),
        };

        let spec = match_spec_for_identifier(&identifier).expect("should build a MatchSpec");

        assert_eq!(
            spec.version,
            Some(VersionSpec::Exact(
                EqualityOperator::Equals,
                Version::from_str("3.12.1").unwrap()
            ))
        );
        assert_eq!(
            spec.build,
            Some(StringMatcher::Exact("h1234567_0".to_string()))
        );
    }
}
