//! Container runtime (podman/docker) detection and job execution.
//!
//! Detects which runtime is available, builds the `run` command with
//! volume mounts, environment variables, and entrypoint, then streams
//! container output back to the terminal.
//!
//! The project source tree can be mounted several ways (see `MountStrategy`):
//! a simple bind mount for interactive use, a git-worktree-based volume with
//! alternates for writable `/git-src` in dispatch mode, or a pre-populated
//! volume for remote runners. [`ContainerConfig`] bundles everything needed
//! to launch a single job.

use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

use anyhow::{Context, Result};
use indexmap::IndexMap;
use log::debug;

use crate::model::{CiSystem, Job, ResolvedNeed, shell_quote};
use crate::resolve::GitContext;

const GITHUB_ENV_MOUNT: &str = "/tmp/github-env";

/// Sentinel image name used when a Docker action has a `build_context` but no
/// explicit image.  `run_container_impl` replaces it with the locally-built
/// image tag before the container is launched.
const BUILD_CONTEXT_PLACEHOLDER: &str = "placeholder:build-context";

/// Pointers to C strings used by the SIGINT handler to run `<runtime> kill <name>`.
/// Set by `SigintGuard::new()`, cleared by its `Drop`.
#[cfg(unix)]
static KILL_RUNTIME: std::sync::atomic::AtomicPtr<libc::c_char> =
    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
#[cfg(unix)]
static KILL_CONTAINER: std::sync::atomic::AtomicPtr<libc::c_char> =
    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());

/// Signal handler that runs `<runtime> kill <container>` via fork+exec.
///
/// All libc calls used here (fork, execlp, open, dup2, close, waitpid, _exit)
/// are async-signal-safe, so this is correct to call from a signal handler.
#[cfg(unix)]
extern "C" fn sigint_kill_container(_: libc::c_int) {
    use std::sync::atomic::Ordering;

    let runtime = KILL_RUNTIME.load(Ordering::SeqCst);
    let container = KILL_CONTAINER.load(Ordering::SeqCst);
    if runtime.is_null() || container.is_null() {
        return;
    }

    unsafe {
        // Double-fork so the grandchild (which runs podman/docker kill) is
        // reparented to init — no zombie, no need for the main thread to
        // reap it.
        let pid = libc::fork();
        if pid == 0 {
            // First child
            let pid2 = libc::fork();
            if pid2 == 0 {
                // Grandchild: redirect stdout/stderr to /dev/null, then exec.
                let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
                if devnull >= 0 {
                    libc::dup2(devnull, 1);
                    libc::dup2(devnull, 2);
                    libc::close(devnull);
                }
                libc::execlp(
                    runtime,
                    runtime,
                    c"kill".as_ptr(),
                    container,
                    std::ptr::null::<libc::c_char>(),
                );
                libc::_exit(1);
            }
            libc::_exit(0);
        }
        if pid > 0 {
            // Reap the first child immediately (it exits right away).
            libc::waitpid(pid, std::ptr::null_mut(), 0);
        }
    }
}

/// RAII guard that intercepts SIGINT while a container is running.
///
/// On creation, installs a handler that runs `<runtime> kill <name>` via
/// fork+exec.  On drop, restores the previous handler and frees the strings.
/// The main thread just calls `child.wait()` — the container dies because
/// `podman kill` / `docker kill` stops it directly.
#[cfg(unix)]
struct SigintGuard {
    prev: libc::sighandler_t,
    runtime_ptr: *mut libc::c_char,
    container_ptr: *mut libc::c_char,
}

// SigintGuard stores raw pointers that it owns exclusively; it is safe to
// move across threads (the pointers are only accessed via atomics).
#[cfg(unix)]
unsafe impl Send for SigintGuard {}

#[cfg(unix)]
impl SigintGuard {
    fn new(runtime: &str, container_name: &str) -> Self {
        use std::ffi::CString;
        use std::sync::atomic::Ordering;

        let runtime_c = CString::new(runtime).expect("runtime name has no NUL bytes");
        let container_c = CString::new(container_name).expect("container name has no NUL bytes");
        let runtime_ptr = runtime_c.into_raw();
        let container_ptr = container_c.into_raw();

        KILL_RUNTIME.store(runtime_ptr, Ordering::SeqCst);
        KILL_CONTAINER.store(container_ptr, Ordering::SeqCst);

        let prev = unsafe {
            libc::signal(
                libc::SIGINT,
                sigint_kill_container as *const () as libc::sighandler_t,
            )
        };
        Self {
            prev,
            runtime_ptr,
            container_ptr,
        }
    }
}

#[cfg(unix)]
impl Drop for SigintGuard {
    fn drop(&mut self) {
        use std::ffi::CString;
        use std::sync::atomic::Ordering;

        KILL_RUNTIME.store(std::ptr::null_mut(), Ordering::SeqCst);
        KILL_CONTAINER.store(std::ptr::null_mut(), Ordering::SeqCst);
        unsafe {
            libc::signal(libc::SIGINT, self.prev);
            // Reclaim the leaked CStrings.
            drop(CString::from_raw(self.runtime_ptr));
            drop(CString::from_raw(self.container_ptr));
        }
    }
}

/// Fork a guardian child process that removes a container when the parent dies.
///
/// Returns `(child_pid, pipe_write_fd)`. The child blocks on the read end of
/// a pipe; when the parent exits (for any reason, including SIGKILL), the
/// kernel closes the write end, `read()` returns 0, and the child execs
/// `<runtime> rm -f <container>`.
///
/// The parent must keep the write fd open for the container's lifetime. On
/// normal cleanup (`StepContainer::stop`), kill the guardian and close the fd
/// before removing the container yourself.
#[cfg(unix)]
fn spawn_container_guardian(
    runtime: ContainerRuntime,
    container_name: &str,
) -> Option<(libc::pid_t, std::os::fd::OwnedFd)> {
    use std::ffi::CString;
    use std::os::fd::FromRawFd;

    let mut fds = [0i32; 2];
    if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
        return None;
    }
    let (read_fd, write_fd) = (fds[0], fds[1]);

    let runtime_c = CString::new(runtime.name()).ok()?;
    let container_c = CString::new(container_name).ok()?;

    let pid = unsafe { libc::fork() };
    match pid.cmp(&0) {
        std::cmp::Ordering::Equal => {
            // Child: close write end, block on read end until parent dies.
            // When the parent exits (any cause including SIGKILL), the
            // kernel closes the write end and read() returns 0.
            // Loop on EINTR since we inherit the parent's signal handlers.
            unsafe {
                libc::close(write_fd);
                // Reset signal handlers that the parent may have installed
                libc::signal(libc::SIGINT, libc::SIG_DFL);
                libc::signal(libc::SIGTERM, libc::SIG_DFL);
                libc::signal(libc::SIGWINCH, libc::SIG_DFL);
                // Ignore SIGHUP so terminal disconnect doesn't kill guardian before cleanup
                libc::signal(libc::SIGHUP, libc::SIG_IGN);
                let mut buf = [0u8; 1];
                loop {
                    let n = libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 1);
                    if n >= 0 {
                        break;
                    }
                    #[cfg(target_os = "linux")]
                    let errno = *libc::__errno_location();
                    #[cfg(target_os = "macos")]
                    let errno = *libc::__error();
                    if errno != libc::EINTR {
                        break;
                    }
                }
                libc::close(read_fd);
                exec_rm_container(runtime_c, container_c);
            }
        }
        std::cmp::Ordering::Less => {
            // Fork failed
            unsafe {
                libc::close(read_fd);
                libc::close(write_fd);
            }
            return None;
        }
        std::cmp::Ordering::Greater => {
            // Parent: close read end, keep write end with CLOEXEC so
            // subprocesses (podman exec, podman build) don't inherit it.
            unsafe {
                libc::close(read_fd);
                libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC);
            }
        }
    }
    // SAFETY: write_fd is a valid fd that we own (read end and child's copy
    // are already closed). OwnedFd will close it on drop, preventing leaks.
    let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd(write_fd) };
    Some((pid, owned))
}

/// Exec `<runtime> rm -f <container>` via double-fork (async-signal-safe).
///
/// The double-fork ensures the grandchild is reparented to init — no zombie.
/// This function never returns in the calling process (it calls `_exit`).
#[cfg(unix)]
unsafe fn exec_rm_container(runtime: std::ffi::CString, container: std::ffi::CString) -> ! {
    unsafe {
        let pid = libc::fork();
        if pid == 0 {
            let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
            if devnull >= 0 {
                libc::dup2(devnull, 1);
                libc::dup2(devnull, 2);
                libc::close(devnull);
            }
            libc::execlp(
                runtime.as_ptr(),
                runtime.as_ptr(),
                c"rm".as_ptr(),
                c"-f".as_ptr(),
                container.as_ptr(),
                std::ptr::null::<libc::c_char>(),
            );
            libc::_exit(1);
        }
        if pid > 0 {
            libc::waitpid(pid, std::ptr::null_mut(), 0);
        }
        libc::_exit(0);
    }
}

#[derive(Clone)]
pub struct ContainerConfig {
    pub image: String,
    pub project_dir: PathBuf,
    pub variables: IndexMap<String, String>,
    pub script_content: String,
    pub job_name: String,
    pub privileged: bool,
    pub no_overlay: bool,
    /// Artifact directories to inject (job_name, path), populated after resolution.
    pub artifact_dirs: Vec<(String, PathBuf)>,
    /// Artifact path globs declared by this job (for saving after container exit).
    pub artifacts_paths: Vec<String>,
    /// Artifact exclude globs (files to omit from artifact archives).
    pub artifacts_exclude: Vec<String>,
    /// Whether to include git-untracked files in artifacts.
    pub artifacts_untracked: bool,
    /// When to save artifacts: "on_success" (default), "on_failure", or "always".
    pub artifacts_when: String,
    /// Path declared in `artifacts:reports:dotenv` (extracted separately from regular artifacts).
    pub dotenv_report: Option<String>,
    /// Project path for container build directory (e.g. "mesa/mesa" -> /builds/mesa/mesa).
    pub project_path: String,
    /// The job's resolved dependencies (for artifact resolution before launch).
    pub needs: Vec<ResolvedNeed>,
    /// Host path to the GitHub Actions cache directory (mounted read-only).
    pub actions_cache_dir: Option<PathBuf>,
    /// Image used for git operations (clone into volume, apply diffs).
    pub git_image: String,
    /// Whether the job allows failure (non-zero exit doesn't block dependents).
    pub allow_failure: bool,
    /// Preferred container runtime (overrides auto-detection when set).
    pub(crate) runtime: Option<ContainerRuntime>,
    /// Expanded kernel path for the `container` runtime's `--kernel` flag.
    pub kernel: Option<String>,
    /// Memory limit for the container (e.g. `"8G"`), passed as `-m <value>`.
    pub memory: Option<String>,
    /// Number of CPUs for the container, passed as `--cpus <value>`.
    pub cpus: Option<u32>,
    /// Directory containing a Dockerfile to build before launching the container.
    pub build_context: Option<PathBuf>,
    /// Host path to a shared directory for GitHub env files (GITHUB_ENV, etc.).
    /// Mounted read-write so step containers can communicate state.
    pub env_dir: Option<PathBuf>,
    /// Checkout sub-path from `actions/checkout` (GitHub Actions).
    /// When set, the source tree is mounted at `<build_dir>/<path>` inside the
    /// container so that `$GITHUB_WORKSPACE/<path>` resolves to the source.
    pub checkout_path: Option<String>,
}

impl ContainerConfig {
    /// Apply runner-level runtime and kernel settings.
    ///
    /// `kernel_template` (e.g. `"$LIMA_KEXEC_ARTIFACT"`) is expanded against
    /// this config's job variables; if the result is non-empty and fully
    /// resolved, it becomes the `--kernel` value.
    pub fn apply_runner_runtime(
        &mut self,
        runtime_name: Option<&str>,
        kernel_template: Option<&str>,
        memory: Option<&str>,
        cpus: Option<u32>,
    ) {
        self.runtime = runtime_name.and_then(ContainerRuntime::from_str);
        self.kernel = kernel_template.and_then(|tmpl| {
            let expanded = crate::resolve::gitlab::expand_vars(tmpl, &self.variables);
            if expanded.is_empty() || expanded.contains('$') {
                None
            } else {
                Some(expanded)
            }
        });
        self.memory = memory.map(|s| s.to_string());
        self.cpus = cpus;
    }
}

/// Serializable subset of [`ContainerConfig`] for shipping to remote runners.
///
/// Contains everything the remote cui-cui needs to run a job without parsing
/// the CI config. Local-only fields (`project_dir`, `artifact_dirs`,
/// `actions_cache_dir`) are excluded — the remote fills those in from its
/// own environment.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct JobSpec {
    pub image: String,
    pub variables: IndexMap<String, String>,
    pub script_content: String,
    pub job_name: String,
    pub privileged: bool,
    pub no_overlay: bool,
    pub artifacts_paths: Vec<String>,
    #[serde(default)]
    pub artifacts_exclude: Vec<String>,
    #[serde(default)]
    pub artifacts_untracked: bool,
    #[serde(default = "default_artifacts_when")]
    pub artifacts_when: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dotenv_report: Option<String>,
    pub project_path: String,
    pub needs: Vec<ResolvedNeed>,
    pub git_image: String,
    #[serde(default)]
    pub allow_failure: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub runtime: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kernel: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpus: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub diff_path: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub container_name: Option<String>,
}

fn default_artifacts_when() -> String {
    "on_success".to_string()
}

impl JobSpec {
    /// Create a `JobSpec` from a fully resolved `ContainerConfig`.
    pub fn from_config(config: &ContainerConfig) -> Self {
        Self {
            image: config.image.clone(),
            variables: config.variables.clone(),
            script_content: config.script_content.clone(),
            job_name: config.job_name.clone(),
            privileged: config.privileged,
            no_overlay: config.no_overlay,
            artifacts_paths: config.artifacts_paths.clone(),
            artifacts_exclude: config.artifacts_exclude.clone(),
            artifacts_untracked: config.artifacts_untracked,
            artifacts_when: config.artifacts_when.clone(),
            dotenv_report: config.dotenv_report.clone(),
            project_path: config.project_path.clone(),
            needs: config.needs.clone(),
            git_image: config.git_image.clone(),
            allow_failure: config.allow_failure,
            runtime: config.runtime.map(|r| r.name().to_string()),
            kernel: config.kernel.clone(),
            memory: config.memory.clone(),
            cpus: config.cpus,
            sha: None,
            diff_path: None,
            container_name: None,
        }
    }

    /// Convert into a `ContainerConfig`, filling in local-only fields.
    pub fn into_container_config(self, project_dir: PathBuf) -> ContainerConfig {
        ContainerConfig {
            image: self.image,
            project_dir,
            variables: self.variables,
            script_content: self.script_content,
            job_name: self.job_name,
            privileged: self.privileged,
            no_overlay: self.no_overlay,
            artifact_dirs: vec![],
            artifacts_paths: self.artifacts_paths,
            artifacts_exclude: self.artifacts_exclude,
            artifacts_untracked: self.artifacts_untracked,
            artifacts_when: self.artifacts_when,
            dotenv_report: self.dotenv_report,
            project_path: self.project_path,
            needs: self.needs,
            actions_cache_dir: None,
            git_image: self.git_image,
            allow_failure: self.allow_failure,
            runtime: self.runtime.as_deref().and_then(ContainerRuntime::from_str),
            kernel: self.kernel,
            memory: self.memory,
            cpus: self.cpus,
            build_context: None,
            env_dir: None,
            checkout_path: None,
        }
    }
}

/// Whether artifacts should be saved given the job exit code and `artifacts:when` policy.
///
/// GitLab semantics:
/// - `"on_success"` (default): save only when exit_code == 0
/// - `"on_failure"`: save only when exit_code != 0
/// - `"always"`: save regardless of exit code
pub fn should_save_artifacts(exit_code: i32, artifacts_when: &str) -> bool {
    match artifacts_when {
        "always" => true,
        "on_failure" => exit_code != 0,
        _ => exit_code == 0,
    }
}

/// Result of running a container non-interactively.
#[derive(Debug)]
pub struct ContainerExitStatus {
    pub exit_code: i32,
    pub log_path: PathBuf,
    pub duration: std::time::Duration,
    pub script_duration: std::time::Duration,
}

impl ContainerExitStatus {
    pub fn format_timing(&self) -> String {
        format!(
            "{}, script: {}",
            format_duration(self.duration),
            format_duration(self.script_duration),
        )
    }
}

pub fn format_duration(d: std::time::Duration) -> String {
    let total_secs = d.as_secs();
    let hours = total_secs / 3600;
    let mins = (total_secs % 3600) / 60;
    let secs = total_secs % 60;
    if hours > 0 {
        format!("{}h{:02}m{:02}s", hours, mins, secs)
    } else if mins > 0 {
        format!("{}m{:02}s", mins, secs)
    } else {
        format!("{}s", secs)
    }
}

