//! Variant-based recipe rendering
//!
//! This module provides functionality to render recipes with variant configurations,
//! allowing you to compute all build matrix combinations and evaluate recipes with
//! specific variant values.
//!
//! For multi-output recipes, this implements a two-stage rendering process:
//! - Stage 0: Expand recipes by variants from Jinja templates
//! - Stage 1: Add variants from dependencies and compute lazy hashes

use std::collections::{BTreeMap, HashSet};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::path::PathBuf;

use indexmap::IndexMap;
use miette::Diagnostic;
use petgraph::graph::{DiGraph, NodeIndex};
use rattler_build_variant_config::VariantExpandError;
use rattler_build_yaml_parser::ParseError;
use rattler_conda_types::{NoArchType, RepodataRevision};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use rattler_build_jinja::{JinjaConfig, Variable};
use rattler_build_types::NormalizedKey;
use rattler_build_variant_config::VariantConfig;

use crate::stage0::evaluate::ALWAYS_INCLUDED_VARS;
use crate::stage1::{Dependency, HashInfo, build::BuildString};
use crate::{
    stage0::{self, MultiOutputRecipe, Recipe as Stage0Recipe, SingleOutputRecipe},
    stage1::{Evaluate, EvaluationContext, Recipe as Stage1Recipe},
};

/// Result type for [`build_recipe_dependency_graph`]: the graph, node mapping, and sorted item indices.
type RecipeDependencyGraph = (DiGraph<usize, ()>, Vec<NodeIndex>, Vec<usize>);

