mod app;
mod artifacts;
mod config;
mod container;
mod dispatch;
mod expr;
mod git_utils;
mod graph;
mod model;
mod parser;
mod remote;
mod resolve;
mod ui;

use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;

use log::info;

use indexmap::IndexMap;

use anyhow::{Context, Result};
use clap::{Args as ClapArgs, CommandFactory, Parser as ClapParser, Subcommand};
use clap_complete::Shell;
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, MouseEventKind},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
    Terminal,
    backend::CrosstermBackend,
    layout::{Constraint, Layout},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Widget},
};

use app::{App, Focus, InputResult};
use ui::{DagView, JobDetailView, Theme};

#[derive(ClapParser)]
#[command(name = "cui-cui", version = env!("CUI_CUI_VERSION"))]
#[command(about = "A TUI for visualizing and debugging CI pipelines (GitLab CI, GitHub Actions)")]
struct Cli {
    /// Path to the project directory (auto-detects CI config from here by default)
    #[arg(default_value = ".")]
    path: PathBuf,

    /// Path to CI config file or directory (defaults to the project path).
    /// Use when CI config lives in a separate repo from the project source.
    /// Example: cui-cui --ci-config ~/Src/kernel-ci/ ~/Src/hid
    #[arg(long)]
    ci_config: Option<PathBuf>,

    /// Override the CI project path (e.g. "group/project").
    /// Auto-detected from git remote if not specified.
    #[arg(long)]
    ci_project: Option<String>,

    /// GitLab instance URL (auto-detected from git remote if not specified)
    #[arg(long)]
    gitlab_url: Option<String>,

    /// Container registry hostname (e.g. "registry.gitlab.gnome.org")
    #[arg(long)]
    ci_registry: Option<String>,

    /// Use light theme (auto-detected if neither --light nor --dark is given)
    #[arg(long)]
    light: bool,

    /// Use dark theme (auto-detected if neither --light nor --dark is given)
    #[arg(long)]
    dark: bool,

    /// Disable overlay mount for containers (use read-only bind mount instead)
    #[arg(long)]
    no_overlay: bool,

    /// Override the pipeline ID, optionally with project path: ID or PROJECT/ID
    /// (e.g. "12345" or "mesa/mesa/12345")
    #[arg(long)]
    pipeline_id: Option<String>,

    /// Forward a host environment variable into the container (can be repeated).
    /// Example: --pass-env GITHUB_TOKEN --pass-env DOCKER_PASSWORD
    #[arg(long)]
    pass_env: Vec<String>,

    /// Set a variable for job execution (can be repeated).
    /// Use KEY=VALUE to set explicitly, or just KEY to read from host environment.
    /// These override job-level variables and are visible to rules evaluation.
    /// For GitHub Actions, INPUT_* variables override action input defaults
    /// (e.g. -e INPUT_UPSTREAM_REPO=bluez/bluez).
    /// Example: -e TEST_TARGET=foo -e HOME -e INPUT_MY_ARG=value
    #[arg(short = 'e', long = "env")]
    env_vars: Vec<String>,

    /// List all jobs grouped by stage and exit.
    #[arg(long)]
    list_jobs: bool,

    /// Print the local shell script for a job and exit.
    /// Supports fuzzy matching (exact, case-insensitive, substring).
    #[arg(long, value_name = "JOB")]
    dump_script: Option<String>,

    /// Run a job non-interactively in a container and exit (no TUI).
    /// Like pressing r/R in the TUI. Supports fuzzy matching.
    #[arg(long, value_name = "JOB")]
    run: Option<String>,

    /// Load a pre-resolved job spec from a JSON file instead of parsing
    /// the CI config (for --run / --container / --prep-job). Used by remote
    /// runners when the CI config lives in a separate repo that isn't
    /// available on the remote. Dispatch fields (sha, diff, container_name)
    /// are also read from this spec.
    #[arg(long, value_name = "FILE")]
    job_spec: Option<PathBuf>,

    /// Launch an interactive container for a job and exit (no TUI).
    /// Like pressing c/C in the TUI. Supports fuzzy matching.
    #[arg(long, value_name = "JOB")]
    container: Option<String>,

    /// Run the container in privileged mode (for --run / --container).
    #[arg(long)]
    privileged: bool,

    /// Run the pipeline locally: walk the DAG and execute each job in a container.
    #[arg(long)]
    dispatch: bool,

    /// Include jobs with no tags in --dispatch mode.
    #[arg(long)]
    no_tags: bool,

    /// Include jobs whose tags are a subset of this list in --dispatch mode.
    /// Comma-separated. Example: --tags kvm,x86_64
    #[arg(long, value_delimiter = ',')]
    tags: Vec<String>,

    /// Skip jobs/stages in --dispatch mode (treated as passed for dependencies).
    /// Supports fuzzy matching. Prefix with stage= or job= to restrict matching.
    /// Can be repeated. Examples: --skip container-prep --skip stage=build
    #[arg(long)]
    skip: Vec<String>,

    /// Allow unresolved extends (missing templates are skipped with a warning).
    /// Useful when remote includes use glob patterns that can't be fetched.
    #[arg(long)]
    allow_unresolved: bool,

    /// Container image used for git operations (clone, apply) in dispatch mode.
    /// Defaults to "docker.io/alpine/git:latest".
    #[arg(long)]
    git_image: Option<String>,

    /// Prepare a per-job worktree and exit. Requires --job-spec with a sha
    /// field. Creates a git worktree, applies the diff if present in the
    /// spec, and prints the worktree path to stdout.
    #[arg(long, hide = true, value_name = "JOB")]
    prep_job: Option<String>,

    #[command(subcommand)]
    command: Option<Commands>,

    /// Generate shell completions and exit.
    /// Usage: cui-cui --completions bash > cui-cui.bash
    #[arg(long, value_name = "SHELL")]
    completions: Option<Shell>,
}

#[derive(Subcommand)]
enum Commands {
    /// Evaluate a Moa expression and print the result.
    Moa(MoaArgs),
}

#[derive(ClapArgs)]
struct MoaArgs {
    /// The expression to evaluate
    expr: String,

    /// JSON object of inputs (e.g. '{"name":"world"}')
    #[arg(long, value_name = "JSON")]
    inputs_json: Option<String>,

    /// JSON object of CI variables (e.g. '{"CI_PROJECT_NAME":"foo"}')
    #[arg(long, value_name = "JSON")]
    vars_json: Option<String>,

    /// Directory containing step output files.
    /// Each file is named after the step and contains key=value lines.
    #[arg(long, value_name = "DIR")]
    step_outputs_dir: Option<PathBuf>,

    /// Path to the current step's output file
    #[arg(long, value_name = "FILE")]
    output_file: Option<String>,

    /// Path to the current step's export file
    #[arg(long, value_name = "FILE")]
    export_file: Option<String>,
}

