use std::collections::{HashMap, HashSet};
use std::path::Path;

use anyhow::{Context, Result};
use indexmap::IndexMap;
use serde::Deserialize;

use crate::expr;
use crate::model::{RawJobConfig, RawPipeline, ScriptLines, SourceLocation, VarValue};

// ---------------------------------------------------------------------------
// Serde types for GitHub Actions workflow YAML
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields are deserialized from YAML but not all are read
pub struct GhWorkflow {
    pub name: Option<String>,
    #[serde(default)]
    pub env: HashMap<String, serde_yaml::Value>,
    #[serde(default)]
    pub jobs: IndexMap<String, GhJob>,
    /// Trigger config — kept as raw YAML for display
    #[serde(rename = "on")]
    pub on_trigger: Option<serde_yaml::Value>,
    /// Catch-all for permissions, concurrency, defaults, etc.
    #[serde(flatten)]
    pub extra: HashMap<String, serde_yaml::Value>,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields are deserialized from YAML but not all are read
pub struct GhJob {
    pub name: Option<String>,
    #[serde(rename = "runs-on")]
    pub runs_on: Option<RunsOn>,
    pub container: Option<ContainerConfig>,
    #[serde(default)]
    pub env: HashMap<String, serde_yaml::Value>,
    #[serde(default)]
    pub steps: Vec<GhStep>,
    pub needs: Option<GhNeeds>,
    #[serde(rename = "if")]
    pub condition: Option<String>,
    pub strategy: Option<serde_yaml::Value>,
    pub permissions: Option<serde_yaml::Value>,
    pub concurrency: Option<serde_yaml::Value>,
    pub outputs: Option<serde_yaml::Value>,
    pub timeout_minutes: Option<serde_yaml::Value>,
    pub services: Option<serde_yaml::Value>,
    /// Reusable workflow reference (e.g. `./.github/workflows/snap.yml`)
    pub uses: Option<String>,
    /// Inputs passed to a reusable workflow via `with:`
    #[serde(rename = "with")]
    pub call_with: Option<serde_yaml::Value>,
    pub secrets: Option<serde_yaml::Value>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_yaml::Value>,
}

/// `runs-on` can be a string, a list, or a mapping (for matrix expressions).
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum RunsOn {
    Single(String),
    List(Vec<String>),
    Expression(serde_yaml::Value),
}

impl RunsOn {
    fn to_tags(&self) -> Vec<String> {
        match self {
            RunsOn::Single(s) => vec![s.clone()],
            RunsOn::List(v) => v.clone(),
            RunsOn::Expression(v) => vec![
                serde_yaml::to_string(v)
                    .unwrap_or_default()
                    .trim()
                    .to_string(),
            ],
        }
    }
}

/// `container` can be a string (image name) or a mapping.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ContainerConfig {
    Simple(String),
    Complex {
        image: String,
        #[serde(flatten)]
        _extra: HashMap<String, serde_yaml::Value>,
    },
}

impl ContainerConfig {
    fn image_name(&self) -> &str {
        match self {
            ContainerConfig::Simple(s) => s,
            ContainerConfig::Complex { image, .. } => image,
        }
    }
}

/// `needs` can be a string, a list of strings, or a list of objects.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum GhNeeds {
    Single(String),
    List(Vec<String>),
}