/// Errors that can occur during recipe rendering with variant configurations.
///
/// This is the primary error type for the rendering layer, covering parse errors,
/// topological sort failures, variant expansion issues, and more.
#[derive(Debug, Error, Diagnostic)]
pub enum RenderError {
    /// A parse error occurred during recipe evaluation
    #[error(transparent)]
    #[diagnostic(transparent)]
    Parse(#[from] ParseError),

    /// A topological sort error occurred (e.g., dependency cycle)
    #[error(transparent)]
    #[diagnostic(transparent)]
    TopologicalSort(#[from] TopologicalSortError),

    /// A variant expansion error occurred
    #[error(transparent)]
    #[diagnostic(transparent)]
    VariantExpand(#[from] VariantExpandError),

    /// A pinned subpackage output was not found among the rendered variants
    #[error("missing output: {package}")]
    MissingOutput {
        /// The name of the missing package
        package: String,
    },

    /// An experimental feature was used without the experimental flag
    #[error("{message}")]
    ExperimentalRequired {
        /// The message describing the experimental feature
        message: String,
    },
}

/// Information about a pin_subpackage dependency for variant tracking
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinSubpackageInfo {
    /// The name of the pinned subpackage
    pub name: rattler_conda_types::PackageName,
    /// The version of the pinned subpackage
    pub version: rattler_conda_types::VersionWithSource,
    /// The build string of the pinned subpackage (if known)
    pub build_string: Option<String>,
    /// Whether this is an exact pin
    pub exact: bool,
}

/// Configuration for rendering recipes with variants
#[derive(Debug, Clone)]
pub struct RenderConfig {
    /// Additional context variables to provide (beyond variant values)
    /// These can be strings, booleans, numbers, etc. using the Variable type
    pub extra_context: IndexMap<String, Variable>,
    /// Whether experimental features are enabled
    pub experimental: bool,
    /// Repodata revision controlling which recipe fields and MatchSpec syntax are accepted.
    pub repodata_revision: RepodataRevision,
    /// Path to the recipe file (for relative path resolution in Jinja functions)
    pub recipe_path: Option<PathBuf>,
    /// Target platform for the build
    pub target_platform: rattler_conda_types::Platform,
    /// Build platform (where the build runs)
    pub build_platform: rattler_conda_types::Platform,
    /// Host platform (for cross-compilation)
    pub host_platform: rattler_conda_types::Platform,
    /// OS environment variable keys that can be overridden by variant configuration.
    /// These are typically derived from `env_vars::os_vars()` and include variables
    /// like `MACOSX_DEPLOYMENT_TARGET` on macOS that have default values but can be
    /// customized via variant config.
    pub os_env_var_keys: HashSet<String>,
    /// An optional prefix to prepend to the auto-generated build string.
    /// When set, the build string becomes `{prefix}_{default_build_string}`.
    pub build_string_prefix: Option<String>,
    /// An optional build number override.
    /// When set, this replaces the build number from the recipe.
    pub build_number_override: Option<u64>,
}

impl Default for RenderConfig {
    fn default() -> Self {
        Self {
            extra_context: IndexMap::new(),
            experimental: false,
            repodata_revision: RepodataRevision::Legacy,
            recipe_path: None,
            target_platform: rattler_conda_types::Platform::current(),
            build_platform: rattler_conda_types::Platform::current(),
            host_platform: rattler_conda_types::Platform::current(),
            os_env_var_keys: HashSet::new(),
            build_string_prefix: None,
            build_number_override: None,
        }
    }
}

impl RenderConfig {
    /// Create a new render configuration
    pub fn new() -> Self {
        Self::default()
    }

    /// Add an extra context variable
    pub fn with_context(mut self, key: impl Into<String>, value: impl Into<Variable>) -> Self {
        self.extra_context.insert(key.into(), value.into());
        self
    }

    /// Enable experimental features
    pub fn with_experimental(mut self, experimental: bool) -> Self {
        self.experimental = experimental;
        self
    }

    /// Set the repodata revision controlling which recipe fields and MatchSpec syntax are accepted.
    pub fn with_repodata_revision(mut self, repodata_revision: RepodataRevision) -> Self {
        self.repodata_revision = repodata_revision;
        self
    }

    /// Set the recipe path for relative path resolution
    pub fn with_recipe_path(mut self, recipe_path: impl Into<PathBuf>) -> Self {
        self.recipe_path = Some(recipe_path.into());
        self
    }

    /// Set the target platform
    ///
    /// This also sets `host_platform` to the same value (mirroring the CLI
    /// behavior). Call [`with_host_platform`](Self::with_host_platform) afterwards
    /// if you need a different host platform.
    pub fn with_target_platform(mut self, platform: rattler_conda_types::Platform) -> Self {
        self.target_platform = platform;
        self.host_platform = platform;
        self
    }

    /// Set the build platform
    pub fn with_build_platform(mut self, platform: rattler_conda_types::Platform) -> Self {
        self.build_platform = platform;
        self
    }

    /// Set the host platform
    pub fn with_host_platform(mut self, platform: rattler_conda_types::Platform) -> Self {
        self.host_platform = platform;
        self
    }

    /// Set the OS environment variable keys that can be overridden by variant config.
    /// These are typically derived from `env_vars::os_vars()` keys.
    pub fn with_os_env_var_keys(mut self, keys: HashSet<String>) -> Self {
        self.os_env_var_keys = keys;
        self
    }

    /// Set a prefix to prepend to the auto-generated build string.
    pub fn with_build_string_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.build_string_prefix = Some(prefix.into());
        self
    }

    /// Override the build number from the recipe.
    pub fn with_build_number_override(mut self, build_number: u64) -> Self {
        self.build_number_override = Some(build_number);
        self
    }
}

/// Result of rendering a recipe with a specific variant combination
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderedVariant {
    /// The variant combination used (variable name -> value)
    /// This is the "used" variant - only variables actually referenced by this output
    pub variant: BTreeMap<NormalizedKey, Variable>,
    /// The full combination that was used to evaluate this output.
    /// This includes ALL variant keys from the combination, not just the ones
    /// this specific output references. This is needed for matching pin_subpackage
    /// dependencies to their correct variants.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub full_combination: BTreeMap<NormalizedKey, Variable>,
    /// Whether experimental features were enabled when this variant was rendered.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub experimental: bool,
    /// The rendered stage1 recipe
    pub recipe: Stage1Recipe,
    /// Pin subpackage dependencies that need to be tracked for exact pinning
    /// Maps package name (normalized) to pin information
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub pin_subpackages: BTreeMap<NormalizedKey, PinSubpackageInfo>,
    /// The hash info that was used to compute the build string.
    pub hash_info: Option<HashInfo>,
}

/// Helper function to extract pin_subpackage information from a recipe.
/// Note: version and build_string are initially placeholders (from the current recipe).
/// They will be updated by `add_pins_to_variant` with the actual pinned package's values.
fn extract_pin_subpackages(recipe: &Stage1Recipe) -> BTreeMap<NormalizedKey, PinSubpackageInfo> {
    recipe
        .requirements
        .exact_pin_subpackages()
        .map(|pin| {
            let key = NormalizedKey::from(pin.pin_subpackage.name.as_normalized());
            // Note: version and build_string are placeholders here.
            // The actual values from the pinned package will be set in add_pins_to_variant.
            let info = PinSubpackageInfo {
                name: pin.pin_subpackage.name.clone(),
                version: recipe.package.version.clone(), // Placeholder, will be updated
                build_string: None, // Will be populated with pinned package's build string
                exact: pin.pin_subpackage.args.exact,
            };
            (key, info)
        })
        .collect()
}

/// Add pin_subpackage information to the variant map for hash computation
///
/// For each exact pin_subpackage dependency, we need to add it to the variant
/// so that the hash includes this information. This ensures that packages with
/// different pinned dependencies get different hashes.
///
/// When there are multiple variants of the pinned package, we match by finding the one
/// whose `full_combination` matches the current variant's `full_combination`. This ensures
/// that e.g. pillow-tests (python=3.11) pins to pillow (python=3.11), not pillow (python=3.10).
///
/// This function also updates `pin_subpackages` with the actual version and build_string
/// from the matched pinned variant.
///
/// Returns an error if a pinned package cannot be found in the rendered outputs.
fn add_pins_to_variant(
    self_name: &rattler_conda_types::PackageName,
    variant: &mut BTreeMap<NormalizedKey, Variable>,
    full_combination: &BTreeMap<NormalizedKey, Variable>,
    pin_subpackages: &mut BTreeMap<NormalizedKey, PinSubpackageInfo>,
    all_rendered: &[RenderedVariant],
) -> Result<(), RenderError> {
    for (pin_name, pin_info) in pin_subpackages.iter_mut() {
        // Ignore ourselves
        if self_name == &pin_info.name {
            continue;
        }

        // Find the rendered variant for this pinned package.
        // When there are multiple variants (e.g., pillow for python 3.10, 3.11, 3.12),
        // we need to find the one whose full_combination matches ours.
        let matching_variants: Vec<_> = all_rendered
            .iter()
            .filter(|v| v.recipe.package.name == pin_info.name)
            .collect();

        let pinned_variant = if matching_variants.len() <= 1 {
            // Only one variant (or none), use it directly
            matching_variants.first().copied()
        } else {
            // Multiple variants - find the one whose full_combination matches ours
            // We look for the variant with the most matching keys from our combination
            matching_variants
                .iter()
                .max_by_key(|v| {
                    // Count how many keys match between our combination and this variant's combination
                    full_combination
                        .iter()
                        .filter(|(key, value)| v.full_combination.get(*key) == Some(*value))
                        .count()
                })
                .copied()
        };

        if let Some(pinned_variant) = pinned_variant {
            // Update pin_info with the actual version and build_string from the pinned package
            pin_info.version = pinned_variant.recipe.package.version.clone();
            pin_info.build_string = pinned_variant
                .recipe
                .build
                .string
                .as_resolved()
                .map(|s| s.to_string());

            // Add the pinned package to the variant with format "version build_string"
            let variant_value = if let Some(build_string) = &pin_info.build_string {
                format!("{} {}", pin_info.version, build_string)
            } else {
                unreachable!("Should not happen when topological ordering succeeded");
            };

            variant.insert(pin_name.clone(), Variable::from(variant_value));
        } else {
            return Err(RenderError::MissingOutput {
                package: pin_info.name.as_normalized().to_string(),
            });
        }
    }
    Ok(())
}

/// Errors that can occur during topological sorting of recipe variants.
#[derive(Debug, Clone, Error, Diagnostic)]
pub enum TopologicalSortError {
    /// A dependency cycle was detected among recipe outputs.
    #[error("Cycle detected in recipe outputs: {packages}")]
    CycleDetected {
        /// The package names involved in the cycle.
        packages: String,
    },
    /// The specified package was not found in the outputs.
    #[error("The package '{package}' was not found in the outputs")]
    PackageNotFound {
        /// The package name that was not found.
        package: String,
    },
}

/// Sort rendered variants topologically based on pin_subpackage dependencies
///
/// This ensures that when building multi-output packages, outputs are built in the
/// correct order - base packages before packages that depend on them via pin_subpackage.
///
/// Returns the variants in topological order, or an error if there's a cycle.
pub fn topological_sort_variants(
    variants: Vec<RenderedVariant>,
) -> Result<Vec<RenderedVariant>, TopologicalSortError> {
    if variants.is_empty() {
        return Ok(variants);
    }

    let recipes: Vec<&Stage1Recipe> = variants.iter().map(|v| &v.recipe).collect();
    let name_to_indices = build_recipe_name_index(&recipes);

    // Validate there are no cycles (uses petgraph toposort internally)
    validate_no_cycles(&recipes, &name_to_indices)?;

    stable_topological_sort(variants, &name_to_indices)
}

/// Sort items with [`Stage1Recipe`]s topologically by their dependencies.
///
/// Takes a list of items, a function to extract the [`Stage1Recipe`] from each item,
/// and an optional `up_to` package name to filter the results.
///
/// When `up_to` is `None`, returns all items in topological order (dependencies first).
/// When `up_to` is `Some(name)`, returns only the items needed to build the named
/// package (its transitive dependencies plus the package itself).
pub fn topological_sort_by_dependencies<T>(
    items: Vec<T>,
    get_recipe: impl Fn(&T) -> &Stage1Recipe,
    up_to: Option<&str>,
) -> Result<Vec<T>, TopologicalSortError> {
    if items.is_empty() {
        return Ok(items);
    }

    let recipes: Vec<&Stage1Recipe> = items.iter().map(&get_recipe).collect();
    let name_to_indices = build_recipe_name_index(&recipes);
    let (graph, idx_to_node, toposorted) =
        build_recipe_dependency_graph(&recipes, &name_to_indices)?;

    let sorted_item_indices: Vec<usize> = if let Some(up_to) = up_to {
        filter_up_to(&graph, &idx_to_node, &name_to_indices, up_to)?
    } else {
        toposorted
    };

    // Reorder by moving items out via Option<T> to avoid Clone bound
    let mut item_map: Vec<Option<T>> = items.into_iter().map(Some).collect();
    let mut result = Vec::with_capacity(sorted_item_indices.len());
    for &idx in &sorted_item_indices {
        result.push(
            item_map[idx]
                .take()
                .expect("each index should be taken exactly once"),
        );
    }

    Ok(result)
}

/// Build a dependency graph from a slice of recipes.
///
/// Returns the graph and a mapping from item index to node index.
fn build_recipe_graph(
    recipes: &[&Stage1Recipe],
    name_to_indices: &BTreeMap<rattler_conda_types::PackageName, Vec<usize>>,
) -> (DiGraph<usize, ()>, Vec<NodeIndex>) {
    let mut graph = DiGraph::<usize, ()>::new();
    let mut idx_to_node: Vec<NodeIndex> = Vec::with_capacity(recipes.len());

    for idx in 0..recipes.len() {
        let node = graph.add_node(idx);
        idx_to_node.push(node);
    }

    for (idx, recipe) in recipes.iter().enumerate() {
        let current_name = &recipe.package.name;

        for dep_name in extract_dependency_names(recipe) {
            if &dep_name == current_name {
                continue;
            }

            if let Some(dep_indices) = name_to_indices.get(&dep_name) {
                for &dep_idx in dep_indices {
                    graph.add_edge(idx_to_node[dep_idx], idx_to_node[idx], ());
                }
            }
        }
    }

    (graph, idx_to_node)
}

/// Perform a stable topological sort on the dependency graph using Kahn's algorithm.
///
/// Uses a BTreeSet so that items with no dependency relationship preserve
/// their original order. Detects cycles and reports a package name involved.
fn stable_toposort(
    graph: &DiGraph<usize, ()>,
    idx_to_node: &[NodeIndex],
    recipes: &[&Stage1Recipe],
) -> Result<Vec<usize>, TopologicalSortError> {
    let n = recipes.len();
    let mut in_degree: Vec<usize> = (0..n)
        .map(|i| {
            graph
                .neighbors_directed(idx_to_node[i], petgraph::Direction::Incoming)
                .count()
        })
        .collect();

    let mut ready: std::collections::BTreeSet<usize> =
        (0..n).filter(|&i| in_degree[i] == 0).collect();

    let mut sorted = Vec::with_capacity(n);
    while let Some(&idx) = ready.iter().next() {
        ready.remove(&idx);
        sorted.push(idx);

        let neighbors: Vec<_> = graph
            .neighbors_directed(idx_to_node[idx], petgraph::Direction::Outgoing)
            .collect();
        for neighbor in neighbors {
            let neighbor_idx = graph[neighbor];
            in_degree[neighbor_idx] -= 1;
            if in_degree[neighbor_idx] == 0 {
                ready.insert(neighbor_idx);
            }
        }
    }

    if sorted.len() != n {
        // All nodes with remaining in-degree are part of (or blocked by) cycles.
        // Collect unique package names involved.
        let cycle_names: Vec<_> = in_degree
            .iter()
            .enumerate()
            .filter(|&(_, d)| *d > 0)
            .map(|(i, _)| recipes[i].package.name.as_normalized().to_string())
            .collect::<std::collections::BTreeSet<_>>()
            .into_iter()
            .collect();
        return Err(TopologicalSortError::CycleDetected {
            packages: cycle_names.join(", "),
        });
    }

    Ok(sorted)
}

/// Build a dependency graph and perform a stable topological sort.
///
/// Returns the graph, node mapping, and sorted item indices.
fn build_recipe_dependency_graph(
    recipes: &[&Stage1Recipe],
    name_to_indices: &BTreeMap<rattler_conda_types::PackageName, Vec<usize>>,
) -> Result<RecipeDependencyGraph, TopologicalSortError> {
    let (graph, idx_to_node) = build_recipe_graph(recipes, name_to_indices);
    let sorted = stable_toposort(&graph, &idx_to_node, recipes)?;
    Ok((graph, idx_to_node, sorted))
}

/// Validate that there are no dependency cycles among the recipes.
fn validate_no_cycles(
    recipes: &[&Stage1Recipe],
    name_to_indices: &BTreeMap<rattler_conda_types::PackageName, Vec<usize>>,
) -> Result<(), TopologicalSortError> {
    let (graph, idx_to_node) = build_recipe_graph(recipes, name_to_indices);
    stable_toposort(&graph, &idx_to_node, recipes)?;
    Ok(())
}

/// Build a mapping from package name to item indices.
fn build_recipe_name_index(
    recipes: &[&Stage1Recipe],
) -> BTreeMap<rattler_conda_types::PackageName, Vec<usize>> {
    let mut name_to_indices: BTreeMap<rattler_conda_types::PackageName, Vec<usize>> =
        BTreeMap::new();
    for (idx, recipe) in recipes.iter().enumerate() {
        name_to_indices
            .entry(recipe.package.name.clone())
            .or_default()
            .push(idx);
    }
    name_to_indices
}

/// Filter the dependency graph to only include items needed to build `up_to` package.
///
/// Performs a DFS post-order traversal from all variants of the target package,
/// returning only the transitive dependencies and the target itself as item indices.
fn filter_up_to(
    graph: &DiGraph<usize, ()>,
    idx_to_node: &[NodeIndex],
    name_to_indices: &BTreeMap<rattler_conda_types::PackageName, Vec<usize>>,
    up_to: &str,
) -> Result<Vec<usize>, TopologicalSortError> {
    use std::str::FromStr;

    let up_to_name = rattler_conda_types::PackageName::from_str(up_to).map_err(|_| {
        TopologicalSortError::PackageNotFound {
            package: up_to.to_string(),
        }
    })?;

    let up_to_indices =
        name_to_indices
            .get(&up_to_name)
            .ok_or_else(|| TopologicalSortError::PackageNotFound {
                package: up_to.to_string(),
            })?;

    // The graph has edges dep -> dependent. We need to find all deps OF the up_to
    // package, so reverse the graph and DFS from the target.
    let reversed = petgraph::visit::Reversed(graph);

    let mut sorted_indices = Vec::new();
    let mut visited = HashSet::new();
    for &up_to_idx in up_to_indices {
        let node = idx_to_node[up_to_idx];
        let mut dfs = petgraph::visit::DfsPostOrder::new(&reversed, node);
        while let Some(nx) = dfs.next(&reversed) {
            if visited.insert(nx) {
                sorted_indices.push(graph[nx]);
            }
        }
    }

    Ok(sorted_indices)
}

/// Perform a stable topological sort that preserves original order when possible.
///
/// This sort uses `full_combination` to match dependencies: when checking if a dependency
/// is satisfied, we only require the variant with the **matching** combination to be added,
/// not ALL variants of that dependency. This preserves the interleaved order:
/// `pillow(3.10), pillow-tests(3.10), pillow(3.11), pillow-tests(3.11), ...`
fn stable_topological_sort(
    variants: Vec<RenderedVariant>,
    name_to_indices: &BTreeMap<rattler_conda_types::PackageName, Vec<usize>>,
) -> Result<Vec<RenderedVariant>, TopologicalSortError> {
    // Use a stable topological sort that preserves original order when possible
    // We iterate through variants in their original order and only skip those
    // that have unsatisfied dependencies
    let mut sorted_variants = Vec::new();
    let mut added = vec![false; variants.len()];
    let mut changed = true;

    while changed && sorted_variants.len() < variants.len() {
        changed = false;
        for idx in 0..variants.len() {
            if added[idx] {
                continue;
            }

            // Check if all dependencies are already added
            let mut can_add = true;
            let current_name = &variants[idx].recipe.package.name;
            let current_combination = &variants[idx].full_combination;

            // Check all dependencies in requirements
            for dep_name in extract_dependency_names(&variants[idx].recipe) {
                // Skip self-dependencies
                if &dep_name == current_name {
                    continue;
                }

                if let Some(dep_indices) = name_to_indices.get(&dep_name) {
                    // Find the matching dependency variant based on full_combination.
                    // We only require the variant with the most matching keys to be added,
                    // not ALL variants of this dependency.
                    let matching_dep_idx = if dep_indices.len() <= 1 {
                        dep_indices.first().copied()
                    } else {
                        dep_indices
                            .iter()
                            .max_by_key(|&&dep_idx| {
                                // Count matching keys between current combination and dep's combination
                                current_combination
                                    .iter()
                                    .filter(|(key, value)| {
                                        variants[dep_idx].full_combination.get(*key) == Some(*value)
                                    })
                                    .count()
                            })
                            .copied()
                    };

                    if let Some(dep_idx) = matching_dep_idx
                        && !added[dep_idx]
                    {
                        can_add = false;
                        break;
                    }
                }
            }

            if can_add {
                sorted_variants.push(variants[idx].clone());
                added[idx] = true;
                changed = true;
            }
        }
    }

    Ok(sorted_variants)
}

/// Helper function to collect used variables from a stage0 recipe
///
/// This collects:
/// - Template variables used in Jinja expressions
/// - Free specs (dependencies without version constraints) that could be variants
/// - Always-included variables (target_platform, etc.)
/// - OS environment variable keys (passed via RenderConfig)
/// - use_keys from build.variant.use_keys (forces keys into the variant matrix)
///
/// Returns only variables that exist in the variant config.
fn collect_used_variables(
    stage0_recipe: &Stage0Recipe,
    variant_config: &VariantConfig,
    os_env_var_keys: &HashSet<String>,
) -> HashSet<NormalizedKey> {
    let mut used_vars = HashSet::new();

    // Get template variables from the recipe
    for var in stage0_recipe.used_variables() {
        used_vars.insert(NormalizedKey::from(var));
    }

    // Get free specs (packages without version constraints) as potential variants
    for spec in stage0_recipe.free_specs() {
        used_vars.insert(NormalizedKey::from(spec.as_normalized()));
    }

    // Insert always-included variables
    for key in ALWAYS_INCLUDED_VARS {
        used_vars.insert(NormalizedKey::from(*key));
    }

    // Insert OS environment variable keys that can be overridden by variant config
    // These come from env_vars::os_vars() and include platform-specific vars like
    // MACOSX_DEPLOYMENT_TARGET
    for key in os_env_var_keys {
        used_vars.insert(NormalizedKey::from(key.as_str()));
    }

    // Add use_keys from build.variant.use_keys
    // These force specific variant keys to be included in the matrix even if
    // not explicitly referenced in templates or dependencies
    for key in stage0_recipe.use_keys() {
        used_vars.insert(NormalizedKey::from(key.as_str()));
    }

    // Filter to only variants that exist in the config
    used_vars
        .into_iter()
        .filter(|v| variant_config.get(v).is_some())
        .collect()
}

/// Evaluate requirements with a variant combination and extract free specs
/// that are also variant keys (not yet in the combination)
fn discover_new_variant_keys_from_evaluation(
    stage0_recipe: &Stage0Recipe,
    combination: &BTreeMap<NormalizedKey, Variable>,
    variant_config: &VariantConfig,
    config: &RenderConfig,
) -> Result<HashSet<NormalizedKey>, ParseError> {
    let context = build_evaluation_context(combination, config)?;

    // Get requirements and evaluate them
    // NOTE: We need to merge the recipe's context variables into the evaluation context
    // before evaluating requirements, so that variables like `name` defined in the
    // context section are available.
    let free_specs: Vec<rattler_conda_types::PackageName> = match stage0_recipe {
        Stage0Recipe::SingleOutput(recipe) => {
            // Merge recipe context variables into evaluation context
            let (context_with_vars, _) = if !recipe.context.is_empty() {
                context.with_context(&recipe.context)?
            } else {
                (context.clone(), IndexMap::new())
            };
            let evaluated = recipe.requirements.evaluate(&context_with_vars)?;
            evaluated.free_specs()
        }
        Stage0Recipe::MultiOutput(recipe) => {
            // Merge recipe context variables into evaluation context
            let (context_with_vars, _) = if !recipe.context.is_empty() {
                context.with_context(&recipe.context)?
            } else {
                (context.clone(), IndexMap::new())
            };

            // Evaluate top-level skip conditions once (inherited by all outputs)
            let toplevel_skipped =
                crate::stage0::evaluate::evaluate_skip_list(&recipe.build.skip, &context_with_vars)
                    .unwrap_or_default();

            let mut all_free_specs = Vec::new();
            for output in &recipe.outputs {
                // For package outputs, check if the output should be skipped before
                // evaluating requirements. This prevents errors from platform-specific
                // functions like stdlib('c') when the output is skipped for that platform.
                let (reqs, should_skip) = match output {
                    stage0::Output::Staging(staging) => {
                        // Staging outputs don't have skip conditions
                        (&staging.requirements, toplevel_skipped)
                    }
                    stage0::Output::Package(pkg) => {
                        // Evaluate skip conditions: combine top-level and output skip (OR logic)
                        let is_skipped = toplevel_skipped
                            || crate::stage0::evaluate::evaluate_skip_list(
                                &pkg.build.skip,
                                &context_with_vars,
                            )
                            .unwrap_or_default();
                        (&pkg.requirements, is_skipped)
                    }
                };

                // Only evaluate requirements for non-skipped outputs
                if !should_skip && let Ok(evaluated) = reqs.evaluate(&context_with_vars) {
                    all_free_specs.extend(evaluated.free_specs());
                }
            }
            all_free_specs
        }
    };

    // Find free specs that are variant keys but not in current combination
    let mut new_keys = HashSet::new();
    for spec in free_specs {
        let key = NormalizedKey::from(spec.as_normalized());
        if variant_config.get(&key).is_some() && !combination.contains_key(&key) {
            new_keys.insert(key);
        }
    }

    Ok(new_keys)
}

/// Expand a variant combination with new keys, creating all combinations
fn expand_combination_with_keys(
    base: &BTreeMap<NormalizedKey, Variable>,
    new_keys: &HashSet<NormalizedKey>,
    variant_config: &VariantConfig,
) -> Vec<BTreeMap<NormalizedKey, Variable>> {
    if new_keys.is_empty() {
        return vec![base.clone()];
    }

    // Get values for each new key
    let key_values: Vec<(NormalizedKey, Vec<Variable>)> = new_keys
        .iter()
        .filter_map(|key| {
            variant_config
                .get(key)
                .map(|values| (key.clone(), values.clone()))
        })
        .collect();

    if key_values.is_empty() {
        return vec![base.clone()];
    }

    // Create cross-product of all new key values
    let mut results = vec![base.clone()];
    for (key, values) in key_values {
        let mut new_results = Vec::new();
        for combo in &results {
            for value in &values {
                let mut new_combo = combo.clone();
                new_combo.insert(key.clone(), value.clone());
                new_results.push(new_combo);
            }
        }
        results = new_results;
    }

    results
}

/// Recursively expand variant combinations by discovering new variant keys from evaluation
/// This implements a tree-based approach where each combination can spawn new combinations
/// if its evaluated free specs reveal new variant keys
fn expand_variants_tree(
    stage0_recipe: &Stage0Recipe,
    initial_combinations: Vec<BTreeMap<NormalizedKey, Variable>>,
    variant_config: &VariantConfig,
    config: &RenderConfig,
) -> Result<Vec<BTreeMap<NormalizedKey, Variable>>, ParseError> {
    let mut final_combinations = Vec::new();
    let mut to_process = initial_combinations;

    // Limit iterations to prevent infinite loops
    const MAX_ITERATIONS: usize = 10;
    let mut iteration = 0;

    while !to_process.is_empty() && iteration < MAX_ITERATIONS {
        iteration += 1;
        let mut next_round = Vec::new();

        for combination in to_process {
            // Discover new variant keys from evaluation
            let new_keys = discover_new_variant_keys_from_evaluation(
                stage0_recipe,
                &combination,
                variant_config,
                config,
            )?;

            if new_keys.is_empty() {
                // No new keys - this combination is final
                final_combinations.push(combination);
            } else {
                // Expand with new keys and process in next round
                let expanded =
                    expand_combination_with_keys(&combination, &new_keys, variant_config);
                next_round.extend(expanded);
            }
        }

        to_process = next_round;
    }

    // Add any remaining combinations (in case we hit iteration limit)
    final_combinations.extend(to_process);

    // Deduplicate combinations
    let mut seen = HashSet::new();
    final_combinations.retain(|combo| {
        let key: Vec<_> = combo
            .iter()
            .map(|(k, v)| (k.clone(), v.to_string()))
            .collect();
        seen.insert(key)
    });

    Ok(final_combinations)
}

/// Helper function to build an evaluation context from variant values and config
fn build_evaluation_context(
    variant: &BTreeMap<NormalizedKey, Variable>,
    config: &RenderConfig,
) -> Result<EvaluationContext, ParseError> {
    // Build context map from variant values and extra context
    // Merge variant into the variables map for template rendering
    let mut context_map = config.extra_context.clone();
    for (key, value) in variant {
        context_map.insert(key.normalize(), value.clone());
    }

    // Create JinjaConfig with the variant properly populated
    let jinja_config = create_jinja_config(config, variant);

    // Create evaluation context with variables, config, and OS env var keys
    // NOTE: Do NOT call with_context() here - that will be done by Stage0Recipe::evaluate()
    // Calling it here would cause double-evaluation of context templates, leading to bugs
    // where e.g., `mpi: ${{ mpi ~ "foobar" }}` would evaluate to "blafoobarfoobar" instead of "blafoobar"
    Ok(
        EvaluationContext::with_variables_config_os_env_keys_and_repodata_revision(
            context_map,
            jinja_config,
            config.os_env_var_keys.clone(),
            config.repodata_revision,
        ),
    )
}

/// Helper function to evaluate a recipe (handles both single and multi-output)
fn evaluate_recipe(
    stage0_recipe: &Stage0Recipe,
    context: &EvaluationContext,
) -> Result<Vec<Stage1Recipe>, ParseError> {
    match stage0_recipe {
        Stage0Recipe::SingleOutput(recipe) => Ok(vec![recipe.evaluate(context)?]),
        Stage0Recipe::MultiOutput(recipe) => recipe.evaluate(context),
    }
}

/// Helper function to handle the empty combinations case
fn render_with_empty_combinations(
    stage0_recipe: &Stage0Recipe,
    config: &RenderConfig,
) -> Result<Vec<RenderedVariant>, RenderError> {
    // Create context with just extra_context (no variant for empty combinations)
    // NOTE: Do NOT call with_context() here - Stage0Recipe::evaluate() handles context evaluation
    let empty_variant = BTreeMap::new();
    let jinja_config = create_jinja_config(config, &empty_variant);
    let context =
        EvaluationContext::with_variables_and_config(config.extra_context.clone(), jinja_config)
            .with_repodata_revision(config.repodata_revision);

    // Evaluate the recipe
    let outputs = evaluate_recipe(stage0_recipe, &context)?;

    // Convert each output to a RenderedVariant
    let results: Vec<_> = outputs
        .into_iter()
        .map(|recipe| {
            let mut variant = recipe.used_variant.clone();

            // Filter out ignore_keys from the variant
            let ignore_keys: HashSet<NormalizedKey> = recipe
                .build
                .variant
                .ignore_keys
                .iter()
                .map(|k| k.as_str().into())
                .collect();
            variant.retain(|key, _| !ignore_keys.contains(key));

            // For noarch packages, override target_platform
            if recipe.build.noarch.is_some() {
                variant.insert("target_platform".into(), "noarch".into());
            }

            RenderedVariant {
                variant,
                full_combination: BTreeMap::new(), // No combination when rendering with empty combinations
                experimental: config.experimental,
                recipe,
                pin_subpackages: BTreeMap::new(),
                hash_info: None,
            }
        })
        .collect();

    // Sort variants topologically by pin_subpackage dependencies
    // This ensures we resolve build strings in the correct order
    let mut results = topological_sort_variants(results)?;

    // Resolve build strings in topological order
    // For each variant, we:
    // 1. Extract its pin_subpackages (which may reference already-resolved variants)
    // 2. Add those pins to its variant (and update pin_subpackages with actual version/build_string)
    // 3. Compute its hash and finalize its build string
    // This ensures that when variant B pins variant A, A's build string is already finalized
    for i in 0..results.len() {
        // Extract pin_subpackages for this variant
        let mut pin_subpackages = extract_pin_subpackages(&results[i].recipe);

        // Add pin information to this variant's variant map
        if !pin_subpackages.is_empty() {
            let results_snapshot = results.clone();
            add_pins_to_variant(
                results_snapshot[i].recipe.package.name(),
                &mut results[i].variant,
                &results_snapshot[i].full_combination,
                &mut pin_subpackages,
                &results_snapshot,
            )?;
        }

        // Store the updated pin_subpackages
        results[i].pin_subpackages = pin_subpackages;

        // Finalize build string with complete pin information
        finalize_build_string_single(&mut results[i], config)?;
    }

    Ok(results)
}

/// Helper function to finalize a single build string
///
/// This computes the hash from the variant (which includes pin information)
/// and resolves the build string for one variant.
fn finalize_build_string_single(
    result: &mut RenderedVariant,
    config: &RenderConfig,
) -> Result<(), RenderError> {
    let build_string_prefix = config.build_string_prefix.as_deref();
    let noarch = result.recipe.build.noarch.unwrap_or(NoArchType::none());

    // Compute hash from the variant (which now includes pin_subpackage information)
    let mut hash_info = HashInfo::from_variant(&result.variant, &noarch);

    // Prepend the user-provided build string prefix to the hash prefix so it
    // becomes part of the default build string format: {prefix}h{hash}_{number}.
    if let Some(prefix) = build_string_prefix {
        if hash_info.prefix.is_empty() {
            hash_info.prefix = format!("{prefix}_");
        } else {
            hash_info.prefix = format!("{prefix}_{}", hash_info.prefix);
        }
    }

    // If build string is not set (Default), or if it needs resolving
    if matches!(
        result.recipe.build.string,
        BuildString::Default | BuildString::Unresolved(_, _)
    ) {
        // Always resolve/re-resolve the build string with the current hash
        // This ensures we use the latest hash that includes all pin information
        // Create a temporary evaluation context for build string resolution
        // Merge both variant variables and recipe context variables
        let mut variables = IndexMap::new();

        // First add variant variables (base values from variant config)
        for (k, v) in &result.variant {
            variables.insert(k.0.as_str().to_string(), v.clone());
        }

        // Then add recipe context variables (which may transform/override variant values)
        // Context takes precedence because it's the user's way of transforming variant values
        for (k, v) in &result.recipe.context {
            variables.insert(k.clone(), v.clone());
        }

        let eval_ctx = EvaluationContext::from_variables(variables);

        // Apply the build number override if provided.
        if let Some(bn) = config.build_number_override {
            result.recipe.build.number = Some(bn);
        }

        // Resolve the build string template with the hash
        result.recipe.build.string.resolve(
            &hash_info,
            result.recipe.build.number.unwrap_or(0),
            &eval_ctx,
        )?;
        result.hash_info = Some(hash_info);
    }
    Ok(())
}

/// Extract the package names a recipe must be built after.
///
/// Collects build/host deps, pin_subpackage refs from run/run_exports, and
/// *exact* pin_subpackage refs from run_constraints (non-exact constraints only
/// pin a version, so they impose no build-order edge; see issue #2531).
///
/// May contain duplicates, which is fine for graph construction.
fn extract_dependency_names(recipe: &Stage1Recipe) -> Vec<rattler_conda_types::PackageName> {
    let requirements = recipe.requirements();

    // Collect names from build/host dependencies (needed at build time)
    let build_host = requirements
        .build_host()
        .filter_map(|dep| dep.name().cloned());

    // pin_subpackage refs in run/run_exports must be built first to resolve the
    // pin. (Plain run deps aren't needed at build time and may form cycles.)
    let run_pin_subpackages = requirements
        .run
        .iter()
        .chain(requirements.run_exports.iter())
        .filter_map(|dep| match dep {
            Dependency::PinSubpackage(pin) => Some(pin.pin_subpackage.name.clone()),
            _ => None,
        });

    // Only *exact* run_constraints pins create an edge: they need the
    // constrained package's build string. Non-exact constraints only pin a
    // version (known from the recipe), so edging them produces false cycles
    // (e.g. openblas runs on libopenblas, libopenblas run-constrains openblas).
    // See https://github.com/prefix-dev/rattler-build/issues/2531.
    let run_constraint_pin_subpackages =
        requirements
            .run_constraints
            .iter()
            .filter_map(|dep| match dep {
                Dependency::PinSubpackage(pin) if pin.pin_subpackage.args.exact => {
                    Some(pin.pin_subpackage.name.clone())
                }
                _ => None,
            });

    build_host
        .chain(run_pin_subpackages)
        .chain(run_constraint_pin_subpackages)
        .collect()
}

/// Helper function to create a JinjaConfig from RenderConfig
fn create_jinja_config(
    config: &RenderConfig,
    variant: &BTreeMap<NormalizedKey, Variable>,
) -> JinjaConfig {
    // Check if the variant specifies a target_platform - if so, use it
    // This allows rendering recipes for platforms different from the current platform
    // (e.g., rendering a Windows variant on Linux to check if outputs would be skipped)
    let target_platform = variant
        .get(&"target_platform".into())
        .and_then(|v| v.to_string().parse::<rattler_conda_types::Platform>().ok())
        .unwrap_or(config.target_platform);

    // Similarly for host_platform (defaults to target_platform if not specified)
    let host_platform = variant
        .get(&"host_platform".into())
        .and_then(|v| v.to_string().parse::<rattler_conda_types::Platform>().ok())
        .unwrap_or_else(|| {
            // If host_platform not in variant, use config.host_platform if it differs from default,
            // otherwise use the (potentially variant-derived) target_platform
            if config.host_platform != config.target_platform {
                config.host_platform
            } else {
                target_platform
            }
        });

    JinjaConfig {
        experimental: config.experimental,
        recipe_path: config.recipe_path.clone(),
        target_platform,
        build_platform: config.build_platform,
        host_platform,
        variant: variant.clone(),
        ..Default::default()
    }
}

/// Render a recipe with variant configuration files
///
/// This function:
/// 1. Loads variant configuration from one or more YAML files
/// 2. Determines which variables are used in the recipe
/// 3. Computes all possible variant combinations
/// 4. Evaluates the recipe for each combination
///
/// # Arguments
///
/// * `recipe_path` - Path to the recipe YAML file
/// * `variant_files` - Paths to variant configuration files (e.g., `variants.yaml`)
/// * `config` - Optional render configuration with extra context
///
/// # Returns
///
/// A vector of `RenderedVariant`, one for each variant combination
///
/// # Example
///
/// ```rust,no_run
/// use rattler_build_recipe::variant_render::{render_recipe_with_variants, RenderConfig};
/// use std::path::Path;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let recipe_path = Path::new("recipe.yaml");
/// let variant_files = vec![Path::new("variants.yaml")];
/// let config = RenderConfig::new()
///     .with_context("unix", "true");
///
/// let rendered = render_recipe_with_variants(recipe_path, &variant_files, Some(config))?;
///
/// for variant in rendered {
///     println!("Variant: {:?}", variant.variant);
///     println!("Package: {} {}",
///         variant.recipe.package().name().as_normalized(),
///         variant.recipe.package().version()
///     );
/// }
/// # Ok(())
/// # }
/// ```
#[cfg(not(target_arch = "wasm32"))]
pub fn render_recipe_with_variants(
    recipe_path: &Path,
    variant_files: &[impl AsRef<Path>],
    config: Option<RenderConfig>,
) -> Result<Vec<RenderedVariant>, RenderError> {
    let mut config = config.unwrap_or_default();

    // Set the recipe path if not already set
    if config.recipe_path.is_none() {
        config.recipe_path = Some(recipe_path.to_path_buf());
    }

    // Read and parse the recipe
    let yaml_content = fs_err::read_to_string(recipe_path)
        .map_err(|e| ParseError::io_error(recipe_path.to_path_buf(), e))?;

    let stage0_recipe = stage0::parse_recipe_or_multi_from_source_with_config(
        &yaml_content,
        stage0::ParseConfig {
            repodata_revision: config.repodata_revision,
        },
    )?;

    // Load variant configuration
    let variant_config =
        VariantConfig::from_files(variant_files, config.target_platform).map_err(|e| match e {
            rattler_build_variant_config::VariantConfigError::ParseError { source, .. } => {
                RenderError::Parse(source)
            }
            rattler_build_variant_config::VariantConfigError::IoError(path, source) => {
                RenderError::Parse(ParseError::io_error(path, source))
            }
            other => RenderError::Parse(ParseError::generic(
                other.to_string(),
                marked_yaml::Span::new_blank(),
            )),
        })?;

    render_recipe_with_variant_config(&stage0_recipe, &variant_config, config)
}

/// Check if any source in a conditional list uses attestation verification.
fn source_list_has_attestation(
    sources: &rattler_build_yaml_parser::ConditionalList<stage0::Source>,
) -> bool {
    sources.iter().any(|item| match item {
        rattler_build_yaml_parser::Item::Value(v) => match v.as_concrete() {
            Some(stage0::Source::Url(url_src)) => url_src.attestation.is_some(),
            _ => false,
        },
        rattler_build_yaml_parser::Item::Conditional(cond) => {
            // Check both then and else branches
            cond.then.iter().any(|item| {
                matches!(
                    item,
                    rattler_build_yaml_parser::Item::Value(v)
                        if matches!(v.as_concrete(), Some(stage0::Source::Url(u)) if u.attestation.is_some())
                )
            })
        }
    })
}

/// Check if a recipe contains any source with attestation config.
fn recipe_has_attestation(recipe: &Stage0Recipe) -> bool {
    match recipe {
        Stage0Recipe::SingleOutput(r) => source_list_has_attestation(&r.source),
        Stage0Recipe::MultiOutput(r) => {
            source_list_has_attestation(&r.source)
                || r.outputs.iter().any(|output| match output {
                    stage0::Output::Staging(s) => source_list_has_attestation(&s.source),
                    stage0::Output::Package(p) => source_list_has_attestation(&p.source),
                })
        }
    }
}

/// Render a stage0 recipe with a loaded variant configuration
///
/// This is a lower-level function that works with already-loaded recipe and variant config.
///
/// # Arguments
///
/// * `stage0_recipe` - The parsed stage0 recipe
/// * `variant_config` - The loaded variant configuration
/// * `config` - Render configuration with extra context
///
/// # Returns
///
/// A vector of `RenderedVariant`, one for each variant combination
pub fn render_recipe_with_variant_config(
    stage0_recipe: &Stage0Recipe,
    variant_config: &VariantConfig,
    config: RenderConfig,
) -> Result<Vec<RenderedVariant>, RenderError> {
    // Check if any source has attestation config - requires experimental flag
    if !config.experimental && recipe_has_attestation(stage0_recipe) {
        return Err(RenderError::ExperimentalRequired {
            message: "source attestation verification is an experimental feature: provide the `--experimental` flag to enable this feature".to_string(),
        });
    }

    // Ensure target_platform and build_platform are in the variant config so they
    // are included in hash computation. Without this, all platforms would produce
    // identical hashes because `collect_used_variables` filters out variables that
    // aren't present in the variant config.
    let mut variant_config = variant_config.clone();
    variant_config
        .variants
        .entry("target_platform".into())
        .or_insert_with(|| vec![Variable::from(config.target_platform.to_string())]);
    variant_config
        .variants
        .entry("build_platform".into())
        .or_insert_with(|| vec![Variable::from(config.build_platform.to_string())]);

    match stage0_recipe {
        Stage0Recipe::SingleOutput(recipe) => {
            render_single_output_with_variants(recipe.as_ref(), &variant_config, config)
        }
        Stage0Recipe::MultiOutput(recipe) => {
            render_multi_output_with_variants(recipe.as_ref(), &variant_config, config)
        }
    }
}

fn render_single_output_with_variants(
    stage0_recipe: &SingleOutputRecipe,
    variant_config: &VariantConfig,
    config: RenderConfig,
) -> Result<Vec<RenderedVariant>, RenderError> {
    let stage0 = Stage0Recipe::SingleOutput(Box::new(stage0_recipe.clone()));
    render_with_variants(&stage0, variant_config, config)
}

fn render_multi_output_with_variants(
    stage0_recipe: &MultiOutputRecipe,
    variant_config: &VariantConfig,
    config: RenderConfig,
) -> Result<Vec<RenderedVariant>, RenderError> {
    let stage0 = Stage0Recipe::MultiOutput(Box::new(stage0_recipe.clone()));
    render_with_variants(&stage0, variant_config, config)
}

/// Internal unified function to render both single and multi-output recipes
fn render_with_variants(
    stage0_recipe: &Stage0Recipe,
    variant_config: &VariantConfig,
    config: RenderConfig,
) -> Result<Vec<RenderedVariant>, RenderError> {
    // Collect initially used variables (from templates and stage0 free specs)
    let used_vars = collect_used_variables(stage0_recipe, variant_config, &config.os_env_var_keys);

    // Compute initial variant combinations
    let initial_combinations = variant_config.combinations(&used_vars)?;

    // If no combinations, render once with just the extra context
    if initial_combinations.is_empty() {
        return render_with_empty_combinations(stage0_recipe, &config);
    }

    // Expand combinations tree-style: evaluate each combination, discover new variant keys
    // from free specs, and expand combinations if new keys are found
    let combinations =
        expand_variants_tree(stage0_recipe, initial_combinations, variant_config, &config)?;

    // Render recipe for each final variant combination
    let mut results = Vec::with_capacity(combinations.len());

    for combination in combinations {
        let context = build_evaluation_context(&combination, &config)?;
        let outputs = evaluate_recipe(stage0_recipe, &context)?;

        // Convert each output to a RenderedVariant
        for recipe in outputs {
            let mut variant = recipe.used_variant.clone();

            // Add use_keys to the variant (forces them to be included even if not referenced)
            // We need to get them from the combination since they were used to compute it
            let use_keys: HashSet<NormalizedKey> = recipe
                .build
                .variant
                .use_keys
                .iter()
                .map(|k| k.as_str().into())
                .collect();
            for key in &use_keys {
                if let Some(value) = combination.get(key) {
                    variant.insert(key.clone(), value.clone());
                }
            }

            // Filter out ignore_keys from the variant
            let ignore_keys: HashSet<NormalizedKey> = recipe
                .build
                .variant
                .ignore_keys
                .iter()
                .map(|k| k.as_str().into())
                .collect();
            variant.retain(|key, _| !ignore_keys.contains(key));

            results.push(RenderedVariant {
                variant,
                full_combination: combination.clone(), // Track the full combination for pin matching
                experimental: config.experimental,
                recipe,
                pin_subpackages: BTreeMap::new(), // Will be populated after first build string resolution
                hash_info: None,
            });
        }
    }

    // Sort variants topologically by pin_subpackage dependencies
    let mut results = topological_sort_variants(results)?;

    // Resolve build strings in topological order
    for i in 0..results.len() {
        // Extract pin_subpackages for this variant
        let mut pin_subpackages = extract_pin_subpackages(&results[i].recipe);

        // Add pin information to variant if needed (also updates pin_subpackages with actual version/build_string)
        if !pin_subpackages.is_empty() {
            let results_snapshot = results.clone();
            add_pins_to_variant(
                results_snapshot[i].recipe.package().name(),
                &mut results[i].variant,
                &results_snapshot[i].full_combination,
                &mut pin_subpackages,
                &results_snapshot,
            )?;
        }

        // Store the updated pin_subpackages
        results[i].pin_subpackages = pin_subpackages;

        // Finalize build string with complete pin information
        finalize_build_string_single(&mut results[i], &config)?;
    }

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stage1::requirements::Dependency;

    #[test]
    fn test_render_with_simple_variants() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  build:
    - ${{ compiler('c') }}
  host:
    - python ${{ python }}
  run:
    - python
"#;

        let variant_yaml = r#"
python:
  - "3.9.*"
  - "3.10.*"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap_or_else(|e| panic!("Failed to render: {:?}", e));

        assert_eq!(rendered.len(), 2);

        // Check that we have both variants (order may vary)
        let variants: Vec<String> = rendered
            .iter()
            .map(|r| r.variant.get(&"python".into()).unwrap().to_string())
            .collect();

        assert!(variants.contains(&"3.9.*".to_string()));
        assert!(variants.contains(&"3.10.*".to_string()));
    }

    #[test]
    fn test_build_steps_if_participates_in_variant_matrix() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

build:
  steps:
    - if: feature_enabled
      run: echo enabled
"#;

        let variant_yaml = r#"
feature_enabled:
  - true
  - false
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_experimental(true),
        )
        .unwrap();