/// Parse `-e` entries into a map. `KEY=VALUE` sets explicitly; bare `KEY`
/// reads the current value from the host environment.
fn parse_env_overrides(entries: &[String]) -> IndexMap<String, String> {
    let mut vars = IndexMap::new();
    for entry in entries {
        if let Some((key, value)) = entry.split_once('=') {
            vars.insert(key.to_string(), value.to_string());
        } else if let Ok(value) = std::env::var(entry) {
            vars.insert(entry.clone(), value);
        } else {
            eprintln!(
                "\x1b[33mWarning: -e {}: variable not found in environment\x1b[0m",
                entry
            );
        }
    }
    vars
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    if let Some(shell) = cli.completions {
        clap_complete::generate(
            shell,
            &mut Cli::command(),
            "cui-cui",
            &mut std::io::stdout(),
        );
        return Ok(());
    }

    if let Some(Commands::Moa(ref moa_args)) = cli.command {
        return run_moa(moa_args);
    }

    let headless = cli.list_jobs
        || cli.dump_script.is_some()
        || cli.run.is_some()
        || cli.container.is_some()
        || cli.dispatch
        || cli.prep_job.is_some();
    let log_file = setup_logging(headless);

    // --prep-job: create a per-job worktree and exit (no CI parsing needed).
    if let Some(ref job_name) = cli.prep_job {
        return prep_job(job_name, &cli);
    }

    // Load and merge config files (user-level, then repo-level, then CLI overrides)
    let user_config = config::load_user_config();
    let repo_config = config::load_repo_config(&cli.path);
    let file_config = config::merge(user_config, repo_config);

    // --job-spec + --run/--container: skip all CI parsing, run directly from
    // the spec. This must happen before detect_ci_file() which fails when the
    // CI config doesn't exist (the whole point of --job-spec).
    if let Some(spec_path) = &cli.job_spec {
        if let Some(query) = &cli.run {
            return run_from_job_spec(spec_path, query, &cli, &file_config);
        }
        if let Some(query) = &cli.container {
            return container_from_job_spec(spec_path, query, &cli, &file_config);
        }
    }

    // Resolve CI config path: --ci-config overrides the positional path
    let ci_config_override = cli
        .ci_config
        .or_else(|| file_config.ci_config.map(PathBuf::from));
    let ci_config_path = ci_config_override
        .as_ref()
        .cloned()
        .unwrap_or_else(|| cli.path.clone());

    let (ci_files, ci_system) = detect_ci_files(&ci_config_path);
    let all_workflow_files: Vec<PathBuf> = ci_files
        .into_iter()
        .map(|p| p.canonicalize().unwrap_or(p))
        .collect();
    let file_path = all_workflow_files[0].clone();

    // When --ci-config is used, the positional arg is the project directory;
    // git context and container mounts should use the project, not the CI config.
    let has_separate_project = ci_config_override.is_some();
    let project_dir = if has_separate_project {
        Some(cli.path.canonicalize().unwrap_or(cli.path))
    } else {
        None
    };

    let gitlab_url = cli.gitlab_url.or(file_config.gitlab_url);
    let ci_registry = cli.ci_registry.or(file_config.ci_registry);
    let ci_project = cli.ci_project.or(file_config.ci_project);
    let allow_unresolved = cli.allow_unresolved || file_config.allow_unresolved.unwrap_or(false);

    // Collect variable overrides from config + CLI. These override job-level
    // definitions and are forwarded to child pipelines.
    let mut var_overrides = IndexMap::new();
    if let Some(ref config_vars) = file_config.variables {
        var_overrides.extend(config_vars.clone());
    }
    var_overrides.extend(parse_env_overrides(&cli.env_vars));

    let (gitlab_config, git_context, pipeline, effective_file_path) = match ci_system {
        model::CiSystem::GitHub => {
            if all_workflow_files.len() > 1 {
                info!(
                    "Detected {} GitHub Actions workflows",
                    all_workflow_files.len()
                );
                let pipeline = build_github_meta_pipeline(&all_workflow_files, &file_path);
                let workflows_dir = file_path.parent().unwrap_or(&file_path).to_path_buf();
                (
                    None,
                    resolve::GitContext::default(),
                    pipeline,
                    workflows_dir,
                )
            } else {
                info!("Detected GitHub Actions workflow: {}", file_path.display());
                let pipeline = resolve_github_workflow(&file_path, &var_overrides)?;
                (
                    None,
                    resolve::GitContext::default(),
                    pipeline,
                    file_path.clone(),
                )
            }
        }
        model::CiSystem::GitLab => {
            let gitlab_config =
                parser::gitlab_fetch::GitlabConfig::detect(&file_path, gitlab_url.as_deref());
            if let Some(ref config) = gitlab_config {
                info!("Detected GitLab instance: {}", config.base_url);
            }

            let raw =
                parser::parse_pipeline_file(&file_path, gitlab_config.as_ref(), allow_unresolved)
                    .with_context(|| format!("Failed to parse {}", file_path.display()))?;

            // Git context: server_url/project_path come from the CI config repo
            // (needed for CI_REGISTRY_IMAGE etc.), but commit info comes from the
            // project dir when --ci-config is used.
            let mut git_context = resolve::GitContext::detect(&file_path);
            if let Some(ref proj_dir) = project_dir {
                let project_context = resolve::GitContext::detect(proj_dir);
                git_context.commit_sha = project_context.commit_sha;
                git_context.branch = project_context.branch;
                git_context.commit_title = project_context.commit_title;
                git_context.commit_message = project_context.commit_message;
                git_context.commit_author = project_context.commit_author;
                git_context.repo_dir = project_context.repo_dir;
            }

            if let Some(ref url) = gitlab_url {
                git_context.server_url = resolve::GitContext::normalize_server_url(url);
            }

            if let Some(ref project) = ci_project {
                git_context.project_path = project.clone();
            }

            if let Some(ref registry) = ci_registry {
                git_context.registry = Some(registry.clone());
            }

            if let Some(ref repo_root) = git_context.repo_dir
                && let Ok(rel) = file_path.strip_prefix(repo_root)
            {
                git_context.config_path = Some(rel.to_string_lossy().into_owned());
            }

            if let Some(ref pipeline_arg) = cli.pipeline_id {
                if let Some((project, id)) = pipeline_arg.rsplit_once('/') {
                    git_context.project_path = project.to_string();
                    git_context.pipeline_id = Some(id.to_string());
                } else {
                    git_context.pipeline_id = Some(pipeline_arg.clone());
                }
            }
            let resolve_result = if allow_unresolved {
                resolve::resolve_pipeline_tolerant(raw, &git_context, &var_overrides)
            } else {
                resolve::resolve_pipeline(raw, &git_context, &var_overrides)
            };
            let pipeline = match resolve_result {
                Ok(p) => p,
                Err(e) if !allow_unresolved => {
                    eprintln!(
                        "\x1b[33mTip: try --allow-unresolved if remote includes have missing templates\x1b[0m"
                    );
                    return Err(e.context("Failed to resolve pipeline"));
                }
                Err(e) => return Err(e.context("Failed to resolve pipeline")),
            };
            (gitlab_config, git_context, pipeline, file_path.clone())
        }
    };
    let file_path = effective_file_path;

    // Compute container options early (used by both headless and TUI paths)
    let no_overlay = cli.no_overlay || file_config.no_overlay.unwrap_or(false);
    let pass_env = if cli.pass_env.is_empty() {
        file_config.pass_env.unwrap_or_default()
    } else {
        cli.pass_env
    };
    let privileged = cli.privileged;
    let git_image = cli
        .git_image
        .or(file_config.git_image)
        .unwrap_or_else(|| container::DEFAULT_GIT_IMAGE.to_string());

    // Load runners early so --run, --container, and TUI modes can use them
    let runners = file_config.runners.unwrap_or_default();

    let resolve_ctx = resolve::ResolveContext {
        gitlab_config: gitlab_config.as_ref(),
        project_root: project_dir.as_deref(),
        allow_unresolved,
        git_context: &git_context,
        var_overrides: &var_overrides,
    };

    // --- Headless modes (no TUI) ---

    if cli.list_jobs {
        if ci_system == model::CiSystem::GitHub && all_workflow_files.len() > 1 {
            for wf_path in &all_workflow_files {
                let filename = wf_path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();
                match resolve_github_workflow(wf_path, &var_overrides) {
                    Ok(p) => {
                        println!("# {}", filename);
                        for stage in &p.stages {
                            println!("{}:", stage.name);
                            for job_name in &stage.job_names {
                                println!("  {}", job_name);
                            }
                        }
                    }
                    Err(e) => eprintln!("# {} (error: {})", filename, e),
                }
            }
        } else {
            for stage in &pipeline.stages {
                println!("{}:", stage.name);
                for job_name in &stage.job_names {
                    println!("  {}", job_name);
                }
            }
        }
        return Ok(());
    }

    if let Some(ref query) = cli.dump_script {
        let resolved = find_job_any_pipeline(
            query,
            &pipeline,
            &file_path,
            ci_system,
            &resolve_ctx,
            &all_workflow_files,
        )?;
        let effective = resolved.pipeline(&pipeline);
        println!(
            "{}",
            effective.jobs[&resolved.job_name].build_local_script(ci_system)
        );
        return Ok(());
    }

    // Config directory for resolving @file references in runner prepare/cleanup
    let config_dir = project_dir
        .as_deref()
        .unwrap_or_else(|| file_path.parent().unwrap_or(&file_path))
        .to_path_buf();

    if let Some(ref query) = cli.run {
        let resolved = find_job_any_pipeline(
            query,
            &pipeline,
            &file_path,
            ci_system,
            &resolve_ctx,
            &all_workflow_files,
        )?;
        let effective_pipeline = resolved.pipeline(&pipeline);
        let job_name = &resolved.job_name;
        let effective_file_path = &resolved.file_path;
        let job = &effective_pipeline.jobs[job_name];
        let mut config = container::build_config_for_job(
            job,
            ci_system,
            effective_file_path,
            project_dir.as_deref(),
            &pass_env,
            no_overlay,
            privileged,
            &git_context,
            &git_image,
        )
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Job '{}' has no container image (no image/runs-on mapping)",
                job_name
            )
        })?;

        // Shared-container path for GitHub Actions step-pipelines
        if effective_pipeline.execution == model::ExecutionMode::SharedContainer {
            println!("\x1b[1;36m▶ Running step: {}\x1b[0m\n", job_name);
            let exit_code = run_shared_container(&config, effective_pipeline, job_name, false)?;
            std::process::exit(exit_code);
        }

        resolve_and_attach_artifacts(
            &mut config,
            git_context.pipeline_id.as_deref(),
            gitlab_config.as_ref(),
            &git_context.project_path,
            Some(effective_pipeline),
        );

        let log_file = tempfile::Builder::new()
            .prefix(&format!("cui-cui-run-{}-", job_name.replace('/', "_")))
            .suffix(".log")
            .tempfile()
            .context("Failed to create log file")?;
        let log_path = log_file.path().to_path_buf();

        // Check for runner match (no load tracking for single-job --run)
        let no_load = std::collections::HashMap::new();
        let runner = dispatch::find_runner(&runners, &job.tags, &no_load, &no_load);

        if let Some(r) = runner {
            println!(
                "\x1b[1;36m▶ Running job: {} → runner '{}' ({})\x1b[0m\n",
                job_name,
                r.name,
                r.host.as_deref().unwrap_or("local"),
            );
        } else {
            println!("\x1b[1;36m▶ Running job: {}\x1b[0m\n", job_name);
        }

        let diff = container::prompt_uncommitted_changes(&config.project_dir)?;

        // Upload artifacts to remote runner before job execution
        if let Some(r) = runner
            && r.host.is_some()
            && !config.artifact_dirs.is_empty()
            && let Err(e) = remote::upload_artifacts(r, &config.project_path, &config.artifact_dirs)
        {
            eprintln!(
                "\x1b[33mWarning: failed to upload artifacts for '{}': {}\x1b[0m",
                job_name, e
            );
        }

        if let Some(r) = runner {
            config.apply_runner_runtime(r.runtime.as_deref(), r.kernel(), r.memory(), r.cpus());
        }

        // Save fields needed after config may be moved into run_container
        let job_artifacts_paths = config.artifacts_paths.clone();
        let job_artifacts_exclude = config.artifacts_exclude.clone();
        let job_artifacts_untracked = config.artifacts_untracked;
        let job_artifacts_when = config.artifacts_when.clone();
        let job_project_path = config.project_path.clone();

        let status = if let Some(runner) = runner {
            remote::run_on_runner(
                &config,
                runner,
                &log_path,
                diff.as_deref(),
                &config_dir,
                &container::OutputConfig::default(),
                None,
            )?
        } else {
            container::run_container(
                config,
                &log_path,
                diff.as_deref(),
                None,
                &container::OutputConfig::default(),
            )?
        };

        // Download artifacts from remote runner based on artifacts:when policy
        if let Some(r) = runner
            && r.host.is_some()
            && container::should_save_artifacts(status.exit_code, &job_artifacts_when)
            && (!job_artifacts_paths.is_empty() || job_artifacts_untracked)
            && let Err(e) = remote::download_artifacts(
                r,
                job_name,
                &job_project_path,
                &job_artifacts_paths,
                &job_artifacts_exclude,
                job_artifacts_untracked,
                &container::OutputConfig::default(),
            )
        {
            eprintln!(
                "\x1b[33mWarning: failed to download artifacts for '{}': {}\x1b[0m",
                job_name, e
            );
        }

        if status.exit_code == 0 {
            println!(
                "\x1b[1;32m✓ Job '{}' passed\x1b[0m (exit code 0, {})",
                job_name,
                status.format_timing()
            );
        } else if job.allow_failure {
            println!(
                "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {})",
                job_name,
                status.exit_code,
                status.format_timing()
            );
        } else {
            println!(
                "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {})",
                job_name,
                status.exit_code,
                status.format_timing()
            );
        }
        println!("  Log: {}", log_path.display());
        let exit_code = if job.allow_failure {
            0
        } else {
            status.exit_code
        };
        std::process::exit(exit_code);
    }

    if let Some(ref query) = cli.container {
        let resolved = find_job_any_pipeline(
            query,
            &pipeline,
            &file_path,
            ci_system,
            &resolve_ctx,
            &all_workflow_files,
        )?;
        let effective_pipeline = resolved.pipeline(&pipeline);
        let job_name = &resolved.job_name;
        let effective_file_path = &resolved.file_path;
        let job = &effective_pipeline.jobs[job_name];
        let mut config = container::build_config_for_job(
            job,
            ci_system,
            effective_file_path,
            project_dir.as_deref(),
            &pass_env,
            no_overlay,
            privileged,
            &git_context,
            &git_image,
        )
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Job '{}' has no container image (no image/runs-on mapping)",
                job_name
            )
        })?;

        // Shared-container path for GitHub Actions step-pipelines
        if effective_pipeline.execution == model::ExecutionMode::SharedContainer {
            println!(
                "\x1b[1;36m▶ Launching container at step: {}\x1b[0m\n",
                job_name
            );
            let exit_code = run_shared_container(&config, effective_pipeline, job_name, true)?;
            std::process::exit(exit_code);
        }

        resolve_and_attach_artifacts(
            &mut config,
            git_context.pipeline_id.as_deref(),
            gitlab_config.as_ref(),
            &git_context.project_path,
            Some(effective_pipeline),
        );

        // Check for runner match (no load tracking for single-job --container)
        let no_load = std::collections::HashMap::new();
        let runner = dispatch::find_runner(&runners, &job.tags, &no_load, &no_load);

        if let Some(r) = runner {
            config.apply_runner_runtime(r.runtime.as_deref(), r.kernel(), r.memory(), r.cpus());
            println!(
                "\x1b[1;36m▶ Launching container: {} → runner '{}' ({})\x1b[0m\n",
                job_name,
                r.name,
                r.host.as_deref().unwrap_or("local"),
            );
            return remote::launch_on_runner(&config, r, &config_dir);
        }

        return container::launch_container(config, None);
    }

    if cli.dispatch {
        let mut tag_filter = dispatch::TagFilter {
            no_tags: cli.no_tags,
            tags: cli.tags,
        };
        dispatch::extend_tag_filter_with_runners(&mut tag_filter, &runners);
        let mut skip_patterns = file_config.dispatch_skip.unwrap_or_default();
        skip_patterns.extend(cli.skip);

        let mut all_passed = true;

        if ci_system == model::CiSystem::GitHub && all_workflow_files.len() > 1 {
            // GitHub multi-workflow: dispatch each code-relevant workflow.
            // No trigger-following needed — GitHub workflows don't use
            // trigger: include: local: child pipelines.
            for wf_path in &all_workflow_files {
                let filename = wf_path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();
                if let Some((_, Some(ref trigger))) = parser::github::parse_workflow_header(wf_path)
                    && !parser::github::has_code_triggers(trigger)
                {
                    eprintln!("Skipping non-code workflow: {}", filename);
                    continue;
                }

                eprintln!("\x1b[1;36m>>> Dispatching workflow: {}\x1b[0m", filename);

                let wf_pipeline = resolve_github_workflow(wf_path, &var_overrides)?;

                let wf_skip_jobs = resolve_skip_patterns(&skip_patterns, &wf_pipeline);
                let success = dispatch::run_dispatch(
                    &wf_pipeline,
                    ci_system,
                    wf_path,
                    project_dir.as_deref(),
                    &pass_env,
                    no_overlay,
                    privileged,
                    &git_context,
                    gitlab_config.as_ref(),
                    &tag_filter,
                    &wf_skip_jobs,
                    &git_image,
                    &runners,
                )?;
                if !success {
                    all_passed = false;
                }
            }
        } else {
            let skip_jobs = resolve_skip_patterns(&skip_patterns, &pipeline);
            let success = dispatch::run_dispatch(
                &pipeline,
                ci_system,
                &file_path,
                project_dir.as_deref(),
                &pass_env,
                no_overlay,
                privileged,
                &git_context,
                gitlab_config.as_ref(),
                &tag_filter,
                &skip_jobs,
                &git_image,
                &runners,
            )?;
            if !success {
                all_passed = false;
            }

            // Follow trigger jobs into child pipelines
            let root_dir = file_path.parent().unwrap_or(&file_path);
            for (name, job) in &pipeline.jobs {
                let Some(ref tf) = job.trigger_file else {
                    continue;
                };
                if job.rules_never {
                    continue;
                }
                let child_path = root_dir.join(tf.trim_start_matches('/'));
                if !child_path.exists() {
                    eprintln!(
                        "\x1b[33mWarning: child pipeline '{}' not found, skipping\x1b[0m",
                        child_path.display()
                    );
                    continue;
                }
                eprintln!(
                    "\x1b[1;36m>>> Dispatching child pipeline: {} ({})\x1b[0m",
                    name, tf
                );

                let resolve_result = match ci_system {
                    model::CiSystem::GitHub => resolve_github_workflow(&child_path, &var_overrides),
                    model::CiSystem::GitLab => {
                        resolve::resolve_child_pipeline(&child_path, &resolve_ctx)
                    }
                };
                let child_pipeline = match resolve_result {
                    Ok(p) => p,
                    Err(e) => {
                        eprintln!(
                            "\x1b[31mError resolving child pipeline {}: {}\x1b[0m",
                            tf, e
                        );
                        all_passed = false;
                        continue;
                    }
                };

                let child_skip = resolve_skip_patterns(&skip_patterns, &child_pipeline);
                let child_ok = dispatch::run_dispatch(
                    &child_pipeline,
                    ci_system,
                    &child_path,
                    project_dir.as_deref(),
                    &pass_env,
                    no_overlay,
                    privileged,
                    &git_context,
                    gitlab_config.as_ref(),
                    &tag_filter,
                    &child_skip,
                    &git_image,
                    &runners,
                )?;
                if !child_ok {
                    all_passed = false;
                }
            }
        }

        std::process::exit(if all_passed { 0 } else { 1 });
    }

    // --- TUI mode ---

    // Detect theme before entering raw mode (OSC queries need normal terminal)
    let theme = if cli.light {
        Theme::light()
    } else if cli.dark {
        Theme::dark()
    } else {
        match file_config.theme {
            Some(config::ThemePreference::Light) => Theme::light(),
            Some(config::ThemePreference::Dark) => Theme::dark(),
            _ => match terminal_light::luma() {
                Ok(luma) if luma > 0.6 => Theme::light(),
                _ => Theme::dark(),
            },
        }
    };

    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut app = App::new(file_path, ci_system, gitlab_config, git_context, pipeline);
    app.allow_unresolved = allow_unresolved;
    app.var_overrides = var_overrides;
    app.container_opts = app::ContainerOptions {
        no_overlay,
        pass_env,
        project_dir,
        git_image: git_image.clone(),
    };
    let mut dragging_border = false;

    // Main loop
    let result = run_app(
        &mut terminal,
        &mut app,
        &mut dragging_border,
        &theme,
        &runners,
        &config_dir,
    );

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    dump_logs(log_file.as_ref().map(|f| f.path()));

    result
}