impl GhNeeds {
    fn to_vec(&self) -> Vec<String> {
        match self {
            GhNeeds::Single(s) => vec![s.clone()],
            GhNeeds::List(v) => v.clone(),
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct GhStep {
    pub name: Option<String>,
    pub run: Option<String>,
    pub uses: Option<String>,
    #[serde(rename = "if")]
    pub condition: Option<String>,
    #[serde(rename = "with")]
    pub with_args: Option<serde_yaml::Value>,
    pub env: Option<HashMap<String, serde_yaml::Value>>,
    pub shell: Option<String>,
    #[serde(rename = "working-directory")]
    pub working_directory: Option<String>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_yaml::Value>,
}

// ---------------------------------------------------------------------------
// Parse entry point
// ---------------------------------------------------------------------------

/// Parse a GitHub Actions workflow YAML file into a `RawPipeline`.
pub fn parse_workflow_file(file_path: &Path) -> Result<RawPipeline> {
    let content = std::fs::read_to_string(file_path)
        .with_context(|| format!("Failed to read {}", file_path.display()))?;

    crate::parser::check_yaml_size(&content, &file_path.display().to_string())?;
    let workflow: GhWorkflow = serde_yaml::from_str(&content).with_context(|| {
        format!(
            "Failed to parse GitHub Actions YAML in {}",
            file_path.display()
        )
    })?;

    convert_workflow(workflow, file_path)
}

/// Parse a GitHub Actions workflow from a YAML string (for tests).
#[cfg(test)]
pub fn parse_workflow_file_from_str(yaml: &str) -> Result<RawPipeline> {
    let workflow: GhWorkflow =
        serde_yaml::from_str(yaml).context("Failed to parse GitHub Actions YAML")?;
    convert_workflow(workflow, Path::new("workflow.yml"))
}

/// Convert a parsed GitHub Actions workflow into the shared `RawPipeline` model.
fn convert_workflow(workflow: GhWorkflow, file_path: &Path) -> Result<RawPipeline> {
    let file_name = file_path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "workflow".to_string());

    // Global variables from workflow env
    let variables: IndexMap<String, VarValue> = workflow
        .env
        .iter()
        .map(|(k, v)| (k.clone(), yaml_value_to_var(v)))
        .collect();

    // Build workflow-level env as a flat string map for expression expansion
    let workflow_env: HashMap<String, String> = workflow
        .env
        .iter()
        .map(|(k, v)| (k.clone(), yaml_value_to_string(v)))
        .collect();

    // Detect github context from the git repository
    let github_ctx = detect_github_context(file_path);

    // Convert jobs, expanding matrix strategies
    let mut jobs = IndexMap::new();
    let mut source_map = IndexMap::new();

    // Track which job IDs are reusable workflow calls, and the leaf jobs
    // they expand into, so we can rewrite `needs:` references afterwards.
    let mut reusable_replacements: IndexMap<String, Vec<String>> = IndexMap::new();

    for (job_id, gh_job) in &workflow.jobs {
        // Reusable workflow call: inline jobs from the referenced file
        if let Some(ref uses) = gh_job.uses
            && let Some(child_jobs) = inline_reusable_workflow(
                uses,
                job_id,
                gh_job,
                file_path,
                &workflow_env,
                &github_ctx,
                &mut jobs,
                &mut source_map,
            )
        {
            reusable_replacements.insert(job_id.clone(), child_jobs);
            continue;
            // If inlining failed (external ref or file not found), fall through
            // to create a placeholder job below.
        }

        let matrix_combos = extract_matrix(&gh_job.strategy);

        if matrix_combos.is_empty() {
            // No matrix — single job
            let raw_job = convert_job(gh_job, &HashMap::new(), &workflow_env, &github_ctx);
            jobs.insert(job_id.clone(), raw_job);
            source_map.insert(
                job_id.clone(),
                SourceLocation {
                    file: file_name.clone(),
                    line: 0,
                },
            );
        } else {
            // Expand matrix into multiple jobs
            for combo in &matrix_combos {
                let suffix = combo
                    .iter()
                    .map(|(k, v)| format!("{}: {}", k, v))
                    .collect::<Vec<_>>()
                    .join(", ");
                let expanded_id = format!("{} [{}]", job_id, suffix);

                let raw_job = convert_job(gh_job, combo, &workflow_env, &github_ctx);
                jobs.insert(expanded_id.clone(), raw_job);
                source_map.insert(
                    expanded_id,
                    SourceLocation {
                        file: file_name.clone(),
                        line: 0,
                    },
                );
            }
        }
    }

    // Rewrite `needs:` references: any job that depended on a reusable workflow
    // call job should instead depend on the leaf jobs of the inlined workflow.
    if !reusable_replacements.is_empty() {
        for raw_job in jobs.values_mut() {
            if let Some(ref mut needs) = raw_job.needs {
                let mut new_needs = Vec::new();
                for need in needs.iter() {
                    let name = need.job_name();
                    if let Some(leaf_jobs) = reusable_replacements.get(name) {
                        for leaf in leaf_jobs {
                            new_needs.push(crate::model::NeedEntry::Simple(leaf.clone()));
                        }
                    } else {
                        new_needs.push(need.clone());
                    }
                }
                *needs = new_needs;
            }
        }
    }

    // Compute stage ordering from the dependency graph — use the expanded
    // jobs map (which now includes inlined workflow jobs) instead of the
    // original workflow.jobs (which still has the `uses:` entries).
    let stages = compute_stages_from_raw(&jobs);

    Ok(RawPipeline {
        stages,
        variables,
        default: None,
        workflow: None,
        jobs,
        source_map,
        github_context: github_ctx,
    })
}

/// Convert a single GitHub Actions job to a `RawJobConfig`.
/// `matrix_values` contains the current matrix combination (empty if no matrix).
/// `workflow_env` contains the workflow-level env values for `${{ env.VAR }}` expansion.
fn convert_job(
    gh_job: &GhJob,
    matrix_values: &HashMap<String, String>,
    workflow_env: &HashMap<String, String>,
    github_ctx: &HashMap<String, String>,
) -> RawJobConfig {
    // Merge workflow env + job env for ${{ env.VAR }} expansion
    let mut merged_env = workflow_env.clone();
    for (k, v) in &gh_job.env {
        merged_env.insert(k.clone(), yaml_value_to_string(v));
    }

    let runner = HashMap::from([
        ("os".to_string(), "Linux".to_string()),
        ("arch".to_string(), std::env::consts::ARCH.to_string()),
        ("temp".to_string(), "/tmp".to_string()),
        ("tool_cache".to_string(), "/opt/hostedtoolcache".to_string()),
    ]);
    let ctx = ExprContext {
        matrix: matrix_values,
        env: &merged_env,
        github: github_ctx,
        runner: &runner,
        inputs: &HashMap::new(),
    };

    // Image from container config
    let image = gh_job
        .container
        .as_ref()
        .map(|c| crate::model::ImageValue::Simple(expand_expressions(c.image_name(), &ctx)));

    // Variables from job env
    let variables: Option<IndexMap<String, VarValue>> = if gh_job.env.is_empty() {
        None
    } else {
        Some(
            gh_job
                .env
                .iter()
                .map(|(k, v)| {
                    let val = expand_expressions(&yaml_value_to_string(v), &ctx);
                    (k.clone(), VarValue::Simple(val))
                })
                .collect(),
        )
    };

    // Convert steps to script lines, expanding expressions
    let script_lines = steps_to_script(&gh_job.steps, &ctx);
    let script = if script_lines.is_empty() {
        None
    } else {
        Some(ScriptLines::from(script_lines))
    };

    // Store structured step data for the resolver to populate Job.steps
    let steps_data = steps_to_yaml_value(&gh_job.steps, &ctx);

    // Tags from runs-on, expanding expressions
    let tags = gh_job.runs_on.as_ref().map(|r| {
        r.to_tags()
            .into_iter()
            .map(|t| expand_expressions(&t, &ctx))
            .collect()
    });

    // Needs — point to the base job name (not the expanded name),
    // the resolver will handle matching
    let needs = gh_job.needs.as_ref().map(|n| {
        n.to_vec()
            .into_iter()
            .map(crate::model::NeedEntry::Simple)
            .collect()
    });

    // Rules summary from `if` condition
    let rules = gh_job.condition.as_ref().map(|cond| {
        let mut mapping = serde_yaml::Mapping::new();
        mapping.insert(
            serde_yaml::Value::String("if".to_string()),
            serde_yaml::Value::String(expand_expressions(cond, &ctx)),
        );
        serde_yaml::Value::Sequence(vec![serde_yaml::Value::Mapping(mapping)])
    });

    // Timeout
    let timeout = gh_job.timeout_minutes.as_ref().and_then(|v| match v {
        serde_yaml::Value::Number(n) => Some(format!("{} minutes", n)),
        serde_yaml::Value::String(s) => Some(format!("{} minutes", s)),
        _ => None,
    });

    let mut extra = IndexMap::new();
    extra.insert("_github_steps".to_string(), steps_data);

    RawJobConfig {
        script,
        image,
        variables,
        tags,
        needs,
        rules,
        timeout,
        extra,
        ..Default::default()
    }
}

/// Convert GitHub Actions steps into script lines for display.
///
/// Each step becomes one or more lines:
/// - `uses:` steps are expanded by fetching and parsing the action
/// - `run:` steps include the shell commands
/// - Step names are shown as comments
fn steps_to_script(steps: &[GhStep], ctx: &ExprContext) -> Vec<String> {
    expand_steps(steps, ctx, 0)
}

/// Emit helper function definitions used between GitHub Actions steps.
///
/// These are emitted once at the top of the script and called between steps
/// to mimic how the real runner applies env/path/output updates.
fn emit_step_helpers() -> Vec<String> {
    vec![
        "# Helper: apply environment changes between steps".to_string(),
        "_ci_apply_env() {".to_string(),
        "  while IFS= read -r _line 2>/dev/null; do [ -n \"$_line\" ] && export \"$_line\"; done < \"$GITHUB_ENV\"; : > \"$GITHUB_ENV\"".to_string(),
        "  while IFS= read -r _p 2>/dev/null; do [ -n \"$_p\" ] && export PATH=\"$_p:$PATH\"; done < \"$GITHUB_PATH\"; : > \"$GITHUB_PATH\"".to_string(),
        "}".to_string(),
        "_ci_apply_output() {".to_string(),
        "  local _prefix=$1".to_string(),
        "  while IFS='=' read -r _k _v 2>/dev/null; do [ -n \"$_k\" ] && export \"${_prefix}_${_k}=${_v}\"; done < \"$GITHUB_OUTPUT\"; : > \"$GITHUB_OUTPUT\"".to_string(),
        "}".to_string(),
    ]
}

/// Recursive step expansion with depth limit to prevent infinite loops.
fn expand_steps(steps: &[GhStep], ctx: &ExprContext, depth: usize) -> Vec<String> {
    let mut lines = Vec::new();

    // Emit helper functions once at the top level
    if depth == 0 && steps.len() > 1 {
        lines.append(&mut emit_step_helpers());
        lines.push(String::new());
    }

    for (i, step) in steps.iter().enumerate() {
        if i > 0 {
            lines.push(String::new());
            lines.push("_ci_apply_env".to_string());

            // Source step outputs from the previous step if it had an id
            if let Some(prev_id) = steps[i - 1].extra.get("id").and_then(|v| v.as_str()) {
                let safe_id = prev_id.replace('-', "_");
                lines.push(format!("_ci_apply_output _STEP_{}", safe_id));
            }

            lines.push(String::new());
        }

        // Step header
        if let Some(ref name) = step.name {
            lines.push(format!("# Step: {}", expand_expressions(name, ctx)));
        }

        // Condition — GitHub Actions if: values are expressions by default,
        // so expand context references even without ${{ }} markers.
        if let Some(ref cond) = step.condition {
            let expanded = expand_condition(cond, ctx);
            lines.push(format!("# if: {}", expanded));
            if eval_condition(cond, ctx) == Some(false) {
                lines.push("# (skipped: condition is false)".to_string());
                continue;
            }
        }

        if let Some(ref uses) = step.uses {
            lines.append(&mut expand_uses_step(uses, step, ctx, depth));
        }

        if let Some(ref run) = step.run {
            // Shell hint
            if let Some(ref shell) = step.shell
                && shell != "bash"
            {
                lines.push(format!("# shell: {}", shell.replace('\n', " ")));
            }
            // Working directory
            if let Some(ref wd) = step.working_directory {
                lines.push(format!(
                    "cd {}",
                    crate::model::shell_quote(&expand_expressions(wd, ctx))
                ));
            }
            // Step-level env
            if let Some(ref env) = step.env {
                for (k, v) in env {
                    let val = expand_expressions(&yaml_value_to_string(v), ctx);
                    lines.push(format!("export {}={}", k, crate::model::shell_quote(&val)));
                }
            }
            let expanded = expand_expressions(run, ctx);
            for line in expanded.lines() {
                lines.push(line.to_string());
            }
        }
    }

    lines
}

/// Expand a `uses:` step into executable script lines.
///
/// Fetches the action repository, parses `action.yml`, and generates lines based
/// on the action type (Node, Composite, Docker).  Falls back to comment-only
/// rendering on any error.
fn expand_uses_step(uses: &str, step: &GhStep, ctx: &ExprContext, depth: usize) -> Vec<String> {
    // Special case: actions/checkout is a no-op (repo is already mounted)
    if uses.starts_with("actions/checkout@") {
        return vec!["# actions/checkout: repo already mounted".to_string()];
    }

    // Try to expand; fall back to comment on failure
    match try_expand_uses(uses, step, ctx, depth) {
        Ok(lines) => lines,
        Err(_) => render_uses_as_comment(uses, step, ctx),
    }
}

/// Attempt to fetch, parse, and expand a `uses:` action.
fn try_expand_uses(
    uses: &str,
    step: &GhStep,
    ctx: &ExprContext,
    depth: usize,
) -> anyhow::Result<Vec<String>> {
    use super::action_cache::*;

    let r = parse_uses_ref(uses).ok_or_else(|| anyhow::anyhow!("cannot parse uses ref"))?;
    let action_dir = fetch_action(&r)?;
    let meta = read_action_metadata(&action_dir)?;

    let mut lines = Vec::new();
    lines.push(format!("# uses: {}", uses));

    // Build INPUT_* env vars from action defaults + step with: overrides
    let input_vars = build_input_vars(&meta, step, ctx);
    for (k, v) in &input_vars {
        lines.push(format!(
            "export INPUT_{}={}",
            k.to_uppercase().replace('-', "_"),
            crate::model::shell_quote(v)
        ));
    }

    let action_path = container_action_path(&r);
    lines.push(format!("export GITHUB_ACTION_PATH={}", action_path));

    match meta.runs {
        ActionRuns::Node { ref main, .. } => {
            lines.push(format!("node {}/{}", action_path, main));
        }
        ActionRuns::Composite { ref steps } if depth < 5 => {
            // Build inputs map for ${{ inputs.* }} expansion inside composite actions
            let inputs_map: HashMap<String, String> = input_vars.iter().cloned().collect();
            let composite_ctx = ExprContext {
                matrix: ctx.matrix,
                env: ctx.env,
                github: ctx.github,
                runner: ctx.runner,
                inputs: &inputs_map,
            };
            lines.push("# --- begin composite action ---".to_string());
            lines.append(&mut expand_steps(steps, &composite_ctx, depth + 1));
            lines.push("# --- end composite action ---".to_string());
        }
        ActionRuns::Composite { .. } => {
            lines.push("# (composite action not expanded: depth limit reached)".to_string());
        }
        ActionRuns::Docker {
            ref image,
            ref entrypoint,
            ..
        } => {
            lines.push(format!("# docker action: image={}", image));
            if let Some(ep) = entrypoint {
                lines.push(format!("# entrypoint: {}", ep));
            }
        }
    }

    Ok(lines)
}

/// Build INPUT_* variable map by merging action input defaults with step `with:`.
fn build_input_vars(
    meta: &super::action_cache::ActionMetadata,
    step: &GhStep,
    ctx: &ExprContext,
) -> Vec<(String, String)> {
    use indexmap::IndexMap;

    let mut vars: IndexMap<String, String> = IndexMap::new();

    // Start with action input defaults (empty string for inputs without defaults)
    for (name, input) in &meta.inputs {
        let value = input
            .default
            .as_deref()
            .map(|d| expand_expressions(d, ctx))
            .unwrap_or_default();
        vars.insert(name.clone(), value);
    }

    // Override with step `with:` values
    if let Some(ref with) = step.with_args
        && let serde_yaml::Value::Mapping(map) = with
    {
        for (k, v) in map {
            if let Some(key) = k.as_str() {
                let val = match v {
                    serde_yaml::Value::String(s) => expand_expressions(s, ctx),
                    other => yaml_value_to_string(other),
                };
                vars.insert(key.to_string(), val);
            }
        }
    }

    vars.into_iter().collect()
}

/// Render a `uses:` step as a comment (fallback when expansion fails).
fn render_uses_as_comment(uses: &str, step: &GhStep, ctx: &ExprContext) -> Vec<String> {
    let mut lines = vec![format!("# uses: {}", uses)];
    if let Some(ref with) = step.with_args
        && let serde_yaml::Value::Mapping(map) = with
    {
        for (k, v) in map {
            let key = k.as_str().unwrap_or("?");
            let val = match v {
                serde_yaml::Value::String(s) => expand_expressions(s, ctx),
                other => serde_yaml::to_string(other)
                    .unwrap_or_default()
                    .trim()
                    .to_string(),
            };
            lines.push(format!("#   {}: {}", key, val));
        }
    }
    lines
}

/// Convert GitHub Actions steps into a YAML value for storage in `RawJobConfig.extra`.
///
/// This preserves step structure so the resolver can populate `Job.steps`.
fn steps_to_yaml_value(steps: &[GhStep], ctx: &ExprContext) -> serde_yaml::Value {
    let entries: Vec<serde_yaml::Value> = steps
        .iter()
        .map(|step| {
            let mut map = serde_yaml::Mapping::new();

            if let Some(ref name) = step.name {
                map.insert(
                    serde_yaml::Value::String("name".to_string()),
                    serde_yaml::Value::String(expand_expressions(name, ctx)),
                );
            }
            if let Some(ref uses) = step.uses {
                map.insert(
                    serde_yaml::Value::String("uses".to_string()),
                    serde_yaml::Value::String(uses.clone()),
                );
            }
            if let Some(ref run) = step.run {
                map.insert(
                    serde_yaml::Value::String("run".to_string()),
                    serde_yaml::Value::String(expand_expressions(run, ctx)),
                );
            }
            if let Some(serde_yaml::Value::Mapping(wmap)) = step.with_args.as_ref() {
                let mut with_map = serde_yaml::Mapping::new();
                for (k, v) in wmap {
                    let val = match v {
                        serde_yaml::Value::String(s) => {
                            serde_yaml::Value::String(expand_expressions(s, ctx))
                        }
                        other => other.clone(),
                    };
                    with_map.insert(k.clone(), val);
                }
                map.insert(
                    serde_yaml::Value::String("with".to_string()),
                    serde_yaml::Value::Mapping(with_map),
                );
            }
            if let Some(ref env) = step.env {
                let mut env_map = serde_yaml::Mapping::new();
                for (k, v) in env {
                    let val = expand_expressions(&yaml_value_to_string(v), ctx);
                    env_map.insert(
                        serde_yaml::Value::String(k.clone()),
                        serde_yaml::Value::String(val),
                    );
                }
                map.insert(
                    serde_yaml::Value::String("env".to_string()),
                    serde_yaml::Value::Mapping(env_map),
                );
            }
            if let Some(ref cond) = step.condition {
                map.insert(
                    serde_yaml::Value::String("if".to_string()),
                    serde_yaml::Value::String(expand_expressions(cond, ctx)),
                );
            }
            if let Some(ref id) = step.extra.get("id").and_then(|v| v.as_str()) {
                map.insert(
                    serde_yaml::Value::String("id".to_string()),
                    serde_yaml::Value::String(id.to_string()),
                );
            }

            serde_yaml::Value::Mapping(map)
        })
        .collect();

    serde_yaml::Value::Sequence(entries)
}

// ---------------------------------------------------------------------------
// Stage computation from dependency graph
// ---------------------------------------------------------------------------

/// Compute stages from the final `RawJobConfig` map (after inlining reusable workflows).
fn compute_stages_from_raw(jobs: &IndexMap<String, RawJobConfig>) -> Vec<String> {
    let mut depths: HashMap<String, usize> = HashMap::new();
    let names: Vec<String> = jobs.keys().cloned().collect();
    for name in &names {
        compute_depth_raw(name, jobs, &mut depths, &mut Vec::new());
    }
    let max_depth = depths.values().copied().max().unwrap_or(0);
    (0..=max_depth).map(|d| d.to_string()).collect()
}

/// Recursively compute the depth of a job from `RawJobConfig` needs.
fn compute_depth_raw(
    name: &str,
    jobs: &IndexMap<String, RawJobConfig>,
    depths: &mut HashMap<String, usize>,
    stack: &mut Vec<String>,
) -> usize {
    if let Some(&d) = depths.get(name) {
        return d;
    }
    if stack.iter().any(|s| s == name) {
        return 0;
    }
    stack.push(name.to_string());

    let depth = jobs
        .get(name)
        .and_then(|job| job.needs.as_ref())
        .map(|needs| {
            needs
                .iter()
                .map(|n| compute_depth_raw(n.job_name(), jobs, depths, stack) + 1)
                .max()
                .unwrap_or(0)
        })
        .unwrap_or(0);

    stack.pop();
    depths.insert(name.to_string(), depth);
    depth
}

/// Inline a reusable workflow referenced by `uses: ./.github/workflows/foo.yml`.
///
/// Returns `Some(leaf_job_names)` if inlining succeeded, `None` if the reference
/// is external or the file could not be found.
///
/// Inlined jobs are prefixed with `"caller_id / "` and their needs are rewritten:
/// - Entry jobs (no needs) inherit the calling job's needs
/// - Internal needs are prefixed to match the new names
/// - Leaf jobs (not depended on by any other inlined job) are returned so the
///   caller can rewrite external `needs:` references.
#[allow(clippy::too_many_arguments)]
fn inline_reusable_workflow(
    uses: &str,
    caller_id: &str,
    caller_job: &GhJob,
    parent_path: &Path,
    workflow_env: &HashMap<String, String>,
    github_ctx: &HashMap<String, String>,
    jobs: &mut IndexMap<String, RawJobConfig>,
    source_map: &mut IndexMap<String, SourceLocation>,
) -> Option<Vec<String>> {
    // Only handle local references (starting with ./)
    let rel_path = uses.strip_prefix("./")?;

    // The path is relative to the repo root. Walk up from the parent file
    // until we find the directory that contains `.github/`.
    let repo_root = find_repo_root(parent_path)?;
    let child_path = repo_root.join(rel_path);

    if !child_path.exists() {
        log::warn!(
            "Reusable workflow not found: {} (resolved to {})",
            uses,
            child_path.display()
        );
        return None;
    }

    let content = std::fs::read_to_string(&child_path).ok()?;
    crate::parser::check_yaml_size(&content, &child_path.display().to_string()).ok()?;
    let child_workflow: GhWorkflow = serde_yaml::from_str(&content).ok()?;

    let child_file_name = child_path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "workflow".to_string());

    // Caller's needs become the entry-point needs for the child workflow
    let caller_needs = caller_job.needs.as_ref().map(|n| {
        n.to_vec()
            .into_iter()
            .map(crate::model::NeedEntry::Simple)
            .collect::<Vec<_>>()
    });

    // Collect child job IDs for determining leaf jobs
    let child_job_ids: Vec<String> = child_workflow.jobs.keys().cloned().collect();

    // Find which child jobs are depended on by other child jobs
    let mut has_dependents: HashSet<String> = HashSet::new();
    for child_job in child_workflow.jobs.values() {
        if let Some(ref needs) = child_job.needs {
            for dep in needs.to_vec() {
                has_dependents.insert(dep);
            }
        }
    }

    let mut leaf_jobs = Vec::new();

    for (child_job_id, child_gh_job) in &child_workflow.jobs {
        let prefixed_id = format!("{} / {}", caller_id, child_job_id);
        let matrix_combos = extract_matrix(&child_gh_job.strategy);

        let is_entry = child_gh_job
            .needs
            .as_ref()
            .map(|n| n.to_vec().is_empty())
            .unwrap_or(true);
        let is_leaf = !has_dependents.contains(child_job_id);

        let process_child_job =
            |gh_job: &GhJob, matrix_vals: &HashMap<String, String>| -> RawJobConfig {
                let mut raw_job = convert_job(gh_job, matrix_vals, workflow_env, github_ctx);

                // Rewrite needs: prefix internal refs, inherit caller's needs for entry jobs
                if is_entry {
                    raw_job.needs = caller_needs.clone();
                } else if let Some(ref mut needs) = raw_job.needs {
                    for need in needs.iter_mut() {
                        let dep_name = need.job_name().to_string();
                        if child_job_ids.contains(&dep_name) {
                            *need = crate::model::NeedEntry::Simple(format!(
                                "{} / {}",
                                caller_id, dep_name
                            ));
                        }
                    }
                }

                raw_job
            };

        if matrix_combos.is_empty() {
            let raw_job = process_child_job(child_gh_job, &HashMap::new());
            jobs.insert(prefixed_id.clone(), raw_job);
            source_map.insert(
                prefixed_id.clone(),
                SourceLocation {
                    file: child_file_name.clone(),
                    line: 0,
                },
            );
            if is_leaf {
                leaf_jobs.push(prefixed_id);
            }
        } else {
            for combo in &matrix_combos {
                let suffix = combo
                    .iter()
                    .map(|(k, v)| format!("{}: {}", k, v))
                    .collect::<Vec<_>>()
                    .join(", ");
                let expanded_id = format!("{} [{}]", prefixed_id, suffix);

                let raw_job = process_child_job(child_gh_job, combo);
                jobs.insert(expanded_id.clone(), raw_job);
                source_map.insert(
                    expanded_id.clone(),
                    SourceLocation {
                        file: child_file_name.clone(),
                        line: 0,
                    },
                );
                if is_leaf {
                    leaf_jobs.push(expanded_id);
                }
            }
        }
    }

    Some(leaf_jobs)
}

/// Find the repository root by walking up from a workflow file path.
///
/// GitHub Actions workflow files live in `.github/workflows/`, so the repo
/// root is the directory two levels above the workflow file.
fn find_repo_root(workflow_path: &Path) -> Option<&Path> {
    workflow_path.parent()?.parent()?.parent()
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Context for expanding `${{ ... }}` expressions in GitHub Actions YAML.
struct ExprContext<'a> {
    /// Values from `strategy.matrix` (e.g., `matrix.os` → `ubuntu-latest`).
    matrix: &'a HashMap<String, String>,
    /// Merged env values (workflow-level + job-level).
    env: &'a HashMap<String, String>,
    /// GitHub context values (e.g., `github.sha`, `github.ref`).
    github: &'a HashMap<String, String>,
    /// Runner context values (e.g., `runner.os`, `runner.arch`).
    runner: &'a HashMap<String, String>,
    /// Action inputs (from `with:` + defaults), for `${{ inputs.* }}` in composite actions.
    inputs: &'a HashMap<String, String>,
}

impl<'a> ExprContext<'a> {
    fn has_values(&self) -> bool {
        !self.matrix.is_empty()
            || !self.env.is_empty()
            || !self.github.is_empty()
            || !self.runner.is_empty()
            || !self.inputs.is_empty()
    }

