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 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>,

    /// 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>,

    /// 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>,
}

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 (file_path, ci_system) = detect_ci_file(&ci_config_path);
    let file_path = file_path.canonicalize().unwrap_or(file_path);

    // 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_project = cli.ci_project.or(file_config.ci_project);

    let (gitlab_config, git_context, pipeline) = match ci_system {
        model::CiSystem::GitHub => {
            info!("Detected GitHub Actions workflow: {}", file_path.display());
            let raw = parser::github::parse_workflow_file(&file_path)
                .with_context(|| format!("Failed to parse {}", file_path.display()))?;
            let pipeline =
                resolve::github::resolve_pipeline(raw).context("Failed to resolve workflow")?;
            (None, resolve::GitContext::default(), pipeline)
        }
        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())
                .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;
            }

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

            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 allow_unresolved =
                cli.allow_unresolved || file_config.allow_unresolved.unwrap_or(false);
            let resolve_result = if allow_unresolved {
                resolve::resolve_pipeline_tolerant(raw, &git_context)
            } else {
                resolve::resolve_pipeline(raw, &git_context)
            };
            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)
        }
    };

    // 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();

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

    if cli.list_jobs {
        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 job_name = pipeline
            .find_job(query)
            .map_err(|e| anyhow::anyhow!("{}", e))?;
        let job = &pipeline.jobs[job_name];
        println!("{}", job.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 job_name = pipeline
            .find_job(query)
            .map_err(|e| anyhow::anyhow!("{}", e))?;
        let job = &pipeline.jobs[job_name];
        let mut config = container::build_config_for_job(
            job,
            ci_system,
            &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
            )
        })?;

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

        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
        let runner = dispatch::find_runner(&runners, &job.tags);

        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_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()
            && let Err(e) = remote::download_artifacts(
                r,
                job_name,
                &job_project_path,
                &job_artifacts_paths,
                &job_artifacts_exclude,
                &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, {:.1}s)",
                job_name,
                status.duration.as_secs_f64()
            );
        } else if job.allow_failure {
            println!(
                "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {:.1}s)",
                job_name,
                status.exit_code,
                status.duration.as_secs_f64()
            );
        } else {
            println!(
                "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {:.1}s)",
                job_name,
                status.exit_code,
                status.duration.as_secs_f64()
            );
        }
        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 job_name = pipeline
            .find_job(query)
            .map_err(|e| anyhow::anyhow!("{}", e))?;
        let job = &pipeline.jobs[job_name];
        let mut config = container::build_config_for_job(
            job,
            ci_system,
            &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
            )
        })?;

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

        // Check for runner match
        let runner = dispatch::find_runner(&runners, &job.tags);

        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);
        // Resolve --skip patterns to actual job names (matches all for ambiguous patterns)
        let mut skip_patterns = file_config.dispatch_skip.unwrap_or_default();
        skip_patterns.extend(cli.skip);
        let mut skip_jobs = Vec::new();
        for pattern in &skip_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));
            }
        }
        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,
        )?;
        std::process::exit(if success { 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.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<()> {
    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,
                        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  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());
            }
        })?;

        // 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::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()?;

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

                            // 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 runner = dispatch::find_runner(runners, job_tags);
                            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)?;

                                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) => {
                                        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, {:.1}s)",
                                                job_name,
                                                status.duration.as_secs_f64()
                                            );
                                        } else if allow_failure {
                                            println!(
                                                "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {:.1}s)",
                                                job_name,
                                                status.exit_code,
                                                status.duration.as_secs_f64()
                                            );
                                        } else {
                                            println!(
                                                "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {:.1}s)",
                                                job_name,
                                                status.exit_code,
                                                status.duration.as_secs_f64()
                                            );
                                        }
                                        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) => {
                            if let Err(e) = app.open_child_tab(path) {
                                app.error = Some(format!("Failed to open child pipeline: {}", e));
                            }
                        }
                        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;
                                // border_y = term_height - 1 (status) - detail_height
                                // new detail_height = term_height - 1 - y
                                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_file(path: &Path) -> (PathBuf, model::CiSystem) {
    if path.is_dir() {
        let gitlab = path.join(".gitlab-ci.yml");
        if gitlab.exists() {
            return (gitlab, model::CiSystem::GitLab);
        }
        let github_dir = path.join(".github").join("workflows");
        if github_dir.is_dir() {
            // Collect all workflow files and pick the first alphabetically
            if 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 let Some(first) = workflows.into_iter().next() {
                    return (first, model::CiSystem::GitHub);
                }
            }
        }
        // Default to GitLab (will fail later with a clear error)
        (gitlab, model::CiSystem::GitLab)
    } else {
        // Guess from the path
        let path_str = path.to_string_lossy();
        if path_str.contains(".github/workflows") || path_str.contains(".github\\workflows") {
            (path.to_path_buf(), model::CiSystem::GitHub)
        } else {
            (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
}

/// 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)");
            }
        }
    }

    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,
    );

    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, {:.1}s)",
            job_name,
            status.duration.as_secs_f64()
        );
    } else if allow_failure {
        println!(
            "\x1b[1;33m⚠ Job '{}' failed (allowed)\x1b[0m (exit code {}, {:.1}s)",
            job_name,
            status.exit_code,
            status.duration.as_secs_f64()
        );
    } else {
        println!(
            "\x1b[1;31m✗ Job '{}' failed\x1b[0m (exit code {}, {:.1}s)",
            job_name,
            status.exit_code,
            status.duration.as_secs_f64()
        );
    }
    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,
) {
    match artifacts::resolve_artifacts(
        &config.needs,
        &config.project_path,
        pipeline_id,
        gitlab_config,
    ) {
        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);
        }
    }
    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
}

#[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_file_gitlab_file() {
        let dir = tempfile::tempdir().unwrap();
        let gitlab = dir.path().join(".gitlab-ci.yml");
        fs::write(&gitlab, "stages: [build]").unwrap();

        let (path, system) = detect_ci_file(dir.path());
        assert_eq!(path, gitlab);
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_file_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 (path, system) = detect_ci_file(dir.path());
        assert_eq!(path, wf);
        assert!(matches!(system, model::CiSystem::GitHub));
    }

    #[test]
    fn test_detect_ci_file_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 (path, system) = detect_ci_file(dir.path());
        assert_eq!(path, gitlab);
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_file_github_picks_first_alphabetically() {
        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 (path, system) = detect_ci_file(dir.path());
        assert_eq!(path, workflows.join("a_lint.yml"));
        assert!(matches!(system, model::CiSystem::GitHub));
    }

    #[test]
    fn test_detect_ci_file_explicit_path() {
        let (_, system) = detect_ci_file(Path::new("/tmp/my/.github/workflows/ci.yml"));
        assert!(matches!(system, model::CiSystem::GitHub));

        let (_, system) = detect_ci_file(Path::new("/tmp/my/.gitlab-ci.yml"));
        assert!(matches!(system, model::CiSystem::GitLab));
    }

    #[test]
    fn test_detect_ci_file_empty_dir_defaults_gitlab() {
        let dir = tempfile::tempdir().unwrap();
        let (_, system) = detect_ci_file(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()));
    }
}