fn run_app(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    dragging_border: &mut bool,
    theme: &Theme,
    runners: &[config::RunnerConfig],
    config_dir: &Path,
) -> Result<()> {
    enum PendingAction {
        OpenChild(PathBuf),
        OpenSteps,
    }
    let mut pending_action: Option<PendingAction> = None;

    loop {
        terminal.draw(|frame| {
            let size = frame.area();

            // Main layout: title bar, DAG, detail panel, status bar
            let chunks = Layout::vertical([
                Constraint::Length(1),                    // Title / tab bar
                Constraint::Min(8),                      // DAG
                Constraint::Length(app.detail_height),    // Detail
                Constraint::Length(1),                    // Status
            ])
            .split(size);

            // Title bar / tab bar
            let title_spans = build_tab_bar(app, theme);
            Paragraph::new(Line::from(title_spans)).render(chunks[0], frame.buffer_mut());

            // DAG view
            let dag_block = Block::default()
                .borders(Borders::ALL)
                .border_style(if app.focus == Focus::Dag {
                    Style::default().fg(theme.border_focused)
                } else {
                    Style::default().fg(theme.border_unfocused)
                })
                .title(match app.ci_system {
                    model::CiSystem::GitHub => " Workflow ",
                    model::CiSystem::GitLab => " Pipeline ",
                });
            let dag_inner = dag_block.inner(chunks[1]);
            app.dag_area_width = dag_inner.width;
            app.dag_area_height = dag_inner.height;
            app.dag_area = dag_inner;
            dag_block.render(chunks[1], frame.buffer_mut());

            // Detail view
            let detail_block = Block::default()
                .borders(Borders::ALL)
                .border_style(if app.focus == Focus::Detail {
                    Style::default().fg(theme.border_focused)
                } else {
                    Style::default().fg(theme.border_unfocused)
                })
                .title(" Job Details ");
            let detail_inner = detail_block.inner(chunks[2]);
            app.detail_area = detail_inner;
            detail_block.render(chunks[2], frame.buffer_mut());

            // Now borrow tab for rendering (after all app mutations are done)
            let tab = app.current_tab();

            let dag_view = DagView {
                layout: &tab.layout,
                selected_job: tab.selected_job.as_deref(),
                scroll_x: tab.dag_scroll_x,
                scroll_y: tab.dag_scroll_y,
                theme,
                pipeline: &tab.pipeline,
                ci_system: app.ci_system,
                run_results: &app.run_results,
            };
            dag_view.render(dag_inner, frame.buffer_mut());

            if let Some(ref selected_name) = tab.selected_job {
                if let Some(job) = tab.pipeline.jobs.get(selected_name) {
                    let detail = JobDetailView {
                        job,
                        edited_vars: app.edited_vars.get(selected_name),
                        tab: tab.detail_tab,
                        scroll: tab.detail_scroll,
                        theme,
                        ci_system: app.ci_system,
                    };
                    detail.render(detail_inner, frame.buffer_mut());
                }
            } else {
                let msg = Paragraph::new("No job selected")
                    .style(Style::default().fg(theme.text_dim));
                msg.render(detail_inner, frame.buffer_mut());
            }

            // Status bar
            let job_count = tab.pipeline.jobs.len();
            let stage_count = tab.pipeline.stages.len();
            let status = Line::from(vec![
                Span::styled(
                    format!(" {} jobs, {} stages ", job_count, stage_count),
                    Style::default().fg(theme.text_dim),
                ),
                Span::raw("  "),
                Span::styled(
                    "q:quit  hjkl:navigate  Tab:detail tab  Enter:open/focus  [/]:switch  Backspace:close  F5:reload  r/R:run  c/C:container  v:vars  y:copy",
                    Style::default().fg(theme.text_dim),
                ),
            ]);
            if let Some(ref err) = app.error {
                let err_line = Line::from(Span::styled(
                    format!(" Error: {} ", err),
                    Style::default().fg(theme.error),
                ));
                Paragraph::new(err_line).render(chunks[3], frame.buffer_mut());
            } else if let Some(ref msg) = app.status_message {
                let msg_line = Line::from(Span::styled(
                    format!(" {} ", msg),
                    Style::default().fg(theme.success),
                ));
                Paragraph::new(msg_line).render(chunks[3], frame.buffer_mut());
            } else {
                Paragraph::new(status).render(chunks[3], frame.buffer_mut());
            }
        })?;

        if let Some(action) = pending_action.take() {
            match action {
                PendingAction::OpenChild(path) => {
                    if let Err(e) = app.open_child_tab(path) {
                        app.error = Some(format!("Failed to open child pipeline: {}", e));
                    }
                    app.status_message = None;
                }
                PendingAction::OpenSteps => {
                    if let Err(e) = app.open_step_pipeline() {
                        app.error = Some(format!("Failed to open step pipeline: {}", e));
                    }
                    app.status_message = None;
                }
            }
            continue;
        }

        // Wait for an event, then process it and drain any remaining queued events
        // before looping back to redraw. This avoids both busy-looping (zero CPU
        // at idle) and sluggishness (batched processing of rapid input).
        event::poll(Duration::MAX)?;
        while event::poll(Duration::ZERO)? {
            match event::read()? {
                Event::Key(key) => {
                    let result = ui::handle_input(app, key);
                    let is_run = matches!(result, InputResult::RunJob(_));
                    match result {
                        InputResult::Quit => return Ok(()),
                        InputResult::Reload => {
                            if let Err(e) = app.reload() {
                                app.error = Some(e.to_string());
                            }
                        }
                        InputResult::EditVariables(job_name) => {
                            let vars = collect_job_variables(app, &job_name);
                            // Suspend TUI
                            disable_raw_mode()?;
                            execute!(
                                terminal.backend_mut(),
                                LeaveAlternateScreen,
                                DisableMouseCapture
                            )?;
                            terminal.show_cursor()?;

                            match edit_variables_in_editor(&vars) {
                                Ok(new_vars) => {
                                    app.edited_vars.insert(job_name.clone(), new_vars);
                                    println!(
                                        "\x1b[1;32m✓ Variables updated for '{}'\x1b[0m",
                                        job_name
                                    );
                                }
                                Err(e) => {
                                    eprintln!("\x1b[31mError editing variables: {}\x1b[0m", e);
                                }
                            }

                            println!("\nPress Enter to return to the TUI...");
                            let _ = std::io::stdin().read_line(&mut String::new());

                            // Resume TUI
                            enable_raw_mode()?;
                            execute!(
                                terminal.backend_mut(),
                                EnterAlternateScreen,
                                EnableMouseCapture
                            )?;
                            terminal.clear()?;
                        }
                        InputResult::LaunchContainer(boxed_config)
                        | InputResult::RunJob(boxed_config) => {
                            let mut config = *boxed_config;
                            // Suspend TUI
                            disable_raw_mode()?;
                            execute!(
                                terminal.backend_mut(),
                                LeaveAlternateScreen,
                                DisableMouseCapture
                            )?;
                            terminal.show_cursor()?;

                            // Shared-container path for step-pipelines
                            if app.current_tab().pipeline.execution
                                == model::ExecutionMode::SharedContainer
                            {
                                let job_name = config.job_name.clone();
                                let current_pipeline = app.current_tab().pipeline.clone();
                                if is_run {
                                    println!("\x1b[1;36m▶ Running step: {}\x1b[0m\n", job_name);
                                } else {
                                    println!(
                                        "\x1b[1;36m▶ Launching container at step: {}\x1b[0m\n",
                                        job_name
                                    );
                                }
                                match run_shared_container(
                                    &config,
                                    &current_pipeline,
                                    &job_name,
                                    !is_run,
                                ) {
                                    Ok(exit_code) => {
                                        if is_run {
                                            app.run_results.insert(job_name.clone(), exit_code);
                                        }
                                    }
                                    Err(e) => {
                                        eprintln!("\x1b[31mError: {}\x1b[0m", e);
                                    }
                                }

                                println!("\nPress Enter to return to the TUI...");
                                let _ = std::io::stdin().read_line(&mut String::new());

                                enable_raw_mode()?;
                                execute!(
                                    terminal.backend_mut(),
                                    EnterAlternateScreen,
                                    EnableMouseCapture
                                )?;
                                terminal.clear()?;
                                continue;
                            }

                            resolve_and_attach_artifacts(
                                &mut config,
                                app.git_context.pipeline_id.as_deref(),
                                app.gitlab_config.as_ref(),
                                &app.git_context.project_path,
                                Some(&app.current_tab().pipeline),
                            );

                            // Look up runner from job tags
                            let job_tags = app
                                .current_tab()
                                .pipeline
                                .jobs
                                .get(&config.job_name)
                                .map(|j| &j.tags[..])
                                .unwrap_or(&[]);
                            let no_load = std::collections::HashMap::new();
                            let runner =
                                dispatch::find_runner(runners, job_tags, &no_load, &no_load);
                            if let Some(r) = runner {
                                config.apply_runner_runtime(
                                    r.runtime.as_deref(),
                                    r.kernel(),
                                    r.memory(),
                                    r.cpus(),
                                );
                            }

                            if is_run {
                                // Run job non-interactively
                                let job_name = config.job_name.clone();
                                let log_file = tempfile::Builder::new()
                                    .prefix(&format!("cui-cui-run-{}-", job_name.replace('/', "_")))
                                    .suffix(".log")
                                    .tempfile()
                                    .context("Failed to create log file")?;
                                let log_path = log_file.path().to_path_buf();

                                if let Some(r) = runner {
                                    println!(
                                        "\x1b[1;36m▶ Running job: {} → runner '{}' ({})\x1b[0m\n",
                                        job_name,
                                        r.name,
                                        r.host.as_deref().unwrap_or("local"),
                                    );
                                } else {
                                    println!("\x1b[1;36m▶ Running job: {}\x1b[0m\n", job_name);
                                }

                                let diff =
                                    container::prompt_uncommitted_changes(&config.project_dir)?;

                                // Upload artifacts to remote runner before job execution
                                if let Some(r) = runner
                                    && r.host.is_some()
                                    && !config.artifact_dirs.is_empty()
                                    && let Err(e) = remote::upload_artifacts(
                                        r,
                                        &config.project_path,
                                        &config.artifact_dirs,
                                    )
                                {
                                    eprintln!(
                                        "\x1b[33mWarning: failed to upload artifacts for '{}': {}\x1b[0m",
                                        job_name, e
                                    );
                                }

                                // Save fields needed after config may be moved into run_container
                                let job_artifacts_paths = config.artifacts_paths.clone();
                                let job_artifacts_exclude = config.artifacts_exclude.clone();
                                let job_artifacts_untracked = config.artifacts_untracked;
                                let job_artifacts_when = config.artifacts_when.clone();
                                let job_project_path = config.project_path.clone();

                                let run_result = if let Some(runner) = runner {
                                    remote::run_on_runner(
                                        &config,
                                        runner,
                                        &log_path,
                                        diff.as_deref(),
                                        config_dir,
                                        &container::OutputConfig::default(),
                                        None,
                                    )
                                } else {
                                    container::run_container(
                                        config,
                                        &log_path,
                                        diff.as_deref(),
                                        None,
                                        &container::OutputConfig::default(),
                                    )
                                };

                                match run_result {
                                    Ok(status) => {
                                        // Download artifacts from remote runner
                                        if let Some(r) = runner
                                            && r.host.is_some()
                                            && container::should_save_artifacts(
                                                status.exit_code,
                                                &job_artifacts_when,
                                            )
                                            && (!job_artifacts_paths.is_empty()
                                                || job_artifacts_untracked)
                                            && let Err(e) = remote::download_artifacts(
                                                r,
                                                &job_name,
                                                &job_project_path,
                                                &job_artifacts_paths,
                                                &job_artifacts_exclude,
                                                job_artifacts_untracked,
                                                &container::OutputConfig::default(),
                                            )
                                        {
                                            eprintln!(
                                                "\x1b[33mWarning: failed to download artifacts for '{}': {}\x1b[0m",
                                                job_name, e
                                            );
                                        }

                                        let allow_failure = app
                                            .current_tab()
                                            .pipeline
                                            .jobs
                                            .get(&job_name)
                                            .is_some_and(|j| j.allow_failure);
                                        app.run_results.insert(job_name.clone(), status.exit_code);
                                        println!();
                                        if status.exit_code == 0 {
                                            println!(
                                                "\x1b[1;32m✓ Job '{}' passed\x1b[0m (exit code 0, {})",
                                                job_name,
                                                status.format_timing()
                                            );
                                        } else if allow_failure {
                                            println!(
                                                "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {})",
                                                job_name,
                                                status.exit_code,
                                                status.format_timing()
                                            );
                                        } else {
                                            println!(
                                                "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {})",
                                                job_name,
                                                status.exit_code,
                                                status.format_timing()
                                            );
                                        }
                                        println!("  Log: {}", log_path.display());
                                    }
                                    Err(e) => {
                                        eprintln!("\x1b[31mError running job: {}\x1b[0m", e);
                                    }
                                }
                            } else {
                                // Launch interactive container
                                let launch_result = if let Some(runner) = runner {
                                    println!(
                                        "\x1b[1;36m▶ Launching container → runner '{}' ({})\x1b[0m\n",
                                        runner.name,
                                        runner.host.as_deref().unwrap_or("local"),
                                    );
                                    remote::launch_on_runner(&config, runner, config_dir)
                                } else {
                                    container::launch_container(config, None)
                                };
                                if let Err(e) = launch_result {
                                    eprintln!("\x1b[31mError: {}\x1b[0m", e);
                                }
                            }

                            // Wait for user before resuming
                            println!("\nPress Enter to return to the TUI...");
                            let _ = std::io::stdin().read_line(&mut String::new());

                            // Resume TUI
                            enable_raw_mode()?;
                            execute!(
                                terminal.backend_mut(),
                                EnterAlternateScreen,
                                EnableMouseCapture
                            )?;
                            terminal.clear()?;
                        }
                        InputResult::OpenChildPipeline(path) => {
                            app.status_message = Some("Loading workflow...".to_string());
                            pending_action = Some(PendingAction::OpenChild(path));
                        }
                        InputResult::OpenStepPipeline => {
                            app.status_message = Some("Loading steps...".to_string());
                            pending_action = Some(PendingAction::OpenSteps);
                        }
                        InputResult::Continue => {}
                    }
                }
                Event::Mouse(mouse) => {
                    let x = mouse.column;
                    let y = mouse.row;
                    let in_dag = app.dag_area.contains(ratatui::layout::Position { x, y });
                    let in_detail = app.detail_area.contains(ratatui::layout::Position { x, y });

                    // The border row is one above the detail inner area
                    let border_y = app.detail_area.y.saturating_sub(1);
                    let on_border = y == border_y;

                    match mouse.kind {
                        MouseEventKind::Down(_) => {
                            if y == 0 {
                                app.select_pipeline_tab_at(x);
                            } else if on_border {
                                *dragging_border = true;
                            } else if in_dag {
                                app.focus = Focus::Dag;
                                app.select_job_at(x, y);
                            } else if in_detail {
                                app.focus = Focus::Detail;
                                app.select_tab_at(x, y);
                            }
                        }
                        MouseEventKind::Up(_) => {
                            *dragging_border = false;
                        }
                        MouseEventKind::Drag(_) if *dragging_border => {
                            let term_height = terminal.size()?.height;
                            let new_height = term_height.saturating_sub(1).saturating_sub(y);
                            app.detail_height =
                                new_height.clamp(3, term_height.saturating_sub(12).max(3));
                        }
                        MouseEventKind::ScrollUp => {
                            if in_dag {
                                app.scroll_dag(0, -3);
                            } else if in_detail {
                                app.scroll_detail(-3);
                            }
                        }
                        MouseEventKind::ScrollDown => {
                            if in_dag {
                                app.scroll_dag(0, 3);
                            } else if in_detail {
                                app.scroll_detail(3);
                            }
                        }
                        MouseEventKind::ScrollLeft if in_dag => {
                            app.scroll_dag(-3, 0);
                        }
                        MouseEventKind::ScrollRight if in_dag => {
                            app.scroll_dag(3, 0);
                        }
                        _ => {}
                    }
                }
                _ => {}
            }
        }
    }
}