    fn resolve_context_ref(&self, id: &str) -> Option<String> {
        if let Some(key) = id.strip_prefix("matrix.") {
            return self.matrix.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("env.") {
            return self.env.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("github.") {
            return self.github.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("runner.") {
            return self.runner.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("inputs.") {
            return self.inputs.get(key).cloned();
        }
        if id == "true" {
            return Some("true".to_string());
        }
        if id == "false" || id == "null" {
            return Some(String::new());
        }
        None
    }
}

impl expr::ExprContext for ExprContext<'_> {
    fn resolve(&self, id: &str) -> Option<String> {
        self.resolve_context_ref(id)
            .or_else(|| step_output_bash_ref(id))
    }
}

// ---------------------------------------------------------------------------
// Expression evaluator — delegates to crate::expr with GitHub-specific context
// ---------------------------------------------------------------------------

/// Convert a `steps.X.outputs.Y` reference to a bash variable reference `${_STEP_X_Y}`.
///
/// Returns `None` for non-step-output identifiers.  Step output values are written
/// to `$GITHUB_OUTPUT` at runtime and sourced between steps (see `expand_steps`),
/// so the bash variable will contain the correct value when the script executes.
fn step_output_bash_ref(id: &str) -> Option<String> {
    let rest = id.strip_prefix("steps.")?;
    let (step_id, key) = rest.split_once(".outputs.")?;
    let safe_id = step_id.replace('-', "_");
    let safe_key = key.replace('-', "_");
    Some(format!("${{_STEP_{}_{}}}", safe_id, safe_key))
}

fn expand_expressions(s: &str, ctx: &ExprContext) -> String {
    if !ctx.has_values() || !s.contains("${{") {
        return s.to_string();
    }
    expr::expand_expressions(s, ctx)
}

/// Expand context references in `if:` condition text for display.
///
/// Substitutes known context references (both inside `${{ }}` and bare) with
/// their values and strips `${{ }}` wrappers, producing a human-readable string.
fn expand_condition(cond: &str, ctx: &ExprContext) -> String {
    let mut result = cond.to_string();
    // Replace context references (both inside ${{ }} and bare)
    for (key, value) in ctx.matrix {
        result = result.replace(&format!("matrix.{}", key), value);
    }
    for (key, value) in ctx.env {
        result = result.replace(&format!("env.{}", key), value);
    }
    for (key, value) in ctx.github {
        result = result.replace(&format!("github.{}", key), value);
    }
    for (key, value) in ctx.runner {
        result = result.replace(&format!("runner.{}", key), value);
    }
    for (key, value) in ctx.inputs {
        result = result.replace(&format!("inputs.{}", key), value);
    }
    // Strip ${{ }} wrappers for display
    while let Some(start) = result.find("${{") {
        if let Some(end_offset) = result[start..].find("}}") {
            let inner = result[start + 3..start + end_offset].trim().to_string();
            result.replace_range(start..start + end_offset + 2, &inner);
        } else {
            break;
        }
    }
    result
}

/// Evaluate an `if:` condition, returning `Some(false)` only when the condition
/// is definitely false.  Returns `None` when the condition contains unresolvable
/// references (e.g. `steps.*` outputs) so that the step is not incorrectly skipped.
fn eval_condition(cond: &str, ctx: &ExprContext) -> Option<bool> {
    let expr = cond.trim();
    // Strip ${{ }} wrapper if the entire condition is wrapped
    let expr = expr
        .strip_prefix("${{")
        .and_then(|s| s.strip_suffix("}}"))
        .map(|s| s.trim())
        .unwrap_or(expr);

    // Check that all context references are resolvable — if any are unknown,
    // we can't reliably evaluate, so return None.
    for id in expr::extract_identifiers(expr) {
        ctx.resolve_context_ref(&id)?;
    }

    let result = expr::eval_expression(expr, ctx);
    Some(expr::is_truthy(&result))
}

/// Detect GitHub context values from the git repository at the given path.
fn detect_github_context(file_path: &Path) -> HashMap<String, String> {
    use crate::git_utils::{detect_remote_name, git_cmd};

    let dir = file_path.parent().unwrap_or_else(|| Path::new("."));

    let sha = git_cmd(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
    let branch =
        git_cmd(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "main".to_string());
    // GITHUB_ACTOR is a username (no spaces).  Best local approximation is
    // the local part of the committer email (e.g. "hughsie" from "hughsie@users.noreply.github.com").
    let actor = git_cmd(dir, &["log", "-1", "--format=%aE"])
        .unwrap_or_default()
        .split('@')
        .next()
        .unwrap_or_default()
        .to_string();

    let remote_name = detect_remote_name(dir);
    let remote_url = git_cmd(dir, &["remote", "get-url", &remote_name]).unwrap_or_default();
    let (server_url, repository) = parse_github_remote(&remote_url);

    let mut ctx = HashMap::new();
    ctx.insert("sha".to_string(), sha.clone());
    ctx.insert("ref".to_string(), format!("refs/heads/{}", branch));
    ctx.insert("ref_name".to_string(), branch.clone());
    ctx.insert("head_ref".to_string(), branch);
    ctx.insert("actor".to_string(), actor);
    ctx.insert("repository".to_string(), repository.clone());
    ctx.insert(
        "repository_owner".to_string(),
        repository.split('/').next().unwrap_or("").to_string(),
    );
    ctx.insert("server_url".to_string(), server_url);
    ctx.insert("event_name".to_string(), "push".to_string());
    ctx.insert("workspace".to_string(), "/home/runner/work".to_string());
    ctx.insert("run_id".to_string(), "0".to_string());
    ctx.insert("run_number".to_string(), "1".to_string());
    ctx
}

/// Extract server URL and owner/repo from a git remote URL.
fn parse_github_remote(remote: &str) -> (String, String) {
    if remote.starts_with("https://") || remote.starts_with("http://") {
        if let Ok(url) = url::Url::parse(remote) {
            let server = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
            let path = url.path().trim_start_matches('/').trim_end_matches(".git");
            return (server, path.to_string());
        }
    } else if let Some(at_pos) = remote.find('@') {
        let after_at = &remote[at_pos + 1..];
        if let Some(colon_pos) = after_at.find(':') {
            let host = &after_at[..colon_pos];
            let server = format!("https://{}", host);
            let path = after_at[colon_pos + 1..].trim_end_matches(".git");
            return (server, path.to_string());
        }
    }
    ("https://github.com".to_string(), String::new())
}

/// Extract matrix combinations from a job's `strategy` field.
///
/// Given:
/// ```yaml
/// strategy:
///   matrix:
///     os: [ubuntu-latest, macos-latest]
///     rust: [stable, nightly]
/// ```
/// Returns the cross product: [{os: ubuntu-latest, rust: stable}, {os: ubuntu-latest, rust: nightly}, ...]
///
/// The special keys `include` and `exclude` are ignored for now.
fn extract_matrix(strategy: &Option<serde_yaml::Value>) -> Vec<HashMap<String, String>> {
    let Some(strategy) = strategy else {
        return vec![];
    };
    let Some(matrix) = strategy.get("matrix") else {
        return vec![];
    };
    let Some(matrix_map) = matrix.as_mapping() else {
        return vec![];
    };

    // Collect variable axes (skip include/exclude/fail-fast)
    let mut axes: Vec<(String, Vec<String>)> = Vec::new();
    for (key, value) in matrix_map {
        let key_str = key.as_str().unwrap_or("").to_string();
        if key_str == "include" || key_str == "exclude" || key_str == "fail-fast" {
            continue;
        }
        let values = match value {
            serde_yaml::Value::Sequence(seq) => seq.iter().map(yaml_value_to_string).collect(),
            _ => vec![yaml_value_to_string(value)],
        };
        if !values.is_empty() {
            axes.push((key_str, values));
        }
    }

    if axes.is_empty() {
        return vec![];
    }

    // Compute cross product
    let mut combos: Vec<HashMap<String, String>> = vec![HashMap::new()];
    for (key, values) in &axes {
        let mut new_combos = Vec::new();
        for combo in &combos {
            for value in values {
                let mut new_combo = combo.clone();
                new_combo.insert(key.clone(), value.clone());
                new_combos.push(new_combo);
            }
        }
        combos = new_combos;
    }

    combos
}

fn yaml_value_to_var(v: &serde_yaml::Value) -> VarValue {
    VarValue::Simple(yaml_value_to_string(v))
}

fn yaml_value_to_string(v: &serde_yaml::Value) -> String {
    match v {
        serde_yaml::Value::String(s) => s.clone(),
        serde_yaml::Value::Number(n) => n.to_string(),
        serde_yaml::Value::Bool(b) => b.to_string(),
        serde_yaml::Value::Null => String::new(),
        other => serde_yaml::to_string(other)
            .unwrap_or_default()
            .trim()
            .to_string(),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn parse_yaml(yaml: &str) -> GhWorkflow {
        serde_yaml::from_str(yaml).unwrap()
    }

    #[test]
    fn test_parse_basic_workflow() {
        let workflow = parse_yaml(
            r#"
name: CI
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: cargo build
"#,
        );
        assert_eq!(workflow.name.as_deref(), Some("CI"));
        assert_eq!(workflow.jobs.len(), 1);
        assert!(workflow.jobs.contains_key("build"));

        let build = &workflow.jobs["build"];
        assert_eq!(build.steps.len(), 2);
        assert_eq!(build.steps[0].uses.as_deref(), Some("actions/checkout@v4"));
        assert_eq!(build.steps[1].run.as_deref(), Some("cargo build"));
    }

    #[test]
    fn test_parse_job_with_needs() {
        let workflow = parse_yaml(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: make build
  test:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - run: make test
  deploy:
    runs-on: ubuntu-latest
    needs: [build, test]
    steps:
      - run: make deploy
"#,
        );
        assert_eq!(workflow.jobs.len(), 3);

        let test_needs = workflow.jobs["test"].needs.as_ref().unwrap().to_vec();
        assert_eq!(test_needs, vec!["build"]);

        let deploy_needs = workflow.jobs["deploy"].needs.as_ref().unwrap().to_vec();
        assert_eq!(deploy_needs, vec!["build", "test"]);
    }

    #[test]
    fn test_parse_container_simple() {
        let workflow = parse_yaml(
            r#"
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    container: rust:1.80
    steps:
      - run: cargo test
"#,
        );
        let container = workflow.jobs["test"].container.as_ref().unwrap();
        assert_eq!(container.image_name(), "rust:1.80");
    }

    #[test]
    fn test_parse_container_complex() {
        let workflow = parse_yaml(
            r#"
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:18
      env:
        NODE_ENV: production
    steps:
      - run: npm test
"#,
        );
        let container = workflow.jobs["test"].container.as_ref().unwrap();
        assert_eq!(container.image_name(), "node:18");
    }

    #[test]
    fn test_compute_stages_no_deps() {
        let raw = parse_workflow_file_from_str(
            r#"
on: push
jobs:
  a:
    runs-on: ubuntu-latest
    steps: [{run: echo a}]
  b:
    runs-on: ubuntu-latest
    steps: [{run: echo b}]
"#,
        )
        .unwrap();
        assert_eq!(raw.stages, vec!["0"]);
    }

    #[test]
    fn test_compute_stages_linear() {
        let raw = parse_workflow_file_from_str(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: echo build}]
  test:
    runs-on: ubuntu-latest
    needs: build
    steps: [{run: echo test}]
  deploy:
    runs-on: ubuntu-latest
    needs: test
    steps: [{run: echo deploy}]
"#,
        )
        .unwrap();
        assert_eq!(raw.stages, vec!["0", "1", "2"]);
    }

    #[test]
    fn test_compute_stages_diamond() {
        let raw = parse_workflow_file_from_str(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: echo}]
  test-unit:
    runs-on: ubuntu-latest
    needs: build
    steps: [{run: echo}]
  test-integration:
    runs-on: ubuntu-latest
    needs: build
    steps: [{run: echo}]
  deploy:
    runs-on: ubuntu-latest
    needs: [test-unit, test-integration]
    steps: [{run: echo}]
"#,
        )
        .unwrap();
        assert_eq!(raw.stages, vec!["0", "1", "2"]);
    }

    #[test]
    fn test_inline_reusable_workflow() {
        // Create a temporary child workflow file
        let dir = tempfile::tempdir().unwrap();
        let workflows_dir = dir.path().join(".github").join("workflows");
        std::fs::create_dir_all(&workflows_dir).unwrap();

        // Write a child workflow
        std::fs::write(
            workflows_dir.join("child.yml"),
            r#"
name: Child
on: workflow_call
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{run: make build}]
  test:
    runs-on: ubuntu-latest
    needs: build
    steps: [{run: make test}]
"#,
        )
        .unwrap();

        // Write a parent workflow that references the child
        let parent_path = workflows_dir.join("ci.yml");
        std::fs::write(
            &parent_path,
            r#"
name: CI
on: push
jobs:
  child:
    uses: ./.github/workflows/child.yml
  deploy:
    runs-on: ubuntu-latest
    needs: child
    steps: [{run: make deploy}]
"#,
        )
        .unwrap();

        let raw = parse_workflow_file(&parent_path).unwrap();

        // The "child" job should be replaced by inlined jobs
        assert!(!raw.jobs.contains_key("child"));
        assert!(raw.jobs.contains_key("child / build"));
        assert!(raw.jobs.contains_key("child / test"));
        assert!(raw.jobs.contains_key("deploy"));

        // "child / build" should have no needs (entry job, caller had no needs)
        assert!(raw.jobs["child / build"].needs.is_none());

        // "child / test" should depend on "child / build"
        let test_needs: Vec<_> = raw.jobs["child / test"]
            .needs
            .as_ref()
            .unwrap()
            .iter()
            .map(|n| n.job_name().to_string())
            .collect();
        assert_eq!(test_needs, vec!["child / build"]);

        // "deploy" should depend on "child / test" (the leaf of the child workflow)
        let deploy_needs: Vec<_> = raw.jobs["deploy"]
            .needs
            .as_ref()
            .unwrap()
            .iter()
            .map(|n| n.job_name().to_string())
            .collect();
        assert_eq!(deploy_needs, vec!["child / test"]);
    }