/// Whether to run the container interactively or execute the script directly.
enum RunMode<'a> {
    /// Interactive shell with history seeding and rc file sourcing.
    Interactive,
    /// Non-interactive execution, streaming output to a log file.
    Dispatch {
        log_path: &'a Path,
        output_config: &'a OutputConfig,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ContainerRuntime {
    Podman,
    Docker,
    Container,
}

impl ContainerRuntime {
    pub(crate) fn name(&self) -> &'static str {
        match self {
            ContainerRuntime::Podman => "podman",
            ContainerRuntime::Docker => "docker",
            ContainerRuntime::Container => "container",
        }
    }

    fn from_str(s: &str) -> Option<Self> {
        match s {
            "podman" => Some(Self::Podman),
            "docker" => Some(Self::Docker),
            "container" => Some(Self::Container),
            _ => None,
        }
    }
}

fn detect_runtime() -> Option<ContainerRuntime> {
    if Command::new("podman")
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
    {
        return Some(ContainerRuntime::Podman);
    }
    if Command::new("docker")
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
    {
        return Some(ContainerRuntime::Docker);
    }
    None
}

fn resolve_runtime(preference: Option<ContainerRuntime>) -> Result<ContainerRuntime> {
    match preference {
        Some(rt) => {
            if Command::new(rt.name())
                .arg("--version")
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status()
                .is_ok_and(|s| s.success())
            {
                Ok(rt)
            } else {
                anyhow::bail!("Configured runtime '{}' not found in PATH", rt.name())
            }
        }
        None => detect_runtime().context("Neither podman nor docker found in PATH"),
    }
}

/// Ensure the container image is available locally, pulling with visible
/// progress if it needs to be fetched from a registry.
fn ensure_image(runtime: ContainerRuntime, image: &str, oc: &OutputConfig) -> Result<()> {
    if runtime == ContainerRuntime::Container {
        return Ok(());
    }

    let exists = Command::new(runtime.name())
        .args(["image", "inspect", image])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success());

    if !exists {
        oc.status(&format!("\x1b[90mPulling image {}...\x1b[0m", image));
        let output = Command::new(runtime.name())
            .args(["pull", image])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .context("Failed to pull image")?;
        for line in String::from_utf8_lossy(&output.stderr).lines() {
            oc.status(line);
        }
        if !output.status.success() {
            anyhow::bail!("Failed to pull image {}", image);
        }
    }

    Ok(())
}

/// Create a `Command` for running a disposable container (`run --rm --pull never`).
///
/// Returns a `Command` with the base args pre-set. The caller adds volumes,
/// entrypoint, working directory, image, and command arguments before running.
fn disposable_container(runtime: ContainerRuntime) -> Command {
    let mut cmd = Command::new(runtime.name());
    cmd.args(["run", "--rm"]);
    if runtime != ContainerRuntime::Container {
        cmd.args(["--pull", "never"]);
    }
    cmd
}

/// SELinux `:z` relabel suffix — needed by podman/docker, unsupported by
/// the macOS `container` CLI (which uses virtiofs).
fn selinux_suffix(runtime: ContainerRuntime) -> &'static str {
    if runtime == ContainerRuntime::Container {
        ""
    } else {
        ",z"
    }
}

/// Staging directory for runtimes that cannot bind-mount individual files
/// (e.g. the macOS `container` CLI uses virtiofs which only supports
/// directory shares).  Files are hard-linked or copied into the staging
/// dir and the whole directory is mounted as a single `-v` entry.
struct StagingDir {
    dir: tempfile::TempDir,
    /// Canonicalized path — on macOS the default temp dir lives behind
    /// symlinks (`/var` → `/private/var`) that virtiofs cannot traverse.
    real_path: PathBuf,
}

impl StagingDir {
    fn new() -> Result<Self> {
        let dir = tempfile::TempDir::new().context("Failed to create staging directory")?;
        let real_path = dir
            .path()
            .canonicalize()
            .unwrap_or_else(|_| dir.path().to_path_buf());
        Ok(Self { dir, real_path })
    }

    /// Stage a file into the directory, returning its path inside the staging dir.
    /// The `name` is the filename used inside the staging dir.
    fn stage_file(&self, source: &std::path::Path, name: &str) -> Result<()> {
        let dest = self.dir.path().join(name);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::copy(source, &dest)
            .with_context(|| format!("Failed to stage {}", source.display()))?;
        Ok(())
    }

    /// Write content directly into a staged file.
    fn write_file(&self, name: &str, content: &[u8]) -> Result<()> {
        let dest = self.dir.path().join(name);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(&dest, content)
            .with_context(|| format!("Failed to write staged file {}", name))?;
        Ok(())
    }

    fn path(&self) -> &std::path::Path {
        &self.real_path
    }
}

/// How the project directory is mounted in the container.
enum MountStrategy {
    /// Podman overlay mount (copy-on-write, writable, no host changes)
    Overlay,
    /// Named volume (files copied in, writable, no host changes)
    Volume(String),
    /// Git worktree: fast checkout sharing the host's object store.
    ///
    /// Layout:
    /// - `/builds/<project>` ← `project_vol` (checked-out working tree)
    /// - `/git-src`          ← `git_vol` (writable shadow: metadata + alternates)
    /// - `/git-host`         ← `git_dir` (host's git common dir, read-only)
    ///
    /// The shadow git dir at `/git-src` contains refs, config, and an empty
    /// objects directory with an `info/alternates` file pointing to
    /// `/git-host/objects`. This makes the git dir fully writable (for
    /// operations like `git am -3`) without modifying the host or copying
    /// the (potentially large) object store.
    GitWorktree {
        project_vol: String,
        git_vol: String,
        git_dir: PathBuf,
    },
    /// Host-side git worktree, bind-mounted into the container.
    ///
    /// Used for the `container` runtime (macOS) where volumes live inside
    /// the VM. Bind mounts use virtiofs, keeping files on the host and
    /// avoiding VM memory pressure.
    HostWorktree {
        worktree_path: PathBuf,
        project_dir: PathBuf,
        git_dir: PathBuf,
    },
}

/// Compute the container build directory path from the project path.
/// E.g. "mesa/mesa" -> "/builds/mesa/mesa", "" -> "/builds/project"
///
/// Sanitizes the path to prevent traversal (strips `..` and leading `/`).
pub fn builds_dir(project_path: &str) -> String {
    if project_path.is_empty() {
        "/builds/project".to_string()
    } else {
        use std::path::Component;
        let sanitized: std::path::PathBuf = std::path::Path::new(project_path)
            .components()
            .filter(|c| matches!(c, Component::Normal(_)))
            .collect();
        let s = sanitized.to_string_lossy();
        if s.is_empty() {
            "/builds/project".to_string()
        } else {
            format!("/builds/{}", s)
        }
    }
}

fn is_tar_artifact(path: &std::path::Path) -> bool {
    path.extension().is_some_and(|ext| ext == "tar")
}

/// Sanitize a job name for use in mount paths to prevent path traversal.
/// Replaces path separators and strips `..` components.
fn sanitize_job_name(name: &str) -> String {
    name.replace(['/', '\\'], "_").replace("..", "_")
}

/// Extract a kernel file from artifact tars when it doesn't exist in the
/// working directory (e.g., when running in a fresh worktree).
///
/// Returns a `TempDir` that must be kept alive until the kernel is no
/// longer needed (the extracted file lives inside it).
fn extract_kernel_from_artifacts(
    kernel: &mut String,
    artifact_dirs: &[(String, PathBuf)],
) -> Result<Option<tempfile::TempDir>> {
    for (_job_name, source_path) in artifact_dirs {
        if !is_tar_artifact(source_path) || !source_path.is_file() {
            continue;
        }
        let file = std::fs::File::open(source_path)?;
        let mut archive = tar::Archive::new(file);
        for entry in archive.entries()? {
            let mut entry = entry?;
            let path = entry.path()?.to_path_buf();
            if path.to_string_lossy() == *kernel {
                let tmp = tempfile::tempdir()?;
                let dest = tmp.path().join(&path);
                if let Some(parent) = dest.parent() {
                    std::fs::create_dir_all(parent)?;
                }
                entry.unpack(&dest)?;
                *kernel = dest.to_string_lossy().to_string();
                return Ok(Some(tmp));
            }
        }
    }
    anyhow::bail!("Kernel file not found: {}", kernel);
}

/// Collect the container argument list shared by both `build_command` and
/// `format_command`. Returns `(args, build_dir)` where `args` is a flat
/// list of flag/value strings.
fn collect_container_args(
    config: &ContainerConfig,
    runtime: ContainerRuntime,
    script_path: &std::path::Path,
    mount: &MountStrategy,
    interactive: bool,
    mount_artifacts: bool,
) -> (Vec<String>, String) {
    let mut args: Vec<String> = Vec::new();

    if let Some(ref kernel) = config.kernel {
        args.extend(["--kernel".into(), kernel.clone()]);
    }

    if let Some(ref memory) = config.memory {
        args.extend(["-m".into(), memory.clone()]);
    }

    if let Some(cpus) = config.cpus {
        args.extend(["--cpus".into(), cpus.to_string()]);
    }

    if interactive {
        args.push("-it".into());
    }

    if config.privileged {
        args.push("--privileged".into());
    }

    let build_dir = builds_dir(&config.project_path);
    let z = selinux_suffix(runtime);

    // Mount project directory (and git mounts for worktree mode)
    match mount {
        MountStrategy::Overlay => {
            args.push("-v".into());
            args.push(format!("{}:{}:O", config.project_dir.display(), build_dir));
        }
        MountStrategy::Volume(vol) => {
            args.push("-v".into());
            args.push(format!("{}:{}", vol, build_dir));
        }
        MountStrategy::GitWorktree {
            project_vol,
            git_vol,
            git_dir,
        } => {
            args.push("-v".into());
            args.push(format!("{}:{}", project_vol, build_dir));
            args.push("-v".into());
            args.push(format!("{}:/git-host:ro{}", git_dir.display(), z));
            args.push("-v".into());
            args.push(format!("{}:/git-src", git_vol));
        }
        MountStrategy::HostWorktree {
            worktree_path,
            git_dir,
            ..
        } => {
            args.push("-v".into());
            args.push(format!("{}:{}", worktree_path.display(), build_dir));
            args.push("-v".into());
            args.push(format!("{}:/git-host:ro{}", git_dir.display(), z));
        }
    }

    // Staging directory for script/init/artifact files
    let staging_dir = script_path.parent().unwrap();
    args.push("-v".into());
    args.push(format!("{}:/tmp/ci-staging:ro{}", staging_dir.display(), z));

    // Mount artifact directories when needed (interactive mode, or dispatch
    // with GIT_STRATEGY=none where the volume has no pre-populated content).
    if mount_artifacts {
        for (job_name, source_path) in &config.artifact_dirs {
            if is_tar_artifact(source_path) {
                continue;
            }
            let safe_name = sanitize_job_name(job_name);
            let container_path = format!("/tmp/artifacts/{}", safe_name);
            args.push("-v".into());
            args.push(format!(
                "{}:{}:ro{}",
                source_path.display(),
                container_path,
                z
            ));
        }
    }

    // Mount GitHub Actions cache
    if let Some(ref actions_dir) = config.actions_cache_dir {
        args.push("-v".into());
        args.push(format!(
            "{}:{}:ro{}",
            actions_dir.display(),
            crate::parser::github::action_cache::ACTIONS_MOUNT_POINT,
            z,
        ));
    }

    // Mount GitHub env files directory (for step-container communication)
    if let Some(ref env_dir) = config.env_dir {
        let env_mount = GITHUB_ENV_MOUNT;
        args.push("-v".into());
        args.push(format!("{}:{}:rw{}", env_dir.display(), env_mount, z));
        args.push("-e".into());
        args.push(format!("GITHUB_ENV={}/github_env", env_mount));
        args.push("-e".into());
        args.push(format!("GITHUB_PATH={}/github_path", env_mount));
        args.push("-e".into());
        args.push(format!("GITHUB_OUTPUT={}/github_output", env_mount));
        args.push("-e".into());
        args.push(format!(
            "GITHUB_STEP_SUMMARY={}/github_step_summary",
            env_mount
        ));
    }

    // Working directory
    args.push("-w".into());
    args.push(build_dir.clone());

    // Environment variables
    for (k, v) in &config.variables {
        args.push("-e".into());
        args.push(format!("{}={}", k, v));
    }

    (args, build_dir)
}

#[allow(clippy::too_many_arguments)]
fn build_command(
    config: &ContainerConfig,
    runtime: ContainerRuntime,
    script_path: &std::path::Path,
    mount: &MountStrategy,
    has_bash: bool,
    mode: &RunMode,
    container_name: &str,
    mount_artifacts: bool,
) -> Command {
    let interactive = matches!(mode, RunMode::Interactive);
    let (args, _build_dir) = collect_container_args(
        config,
        runtime,
        script_path,
        mount,
        interactive,
        mount_artifacts,
    );

    let mut cmd = Command::new(runtime.name());
    cmd.args(["run", "--rm", "--name", container_name]);
    cmd.args(&args);

    let ci_script = "/tmp/ci-staging/ci-script.sh";
    let ci_init = "/tmp/ci-staging/ci-init.sh";

    cmd.arg(&config.image);
    match mode {
        RunMode::Interactive => {
            if has_bash {
                cmd.args(["bash", "--rcfile", ci_init]);
            } else {
                cmd.args(["sh", "-c", &format!(". {}; exec sh -i", ci_init)]);
            }
        }
        RunMode::Dispatch { .. } => {
            if has_bash {
                cmd.args([
                    "bash",
                    "-c",
                    &format!(". {} && exec bash -xe {}", ci_init, ci_script),
                ]);
            } else {
                cmd.args([
                    "sh",
                    "-c",
                    &format!(". {} && exec sh -xe {}", ci_init, ci_script),
                ]);
            }
        }
    }

    cmd
}

/// Format the command for display purposes (interactive mode only).
fn format_command(
    config: &ContainerConfig,
    runtime: ContainerRuntime,
    script_path: &std::path::Path,
    mount: &MountStrategy,
    has_bash: bool,
) -> String {
    let (args, _build_dir) =
        collect_container_args(config, runtime, script_path, mount, true, true);

    let mut parts = vec![
        runtime.name().to_string(),
        "run".to_string(),
        "--rm".to_string(),
    ];

    // Shell-quote argument values for display (flags like -v, -e, -w stay unquoted)
    for arg in &args {
        if arg.starts_with('-') {
            parts.push(arg.clone());
        } else {
            parts.push(shell_quote(arg));
        }
    }

    let ci_init = "/tmp/ci-staging/ci-init.sh";
    parts.push(shell_quote(&config.image));
    if has_bash {
        parts.push(format!("bash --rcfile {}", ci_init));
    } else {
        parts.push(format!("sh -c '. {}; exec sh -i'", ci_init));
    }

    parts.join(" \\\n  ")
}

use crate::artifacts::MAX_DOTENV_SIZE;

/// Sanitize a dotenv path from CI config, rejecting absolute paths and `..` traversal.
fn sanitize_dotenv_path(raw: &str) -> anyhow::Result<std::path::PathBuf> {
    let p = std::path::Path::new(raw);
    if p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) {
        anyhow::bail!("dotenv path contains traversal or is absolute: {}", raw);
    }
    Ok(p.to_path_buf())
}