        assert_eq!(rendered.len(), 2);
        let variants = rendered
            .iter()
            .map(|r| {
                r.variant
                    .get(&"feature_enabled".into())
                    .unwrap()
                    .to_string()
            })
            .collect::<Vec<_>>();
        assert!(variants.contains(&"true".to_string()), "{variants:?}");
        assert!(variants.contains(&"false".to_string()), "{variants:?}");
        assert!(
            rendered
                .iter()
                .all(|r| r.recipe.build.plan.steps().is_some())
        );
    }

    #[test]
    fn test_render_with_free_specs() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  build:
    - python
"#;

        let variant_yaml = r#"
python:
  - "3.9.*"
  - "3.10.*"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Should create variants based on free spec "python"
        assert_eq!(rendered.len(), 2);
    }

    #[test]
    fn test_render_v3_matchspec_requires_render_config() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  run:
    - 'foo[extras=[bar]]'
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source_with_config(
            recipe_yaml,
            stage0::ParseConfig {
                repodata_revision: RepodataRevision::V3,
            },
        )
        .unwrap();
        let variant_config = VariantConfig::default();

        let err =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap_err();
        assert!(err.to_string().contains("--v3"));

        let rendered = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_repodata_revision(RepodataRevision::V3),
        )
        .unwrap();
        assert_eq!(rendered.len(), 1);
        assert_eq!(
            rendered[0].recipe.requirements.run[0].to_string(),
            "foo[extras=[bar]]"
        );
    }

    #[test]
    fn test_render_v3_recipe_fields_require_render_config() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