    #[test]
    fn test_steps_to_script() {
        let steps = vec![
            GhStep {
                name: None,
                run: None,
                uses: Some("actions/checkout@v4".to_string()),
                condition: None,
                with_args: None,
                env: None,
                shell: None,
                working_directory: None,
                extra: HashMap::new(),
            },
            GhStep {
                name: Some("Build".to_string()),
                run: Some("cargo build\ncargo test".to_string()),
                uses: None,
                condition: None,
                with_args: None,
                env: None,
                shell: None,
                working_directory: None,
                extra: HashMap::new(),
            },
        ];

        let lines = steps_to_script(
            &steps,
            &ExprContext {
                matrix: &HashMap::new(),
                env: &HashMap::new(),
                github: &HashMap::new(),
                runner: &HashMap::new(),
                inputs: &HashMap::new(),
            },
        );
        assert!(lines.contains(&"# actions/checkout: repo already mounted".to_string()));
        assert!(lines.contains(&"# Step: Build".to_string()));
        assert!(lines.contains(&"cargo build".to_string()));
        assert!(lines.contains(&"cargo test".to_string()));
    }

    #[test]
    fn test_working_directory_shell_quoted() {
        let steps = vec![GhStep {
            name: Some("Test".to_string()),
            run: Some("echo hi".to_string()),
            uses: None,
            condition: None,
            with_args: None,
            env: None,
            shell: None,
            working_directory: Some("my dir".to_string()),
            extra: HashMap::new(),
        }];
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        let lines = steps_to_script(&steps, &ctx);
        // Space in directory name must be quoted
        assert!(
            lines.contains(&"cd 'my dir'".to_string()),
            "working-directory with spaces should be shell-quoted, got: {:?}",
            lines
        );
    }