/// Extract a dotenv report file from the container mount after job completion.
///
/// Reads the file specified by `artifacts:reports:dotenv` and saves it as
/// `<job>.dotenv` in the local artifact cache. Returns the saved path.
fn extract_dotenv_from_mount(
    mount: &MountStrategy,
    runtime: &ContainerRuntime,
    project_path: &str,
    git_image: &str,
    job_name: &str,
    dotenv_path: &str,
) -> anyhow::Result<Option<std::path::PathBuf>> {
    let safe_dotenv_path = sanitize_dotenv_path(dotenv_path)?;

    let content = match mount {
        MountStrategy::HostWorktree { worktree_path, .. } => {
            let full_path = worktree_path.join(&safe_dotenv_path);
            let file = match std::fs::File::open(&full_path) {
                Ok(f) => f,
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
                Err(e) => return Err(e.into()),
            };
            let mut buf = String::new();
            use std::io::Read;
            std::io::BufReader::new(file)
                .take(MAX_DOTENV_SIZE + 1)
                .read_to_string(&mut buf)?;
            if buf.len() as u64 > MAX_DOTENV_SIZE {
                anyhow::bail!(
                    "dotenv file exceeds {} byte limit ({})",
                    MAX_DOTENV_SIZE,
                    buf.len()
                );
            }
            buf
        }
        _ => {
            let vol = match mount {
                MountStrategy::Volume(vol) => Some(vol.as_str()),
                MountStrategy::GitWorktree { project_vol, .. } => Some(project_vol.as_str()),
                _ => return Ok(None),
            };
            let Some(vol) = vol else {
                return Ok(None);
            };
            let build_dir = builds_dir(project_path);
            let file_in_vol = format!("{}/{}", build_dir, safe_dotenv_path.display());
            let vol_mount = format!("{}:{}", vol, build_dir);
            let output = Command::new(runtime.name())
                .args(["run", "--rm"])
                .args(if runtime.name() != "container" {
                    vec!["--pull", "never"]
                } else {
                    vec![]
                })
                .args([
                    "-v",
                    &vol_mount,
                    "--entrypoint",
                    "cat",
                    git_image,
                    &file_in_vol,
                ])
                .stdout(std::process::Stdio::piped())
                .stderr(std::process::Stdio::piped())
                .output()?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                log::debug!("dotenv extraction failed for '{}': {}", job_name, stderr);
                return Ok(None);
            }
            if output.stdout.len() as u64 > MAX_DOTENV_SIZE {
                anyhow::bail!(
                    "dotenv output exceeds {} byte limit ({})",
                    MAX_DOTENV_SIZE,
                    output.stdout.len()
                );
            }
            String::from_utf8_lossy(&output.stdout).into_owned()
        }
    };

    if content.trim().is_empty() {
        return Ok(None);
    }

    let local_dir =
        crate::artifacts::artifact_local_dir(project_path).context("Cannot determine cache dir")?;
    std::fs::create_dir_all(&local_dir)?;
    let safe_name = crate::artifacts::sanitize_job_filename(job_name);
    let dest = local_dir.join(format!("{}.dotenv", safe_name));
    std::fs::write(&dest, &content)?;

    Ok(Some(dest))
}

/// Build the init script content that runs when the container shell starts.
///
/// In interactive mode: sources default rc files, sets environment, copies
/// artifact bind mounts into the working directory, and seeds command history.
/// In dispatch mode: only sets environment and copies artifacts.
fn build_init_script(
    artifact_dirs: &[(String, PathBuf)],
    has_bash: bool,
    mode: &RunMode,
    ci_script_path: &str,
) -> String {
    let mut script = String::new();

    // Interactive: source rc files for a proper shell environment.
    // Bash gets its own rc files; sh gets /etc/profile instead.
    if matches!(mode, RunMode::Interactive) {
        if has_bash {
            script.push_str(
                "[ -f /etc/bash.bashrc ] && . /etc/bash.bashrc\n\
                 [ -f /etc/bashrc ] && . /etc/bashrc\n\
                 [ -f ~/.bashrc ] && . ~/.bashrc\n",
            );
        } else {
            script.push_str("[ -f /etc/profile ] && . /etc/profile\n");
        }
    }

    // Common: environment and artifact injection
    // Artifacts may be .tar files (local dispatch cache) or directories (GitLab API).
    script.push_str("export FDO_CI_CONCURRENT=$(nproc)\n");
    for (job_name, source_path) in artifact_dirs {
        let safe_name = sanitize_job_name(job_name);
        let quoted_name = shell_quote(&safe_name);
        let is_tar = is_tar_artifact(source_path);
        let inject_cmd = if is_tar {
            format!("tar xf /tmp/ci-staging/artifacts/{}.tar", quoted_name)
        } else {
            format!("cp -a /tmp/artifacts/{}/. .", quoted_name)
        };
        match mode {
            RunMode::Interactive => {
                script.push_str(&format!(
                    "{} 2>/dev/null && \
                     echo '\\033[32mInjected artifacts from '{}' \\033[0m'\n",
                    inject_cmd, quoted_name
                ));
            }
            RunMode::Dispatch { .. } => {
                script.push_str(&format!(
                    "{} 2>/dev/null && \
                     echo 'Injected artifacts from {}'\n",
                    inject_cmd, quoted_name
                ));
            }
        }
    }

    // Interactive: seed history / run hint
    if matches!(mode, RunMode::Interactive) {
        if has_bash {
            script.push_str(&format!("history -s 'bash -x {}'\n", ci_script_path));
        } else {
            script.push_str(&format!(
                "echo '\\033[90mRun: sh -x {}\\033[0m'\n",
                ci_script_path
            ));
        }
    }

    script
}

/// Generate a unique volume name with PID and random suffix.
///
/// Using PID alone can collide if a previous run was killed without cleanup
/// and the PID is recycled. The random suffix prevents this.
pub(crate) fn unique_volume_name(prefix: &str) -> String {
    use std::time::SystemTime;
    let pid = std::process::id();
    let nonce = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap_or_default()
        .subsec_nanos();
    format!("{}-{}-{:08x}", prefix, pid, nonce)
}

/// Default image for jobs that don't specify one.
pub const DEFAULT_IMAGE: &str = "alpine:latest";

/// Default image used for git operations (clone, apply) inside containers.
pub const DEFAULT_GIT_IMAGE: &str = "docker.io/alpine/git:latest";

/// Create an empty named volume (for GIT_STRATEGY=none).
fn create_empty_volume(runtime: ContainerRuntime) -> Result<String> {
    let vol_name = unique_volume_name("cui-cui");
    let status = Command::new(runtime.name())
        .args(["volume", "create", &vol_name])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .context("Failed to create volume")?;
    if !status.success() {
        anyhow::bail!("Failed to create volume {}", vol_name);
    }
    Ok(vol_name)
}