// ---------------------------------------------------------------------------
// `moa` subcommand: evaluate a Moa expression
// ---------------------------------------------------------------------------

struct MoaCliContext {
    inputs: std::collections::HashMap<String, String>,
    vars: std::collections::HashMap<String, String>,
    step_outputs_dir: Option<PathBuf>,
    output_file: Option<String>,
    export_file: Option<String>,
}

impl MoaCliContext {
    fn resolve_step_output(&self, rest: &str) -> Option<String> {
        let dir = self.step_outputs_dir.as_ref()?;
        let (step_name, key) = rest.split_once(".outputs.")?;
        if step_name.contains('/') || step_name.contains('\\') || step_name.contains("..") {
            log::warn!("Ignoring step name with path separators: {:?}", step_name);
            return None;
        }
        let file_path = dir.join(step_name);
        let content = std::fs::read_to_string(file_path).ok()?;
        for line in content.lines() {
            if let Some((k, v)) = line.split_once('=')
                && k == key
            {
                return Some(v.to_string());
            }
        }
        None
    }
}

impl expr::ExprContext for MoaCliContext {
    fn resolve(&self, id: &str) -> Option<String> {
        if let Some(key) = id.strip_prefix("inputs.") {
            return self.inputs.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("vars.") {
            return self.vars.get(key).cloned();
        }
        if let Some(key) = id.strip_prefix("env.") {
            return std::env::var(key).ok();
        }
        if let Some(rest) = id.strip_prefix("steps.") {
            return self.resolve_step_output(rest);
        }
        if id == "output_file" {
            return self.output_file.clone();
        }
        if id == "export_file" {
            return self.export_file.clone();
        }
        if id == "func_dir" || id == "work_dir" {
            return std::env::var(id).ok();
        }
        None
    }
}

fn parse_json_map(json: &Option<String>) -> Result<std::collections::HashMap<String, String>> {
    let s = match json.as_ref() {
        None => return Ok(Default::default()),
        Some(s) => s,
    };
    let v: serde_json::Value =
        serde_json::from_str(s).with_context(|| format!("invalid JSON: {}", s))?;
    let obj = v
        .as_object()
        .ok_or_else(|| anyhow::anyhow!("expected JSON object, got: {}", s))?;
    Ok(obj
        .iter()
        .map(|(k, v)| {
            let s = match v {
                serde_json::Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            (k.clone(), s)
        })
        .collect())
}

fn run_moa(args: &MoaArgs) -> Result<()> {
    let ctx = MoaCliContext {
        inputs: parse_json_map(&args.inputs_json)?,
        vars: parse_json_map(&args.vars_json)?,
        step_outputs_dir: args.step_outputs_dir.clone(),
        output_file: args.output_file.clone(),
        export_file: args.export_file.clone(),
    };
    print!("{}", expr::eval_expression(&args.expr, &ctx));
    Ok(())
}

/// Detect the CI system and resolve the file path from a CLI argument.
///
/// - If a `.gitlab-ci.yml` file is given directly, use GitLab.
/// - If a GitHub Actions workflow YAML is given directly, use GitHub.
/// - If a directory is given, prefer `.gitlab-ci.yml`, then `.github/workflows/`.
fn detect_ci_files(path: &Path) -> (Vec<PathBuf>, model::CiSystem) {
    if path.is_dir() {
        let gitlab = path.join(".gitlab-ci.yml");
        if gitlab.exists() {
            return (vec![gitlab], model::CiSystem::GitLab);
        }
        let github_dir = path.join(".github").join("workflows");
        if github_dir.is_dir()
            && let Ok(entries) = std::fs::read_dir(&github_dir)
        {
            let mut workflows: Vec<PathBuf> = entries
                .flatten()
                .map(|e| e.path())
                .filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml")))
                .collect();
            workflows.sort();
            if !workflows.is_empty() {
                return (workflows, model::CiSystem::GitHub);
            }
        }
        // Default to GitLab (will fail later with a clear error)
        (vec![gitlab], model::CiSystem::GitLab)
    } else {
        let path_str = path.to_string_lossy();
        if path_str.contains(".github/workflows") || path_str.contains(".github\\workflows") {
            (vec![path.to_path_buf()], model::CiSystem::GitHub)
        } else {
            (vec![path.to_path_buf()], model::CiSystem::GitLab)
        }
    }
}

/// Set up logging: headless modes log to stderr, TUI mode logs to a temp file.
/// Returns the `NamedTempFile` (if any) so the caller keeps it alive — dropping
/// it would delete the file before `dump_logs()` can read it back.
fn setup_logging(headless: bool) -> Option<tempfile::NamedTempFile> {
    let log_file = if headless {
        None
    } else {
        tempfile::Builder::new()
            .prefix("cui-cui-")
            .suffix(".log")
            .tempfile()
            .ok()
    };
    let mut builder = env_logger::Builder::from_env(
        env_logger::Env::default().default_filter_or("warn,rustls_platform_verifier=error"),
    );
    builder.format_timestamp(None);
    if let Some(ref log_file) = log_file {
        match log_file.as_file().try_clone() {
            Ok(writer) => {
                builder.write_style(env_logger::WriteStyle::Never);
                builder.target(env_logger::Target::Pipe(Box::new(writer)));
            }
            Err(e) => {
                eprintln!("Warning: could not clone log fd ({}), logging to stderr", e);
                builder.target(env_logger::Target::Stderr);
            }
        }
    } else {
        builder.target(env_logger::Target::Stderr);
    }
    builder.init();
    log_file
}

/// Run a step-job inside a shared container, replaying predecessors first.
///
/// For `--run`: starts a container, executes all predecessors, then the target.
/// For `--container`: starts a container, executes predecessors, then drops
/// into an interactive shell.
fn run_shared_container(
    config: &container::ContainerConfig,
    step_pipeline: &model::Pipeline,
    target_job_name: &str,
    interactive: bool,
) -> Result<i32> {
    let target_job = &step_pipeline.jobs[target_job_name];
    let target_stage_idx = step_pipeline.stage_index(&target_job.stage).unwrap_or(0);

    let (config, env_dir_handle) = dispatch::prepare_step_config(config, step_pipeline)?;
    let env_dir = env_dir_handle.path();

    println!("\x1b[90mStarting shared container for step execution...\x1b[0m");
    let mut step_container = container::StepContainer::start(&config)?;
    println!("\x1b[90mContainer: {}\x1b[0m", step_container.name());

    let mut env_state = resolve::github::StepEnvState::default();

    // Execute predecessors (all steps in stages before the target)
    for stage in &step_pipeline.stages {
        let stage_idx = step_pipeline.stage_index(&stage.name).unwrap_or(0);
        if stage_idx >= target_stage_idx {
            break;
        }
        for job_name in &stage.job_names {
            let job = &step_pipeline.jobs[job_name];
            if job.rules_never {
                println!("\x1b[90m  Skipping: {} (disabled)\x1b[0m", job_name);
                continue;
            }

            println!("\x1b[36m  Replaying: {}\x1b[0m", job_name);

            let log_file = tempfile::Builder::new()
                .prefix(&format!("cuicui-step-{}-", job_name.replace('/', "_")))
                .suffix(".log")
                .tempfile()
                .context("Failed to create step log file")?;
            let log_path = log_file.path().to_path_buf();

            let oc = container::OutputConfig::default();
            let exit_code = dispatch::execute_step(
                &mut step_container,
                job,
                env_dir,
                &mut env_state,
                &log_path,
                &oc,
            )?;

            if exit_code != 0 {
                println!(
                    "\x1b[1;31m  Predecessor '{}' failed (exit code {})\x1b[0m",
                    job_name, exit_code
                );
                step_container.stop();
                return Ok(exit_code);
            }
        }
    }

    if interactive {
        println!(
            "\x1b[33mDropping into shell at step: {}\x1b[0m",
            target_job_name
        );
        println!();
        let exit_code = if target_job.build_context.is_some() {
            let step_vars = dispatch::prepare_step_vars(target_job, &mut env_state);
            step_container.run_docker_action_interactive(target_job, &step_vars)?
        } else {
            let script = target_job.script.join("\n");
            step_container.exec_interactive(Some(&script))?
        };
        Ok(exit_code)
    } else {
        println!("\x1b[36m  Running: {}\x1b[0m", target_job_name);

        let log_file = tempfile::Builder::new()
            .prefix(&format!(
                "cuicui-step-{}-",
                target_job_name.replace('/', "_")
            ))
            .suffix(".log")
            .tempfile()
            .context("Failed to create step log file")?;
        let log_path = log_file.path().to_path_buf();

        let oc = container::OutputConfig::default();
        let exit_code = dispatch::execute_step(
            &mut step_container,
            target_job,
            env_dir,
            &mut env_state,
            &log_path,
            &oc,
        )?;

        if exit_code == 0 {
            println!("\x1b[1;32m✓ Step '{}' passed\x1b[0m", target_job_name);
        } else {
            println!(
                "\x1b[1;31m✗ Step '{}' failed\x1b[0m (exit code {})",
                target_job_name, exit_code
            );
        }
        let (_persisted, log_path) = log_file.keep().context("Failed to persist log file")?;
        println!("  Log: {}", log_path.display());
        Ok(exit_code)
    }
}

fn find_job_any_pipeline(
    query: &str,
    pipeline: &model::Pipeline,
    file_path: &Path,
    ci_system: model::CiSystem,
    ctx: &resolve::ResolveContext,
    extra_workflow_files: &[PathBuf],
) -> Result<model::ResolvedJob> {
    match pipeline.find_job(query) {
        Ok(name) => Ok(model::ResolvedJob {
            child_pipeline: None,
            job_name: name.to_string(),
            file_path: file_path.to_path_buf(),
        }),
        Err(_) if ci_system == model::CiSystem::GitLab => {
            let (cp, jn, cf) = resolve::resolve_child_job(query, pipeline, file_path, ctx)?;
            Ok(model::ResolvedJob {
                child_pipeline: Some(cp),
                job_name: jn,
                file_path: cf,
            })
        }
        Err(root_err) if ci_system == model::CiSystem::GitHub => {
            // Try step-pipeline resolution (e.g. "ci/Build" or auto-search)
            if let Some(resolved) = find_github_step_job(query, pipeline, file_path) {
                return Ok(resolved);
            }

            // Try other workflow files
            for wf_path in extra_workflow_files {
                let p = match resolve_github_workflow(wf_path, ctx.var_overrides) {
                    Ok(p) => p,
                    Err(_) => continue,
                };
                if let Ok(name) = p.find_job(query) {
                    return Ok(model::ResolvedJob {
                        job_name: name.to_string(),
                        file_path: wf_path.clone(),
                        child_pipeline: Some(p),
                    });
                }
                // Also search step-pipelines in other workflows
                if let Some(resolved) = find_github_step_job(query, &p, wf_path) {
                    return Ok(resolved);
                }
            }
            Err(anyhow::anyhow!("{}", root_err))
        }
        Err(e) => Err(anyhow::anyhow!("{}", e)),
    }
}

/// Try to resolve a query as a step-job within a GitHub Actions pipeline.
///
/// Supports `parent_job/step_name` syntax and auto-search across all jobs.
fn find_github_step_job(
    query: &str,
    pipeline: &model::Pipeline,
    file_path: &Path,
) -> Option<model::ResolvedJob> {
    if let Some((parent_query, step_query)) = query.split_once('/') {
        let parent_name = pipeline.find_job(parent_query).ok()?;
        let parent_job = &pipeline.jobs[parent_name];
        let step_pipeline = resolve::github::build_step_pipeline(parent_job)?;
        let step_name = step_pipeline.find_job(step_query).ok()?;
        return Some(model::ResolvedJob {
            job_name: step_name.to_string(),
            file_path: file_path.to_path_buf(),
            child_pipeline: Some(step_pipeline),
        });
    }

    // Auto-search: look for the query as a step name across all jobs
    let mut matches = Vec::new();
    for (name, job) in &pipeline.jobs {
        if let Some(sp) = resolve::github::build_step_pipeline(job)
            && let Ok(step_name) = sp.find_job(query)
        {
            matches.push((name.clone(), step_name.to_string(), sp));
        }
    }

    match matches.len() {
        1 => {
            let (_, step_name, sp) = matches.into_iter().next().unwrap();
            Some(model::ResolvedJob {
                job_name: step_name,
                file_path: file_path.to_path_buf(),
                child_pipeline: Some(sp),
            })
        }
        _ => None,
    }
}

fn resolve_github_workflow(
    path: &Path,
    var_overrides: &IndexMap<String, String>,
) -> Result<model::Pipeline> {
    let raw = parser::github::parse_workflow_file(path)
        .with_context(|| format!("Failed to parse {}", path.display()))?;
    resolve::github::resolve_pipeline(raw, var_overrides)
        .with_context(|| format!("Failed to resolve {}", path.display()))
}

fn resolve_skip_patterns(patterns: &[String], pipeline: &model::Pipeline) -> Vec<String> {
    let mut skip_jobs = Vec::new();
    for pattern in patterns {
        let matches = pipeline.find_jobs(pattern);
        if matches.is_empty() {
            eprintln!("Warning: --skip {}: no matching jobs found", pattern);
        } else {
            skip_jobs.extend(matches.into_iter().map(String::from));
        }
    }
    skip_jobs
}

/// Build a synthetic "overview" pipeline where each GitHub workflow file
/// appears as a job node. Non-code workflows are marked `rules_never`.
fn build_github_meta_pipeline(workflow_files: &[PathBuf], primary_file: &Path) -> model::Pipeline {
    use model::{Job, JobDebugInfo, Pipeline, Stage};

    let workflows_dir = primary_file.parent().unwrap_or(primary_file);
    // trigger_file must be relative to root_file_path().parent(), which is
    // workflows_dir.parent() since the meta-pipeline's file_path = workflows_dir
    let trigger_root = workflows_dir.parent().unwrap_or(workflows_dir);

    let mut jobs = IndexMap::new();
    let mut all_jobs = Vec::new();

    for wf_path in workflow_files {
        let filename = wf_path
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| "workflow".to_string());

        let (name, is_code) = match parser::github::parse_workflow_header(wf_path) {
            Some((wf_name, Some(ref trigger))) => {
                let name = wf_name.unwrap_or_else(|| filename.clone());
                (name, parser::github::has_code_triggers(trigger))
            }
            Some((wf_name, None)) => {
                let name = wf_name.unwrap_or_else(|| filename.clone());
                (name, true)
            }
            None => (filename.clone(), true),
        };

        let trigger_file = wf_path
            .strip_prefix(trigger_root)
            .map(|rel| rel.to_string_lossy().to_string())
            .unwrap_or_else(|_| filename.clone());

        let display_name = if jobs.contains_key(&name) {
            format!("{} ({})", name, filename)
        } else {
            name
        };

        let job = Job {
            name: display_name.clone(),
            stage: "workflows".to_string(),
            image: None,
            services: vec![],
            variables: IndexMap::new(),
            before_script: vec![],
            script: vec![],
            after_script: vec![],
            needs: vec![],
            artifact_sources: Some(vec![]),
            tags: vec![],
            when_val: "on_success".to_string(),
            allow_failure: false,
            rules_never: !is_code,
            unresolved_extends: vec![],
            rules_summary: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            timeout: None,
            resource_group: None,
            coverage: None,
            resolved_yaml: format!("# {}\ntrigger: {}", filename, trigger_file),
            trigger_file: Some(trigger_file),
            build_context: None,
            docker_entrypoint: None,
            step_id: None,
            steps: vec![],
            debug_info: JobDebugInfo::default(),
        };

        all_jobs.push(display_name.clone());
        jobs.insert(display_name, job);
    }

    let stages = vec![Stage {
        name: "workflows".to_string(),
        job_names: all_jobs,
    }];

    Pipeline {
        stages,
        jobs,
        execution: Default::default(),
        checkout_path: None,
    }
}

/// Flush logs and dump the temp log file to stderr with ANSI colors.
fn dump_logs(log_path: Option<&Path>) {
    log::logger().flush();
    if let Some(path) = log_path
        && let Ok(contents) = std::fs::read_to_string(path)
    {
        for line in contents.lines() {
            let colored = colorize_log_line(line);
            eprintln!("{}", colored);
        }
    }
}

/// Add ANSI color codes to a log line based on its level prefix.
fn colorize_log_line(line: &str) -> String {
    let color = if line.starts_with("[ERROR") {
        "\x1b[31m" // red
    } else if line.starts_with("[WARN") {
        "\x1b[33m" // yellow
    } else if line.starts_with("[INFO") {
        "\x1b[36m" // cyan
    } else if line.starts_with("[DEBUG") {
        "\x1b[90m" // dark gray
    } else {
        return line.to_string();
    };
    format!("{}{}\x1b[0m", color, line)
}

/// Resolve artifacts for a container config: fetch from cache/remote, handle
/// conflicts, and evict old cache entries.
/// Run a job from a pre-resolved job spec (JSON file).
///
/// Create a per-job git worktree and optionally apply a diff.
/// Reads `sha` and `diff_path` from the job spec (--job-spec).
/// Prints the worktree path to stdout for the dispatcher to capture.
fn prep_job(job_name: &str, cli: &Cli) -> Result<()> {
    let spec_path = cli
        .job_spec
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("--prep-job requires --job-spec"))?;
    let json = std::fs::read_to_string(spec_path)
        .with_context(|| format!("Failed to read job spec: {}", spec_path.display()))?;
    let spec: container::JobSpec = serde_json::from_str(&json)
        .with_context(|| format!("Failed to parse job spec: {}", spec_path.display()))?;
    let sha = spec
        .sha
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("--prep-job requires sha in job spec"))?;

    let project_dir = cli.path.canonicalize().unwrap_or_else(|_| cli.path.clone());
    let job_slug = job_name.replace('/', "_").replace(' ', "-");
    let wt_path = project_dir.join(format!("../.cui-cui-wt-{}", job_slug));

    // Remove stale worktree if it exists
    let _ = std::process::Command::new("git")
        .args(["worktree", "remove", "--force"])
        .arg(&wt_path)
        .current_dir(&project_dir)
        .output();

    let output = std::process::Command::new("git")
        .args(["worktree", "add", "--detach"])
        .arg(&wt_path)
        .arg(sha)
        .current_dir(&project_dir)
        .stderr(Stdio::piped())
        .output()
        .context("Failed to create git worktree")?;
    if !output.status.success() {
        anyhow::bail!(
            "git worktree add failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    let canonical = wt_path.canonicalize().unwrap_or(wt_path);

    if let Some(ref diff_path) = spec.diff_path {
        let diff_bytes = std::fs::read(diff_path)
            .with_context(|| format!("Failed to read diff: {}", diff_path))?;
        if !diff_bytes.is_empty() {
            let mut child = std::process::Command::new("git")
                .args(["apply", "--allow-empty", diff_path])
                .current_dir(&canonical)
                .stderr(Stdio::piped())
                .spawn()
                .context("Failed to apply diff")?;
            let status = child.wait()?;
            if !status.success() {
                eprintln!("Warning: diff apply failed (continuing without it)");
            }
        }
    }

    // Initialize submodules if GIT_SUBMODULE_STRATEGY is set
    let submodule_strategy = spec
        .variables
        .get("GIT_SUBMODULE_STRATEGY")
        .map(|s| s.to_lowercase());
    match submodule_strategy.as_deref() {
        Some("normal") => {
            eprintln!("\x1b[90mInitializing submodules...\x1b[0m");
            let status = std::process::Command::new("git")
                .args(["submodule", "update", "--init"])
                .current_dir(&canonical)
                .stdout(Stdio::null())
                .status();
            if !status.is_ok_and(|s| s.success()) {
                eprintln!("\x1b[33mWarning: submodule init failed\x1b[0m");
            }
        }
        Some("recursive") => {
            eprintln!("\x1b[90mInitializing submodules (recursive)...\x1b[0m");
            let status = std::process::Command::new("git")
                .args(["submodule", "update", "--init", "--recursive"])
                .current_dir(&canonical)
                .stdout(Stdio::null())
                .status();
            if !status.is_ok_and(|s| s.success()) {
                eprintln!("\x1b[33mWarning: submodule init failed\x1b[0m");
            }
        }
        _ => {}
    }

    println!("{}", canonical.display());
    Ok(())
}

/// Load a job spec, check out the commit, resolve artifacts, and return the
/// config along with the dispatch fields (diff_path, container_name) that
/// callers need.
fn load_job_spec(
    spec_path: &Path,
    job_query: &str,
    mode_name: &str,
    project_dir: &Path,
) -> Result<(container::ContainerConfig, Option<String>, Option<String>)> {
    let json = std::fs::read_to_string(spec_path)
        .with_context(|| format!("Failed to read job spec: {}", spec_path.display()))?;
    let spec: container::JobSpec = serde_json::from_str(&json)
        .with_context(|| format!("Failed to parse job spec: {}", spec_path.display()))?;

    let project_dir = project_dir
        .canonicalize()
        .unwrap_or_else(|_| project_dir.to_path_buf());

    if let Some(ref sha) = spec.sha {
        let output = std::process::Command::new("git")
            .args(["checkout", "--detach", sha])
            .current_dir(&project_dir)
            .output()
            .context("Failed to run git checkout")?;
        if !output.status.success() {
            anyhow::bail!(
                "git checkout {} failed: {}",
                sha,
                String::from_utf8_lossy(&output.stderr).trim()
            );
        }
    }

    let job_name = &spec.job_name;
    if job_query != job_name {
        info!(
            "--{} '{}' ignored, using job '{}' from spec",
            mode_name, job_query, job_name
        );
    }

    let diff_path = spec.diff_path.clone();
    let container_name = spec.container_name.clone();
    let mut config = spec.into_container_config(project_dir);

    let git_context = resolve::GitContext::detect(&config.project_dir);
    resolve_and_attach_artifacts(
        &mut config,
        git_context.pipeline_id.as_deref(),
        None,
        &git_context.project_path,
        None,
    );

    Ok((config, diff_path, container_name))
}

fn run_from_job_spec(
    spec_path: &Path,
    job_query: &str,
    cli: &Cli,
    _file_config: &config::Config,
) -> Result<()> {
    let (config, diff_path, container_name) =
        load_job_spec(spec_path, job_query, "run", &cli.path)?;
    let job_name = config.job_name.clone();
    let allow_failure = config.allow_failure;

    println!("\x1b[1;36m▶ Running job: {}\x1b[0m\n", job_name);

    let diff = if let Some(ref diff_path) = diff_path {
        let bytes = std::fs::read(diff_path)
            .with_context(|| format!("Failed to read diff file: {}", diff_path))?;
        if bytes.is_empty() { None } else { Some(bytes) }
    } else {
        None
    };

    let log_file = tempfile::Builder::new()
        .prefix(&format!("cui-cui-run-{}-", job_name.replace('/', "_")))
        .suffix(".log")
        .tempfile()
        .context("Failed to create log file")?;
    let log_path = log_file.path().to_path_buf();

    let status = container::run_container(
        config,
        &log_path,
        diff.as_deref(),
        container_name,
        &container::OutputConfig::default(),
    )?;

    if status.exit_code == 0 {
        println!(
            "\x1b[1;32m✓ Job '{}' passed\x1b[0m (exit code 0, {})",
            job_name,
            status.format_timing()
        );
    } else if allow_failure {
        println!(
            "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {})",
            job_name,
            status.exit_code,
            status.format_timing()
        );
    } else {
        println!(
            "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {})",
            job_name,
            status.exit_code,
            status.format_timing()
        );
    }
    println!("  Log: {}", log_path.display());
    let exit_code = if allow_failure { 0 } else { status.exit_code };
    std::process::exit(exit_code);
}

/// Launch an interactive container from a pre-resolved job spec (skips CI parsing).
fn container_from_job_spec(
    spec_path: &Path,
    job_query: &str,
    cli: &Cli,
    _file_config: &config::Config,
) -> Result<()> {
    let (config, _, container_name) = load_job_spec(spec_path, job_query, "container", &cli.path)?;
    container::launch_container(config, container_name)
}

fn resolve_and_attach_artifacts(
    config: &mut container::ContainerConfig,
    pipeline_id: Option<&str>,
    gitlab_config: Option<&parser::gitlab_fetch::GitlabConfig>,
    project_path: &str,
    pipeline: Option<&model::Pipeline>,
) {
    let oc = container::OutputConfig::default();
    match artifacts::resolve_artifacts(
        &config.needs,
        &config.project_path,
        pipeline_id,
        gitlab_config,
        &oc,
    ) {
        Ok(resolution) => {
            config.artifact_dirs = resolution.ready;
            if !resolution.conflicts.is_empty() {
                let resolved = artifacts::resolve_conflicts(&resolution.conflicts, pipeline_id);
                config.artifact_dirs.extend(resolved);
            }
        }
        Err(e) => {
            eprintln!("\x1b[33mWarning: failed to resolve artifacts: {}\x1b[0m", e);
        }
    }
    dispatch::inject_dotenv_variables(config, pipeline, &oc);

    let _ = artifacts::evict_old_artifacts(project_path, 5);
}

/// Build the tab bar spans for the title row.
fn build_tab_bar<'a>(app: &App, theme: &'a Theme) -> Vec<Span<'a>> {
    let mut spans = Vec::new();

    if app.tabs.len() == 1 {
        // Single tab: show the classic title bar
        spans.push(Span::styled(
            " cui-cui ",
            Style::default().fg(theme.title_fg).bg(theme.title_bg),
        ));
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            app.current_tab().file_path.display().to_string(),
            Style::default().fg(theme.title_path),
        ));
    } else {
        // Multiple tabs: show tab bar
        spans.push(Span::styled(
            " cui-cui ",
            Style::default().fg(theme.title_fg).bg(theme.title_bg),
        ));
        spans.push(Span::raw(" "));

        for (i, tab) in app.tabs.iter().enumerate() {
            if i > 0 {
                spans.push(Span::styled(" │ ", Style::default().fg(theme.text_dim)));
            }
            let style = if i == app.active_tab {
                Style::default()
                    .fg(theme.accent)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(theme.title_path)
            };
            spans.push(Span::styled(tab.label.clone(), style));
        }
    }

    spans
}