    #[test]
    fn test_working_directory_injection_prevented() {
        let steps = vec![GhStep {
            name: Some("Evil".to_string()),
            run: Some("echo safe".to_string()),
            uses: None,
            condition: None,
            with_args: None,
            env: None,
            shell: None,
            working_directory: Some("; rm -rf / #".to_string()),
            extra: HashMap::new(),
        }];
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        let lines = steps_to_script(&steps, &ctx);
        // The malicious payload must be quoted, not executed as separate commands
        let cd_line = lines.iter().find(|l| l.starts_with("cd ")).unwrap();
        assert!(
            cd_line.contains("'; rm -rf / #'"),
            "injection payload should be single-quoted: {}",
            cd_line
        );
        assert!(
            !cd_line.contains("cd ; rm"),
            "injection should not produce bare semicolon: {}",
            cd_line
        );
    }

    #[test]
    fn test_working_directory_simple_path_unquoted() {
        let steps = vec![GhStep {
            name: None,
            run: Some("ls".to_string()),
            uses: None,
            condition: None,
            with_args: None,
            env: None,
            shell: None,
            working_directory: Some("subdir".to_string()),
            extra: HashMap::new(),
        }];
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        let lines = steps_to_script(&steps, &ctx);
        // Simple alphanumeric path doesn't need quotes
        assert!(
            lines.contains(&"cd subdir".to_string()),
            "simple path should not be quoted: {:?}",
            lines
        );
    }