/// Create a named volume and populate it with the project directory contents.
fn create_volume(
    runtime: ContainerRuntime,
    config: &ContainerConfig,
    oc: &OutputConfig,
) -> Result<String> {
    let vol_name = unique_volume_name("cui-cui");

    // Create the volume
    let status = Command::new(runtime.name())
        .args(["volume", "create", &vol_name])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .context("Failed to create volume")?;
    if !status.success() {
        anyhow::bail!("Failed to create volume {}", vol_name);
    }

    // Populate it by copying project files into the volume
    oc.status("\x1b[90mCopying project files into volume...\x1b[0m");
    let src_mount = format!(
        "{}:/src:ro{}",
        config.project_dir.display(),
        selinux_suffix(runtime)
    );
    let dst_mount = format!("{}:/dst", vol_name);
    let output = disposable_container(runtime)
        .args(["-v", &src_mount, "-v", &dst_mount, &config.image])
        .args(["cp", "-a", "/src/.", "/dst/"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("Failed to populate volume")?;
    for line in String::from_utf8_lossy(&output.stderr).lines() {
        oc.status(line);
    }
    if !output.status.success() {
        // Clean up on failure
        let _ = Command::new(runtime.name())
            .args(["volume", "rm", &vol_name])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        anyhow::bail!("Failed to copy project files into volume");
    }

    Ok(vol_name)
}

/// Create two named volumes and populate the project volume via `git worktree add`.
///
/// This is much faster than `git clone` on large repos because `git worktree`
/// shares the object store with the host repo — only the working tree files
/// are checked out.
///
/// Layout inside the prepare container:
/// - `/git-host`           ← host's git common dir (read-only)
/// - `/git-src`            ← writable shadow git dir (metadata + alternates)
/// - `/builds/<project>`   ← project volume (rw, receives checked-out files)
///
/// The shadow git dir at `/git-src` contains copies of the small metadata
/// files (refs, config, HEAD, packed-refs) and an empty `objects/` directory
/// with `info/alternates` pointing to `/git-host/objects`. This makes the
/// git dir fully writable for operations like `git am -3` without copying
/// the (potentially large) object store.
///
/// Returns `(project_vol, git_vol, git_common_dir)` on success.
fn create_volume_from_git_worktree(
    runtime: ContainerRuntime,
    config: &ContainerConfig,
    diff_to_apply: Option<&[u8]>,
    oc: &OutputConfig,
) -> Result<(String, String, PathBuf)> {
    let project_vol = unique_volume_name("cui-cui");
    let git_vol = unique_volume_name("cui-cui-git");

    // Resolve the git common directory on the host.
    // This handles both regular checkouts (.git is a dir) and worktrees
    // (.git is a file pointing elsewhere).
    let git_common_dir =
        crate::git_utils::git_cmd(&config.project_dir, &["rev-parse", "--git-common-dir"])
            .map(|p| {
                let path = PathBuf::from(&p);
                if path.is_absolute() {
                    path
                } else {
                    config.project_dir.join(path)
                }
            })
            .and_then(|p| std::fs::canonicalize(&p).ok())
            .ok_or_else(|| anyhow::anyhow!("Failed to resolve git common directory"))?;

    // Create both volumes
    for vol in [&project_vol, &git_vol] {
        let status = Command::new(runtime.name())
            .args(["volume", "create", vol])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .context("Failed to create volume")?;
        if !status.success() {
            anyhow::bail!("Failed to create volume {}", vol);
        }
    }

    ensure_image(runtime, &config.git_image, oc)?;

    let build_dir = builds_dir(&config.project_path);

    // Resolve HEAD to a concrete SHA so the setup script works even when the
    // git dir's HEAD symref points to a non-existent branch (e.g. a remote
    // bare repo initialised with `git init` defaults HEAD to `master` but
    // only `main` was pushed).
    let sha = crate::git_utils::git_cmd(&config.project_dir, &["rev-parse", "HEAD"])
        .ok_or_else(|| anyhow::anyhow!("Failed to resolve HEAD to a commit SHA"))?;

    // Build setup script that runs inside a single container.
    // First: create a writable shadow git dir with alternates to the
    // read-only host objects, then create the worktree from it.
    let mut setup_script = String::from(
        "#!/bin/sh\nset -e\n\
         # Copy git metadata (small files) to writable shadow git dir\n\
         for f in /git-host/*; do\n\
           case \"$(basename \"$f\")\" in\n\
             objects) ;;\n\
             *) cp -a \"$f\" /git-src/ 2>/dev/null || true ;;\n\
           esac\n\
         done\n\
         # Set up object store with alternates to read-only host objects\n\
         mkdir -p /git-src/objects/info /git-src/objects/pack\n\
         echo '/git-host/objects' > /git-src/objects/info/alternates\n",
    );

    setup_script.push_str(&format!(
        "if [ -d {build_dir} ]; then rm -rf {build_dir}/* {build_dir}/.[!.]* 2>/dev/null; rmdir {build_dir} 2>/dev/null || true; fi\n\
         GIT_DIR=/git-src git worktree add --force --detach {build_dir} {sha}\n",
    ));
    setup_script.push_str(&format!("cd {}\n", build_dir));

    let staging_prefix = "/tmp/ci-staging";

    // Apply diff if present
    if diff_to_apply.is_some() {
        setup_script.push_str(&format!(
            "if [ -f {staging_prefix}/ci-diff.patch ] && [ -s {staging_prefix}/ci-diff.patch ]; then\n\
             echo 'Applying uncommitted changes...'\n\
             git apply {staging_prefix}/ci-diff.patch || echo 'Warning: git apply failed'\n\
             fi\n",
        ));
    }

    // Initialize submodules if GIT_SUBMODULE_STRATEGY is set
    let submodule_strategy = config
        .variables
        .get("GIT_SUBMODULE_STRATEGY")
        .map(|s| s.to_lowercase());
    match submodule_strategy.as_deref() {
        Some("normal") => {
            setup_script.push_str("git submodule update --init\n");
        }
        Some("recursive") => {
            setup_script.push_str("git submodule update --init --recursive\n");
        }
        _ => {}
    }

    // Inject artifacts from previous jobs (tar files or directories)
    for (job_name, source_path) in &config.artifact_dirs {
        let safe_name = sanitize_job_name(job_name);
        let is_tar = is_tar_artifact(source_path);
        if is_tar {
            setup_script.push_str(&format!(
                "if [ -f {staging_prefix}/artifacts/{safe}.tar ]; then\n\
                 tar xf {staging_prefix}/artifacts/{safe}.tar 2>/dev/null && \
                 echo 'Injected artifacts from {safe}'\n\
                 fi\n",
                safe = safe_name
            ));
        } else {
            setup_script.push_str(&format!(
                "if [ -d /tmp/artifacts/{safe} ]; then\n\
                 cp -a /tmp/artifacts/{safe}/. . 2>/dev/null && \
                 echo 'Injected artifacts from {safe}'\n\
                 fi\n",
                safe = safe_name
            ));
        }
    }

    oc.status("\x1b[90mCreating worktree checkout into volume...\x1b[0m");

    let mut cmd = disposable_container(runtime);

    let z = selinux_suffix(runtime);

    // Host git common dir (read-only, for object store + metadata source)
    cmd.args([
        "-v",
        &format!("{}:/git-host:ro{}", git_common_dir.display(), z),
    ]);
    // Writable shadow git dir (receives metadata copies + alternates)
    cmd.args(["-v", &format!("{}:/git-src", git_vol)]);
    // Project volume
    cmd.args(["-v", &format!("{}:{}", project_vol, build_dir)]);

    // Write setup script, diff, and artifact tars into a staging directory
    // mounted as a single read-only volume.
    let staging = StagingDir::new()?;
    staging.write_file("ci-setup.sh", setup_script.as_bytes())?;
    if let Some(diff_bytes) = diff_to_apply {
        staging.write_file("ci-diff.patch", diff_bytes)?;
    }
    for (job_name, source_path) in &config.artifact_dirs {
        if is_tar_artifact(source_path) && source_path.is_file() {
            let safe_name = sanitize_job_name(job_name);
            staging.stage_file(source_path, &format!("artifacts/{}.tar", safe_name))?;
        }
    }
    cmd.args([
        "-v",
        &format!("{}:/tmp/ci-staging:ro{}", staging.path().display(), z),
    ]);

    // Artifact directory mounts (non-tar)
    for (job_name, source_path) in &config.artifact_dirs {
        if !source_path.exists() || is_tar_artifact(source_path) {
            continue;
        }
        let safe_name = sanitize_job_name(job_name);
        let container_path = format!("/tmp/artifacts/{}", safe_name);
        cmd.args([
            "-v",
            &format!("{}:{}:ro{}", source_path.display(), container_path, z),
        ]);
    }

    cmd.args([
        "--entrypoint",
        "sh",
        &config.git_image,
        "/tmp/ci-staging/ci-setup.sh",
    ]);

    let output = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("Failed to run worktree setup container")?;

    for line in String::from_utf8_lossy(&output.stdout).lines() {
        oc.status(line);
    }
    for line in String::from_utf8_lossy(&output.stderr).lines() {
        oc.status(line);
    }

    let status = output.status;

    if !status.success() {
        for vol in [&project_vol, &git_vol] {
            let _ = Command::new(runtime.name())
                .args(["volume", "rm", vol])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
        }
        anyhow::bail!("Failed to create git worktree in volume");
    }

    Ok((project_vol, git_vol, git_common_dir))
}

/// Create a host-side git worktree and optionally apply a diff.
///
/// Used for the `container` runtime (macOS) where volumes live inside the VM
/// and consume VM memory. A bind-mounted host directory lets the VM use
/// virtiofs to access files on the host, keeping file data in the host's
/// page cache rather than the VM's limited RAM.
///
/// Returns `(worktree_path, project_dir, git_common_dir)`.
fn create_host_worktree(
    config: &ContainerConfig,
    diff_to_apply: Option<&[u8]>,
    oc: &OutputConfig,
) -> Result<(PathBuf, PathBuf, PathBuf)> {
    let git_common_dir =
        crate::git_utils::git_cmd(&config.project_dir, &["rev-parse", "--git-common-dir"])
            .map(|p| {
                let path = PathBuf::from(&p);
                if path.is_absolute() {
                    path
                } else {
                    config.project_dir.join(path)
                }
            })
            .and_then(|p| std::fs::canonicalize(&p).ok())
            .ok_or_else(|| anyhow::anyhow!("Failed to resolve git common directory"))?;

    let temp_dir = tempfile::Builder::new()
        .prefix("cui-cui-wt-")
        .tempdir()
        .context("Failed to create temp directory for worktree")?;
    let worktree_path = temp_dir.keep();

    oc.status("\x1b[90mCreating host-side git worktree...\x1b[0m");

    let sha = crate::git_utils::git_cmd(&config.project_dir, &["rev-parse", "HEAD"])
        .ok_or_else(|| anyhow::anyhow!("Failed to resolve HEAD to a commit SHA"))?;

    let output = Command::new("git")
        .args(["worktree", "add", "--detach", "--force"])
        .arg(&worktree_path)
        .arg(&sha)
        .current_dir(&config.project_dir)
        .output()
        .context("Failed to create git worktree")?;

    if !output.status.success() {
        let _ = std::fs::remove_dir_all(&worktree_path);
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("git worktree add failed: {}", stderr.trim());
    }

    // Replace the .git file (which contains an absolute host path that
    // won't exist inside the container) with a proper .git directory.
    // Copy metadata from the git common dir and set up alternates
    // pointing to /git-host/objects (the bind-mounted host object store).
    let git_file = worktree_path.join(".git");
    if git_file.is_file() {
        std::fs::remove_file(&git_file)?;
    }
    let dot_git = worktree_path.join(".git");
    std::fs::create_dir_all(dot_git.join("objects/info"))?;
    std::fs::create_dir_all(dot_git.join("objects/pack"))?;

    for entry in std::fs::read_dir(&git_common_dir)? {
        let entry = entry?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if matches!(name_str.as_ref(), "objects" | "worktrees" | "modules") {
            continue;
        }
        let _ = Command::new("cp")
            .arg("-a")
            .arg(entry.path())
            .arg(dot_git.join(&name))
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }

    std::fs::write(
        dot_git.join("objects/info/alternates"),
        "/git-host/objects\n",
    )?;

    if let Some(diff) = diff_to_apply
        && !diff.is_empty()
    {
        oc.status("\x1b[90mApplying uncommitted changes...\x1b[0m");
        let mut child = Command::new("git")
            .args(["apply"])
            .current_dir(&worktree_path)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .context("Failed to start git apply")?;
        if let Some(ref mut stdin) = child.stdin {
            let _ = stdin.write_all(diff);
        }
        drop(child.stdin.take());
        let output = child
            .wait_with_output()
            .context("Failed to wait for git apply")?;
        for line in String::from_utf8_lossy(&output.stderr).lines() {
            oc.status(line);
        }
        if !output.status.success() {
            oc.status("\x1b[33mWarning: git apply failed, continuing without diff\x1b[0m");
        }
    }

    // Initialize submodules if GIT_SUBMODULE_STRATEGY is set
    let submodule_strategy = config
        .variables
        .get("GIT_SUBMODULE_STRATEGY")
        .map(|s| s.to_lowercase());
    match submodule_strategy.as_deref() {
        Some("normal") => {
            oc.status("\x1b[90mInitializing submodules...\x1b[0m");
            let output = Command::new("git")
                .args(["submodule", "update", "--init"])
                .current_dir(&worktree_path)
                .stdout(Stdio::null())
                .stderr(Stdio::piped())
                .output();
            if let Ok(ref out) = output {
                for line in String::from_utf8_lossy(&out.stderr).lines() {
                    oc.status(line);
                }
                if !out.status.success() {
                    oc.status(
                        "\x1b[33mWarning: submodule init failed, continuing without submodules\x1b[0m",
                    );
                }
            }
        }
        Some("recursive") => {
            oc.status("\x1b[90mInitializing submodules (recursive)...\x1b[0m");
            let output = Command::new("git")
                .args(["submodule", "update", "--init", "--recursive"])
                .current_dir(&worktree_path)
                .stdout(Stdio::null())
                .stderr(Stdio::piped())
                .output();
            if let Ok(ref out) = output {
                for line in String::from_utf8_lossy(&out.stderr).lines() {
                    oc.status(line);
                }
                if !out.status.success() {
                    oc.status(
                        "\x1b[33mWarning: submodule init failed, continuing without submodules\x1b[0m",
                    );
                }
            }
        }
        _ => {}
    }

    // Inject artifacts from previous jobs
    for (job_name, source_path) in &config.artifact_dirs {
        if !source_path.exists() {
            continue;
        }
        if is_tar_artifact(source_path) {
            let status = Command::new("tar")
                .args(["xf"])
                .arg(source_path)
                .current_dir(&worktree_path)
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
            if status.is_ok_and(|s| s.success()) {
                oc.status(&format!(
                    "\x1b[90mInjected artifacts from {}\x1b[0m",
                    job_name
                ));
            }
        } else if source_path.is_dir() {
            let status = Command::new("cp")
                .args(["-a"])
                .arg(format!("{}/.", source_path.display()))
                .arg(&worktree_path)
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
            if status.is_ok_and(|s| s.success()) {
                oc.status(&format!(
                    "\x1b[90mInjected artifacts from {}\x1b[0m",
                    job_name
                ));
            }
        }
    }

    Ok((worktree_path, config.project_dir.clone(), git_common_dir))
}

/// Clean up a host-side git worktree created by `create_host_worktree`.
fn cleanup_host_worktree(worktree_path: &Path, project_dir: &Path) {
    let _ = std::fs::remove_dir_all(worktree_path);
    let _ = Command::new("git")
        .args(["worktree", "prune"])
        .current_dir(project_dir)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

/// Check for uncommitted changes and prompt the user for confirmation.
///
/// Returns `Some(diff_bytes)` if there are uncommitted changes and the user
/// confirms they should be applied. Returns `None` if there are no changes.
/// Returns an error if the user declines.
pub fn prompt_uncommitted_changes(project_dir: &Path) -> Result<Option<Vec<u8>>> {
    let diff = crate::git_utils::git_cmd_bytes(project_dir, &["diff"]);
    match diff {
        Some(ref diff_bytes) if !diff_bytes.is_empty() => {}
        _ => return Ok(None),
    }
    let diff_bytes = diff.unwrap();

    eprintln!(
        "\x1b[33mWorking directory has uncommitted changes.\n\
         These will be applied to the fresh clone.\n\
         Commit or stash your changes if you want the CI to match exactly.\x1b[0m"
    );
    eprint!("Continue with uncommitted changes? [y/N]: ");
    let _ = std::io::stderr().flush();

    let mut input = String::new();
    let confirmed = std::io::stdin().read_line(&mut input).is_ok()
        && matches!(input.trim().to_lowercase().as_str(), "y" | "yes");

    if !confirmed {
        eprintln!("Aborting.");
        anyhow::bail!("Aborted: uncommitted changes not confirmed");
    }

    Ok(Some(diff_bytes))
}

/// Map GitHub Actions `runs-on` labels to a Docker image name.
///
/// Splits the first label containing `-` into `<os>-<version>` and maps it
/// to `docker.io/catthehacker/<os>:act-<version>` (the same images used by `act`).
/// If the image doesn't exist, the container runtime will report the error.
pub fn runs_on_to_image(tags: &[String]) -> Option<String> {
    for tag in tags {
        let t = tag.to_lowercase();
        if t == "self-hosted" {
            continue;
        }
        if let Some((os, version)) = t.split_once('-') {
            return Some(format!("docker.io/catthehacker/{}:act-{}", os, version));
        }
    }
    None
}

/// Find the repository root by walking up from a workflow file path
/// looking for a directory that contains `.github/`.
pub fn find_repo_root(path: &std::path::Path) -> Option<PathBuf> {
    let mut dir = path.parent()?;
    for _ in 0..10 {
        if dir.join(".github").is_dir() {
            return Some(dir.to_path_buf());
        }
        dir = dir.parent()?;
    }
    None
}

/// Build a ContainerConfig for a job without TUI dependency.
///
/// Returns `None` if the job has no image (and no `runs-on` mapping for GitHub).
#[allow(clippy::too_many_arguments)]
pub fn build_config_for_job(
    job: &Job,
    ci_system: CiSystem,
    file_path: &std::path::Path,
    project_dir: Option<&std::path::Path>,
    pass_env: &[String],
    no_overlay: bool,
    privileged: bool,
    git_context: &GitContext,
    git_image: &str,
) -> Option<ContainerConfig> {
    let build_context = job.build_context.clone();
    let image = job.image.clone().or_else(|| {
        if build_context.is_some() {
            Some(BUILD_CONTEXT_PLACEHOLDER.to_string())
        } else if ci_system == CiSystem::GitHub {
            runs_on_to_image(&job.tags)
        } else {
            Some(DEFAULT_IMAGE.to_string())
        }
    })?;

    let project_dir = if let Some(dir) = project_dir {
        dir.to_path_buf()
    } else if ci_system == CiSystem::GitHub {
        find_repo_root(file_path).or_else(|| file_path.parent().map(|p| p.to_path_buf()))?
    } else {
        file_path.parent()?.to_path_buf()
    };

    let actions_cache_dir = if ci_system == CiSystem::GitHub {
        crate::parser::github::action_cache::actions_cache_dir()
    } else {
        None
    };

    let mut variables = job.variables.clone();
    // CI_JOB_IMAGE is a GitLab predefined variable that isn't available at
    // variable-resolution time (the image is resolved here). Insert it so
    // that runner prepare/cleanup scripts can reference it.
    variables
        .entry("CI_JOB_IMAGE".to_string())
        .or_insert_with(|| image.clone());
    if ci_system == CiSystem::GitHub && !job.is_override_var("GITHUB_WORKSPACE") {
        variables.insert(
            "GITHUB_WORKSPACE".to_string(),
            builds_dir(&git_context.project_path),
        );
    }
    for name in pass_env {
        if let Ok(val) = std::env::var(name) {
            variables.insert(name.clone(), val);
        }
    }

    Some(ContainerConfig {
        image,
        project_dir,
        variables,
        script_content: job.build_container_script(ci_system),
        job_name: job.name.clone(),
        privileged,
        no_overlay,
        artifact_dirs: vec![],
        artifacts_paths: job.artifacts_paths.clone(),
        artifacts_exclude: job.artifacts_exclude.clone(),
        artifacts_untracked: job.artifacts_untracked,
        artifacts_when: job.artifacts_when.clone(),
        dotenv_report: job.dotenv_report.clone(),
        project_path: git_context.project_path.clone(),
        needs: job.artifact_sources.clone().unwrap_or_default(),
        actions_cache_dir,
        git_image: git_image.to_string(),
        allow_failure: job.allow_failure,
        runtime: None,
        kernel: None,
        memory: None,
        cpus: None,
        build_context,
        env_dir: None,
        checkout_path: None,
    })
}

/// Build a Docker action image from a Dockerfile using the container runtime.
///
/// Returns the tag assigned to the built image. The caller is responsible for
/// removing the image after use via `remove_action_image()`.
fn build_docker_action_image(runtime: ContainerRuntime, build_context: &Path) -> Result<String> {
    let tag = unique_volume_name("cuicui-action");

    eprintln!(
        "\x1b[1;36m▶ Building Docker action image from {}\x1b[0m",
        build_context.display()
    );

    let status = Command::new(runtime.name())
        .args(["build", "-t", &tag])
        .arg(build_context)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .with_context(|| format!("failed to run {} build", runtime.name()))?;

    if !status.success() {
        anyhow::bail!(
            "{} build failed with exit code {:?}",
            runtime.name(),
            status.code()
        );
    }

    Ok(tag)
}

/// Remove a locally-built action image. Best-effort, ignores errors.
fn remove_action_image(runtime: ContainerRuntime, tag: &str) {
    Command::new(runtime.name())
        .args(["rmi", tag])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .ok();
}

/// RAII guard that removes a locally-built Docker action image on drop.
/// Call `defuse()` to keep the image (e.g. after a successful hand-off).
struct ActionImageGuard {
    runtime: ContainerRuntime,
    tag: Option<String>,
}

impl ActionImageGuard {
    fn new(runtime: ContainerRuntime, tag: String) -> Self {
        Self {
            runtime,
            tag: Some(tag),
        }
    }

    fn defuse(&mut self) {
        self.tag = None;
    }
}

impl Drop for ActionImageGuard {
    fn drop(&mut self) {
        if let Some(ref tag) = self.tag {
            remove_action_image(self.runtime, tag);
        }
    }
}

/// Inspect a container image and return its ENTRYPOINT, if any.
fn image_entrypoint(runtime: ContainerRuntime, tag: &str) -> Option<String> {
    let output = Command::new(runtime.name())
        .args(["inspect", "--format", "{{json .Config.Entrypoint}}", tag])
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&output.stdout);
    let text = text.trim();
    let arr: Vec<String> = serde_json::from_str(text).ok()?;
    if arr.is_empty() {
        None
    } else {
        Some(arr.join(" "))
    }
}

/// Shared implementation for both interactive and non-interactive container execution.
///
/// `diff_to_apply` is only used in dispatch mode — if `Some`, the diff is
/// applied to the fresh worktree checkout. Callers that run multiple jobs
/// should call `prompt_uncommitted_changes()` once and pass the result here
/// to avoid re-prompting for every job.
fn run_container_impl(
    mut config: ContainerConfig,
    mode: RunMode,
    diff_to_apply: Option<&[u8]>,
    container_name: Option<String>,
) -> Result<ContainerExitStatus> {
    let start = std::time::Instant::now();
    let runtime = resolve_runtime(config.runtime)?;

    let mut action_image_guard = if let Some(ref ctx) = config.build_context {
        let tag = build_docker_action_image(runtime, ctx)?;
        config.image = tag.clone();
        Some(ActionImageGuard::new(runtime, tag))
    } else {
        None
    };

    let default_oc = OutputConfig::default();
    let oc = match &mode {
        RunMode::Dispatch { output_config, .. } => output_config,
        _ => &default_oc,
    };

    let _kernel_tempdir;
    if let Some(ref mut kernel) = config.kernel
        && !std::path::Path::new(kernel.as_str()).exists()
    {
        _kernel_tempdir = extract_kernel_from_artifacts(kernel, &config.artifact_dirs)?;
    }

    ensure_image(runtime, &config.image, oc)?;

    let is_dispatch = matches!(mode, RunMode::Dispatch { .. });

    // Detect if bash is available in the image
    let has_bash = disposable_container(runtime)
        .args([&config.image, "bash", "-c", "true"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success());
    if !has_bash && matches!(mode, RunMode::Interactive) {
        println!("\x1b[33mNo bash in image, falling back to sh.\x1b[0m");
    }

    let git_strategy_none = config
        .variables
        .get("GIT_STRATEGY")
        .is_some_and(|v| v.eq_ignore_ascii_case("none"));

    // In dispatch mode, artifacts are normally pre-populated in the git
    // worktree volume. But with GIT_STRATEGY=none there is no worktree,
    // so inject artifacts via the init script like interactive mode.
    let init_artifact_dirs = if is_dispatch && !git_strategy_none {
        &[][..]
    } else {
        &config.artifact_dirs[..]
    };
    let ci_script_path = "/tmp/ci-staging/ci-script.sh";
    let init_script = build_init_script(init_artifact_dirs, has_bash, &mode, ci_script_path);

    // Write script/init/artifact files into a staging directory that is
    // mounted as a single read-only volume at /tmp/ci-staging.  This is
    // required for the Container runtime (virtiofs only supports directory
    // shares) and works equally well for podman/docker.
    let staging = StagingDir::new()?;
    staging.write_file("ci-script.sh", config.script_content.as_bytes())?;

    if !is_dispatch || git_strategy_none {
        for (job_name, source_path) in &config.artifact_dirs {
            if is_tar_artifact(source_path) && source_path.is_file() {
                let safe_name = sanitize_job_name(job_name);
                staging.stage_file(source_path, &format!("artifacts/{}.tar", safe_name))?;
            }
        }
    }

    if let Ok(exe) = std::env::current_exe()
        && let Err(e) = staging.stage_file(&exe, "cui-cui")
    {
        debug!("could not stage cui-cui binary: {}", e);
    }

    staging.write_file("ci-init.sh", init_script.as_bytes())?;
    let script_path = staging.path().join("ci-script.sh");

    // Determine mount strategy
    let mount = if git_strategy_none {
        let vol = create_empty_volume(runtime)?;
        MountStrategy::Volume(vol)
    } else if is_dispatch && matches!(runtime, ContainerRuntime::Container) {
        // Container runtime (macOS): use host-side worktree with bind mount
        // so build files stay on the host and don't consume VM memory.
        let (worktree_path, project_dir, git_dir) =
            create_host_worktree(&config, diff_to_apply, oc)?;
        MountStrategy::HostWorktree {
            worktree_path,
            project_dir,
            git_dir,
        }
    } else if is_dispatch {
        // Dispatch mode: git worktree checkout with writable shadow git dir
        let (project_vol, git_vol, git_dir) =
            create_volume_from_git_worktree(runtime, &config, diff_to_apply, oc)?;
        MountStrategy::GitWorktree {
            project_vol,
            git_vol,
            git_dir,
        }
    } else if !config.no_overlay && matches!(runtime, ContainerRuntime::Podman) {
        let build_dir = builds_dir(&config.project_path);
        let test_mount = format!("{}:{}:O", config.project_dir.display(), build_dir);
        let ok = disposable_container(runtime)
            .args(["-v", &test_mount, &config.image, "true"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|s| s.success());
        if ok {
            MountStrategy::Overlay
        } else {
            oc.status("\x1b[33mOverlay mount not supported, using volume mount.\x1b[0m");
            oc.status("\x1b[90mTip: use --no-overlay to skip this test in the future.\x1b[0m");
            let vol = create_volume(runtime, &config, oc)?;
            MountStrategy::Volume(vol)
        }
    } else {
        let vol = create_volume(runtime, &config, oc)?;
        MountStrategy::Volume(vol)
    };

    let container_name = container_name.unwrap_or_else(|| unique_volume_name("cuicui"));

    // Build container command. Mount artifact directories unless dispatch
    // mode already pre-populated them in the git worktree volume.
    let mount_artifacts = !is_dispatch || git_strategy_none;
    let mut cmd = build_command(
        &config,
        runtime,
        &script_path,
        &mount,
        has_bash,
        &mode,
        &container_name,
        mount_artifacts,
    );

    // Execute (mode-specific)
    let script_start;
    let exit_code = match &mode {
        RunMode::Interactive => {
            // Print info
            println!(
                "\x1b[36m--- Launching container for job: {} ---\x1b[0m",
                config.job_name
            );
            println!("\x1b[90mRuntime: {}\x1b[0m", runtime.name());
            println!("\x1b[90mImage:   {}\x1b[0m", config.image);
            println!();
            println!(
                "\x1b[90m$ {}\x1b[0m",
                format_command(&config, runtime, &script_path, &mount, has_bash,)
            );
            println!();
            if has_bash {
                println!(
                    "\x1b[33mPress Up to get \x1b[1mbash -x {}\x1b[0m\x1b[33m — runs the job script.\x1b[0m",
                    ci_script_path,
                );
            } else {
                println!(
                    "\x1b[33mRun \x1b[1msh -x {}\x1b[0m\x1b[33m to execute the job script.\x1b[0m",
                    ci_script_path,
                );
            }
            println!();

            cmd.stdin(Stdio::inherit())
                .stdout(Stdio::inherit())
                .stderr(Stdio::inherit());

            script_start = std::time::Instant::now();
            let mut child = cmd.spawn().context("Failed to start container")?;
            // Ctrl-C kills the container by name via fork+exec in the handler.
            #[cfg(unix)]
            let _sigint_guard = SigintGuard::new(runtime.name(), &container_name);

            let status = child.wait().context("Failed to wait for container")?;
            let code = status.code().unwrap_or(-1);

            if !status.success() {
                println!("\x1b[33mContainer exited with status: {}\x1b[0m", code);
            }
            code
        }
        RunMode::Dispatch {
            log_path,
            output_config,
        } => {
            cmd.stdin(Stdio::null())
                .stdout(Stdio::piped())
                .stderr(Stdio::piped());

            script_start = std::time::Instant::now();
            let mut child = cmd.spawn().context("Failed to start container")?;

            let kill_cmd = format!("{} kill {}", runtime.name(), container_name);
            if let Some(ref registry) = output_config.active_containers {
                registry.register(kill_cmd.clone());
            }
            #[cfg(unix)]
            let _sigint_guard = if output_config.active_containers.is_none() {
                Some(SigintGuard::new(runtime.name(), &container_name))
            } else {
                None
            };

            let exit_code = stream_output(&mut child, log_path, output_config)?;

            if let Some(ref registry) = output_config.active_containers {
                registry.deregister(&kill_cmd);
            }

            exit_code
        }
    };

    // Extract artifacts from the project volume (dispatch mode only).
    // In interactive mode, artifacts are not extracted — the user can inspect
    // them inside the container.
    if is_dispatch
        && (!config.artifacts_paths.is_empty() || config.artifacts_untracked)
        && should_save_artifacts(exit_code, &config.artifacts_when)
    {
        let result = match &mount {
            MountStrategy::HostWorktree { worktree_path, .. } => {
                crate::artifacts::extract_artifacts_from_dir(
                    worktree_path,
                    &config.job_name,
                    &config.project_path,
                    &config.artifacts_paths,
                    &config.artifacts_exclude,
                    config.artifacts_untracked,
                    oc,
                )
            }
            _ => {
                let vol = match &mount {
                    MountStrategy::Volume(vol) => Some(vol.as_str()),
                    MountStrategy::GitWorktree { project_vol, .. } => Some(project_vol.as_str()),
                    _ => None,
                };
                if let Some(vol) = vol {
                    let build_dir = builds_dir(&config.project_path);
                    crate::artifacts::extract_artifacts_from_volume(
                        runtime.name(),
                        vol,
                        &config.git_image,
                        &config.job_name,
                        &config.project_path,
                        &build_dir,
                        &config.artifacts_paths,
                        &config.artifacts_exclude,
                        config.artifacts_untracked,
                        oc,
                    )
                } else {
                    Ok(None)
                }
            }
        };
        match result {
            Ok(Some(path)) => {
                oc.status(&format!(
                    "\x1b[32mArtifacts saved to {}\x1b[0m",
                    path.display()
                ));
            }
            Ok(None) => {}
            Err(e) => {
                oc.status(&format!("\x1b[33mFailed to save artifacts: {}\x1b[0m", e));
            }
        }
    }

    // Extract dotenv report file (separate from regular artifacts).
    if is_dispatch
        && exit_code == 0
        && let Some(ref dotenv_path) = config.dotenv_report
    {
        match extract_dotenv_from_mount(
            &mount,
            &runtime,
            &config.project_path,
            &config.git_image,
            &config.job_name,
            dotenv_path,
        ) {
            Ok(Some(path)) => {
                oc.status(&format!(
                    "\x1b[32mDotenv report saved to {}\x1b[0m",
                    path.display()
                ));
            }
            Ok(None) => {}
            Err(e) => {
                oc.status(&format!(
                    "\x1b[33mFailed to extract dotenv report: {}\x1b[0m",
                    e
                ));
            }
        }
    }

    // Clean up mount resources
    match &mount {
        MountStrategy::Volume(vol) => {
            let _ = Command::new(runtime.name())
                .args(["volume", "rm", vol])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
        }
        MountStrategy::GitWorktree {
            project_vol,
            git_vol,
            ..
        } => {
            for vol in [project_vol, git_vol] {
                let _ = Command::new(runtime.name())
                    .args(["volume", "rm", vol])
                    .stdout(Stdio::null())
                    .stderr(Stdio::null())
                    .status();
            }
        }
        MountStrategy::HostWorktree {
            worktree_path,
            project_dir,
            ..
        } => {
            cleanup_host_worktree(worktree_path, project_dir);
        }
        MountStrategy::Overlay => {}
    }

    let log_path = match mode {
        RunMode::Dispatch { log_path, .. } => log_path.to_path_buf(),
        RunMode::Interactive => PathBuf::new(),
    };

    if let Some(ref mut guard) = action_image_guard {
        guard.defuse();
        remove_action_image(runtime, config.image.as_str());
    }

    Ok(ContainerExitStatus {
        exit_code,
        log_path,
        duration: start.elapsed(),
        script_duration: script_start.elapsed(),
    })
}

pub fn launch_container(config: ContainerConfig, container_name: Option<String>) -> Result<()> {
    run_container_impl(config, RunMode::Interactive, None, container_name)?;
    Ok(())
}

/// Run a container non-interactively (dispatch mode).
///
/// `diff_to_apply` is applied to the fresh worktree checkout. Pass `None`
/// for a clean checkout, or the result of `prompt_uncommitted_changes()` to
/// include local modifications. Callers running multiple jobs should prompt
/// once and reuse the same diff slice for every call.
pub fn run_container(
    config: ContainerConfig,
    log_path: &Path,
    diff_to_apply: Option<&[u8]>,
    container_name: Option<String>,
    output_config: &OutputConfig,
) -> Result<ContainerExitStatus> {
    run_container_impl(
        config,
        RunMode::Dispatch {
            log_path,
            output_config,
        },
        diff_to_apply,
        container_name,
    )
}

/// Long-lived container for shared-container execution (GitHub Actions steps).
///
/// Instead of creating a fresh container per step, this starts a single
/// detached container (`sleep infinity`) and runs each step via `podman exec`.
/// The container is removed on drop.
pub struct StepContainer {
    runtime: ContainerRuntime,
    container_name: String,
    workdir: String,
    source_mount: String,
    project_dir: PathBuf,
    has_bash: bool,
    staging: StagingDir,
    stopped: bool,
    #[cfg(unix)]
    guardian_pid: Option<libc::pid_t>,
    #[cfg(unix)]
    guardian_pipe: Option<std::os::fd::OwnedFd>,
}

impl StepContainer {
    /// Start a long-lived container from a `ContainerConfig`.
    ///
    /// The container runs `sleep infinity` in the background. The caller
    /// then uses `exec_step()` or `exec_interactive()` to run commands.
    pub fn start(config: &ContainerConfig) -> Result<Self> {
        let runtime = resolve_runtime(config.runtime)?;

        let default_oc = OutputConfig::default();
        ensure_image(runtime, &config.image, &default_oc)?;

        let has_bash = disposable_container(runtime)
            .args([&config.image, "bash", "-c", "true"])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|s| s.success());

        let staging = StagingDir::new()?;
        staging.write_file("ci-init.sh", b"")?;

        let build_dir = builds_dir(&config.project_path);
        let source_mount = match config.checkout_path {
            Some(ref p) => {
                let sanitized: std::path::PathBuf = std::path::Path::new(p)
                    .components()
                    .filter(|c| matches!(c, std::path::Component::Normal(_)))
                    .collect();
                let s = sanitized.to_string_lossy();
                if s.is_empty() {
                    build_dir.clone()
                } else {
                    format!("{}/{}", build_dir, s)
                }
            }
            None => build_dir.clone(),
        };
        let z = selinux_suffix(runtime);
        let container_name = unique_volume_name("cuicui-step");

        let mut cmd = Command::new(runtime.name());
        cmd.args(["run", "-d", "--name", &container_name]);

        if config.privileged {
            cmd.arg("--privileged");
        }
        if let Some(ref memory) = config.memory {
            cmd.args(["-m", memory]);
        }
        if let Some(cpus) = config.cpus {
            cmd.args(["--cpus", &cpus.to_string()]);
        }

        // Mount project directory at the checkout sub-path (or build_dir if no checkout path)
        if !config.no_overlay && matches!(runtime, ContainerRuntime::Podman) {
            cmd.args([
                "-v",
                &format!("{}:{}:O", config.project_dir.display(), source_mount),
            ]);
        } else {
            cmd.args([
                "-v",
                &format!("{}:{}:ro{}", config.project_dir.display(), source_mount, z),
            ]);
        }

        // Staging directory
        cmd.args([
            "-v",
            &format!("{}:/tmp/ci-staging:ro{}", staging.path().display(), z),
        ]);

        // GitHub Actions cache
        if let Some(ref actions_dir) = config.actions_cache_dir {
            cmd.args([
                "-v",
                &format!(
                    "{}:{}:ro{}",
                    actions_dir.display(),
                    crate::parser::github::action_cache::ACTIONS_MOUNT_POINT,
                    z,
                ),
            ]);
        }

        // Mount GitHub env files directory
        if let Some(ref env_dir) = config.env_dir {
            let env_mount = GITHUB_ENV_MOUNT;
            cmd.args([
                "-v",
                &format!("{}:{}:rw{}", env_dir.display(), env_mount, z),
            ]);
            cmd.args(["-e", &format!("GITHUB_ENV={}/github_env", env_mount)]);
            cmd.args(["-e", &format!("GITHUB_PATH={}/github_path", env_mount)]);
            cmd.args(["-e", &format!("GITHUB_OUTPUT={}/github_output", env_mount)]);
            cmd.args([
                "-e",
                &format!("GITHUB_STEP_SUMMARY={}/github_step_summary", env_mount),
            ]);
        }

        // Working directory
        cmd.args(["-w", &build_dir]);

        // Environment variables (skip any that env_dir already set)
        for (k, v) in &config.variables {
            if config.env_dir.is_some()
                && matches!(
                    k.as_str(),
                    "GITHUB_ENV" | "GITHUB_PATH" | "GITHUB_OUTPUT" | "GITHUB_STEP_SUMMARY"
                )
            {
                continue;
            }
            cmd.args(["-e", &format!("{}={}", k, v)]);
        }

        // Image + long-running command
        cmd.arg(&config.image);
        if has_bash {
            cmd.args(["bash", "-c", "sleep infinity"]);
        } else {
            cmd.args(["sh", "-c", "sleep infinity"]);
        }

        cmd.stdout(Stdio::null()).stderr(Stdio::piped());

        let output = cmd.output().context("Failed to start step container")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            anyhow::bail!("Failed to start step container: {}", stderr.trim());
        }

        // Verify container is running
        let check = match Command::new(runtime.name())
            .args(["inspect", "--format", "{{.State.Running}}", &container_name])
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .output()
        {
            Ok(output) => output,
            Err(e) => {
                let _ = Command::new(runtime.name())
                    .args(["rm", "-f", &container_name])
                    .stdout(Stdio::null())
                    .stderr(Stdio::null())
                    .status();
                return Err(e).context("Failed to inspect step container");
            }
        };
        let running = String::from_utf8_lossy(&check.stdout);
        if running.trim() != "true" {
            let _ = Command::new(runtime.name())
                .args(["rm", "-f", &container_name])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
            anyhow::bail!("Step container is not running after start");
        }

        #[cfg(unix)]
        let (guardian_pid, guardian_pipe) = match spawn_container_guardian(runtime, &container_name)
        {
            Some((pid, fd)) => (Some(pid), Some(fd)),
            None => (None, None),
        };

        Ok(Self {
            runtime,
            container_name,
            workdir: build_dir,
            source_mount,
            project_dir: config.project_dir.clone(),
            has_bash,
            staging,
            stopped: false,
            #[cfg(unix)]
            guardian_pid,
            #[cfg(unix)]
            guardian_pipe,
        })
    }

    /// Execute a step script inside the running container.
    ///
    /// Writes the script to the staging dir (already mounted), then runs
    /// `podman exec <name> bash -xe <script>`. Streams output to `log_path`
    /// and `output_config`. Returns the exit code.
    pub fn exec_step(
        &self,
        script_content: &str,
        extra_env: &IndexMap<String, String>,
        log_path: &Path,
        output_config: &OutputConfig,
    ) -> Result<i32> {
        let script_name = format!("step-{}.sh", unique_volume_name("s"));
        self.staging
            .write_file(&script_name, script_content.as_bytes())?;
        let script_path = format!("/tmp/ci-staging/{}", script_name);

        let mut cmd = Command::new(self.runtime.name());
        cmd.args(["exec"]);

        // Working directory
        cmd.args(["-w", &self.workdir]);

        // Extra environment variables
        for (k, v) in extra_env {
            cmd.args(["-e", &format!("{}={}", k, v)]);
        }
        cmd.args(["-e", &format!("GITHUB_WORKSPACE={}", self.workdir)]);

        cmd.arg(&self.container_name);
        if self.has_bash {
            cmd.args(["bash", "-xe", &script_path]);
        } else {
            cmd.args(["sh", "-xe", &script_path]);
        }

        cmd.stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        let mut child = cmd.spawn().context("Failed to exec step in container")?;

        let kill_cmd = format!("{} kill {}", self.runtime.name(), self.container_name);
        if let Some(ref registry) = output_config.active_containers {
            registry.register(kill_cmd.clone());
        }

        let exit_code = stream_output(&mut child, log_path, output_config)?;

        if let Some(ref registry) = output_config.active_containers {
            registry.deregister(&kill_cmd);
        }

        Ok(exit_code)
    }

    /// Write an init script that sources rc files and seeds bash history with
    /// `history_cmd`. Returns the container-side path to the init script.
    fn write_history_init(&self, history_cmd: &str) -> Result<&'static str> {
        let init = format!(
            "[ -f /etc/bash.bashrc ] && . /etc/bash.bashrc\n\
             [ -f ~/.bashrc ] && . ~/.bashrc\n\
             history -s '{}'\n",
            history_cmd.replace('\'', "'\\''")
        );
        self.staging.write_file("ci-init.sh", init.as_bytes())?;
        Ok("/tmp/ci-staging/ci-init.sh")
    }

    /// Drop into an interactive shell inside the running container.
    ///
    /// When `script_content` is provided, the script is written to the staging
    /// dir and seeded into bash history so pressing arrow-up recalls it.
    pub fn exec_interactive(&self, script_content: Option<&str>) -> Result<i32> {
        let script_path = if let Some(content) = script_content {
            let name = "ci-interactive.sh";
            self.staging.write_file(name, content.as_bytes())?;
            Some(format!("/tmp/ci-staging/{}", name))
        } else {
            None
        };

        let mut cmd = Command::new(self.runtime.name());
        cmd.args(["exec", "-it", "-w", &self.workdir]);
        cmd.arg(&self.container_name);

        if self.has_bash {
            if let Some(ref path) = script_path {
                let init_path = self.write_history_init(&format!("bash -x {}", path))?;
                cmd.args(["bash", "--rcfile", init_path]);
            } else {
                cmd.arg("bash");
            }
        } else if let Some(ref path) = script_path {
            eprintln!("\x1b[90mRun: sh -x {}\x1b[0m", path);
            cmd.arg("sh");
        } else {
            cmd.arg("sh");
        }

        cmd.stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());

        #[cfg(unix)]
        let _sigint_guard = SigintGuard::new(self.runtime.name(), &self.container_name);

        let status = cmd
            .spawn()
            .context("Failed to exec interactive shell")?
            .wait()
            .context("Failed to wait for interactive shell")?;

        Ok(status.code().unwrap_or(-1))
    }

    fn docker_action_cmd(
        &self,
        job: &crate::model::Job,
        extra_env: &IndexMap<String, String>,
    ) -> Result<(Command, String)> {
        let build_context = job.build_context.as_ref().context("no build_context")?;
        let tag = build_docker_action_image(self.runtime, build_context)?;

        let z = selinux_suffix(self.runtime);
        let mut cmd = Command::new(self.runtime.name());
        cmd.args(["run", "--rm"]);
        cmd.args(["-w", &self.workdir]);
        cmd.args([
            "-v",
            &format!(
                "{}:{}:rw{}",
                self.project_dir.display(),
                self.source_mount,
                z
            ),
        ]);

        for (k, v) in extra_env {
            cmd.args(["-e", &format!("{}={}", k, v)]);
        }
        cmd.args(["-e", &format!("GITHUB_WORKSPACE={}", self.workdir)]);

        Ok((cmd, tag))
    }

    pub fn run_docker_action(
        &self,
        job: &crate::model::Job,
        extra_env: &IndexMap<String, String>,
        log_path: &Path,
        output_config: &OutputConfig,
    ) -> Result<i32> {
        let (mut cmd, tag) = self.docker_action_cmd(job, extra_env)?;
        let _guard = ActionImageGuard::new(self.runtime, tag.clone());

        if let Some(ref ep) = job.docker_entrypoint {
            cmd.args(["--entrypoint", ep]);
        }

        cmd.arg(&tag);
        cmd.args(&job.script);

        cmd.stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        cmd.spawn()
            .context("Failed to run Docker action container")
            .and_then(|mut child| stream_output(&mut child, log_path, output_config))
    }

    pub fn run_docker_action_interactive(
        &self,
        job: &crate::model::Job,
        extra_env: &IndexMap<String, String>,
    ) -> Result<i32> {
        let (mut cmd, tag) = self.docker_action_cmd(job, extra_env)?;
        let _guard = ActionImageGuard::new(self.runtime, tag.clone());

        // Build the command the action would normally run, so we can seed it
        // into bash history for arrow-up recall.
        let action_cmd = {
            let mut parts = Vec::new();
            let entrypoint = job
                .docker_entrypoint
                .clone()
                .or_else(|| image_entrypoint(self.runtime, &tag));
            if let Some(ref ep) = entrypoint {
                parts.push(shell_quote(ep));
            }
            for arg in &job.script {
                parts.push(shell_quote(arg));
            }
            parts.join(" ")
        };

        let init_path = self.write_history_init(&action_cmd)?;

        cmd.args([
            "-v",
            &format!(
                "{}:/tmp/ci-staging:ro{}",
                self.staging.path().display(),
                selinux_suffix(self.runtime)
            ),
        ]);
        cmd.args(["-it", "--entrypoint", "/bin/bash"]);
        cmd.arg(&tag);
        cmd.args(["--rcfile", init_path]);

        let status = cmd
            .stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .context("Failed to launch interactive Docker action container")?;

        drop(_guard);

        Ok(status.code().unwrap_or(-1))
    }

    /// Stop and remove the container. Idempotent.
    pub fn stop(&mut self) {
        if self.stopped {
            return;
        }
        self.stopped = true;
        #[cfg(unix)]
        self.kill_guardian();
        let _ = Command::new(self.runtime.name())
            .args(["rm", "-f", &self.container_name])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }

    #[cfg(unix)]
    fn kill_guardian(&mut self) {
        // Kill the guardian BEFORE closing the pipe — otherwise the guardian
        // wakes up from read() and starts cleanup in the race window.
        if let Some(pid) = self.guardian_pid.take() {
            unsafe {
                libc::kill(pid, libc::SIGKILL);
                libc::waitpid(pid, std::ptr::null_mut(), 0);
            }
        }
        drop(self.guardian_pipe.take());
    }

    pub fn name(&self) -> &str {
        &self.container_name
    }
}

