use std::sync::LazyLock;

use anyhow::{Context, Result};
use indexmap::IndexMap;
use log::{debug, info};
use regex::Regex;
use serde_yaml::Value;

use crate::model::*;
use crate::parser::gitlab::value_to_string;

use super::extends::{apply_defaults, resolve_all_extends};
use super::func_cache::{fetch_oci_func_yml, parse_oci_func_ref};
use super::variables::{
    GitContext, expand_script_vars, expand_vars, merge_and_expand_variables_with_origins,
};

/// Simple base64url encoding without padding (for stub JWTs).
fn base64_encode(input: &str) -> String {
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    let bytes = input.as_bytes();
    let mut out = String::new();
    for chunk in bytes.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
        let n = (b0 << 16) | (b1 << 8) | b2;
        out.push(CHARS[(n >> 18 & 63) as usize] as char);
        out.push(CHARS[(n >> 12 & 63) as usize] as char);
        if chunk.len() > 1 {
            out.push(CHARS[(n >> 6 & 63) as usize] as char);
        }
        if chunk.len() > 2 {
            out.push(CHARS[(n & 63) as usize] as char);
        }
    }
    out
}

/// Resolve a RawPipeline into a fully resolved Pipeline.
pub fn resolve_pipeline(raw: RawPipeline, git: &GitContext) -> Result<Pipeline> {
    resolve_pipeline_inner(raw, git, false)
}

/// Like `resolve_pipeline`, but with `tolerant` mode: missing extends targets
/// are warned about and skipped instead of causing an error.
pub fn resolve_pipeline_tolerant(raw: RawPipeline, git: &GitContext) -> Result<Pipeline> {
    resolve_pipeline_inner(raw, git, true)
}