    #[test]
    fn test_shell_comment_newline_stripped() {
        let steps = vec![GhStep {
            name: None,
            run: Some("echo hi".to_string()),
            uses: None,
            condition: None,
            with_args: None,
            env: None,
            shell: Some("pwsh\nmalicious".to_string()),
            working_directory: None,
            extra: HashMap::new(),
        }];
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        let lines = steps_to_script(&steps, &ctx);
        // Newline in shell name must not inject a new script line
        let shell_line = lines.iter().find(|l| l.starts_with("# shell:")).unwrap();
        assert!(
            !shell_line.contains('\n'),
            "shell comment should not contain newline: {:?}",
            shell_line
        );
        assert!(
            shell_line.contains("pwsh malicious"),
            "newline should be replaced with space: {}",
            shell_line
        );
    }

    #[test]
    fn test_convert_workflow_to_raw_pipeline() {
        let workflow = parse_yaml(
            r#"
name: CI
on: push
env:
  RUST_BACKTRACE: "1"
jobs:
  build:
    runs-on: ubuntu-latest
    container: rust:latest
    env:
      CARGO_TERM_COLOR: always
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: cargo build
  test:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v4
      - run: cargo test
"#,
        );

        let pipeline = convert_workflow(workflow, Path::new("ci.yml")).unwrap();

        // Stages
        assert_eq!(pipeline.stages, vec!["0", "1"]);

        // Global variables
        assert_eq!(pipeline.variables["RUST_BACKTRACE"].value(), "1");

        // Jobs
        assert_eq!(pipeline.jobs.len(), 2);
        assert!(pipeline.jobs.contains_key("build"));
        assert!(pipeline.jobs.contains_key("test"));

        // Build job has image from container
        let build = &pipeline.jobs["build"];
        assert_eq!(build.image.as_ref().unwrap().name(), "rust:latest");
        assert!(
            build
                .variables
                .as_ref()
                .unwrap()
                .contains_key("CARGO_TERM_COLOR")
        );
        assert!(build.script.is_some());

        // Test job has needs
        let test = &pipeline.jobs["test"];
        let need_names: Vec<&str> = test
            .needs
            .as_ref()
            .unwrap()
            .iter()
            .map(|n| n.job_name())
            .collect();
        assert_eq!(need_names, vec!["build"]);
    }