build:
  flags:
    - cuda

requirements:
  extras:
    dev:
      - pytest
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        let err =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap_err();
        assert!(err.to_string().contains("--v3"));

        let rendered = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_repodata_revision(RepodataRevision::V3),
        )
        .unwrap();
        assert_eq!(rendered.len(), 1);
        assert_eq!(rendered[0].recipe.build.flags[0].as_str(), "cuda");
        assert_eq!(
            rendered[0].recipe.requirements.extras["dev"][0].to_string(),
            "pytest"
        );
    }

    #[test]
    fn test_render_multi_output_simple() {
        let recipe_yaml = r#"
schema_version: 1

recipe:
  name: multi-pkg
  version: "1.0.0"

context:
  name: multi-pkg
  version: "1.0.0"

outputs:
  - package:
      name: ${{ name }}-lib
      version: ${{ version }}
    build:
      noarch: generic

  - package:
      name: ${{ name }}
      version: ${{ version }}
    build:
      noarch: generic
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Should have 2 outputs
        assert_eq!(rendered.len(), 2);

        // Check package names
        let names: Vec<String> = rendered
            .iter()
            .map(|r| r.recipe.package.name.as_normalized().to_string())
            .collect();

        assert!(names.contains(&"multi-pkg-lib".to_string()));
        assert!(names.contains(&"multi-pkg".to_string()));
    }

    #[test]
    fn test_cache_inherited_output_inherits_top_level_build_number_before_build_string() {
        let recipe_yaml = r#"
schema_version: 1

recipe:
  name: multi-pkg
  version: "1.0.0"

build:
  number: 1

outputs:
  - staging:
      name: build-cache
    build:
      script: build

  - package:
      name: runtime
    inherit: build-cache
    build:
      script: install
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str("{}").unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        assert_eq!(rendered.len(), 1);

        let output = &rendered[0].recipe;
        assert_eq!(output.package.name.as_normalized(), "runtime");
        assert_eq!(output.build.number, Some(1));

        let build_string = output.build.string.as_resolved().unwrap();
        assert!(
            build_string.ends_with("_1"),
            "cache-inherited output should resolve its build string with the inherited build number, got {build_string}"
        );
    }

    /// Regression test for https://github.com/prefix-dev/rattler-build/issues/2497
    ///
    /// A package output that has its own `build` section and inherits from a
    /// staging cache must still inherit top-level build settings (build number,
    /// `files`, ...) that it does not override itself. Previously the
    /// cache-inheritance merge only copied a hand-maintained subset of build
    /// fields, so top-level settings such as `build.files` were silently lost.
    #[test]
    fn test_cache_inherited_output_inherits_top_level_build_settings() {
        let recipe_yaml = r#"
recipe:
  name: libgdal-split
  version: 3.21.3

build:
  number: 1
  files:
    - "*.txt"

outputs:
  - staging:
      name: core-build
    requirements:
      build:
    build:
      script: build

  - package:
      name: libgdal-core
    inherit: core-build
    requirements:
      build:
    build:
      script: build
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str("{}").unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        assert_eq!(rendered.len(), 1);
        let output = &rendered[0].recipe;
        assert_eq!(output.package.name.as_normalized(), "libgdal-core");
        // The top-level build number must be inherited.
        assert_eq!(output.build.number, Some(1));
        // The build string must end with the inherited build number.
        let build_string = output.build.string.as_resolved().unwrap();
        assert!(
            build_string.ends_with("_1"),
            "cache-inherited output should resolve its build string with the inherited build number, got {build_string}"
        );
        // Other top-level build settings (e.g. `files`) must be inherited too.
        assert!(
            !output.build.files.is_empty(),
            "cache-inherited output should inherit the top-level build.files glob"
        );
    }

    #[test]
    fn test_render_multi_output_with_pin_subpackage() {
        let recipe_yaml = r#"
schema_version: 1

context:
  name: my-pkg
  version: "0.1.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: ${{ name }}
    build:
      noarch: generic

  - package:
      name: ${{ name }}-extra
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage(name, exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Should have 2 outputs
        assert_eq!(rendered.len(), 2);

        // Check that the second output has the pin_subpackage reference
        let extra_pkg = rendered
            .iter()
            .find(|r| r.recipe.package.name.as_normalized() == "my-pkg-extra")
            .expect("Should have my-pkg-extra output");

        // The variant should include the pinned package (normalized to my_pkg)
        // For now, let's check that it has requirements
        assert!(!extra_pkg.recipe.requirements.run.is_empty());

        // Check that we have a pin_subpackage dependency
        let pin_sub = extra_pkg
            .recipe
            .requirements
            .run
            .iter()
            .find_map(|dep| {
                if let Dependency::PinSubpackage(pin) = dep {
                    Some(pin)
                } else {
                    None
                }
            })
            .expect("Should have pin_subpackage in run requirements");

        assert_eq!(pin_sub.pin_subpackage.name.as_normalized(), "my-pkg");
        assert!(
            pin_sub.pin_subpackage.args.exact,
            "pin_subpackage should have exact=true"
        );

        // Verify that the pin_subpackages field is populated
        assert_eq!(
            extra_pkg.pin_subpackages.len(),
            1,
            "Should have 1 pin_subpackage tracked"
        );

        let pin_info = extra_pkg
            .pin_subpackages
            .get(&"my-pkg".into())
            .expect("Should have my-pkg in pin_subpackages");

        assert_eq!(pin_info.name.as_normalized(), "my-pkg");
        assert_eq!(pin_info.version.to_string(), "0.1.0");
        assert!(pin_info.exact, "Pin should be marked as exact");

        assert!(
            extra_pkg.variant.contains_key(&"my-pkg".into()),
            "Variant should include pinned package for hash computation"
        );
    }

    #[test]
    fn test_render_multi_output_with_pin_subpackage_and_variants() {
        // Test that pin_subpackage with exact=true creates variant dependencies
        let recipe_yaml = r#"
schema_version: 1

context:
  name: mylib
  version: "1.2.3"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: ${{ name }}
    build:
      noarch: python
    requirements:
      host:
        - python ${{ python }}.*

  - package:
      name: ${{ name }}-tools
    build:
      noarch: python
    requirements:
      host:
        - python ${{ python }}.*
      run:
        - python ${{ python }}.*
        - ${{ pin_subpackage(name, exact=true) }}
"#;

        let variant_yaml = r#"
python:
  - "3.9"
  - "3.10"
  - "3.11"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Should have 2 outputs × 3 python variants = 6 total
        assert_eq!(rendered.len(), 6);

        // Check each tools package has pin_subpackage with exact=true
        let tools_packages: Vec<_> = rendered
            .iter()
            .filter(|r| r.recipe.package.name.as_normalized() == "mylib-tools")
            .collect();

        assert_eq!(
            tools_packages.len(),
            3,
            "Should have 3 mylib-tools variants"
        );

        for tools_pkg in tools_packages {
            // Check pin_subpackage dependency
            let pin_sub = tools_pkg
                .recipe
                .requirements
                .run
                .iter()
                .find_map(|dep| {
                    if let Dependency::PinSubpackage(pin) = dep {
                        Some(pin)
                    } else {
                        None
                    }
                })
                .expect("Should have pin_subpackage in run requirements");

            assert_eq!(pin_sub.pin_subpackage.name.as_normalized(), "mylib");
            assert!(
                pin_sub.pin_subpackage.args.exact,
                "pin_subpackage should have exact=true"
            );

            assert_eq!(
                tools_pkg.pin_subpackages.len(),
                1,
                "Should have 1 pin_subpackage tracked"
            );

            let pin_info = tools_pkg
                .pin_subpackages
                .get(&"mylib".into())
                .expect("Should have mylib in pin_subpackages");

            assert_eq!(pin_info.name.as_normalized(), "mylib");
            assert_eq!(pin_info.version.to_string(), "1.2.3");
            assert!(pin_info.exact, "Pin should be marked as exact");

            assert!(
                tools_pkg.variant.contains_key(&"mylib".into()),
                "Variant should include pinned package 'mylib' for hash computation"
            );

            // The variant should contain the version and build string
            let mylib_variant = tools_pkg.variant.get(&"mylib".into()).unwrap();
            assert!(
                mylib_variant.to_string().contains("1.2.3"),
                "Variant should include mylib version"
            );
        }
    }

    #[test]
    fn test_topological_sort_simple() {
        // Test simple case: base package -> dependent package
        let recipe_yaml = r#"
schema_version: 1

context:
  name: base
  version: "1.0.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: ${{ name }}
    build:
      noarch: generic

  - package:
      name: ${{ name }}-tools
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage(name, exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Sort topologically
        let sorted = topological_sort_variants(rendered).unwrap();

        assert_eq!(sorted.len(), 2);
        // Base package should come first
        assert_eq!(sorted[0].recipe.package.name.as_normalized(), "base");
        // Dependent package should come second
        assert_eq!(sorted[1].recipe.package.name.as_normalized(), "base-tools");
    }

    #[test]
    fn test_topological_sort_chain() {
        // Test chain: pkg-a -> pkg-b -> pkg-c
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "1.0.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: pkg-a
    build:
      noarch: generic

  - package:
      name: pkg-b
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage("pkg-a", exact=true) }}

  - package:
      name: pkg-c
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage("pkg-b", exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Sort topologically
        let sorted = topological_sort_variants(rendered).unwrap();

        assert_eq!(sorted.len(), 3);
        // Should be in dependency order
        assert_eq!(sorted[0].recipe.package.name.as_normalized(), "pkg-a");
        assert_eq!(sorted[1].recipe.package.name.as_normalized(), "pkg-b");
        assert_eq!(sorted[2].recipe.package.name.as_normalized(), "pkg-c");
    }

    #[test]
    fn test_topological_sort_no_dependencies() {
        // Test case with no pin_subpackage dependencies
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "1.0.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: pkg-a
    build:
      noarch: generic

  - package:
      name: pkg-b
    build:
      noarch: generic

  - package:
      name: pkg-c
    build:
      noarch: generic
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Sort topologically (should succeed even with no dependencies)
        let sorted = topological_sort_variants(rendered).unwrap();

        assert_eq!(sorted.len(), 3);

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized())
            .collect();
        // Check that packages are in the right order
        assert_eq!(names, vec!["pkg-a", "pkg-b", "pkg-c"]);
    }

    #[test]
    fn test_topological_sort_cycle_detection() {
        // This test documents that cycle detection works
        // In practice, this would be caught earlier during recipe parsing/evaluation
        // since pin_subpackage typically can't create cycles (packages can't depend on themselves)

        // For now, we'll just test the empty case to ensure the function handles edge cases
        let sorted = topological_sort_variants(vec![]).unwrap();
        assert_eq!(sorted.len(), 0);
    }

    #[test]
    fn test_topological_sort_self_pin() {
        // Test that self-pins (package pinning itself) are allowed and don't cause cycles
        // This is common in run_exports where a package exports a pin to itself
        let recipe_yaml = r#"
schema_version: 1

context:
  name: mylib
  version: "1.0.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: ${{ name }}
    build:
      noarch: generic
    requirements:
      run_exports:
        - ${{ pin_subpackage(name, exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Should succeed without cycle error
        let sorted = topological_sort_variants(rendered).unwrap();
        assert_eq!(sorted.len(), 1);
        assert_eq!(sorted[0].recipe.package.name.as_normalized(), "mylib");

        // Self-pin should be tracked but not cause ordering issues
        assert_eq!(sorted[0].pin_subpackages.len(), 1);
    }

    #[test]
    fn test_non_exact_run_constraint_is_not_a_cycle() {
        // Regression test for issue #2531: a non-exact run_constraints pin must
        // not edge the graph, so mutual references aren't a false cycle.
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "0.3.20"

recipe:
  name: openblas-split
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: libopenblas
    build:
      noarch: generic
    requirements:
      run_constraints:
        - ${{ pin_subpackage("openblas", upper_bound="x.x.x") }}

  - package:
      name: openblas
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage("libopenblas", exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // No (false) cycle; libopenblas comes first via openblas's exact pin.
        let sorted = topological_sort_variants(rendered).unwrap();
        assert_eq!(sorted.len(), 2);
        assert_eq!(sorted[0].recipe.package.name.as_normalized(), "libopenblas");
        assert_eq!(sorted[1].recipe.package.name.as_normalized(), "openblas");
    }

    #[test]
    fn test_exact_run_constraint_still_orders() {
        // An exact run_constraints pin still edges the graph: base first.
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "1.0.0"

recipe:
  name: split
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: consumer
    build:
      noarch: generic
    requirements:
      run_constraints:
        - ${{ pin_subpackage("base", exact=true) }}

  - package:
      name: base
    build:
      noarch: generic
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        let sorted = topological_sort_variants(rendered).unwrap();
        assert_eq!(sorted.len(), 2);
        assert_eq!(sorted[0].recipe.package.name.as_normalized(), "base");
        assert_eq!(sorted[1].recipe.package.name.as_normalized(), "consumer");
    }

    #[test]
    fn test_up_to_filters_transitive_deps() {
        // Test that --up-to returns only the target package and its transitive dependencies.
        // This is the scenario that previously broke: pkg-c depends on pkg-b which depends on pkg-a.
        // Using --up-to=pkg-b should return [pkg-a, pkg-b] (not pkg-c).
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "1.0.0"

recipe:
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: pkg-a
    build:
      noarch: generic

  - package:
      name: pkg-b
    build:
      noarch: generic
    requirements:
      host:
        - ${{ pin_subpackage('pkg-a', exact=true) }}

  - package:
      name: pkg-c
    build:
      noarch: generic
    requirements:
      host:
        - ${{ pin_subpackage('pkg-b', exact=true) }}
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // --up-to=pkg-b should include pkg-a (dependency) and pkg-b itself, but not pkg-c
        let sorted =
            topological_sort_by_dependencies(rendered.clone(), |v| &v.recipe, Some("pkg-b"))
                .unwrap();

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized().to_string())
            .collect();
        assert_eq!(names, vec!["pkg-a", "pkg-b"]);

        // --up-to=pkg-a should return only pkg-a (no deps)
        let sorted =
            topological_sort_by_dependencies(rendered.clone(), |v| &v.recipe, Some("pkg-a"))
                .unwrap();

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized().to_string())
            .collect();
        assert_eq!(names, vec!["pkg-a"]);

        // --up-to=pkg-c should return all three in dependency order
        let sorted =
            topological_sort_by_dependencies(rendered.clone(), |v| &v.recipe, Some("pkg-c"))
                .unwrap();

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized().to_string())
            .collect();
        assert!(names.contains(&"pkg-a".to_string()));
        assert!(names.contains(&"pkg-b".to_string()));
        assert!(names.contains(&"pkg-c".to_string()));
        assert_eq!(names.len(), 3);

        // --up-to with nonexistent package should error
        let result = topological_sort_by_dependencies(rendered, |v| &v.recipe, Some("nonexistent"));
        assert!(matches!(
            result,
            Err(TopologicalSortError::PackageNotFound { .. })
        ));
    }

    #[test]
    fn test_up_to_with_variants() {
        // Test --up-to with multiple variants: should include all variants of the
        // target package and its transitive dependencies.
        let recipe_yaml = r#"
schema_version: 1

context:
  name: my-package

recipe:
  name: ${{ name }}
  version: "1.0.0"

outputs:
  - package:
      name: ${{ name }}-${{ variant }}
    build:
      noarch: generic

  - package:
      name: ${{ name }}
    build:
      noarch: generic
    requirements:
      run:
        - ${{ pin_subpackage(name ~ '-' ~ variant, exact=true) }}
"#;

        let variant_yaml = r#"
variant:
  - a
  - b
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // --up-to=my-package should include the my-package variants plus
        // the my-package-a and my-package-b variants they depend on via pin_subpackage
        let sorted =
            topological_sort_by_dependencies(rendered.clone(), |v| &v.recipe, Some("my-package"))
                .unwrap();

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized().to_string())
            .collect();

        // Both my-package-a and my-package-b should be included as deps
        assert!(names.contains(&"my-package-a".to_string()));
        assert!(names.contains(&"my-package-b".to_string()));
        assert!(names.contains(&"my-package".to_string()));

        // --up-to=my-package-a should return only my-package-a (no deps)
        let sorted =
            topological_sort_by_dependencies(rendered, |v| &v.recipe, Some("my-package-a"))
                .unwrap();

        let names: Vec<_> = sorted
            .iter()
            .map(|v| v.recipe.package.name.as_normalized().to_string())
            .collect();
        assert_eq!(names, vec!["my-package-a"]);
    }

    #[test]
    fn test_attestation_requires_experimental() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