/// Collect the effective variables for a job: pipeline-level variables, then
/// job variables, then any existing per-job edits.
fn collect_job_variables(app: &App, job_name: &str) -> IndexMap<String, String> {
    let mut vars = IndexMap::new();
    if let Some(job) = app.current_tab().pipeline.jobs.get(job_name) {
        for (k, v) in &job.variables {
            vars.insert(k.clone(), v.clone());
        }
    }
    if let Some(overrides) = app.edited_vars.get(job_name) {
        for (k, v) in overrides {
            vars.insert(k.clone(), v.clone());
        }
    }
    vars
}

/// Escape a value for the KEY="value" format: backslash-escape `\`, `"`, and
/// newlines.
fn escape_var_value(v: &str) -> String {
    let mut out = String::with_capacity(v.len() + 2);
    out.push('"');
    for ch in v.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            other => out.push(other),
        }
    }
    out.push('"');
    out
}

/// Unescape a double-quoted value, reversing [`escape_var_value`].
fn unescape_var_value(v: &str) -> String {
    let inner = v
        .strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .unwrap_or(v);
    let mut out = String::with_capacity(inner.len());
    let mut chars = inner.chars();
    while let Some(ch) = chars.next() {
        if ch == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('\\') => out.push('\\'),
                Some('"') => out.push('"'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(ch);
        }
    }
    out
}