    #[test]
    fn test_runs_on_list() {
        let workflow = parse_yaml(
            r#"
on: push
jobs:
  build:
    runs-on: [self-hosted, linux, x64]
    steps: [{run: echo hi}]
"#,
        );
        let tags = workflow.jobs["build"].runs_on.as_ref().unwrap().to_tags();
        assert_eq!(tags, vec!["self-hosted", "linux", "x64"]);
    }

    #[test]
    fn test_step_with_condition() {
        let steps = vec![GhStep {
            name: Some("Deploy".to_string()),
            run: Some("deploy.sh".to_string()),
            uses: None,
            condition: Some("github.ref == 'refs/heads/main'".to_string()),
            with_args: None,
            env: None,
            shell: None,
            working_directory: None,
            extra: HashMap::new(),
        }];

        let lines = steps_to_script(
            &steps,
            &ExprContext {
                matrix: &HashMap::new(),
                env: &HashMap::new(),
                github: &HashMap::new(),
                runner: &HashMap::new(),
                inputs: &HashMap::new(),
            },
        );
        assert!(lines.contains(&"# Step: Deploy".to_string()));
        assert!(lines.contains(&"# if: github.ref == 'refs/heads/main'".to_string()));
        assert!(lines.contains(&"deploy.sh".to_string()));
    }

    #[test]
    fn test_expand_matrix() {
        let mut matrix = HashMap::new();
        matrix.insert("os".to_string(), "ubuntu-latest".to_string());
        matrix.insert("rust".to_string(), "stable".to_string());

        let ctx = ExprContext {
            matrix: &matrix,
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        assert_eq!(
            expand_expressions("${{ matrix.os }}", &ctx),
            "ubuntu-latest"
        );
        assert_eq!(
            expand_expressions("cargo +${{ matrix.rust }} build", &ctx),
            "cargo +stable build"
        );
        // No expansion when no values
        assert_eq!(
            expand_expressions(
                "${{ matrix.os }}",
                &ExprContext {
                    matrix: &HashMap::new(),
                    env: &HashMap::new(),
                    github: &HashMap::new(),
                    runner: &HashMap::new(),
                    inputs: &HashMap::new(),
                }
            ),
            "${{ matrix.os }}"
        );
    }

    #[test]
    fn test_extract_matrix() {
        let strategy: serde_yaml::Value = serde_yaml::from_str(
            r#"
matrix:
  os: [ubuntu-latest, macos-latest]
  rust: [stable, nightly]
"#,
        )
        .unwrap();

        let combos = extract_matrix(&Some(strategy));
        assert_eq!(combos.len(), 4);
        assert!(
            combos
                .iter()
                .any(|c| c["os"] == "ubuntu-latest" && c["rust"] == "stable")
        );
        assert!(
            combos
                .iter()
                .any(|c| c["os"] == "macos-latest" && c["rust"] == "nightly")
        );
    }

    #[test]
    fn test_extract_matrix_empty() {
        assert!(extract_matrix(&None).is_empty());

        let strategy: serde_yaml::Value = serde_yaml::from_str("fail-fast: false").unwrap();
        assert!(extract_matrix(&Some(strategy)).is_empty());
    }

    #[test]
    fn test_matrix_expansion_in_workflow() {
        let workflow = parse_yaml(
            r#"
on: push
jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
    steps:
      - name: Build on ${{ matrix.os }}
        run: echo ${{ matrix.os }}
"#,
        );

        let pipeline = convert_workflow(workflow, Path::new("ci.yml")).unwrap();

        // Should have 2 expanded jobs
        assert_eq!(pipeline.jobs.len(), 2);
        assert!(pipeline.jobs.contains_key("build [os: ubuntu-latest]"));
        assert!(pipeline.jobs.contains_key("build [os: macos-latest]"));

        // Tags should be expanded
        let ubuntu_job = &pipeline.jobs["build [os: ubuntu-latest]"];
        assert_eq!(ubuntu_job.tags.as_ref().unwrap(), &["ubuntu-latest"]);

        // Script should have expanded expressions
        let script = ubuntu_job.script.as_ref().unwrap();
        assert!(
            script
                .as_slice()
                .iter()
                .any(|l| l.contains("echo ubuntu-latest"))
        );
    }

    #[test]
    fn test_expand_env_expressions() {
        let mut env = HashMap::new();
        env.insert("MY_VAR".to_string(), "hello".to_string());
        env.insert("BUILD_DIR".to_string(), "/tmp/build".to_string());

        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &env,
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        assert_eq!(expand_expressions("${{ env.MY_VAR }}", &ctx), "hello");
        assert_eq!(
            expand_expressions("cd ${{ env.BUILD_DIR }}", &ctx),
            "cd /tmp/build"
        );
        // Mixed matrix + env
        let mut matrix = HashMap::new();
        matrix.insert("os".to_string(), "linux".to_string());
        let ctx = ExprContext {
            matrix: &matrix,
            env: &env,
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };
        assert_eq!(
            expand_expressions("${{ matrix.os }}: ${{ env.MY_VAR }}", &ctx),
            "linux: hello"
        );
    }

    #[test]
    fn test_env_expansion_in_workflow() {
        let pipeline = parse_workflow_file_from_str(
            r#"
on: push
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: myapp
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      TAG: latest
    steps:
      - name: Build image
        run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} .
"#,
        )
        .unwrap();

        let build = &pipeline.jobs["build"];
        let script = build.script.as_ref().unwrap();
        assert!(
            script
                .as_slice()
                .iter()
                .any(|l| l.contains("docker build -t ghcr.io/myapp:latest ."))
        );
    }