source:
  url: https://files.pythonhosted.org/packages/test/test-1.0.tar.gz
  sha256: "0000000000000000000000000000000000000000000000000000000000000000"
  attestation:
    publishers:
      - github:test/test
"#;

        let variant_yaml = r#"{}"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        // Without experimental flag, should fail
        let result =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new());

        assert!(
            result.is_err(),
            "Attestation should require experimental flag"
        );
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("experimental"),
            "Error should mention experimental flag: {}",
            err
        );

        // With experimental flag, should succeed
        let result = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_experimental(true),
        );

        assert!(
            result.is_ok(),
            "Attestation should work with experimental flag: {:?}",
            result.err()
        );
    }

    #[test]
    fn test_variant_discovery_from_conditional_template() {
        // Test that `host: ["${{ 'openssl' if unix }}"]` discovers 'openssl' as a variant key
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  host:
    - ${{ 'openssl' if unix }}
"#;

        let variant_yaml = r#"
openssl:
  - "1.1"
  - "3.0"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        // Use linux platform to ensure `unix` is true
        let config =
            RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Linux64);

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, config).unwrap();

        // Should create 2 variants for openssl (1.1 and 3.0)
        assert_eq!(
            rendered.len(),
            2,
            "Should have 2 variants from openssl in conditional template"
        );

        // Check that openssl is in the variants
        let openssl_versions: Vec<String> = rendered
            .iter()
            .filter_map(|r| r.variant.get(&"openssl".into()).map(|v| v.to_string()))
            .collect();

        assert!(
            openssl_versions.contains(&"1.1".to_string()),
            "Should have openssl 1.1 variant"
        );
        assert!(
            openssl_versions.contains(&"3.0".to_string()),
            "Should have openssl 3.0 variant"
        );
    }

    #[test]
    fn test_variant_discovery_from_variable_template() {
        // Test tree-based variant discovery:
        // - `host: ["${{ mpi }}"]` with `mpi: [openmpi, mpich]`
        // - After evaluating mpi=openmpi, free spec 'openmpi' is discovered as a variant key
        // - After evaluating mpi=mpich, free spec 'mpich' is discovered as a variant key
        // - This creates a tree of variants
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  host:
    - ${{ mpi }}
"#;

        let variant_yaml = r#"
mpi:
  - openmpi
  - mpich
openmpi:
  - "4.0"
  - "4.1"
mpich:
  - "3.4"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Tree-based variant discovery should create:
        // - {mpi: openmpi, openmpi: 4.0}
        // - {mpi: openmpi, openmpi: 4.1}
        // - {mpi: mpich, mpich: 3.4}
        assert_eq!(
            rendered.len(),
            3,
            "Should have 3 variants: 2 for openmpi × openmpi versions, 1 for mpich × mpich version"
        );

        // Check mpi=openmpi variants have openmpi key
        let openmpi_variants: Vec<_> = rendered
            .iter()
            .filter(|r| {
                r.variant.get(&"mpi".into()).map(|v| v.to_string()) == Some("openmpi".to_string())
            })
            .collect();
        assert_eq!(openmpi_variants.len(), 2, "Should have 2 openmpi variants");

        let openmpi_versions: Vec<String> = openmpi_variants
            .iter()
            .filter_map(|r| r.variant.get(&"openmpi".into()).map(|v| v.to_string()))
            .collect();
        assert!(
            openmpi_versions.contains(&"4.0".to_string()),
            "Should have openmpi 4.0"
        );
        assert!(
            openmpi_versions.contains(&"4.1".to_string()),
            "Should have openmpi 4.1"
        );

        // Check mpi=mpich variant has mpich key
        let mpich_variants: Vec<_> = rendered
            .iter()
            .filter(|r| {
                r.variant.get(&"mpi".into()).map(|v| v.to_string()) == Some("mpich".to_string())
            })
            .collect();
        assert_eq!(mpich_variants.len(), 1, "Should have 1 mpich variant");
        assert_eq!(
            mpich_variants[0]
                .variant
                .get(&"mpich".into())
                .map(|v| v.to_string()),
            Some("3.4".to_string()),
            "mpich variant should have mpich=3.4"
        );
    }

    #[test]
    fn test_skipped_output_with_platform_specific_requirements() {
        // Test that when an output is skipped (e.g., skip: win), its requirements
        // are not evaluated. This prevents errors from platform-specific functions
        // like stdlib('c') which may not have defaults on certain platforms.
        let recipe_yaml = r#"
schema_version: 1

context:
  name: test-pkg
  version: "1.0.0"

recipe:
  name: ${{ name }}-split
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: ${{ name }}-unix
    build:
      skip: win
    requirements:
      build:
        - ${{ stdlib('c') }}
        - ${{ compiler('cxx') }}
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();

        // Variant for Windows - the output should be skipped
        let variant_config = VariantConfig::default();

        // RenderConfig with Windows as target platform
        let config = RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Win64);

        // The rendering should NOT fail - even though stdlib('c') would fail on Windows,
        // the output is skipped (skip: win) so requirements should not be evaluated
        let result = render_recipe_with_variant_config(&stage0_recipe, &variant_config, config);

        // Check if result is OK - it should be because the output is skipped
        assert!(
            result.is_ok(),
            "Rendering skipped output should not fail. Error: {:?}",
            result.err()
        );

        let rendered = result.unwrap();
        // Skipped outputs should be filtered out entirely
        assert_eq!(
            rendered.len(),
            0,
            "Skipped output should be filtered out, not returned"
        );
    }

    #[test]
    fn test_pin_subpackage_inherits_variants_from_pinned_output() {
        // Test the case where:
        // - Output A (pillow) has python as a host/run dependency -> creates 3 variants
        // - Output B (pillow-tests) ONLY uses pin_subpackage("pillow", exact=True)
        //   -> should ALSO create 3 variants, one for each pillow variant
        //
        // This is the user's exact scenario from the pillow-expansion recipe.
        let recipe_yaml = r#"
schema_version: 1

context:
  version: "12.1.0"

recipe:
  name: pillow-split
  version: ${{ version }}

build:
  number: 0

outputs:
  - package:
      name: pillow
    requirements:
      host:
        - python
      run:
        - python

  - package:
      name: pillow-tests
    requirements:
      run:
        - ${{ pin_subpackage("pillow", exact=True) }}
"#;

        let variant_yaml = r#"
python:
  - "3.10"
  - "3.11"
  - "3.12"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        // Count outputs by name
        let pillow_variants: Vec<_> = rendered
            .iter()
            .filter(|r| r.recipe.package.name.as_normalized() == "pillow")
            .collect();

        let pillow_tests_variants: Vec<_> = rendered
            .iter()
            .filter(|r| r.recipe.package.name.as_normalized() == "pillow-tests")
            .collect();

        // pillow should have 3 variants (one for each Python version)
        assert_eq!(
            pillow_variants.len(),
            3,
            "pillow should have 3 variants (one per Python version)"
        );

        // pillow-tests should ALSO have 3 variants because pin_subpackage(exact=True)
        // should cause it to inherit variants from pillow
        assert_eq!(
            pillow_tests_variants.len(),
            3,
            "pillow-tests should have 3 variants because pin_subpackage(exact=True) \
             should cause variant inheritance from pillow. Currently has {} variants.",
            pillow_tests_variants.len()
        );

        // Additionally, each pillow-tests should pin a DIFFERENT pillow variant
        let pinned_build_strings: std::collections::HashSet<String> = pillow_tests_variants
            .iter()
            .filter_map(|v| {
                v.pin_subpackages
                    .get(&"pillow".into())
                    .and_then(|p| p.build_string.clone())
            })
            .collect();

        assert_eq!(
            pinned_build_strings.len(),
            3,
            "Each pillow-tests variant should pin a different pillow variant. \
             Pinned build strings: {:?}",
            pinned_build_strings
        );

        // Verify the order is interleaved: pillow(3.10), pillow-tests(3.10), pillow(3.11), ...
        // Each pillow should be immediately followed by its matching pillow-tests
        let output_names: Vec<_> = rendered
            .iter()
            .map(|r| r.recipe.package.name.as_normalized().to_string())
            .collect();

        assert_eq!(
            output_names,
            vec![
                "pillow",
                "pillow-tests",
                "pillow",
                "pillow-tests",
                "pillow",
                "pillow-tests"
            ],
            "Order should be interleaved: each pillow immediately followed by its pillow-tests"
        );

        // Additionally verify that each pillow-tests pins the immediately preceding pillow
        for i in (1..rendered.len()).step_by(2) {
            let pillow_idx = i - 1;
            let pillow_tests_idx = i;

            let pillow_build_string = rendered[pillow_idx]
                .recipe
                .build
                .string
                .as_resolved()
                .unwrap();
            let pinned_build_string = rendered[pillow_tests_idx]
                .pin_subpackages
                .get(&"pillow".into())
                .and_then(|p| p.build_string.as_ref())
                .unwrap();

            assert_eq!(
                pillow_build_string, pinned_build_string,
                "pillow-tests at index {} should pin pillow at index {}",
                pillow_tests_idx, pillow_idx
            );
        }
    }

    #[test]
    fn test_single_output_pin_subpackage_with_context_variable() {
        // Test that single-output recipes can use context variables in pin_subpackage.
        // This was a regression where `pin_subpackage(name)` would fail with
        // "Undefined variable 'name'" even though `name` was defined in the context section.
        let recipe_yaml = r#"
schema_version: 1

context:
  name: test-pkg
  version: 0.1.0

package:
  name: ${{ name }}
  version: ${{ version }}

requirements:
  run_exports:
    - ${{ pin_subpackage(name, upper_bound='x.x.x') }}
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        // This should succeed - the `name` variable from context should be available
        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new());

        assert!(
            rendered.is_ok(),
            "pin_subpackage(name) should work with context variables. Error: {:?}",
            rendered.err()
        );

        let rendered = rendered.unwrap();
        assert_eq!(rendered.len(), 1);

        // Verify the pin_subpackage was correctly evaluated
        let recipe = &rendered[0].recipe;
        assert_eq!(recipe.package.name.as_normalized(), "test-pkg");

        // Check that run_exports contains the pin_subpackage
        let weak_exports = &recipe.requirements.run_exports.weak;
        assert_eq!(weak_exports.len(), 1, "Should have one run_export");

        // The pin_subpackage should reference "test-pkg"
        if let Dependency::PinSubpackage(pin) = &weak_exports[0] {
            assert_eq!(
                pin.pin_subpackage.name.as_normalized(),
                "test-pkg",
                "pin_subpackage should reference the context variable value"
            );
        } else {
            panic!("Expected PinSubpackage dependency");
        }
    }

    #[test]
    fn test_context_variables_not_included_in_variant() {
        // Test that context variables like build_drafts and build_prefix are NOT included
        // in the variant, even when they are accessed during context evaluation.
        // Only actual variant keys (like drafts) should be in the variant.
        let recipe_yaml = r#"
schema_version: 1

context:
  name: zeromq
  version: 4.3.5
  build: 9
  build_drafts: ${{ drafts == "ON" }}
  build_prefix: "${{ 'drafts_' if build_drafts else '' }}"

package:
  name: ${{ name|lower }}
  version: ${{ version }}

build:
  number: ${{ build }}
  string: ${{ build_prefix }}h${{ hash }}_${{ build }}
"#;

        let variant_yaml = r#"
drafts:
  - "ON"
  - "OFF"
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        assert_eq!(
            rendered.len(),
            2,
            "Should have 2 variants (drafts ON and OFF)"
        );

        for variant in &rendered {
            // The variant should contain 'drafts' (actual variant key) and 'target_platform'
            // but NOT 'build_drafts' or 'build_prefix' (which are context variables)
            assert!(
                variant.variant.contains_key(&"drafts".into()),
                "Variant should contain 'drafts'"
            );
            assert!(
                !variant.variant.contains_key(&"build_drafts".into()),
                "Variant should NOT contain context variable 'build_drafts'"
            );
            assert!(
                !variant.variant.contains_key(&"build_prefix".into()),
                "Variant should NOT contain context variable 'build_prefix'"
            );
            assert!(
                !variant.variant.contains_key(&"name".into()),
                "Variant should NOT contain context variable 'name'"
            );
            assert!(
                !variant.variant.contains_key(&"version".into()),
                "Variant should NOT contain context variable 'version'"
            );
            assert!(
                !variant.variant.contains_key(&"build".into()),
                "Variant should NOT contain context variable 'build'"
            );
        }

        // Check that build strings are correct
        let build_strings: Vec<_> = rendered
            .iter()
            .map(|r| r.recipe.build.string.as_resolved().unwrap().to_string())
            .collect();

        // One should have drafts_ prefix, one shouldn't
        assert!(
            build_strings.iter().any(|s| s.starts_with("drafts_")),
            "One build string should have 'drafts_' prefix for drafts=ON"
        );
        assert!(
            build_strings.iter().any(|s| !s.starts_with("drafts_")),
            "One build string should NOT have 'drafts_' prefix for drafts=OFF"
        );
    }

    #[test]
    fn test_context_variable_same_name_as_variant_included() {
        // Test that when a context variable has the same name as a variant variable
        // and references it (like `context: { mpi: ${{ mpi }} }`), the variant
        // variable IS included in the variant.
        let recipe_yaml = r#"
schema_version: 1

context:
  mpi: ${{ mpi }}

package:
  name: test-pkg
  version: 1.0.0

build:
  string: ${{ mpi }}_h${{ hash }}_0
"#;

        let variant_yaml = r#"
mpi:
  - openmpi
  - mpich
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        assert_eq!(rendered.len(), 2, "Should have 2 variants (mpi values)");

        for variant in &rendered {
            // The variant SHOULD contain 'mpi' because it references a variant variable
            // of the same name
            assert!(
                variant.variant.contains_key(&"mpi".into()),
                "Variant should contain 'mpi' since context.mpi references variant.mpi"
            );
        }

        // Check mpi values are correct
        let mpi_values: Vec<_> = rendered
            .iter()
            .map(|r| r.variant.get(&"mpi".into()).unwrap().to_string())
            .collect();

        assert!(mpi_values.contains(&"openmpi".to_string()));
        assert!(mpi_values.contains(&"mpich".to_string()));
    }

    #[test]
    fn test_single_output_skip_true_filters_output() {
        // Test that a single-output recipe with `skip: true` returns an empty result
        use crate::stage0::parse_recipe_from_source;

        let recipe_yaml = r#"
schema_version: 1
package:
  name: test-skip
  version: "1.0.0"
build:
  number: 0
  skip: true
  script:
    - exit 1
"#;

        let stage0_recipe = parse_recipe_from_source(recipe_yaml).unwrap();
        let stage0 = Stage0Recipe::SingleOutput(Box::new(stage0_recipe));

        let variant_config = VariantConfig::default();
        let rendered =
            render_recipe_with_variant_config(&stage0, &variant_config, RenderConfig::new())
                .unwrap();

        // Single-output recipes with skip: true are NOT filtered during variant rendering
        // The skip check happens at build time in src/lib.rs (like main branch)
        // This allows the CLI to show the variant as "(skipped)" in the output
        assert_eq!(
            rendered.len(),
            1,
            "Single-output recipe should still be returned (skip happens at build time), got {} outputs",
            rendered.len()
        );

        // Verify the recipe has skip evaluated to true
        let recipe = &rendered[0].recipe;
        assert!(
            recipe.build.skip,
            "Recipe with `skip: true` should have skip evaluated to true"
        );
    }

    #[test]
    fn test_noarch_should_not_override_target_platform_for_skip() {
        // When building a noarch package on a native platform (e.g., linux-64),
        // the skip condition `target_platform == "noarch"` should evaluate to false
        // because the target_platform should remain the build platform, not "noarch".
        // Skip conditions are evaluated eagerly during recipe evaluation, before
        // the variant gets the noarch target_platform override.
        use crate::stage0::parse_recipe_from_source;

        let recipe_yaml = r#"
schema_version: 1
package:
  name: skip
  version: "1.0.0"
build:
  number: 0
  skip: target_platform == "noarch"
  noarch: generic
"#;

        let stage0_recipe = parse_recipe_from_source(recipe_yaml).unwrap();
        let stage0 = Stage0Recipe::SingleOutput(Box::new(stage0_recipe));

        let variant_config = VariantConfig::default();
        let config =
            RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Linux64);

        let rendered = render_recipe_with_variant_config(&stage0, &variant_config, config).unwrap();

        assert_eq!(
            rendered.len(),
            1,
            "Recipe should be returned from rendering"
        );

        // Skip should be false: target_platform is "linux-64" (the build platform),
        // not "noarch", when the skip condition is evaluated
        assert!(
            !rendered[0].recipe.build.skip,
            "Recipe with `skip: target_platform == \"noarch\"` should NOT be skipped \
             when building on linux-64"
        );
    }

    #[test]
    fn test_toplevel_skip_inherited_by_outputs_during_variant_discovery() {
        // Regression test for GitHub issue #2110
        // When a multi-output recipe has a top-level skip condition that references
        // a variant key (e.g., match(python, "<3.11")), ALL outputs should be skipped
        // for matching variants — even outputs that define their own output-level skip.
        //
        // Additionally, variant keys used in top-level skip conditions should be
        // discovered and included in the variant matrix.
        let recipe_yaml = r#"
schema_version: 1

context:
  name: test-pkg
  version: "1.0.0"

recipe:
  name: ${{ name }}-split
  version: ${{ version }}

build:
  number: 0
  skip:
    - match(python, "<3.11")

outputs:
  - package:
      name: ${{ name }}
    requirements:
      host:
        - python
      run:
        - python

  - package:
      name: ${{ name }}-tests
    build:
      skip:
        - win
    requirements:
      run:
        - python
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();

        let variant_yaml = r#"
python:
  - "3.10"
  - "3.11"
  - "3.12"
"#;
        let variant_config =
            VariantConfig::from_yaml_str(variant_yaml).expect("Failed to parse variant config");

        // On Linux: python 3.10 should be skipped entirely (top-level skip),
        // python 3.11 and 3.12 should each produce 2 outputs
        let config_linux =
            RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Linux64);
        let rendered_linux =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, config_linux)
                .unwrap();
        // 2 python versions * 2 outputs = 4
        assert_eq!(
            rendered_linux.len(),
            4,
            "Expected 4 outputs on Linux (py3.10 skipped, py3.11+3.12 x 2 outputs). Got {}",
            rendered_linux.len()
        );

        // On Windows: python 3.10 skipped by top-level match(python, "<3.11"),
        // tests output skipped by output-level "win", so only the main package
        // for python 3.11 and 3.12
        let config_win =
            RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Win64);
        let rendered_win =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, config_win).unwrap();
        // 2 python versions * 1 output (tests skipped on win) = 2
        assert_eq!(
            rendered_win.len(),
            2,
            "Expected 2 outputs on Windows (py3.10 skipped, tests skipped on win). Got {}",
            rendered_win.len()
        );
        for rv in &rendered_win {
            assert_eq!(rv.recipe.package.name.as_normalized(), "test-pkg");
        }
    }

    #[test]
    fn test_toplevel_skip_prevents_requirement_evaluation_in_multi_output() {
        // When a top-level skip fires (e.g., skip: win), outputs should NOT have
        // their requirements evaluated — even if the output has no skip of its own.
        // This prevents errors from platform-specific functions like stdlib('c').
        let recipe_yaml = r#"
schema_version: 1

recipe:
  name: test-pkg-split
  version: "1.0.0"

build:
  number: 0
  skip:
    - win

outputs:
  - package:
      name: test-pkg
    requirements:
      build:
        - ${{ stdlib('c') }}
        - ${{ compiler('cxx') }}

  - package:
      name: test-pkg-extra
    build:
      skip:
        - osx
    requirements:
      build:
        - ${{ stdlib('c') }}
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        // On Windows: top-level skip: win should prevent requirement evaluation
        // for all outputs, avoiding stdlib('c') errors
        let config_win =
            RenderConfig::new().with_target_platform(rattler_conda_types::Platform::Win64);
        let result = render_recipe_with_variant_config(&stage0_recipe, &variant_config, config_win);
        assert!(
            result.is_ok(),
            "Top-level skip should prevent requirement evaluation on Windows. Error: {:?}",
            result.err()
        );
        assert_eq!(
            result.unwrap().len(),
            0,
            "All outputs should be skipped on Windows"
        );
    }

    #[test]
    fn test_match_with_integer_variant_values() {
        // Regression: YAML integer values (unquoted `14` vs `"14"`) passed to match()
        // were not recognized, causing match() to always return true and skip all builds.
        let recipe_yaml = r#"
package:
  name: test
  version: "1.0.0"

requirements:
  host:
    - postgresql

build:
  skip:
    - match(postgresql, "<=16")
"#;

        let variant_yaml = r#"
postgresql:
  - 16
  - 17
"#;

        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config =
            VariantConfig::from_yaml_str(variant_yaml).expect("Failed to parse variant config");

        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap_or_else(|e| panic!("Failed to render: {e:?}"));

        let skip_flags: Vec<_> = rendered
            .iter()
            .map(|r| {
                let pg = r.variant.get(&"postgresql".into()).unwrap().to_string();
                (pg, r.recipe.build.skip)
            })
            .collect();
        // postgresql 16 should be skipped (<=16), 17 should build
        assert_eq!(skip_flags, vec![("16".into(), true), ("17".into(), false)]);
    }

    #[test]
    fn test_build_string_prefix_simple() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        let config = RenderConfig::new().with_build_string_prefix("foobar");
        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, config).unwrap();

        assert_eq!(rendered.len(), 1);
        let build_string = rendered[0]
            .recipe
            .build
            .string
            .as_resolved()
            .unwrap()
            .to_string();
        assert!(
            build_string.starts_with("foobar_h"),
            "build string should start with 'foobar_h', got '{build_string}'"
        );
    }

    #[test]
    fn test_build_string_prefix_with_variants() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