impl Drop for StepContainer {
    fn drop(&mut self) {
        self.stop();
    }
}

/// Registry of running containers that can be killed on interrupt.
///
/// Each entry is a shell command string (e.g. `podman kill cuicui-123` or
/// `ssh user@host 'podman kill cuicui-123'`). Entries are added when a
/// container starts and removed when it exits.
#[derive(Default, Clone)]
pub struct ActiveContainers(Arc<Mutex<Vec<String>>>);

impl ActiveContainers {
    pub fn new() -> Self {
        Self(Arc::new(Mutex::new(Vec::new())))
    }

    pub fn register(&self, kill_cmd: String) {
        if let Ok(mut cmds) = self.0.lock() {
            cmds.push(kill_cmd);
        }
    }

    pub fn deregister(&self, kill_cmd: &str) {
        if let Ok(mut cmds) = self.0.lock() {
            cmds.retain(|c| c != kill_cmd);
        }
    }

    pub fn kill_all(&self) {
        let cmds = match self.0.lock() {
            Ok(cmds) => cmds.clone(),
            Err(_) => return,
        };
        for cmd in &cmds {
            let _ = Command::new("sh")
                .args(["-c", cmd])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
        }
    }
}

/// Configuration for streaming process output.
#[derive(Default)]
pub struct OutputConfig {
    /// Optional prefix prepended to each output line (e.g. job name).
    pub prefix: Option<String>,
    /// When set, output lines are pushed here instead of printed to stdout/stderr.
    pub log_sink: Option<Arc<Mutex<Vec<String>>>>,
    /// When set, running containers register their kill commands here.
    pub active_containers: Option<ActiveContainers>,
}