    #[test]
    fn test_expand_github_expressions() {
        let mut github = HashMap::new();
        github.insert("sha".to_string(), "abc123".to_string());
        github.insert("ref_name".to_string(), "main".to_string());
        github.insert("repository".to_string(), "owner/repo".to_string());

        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &github,
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        assert_eq!(expand_expressions("${{ github.sha }}", &ctx), "abc123");
        assert_eq!(
            expand_expressions("branch: ${{ github.ref_name }}", &ctx),
            "branch: main"
        );
        assert_eq!(
            expand_expressions("${{ github.repository }}", &ctx),
            "owner/repo"
        );
    }

    #[test]
    fn test_eval_expression_ternary() {
        let runner = HashMap::from([
            ("os".to_string(), "Linux".to_string()),
            ("arch".to_string(), "x86_64".to_string()),
            ("temp".to_string(), "/tmp".to_string()),
            ("tool_cache".to_string(), "/opt/hostedtoolcache".to_string()),
        ]);
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &runner,
            inputs: &HashMap::new(),
        };

        // Ternary: runner.os == 'Windows' && '$USERPROFILE\.cargo' || '$HOME/.cargo'
        assert_eq!(
            expand_expressions(
                "${{runner.os == 'Windows' && '$USERPROFILE\\.cargo' || '$HOME/.cargo'}}",
                &ctx
            ),
            "$HOME/.cargo"
        );

        // Ternary: runner.arch == 'ARM64' && 'aarch64' || 'x86_64'
        assert_eq!(
            expand_expressions(
                "${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}",
                &ctx
            ),
            "x86_64"
        );

        // Simple context ref still works
        assert_eq!(expand_expressions("${{ runner.os }}", &ctx), "Linux");
        assert_eq!(
            expand_expressions("${{ runner.tool_cache }}", &ctx),
            "/opt/hostedtoolcache"
        );

        // Chained OR: inputs.a || inputs.b || ''
        let inputs = HashMap::from([("a".to_string(), String::new())]);
        let ctx_with_inputs = ExprContext {
            inputs: &inputs,
            ..ctx
        };
        assert_eq!(
            expand_expressions("${{ inputs.a || '' }}", &ctx_with_inputs),
            ""
        );

        // Step outputs → bash variable reference for runtime resolution
        assert_eq!(
            expand_expressions("${{ steps.parse.outputs.toolchain }}", &ctx),
            "${_STEP_parse_toolchain}"
        );

        // Condition with ternary + unknown: should resolve to empty
        assert_eq!(
            expand_expressions(
                "${{steps.parse.outputs.toolchain == 'nightly' && inputs.components && ' --allow-downgrade' || ''}}",
                &ctx
            ),
            ""
        );
    }

    #[test]
    fn test_eval_condition_skips_windows() {
        let runner = HashMap::from([("os".to_string(), "Linux".to_string())]);
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &runner,
            inputs: &HashMap::new(),
        };

        // runner.os == 'Windows' should be false on Linux
        assert_eq!(eval_condition("runner.os == 'Windows'", &ctx), Some(false));
        // runner.os != 'Windows' should be true on Linux
        assert_eq!(eval_condition("runner.os != 'Windows'", &ctx), Some(true));
        // Wrapped in ${{ }}
        assert_eq!(
            eval_condition("${{ runner.os == 'Windows' }}", &ctx),
            Some(false)
        );
        // Unknown context ref → None (can't evaluate)
        assert_eq!(
            eval_condition("steps.check.outputs.should_run == 'true'", &ctx),
            None
        );
    }

    #[test]
    fn test_github_context_in_workflow() {
        // parse_workflow_file_from_str uses Path::new("workflow.yml") which won't
        // find a real git repo, so github.* values will be empty/defaults.
        // We test the mechanism through unit test above; here just verify no crash.
        let pipeline = parse_workflow_file_from_str(
            r#"
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Show ref
        run: echo ${{ github.ref_name }}
"#,
        )
        .unwrap();

        let build = &pipeline.jobs["build"];
        assert!(build.script.is_some());
    }

    #[test]
    fn test_expand_expressions_no_infinite_loop_on_dollar_brace_in_value() {
        // If a variable value itself contains "${{", the old code would
        // re-scan and loop forever. With position tracking, it terminates.
        let mut env = HashMap::new();
        env.insert("TRICKY".to_string(), "${{ not.real }}".to_string());
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &env,
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        // Should expand env.TRICKY to its value and stop — not re-expand
        let result = expand_expressions("val=${{ env.TRICKY }}", &ctx);
        assert_eq!(result, "val=${{ not.real }}");
    }

    #[test]
    fn test_expand_expressions_recursive_looking_value() {
        // Value expands to something that looks like another expression
        let mut env = HashMap::new();
        env.insert("A".to_string(), "${{ env.B }}".to_string());
        env.insert("B".to_string(), "final".to_string());
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &env,
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        // Should expand env.A to "${{ env.B }}" literally, not further expand
        let result = expand_expressions("${{ env.A }}", &ctx);
        assert_eq!(result, "${{ env.B }}");
    }

    #[test]
    fn test_expand_expressions_multiple_adjacent() {
        let mut matrix = HashMap::new();
        matrix.insert("os".to_string(), "linux".to_string());
        matrix.insert("ver".to_string(), "22.04".to_string());
        let ctx = ExprContext {
            matrix: &matrix,
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        let result = expand_expressions("${{ matrix.os }}-${{ matrix.ver }}", &ctx);
        assert_eq!(result, "linux-22.04");
    }

    #[test]
    fn test_expand_expressions_unclosed_brace() {
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &HashMap::new(),
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        // Unclosed ${{ should be left as-is
        let result = expand_expressions("prefix ${{ no close", &ctx);
        assert_eq!(result, "prefix ${{ no close");
    }

    #[test]
    fn test_expand_expressions_value_containing_closing_braces() {
        // Value with }} in it — should not confuse the parser after expansion
        let mut env = HashMap::new();
        env.insert("JSON".to_string(), "{\"key\": \"val\"}}extra".to_string());
        let ctx = ExprContext {
            matrix: &HashMap::new(),
            env: &env,
            github: &HashMap::new(),
            runner: &HashMap::new(),
            inputs: &HashMap::new(),
        };

        let result = expand_expressions("data=${{ env.JSON }}", &ctx);
        assert_eq!(result, "data={\"key\": \"val\"}}extra");
    }
}