requirements:
  host:
    - python ${{ python }}
  run:
    - python
"#;
        let variant_yaml = r#"
python:
  - "3.9.*"
  - "3.10.*"
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::from_yaml_str(variant_yaml).unwrap();

        let config = RenderConfig::new().with_build_string_prefix("myprefix");
        let rendered =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, config).unwrap();

        assert_eq!(rendered.len(), 2);
        for variant in &rendered {
            let build_string = variant
                .recipe
                .build
                .string
                .as_resolved()
                .unwrap()
                .to_string();
            assert!(
                build_string.starts_with("myprefix_"),
                "build string should start with 'myprefix_', got '{build_string}'"
            );
        }

        // The two variants should produce different build strings
        let bs0 = rendered[0].recipe.build.string.as_resolved().unwrap();
        let bs1 = rendered[1].recipe.build.string.as_resolved().unwrap();
        assert_ne!(
            bs0, bs1,
            "different variants should produce different build strings"
        );
    }

    #[test]
    fn test_build_string_prefix_absent_gives_default() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        let with_prefix = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_build_string_prefix("pfx"),
        )
        .unwrap();
        let without_prefix =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();

        let bs_with = with_prefix[0].recipe.build.string.as_resolved().unwrap();
        let bs_without = without_prefix[0].recipe.build.string.as_resolved().unwrap();

        // The prefixed version should contain the unprefixed version
        assert!(
            bs_with.starts_with("pfx_"),
            "prefixed build string should start with 'pfx_', got '{bs_with}'"
        );
        assert!(
            bs_with.ends_with(bs_without),
            "prefixed '{bs_with}' should end with default '{bs_without}'"
        );
    }

    #[test]
    fn test_build_number_override() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"