fn resolve_pipeline_inner(raw: RawPipeline, git: &GitContext, tolerant: bool) -> Result<Pipeline> {
    // Step 1: Convert jobs back to Value for extends resolution
    // We strip null values so that extends merging works correctly
    // (a None field in child should not block inheriting from parent).
    let mut job_values: IndexMap<String, Value> = IndexMap::new();
    for (name, job) in &raw.jobs {
        let mut val = serde_yaml::to_value(job).with_context(|| {
            format!("Failed to serialize job '{}' for extends resolution", name)
        })?;
        strip_nulls(&mut val);
        job_values.insert(name.clone(), val);
    }

    // Step 1.5: Extract extends chains and save original values before resolution
    let extends_chains = extract_all_extends_chains(&job_values);
    let original_job_values = job_values.clone();

    // Step 2: Resolve extends chains
    resolve_all_extends(&mut job_values, tolerant)?;

    // Step 3: Apply defaults, tracking which keys were added.
    // Respects `inherit:default` on each job: false → skip all defaults,
    // [list] → only inherit listed keywords, true/absent → inherit all.
    let mut defaults_applied: IndexMap<String, Vec<String>> = IndexMap::new();
    if let Some(ref default) = raw.default {
        let mut default_val = serde_yaml::to_value(default)?;
        strip_nulls(&mut default_val);
        for (name, job_val) in job_values.iter_mut() {
            if !name.starts_with('.') {
                let inherit_default = extract_inherit_default(job_val);
                if matches!(inherit_default, InheritFilter::None) {
                    continue;
                }

                let effective_default = match &inherit_default {
                    InheritFilter::All => default_val.clone(),
                    InheritFilter::Listed(keys) => filter_mapping(&default_val, keys),
                    InheritFilter::None => unreachable!(),
                };

                let keys_before: std::collections::HashSet<String> =
                    if let Value::Mapping(m) = &*job_val {
                        m.keys()
                            .filter_map(|k| k.as_str().map(String::from))
                            .collect()
                    } else {
                        std::collections::HashSet::new()
                    };
                apply_defaults(job_val, &effective_default);
                if let Value::Mapping(m) = &*job_val {
                    let added: Vec<String> = m
                        .keys()
                        .filter_map(|k| k.as_str().map(String::from))
                        .filter(|k| !keys_before.contains(k))
                        .collect();
                    if !added.is_empty() {
                        defaults_applied.insert(name.clone(), added);
                    }
                }
            }
        }
    }

    // Step 3.5: Expand parallel:matrix jobs
    let (job_values, matrix_expansions) = expand_matrix_jobs(job_values);

    // Step 4: Convert back to RawJobConfig and build resolved jobs
    let mut jobs: IndexMap<String, Job> = IndexMap::new();

    for (name, job_val) in &job_values {
        // Skip hidden jobs
        if name.starts_with('.') {
            continue;
        }

        // Extract unresolved extends marker (injected by tolerant extends resolution)
        let unresolved_extends: Vec<String> = job_val
            .get("_cui_cui_unresolved")
            .and_then(|v| v.as_sequence())
            .map(|seq| {
                seq.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();

        // Strip the marker before deserializing
        let mut clean_val = job_val.clone();
        if let Value::Mapping(ref mut map) = clean_val {
            map.remove(Value::String("_cui_cui_unresolved".into()));
        }

        let raw_job: RawJobConfig = serde_yaml::from_value(clean_val)
            .with_context(|| format!("Failed to deserialize resolved job '{}'", name))?;

        // A job must have a `script`, `run`, or `trigger` to be valid
        if raw_job.script.is_none() && raw_job.trigger.is_none() && raw_job.run.is_none() {
            debug!("skipping job '{}': no script, run, or trigger", name);
            continue;
        }

        let resolved_yaml =
            serde_yaml::to_string(&job_val).unwrap_or_else(|_| "# failed to serialize".to_string());

        let stage = raw_job.stage.clone().unwrap_or_else(|| "test".to_string());

        let inherit_vars = raw_job
            .inherit
            .as_ref()
            .map(|i| &i.variables)
            .unwrap_or(&InheritFilter::All);
        let effective_global_vars: IndexMap<String, VarValue> = raw
            .variables
            .iter()
            .filter(|(k, _)| inherit_vars.allows(k))
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect();

        let (mut variables, mut variable_origins) = merge_and_expand_variables_with_origins(
            &effective_global_vars,
            &raw_job.variables,
            name,
            &stage,
            git,
        );

        // Generate stub JWT tokens for id_tokens entries
        if let Some(Value::Mapping(tokens)) = &raw_job.id_tokens {
            for (token_name, token_config) in tokens {
                if let Some(name_str) = token_name.as_str() {
                    let aud = token_config
                        .get("aud")
                        .and_then(|v| v.as_str())
                        .unwrap_or("unknown");
                    // Build a stub JWT: header.payload.signature
                    let header = base64_encode(r#"{"alg":"RS256","typ":"JWT"}"#);
                    let payload = base64_encode(&format!(
                        r#"{{"iss":"gitlab","aud":"{}","sub":"{}","iat":0}}"#,
                        aud, name_str
                    ));
                    let jwt = format!("{}.{}.stub-signature", header, payload);
                    variables.entry(name_str.to_string()).or_insert(jwt);
                    // GitLab also sets <NAME>_FILE_SCRIPT for writing the token to a file
                    let file_name = format!("{}_FILE", name_str);
                    let file_var = format!(
                        "export {file_name}=$(mktemp); echo -n \"${name_str}\" > \"${file_name}\"",
                    );
                    variables
                        .entry(format!("{}_FILE_SCRIPT", name_str))
                        .or_insert(file_var);
                    variable_origins
                        .entry(name_str.to_string())
                        .or_insert(VarOrigin::Predefined);
                    variable_origins
                        .entry(format!("{}_FILE_SCRIPT", name_str))
                        .or_insert(VarOrigin::Predefined);
                }
            }
        }

        // Mark matrix-injected variables
        if let Some(base_name) = name.split(" [").next()
            && matrix_expansions.contains_key(base_name)
            && let Some(Value::Mapping(var_map)) = job_val.get("variables")
        {
            for key in var_map.keys() {
                if let Some(k) = key.as_str()
                    && !raw.variables.contains_key(k)
                {
                    let is_from_matrix = match &raw_job.variables {
                        Some(orig_vars) => !orig_vars.contains_key(k),
                        None => true,
                    };
                    if is_from_matrix {
                        variable_origins.insert(k.to_string(), VarOrigin::Matrix);
                    }
                }
            }
        }

        let image = raw_job.image.as_ref().map(|i| {
            let name = i.name().to_string();
            expand_vars(&name, &variables)
        });

        let services = raw_job
            .services
            .as_ref()
            .map(|svcs| {
                svcs.iter()
                    .map(|s| match s {
                        ServiceEntry::Simple(name) => name.clone(),
                        ServiceEntry::Complex { name, .. } => name.clone(),
                    })
                    .collect()
            })
            .unwrap_or_default();

        let project_dir = variables.get("CI_PROJECT_DIR").map(std::path::Path::new);
        let (before_script, script, after_script) = if let Some(ref steps) = raw_job.run {
            (
                vec![],
                convert_run_steps(steps, project_dir, &variables),
                vec![],
            )
        } else {
            let before_script = raw_job
                .before_script
                .as_ref()
                .map(|s| s.as_slice())
                .unwrap_or(&[])
                .to_vec();
            let script = raw_job
                .script
                .as_ref()
                .map(|s| s.as_slice())
                .unwrap_or(&[])
                .to_vec();
            let after_script = raw_job
                .after_script
                .as_ref()
                .map(|s| s.as_slice())
                .unwrap_or(&[])
                .to_vec();
            (before_script, script, after_script)
        };

        // Exclude predefined CI variables from script expansion — they are
        // exported as env vars and the shell expands them at runtime.
        // Pre-expanding would embed placeholder values (e.g. "<commit-sha>")
        // that contain shell metacharacters.
        let script_vars: IndexMap<String, String> = variables
            .iter()
            .filter(|(k, _)| variable_origins.get(k.as_str()) != Some(&VarOrigin::Predefined))
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect();
        let before_script = expand_script_vars(&before_script, &script_vars);
        let script = expand_script_vars(&script, &script_vars);
        let after_script = expand_script_vars(&after_script, &script_vars);

        let needs: Vec<ResolvedNeed> = raw_job
            .needs
            .as_ref()
            .map(|n| {
                n.iter()
                    .flat_map(|e| {
                        let job_name = e.job_name();
                        let artifacts = e.wants_artifacts();
                        let optional = e.is_optional();
                        if let Some(need_parallel) = e.parallel() {
                            // Need has parallel:matrix with $[[ matrix.* ]] expressions —
                            // resolve to specific variant(s) instead of fanning out.
                            let targets = resolve_matrix_need(job_name, need_parallel, &variables);
                            if targets.is_empty() && !optional {
                                vec![ResolvedNeed {
                                    job_name: job_name.to_string(),
                                    artifacts,
                                }]
                            } else {
                                targets
                                    .into_iter()
                                    .map(|v| ResolvedNeed {
                                        job_name: v,
                                        artifacts,
                                    })
                                    .collect()
                            }
                        } else if let Some(variants) = matrix_expansions.get(job_name) {
                            // Need refers to a matrix-expanded job without specifying
                            // parallel:matrix — fan out to all variants.
                            variants
                                .iter()
                                .map(|v| ResolvedNeed {
                                    job_name: v.clone(),
                                    artifacts,
                                })
                                .collect()
                        } else if optional && !job_values.contains_key(job_name) {
                            vec![]
                        } else {
                            vec![ResolvedNeed {
                                job_name: job_name.to_string(),
                                artifacts,
                            }]
                        }
                    })
                    .collect()
            })
            .unwrap_or_default();

        let tags: Vec<String> = raw_job
            .tags
            .clone()
            .unwrap_or_default()
            .iter()
            .map(|t| expand_vars(t, &variables))
            .collect();

        let when_val = raw_job
            .when_val
            .clone()
            .unwrap_or_else(|| "on_success".to_string());

        let allow_failure = match &raw_job.allow_failure {
            Some(Value::Bool(b)) => *b,
            _ => when_val == "manual",
        };

        let parsed_rules: Option<Vec<RuleEntry>> = raw_job
            .rules
            .as_ref()
            .and_then(|v| serde_yaml::from_value(v.clone()).ok());

        // A job is "never" if it has rules and every rule sets when: never
        let rules_never = parsed_rules.as_ref().is_some_and(|rules| {
            !rules.is_empty() && rules.iter().all(|r| r.when_val.as_deref() == Some("never"))
        });

        let rules_summary: Vec<String> = parsed_rules
            .map(|rules| {
                rules
                    .iter()
                    .map(|r| {
                        let mut parts = Vec::new();
                        if let Some(ref cond) = r.condition {
                            parts.push(format!("if: {}", cond));
                        }
                        if let Some(ref w) = r.when_val {
                            parts.push(format!("when: {}", w));
                        }
                        parts.join(", ")
                    })
                    .collect()
            })
            .unwrap_or_default();

        let artifacts_paths = raw_job
            .artifacts
            .as_ref()
            .and_then(|a| a.paths.clone())
            .unwrap_or_default();

        let artifacts_exclude = raw_job
            .artifacts
            .as_ref()
            .and_then(|a| a.exclude.clone())
            .unwrap_or_default();

        let artifacts_when = raw_job
            .artifacts
            .as_ref()
            .and_then(|a| a.when_val.clone())
            .unwrap_or_else(|| "on_success".to_string());

        let job = Job {
            name: name.clone(),
            stage,
            image,
            services,
            variables,
            before_script,
            script,
            after_script,
            needs,
            tags,
            when_val,
            allow_failure,
            rules_never,
            unresolved_extends,
            rules_summary,
            artifacts_paths,
            artifacts_exclude,
            artifacts_when,
            timeout: raw_job.timeout.clone(),
            resource_group: raw_job.resource_group.clone(),
            coverage: raw_job.coverage.clone(),
            resolved_yaml,
            trigger_file: raw_job.trigger.as_ref().and_then(extract_trigger_local),
            steps: vec![],
            debug_info: JobDebugInfo {
                source: raw.source_map.get(name).cloned(),
                extends_chain: extends_chains
                    .get(name)
                    .map(|chain| {
                        chain
                            .iter()
                            .map(|tmpl| (tmpl.clone(), raw.source_map.get(tmpl).cloned()))
                            .collect()
                    })
                    .unwrap_or_default(),
                key_provenance: compute_key_provenance(
                    name,
                    &original_job_values,
                    defaults_applied
                        .get(name)
                        .map(|v| v.as_slice())
                        .unwrap_or(&[]),
                    &raw.source_map,
                ),
                variable_origins,
            },
        };

        jobs.insert(name.clone(), job);
    }

    // Step 5: Build stages
    let mut stages: Vec<Stage> = Vec::new();
    let mut used_stages: IndexMap<String, Vec<String>> = IndexMap::new();

    // Initialize with declared stages
    for stage_name in &raw.stages {
        used_stages.entry(stage_name.clone()).or_default();
    }

    // Assign jobs to stages
    for (name, job) in &jobs {
        used_stages
            .entry(job.stage.clone())
            .or_default()
            .push(name.clone());
    }

    // Only keep stages that have jobs, preserving order
    for (stage_name, job_names) in &used_stages {
        if !job_names.is_empty() {
            stages.push(Stage {
                name: stage_name.clone(),
                job_names: job_names.clone(),
            });
        }
    }

    info!("resolved {} jobs in {} stages", jobs.len(), stages.len());
    for stage in &stages {
        debug!("stage '{}': {:?}", stage.name, stage.job_names);
    }

    Ok(Pipeline { stages, jobs })
}

/// Compute key-level provenance for a job: which template (or the job itself,
/// or `default:`) contributed each top-level key. Replays the extends merge
/// precedence: later parents override earlier ones, child overrides all.
fn compute_key_provenance(
    job_name: &str,
    original_job_values: &IndexMap<String, Value>,
    defaults_applied: &[String],
    source_map: &IndexMap<String, SourceLocation>,
) -> IndexMap<String, (String, Option<SourceLocation>)> {
    let mut provenance: IndexMap<String, (String, Option<SourceLocation>)> = IndexMap::new();

    fn fill_provenance(
        name: &str,
        original_job_values: &IndexMap<String, Value>,
        source_map: &IndexMap<String, SourceLocation>,
        provenance: &mut IndexMap<String, (String, Option<SourceLocation>)>,
        visited: &mut Vec<String>,
    ) {
        if visited.contains(&name.to_string()) {
            return;
        }
        let val = match original_job_values.get(name) {
            Some(v) => v,
            None => return,
        };
        let parents = match val.get("extends") {
            Some(Value::String(s)) => vec![s.clone()],
            Some(Value::Sequence(seq)) => seq
                .iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect(),
            _ => vec![],
        };

        visited.push(name.to_string());
        // Process parents in order; later parents override earlier ones
        for parent in &parents {
            fill_provenance(parent, original_job_values, source_map, provenance, visited);
            // Parent's own keys override any grandparent attributions
            if let Some(Value::Mapping(m)) = original_job_values.get(parent.as_str()) {
                for key in m.keys() {
                    if let Some(k) = key.as_str()
                        && k != "extends"
                    {
                        provenance.insert(
                            k.to_string(),
                            (parent.clone(), source_map.get(parent.as_str()).cloned()),
                        );
                    }
                }
            }
        }
        visited.pop();
    }

    fill_provenance(
        job_name,
        original_job_values,
        source_map,
        &mut provenance,
        &mut Vec::new(),
    );

    // Job's own keys override everything
    if let Some(Value::Mapping(m)) = original_job_values.get(job_name) {
        for key in m.keys() {
            if let Some(k) = key.as_str()
                && k != "extends"
            {
                provenance.insert(
                    k.to_string(),
                    (job_name.to_string(), source_map.get(job_name).cloned()),
                );
            }
        }
    }

    // Default-applied keys
    for key in defaults_applied {
        provenance
            .entry(key.clone())
            .or_insert_with(|| ("default:".to_string(), None));
    }

    provenance
}

/// Extract the `extends` chain for every job, before resolution strips them.
/// Returns a map from job name to the ordered list of templates it extends.
fn extract_all_extends_chains(
    job_values: &IndexMap<String, Value>,
) -> IndexMap<String, Vec<String>> {
    let mut chains: IndexMap<String, Vec<String>> = IndexMap::new();

    for (name, _val) in job_values {
        let mut chain = Vec::new();
        collect_extends_chain(name, job_values, &mut chain, &mut Vec::new());
        if !chain.is_empty() {
            chains.insert(name.clone(), chain);
        }
    }

    chains
}

/// Recursively collect the extends chain for a job.
fn collect_extends_chain(
    name: &str,
    job_values: &IndexMap<String, Value>,
    chain: &mut Vec<String>,
    visited: &mut Vec<String>,
) {
    if visited.contains(&name.to_string()) {
        return; // cycle guard
    }

    let val = match job_values.get(name) {
        Some(v) => v,
        None => return,
    };

    let parents = match val.get("extends") {
        Some(Value::String(s)) => vec![s.clone()],
        Some(Value::Sequence(seq)) => seq
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect(),
        _ => return,
    };

    visited.push(name.to_string());
    for parent in &parents {
        chain.push(parent.clone());
        collect_extends_chain(parent, job_values, chain, visited);
    }
    visited.pop();
}

/// Extract the local file path from a trigger configuration.
/// Supports `trigger: { include: [{ local: 'path' }] }` and
/// `trigger: { include: { local: 'path' } }`.
fn extract_trigger_local(trigger: &Value) -> Option<String> {
    let include = trigger.get("include")?;
    match include {
        Value::Sequence(seq) => seq
            .iter()
            .find_map(|entry| entry.get("local")?.as_str().map(String::from)),
        Value::Mapping(_) => include.get("local")?.as_str().map(String::from),
        _ => None,
    }
}

/// Expand jobs with `parallel:matrix` into multiple concrete job instances.
/// Returns the expanded job map and a mapping from base job name to expanded variant names.
fn expand_matrix_jobs(
    job_values: IndexMap<String, Value>,
) -> (IndexMap<String, Value>, IndexMap<String, Vec<String>>) {
    let mut expanded: IndexMap<String, Value> = IndexMap::new();
    let mut matrix_expansions: IndexMap<String, Vec<String>> = IndexMap::new();

    for (name, mut job_val) in job_values {
        let matrix_combos = extract_matrix(&job_val);

        if matrix_combos.is_empty() {
            expanded.insert(name, job_val);
            continue;
        }

        // Remove the parallel key from the job value since we're expanding it
        if let Value::Mapping(ref mut map) = job_val {
            map.remove(Value::String("parallel".into()));
        }

        let mut variant_names = Vec::new();

        for combo in &matrix_combos {
            // Build the variant name: "job [val1, val2]"
            let suffix: Vec<&str> = combo.values().map(|v| v.as_str()).collect();
            let variant_name = format!("{} [{}]", name, suffix.join(", "));

            // Clone the job and merge matrix variables into it
            let mut variant_val = job_val.clone();
            if let Value::Mapping(ref mut map) = variant_val {
                let vars = map
                    .entry(Value::String("variables".into()))
                    .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
                if let Value::Mapping(var_map) = vars {
                    for (k, v) in combo {
                        var_map.insert(Value::String(k.clone()), Value::String(v.clone()));
                    }
                }
            }

            variant_names.push(variant_name.clone());
            expanded.insert(variant_name, variant_val);
        }

        matrix_expansions.insert(name, variant_names);
    }

    (expanded, matrix_expansions)
}

/// Extract matrix combinations from a job's `parallel:matrix` field.
/// Each combination is an ordered map of variable name -> value.
fn extract_matrix(job_val: &Value) -> Vec<IndexMap<String, String>> {
    let parallel = match job_val.get("parallel") {
        Some(v) => v,
        None => return vec![],
    };

    let matrix_entries = match parallel.get("matrix") {
        Some(Value::Sequence(seq)) => seq,
        _ => return vec![],
    };

    // Each entry in the matrix sequence is a mapping of VAR -> value-or-list.
    // Multiple values for a single var produce a cross-product within that entry.
    // Multiple entries are concatenated (union, not cross-product between entries).
    let mut all_combos = Vec::new();

    for entry in matrix_entries {
        let mapping = match entry.as_mapping() {
            Some(m) => m,
            None => continue,
        };

        // Collect each variable's possible values
        let mut var_values: Vec<(String, Vec<String>)> = Vec::new();
        for (key, val) in mapping {
            let key_str = match key.as_str() {
                Some(s) => s.to_string(),
                None => continue,
            };
            let values = match val {
                Value::String(s) => vec![s.clone()],
                Value::Number(n) => vec![n.to_string()],
                Value::Bool(b) => vec![b.to_string()],
                Value::Sequence(seq) => seq
                    .iter()
                    .filter_map(|v| match v {
                        Value::String(s) => Some(s.clone()),
                        Value::Number(n) => Some(n.to_string()),
                        Value::Bool(b) => Some(b.to_string()),
                        _ => None,
                    })
                    .collect(),
                _ => continue,
            };
            var_values.push((key_str, values));
        }

        // Compute cross-product for this entry
        let combos = cross_product(&var_values);
        all_combos.extend(combos);
    }

    all_combos
}

/// Compute the cross-product of variable values.
/// E.g., [("A", ["1","2"]), ("B", ["x","y"])] produces:
/// [{"A":"1","B":"x"}, {"A":"1","B":"y"}, {"A":"2","B":"x"}, {"A":"2","B":"y"}]
fn cross_product(var_values: &[(String, Vec<String>)]) -> Vec<IndexMap<String, String>> {
    if var_values.is_empty() {
        return vec![IndexMap::new()];
    }

    let (ref key, ref values) = var_values[0];
    let rest = cross_product(&var_values[1..]);

    let mut result = Vec::new();
    for val in values {
        for combo in &rest {
            let mut new_combo = IndexMap::new();
            new_combo.insert(key.clone(), val.clone());
            new_combo.extend(combo.clone());
            result.push(new_combo);
        }
    }
    result
}

/// Recursively remove null values from a YAML Value tree.
/// This ensures that `None` fields in the child don't block
/// inheriting values from the parent during extends resolution.
fn strip_nulls(value: &mut Value) {
    if let Value::Mapping(map) = value {
        map.retain(|_k, v| !v.is_null());
        for (_k, v) in map.iter_mut() {
            strip_nulls(v);
        }
    } else if let Value::Sequence(seq) = value {
        for item in seq.iter_mut() {
            strip_nulls(item);
        }
    }
}

/// Extract the `inherit:default` filter from a job Value.
/// Returns `InheritFilter::All` when absent or unparseable.
fn extract_inherit_default(job_val: &Value) -> InheritFilter {
    let Some(inherit) = job_val.get("inherit") else {
        return InheritFilter::All;
    };
    let Some(default) = inherit.get("default") else {
        return InheritFilter::All;
    };
    serde_yaml::from_value(default.clone()).unwrap_or_default()
}

/// Resolve `$[[ matrix.* ]]` expressions in a need's `parallel:matrix` field
/// using the current job's variables, and return the target variant name(s).
///
/// The need's parallel:matrix may contain `$[[ matrix.IDENTIFIER ]]` placeholders
/// that reference the current job's own matrix variables. After substitution,
/// each matrix entry produces a concrete variant name like `"job [val1, val2]"`.
static MATRIX_EXPR_PATTERN: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\$\[\[\s*matrix\.([\w-]+)\s*\]\]").unwrap());

fn resolve_matrix_need(
    need_job: &str,
    need_parallel: &Value,
    current_vars: &IndexMap<String, String>,
) -> Vec<String> {
    let matrix_entries = match need_parallel.get("matrix") {
        Some(Value::Sequence(seq)) => seq,
        _ => return vec![],
    };

    let re = &*MATRIX_EXPR_PATTERN;

    let mut variant_names = Vec::new();

    for entry in matrix_entries {
        let mapping = match entry.as_mapping() {
            Some(m) => m,
            None => continue,
        };

        let mut var_values: Vec<(String, Vec<String>)> = Vec::new();
        for (key, val) in mapping {
            let key_str = match key.as_str() {
                Some(s) => s.to_string(),
                None => continue,
            };
            let raw_values = match val {
                Value::String(s) => vec![s.clone()],
                Value::Number(n) => vec![n.to_string()],
                Value::Bool(b) => vec![b.to_string()],
                Value::Sequence(seq) => seq
                    .iter()
                    .filter_map(|v| match v {
                        Value::String(s) => Some(s.clone()),
                        Value::Number(n) => Some(n.to_string()),
                        Value::Bool(b) => Some(b.to_string()),
                        _ => None,
                    })
                    .collect(),
                _ => continue,
            };
            let resolved: Vec<String> = raw_values
                .iter()
                .map(|v| {
                    re.replace_all(v, |caps: &regex::Captures| {
                        let ident = &caps[1];
                        current_vars
                            .get(ident)
                            .cloned()
                            .unwrap_or_else(|| caps[0].to_string())
                    })
                    .to_string()
                })
                .collect();
            var_values.push((key_str, resolved));
        }

        let combos = cross_product(&var_values);
        for combo in combos {
            let suffix: Vec<&str> = combo.values().map(|v| v.as_str()).collect();
            variant_names.push(format!("{} [{}]", need_job, suffix.join(", ")));
        }
    }

    variant_names
}

/// Replace `${{ expr }}` in a script line with a shell command substitution
/// that calls `cui-cui moa` at runtime inside the container.
fn moa_shell_expand(
    line: &str,
    inputs: Option<&IndexMap<String, serde_yaml::Value>>,
    variables: &IndexMap<String, String>,
    step_name: &str,
) -> String {
    if !line.contains("${{") {
        return line.to_string();
    }
    let mut result = line.to_string();
    let mut search_pos = 0;
    let outputs_dir = "/tmp/ci-staging/step-outputs";
    let output_file = format!("{}/{}", outputs_dir, step_name);
    let export_file = format!("/tmp/ci-staging/step-exports/{}", step_name);
    while search_pos < result.len() {
        if let Some(offset) = result[search_pos..].find("${{") {
            let start = search_pos + offset;
            if start > 0 && result.as_bytes()[start - 1] == b'\\' {
                result.replace_range(start - 1..start, "");
                search_pos = start + 2;
                continue;
            }
            if let Some(end_offset) = result[start..].find("}}") {
                let end = start + end_offset + 2;
                let expr = result[start + 3..start + end_offset].trim();
                let mut cmd = format!(
                    "\"$(/tmp/ci-staging/cui-cui moa '{}'",
                    expr.replace('\'', "'\\''")
                );
                if let Some(inp) = inputs {
                    let json = inputs_to_json(inp);
                    cmd.push_str(&format!(" --inputs-json '{}'", json.replace('\'', "'\\''")));
                }
                if !variables.is_empty() {
                    let json = vars_to_json(variables);
                    cmd.push_str(&format!(" --vars-json '{}'", json.replace('\'', "'\\''")));
                }
                cmd.push_str(&format!(
                    " --step-outputs-dir '{}' --output-file '{}' --export-file '{}'",
                    outputs_dir, output_file, export_file
                ));
                cmd.push_str(")\"");
                result.replace_range(start..end, &cmd);
                search_pos = start + cmd.len();
            } else {
                break;
            }
        } else {
            break;
        }
    }
    result
}

fn inputs_to_json(inputs: &IndexMap<String, serde_yaml::Value>) -> String {
    let map: serde_json::Map<String, serde_json::Value> = inputs
        .iter()
        .map(|(k, v)| {
            let s = match v {
                serde_yaml::Value::String(s) => s.clone(),
                other => serde_yaml::to_string(other)
                    .unwrap_or_default()
                    .trim()
                    .to_string(),
            };
            (k.clone(), serde_json::Value::String(s))
        })
        .collect();
    serde_json::Value::Object(map).to_string()
}

fn vars_to_json(vars: &IndexMap<String, String>) -> String {
    let map: serde_json::Map<String, serde_json::Value> = vars
        .iter()
        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
        .collect();
    serde_json::Value::Object(map).to_string()
}

/// Convert `run:` steps into script lines for display and execution.
/// `script:` steps become executable shell commands; local `func:` references
/// are resolved by reading `func.yml` and inlining the exec command;
/// unresolvable `func:` steps become descriptive comments.
fn convert_run_steps(
    steps: &[RunStep],
    project_dir: Option<&std::path::Path>,
    variables: &IndexMap<String, String>,
) -> Vec<String> {
    let mut lines = Vec::new();
    let has_moa = steps
        .iter()
        .any(|s| s.script.as_deref().is_some_and(|sc| sc.contains("${{")) || s.func.is_some());
    if has_moa {
        lines
            .push("mkdir -p /tmp/ci-staging/step-outputs /tmp/ci-staging/step-exports".to_string());
    }
    for step in steps {
        if let Some(ref script) = step.script {
            lines.push(format!("# step: {}", step.name));
            if let Some(ref env) = step.env {
                for (k, v) in env {
                    lines.push(format!("export {}={}", k, shell_quote(v.value())));
                }
            }
            lines.push(moa_shell_expand(
                script,
                step.inputs.as_ref(),
                variables,
                &step.name,
            ));
        } else if let Some(ref func) = step.func {
            if let Some(resolved) =
                resolve_local_func(func, project_dir).or_else(|| resolve_oci_func(func))
            {
                lines.push(format!("# step: {} (func: {})", step.name, func));
                if let Some(ref env) = step.env {
                    for (k, v) in env {
                        lines.push(format!("export {}={}", k, shell_quote(v.value())));
                    }
                }
                for cmd in resolved {
                    lines.push(moa_shell_expand(
                        &cmd,
                        step.inputs.as_ref(),
                        variables,
                        &step.name,
                    ));
                }
            } else {
                lines.push(format!("# step: {} (func: {})", step.name, func));
                if let Some(ref env) = step.env {
                    for (k, v) in env {
                        lines.push(format!("#   env: {}={}", k, v.value()));
                    }
                }
                if let Some(ref inputs) = step.inputs {
                    for (k, v) in inputs {
                        lines.push(format!("#   input: {}={}", k, value_to_string(v)));
                    }
                }
                lines.push(format!(
                    "echo \"TODO: func '{}' not yet supported for local execution\"",
                    func
                ));
            }
        } else {
            lines.push(format!("# step: {} (no script or func)", step.name));
        }
    }
    lines
}

/// Try to resolve a local filesystem func reference (starting with `./` or `/`).
/// Reads `func.yml` from the function directory, parses the definition,
/// and returns script lines for `exec:` commands or `None` if unresolvable.
fn resolve_local_func(
    func_ref: &str,
    project_dir: Option<&std::path::Path>,
) -> Option<Vec<String>> {
    if !func_ref.starts_with("./") && !func_ref.starts_with('/') {
        return None;
    }
    let project_dir = project_dir?;

    let (func_dir, func_file) = if func_ref.ends_with(".yml") || func_ref.ends_with(".yaml") {
        let path = std::path::Path::new(func_ref);
        (
            path.parent().unwrap_or(std::path::Path::new(".")),
            path.file_name()
                .unwrap_or_default()
                .to_str()
                .unwrap_or("func.yml"),
        )
    } else {
        (std::path::Path::new(func_ref), "func.yml")
    };

    let full_path = project_dir.join(func_dir).join(func_file);
    if full_path
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        debug!("func ref '{}' contains path traversal, skipping", func_ref);
        return None;
    }
    let content = match std::fs::read_to_string(&full_path) {
        Ok(c) => c,
        Err(e) => {
            debug!(
                "could not read func definition '{}': {}",
                full_path.display(),
                e
            );
            return None;
        }
    };

    parse_func_definition(&content)
}

/// Try to resolve an OCI registry func reference.
/// Fetches the OCI image, extracts `func.yml`, and parses it.
/// Returns `None` if the reference is not OCI or fetching fails.
fn resolve_oci_func(func_ref: &str) -> Option<Vec<String>> {
    let oci_ref = parse_oci_func_ref(func_ref)?;
    match fetch_oci_func_yml(&oci_ref) {
        Ok(content) => parse_func_definition(&content),
        Err(e) => {
            debug!("could not fetch OCI func '{}': {}", func_ref, e);
            None
        }
    }
}

/// Parse a `func.yml` file and extract executable script lines.
/// The file has two YAML documents separated by `---`:
/// 1. Spec (inputs/outputs) — used for validation but not execution
/// 2. Definition — either `exec: { command: [...] }` or `steps: [...]`
fn parse_func_definition(content: &str) -> Option<Vec<String>> {
    let body = if let Some(pos) = content.find("\n---\n") {
        &content[pos + 5..]
    } else if let Some(pos) = content.find("\n---") {
        &content[pos + 4..]
    } else {
        content
    };

    let def: Value = serde_yaml::from_str(body).ok()?;

    if let Some(exec) = def.get("exec")
        && let Some(Value::Sequence(cmd_parts)) = exec.get("command")
    {
        let cmd: Vec<String> = cmd_parts.iter().map(value_to_string).collect();
        if cmd.is_empty() {
            return None;
        }
        // If the command is `bash -c <script>`, extract just the script part
        let script = if cmd.len() >= 3 && cmd[0] == "bash" && cmd[1] == "-c" {
            cmd[2..].join(" ")
        } else {
            cmd.join(" ")
        };
        return Some(vec![script]);
    }

    if let Some(Value::Sequence(sub_steps)) = def.get("steps") {
        let mut lines = Vec::new();
        for step_val in sub_steps {
            if let Some(name) = step_val.get("name").and_then(|v| v.as_str()) {
                lines.push(format!("# sub-step: {}", name));
            }
            if let Some(script) = step_val.get("script").and_then(|v| v.as_str()) {
                lines.push(script.to_string());
            } else if let Some(func) = step_val.get("func").and_then(|v| v.as_str()) {
                lines.push(format!(
                    "echo \"TODO: nested func '{}' not yet supported\"",
                    func
                ));
            }
        }
        if !lines.is_empty() {
            return Some(lines);
        }
    }

    None
}

/// Return a copy of `mapping` containing only keys present in `allowed`.
fn filter_mapping(mapping: &Value, allowed: &[String]) -> Value {
    match mapping {
        Value::Mapping(m) => {
            let filtered: serde_yaml::Mapping = m
                .iter()
                .filter(|(k, _)| k.as_str().is_some_and(|s| allowed.iter().any(|a| a == s)))
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect();
            Value::Mapping(filtered)
        }
        other => other.clone(),
    }
}

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

    fn yaml(s: &str) -> Value {
        serde_yaml::from_str(s).unwrap()
    }

    #[test]
    fn test_extract_matrix_single_var() {
        let job = yaml(
            r#"
            script: echo hello
            parallel:
              matrix:
                - KERNEL_VERSION: "6.12"
                - KERNEL_VERSION: "6.6"
            "#,
        );
        let combos = extract_matrix(&job);
        assert_eq!(combos.len(), 2);
        assert_eq!(combos[0]["KERNEL_VERSION"], "6.12");
        assert_eq!(combos[1]["KERNEL_VERSION"], "6.6");
    }

    #[test]
    fn test_extract_matrix_cross_product() {
        let job = yaml(
            r#"
            script: echo hello
            parallel:
              matrix:
                - OS: [debian, fedora]
                  ARCH: [x86, arm]
            "#,
        );
        let combos = extract_matrix(&job);
        assert_eq!(combos.len(), 4);
        assert_eq!(combos[0]["OS"], "debian");
        assert_eq!(combos[0]["ARCH"], "x86");
        assert_eq!(combos[1]["OS"], "debian");
        assert_eq!(combos[1]["ARCH"], "arm");
        assert_eq!(combos[2]["OS"], "fedora");
        assert_eq!(combos[2]["ARCH"], "x86");
        assert_eq!(combos[3]["OS"], "fedora");
        assert_eq!(combos[3]["ARCH"], "arm");
    }

    #[test]
    fn test_extract_matrix_multiple_entries() {
        // Multiple entries are concatenated, not cross-producted
        let job = yaml(
            r#"
            script: echo hello
            parallel:
              matrix:
                - FILTER: trace
                - FILTER: Xbox
                - FILTER: "trace,Xbox"
            "#,
        );
        let combos = extract_matrix(&job);
        assert_eq!(combos.len(), 3);
        assert_eq!(combos[0]["FILTER"], "trace");
        assert_eq!(combos[1]["FILTER"], "Xbox");
        assert_eq!(combos[2]["FILTER"], "trace,Xbox");
    }

    #[test]
    fn test_extract_matrix_no_parallel() {
        let job = yaml("script: echo hello");
        let combos = extract_matrix(&job);
        assert!(combos.is_empty());
    }

    #[test]
    fn test_expand_matrix_jobs_naming() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            "rebuild kernel".to_string(),
            yaml(
                r#"
                script: make
                stage: build
                parallel:
                  matrix:
                    - KERNEL_VERSION: "6.12"
                    - KERNEL_VERSION: "6.6"
                "#,
            ),
        );
        jobs.insert(
            "simple job".to_string(),
            yaml(
                r#"
                script: echo hi
                stage: build
                "#,
            ),
        );

        let (expanded, expansions) = expand_matrix_jobs(jobs);

        // simple job is untouched
        assert!(expanded.contains_key("simple job"));
        // matrix job is expanded into 2 variants
        assert!(!expanded.contains_key("rebuild kernel"));
        assert!(expanded.contains_key("rebuild kernel [6.12]"));
        assert!(expanded.contains_key("rebuild kernel [6.6]"));

        // expansions map tracks the base name
        assert_eq!(
            expansions["rebuild kernel"],
            vec!["rebuild kernel [6.12]", "rebuild kernel [6.6]"]
        );
    }

    #[test]
    fn test_expand_matrix_jobs_variables_injected() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            "test".to_string(),
            yaml(
                r#"
                script: echo $A
                parallel:
                  matrix:
                    - A: val1
                "#,
            ),
        );

        let (expanded, _) = expand_matrix_jobs(jobs);
        let variant = &expanded["test [val1]"];
        let vars = variant.get("variables").unwrap().as_mapping().unwrap();
        assert_eq!(
            vars.get(Value::String("A".into())).unwrap().as_str(),
            Some("val1")
        );
        // parallel key should be removed
        assert!(variant.get("parallel").is_none());
    }

    #[test]
    fn test_cross_product_empty() {
        let result = cross_product(&[]);
        assert_eq!(result.len(), 1);
        assert!(result[0].is_empty());
    }

    #[test]
    fn test_cross_product_single() {
        let vars = vec![("A".to_string(), vec!["1".to_string(), "2".to_string()])];
        let result = cross_product(&vars);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0]["A"], "1");
        assert_eq!(result[1]["A"], "2");
    }

    #[test]
    fn test_resolve_pipeline_with_matrix() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: build
                        script: ["make"]
                        parallel:
                          matrix:
                            - ARCH: x86
                            - ARCH: arm
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: ["make test"]
                        needs: ["build"]
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();

        // build should be expanded
        assert!(pipeline.jobs.contains_key("build [x86]"));
        assert!(pipeline.jobs.contains_key("build [arm]"));
        assert!(!pipeline.jobs.contains_key("build"));

        // test should need both build variants
        let test_job = &pipeline.jobs["test"];
        let need_names = test_job.need_names();
        assert!(need_names.contains(&"build [x86]"));
        assert!(need_names.contains(&"build [arm]"));
        assert_eq!(test_job.needs.len(), 2);

        // build variants should be in the build stage
        assert_eq!(pipeline.jobs["build [x86]"].stage, "build");
        assert_eq!(pipeline.jobs["build [arm]"].stage, "build");
    }

    #[test]
    fn test_matrix_with_extends() {
        // A matrix job that extends a template should inherit the template's
        // keys and then be expanded into variants.
        let raw = RawPipeline {
            stages: vec!["build".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    ".build_template".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        image: rust:latest
                        before_script: ["cargo fetch"]
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        extends: .build_template
                        stage: build
                        script: ["cargo build"]
                        parallel:
                          matrix:
                            - TARGET: x86_64
                            - TARGET: aarch64
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();

        // Template is hidden, not in final pipeline
        assert!(!pipeline.jobs.contains_key(".build_template"));
        assert!(!pipeline.jobs.contains_key("build"));

        // Matrix variants exist
        assert!(pipeline.jobs.contains_key("build [x86_64]"));
        assert!(pipeline.jobs.contains_key("build [aarch64]"));

        // Each variant inherited image from the template
        assert_eq!(
            pipeline.jobs["build [x86_64]"].image.as_deref(),
            Some("rust:latest")
        );
        assert_eq!(
            pipeline.jobs["build [aarch64]"].image.as_deref(),
            Some("rust:latest")
        );

        // Each variant has the matrix variable injected
        assert_eq!(
            pipeline.jobs["build [x86_64]"].variables.get("TARGET"),
            Some(&"x86_64".to_string())
        );
        assert_eq!(
            pipeline.jobs["build [aarch64]"].variables.get("TARGET"),
            Some(&"aarch64".to_string())
        );

        // Variants are in the build stage
        assert_eq!(pipeline.jobs["build [x86_64]"].stage, "build");
        assert_eq!(pipeline.jobs["build [aarch64]"].stage, "build");
    }

    #[test]
    fn test_extract_trigger_local_sequence() {
        let trigger = yaml(
            r#"
            include:
              - local: '/.gitlab-ci/child.yml'
            "#,
        );
        assert_eq!(
            extract_trigger_local(&trigger),
            Some("/.gitlab-ci/child.yml".to_string())
        );
    }

    #[test]
    fn test_extract_trigger_local_mapping() {
        let trigger = yaml(
            r#"
            include:
              local: '/.gitlab-ci/child.yml'
            "#,
        );
        assert_eq!(
            extract_trigger_local(&trigger),
            Some("/.gitlab-ci/child.yml".to_string())
        );
    }

    #[test]
    fn test_extract_trigger_local_no_include() {
        let trigger = yaml("strategy: depend");
        assert_eq!(extract_trigger_local(&trigger), None);
    }

    #[test]
    fn test_key_provenance_simple_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            ".base".to_string(),
            yaml("image: alpine\nbefore_script: [setup]"),
        );
        jobs.insert(
            "job".to_string(),
            yaml("extends: .base\nscript: [make]\nstage: build"),
        );

        let prov = compute_key_provenance("job", &jobs, &[], &IndexMap::new());
        // image and before_script come from .base
        assert_eq!(prov["image"].0, ".base");
        assert_eq!(prov["before_script"].0, ".base");
        // script and stage come from the job itself
        assert_eq!(prov["script"].0, "job");
        assert_eq!(prov["stage"].0, "job");
    }

    #[test]
    fn test_key_provenance_child_overrides_parent() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".base".to_string(), yaml("image: alpine\nstage: test"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: .base\nimage: rust:latest\nscript: [make]"),
        );

        let prov = compute_key_provenance("job", &jobs, &[], &IndexMap::new());
        // image is overridden by job
        assert_eq!(prov["image"].0, "job");
        // stage inherited from .base
        assert_eq!(prov["stage"].0, ".base");
    }

    #[test]
    fn test_key_provenance_chain() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            ".grandparent".to_string(),
            yaml("image: alpine\ntags: [shared]"),
        );
        jobs.insert(
            ".parent".to_string(),
            yaml("extends: .grandparent\nstage: test"),
        );
        jobs.insert("job".to_string(), yaml("extends: .parent\nscript: [make]"));

        let prov = compute_key_provenance("job", &jobs, &[], &IndexMap::new());
        assert_eq!(prov["image"].0, ".grandparent");
        assert_eq!(prov["tags"].0, ".grandparent");
        assert_eq!(prov["stage"].0, ".parent");
        assert_eq!(prov["script"].0, "job");
    }

    #[test]
    fn test_key_provenance_multiple_parents() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".img".to_string(), yaml("image: rust:latest"));
        jobs.insert(".policy".to_string(), yaml("image: alpine\nretry: 2"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: [.img, .policy]\nscript: [make]"),
        );

        let prov = compute_key_provenance("job", &jobs, &[], &IndexMap::new());
        // .policy is later in the list, higher precedence → wins for image
        assert_eq!(prov["image"].0, ".policy");
        assert_eq!(prov["retry"].0, ".policy");
        assert_eq!(prov["script"].0, "job");
    }

    #[test]
    fn test_key_provenance_defaults() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("script: [make]"));

        let prov = compute_key_provenance(
            "job",
            &jobs,
            &["after_script".to_string(), "cache".to_string()],
            &IndexMap::new(),
        );
        assert_eq!(prov["script"].0, "job");
        assert_eq!(prov["after_script"].0, "default:");
        assert_eq!(prov["cache"].0, "default:");
    }

    #[test]
    fn test_extract_trigger_local_no_local() {
        let trigger = yaml(
            r#"
            include:
              - project: 'group/repo'
                file: '/ci.yml'
            "#,
        );
        assert_eq!(extract_trigger_local(&trigger), None);
    }

    #[test]
    fn test_predefined_vars_not_expanded_in_scripts() {
        let raw = RawPipeline {
            stages: vec!["test".to_string()],
            variables: {
                let mut v = IndexMap::new();
                v.insert("MY_VAR".to_string(), VarValue::Simple("hello".to_string()));
                v
            },
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "check".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script:
                          - echo $MY_VAR
                          - echo $CI_COMMIT_SHA
                          - MERGE=$(git rev-list --count $CI_COMMIT_SHA)
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["check"];

        // User-defined variable should be expanded
        assert_eq!(job.script[0], "echo hello");
        // Predefined CI variable should NOT be expanded (shell handles it)
        assert_eq!(job.script[1], "echo $CI_COMMIT_SHA");
        // Angle-bracket placeholder must not appear in script lines
        assert_eq!(
            job.script[2],
            "MERGE=$(git rev-list --count $CI_COMMIT_SHA)"
        );
    }

    fn make_raw_pipeline_with_inherit(inherit_yaml: &str) -> RawPipeline {
        RawPipeline {
            stages: vec!["test".to_string()],
            variables: {
                let mut v = IndexMap::new();
                v.insert("VAR1".to_string(), VarValue::Simple("val1".to_string()));
                v.insert("VAR2".to_string(), VarValue::Simple("val2".to_string()));
                v.insert("VAR3".to_string(), VarValue::Simple("val3".to_string()));
                v
            },
            default: Some(serde_yaml::from_str("image: ruby:3.0\ntags: [docker]").unwrap()),
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "job".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(&format!(
                        "stage: test\nscript: [echo hi]\n{}",
                        inherit_yaml
                    ))
                    .unwrap(),
                );
                jobs
            },
        }
    }

    #[test]
    fn test_inherit_default_false() {
        let raw = make_raw_pipeline_with_inherit("inherit:\n  default: false");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert!(job.image.is_none(), "should not inherit default image");
        assert!(job.tags.is_empty(), "should not inherit default tags");
        // global variables should still be inherited
        assert_eq!(job.variables.get("VAR1"), Some(&"val1".to_string()));
    }

    #[test]
    fn test_inherit_default_listed() {
        let raw = make_raw_pipeline_with_inherit("inherit:\n  default: [image]");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert_eq!(
            job.image.as_deref(),
            Some("ruby:3.0"),
            "should inherit image"
        );
        assert!(job.tags.is_empty(), "should NOT inherit tags");
    }

    #[test]
    fn test_inherit_variables_false() {
        let raw = make_raw_pipeline_with_inherit("inherit:\n  variables: false");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert!(
            job.variables.get("VAR1").is_none(),
            "should not inherit VAR1"
        );
        assert!(
            job.variables.get("VAR2").is_none(),
            "should not inherit VAR2"
        );
        // defaults should still be inherited
        assert_eq!(job.image.as_deref(), Some("ruby:3.0"));
    }

    #[test]
    fn test_inherit_variables_listed() {
        let raw = make_raw_pipeline_with_inherit("inherit:\n  variables: [VAR1]");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert_eq!(
            job.variables.get("VAR1"),
            Some(&"val1".to_string()),
            "should inherit VAR1"
        );
        assert!(
            job.variables.get("VAR2").is_none(),
            "should NOT inherit VAR2"
        );
        assert!(
            job.variables.get("VAR3").is_none(),
            "should NOT inherit VAR3"
        );
    }

    #[test]
    fn test_inherit_default_and_variables() {
        let raw = make_raw_pipeline_with_inherit("inherit:\n  default: false\n  variables: [VAR2]");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert!(job.image.is_none(), "should not inherit default image");
        assert!(job.tags.is_empty(), "should not inherit default tags");
        assert!(
            job.variables.get("VAR1").is_none(),
            "should NOT inherit VAR1"
        );
        assert_eq!(
            job.variables.get("VAR2"),
            Some(&"val2".to_string()),
            "should inherit VAR2"
        );
    }

    #[test]
    fn test_inherit_absent_inherits_everything() {
        let raw = make_raw_pipeline_with_inherit("");
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["job"];
        assert_eq!(job.image.as_deref(), Some("ruby:3.0"));
        assert_eq!(job.variables.get("VAR1"), Some(&"val1".to_string()));
        assert_eq!(job.variables.get("VAR2"), Some(&"val2".to_string()));
        assert_eq!(job.variables.get("VAR3"), Some(&"val3".to_string()));
    }

    #[test]
    fn test_needs_optional_missing_job_dropped() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>("stage: build\nscript: [make]").unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: [make test]
                        needs:
                          - job: build
                          - job: lint
                            optional: true
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let test_job = &pipeline.jobs["test"];
        let need_names = test_job.need_names();
        assert_eq!(need_names, vec!["build"]);
    }

    #[test]
    fn test_needs_optional_existing_job_kept() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>("stage: build\nscript: [make]").unwrap(),
                );
                jobs.insert(
                    "lint".to_string(),
                    serde_yaml::from_str::<RawJobConfig>("stage: build\nscript: [make lint]")
                        .unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: [make test]
                        needs:
                          - job: build
                          - job: lint
                            optional: true
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let test_job = &pipeline.jobs["test"];
        let need_names = test_job.need_names();
        assert!(need_names.contains(&"build"));
        assert!(need_names.contains(&"lint"));
    }

    #[test]
    fn test_matrix_expression_in_needs_1to1() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: build
                        script: [make]
                        parallel:
                          matrix:
                            - OS: [ubuntu, alpine]
                              ARCH: [amd64, arm64]
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: [make test]
                        parallel:
                          matrix:
                            - OS: [ubuntu, alpine]
                              ARCH: [amd64, arm64]
                        needs:
                          - job: build
                            parallel:
                              matrix:
                                - OS: ['$[[ matrix.OS ]]']
                                  ARCH: ['$[[ matrix.ARCH ]]']
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();

        // Each test variant should depend on exactly one build variant
        let test_ubuntu_amd64 = &pipeline.jobs["test [ubuntu, amd64]"];
        assert_eq!(test_ubuntu_amd64.needs.len(), 1);
        assert_eq!(
            test_ubuntu_amd64.need_names(),
            vec!["build [ubuntu, amd64]"]
        );

        let test_alpine_arm64 = &pipeline.jobs["test [alpine, arm64]"];
        assert_eq!(test_alpine_arm64.needs.len(), 1);
        assert_eq!(
            test_alpine_arm64.need_names(),
            vec!["build [alpine, arm64]"]
        );

        let test_ubuntu_arm64 = &pipeline.jobs["test [ubuntu, arm64]"];
        assert_eq!(test_ubuntu_arm64.needs.len(), 1);
        assert_eq!(
            test_ubuntu_arm64.need_names(),
            vec!["build [ubuntu, arm64]"]
        );

        let test_alpine_amd64 = &pipeline.jobs["test [alpine, amd64]"];
        assert_eq!(test_alpine_amd64.needs.len(), 1);
        assert_eq!(
            test_alpine_amd64.need_names(),
            vec!["build [alpine, amd64]"]
        );
    }

    #[test]
    fn test_matrix_expression_subset() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: build
                        script: [make]
                        parallel:
                          matrix:
                            - PLATFORM: [linux, windows, macos]
                              VERSION: ["16", "18", "20"]
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: [make test]
                        parallel:
                          matrix:
                            - PLATFORM: [linux, windows, macos]
                        needs:
                          - job: build
                            parallel:
                              matrix:
                                - PLATFORM: ['$[[ matrix.PLATFORM ]]']
                                  VERSION: ["18"]
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();

        // Each test variant depends only on the VERSION=18 build variant
        let test_linux = &pipeline.jobs["test [linux]"];
        assert_eq!(test_linux.needs.len(), 1);
        assert_eq!(test_linux.need_names(), vec!["build [linux, 18]"]);

        let test_windows = &pipeline.jobs["test [windows]"];
        assert_eq!(test_windows.needs.len(), 1);
        assert_eq!(test_windows.need_names(), vec!["build [windows, 18]"]);

        let test_macos = &pipeline.jobs["test [macos]"];
        assert_eq!(test_macos.needs.len(), 1);
        assert_eq!(test_macos.need_names(), vec!["build [macos, 18]"]);
    }

    #[test]
    fn test_matrix_expression_mixed_needs() {
        let raw = RawPipeline {
            stages: vec!["build".to_string(), "test".to_string()],
            variables: IndexMap::new(),
            default: None,
            workflow: None,
            source_map: IndexMap::new(),
            github_context: std::collections::HashMap::new(),
            jobs: {
                let mut jobs = IndexMap::new();
                jobs.insert(
                    "build".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: build
                        script: [make]
                        parallel:
                          matrix:
                            - ARCH: [x86, arm]
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "setup".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: build
                        script: [setup.sh]
                        "#,
                    )
                    .unwrap(),
                );
                jobs.insert(
                    "test".to_string(),
                    serde_yaml::from_str::<RawJobConfig>(
                        r#"
                        stage: test
                        script: [make test]
                        parallel:
                          matrix:
                            - ARCH: [x86, arm]
                        needs:
                          - job: build
                            parallel:
                              matrix:
                                - ARCH: ['$[[ matrix.ARCH ]]']
                          - setup
                        "#,
                    )
                    .unwrap(),
                );
                jobs
            },
        };

        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();

        let test_x86 = &pipeline.jobs["test [x86]"];
        let names = test_x86.need_names();
        assert_eq!(names.len(), 2);
        assert!(names.contains(&"build [x86]"));
        assert!(names.contains(&"setup"));

        let test_arm = &pipeline.jobs["test [arm]"];
        let names = test_arm.need_names();
        assert_eq!(names.len(), 2);
        assert!(names.contains(&"build [arm]"));
        assert!(names.contains(&"setup"));
    }

    #[test]
    fn test_resolve_matrix_need_helper() {
        let need_parallel = yaml(
            r#"
            matrix:
              - OS: ['$[[ matrix.OS ]]']
                ARCH: ['$[[ matrix.ARCH ]]']
            "#,
        );
        let mut vars = IndexMap::new();
        vars.insert("OS".to_string(), "ubuntu".to_string());
        vars.insert("ARCH".to_string(), "amd64".to_string());

        let result = resolve_matrix_need("build", &need_parallel, &vars);
        assert_eq!(result, vec!["build [ubuntu, amd64]"]);
    }

    #[test]
    fn test_resolve_matrix_need_fixed_value() {
        let need_parallel = yaml(
            r#"
            matrix:
              - PLATFORM: ['$[[ matrix.PLATFORM ]]']
                VERSION: ["18"]
            "#,
        );
        let mut vars = IndexMap::new();
        vars.insert("PLATFORM".to_string(), "linux".to_string());

        let result = resolve_matrix_need("build", &need_parallel, &vars);
        assert_eq!(result, vec!["build [linux, 18]"]);
    }

    #[test]
    fn test_resolve_pipeline_with_run() {
        let raw = RawPipeline {
            stages: vec!["deploy".to_string()],
            jobs: {
                let mut m = IndexMap::new();
                let job: RawJobConfig = serde_yaml::from_str(
                    r#"
                    stage: deploy
                    run:
                      - name: setup
                        script: echo "setting up"
                        env:
                          TARGET: prod
                      - name: deploy_func
                        func: registry.gitlab.com/org/deploy:1.0
                        inputs:
                          environment: production
                    "#,
                )
                .unwrap();
                m.insert("deploy-job".to_string(), job);
                m
            },
            ..Default::default()
        };
        let git = GitContext::default();
        let pipeline = resolve_pipeline(raw, &git).unwrap();
        let job = &pipeline.jobs["deploy-job"];
        assert!(job.before_script.is_empty());
        assert!(job.after_script.is_empty());
        let script = job.script.join("\n");
        assert!(script.contains("# step: setup"));
        assert!(script.contains("echo \"setting up\""));
        assert!(script.contains("export TARGET="));
        assert!(script.contains("# step: deploy_func (func:"));
        assert!(script.contains("TODO"));
    }

    #[test]
    fn test_convert_run_steps_script() {
        let steps = vec![RunStep {
            name: "greet".to_string(),
            script: Some("echo hello".to_string()),
            func: None,
            inputs: None,
            env: None,
        }];
        let lines = convert_run_steps(&steps, None, &IndexMap::new());
        assert_eq!(lines[0], "# step: greet");
        assert_eq!(lines[1], "echo hello");
    }

    #[test]
    fn test_convert_run_steps_func() {
        let mut inputs = IndexMap::new();
        inputs.insert("env".to_string(), Value::String("prod".into()));
        let steps = vec![RunStep {
            name: "deploy".to_string(),
            script: None,
            func: Some("registry.gitlab.com/org/deploy:1.0".to_string()),
            inputs: Some(inputs),
            env: None,
        }];
        let lines = convert_run_steps(&steps, None, &IndexMap::new());
        assert!(lines[0].contains("mkdir -p"));
        assert!(lines[1].contains("# step: deploy (func:"));
        assert!(lines[2].contains("input: env=prod"));
        assert!(lines[3].contains("TODO"));
    }

    #[test]
    fn test_convert_run_steps_with_env() {
        let mut env = IndexMap::new();
        env.insert("MY_VAR".to_string(), VarValue::Simple("value".to_string()));
        let steps = vec![RunStep {
            name: "build".to_string(),
            script: Some("make build".to_string()),
            func: None,
            inputs: None,
            env: Some(env),
        }];
        let lines = convert_run_steps(&steps, None, &IndexMap::new());
        assert_eq!(lines[0], "# step: build");
        assert!(lines[1].starts_with("export MY_VAR="));
        assert_eq!(lines[2], "make build");
    }

    #[test]
    fn test_parse_func_definition_exec() {
        let content = r#"
spec:
  inputs:
    who:
      type: string
      default: world
---
exec:
  command:
    - bash
    - -c
    - echo 'hello ${{inputs.who}}'
"#;
        let lines = parse_func_definition(content).unwrap();
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains("hello ${{inputs.who}}"));
    }

    #[test]
    fn test_parse_func_definition_exec_no_bash() {
        let content = "---\nexec:\n  command: [echo, hello]\n";
        let lines = parse_func_definition(content).unwrap();
        assert_eq!(lines, vec!["echo hello"]);
    }

    #[test]
    fn test_parse_func_definition_steps() {
        let content = r#"
spec: {}
---
steps:
  - name: greet
    script: echo hello
  - name: deploy
    func: ./other-func
"#;
        let lines = parse_func_definition(content).unwrap();
        assert!(lines.iter().any(|l| l.contains("# sub-step: greet")));
        assert!(lines.iter().any(|l| l.contains("echo hello")));
        assert!(lines.iter().any(|l| l.contains("TODO: nested func")));
    }

    #[test]
    fn test_parse_func_definition_invalid() {
        assert!(parse_func_definition("not valid yaml: [").is_none());
    }

    #[test]
    fn test_resolve_local_func_reads_file() {
        let dir = tempfile::tempdir().unwrap();
        let func_dir = dir.path().join("my-func");
        std::fs::create_dir_all(&func_dir).unwrap();
        std::fs::write(
            func_dir.join("func.yml"),
            "spec: {}\n---\nexec:\n  command: [echo, hello]\n",
        )
        .unwrap();
        let lines = resolve_local_func("./my-func", Some(dir.path())).unwrap();
        assert_eq!(lines, vec!["echo hello"]);
    }

    #[test]
    fn test_resolve_local_func_custom_filename() {
        let dir = tempfile::tempdir().unwrap();
        let func_dir = dir.path().join("funcs");
        std::fs::create_dir_all(&func_dir).unwrap();
        std::fs::write(
            func_dir.join("deploy.yml"),
            "spec: {}\n---\nexec:\n  command: [make, deploy]\n",
        )
        .unwrap();
        let lines = resolve_local_func("./funcs/deploy.yml", Some(dir.path())).unwrap();
        assert_eq!(lines, vec!["make deploy"]);
    }

    #[test]
    fn test_resolve_local_func_not_local() {
        assert!(resolve_local_func("registry.gitlab.com/org/func:1.0", None).is_none());
    }

    #[test]
    fn test_resolve_local_func_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        assert!(resolve_local_func("./nonexistent", Some(dir.path())).is_none());
    }

    #[test]
    fn test_convert_run_steps_local_func() {
        let dir = tempfile::tempdir().unwrap();
        let func_dir = dir.path().join("deploy");
        std::fs::create_dir_all(&func_dir).unwrap();
        std::fs::write(
            func_dir.join("func.yml"),
            "spec:\n  inputs:\n    env:\n      type: string\n---\nexec:\n  command:\n    - bash\n    - -c\n    - echo deploy to ${{inputs.env}}\n",
        )
        .unwrap();
        let mut inputs = IndexMap::new();
        inputs.insert("env".to_string(), Value::String("staging".into()));
        let steps = vec![RunStep {
            name: "deploy".to_string(),
            script: None,
            func: Some("./deploy".to_string()),
            inputs: Some(inputs),
            env: None,
        }];
        let lines = convert_run_steps(&steps, Some(dir.path()), &IndexMap::new());
        let joined = lines.join("\n");
        assert!(joined.contains("# step: deploy (func: ./deploy)"));
        // ${{inputs.env}} is replaced by a shell command substitution calling cui-cui moa
        assert!(joined.contains("/tmp/ci-staging/cui-cui moa"));
        assert!(joined.contains("inputs.env"));
        assert!(joined.contains("--inputs-json"));
        assert!(!joined.contains("TODO"));
    }

    #[test]
    fn test_moa_shell_expand_basic() {
        let mut inputs = IndexMap::new();
        inputs.insert(
            "name".to_string(),
            serde_yaml::Value::String("world".to_string()),
        );
        let vars = IndexMap::new();
        let result = moa_shell_expand("echo ${{inputs.name}}", Some(&inputs), &vars, "build");
        assert!(result.contains("/tmp/ci-staging/cui-cui moa"));
        assert!(result.contains("inputs.name"));
        assert!(result.contains("--inputs-json"));
        assert!(result.starts_with("echo "));
    }

    #[test]
    fn test_moa_shell_expand_no_expressions() {
        let result = moa_shell_expand("echo hello", None, &IndexMap::new(), "build");
        assert_eq!(result, "echo hello");
    }

    #[test]
    fn test_moa_shell_expand_with_vars() {
        let mut vars = IndexMap::new();
        vars.insert("CI_PROJECT_NAME".to_string(), "foo".to_string());
        let result = moa_shell_expand("echo ${{vars.CI_PROJECT_NAME}}", None, &vars, "build");
        assert!(result.contains("--vars-json"));
        assert!(result.contains("CI_PROJECT_NAME"));
    }

    #[test]
    fn test_moa_shell_expand_or_default() {
        let result = moa_shell_expand(
            "echo ${{inputs.name || \"default\"}}",
            None,
            &IndexMap::new(),
            "build",
        );
        assert!(
            result.contains(r#"inputs.name || "default""#),
            "got: {}",
            result
        );
    }

    #[test]
    fn test_moa_shell_expand_escaped_expression() {
        let result = moa_shell_expand(r"echo \${{nope}}", None, &IndexMap::new(), "test");
        assert_eq!(result, "echo ${{nope}}");
    }

    #[test]
    fn test_moa_shell_expand_with_step_outputs() {
        let vars = IndexMap::new();
        let result = moa_shell_expand("echo ${{steps.scan.outputs.critical}}", None, &vars, "gate");
        assert!(result.contains("--step-outputs-dir"));
        assert!(result.contains("/tmp/ci-staging/step-outputs"));
        assert!(result.contains("--output-file"));
        assert!(result.contains("/tmp/ci-staging/step-outputs/gate"));
        assert!(result.contains("--export-file"));
        assert!(result.contains("/tmp/ci-staging/step-exports/gate"));
    }
}