impl OutputConfig {
    /// Print a status message, routing to the log sink if present.
    pub fn status(&self, msg: &str) {
        if let Some(ref sink) = self.log_sink {
            if let Ok(mut buf) = sink.lock() {
                let formatted = match &self.prefix {
                    Some(pfx) => format!("{} {}", pfx, msg),
                    None => msg.to_string(),
                };
                buf.push(formatted);
            }
        } else {
            println!("{}", msg);
        }
    }
}

/// Stream stdout/stderr from a child process to the terminal and a log file.
///
/// Returns the child's exit code.
pub fn stream_output(
    child: &mut std::process::Child,
    log_path: &Path,
    config: &OutputConfig,
) -> Result<i32> {
    let log_file = std::fs::File::create(log_path).context("Failed to create log file")?;
    let log_file = Arc::new(Mutex::new(log_file));

    let stdout = child.stdout.take();
    let stderr = child.stderr.take();

    let prefix_clone = config.prefix.clone();
    let log_clone = log_file.clone();
    let sink_clone = config.log_sink.clone();
    let stdout_handle = stdout.map(|pipe| {
        std::thread::spawn(move || {
            let reader = std::io::BufReader::new(pipe);
            for line in reader.lines().map_while(Result::ok) {
                if let Some(ref sink) = sink_clone {
                    let formatted = match &prefix_clone {
                        Some(pfx) => format!("{} {}", pfx, line),
                        None => line.clone(),
                    };
                    if let Ok(mut buf) = sink.lock() {
                        buf.push(formatted);
                    }
                } else if let Some(pfx) = &prefix_clone {
                    println!("{} {}", pfx, line);
                } else {
                    println!("{}", line);
                }
                if let Ok(mut f) = log_clone.lock() {
                    let _ = writeln!(f, "{}", line);
                }
            }
        })
    });

    let prefix_clone2 = config.prefix.clone();
    let log_clone2 = log_file.clone();
    let sink_clone2 = config.log_sink.clone();
    let stderr_handle = stderr.map(|pipe| {
        std::thread::spawn(move || {
            let reader = std::io::BufReader::new(pipe);
            for line in reader.lines().map_while(Result::ok) {
                if let Some(ref sink) = sink_clone2 {
                    let formatted = match &prefix_clone2 {
                        Some(pfx) => format!("{} {}", pfx, line),
                        None => line.clone(),
                    };
                    if let Ok(mut buf) = sink.lock() {
                        buf.push(formatted);
                    }
                } else if let Some(pfx) = &prefix_clone2 {
                    eprintln!("{} {}", pfx, line);
                } else {
                    eprintln!("{}", line);
                }
                if let Ok(mut f) = log_clone2.lock() {
                    let _ = writeln!(f, "STDERR: {}", line);
                }
            }
        })
    });

    let status = child.wait().context("Failed to wait for process")?;

    if let Some(h) = stdout_handle {
        h.join().ok();
    }
    if let Some(h) = stderr_handle {
        h.join().ok();
    }

    Ok(status.code().unwrap_or(-1))
}

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

    #[test]
    fn test_detect_runtime_returns_something_or_none() {
        // Just ensure it doesn't panic
        let _ = detect_runtime();
    }

    #[test]
    fn test_stream_output_captures_stdout_and_stderr() {
        let dir = tempfile::tempdir().unwrap();
        let log_path = dir.path().join("test.log");

        let mut child = Command::new("sh")
            .args(["-c", "echo out-line && echo err-line >&2"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();

        let exit_code = stream_output(&mut child, &log_path, &OutputConfig::default()).unwrap();
        assert_eq!(exit_code, 0);

        let log = std::fs::read_to_string(&log_path).unwrap();
        assert!(
            log.contains("out-line"),
            "log should contain stdout: {}",
            log
        );
        assert!(
            log.contains("STDERR: err-line"),
            "log should contain stderr: {}",
            log
        );
    }

    #[test]
    fn test_stream_output_returns_nonzero_exit_code() {
        let dir = tempfile::tempdir().unwrap();
        let log_path = dir.path().join("test.log");

        let mut child = Command::new("sh")
            .args(["-c", "exit 42"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();

        let exit_code = stream_output(&mut child, &log_path, &OutputConfig::default()).unwrap();
        assert_eq!(exit_code, 42);
    }

    #[test]
    fn test_stream_output_with_prefix() {
        let dir = tempfile::tempdir().unwrap();
        let log_path = dir.path().join("test.log");

        let mut child = Command::new("sh")
            .args(["-c", "echo hello && echo oops >&2"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();

        let config = OutputConfig {
            prefix: Some("[build]".to_string()),
            ..Default::default()
        };
        let exit_code = stream_output(&mut child, &log_path, &config).unwrap();
        assert_eq!(exit_code, 0);

        // Log file stores raw lines without prefix
        let log = std::fs::read_to_string(&log_path).unwrap();
        assert!(log.contains("hello"), "log should contain stdout: {}", log);
        assert!(
            log.contains("STDERR: oops"),
            "log should contain stderr: {}",
            log
        );
        assert!(
            !log.contains("[build]"),
            "log should not contain prefix: {}",
            log
        );
    }

    fn make_config() -> ContainerConfig {
        ContainerConfig {
            image: "rust:latest".to_string(),
            project_dir: PathBuf::from("/home/user/project"),
            variables: {
                let mut vars = IndexMap::new();
                vars.insert("FOO".to_string(), "bar".to_string());
                vars.insert("BAZ".to_string(), "hello world".to_string());
                vars
            },
            script_content: String::new(),
            job_name: "test-job".to_string(),
            privileged: false,
            no_overlay: false,
            artifact_dirs: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            project_path: String::new(),
            needs: vec![],
            actions_cache_dir: None,
            git_image: DEFAULT_GIT_IMAGE.to_string(),
            allow_failure: false,
            runtime: None,
            kernel: None,
            memory: None,
            cpus: None,
            build_context: None,
            env_dir: None,
            checkout_path: None,
        }
    }

    fn get_args(cmd: &Command) -> Vec<String> {
        cmd.get_args()
            .map(|a| a.to_string_lossy().to_string())
            .collect()
    }

    #[test]
    fn test_build_command_interactive() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"run".to_string()));
        assert!(args.contains(&"--rm".to_string()));
        assert!(args.contains(&"--name".to_string()));
        assert!(args.contains(&"test-container".to_string()));
        assert!(args.contains(&"-it".to_string()));
        assert!(args.contains(&"/home/user/project:/builds/project:O".to_string()));
        assert!(args.contains(&"/tmp/staging:/tmp/ci-staging:ro,z".to_string()));
        assert!(args.contains(&"/builds/project".to_string()));
        assert!(args.contains(&"FOO=bar".to_string()));
        assert!(args.contains(&"BAZ=hello world".to_string()));
        assert!(args.contains(&"rust:latest".to_string()));
        assert!(args.contains(&"bash".to_string()));
        assert!(args.contains(&"--rcfile".to_string()));
        assert!(args.contains(&"/tmp/ci-staging/ci-init.sh".to_string()));
    }

    #[test]
    fn test_build_command_dispatch() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let log_path = std::path::Path::new("/tmp/test.log");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // No -it in dispatch mode
        assert!(!args.contains(&"-it".to_string()));
        // Shell entry point runs the script directly
        assert!(args.contains(&"bash".to_string()));
        assert!(args.contains(&"-c".to_string()));
        assert!(
            args.contains(
                &". /tmp/ci-staging/ci-init.sh && exec bash -xe /tmp/ci-staging/ci-script.sh"
                    .to_string()
            )
        );
        // No --rcfile in dispatch mode
        assert!(!args.contains(&"--rcfile".to_string()));
    }

    #[test]
    fn test_build_command_dispatch_sh() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let log_path = std::path::Path::new("/tmp/test.log");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            false,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"sh".to_string()));
        assert!(args.contains(
            &". /tmp/ci-staging/ci-init.sh && exec sh -xe /tmp/ci-staging/ci-script.sh".to_string()
        ));
    }

    #[test]
    fn test_build_command_docker_volume() {
        let mut config = make_config();
        config.image = "alpine:3".to_string();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Docker,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"test-vol:/builds/project".to_string()));
    }

    #[test]
    fn test_build_command_privileged() {
        let mut config = make_config();
        config.privileged = true;
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"--privileged".to_string()));
    }

    #[test]
    fn test_build_command_not_privileged() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(!args.contains(&"--privileged".to_string()));
    }

    #[test]
    fn test_format_command_contains_key_parts() {
        let mut config = make_config();
        config.privileged = true;
        config.variables = {
            let mut vars = IndexMap::new();
            vars.insert("FOO".to_string(), "bar".to_string());
            vars
        };

        let output = format_command(
            &config,
            ContainerRuntime::Podman,
            std::path::Path::new("/tmp/staging/ci-script.sh"),
            &MountStrategy::Overlay,
            true,
        );

        assert!(output.contains("podman"));
        assert!(output.contains("--privileged"));
        assert!(output.contains("rust:latest"));
        assert!(output.contains("FOO=bar"));
    }

    #[test]
    fn test_build_command_no_bash_fallback() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            false,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"sh".to_string()));
        assert!(args.contains(&"-c".to_string()));
        assert!(args.contains(&". /tmp/ci-staging/ci-init.sh; exec sh -i".to_string()));
        assert!(!args.contains(&"bash".to_string()));
    }

    #[test]
    fn test_init_script_interactive_bash() {
        let artifact_dirs = vec![("build".to_string(), PathBuf::from("/cache/build"))];
        let script = build_init_script(
            &artifact_dirs,
            true,
            &RunMode::Interactive,
            "/tmp/ci-staging/ci-script.sh",
        );

        assert!(script.contains("/etc/bash.bashrc"));
        assert!(script.contains("[ -f /etc/bashrc ] && . /etc/bashrc"));
        assert!(script.contains("history -s"));
        assert!(script.contains("cp -a /tmp/artifacts/build/. ."));
        assert!(!script.contains("/etc/profile"));
    }

    #[test]
    fn test_init_script_interactive_sh() {
        let script = build_init_script(
            &[],
            false,
            &RunMode::Interactive,
            "/tmp/ci-staging/ci-script.sh",
        );

        assert!(script.contains("/etc/profile"));
        assert!(!script.contains("bashrc"));
        assert!(!script.contains("history -s"));
        assert!(script.contains("sh -x /tmp/ci-staging/ci-script.sh"));
    }

    #[test]
    fn test_init_script_dispatch() {
        let artifact_dirs = vec![("build".to_string(), PathBuf::from("/cache/build"))];
        let log_path = std::path::Path::new("/tmp/test.log");
        let script = build_init_script(
            &artifact_dirs,
            true,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "/tmp/ci-staging/ci-script.sh",
        );

        assert!(!script.contains("/etc/profile"));
        assert!(!script.contains("bashrc"));
        assert!(!script.contains("history -s"));
        // Still has environment and artifact injection
        assert!(script.contains("FDO_CI_CONCURRENT"));
        assert!(script.contains("cp -a /tmp/artifacts/build/. ."));
        // No ANSI color codes in dispatch artifact messages
        assert!(!script.contains("\\033[32m"));
    }

    #[test]
    fn test_init_script_artifact_quoting() {
        let artifact_dirs = vec![
            ("simple-job".to_string(), PathBuf::from("/cache/simple-job")),
            (
                "job with spaces".to_string(),
                PathBuf::from("/cache/job with spaces"),
            ),
        ];
        let script = build_init_script(
            &artifact_dirs,
            true,
            &RunMode::Interactive,
            "/tmp/ci-staging/ci-script.sh",
        );

        // Simple name needs no quoting, name with spaces gets single-quoted
        assert!(script.contains("cp -a /tmp/artifacts/simple-job/. ."));
        assert!(script.contains("cp -a /tmp/artifacts/'job with spaces'/. ."));
    }

    #[test]
    fn test_init_script_tar_artifacts() {
        let artifact_dirs = vec![
            ("build".to_string(), PathBuf::from("/cache/build.tar")),
            ("lint".to_string(), PathBuf::from("/cache/lint")),
        ];
        let script = build_init_script(
            &artifact_dirs,
            true,
            &RunMode::Interactive,
            "/tmp/ci-staging/ci-script.sh",
        );

        // Tar file: uses staging dir
        assert!(script.contains("tar xf /tmp/ci-staging/artifacts/build.tar"));
        // Directory: mounted separately at /tmp/artifacts
        assert!(script.contains("cp -a /tmp/artifacts/lint/. ."));
    }

    #[test]
    fn test_build_command_artifact_dirs_with_spaces() {
        let mut config = make_config();
        config.image = "alpine:3".to_string();
        config.job_name = "release_job".to_string();
        config.artifact_dirs = vec![(
            "release build".to_string(),
            PathBuf::from("/cache/artifacts/release build"),
        )];

        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(
            &"/cache/artifacts/release build:/tmp/artifacts/release build:ro,z".to_string()
        ));
    }

    #[test]
    fn test_sanitize_job_name_path_traversal() {
        assert_eq!(sanitize_job_name("normal-job"), "normal-job");
        assert_eq!(sanitize_job_name("../../../etc"), "______etc");
        assert_eq!(sanitize_job_name("job/with/slashes"), "job_with_slashes");
        assert_eq!(sanitize_job_name("job\\backslash"), "job_backslash");
        assert_eq!(sanitize_job_name("../../passwd"), "____passwd");
    }

    #[test]
    fn test_artifact_mount_sanitized() {
        let mut config = make_config();
        config.artifact_dirs = vec![(
            "../../../etc".to_string(),
            PathBuf::from("/cache/artifacts/evil"),
        )];

        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // The sanitized name should not contain path traversal
        assert!(
            args.iter()
                .any(|a| a.contains("/tmp/artifacts/______etc:ro,z"))
        );
        assert!(!args.iter().any(|a| a.contains("/tmp/artifacts/../")));
    }

    #[test]
    fn test_build_command_no_artifacts_volume() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(!args.iter().any(|a| a.contains("artifacts-out")));
    }

    #[test]
    fn test_build_command_dispatch_skips_artifact_bind_mounts() {
        let mut config = make_config();
        config.artifact_dirs = vec![
            ("build".to_string(), PathBuf::from("/cache/build")),
            ("lint".to_string(), PathBuf::from("/cache/lint")),
        ];
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let log_path = std::path::Path::new("/tmp/test.log");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "test-container",
            false,
        );
        let args = get_args(&cmd);

        // Dispatch mode (with git worktree) should NOT have artifact bind mounts
        assert!(
            !args.iter().any(|a| a.contains("/tmp/artifacts/")),
            "dispatch mode should not have artifact bind mounts"
        );
    }

    #[test]
    fn test_build_command_interactive_has_artifact_bind_mounts() {
        let mut config = make_config();
        config.artifact_dirs = vec![("build".to_string(), PathBuf::from("/cache/build"))];
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // Interactive mode SHOULD have artifact bind mounts
        assert!(
            args.contains(&"/cache/build:/tmp/artifacts/build:ro,z".to_string()),
            "interactive mode should have artifact bind mounts"
        );
    }

    #[test]
    fn test_build_command_tar_artifacts_staged_not_mounted() {
        let mut config = make_config();
        config.artifact_dirs = vec![
            ("build".to_string(), PathBuf::from("/cache/build.tar")),
            ("lint".to_string(), PathBuf::from("/cache/lint")),
        ];
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // Tar artifact should NOT be mounted (staged into StagingDir instead)
        assert!(
            !args.iter().any(|a| a.contains("build.tar")),
            "tar artifacts should not be mounted individually"
        );
        // Directory artifact SHOULD still be mounted
        assert!(
            args.iter()
                .any(|a| a.contains("/cache/lint:/tmp/artifacts/lint:ro")),
            "directory artifacts should still be mounted"
        );
    }

    #[test]
    fn test_init_script_dispatch_skips_artifact_injection() {
        let artifact_dirs = vec![("build".to_string(), PathBuf::from("/cache/build"))];
        let log_path = std::path::Path::new("/tmp/test.log");

        // In dispatch mode, init script should receive empty artifact_dirs
        // (artifacts are pre-populated in the volume)
        let script = build_init_script(
            &[],
            true,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "/tmp/ci-staging/ci-script.sh",
        );
        assert!(
            !script.contains("artifacts"),
            "dispatch init script should not inject artifacts"
        );

        // In interactive mode, init script should inject artifacts
        let script = build_init_script(
            &artifact_dirs,
            true,
            &RunMode::Interactive,
            "/tmp/ci-staging/ci-script.sh",
        );
        assert!(
            script.contains("artifacts"),
            "interactive init script should inject artifacts"
        );
    }

    #[test]
    fn test_build_command_actions_cache_mount() {
        let mut config = make_config();
        config.actions_cache_dir = Some(PathBuf::from("/home/user/.cache/cui-cui/actions"));
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        let mount_point = crate::parser::github::action_cache::ACTIONS_MOUNT_POINT;
        assert!(args.iter().any(|a| a.contains(mount_point)
            && a.contains("/home/user/.cache/cui-cui/actions")));
    }

    #[test]
    fn test_build_command_project_path_in_workdir() {
        let mut config = make_config();
        config.project_path = "mesa/mesa".to_string();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // Working directory should use the project path
        assert!(args.contains(&"/builds/mesa/mesa".to_string()));
        // Overlay mount also uses the project path
        assert!(args.contains(&"/home/user/project:/builds/mesa/mesa:O".to_string()));
    }

    #[test]
    fn test_build_command_multiple_artifact_dirs() {
        let mut config = make_config();
        config.artifact_dirs = vec![
            ("build".to_string(), PathBuf::from("/cache/build")),
            ("lint".to_string(), PathBuf::from("/cache/lint")),
            ("compile".to_string(), PathBuf::from("/cache/compile")),
        ];
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        assert!(args.contains(&"/cache/build:/tmp/artifacts/build:ro,z".to_string()));
        assert!(args.contains(&"/cache/lint:/tmp/artifacts/lint:ro,z".to_string()));
        assert!(args.contains(&"/cache/compile:/tmp/artifacts/compile:ro,z".to_string()));
    }

    #[test]
    fn test_should_save_artifacts() {
        // on_success (default): only on exit 0
        assert!(should_save_artifacts(0, "on_success"));
        assert!(!should_save_artifacts(1, "on_success"));
        assert!(!should_save_artifacts(-1, "on_success"));

        // on_failure: only on non-zero exit
        assert!(!should_save_artifacts(0, "on_failure"));
        assert!(should_save_artifacts(1, "on_failure"));
        assert!(should_save_artifacts(137, "on_failure"));

        // always: regardless of exit code
        assert!(should_save_artifacts(0, "always"));
        assert!(should_save_artifacts(1, "always"));

        // unknown value falls back to on_success
        assert!(should_save_artifacts(0, "bogus"));
        assert!(!should_save_artifacts(1, "bogus"));
    }

    #[test]
    fn test_build_command_no_variables() {
        let mut config = make_config();
        config.variables = IndexMap::new();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // No -e flags
        assert!(!args.contains(&"-e".to_string()));
    }

    #[test]
    fn test_builds_dir_with_project_path() {
        assert_eq!(builds_dir("mesa/mesa"), "/builds/mesa/mesa");
        assert_eq!(builds_dir("group/sub/proj"), "/builds/group/sub/proj");
    }

    #[test]
    fn test_builds_dir_empty_project_path() {
        assert_eq!(builds_dir(""), "/builds/project");
    }

    #[test]
    fn test_builds_dir_path_traversal() {
        assert_eq!(builds_dir("../../etc"), "/builds/etc");
        assert_eq!(builds_dir("../../../passwd"), "/builds/passwd");
        assert_eq!(builds_dir("group/../../../etc"), "/builds/group/etc");
    }

    #[test]
    fn test_builds_dir_absolute_path() {
        assert_eq!(builds_dir("/etc/passwd"), "/builds/etc/passwd");
    }

    #[test]
    fn test_builds_dir_only_traversal() {
        // Pure traversal with no normal components falls back to default
        assert_eq!(builds_dir("../.."), "/builds/project");
    }

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

        let root = find_repo_root(&workflow);
        assert_eq!(root, Some(dir.path().to_path_buf()));
    }

    #[test]
    fn test_find_repo_root_no_github_dir() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("some_file.yml");
        std::fs::write(&file, "content").unwrap();

        let root = find_repo_root(&file);
        assert!(root.is_none());
    }

    #[test]
    fn test_runs_on_to_image() {
        assert_eq!(
            runs_on_to_image(&["ubuntu-latest".to_string()]),
            Some("docker.io/catthehacker/ubuntu:act-latest".to_string())
        );
        assert_eq!(
            runs_on_to_image(&["ubuntu-22.04".to_string()]),
            Some("docker.io/catthehacker/ubuntu:act-22.04".to_string())
        );
        // Non-ubuntu labels still produce an image (runtime reports errors)
        assert_eq!(
            runs_on_to_image(&["windows-latest".to_string()]),
            Some("docker.io/catthehacker/windows:act-latest".to_string())
        );
        assert_eq!(
            runs_on_to_image(&["macos-latest".to_string()]),
            Some("docker.io/catthehacker/macos:act-latest".to_string())
        );
        // self-hosted alone has no mapping
        assert_eq!(runs_on_to_image(&["self-hosted".to_string()]), None);
        assert_eq!(runs_on_to_image(&[]), None);
        // List form: skips self-hosted and bare labels, picks first with version
        assert_eq!(
            runs_on_to_image(&[
                "self-hosted".to_string(),
                "linux".to_string(),
                "ubuntu-22.04".to_string()
            ]),
            Some("docker.io/catthehacker/ubuntu:act-22.04".to_string())
        );
        // List with only non-splittable labels
        assert_eq!(runs_on_to_image(&["self-hosted".to_string()]), None);
    }

    #[test]
    fn test_unique_volume_name_has_prefix_and_pid() {
        let name = unique_volume_name("cui-cui");
        assert!(name.starts_with("cui-cui-"));
        let pid = std::process::id().to_string();
        assert!(
            name.contains(&pid),
            "volume name '{}' should contain PID '{}'",
            name,
            pid
        );
    }

    #[test]
    fn test_unique_volume_name_different_prefixes() {
        let name1 = unique_volume_name("cui-cui");
        let name2 = unique_volume_name("cui-cui-artifacts");
        assert!(name1.starts_with("cui-cui-"));
        assert!(name2.starts_with("cui-cui-artifacts-"));
    }

    #[test]
    fn test_unique_volume_name_has_hex_suffix() {
        let name = unique_volume_name("test");
        // Format: test-{pid}-{8 hex chars}
        let parts: Vec<&str> = name.splitn(3, '-').collect();
        assert_eq!(parts.len(), 3, "expected 3 parts in '{}'", name);
        assert_eq!(parts[0], "test");
        // PID part should be numeric
        assert!(parts[1].parse::<u32>().is_ok(), "PID part not numeric");
        // Hex suffix should be 8 hex chars
        assert_eq!(parts[2].len(), 8, "hex suffix length wrong in '{}'", name);
        assert!(
            parts[2].chars().all(|c| c.is_ascii_hexdigit()),
            "suffix '{}' should be hex",
            parts[2]
        );
    }

    #[test]
    fn test_unique_volume_name_successive_calls_differ() {
        let name1 = unique_volume_name("cui-cui");
        // Burn a tiny bit of time so subsec_nanos changes
        std::thread::sleep(std::time::Duration::from_millis(1));
        let name2 = unique_volume_name("cui-cui");
        assert_ne!(
            name1, name2,
            "successive calls should produce different names"
        );
    }

    use crate::model::JobDebugInfo;

    fn make_minimal_job() -> Job {
        Job {
            name: "test-job".to_string(),
            stage: "test".to_string(),
            image: Some("alpine:3".to_string()),
            services: vec![],
            variables: IndexMap::new(),
            before_script: vec![],
            script: vec!["echo hello".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(),
        }
    }

    #[test]
    fn test_build_config_root_path_returns_none() {
        // Passing "/" as file_path with no project_dir should return None, not panic
        let job = make_minimal_job();
        let git_context = GitContext::default();
        let result = build_config_for_job(
            &job,
            CiSystem::GitHub,
            std::path::Path::new("/"),
            None,
            &[],
            false,
            false,
            &git_context,
            DEFAULT_GIT_IMAGE,
        );
        // "/" has no parent, so should gracefully return None
        assert!(result.is_none());
    }

    #[test]
    fn test_build_config_normal_path_returns_some() {
        let job = make_minimal_job();
        let git_context = GitContext::default();
        let result = build_config_for_job(
            &job,
            CiSystem::GitHub,
            std::path::Path::new("/home/user/project/.github/workflows/ci.yml"),
            None,
            &[],
            false,
            false,
            &git_context,
            DEFAULT_GIT_IMAGE,
        );
        assert!(result.is_some());
    }

    #[test]
    fn test_build_config_custom_git_image() {
        let job = make_minimal_job();
        let git_context = GitContext::default();
        let result = build_config_for_job(
            &job,
            CiSystem::GitHub,
            std::path::Path::new("/home/user/project/.github/workflows/ci.yml"),
            None,
            &[],
            false,
            false,
            &git_context,
            "my-registry.com/git:v2",
        );
        let config = result.unwrap();
        assert_eq!(config.git_image, "my-registry.com/git:v2");
    }

    #[test]
    fn test_build_config_gitlab_no_image_uses_default() {
        let mut job = make_minimal_job();
        job.image = None;
        let git_context = GitContext::default();
        let result = build_config_for_job(
            &job,
            CiSystem::GitLab,
            std::path::Path::new("/home/user/project/.gitlab-ci.yml"),
            None,
            &[],
            false,
            false,
            &git_context,
            DEFAULT_GIT_IMAGE,
        );
        let config = result.unwrap();
        assert_eq!(config.image, DEFAULT_IMAGE);
    }

    #[test]
    fn test_build_config_github_no_image_returns_none() {
        let mut job = make_minimal_job();
        job.image = None;
        job.tags = vec![];
        let git_context = GitContext::default();
        let result = build_config_for_job(
            &job,
            CiSystem::GitHub,
            std::path::Path::new("/home/user/project/.github/workflows/ci.yml"),
            None,
            &[],
            false,
            false,
            &git_context,
            DEFAULT_GIT_IMAGE,
        );
        assert!(result.is_none());
    }

    #[test]
    fn test_prompt_uncommitted_changes_clean_repo() {
        // A repo with no uncommitted changes should return None
        let tmp = tempfile::tempdir().expect("failed to create temp dir");
        let repo_dir = tmp.path();
        let _ = Command::new("git")
            .args(["init", "--initial-branch=main"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["config", "user.email", "test@test.com"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["config", "user.name", "Test"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        std::fs::write(repo_dir.join("file.txt"), "content\n").unwrap();
        let _ = Command::new("git")
            .args(["add", "file.txt"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["commit", "-m", "initial"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();

        let result = prompt_uncommitted_changes(repo_dir).unwrap();
        assert!(result.is_none(), "clean repo should return None");
    }

    /// Helper to clean up volumes, used by worktree tests.
    fn cleanup_volumes(runtime: ContainerRuntime, vols: &[&str]) {
        for vol in vols {
            let _ = Command::new(runtime.name())
                .args(["volume", "rm", vol])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
        }
    }

    #[test]
    fn test_create_empty_volume() {
        let runtime = match detect_runtime() {
            Some(r) => r,
            None => return,
        };

        let vol = create_empty_volume(runtime).unwrap();

        // Volume should exist and be empty
        let output = Command::new(runtime.name())
            .args(["run", "--rm", "-v", &format!("{}:/vol", vol)])
            .args(["alpine", "ls", "-A", "/vol"])
            .output()
            .unwrap();
        let listing = String::from_utf8_lossy(&output.stdout);
        assert!(listing.trim().is_empty(), "volume should be empty");

        cleanup_volumes(runtime, &[&vol]);
    }

    #[test]
    fn test_create_volume_from_git_worktree() {
        let runtime = match detect_runtime() {
            Some(r) => r,
            None => return,
        };

        // Use the cui-cui repo itself as the source
        let git_root = crate::git_utils::find_git_root(std::path::Path::new("."))
            .expect("should find git root");
        let mut config = make_config();
        config.project_dir = git_root;
        config.git_image = DEFAULT_GIT_IMAGE.to_string();

        let result =
            create_volume_from_git_worktree(runtime, &config, None, &OutputConfig::default());
        match result {
            Ok((project_vol, git_vol, git_dir)) => {
                assert!(project_vol.starts_with("cui-cui-"));
                assert!(git_vol.starts_with("cui-cui-git-"));
                assert!(git_dir.exists(), "git_dir should exist on host");

                // Verify the volume contains checked-out files
                let output = Command::new(runtime.name())
                    .args([
                        "run",
                        "--rm",
                        "--pull",
                        "never",
                        "--entrypoint",
                        "ls",
                        "-v",
                        &format!("{}:/builds/project", project_vol),
                        "-w",
                        "/builds/project",
                        DEFAULT_GIT_IMAGE,
                        "Cargo.toml",
                    ])
                    .output()
                    .expect("failed to check volume contents");
                assert!(
                    output.status.success(),
                    "Cargo.toml should exist in worktree volume: {}",
                    String::from_utf8_lossy(&output.stderr)
                );

                // Verify git history is accessible via alternates
                let output = Command::new(runtime.name())
                    .args([
                        "run",
                        "--rm",
                        "--pull",
                        "never",
                        "-v",
                        &format!("{}:/builds/project", project_vol),
                        "-v",
                        &format!("{}:/git-host:ro,z", git_dir.display()),
                        "-v",
                        &format!("{}:/git-src", git_vol),
                        "-w",
                        "/builds/project",
                        DEFAULT_GIT_IMAGE,
                        "log",
                        "--oneline",
                        "-1",
                    ])
                    .output()
                    .expect("failed to run git log in volume");
                assert!(
                    output.status.success(),
                    "git log should work in worktree volume: {}",
                    String::from_utf8_lossy(&output.stderr)
                );
                let log_output = String::from_utf8_lossy(&output.stdout);
                assert!(
                    !log_output.trim().is_empty(),
                    "git log should produce output"
                );

                cleanup_volumes(runtime, &[&project_vol, &git_vol]);
            }
            Err(e) => {
                let msg = e.to_string();
                if msg.contains("pull") || msg.contains("image") {
                    eprintln!("Skipping test: git image not available: {}", msg);
                    return;
                }
                panic!("create_volume_from_git_worktree failed: {}", e);
            }
        }
    }

    #[test]
    fn test_create_volume_from_git_worktree_with_diff() {
        let runtime = match detect_runtime() {
            Some(r) => r,
            None => return,
        };

        // Create a temporary git repo
        let tmp = tempfile::tempdir().expect("failed to create temp dir");
        let repo_dir = tmp.path();
        let _ = Command::new("git")
            .args(["init", "--initial-branch=main"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["config", "user.email", "test@test.com"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["config", "user.name", "Test"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        std::fs::write(repo_dir.join("hello.txt"), "original\n").unwrap();
        let _ = Command::new("git")
            .args(["add", "hello.txt"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .status();
        let _ = Command::new("git")
            .args(["commit", "-m", "initial"])
            .current_dir(repo_dir)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();

        // Make uncommitted change and get the diff
        std::fs::write(repo_dir.join("hello.txt"), "modified\n").unwrap();
        let diff_bytes =
            crate::git_utils::git_cmd_bytes(repo_dir, &["diff"]).expect("should get diff");

        let mut config = make_config();
        config.project_dir = repo_dir.to_path_buf();
        config.git_image = DEFAULT_GIT_IMAGE.to_string();

        let result = create_volume_from_git_worktree(
            runtime,
            &config,
            Some(&diff_bytes),
            &OutputConfig::default(),
        );
        match result {
            Ok((project_vol, git_vol, _git_dir)) => {
                // Verify the modified content is in the volume
                let output = Command::new(runtime.name())
                    .args([
                        "run",
                        "--rm",
                        "--pull",
                        "never",
                        "--entrypoint",
                        "cat",
                        "-v",
                        &format!("{}:/builds/project", project_vol),
                        "-w",
                        "/builds/project",
                        DEFAULT_GIT_IMAGE,
                        "hello.txt",
                    ])
                    .output()
                    .expect("failed to read file from volume");
                let content = String::from_utf8_lossy(&output.stdout);
                assert_eq!(
                    content.trim(),
                    "modified",
                    "file should have the applied diff"
                );

                cleanup_volumes(runtime, &[&project_vol, &git_vol]);
            }
            Err(e) => {
                let msg = e.to_string();
                if msg.contains("pull") || msg.contains("image") {
                    eprintln!("Skipping test: git image not available: {}", msg);
                    return;
                }
                panic!("create_volume_from_git_worktree with diff failed: {}", e);
            }
        }
    }

    #[test]
    fn test_build_command_git_worktree_mounts() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/staging/ci-script.sh");
        let log_path = std::path::Path::new("/tmp/test.log");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::GitWorktree {
                project_vol: "proj-vol".to_string(),
                git_vol: "git-vol".to_string(),
                git_dir: PathBuf::from("/home/user/.git"),
            },
            true,
            &RunMode::Dispatch {
                log_path,
                output_config: &OutputConfig::default(),
            },
            "test-container",
            true,
        );
        let args = get_args(&cmd);

        // Project volume mounted at build dir
        assert!(
            args.contains(&"proj-vol:/builds/project".to_string()),
            "should have project volume mount"
        );
        // Host git dir mounted read-only at /git-host
        assert!(
            args.contains(&"/home/user/.git:/git-host:ro,z".to_string()),
            "should have git host mount"
        );
        // Writable shadow git dir at /git-src
        assert!(
            args.contains(&"git-vol:/git-src".to_string()),
            "should have writable git volume mount"
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_sigint_guard_restores_handler() {
        use std::sync::atomic::{AtomicBool, Ordering};

        static CAUGHT: AtomicBool = AtomicBool::new(false);

        extern "C" fn handler(_: libc::c_int) {
            CAUGHT.store(true, Ordering::SeqCst);
        }
        unsafe {
            libc::signal(libc::SIGINT, handler as *const () as libc::sighandler_t);
        }

        // While the guard is held, our handler should NOT run —
        // the kill handler runs instead.
        {
            let _guard = SigintGuard::new("true", "nonexistent-container");
            unsafe {
                libc::raise(libc::SIGINT);
            }
            assert!(
                !CAUGHT.load(Ordering::SeqCst),
                "custom handler should not run while guard is held"
            );
        }
        // Guard dropped — previous handler should be restored
        CAUGHT.store(false, Ordering::SeqCst);
        unsafe {
            libc::raise(libc::SIGINT);
        }
        assert!(
            CAUGHT.load(Ordering::SeqCst),
            "custom handler should run after guard is dropped"
        );

        // Restore default handler for other tests
        unsafe {
            libc::signal(libc::SIGINT, libc::SIG_DFL);
        }
    }

    #[test]
    #[cfg(unix)]
    fn test_sigint_guard_clears_pointers_on_drop() {
        use std::sync::atomic::Ordering;

        {
            let _guard = SigintGuard::new("podman", "my-container");
            assert!(
                !KILL_RUNTIME.load(Ordering::SeqCst).is_null(),
                "runtime pointer should be set while guard is held"
            );
            assert!(
                !KILL_CONTAINER.load(Ordering::SeqCst).is_null(),
                "container pointer should be set while guard is held"
            );
        }
        // After drop, pointers should be cleared
        assert!(
            KILL_RUNTIME.load(Ordering::SeqCst).is_null(),
            "runtime pointer should be null after guard is dropped"
        );
        assert!(
            KILL_CONTAINER.load(Ordering::SeqCst).is_null(),
            "container pointer should be null after guard is dropped"
        );

        // Restore default handler
        unsafe {
            libc::signal(libc::SIGINT, libc::SIG_DFL);
        }
    }

    #[test]
    fn test_container_runtime_from_str() {
        assert_eq!(
            ContainerRuntime::from_str("podman"),
            Some(ContainerRuntime::Podman)
        );
        assert_eq!(
            ContainerRuntime::from_str("docker"),
            Some(ContainerRuntime::Docker)
        );
        assert_eq!(
            ContainerRuntime::from_str("container"),
            Some(ContainerRuntime::Container)
        );
        assert_eq!(ContainerRuntime::from_str("unknown"), None);
    }

    #[test]
    fn test_container_runtime_name() {
        assert_eq!(ContainerRuntime::Podman.name(), "podman");
        assert_eq!(ContainerRuntime::Docker.name(), "docker");
        assert_eq!(ContainerRuntime::Container.name(), "container");
    }

    #[test]
    fn test_apply_runner_runtime_sets_runtime() {
        let mut config = make_config();
        config.apply_runner_runtime(Some("container"), None, None, None);
        assert_eq!(config.runtime, Some(ContainerRuntime::Container));
        assert_eq!(config.kernel, None);
        assert_eq!(config.memory, None);
    }

    #[test]
    fn test_apply_runner_runtime_sets_memory() {
        let mut config = make_config();
        config.apply_runner_runtime(Some("container"), None, Some("8G"), None);
        assert_eq!(config.memory, Some("8G".to_string()));
    }

    #[test]
    fn test_apply_runner_runtime_sets_cpus() {
        let mut config = make_config();
        config.apply_runner_runtime(Some("container"), None, None, Some(4));
        assert_eq!(config.cpus, Some(4));
    }

    #[test]
    fn test_apply_runner_runtime_unknown_runtime_ignored() {
        let mut config = make_config();
        config.apply_runner_runtime(Some("unknown"), None, None, None);
        assert_eq!(config.runtime, None);
    }

    #[test]
    fn test_apply_runner_runtime_expands_kernel_variable() {
        let mut config = make_config();
        config.variables.insert(
            "LIMA_KEXEC_ARTIFACT".to_string(),
            "/path/to/vmlinuz".to_string(),
        );
        config.apply_runner_runtime(Some("container"), Some("$LIMA_KEXEC_ARTIFACT"), None, None);
        assert_eq!(config.runtime, Some(ContainerRuntime::Container));
        assert_eq!(config.kernel, Some("/path/to/vmlinuz".to_string()));
    }

    #[test]
    fn test_apply_runner_runtime_kernel_empty_variable_is_none() {
        let mut config = make_config();
        config
            .variables
            .insert("LIMA_KEXEC_ARTIFACT".to_string(), String::new());
        config.apply_runner_runtime(Some("container"), Some("$LIMA_KEXEC_ARTIFACT"), None, None);
        assert_eq!(config.kernel, None);
    }

    #[test]
    fn test_apply_runner_runtime_kernel_missing_variable_is_none() {
        let mut config = make_config();
        config.apply_runner_runtime(Some("container"), Some("$NONEXISTENT_VAR"), None, None);
        assert_eq!(config.kernel, None);
    }

    #[test]
    fn test_apply_runner_runtime_kernel_braced_syntax() {
        let mut config = make_config();
        config
            .variables
            .insert("MY_KERNEL".to_string(), "/boot/vmlinuz".to_string());
        config.apply_runner_runtime(Some("container"), Some("${MY_KERNEL}"), None, None);
        assert_eq!(config.kernel, Some("/boot/vmlinuz".to_string()));
    }

    #[test]
    fn test_apply_runner_runtime_kernel_with_default() {
        let mut config = make_config();
        config.apply_runner_runtime(
            Some("container"),
            Some("${MISSING:-/default/kernel}"),
            None,
            None,
        );
        assert_eq!(config.kernel, Some("/default/kernel".to_string()));
    }

    #[test]
    fn test_build_command_with_kernel() {
        let mut config = make_config();
        config.kernel = Some("/path/to/vmlinuz".to_string());
        let script_path = std::path::Path::new("/tmp/test-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Container,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);
        assert_eq!(cmd.get_program().to_string_lossy(), "container");
        let kernel_idx = args.iter().position(|a| a == "--kernel").unwrap();
        assert_eq!(args[kernel_idx + 1], "/path/to/vmlinuz");
    }

    #[test]
    fn test_build_command_with_memory() {
        let mut config = make_config();
        config.memory = Some("8G".to_string());
        let script_path = std::path::Path::new("/tmp/test-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Container,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);
        let mem_idx = args.iter().position(|a| a == "-m").unwrap();
        assert_eq!(args[mem_idx + 1], "8G");
    }

    #[test]
    fn test_build_command_with_cpus() {
        let mut config = make_config();
        config.cpus = Some(4);
        let script_path = std::path::Path::new("/tmp/test-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Container,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);
        let cpus_idx = args.iter().position(|a| a == "--cpus").unwrap();
        assert_eq!(args[cpus_idx + 1], "4");
    }

    #[test]
    fn test_build_command_without_kernel() {
        let config = make_config();
        let script_path = std::path::Path::new("/tmp/test-script.sh");
        let cmd = build_command(
            &config,
            ContainerRuntime::Podman,
            script_path,
            &MountStrategy::Overlay,
            true,
            &RunMode::Interactive,
            "test-container",
            true,
        );
        let args = get_args(&cmd);
        assert!(!args.contains(&"--kernel".to_string()));
    }

    #[test]
    fn test_format_command_with_kernel() {
        let mut config = make_config();
        config.kernel = Some("/path/to/vmlinuz".to_string());
        let script_path = std::path::Path::new("/tmp/test-script.sh");
        let output = format_command(
            &config,
            ContainerRuntime::Container,
            script_path,
            &MountStrategy::Volume("test-vol".to_string()),
            true,
        );
        assert!(output.contains("--kernel"));
        assert!(output.contains("/path/to/vmlinuz"));
        assert!(output.starts_with("container"));
    }

    #[test]
    fn test_jobspec_roundtrip_with_runtime_and_kernel() {
        let mut config = make_config();
        config.runtime = Some(ContainerRuntime::Container);
        config.kernel = Some("/path/to/vmlinuz".to_string());

        let spec = JobSpec::from_config(&config);
        assert_eq!(spec.runtime, Some("container".to_string()));
        assert_eq!(spec.kernel, Some("/path/to/vmlinuz".to_string()));

        let restored = spec.into_container_config(PathBuf::from("/tmp"));
        assert_eq!(restored.runtime, Some(ContainerRuntime::Container));
        assert_eq!(restored.kernel, Some("/path/to/vmlinuz".to_string()));
    }

    #[test]
    fn test_jobspec_roundtrip_without_runtime() {
        let config = make_config();
        let spec = JobSpec::from_config(&config);
        assert_eq!(spec.runtime, None);
        assert_eq!(spec.kernel, None);

        let restored = spec.into_container_config(PathBuf::from("/tmp"));
        assert_eq!(restored.runtime, None);
        assert_eq!(restored.kernel, None);
    }

    #[test]
    fn test_extract_dotenv_from_host_worktree() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("build.env"), "BUILD_TAG=0.1\nFOO=bar\n").unwrap();

        let mount = MountStrategy::HostWorktree {
            worktree_path: dir.path().to_path_buf(),
            project_dir: dir.path().to_path_buf(),
            git_dir: dir.path().to_path_buf(),
        };
        let runtime = ContainerRuntime::Podman;

        let result = extract_dotenv_from_mount(
            &mount,
            &runtime,
            "test/project",
            "alpine:latest",
            "build-job1",
            "build.env",
        )
        .unwrap();

        assert!(result.is_some());
        let path = result.unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("BUILD_TAG=0.1"));
        assert!(content.contains("FOO=bar"));
    }

    #[test]
    fn test_extract_dotenv_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        let mount = MountStrategy::HostWorktree {
            worktree_path: dir.path().to_path_buf(),
            project_dir: dir.path().to_path_buf(),
            git_dir: dir.path().to_path_buf(),
        };
        let runtime = ContainerRuntime::Podman;

        let result = extract_dotenv_from_mount(
            &mount,
            &runtime,
            "test/project",
            "alpine:latest",
            "build-job1",
            "build.env",
        )
        .unwrap();

        assert!(result.is_none());
    }

    #[test]
    fn test_sanitize_dotenv_path_valid() {
        assert!(sanitize_dotenv_path("build.env").is_ok());
        assert!(sanitize_dotenv_path("subdir/build.env").is_ok());
    }

    #[test]
    fn test_sanitize_dotenv_path_rejects_absolute() {
        assert!(sanitize_dotenv_path("/etc/passwd").is_err());
    }

    #[test]
    fn test_sanitize_dotenv_path_rejects_traversal() {
        assert!(sanitize_dotenv_path("../secret").is_err());
        assert!(sanitize_dotenv_path("subdir/../../etc/passwd").is_err());
    }

    #[test]
    fn test_resolve_runtime_with_preference_not_found() {
        let result = resolve_runtime(Some(ContainerRuntime::Container));
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("container"));
    }
}