build:
  number: 5
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        // Without override — should use the recipe's build number (5)
        let rendered_default =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();
        assert_eq!(rendered_default[0].recipe.build.number, Some(5));

        // With override — should use 42
        let rendered_override = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_build_number_override(42),
        )
        .unwrap();
        assert_eq!(rendered_override[0].recipe.build.number, Some(42));

        // Build string should contain the overridden build number
        let bs = rendered_override[0]
            .recipe
            .build
            .string
            .as_resolved()
            .unwrap();
        assert!(
            bs.ends_with("_42"),
            "build string should end with '_42', got '{bs}'"
        );
    }

    #[test]
    fn test_build_number_override_default_recipe() {
        let recipe_yaml = r#"
package:
  name: test-pkg
  version: "1.0.0"
"#;
        let stage0_recipe = stage0::parse_recipe_or_multi_from_source(recipe_yaml).unwrap();
        let variant_config = VariantConfig::default();

        // Recipe has no build number (defaults to 0)
        let rendered_default =
            render_recipe_with_variant_config(&stage0_recipe, &variant_config, RenderConfig::new())
                .unwrap();
        let bs_default = rendered_default[0]
            .recipe
            .build
            .string
            .as_resolved()
            .unwrap();
        assert!(
            bs_default.ends_with("_0"),
            "default build string should end with '_0', got '{bs_default}'"
        );

        // Override to 7
        let rendered_override = render_recipe_with_variant_config(
            &stage0_recipe,
            &variant_config,
            RenderConfig::new().with_build_number_override(7),
        )
        .unwrap();
        assert_eq!(rendered_override[0].recipe.build.number, Some(7));
        let bs_override = rendered_override[0]
            .recipe
            .build
            .string
            .as_resolved()
            .unwrap();
        assert!(
            bs_override.ends_with("_7"),
            "overridden build string should end with '_7', got '{bs_override}'"
        );
    }
}