/// Write variables to a temp file, open `$EDITOR`, and parse back the result.
fn edit_variables_in_editor(vars: &IndexMap<String, String>) -> Result<IndexMap<String, String>> {
    let editor = std::env::var("VISUAL")
        .or_else(|_| std::env::var("EDITOR"))
        .unwrap_or_else(|_| "vi".to_string());
    edit_variables_with_editor(vars, &editor)
}

fn edit_variables_with_editor(
    vars: &IndexMap<String, String>,
    editor: &str,
) -> Result<IndexMap<String, String>> {
    use std::io::Write;

    let mut tmp = tempfile::Builder::new()
        .prefix("cui-cui-vars-")
        .suffix(".env")
        .tempfile()
        .context("Failed to create temp file")?;

    for (k, v) in vars {
        writeln!(tmp, "{}={}", k, escape_var_value(v))?;
    }
    tmp.flush()?;

    let status = std::process::Command::new("sh")
        .arg("-c")
        .arg(format!("{} \"$1\"", editor))
        .arg("--")
        .arg(tmp.path())
        .status()
        .with_context(|| format!("Failed to launch editor '{}'", editor))?;

    if !status.success() {
        anyhow::bail!("Editor exited with {}", status);
    }

    let content = std::fs::read_to_string(tmp.path()).context("Failed to read edited file")?;
    let mut result = IndexMap::new();
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((key, value)) = line.split_once('=') {
            result.insert(key.trim().to_string(), unescape_var_value(value.trim()));
        }
    }
    Ok(result)
}

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

    #[test]
    fn test_colorize_log_line_error() {
        let result = colorize_log_line("[ERROR some error]");
        assert!(result.starts_with("\x1b[31m"));
        assert!(result.ends_with("\x1b[0m"));
        assert!(result.contains("[ERROR some error]"));
    }

    #[test]
    fn test_colorize_log_line_warn() {
        let result = colorize_log_line("[WARN  something]");
        assert!(result.starts_with("\x1b[33m"));
    }

    #[test]
    fn test_colorize_log_line_info() {
        let result = colorize_log_line("[INFO  message]");
        assert!(result.starts_with("\x1b[36m"));
    }

    #[test]
    fn test_colorize_log_line_debug() {
        let result = colorize_log_line("[DEBUG details]");
        assert!(result.starts_with("\x1b[90m"));
    }

    #[test]
    fn test_colorize_log_line_plain() {
        let result = colorize_log_line("just a plain line");
        assert_eq!(result, "just a plain line");
        assert!(!result.contains("\x1b["));
    }

    #[test]
    fn test_detect_ci_files_gitlab_file() {
        let dir = tempfile::tempdir().unwrap();
        let gitlab = dir.path().join(".gitlab-ci.yml");
        fs::write(&gitlab, "stages: [build]").unwrap();

        let (paths, system) = detect_ci_files(dir.path());
        assert_eq!(paths, vec![gitlab]);
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_files_github_dir() {
        let dir = tempfile::tempdir().unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        let wf = workflows.join("ci.yml");
        fs::write(&wf, "on: push").unwrap();

        let (paths, system) = detect_ci_files(dir.path());
        assert_eq!(paths, vec![wf]);
        assert!(matches!(system, model::CiSystem::GitHub));
    }

    #[test]
    fn test_detect_ci_files_gitlab_takes_precedence() {
        let dir = tempfile::tempdir().unwrap();
        let gitlab = dir.path().join(".gitlab-ci.yml");
        fs::write(&gitlab, "stages: [build]").unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        fs::write(workflows.join("ci.yml"), "on: push").unwrap();

        let (paths, system) = detect_ci_files(dir.path());
        assert_eq!(paths, vec![gitlab]);
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_files_github_returns_all_sorted() {
        let dir = tempfile::tempdir().unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        fs::write(workflows.join("b_build.yml"), "on: push").unwrap();
        fs::write(workflows.join("a_lint.yml"), "on: push").unwrap();

        let (paths, system) = detect_ci_files(dir.path());
        assert_eq!(
            paths,
            vec![workflows.join("a_lint.yml"), workflows.join("b_build.yml")]
        );
        assert!(matches!(system, model::CiSystem::GitHub));
    }

    #[test]
    fn test_detect_ci_files_explicit_path() {
        let (paths, system) = detect_ci_files(Path::new("/tmp/my/.github/workflows/ci.yml"));
        assert_eq!(
            paths,
            vec![PathBuf::from("/tmp/my/.github/workflows/ci.yml")]
        );
        assert!(matches!(system, model::CiSystem::GitHub));

        let (paths, system) = detect_ci_files(Path::new("/tmp/my/.gitlab-ci.yml"));
        assert_eq!(paths, vec![PathBuf::from("/tmp/my/.gitlab-ci.yml")]);
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_files_empty_dir_defaults_gitlab() {
        let dir = tempfile::tempdir().unwrap();
        let (_, system) = detect_ci_files(dir.path());
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    /// Verify that setup_logging returns None in headless mode (logs to stderr,
    /// no try_clone needed).
    #[test]
    fn test_setup_logging_headless_returns_none() {
        // Can't actually call setup_logging (env_logger::init is once-only),
        // but we can verify the temp file branch: headless=true → no file.
        let log_file: Option<tempfile::NamedTempFile> = if true {
            // headless path
            None
        } else {
            tempfile::Builder::new()
                .prefix("cui-cui-")
                .suffix(".log")
                .tempfile()
                .ok()
        };
        assert!(log_file.is_none());
    }

    /// Verify try_clone graceful fallback: a failed clone should not panic.
    /// We simulate by testing the match pattern used in setup_logging.
    #[test]
    fn test_try_clone_fallback_does_not_panic() {
        let log_file = tempfile::Builder::new()
            .prefix("cui-cui-test-")
            .suffix(".log")
            .tempfile()
            .unwrap();

        // Normal clone succeeds
        let result = log_file.as_file().try_clone();
        assert!(result.is_ok());

        // Verify the match pattern used in setup_logging handles both arms
        let mut used_pipe = false;
        let mut used_stderr = false;
        match log_file.as_file().try_clone() {
            Ok(_writer) => used_pipe = true,
            Err(_) => used_stderr = true,
        }
        assert!(used_pipe);
        assert!(!used_stderr);
    }

    #[test]
    fn test_load_job_spec_parses_correctly() {
        let dir = tempfile::tempdir().unwrap();
        let spec_path = dir.path().join("spec.json");
        let spec_json = serde_json::json!({
            "image": "rust:latest",
            "variables": {"CI_JOB_NAME": "build"},
            "script_content": "echo hello",
            "job_name": "build",
            "privileged": false,
            "no_overlay": false,
            "artifacts_paths": [],
            "artifacts_when": "on_success",
            "project_path": "test/project",
            "needs": [],
            "git_image": "docker.io/alpine/git:latest"
        });
        fs::write(&spec_path, spec_json.to_string()).unwrap();

        let (config, diff_path, container_name) =
            load_job_spec(&spec_path, "build", "run", dir.path()).unwrap();

        assert_eq!(config.job_name, "build");
        assert_eq!(config.image, "rust:latest");
        assert_eq!(config.script_content, "echo hello");
        assert!(diff_path.is_none());
        assert!(container_name.is_none());
    }

    #[test]
    fn test_load_job_spec_invalid_json() {
        let dir = tempfile::tempdir().unwrap();
        let spec_path = dir.path().join("bad.json");
        fs::write(&spec_path, "not valid json").unwrap();

        let result = load_job_spec(&spec_path, "build", "run", dir.path());
        assert!(result.is_err());
    }

    #[test]
    fn test_load_job_spec_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        let spec_path = dir.path().join("nonexistent.json");

        let result = load_job_spec(&spec_path, "build", "run", dir.path());
        assert!(result.is_err());
    }

    /// Verify that a NamedTempFile's path is readable while the handle is alive.
    /// This is the invariant that setup_logging must preserve: returning the
    /// NamedTempFile (not just the PathBuf) keeps the file on disk for dump_logs.
    #[test]
    fn test_log_file_readable_while_alive() {
        let log_file = tempfile::Builder::new()
            .prefix("cui-cui-test-")
            .suffix(".log")
            .tempfile()
            .unwrap();
        let path = log_file.path().to_path_buf();

        // Write via a cloned fd (same as env_logger would)
        use std::io::Write;
        let mut writer = log_file.as_file().try_clone().unwrap();
        writeln!(writer, "[INFO  test] hello from logger").unwrap();
        writer.flush().unwrap();

        // Path is readable while NamedTempFile is alive
        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("hello from logger"));

        // After dropping the NamedTempFile, the path is gone
        drop(log_file);
        assert!(
            fs::read_to_string(&path).is_err(),
            "file should be deleted after NamedTempFile is dropped"
        );
    }

    #[test]
    fn test_moa_resolve_steps_outputs() {
        let dir = tempfile::tempdir().unwrap();
        let outputs_dir = dir.path().join("step-outputs");
        fs::create_dir(&outputs_dir).unwrap();
        fs::write(outputs_dir.join("scan"), "critical=0\nhigh=3\n").unwrap();

        let ctx = MoaCliContext {
            inputs: Default::default(),
            vars: Default::default(),
            step_outputs_dir: Some(outputs_dir),
            output_file: None,
            export_file: None,
        };
        use expr::ExprContext;
        assert_eq!(
            ctx.resolve("steps.scan.outputs.critical"),
            Some("0".to_string())
        );
        assert_eq!(
            ctx.resolve("steps.scan.outputs.high"),
            Some("3".to_string())
        );
        assert_eq!(ctx.resolve("steps.scan.outputs.missing"), None);
        assert_eq!(ctx.resolve("steps.other.outputs.key"), None);
    }

    #[test]
    fn test_skip_patterns_merge_cli_and_config() {
        let config_skip = Some(vec!["stage=build".to_string(), "lint".to_string()]);
        let cli_skip = vec!["test-e2e".to_string()];

        let mut skip_patterns = config_skip.unwrap_or_default();
        skip_patterns.extend(cli_skip);

        assert_eq!(skip_patterns.len(), 3);
        assert_eq!(skip_patterns[0], "stage=build");
        assert_eq!(skip_patterns[1], "lint");
        assert_eq!(skip_patterns[2], "test-e2e");
    }

    #[test]
    fn test_skip_patterns_config_only() {
        let config_skip = Some(vec!["stage=build".to_string()]);
        let cli_skip: Vec<String> = vec![];

        let mut skip_patterns = config_skip.unwrap_or_default();
        skip_patterns.extend(cli_skip);

        assert_eq!(skip_patterns, vec!["stage=build"]);
    }

    #[test]
    fn test_skip_patterns_cli_only() {
        let config_skip: Option<Vec<String>> = None;
        let cli_skip = vec!["test-e2e".to_string()];

        let mut skip_patterns = config_skip.unwrap_or_default();
        skip_patterns.extend(cli_skip);

        assert_eq!(skip_patterns, vec!["test-e2e"]);
    }

    #[test]
    fn test_moa_resolve_output_file() {
        let ctx = MoaCliContext {
            inputs: Default::default(),
            vars: Default::default(),
            step_outputs_dir: None,
            output_file: Some("/tmp/out".to_string()),
            export_file: Some("/tmp/exp".to_string()),
        };
        use expr::ExprContext;
        assert_eq!(ctx.resolve("output_file"), Some("/tmp/out".to_string()));
        assert_eq!(ctx.resolve("export_file"), Some("/tmp/exp".to_string()));
    }

    #[test]
    fn test_parse_env_overrides_key_value() {
        let entries = vec!["FOO=bar".to_string(), "BAZ=qux".to_string()];
        let vars = parse_env_overrides(&entries);
        assert_eq!(vars.get("FOO").unwrap(), "bar");
        assert_eq!(vars.get("BAZ").unwrap(), "qux");
    }

    #[test]
    fn test_parse_env_overrides_bare_key_from_env() {
        // SAFETY: single-threaded test, no other threads reading this var
        unsafe { std::env::set_var("CUI_CUI_TEST_VAR_42", "from_env") };
        let entries = vec!["CUI_CUI_TEST_VAR_42".to_string()];
        let vars = parse_env_overrides(&entries);
        assert_eq!(vars.get("CUI_CUI_TEST_VAR_42").unwrap(), "from_env");
        unsafe { std::env::remove_var("CUI_CUI_TEST_VAR_42") };
    }

    #[test]
    fn test_parse_env_overrides_bare_key_missing() {
        let entries = vec!["CUI_CUI_NONEXISTENT_VAR_XYZ".to_string()];
        let vars = parse_env_overrides(&entries);
        assert!(!vars.contains_key("CUI_CUI_NONEXISTENT_VAR_XYZ"));
    }

    #[test]
    fn test_parse_env_overrides_value_with_equals() {
        let entries = vec!["URL=https://example.com?a=1&b=2".to_string()];
        let vars = parse_env_overrides(&entries);
        assert_eq!(vars.get("URL").unwrap(), "https://example.com?a=1&b=2");
    }

    #[test]
    fn test_edit_variables_with_true_editor() {
        // Use "true" as the editor — it exits 0 without modifying the file,
        // so we get the same content back.
        let mut vars = IndexMap::new();
        vars.insert("FOO".to_string(), "bar".to_string());
        vars.insert("BAZ".to_string(), "qux".to_string());

        let result = edit_variables_with_editor(&vars, "true").unwrap();
        assert_eq!(result.get("FOO").unwrap(), "bar");
        assert_eq!(result.get("BAZ").unwrap(), "qux");
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn test_edit_variables_empty() {
        let vars = IndexMap::new();
        let result = edit_variables_with_editor(&vars, "true").unwrap();
        assert!(result.is_empty());
    }

    #[test]
    fn test_edit_variables_skips_comments_and_blanks() {
        use std::io::Write;

        let mut tmp = tempfile::Builder::new()
            .prefix("cui-cui-test-")
            .suffix(".env")
            .tempfile()
            .unwrap();
        writeln!(tmp, "# comment").unwrap();
        writeln!(tmp, "").unwrap();
        writeln!(tmp, "KEY=value").unwrap();
        writeln!(tmp, "  # another comment").unwrap();
        writeln!(tmp, "OTHER=stuff").unwrap();
        tmp.flush().unwrap();

        let content = fs::read_to_string(tmp.path()).unwrap();
        let mut result = IndexMap::new();
        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some((key, value)) = line.split_once('=') {
                result.insert(key.trim().to_string(), value.to_string());
            }
        }
        assert_eq!(result.len(), 2);
        assert_eq!(result.get("KEY").unwrap(), "value");
        assert_eq!(result.get("OTHER").unwrap(), "stuff");
    }

    #[test]
    fn test_edit_variables_editor_failure() {
        let vars = IndexMap::new();
        let result = edit_variables_with_editor(&vars, "false");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Editor exited"));
    }

    #[test]
    fn test_escape_unescape_roundtrip() {
        let cases = &[
            "simple",
            "with spaces",
            "line1\nline2\nline3",
            "has \"quotes\"",
            "back\\slash",
            "mixed\n\"quotes\"\nand\\backslash",
            "",
            "\n",
            "trailing\n",
        ];
        for &original in cases {
            let escaped = escape_var_value(original);
            let restored = unescape_var_value(&escaped);
            assert_eq!(restored, original, "round-trip failed for {:?}", original);
        }
    }

    #[test]
    fn test_escape_var_value_format() {
        assert_eq!(escape_var_value("hello"), "\"hello\"");
        assert_eq!(escape_var_value("a\nb"), "\"a\\nb\"");
        assert_eq!(escape_var_value("a\"b"), "\"a\\\"b\"");
        assert_eq!(escape_var_value("a\\b"), "\"a\\\\b\"");
    }

    #[test]
    fn test_unescape_unquoted_passthrough() {
        assert_eq!(unescape_var_value("plain"), "plain");
    }

    #[test]
    fn test_edit_variables_multiline_roundtrip() {
        let mut vars = IndexMap::new();
        vars.insert("SIMPLE".to_string(), "value".to_string());
        vars.insert("MULTI".to_string(), "line1\nline2\nline3".to_string());
        vars.insert("QUOTED".to_string(), "has \"quotes\"".to_string());

        let result = edit_variables_with_editor(&vars, "true").unwrap();
        assert_eq!(result.get("SIMPLE").unwrap(), "value");
        assert_eq!(result.get("MULTI").unwrap(), "line1\nline2\nline3");
        assert_eq!(result.get("QUOTED").unwrap(), "has \"quotes\"");
    }

    #[test]
    fn test_collect_job_variables_basic() {
        let pipeline = make_test_pipeline("test-collect");
        let app = App::new(
            PathBuf::from("test.yml"),
            model::CiSystem::GitLab,
            None,
            crate::resolve::GitContext::default(),
            pipeline,
        );
        let vars = collect_job_variables(&app, "build");
        assert_eq!(vars.get("CI_JOB_NAME").unwrap(), "build");
    }

    #[test]
    fn test_collect_job_variables_with_edits() {
        let pipeline = make_test_pipeline("test-edits");
        let mut app = App::new(
            PathBuf::from("test.yml"),
            model::CiSystem::GitLab,
            None,
            crate::resolve::GitContext::default(),
            pipeline,
        );
        let mut overrides = IndexMap::new();
        overrides.insert("EXTRA".to_string(), "val".to_string());
        overrides.insert("CI_JOB_NAME".to_string(), "overridden".to_string());
        app.edited_vars.insert("build".to_string(), overrides);

        let vars = collect_job_variables(&app, "build");
        assert_eq!(vars.get("EXTRA").unwrap(), "val");
        assert_eq!(vars.get("CI_JOB_NAME").unwrap(), "overridden");
    }

    fn make_test_pipeline(label: &str) -> model::Pipeline {
        use model::{Job, JobDebugInfo, Pipeline, Stage};
        let _ = label;
        let mut vars = IndexMap::new();
        vars.insert("CI_JOB_NAME".to_string(), "build".to_string());
        let job = Job {
            name: "build".to_string(),
            stage: "build".to_string(),
            image: Some("rust:latest".to_string()),
            services: vec![],
            variables: vars,
            before_script: vec![],
            script: vec!["echo hi".to_string()],
            after_script: vec![],
            needs: vec![],
            artifact_sources: Some(vec![]),
            tags: vec![],
            when_val: "on_success".to_string(),
            allow_failure: false,
            rules_never: false,
            unresolved_extends: vec![],
            rules_summary: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            timeout: None,
            resource_group: None,
            coverage: None,
            resolved_yaml: String::new(),
            trigger_file: None,
            build_context: None,
            docker_entrypoint: None,
            step_id: None,
            steps: vec![],
            debug_info: JobDebugInfo::default(),
        };
        let mut jobs = IndexMap::new();
        jobs.insert("build".to_string(), job);
        Pipeline {
            stages: vec![Stage {
                name: "build".to_string(),
                job_names: vec!["build".to_string()],
            }],
            jobs,
            execution: Default::default(),
            checkout_path: None,
        }
    }

    #[test]
    fn test_build_github_meta_pipeline() {
        let dir = tempfile::tempdir().unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        // Code workflow
        fs::write(
            workflows.join("ci.yml"),
            "name: CI\non: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi",
        ).unwrap();
        // Non-code workflow
        fs::write(
            workflows.join("stale.yml"),
            "name: Stale\non:\n  schedule:\n    - cron: '0 0 * * *'\n  issues:\n    types: [opened]\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo stale",
        ).unwrap();

        let files = vec![workflows.join("ci.yml"), workflows.join("stale.yml")];
        let pipeline = build_github_meta_pipeline(&files, &files[0]);

        assert_eq!(pipeline.stages.len(), 1);
        assert_eq!(pipeline.stages[0].name, "workflows");
        assert_eq!(pipeline.stages[0].job_names.len(), 2);

        let ci_job = &pipeline.jobs["CI"];
        assert!(!ci_job.rules_never);
        assert!(ci_job.trigger_file.is_some());

        let stale_job = &pipeline.jobs["Stale"];
        assert!(stale_job.rules_never);
        assert!(stale_job.trigger_file.is_some());
    }

    #[test]
    fn test_build_github_meta_pipeline_no_name_uses_filename() {
        let dir = tempfile::tempdir().unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        fs::write(
            workflows.join("ci.yml"),
            "on: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo",
        )
        .unwrap();
        fs::write(
            workflows.join("lint.yml"),
            "on: pull_request\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo",
        ).unwrap();

        let files = vec![workflows.join("ci.yml"), workflows.join("lint.yml")];
        let pipeline = build_github_meta_pipeline(&files, &files[0]);

        assert!(pipeline.jobs.contains_key("ci.yml"));
        assert!(pipeline.jobs.contains_key("lint.yml"));
        assert_eq!(pipeline.stages.len(), 1);
        assert_eq!(pipeline.stages[0].job_names.len(), 2);
    }

    #[test]
    fn test_build_github_meta_pipeline_duplicate_names() {
        let dir = tempfile::tempdir().unwrap();
        let workflows = dir.path().join(".github").join("workflows");
        fs::create_dir_all(&workflows).unwrap();
        fs::write(
            workflows.join("a.yml"),
            "name: CI\non: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo a",
        ).unwrap();
        fs::write(
            workflows.join("b.yml"),
            "name: CI\non: pull_request\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo b",
        ).unwrap();

        let files = vec![workflows.join("a.yml"), workflows.join("b.yml")];
        let pipeline = build_github_meta_pipeline(&files, &files[0]);

        assert_eq!(pipeline.jobs.len(), 2, "both workflows must be present");
        assert_eq!(pipeline.stages[0].job_names.len(), 2);
        assert!(pipeline.jobs.contains_key("CI"));
        assert!(pipeline.jobs.contains_key("CI (b.yml)"));
    }
}
