//! Container-runtime discovery and the in-memory build/run plan.
//!
//! Everything here is pure and side-effect-free except [`which`] / [`detect_runtime`],
//! which only read `$PATH`. The actual process spawning lives in `main.rs`, which
//! keeps this module easy to unit-test without a container runtime installed.

use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use crate::config::Harness;

/// A located container runtime executable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Runtime {
  pub name: String,
  pub path: PathBuf,
}

/// Candidate runtimes, in the order scsh tries them. Apple's `container` is
/// preferred on macOS; Docker is the primary everywhere; Podman is the fallback.
pub fn runtime_candidates(is_macos: bool) -> &'static [&'static str] {
  if is_macos {
    &["container", "docker", "podman"]
  } else {
    &["docker", "podman"]
  }
}

/// Find the first available runtime for the current OS. If `SCSH_RUNTIME` is set
/// (and non-empty), it overrides detection — scsh uses exactly that runtime when
/// it is on `PATH`. This is handy when the auto-picked runtime can't bind-mount
/// the clone (e.g. snap-packaged Docker is confined away from `/tmp`, so a
/// `SCSH_RUNTIME=podman` override is needed there).
pub fn detect_runtime() -> Option<Runtime> {
  if let Some(name) = std::env::var_os("SCSH_RUNTIME") {
    let name = name.to_string_lossy().into_owned();
    if !name.is_empty() {
      return which(&name).map(|path| Runtime { name, path });
    }
  }
  let path = std::env::var_os("PATH").unwrap_or_default();
  detect_runtime_in(cfg!(target_os = "macos"), &path)
}

/// Testable core of [`detect_runtime`]: search `path` for the OS's candidates.
///
/// Auto-detection additionally avoids a **snap-packaged Docker**: it is
/// AppArmor-confined away from the system temp dir, so it can't bind-mount the
/// per-run clone (the container would see an empty `/home/agent` and the skill's
/// opencode would crash with `EACCES`). When the preferred runtime is a snap
/// Docker *and* another runtime is available, scsh picks the other one instead.
/// An explicit `SCSH_RUNTIME` still forces any choice (see [`detect_runtime`]).
pub fn detect_runtime_in(is_macos: bool, path: &OsStr) -> Option<Runtime> {
  let found: Vec<Runtime> = runtime_candidates(is_macos)
    .iter()
    .filter_map(|&name| which_in(name, path).map(|p| Runtime { name: name.to_string(), path: p }))
    .collect();
  let snap_docker_first = matches!(found.first(), Some(r) if r.name == "docker" && is_snap_confined(&r.path));
  if snap_docker_first {
    if let Some(other) = found.iter().find(|r| r.name != "docker") {
      return Some(other.clone());
    }
  }
  found.into_iter().next()
}

/// Whether an executable path is inside a snap mount (e.g. `/snap/bin/docker`).
/// Snap-packaged Docker can't reach the system temp dir, which is where scsh
/// puts each run's clone, so the container sees nothing mounted.
pub fn is_snap_confined(path: &Path) -> bool {
  path.to_string_lossy().contains("/snap/")
}

/// Resolve an executable on `$PATH` (like the `which` command).
pub fn which(cmd: &str) -> Option<PathBuf> {
  let path = std::env::var_os("PATH")?;
  which_in(cmd, &path)
}

/// Testable core of [`which`]: search the given `path` value.
pub fn which_in(cmd: &str, path: &OsStr) -> Option<PathBuf> {
  if cmd.contains('/') {
    let p = PathBuf::from(cmd);
    return is_executable(&p).then_some(p);
  }
  for dir in std::env::split_paths(path) {
    if dir.as_os_str().is_empty() {
      continue;
    }
    let candidate = dir.join(cmd);
    if is_executable(&candidate) {
      return Some(candidate);
    }
  }
  None
}

#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
  use std::os::unix::fs::PermissionsExt;
  match std::fs::metadata(p) {
    Ok(m) => m.is_file() && (m.permissions().mode() & 0o111 != 0),
    Err(_) => false,
  }
}

#[cfg(not(unix))]
fn is_executable(p: &Path) -> bool {
  std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false)
}

/// Tag for the shared base image (`scsh-base` Dockerfile stage).
pub const BASE_IMAGE_TAG: &str = "scsh-base:latest";

/// Dockerfile `--target` for the shared base image.
pub const BASE_IMAGE_TARGET: &str = "scsh-base";

/// Fingerprint for [`BASE_IMAGE_TAG`] (toolchain layer only; harness stages excluded).
pub fn base_image_fingerprint(dockerfile: &str, uid: u32, gid: u32, tz: &str) -> String {
  image_build_fingerprint(dockerfile, BASE_IMAGE_TARGET, uid, gid, tz)
}

/// The tag of the harness-specific image scsh builds.
pub fn image_tag(harness: Harness) -> String {
  match harness {
    Harness::Opencode => "scsh-opencode:latest".to_string(),
    Harness::Claude => "scsh-claude:latest".to_string(),
    Harness::Codex => "scsh-codex:latest".to_string(),
    Harness::Grok => "scsh-grok:latest".to_string(),
    Harness::Cursor => "scsh-cursor:latest".to_string(),
  }
}

/// The Dockerfile build `--target` for a harness image.
pub fn image_target(harness: Harness) -> &'static str {
  match harness {
    Harness::Opencode => "scsh-opencode",
    Harness::Claude => "scsh-claude",
    Harness::Codex => "scsh-codex",
    Harness::Grok => "scsh-grok",
    Harness::Cursor => "scsh-cursor",
  }
}

/// Run-dir-relative path where scsh copies forwarded Claude auth before a run (gitignored
/// `tmp/`). The image sets `CLAUDE_CONFIG_DIR` to this tree's `.claude` dir, so the config
/// rides along with the repo mount and stays writable — no bind mounts, same pattern as
/// codex/grok/cursor. (Single-file bind mounts are read-only under Apple containers, and
/// Claude Code's interactive TUI re-runs onboarding when it cannot write its state json.)
pub const CLAUDE_AUTH_REL: &str = "tmp/.claude-auth";

/// In-container path where opencode reads `auth.json` (`$XDG_DATA_HOME/opencode/auth.json` in the
/// image). scsh bind-mounts the host's `~/.local/share/opencode/auth.json` here when that file
/// exists — required for third-party opencode providers (e.g. Nebius GLM) that authenticate via
/// the host login rather than a built-in model route.
pub const OPENCODE_AUTH_MOUNT: &str = "/home/agent/repo/tmp/.xdg-data/opencode/auth.json";

/// Run-dir-relative tree where scsh copies forwarded opencode auth/config before a run.
pub const OPENCODE_FORWARD_REL: &str = "tmp/.opencode-forward";

/// In-container paths for forwarded opencode config (`$XDG_CONFIG_HOME/opencode/` on the host).
/// Custom providers (e.g. Nebius GLM) are declared here; auth.json alone is not enough.
/// scsh copies these from the host into each run clone (parallel runs cannot safely share one
/// host bind-mount on Apple Containers).
pub const OPENCODE_CONFIG_JSON_MOUNT: &str = "/home/agent/.config/opencode/opencode.json";
pub const OPENCODE_CONFIG_JSONC_MOUNT: &str = "/home/agent/.config/opencode/opencode.jsonc";

/// Host env var for long-lived Claude OAuth (`claude setup-token`).
pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN";

/// Run-dir-relative Codex home. The image sets `CODEX_HOME` to [`AGENT_REPO`]`/`this, so
/// forwarding host credentials is just copying `auth.json`/`config.toml` here — the tree is
/// under the gitignored `tmp/`, which is visible in-container in BOTH repo mount modes.
/// Codex's own per-run session/log data lands here too (readable on the host afterwards).
pub const CODEX_FORWARD_REL: &str = "tmp/.codex";

/// Host env var for API-key Codex auth (works headless; ChatGPT-plan auth uses auth.json).
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";

/// The host's Codex home: `$CODEX_HOME` or `~/.codex`.
pub fn codex_home_on_host() -> Option<PathBuf> {
  if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
    return Some(PathBuf::from(dir));
  }
  std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
}

/// Host `auth.json` written by `codex login` (ChatGPT plan or API-key login).
pub fn codex_auth_file_on_host() -> Option<PathBuf> {
  codex_home_on_host().map(|d| d.join("auth.json")).filter(|p| p.is_file())
}

/// Whether the host has credentials codex containers can use: `auth.json` or `OPENAI_API_KEY`.
pub fn codex_container_auth_ready() -> bool {
  codex_auth_file_on_host().is_some() || std::env::var(OPENAI_API_KEY_ENV).map(|v| !v.is_empty()).unwrap_or(false)
}

/// Run-dir-relative Grok home (same pattern as codex): the image sets `GROK_HOME` to
/// [`AGENT_REPO`]`/`this, so forwarding host credentials is just copying files here.
pub const GROK_FORWARD_REL: &str = "tmp/.grok";

/// Host env var for API-key Grok auth (xAI API key from console.x.ai; works headless).
pub const XAI_API_KEY_ENV: &str = "XAI_API_KEY";

/// The host's Grok home: `$GROK_HOME` or `~/.grok`.
pub fn grok_home_on_host() -> Option<PathBuf> {
  if let Some(dir) = std::env::var_os("GROK_HOME").filter(|d| !d.is_empty()) {
    return Some(PathBuf::from(dir));
  }
  std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".grok"))
}

/// Host `auth.json` written by `grok login` (browser OIDC or device code).
pub fn grok_auth_file_on_host() -> Option<PathBuf> {
  grok_home_on_host().map(|d| d.join("auth.json")).filter(|p| p.is_file())
}

/// Whether the host has credentials grok containers can use: `auth.json` or `XAI_API_KEY`.
pub fn grok_container_auth_ready() -> bool {
  grok_auth_file_on_host().is_some() || std::env::var(XAI_API_KEY_ENV).map(|v| !v.is_empty()).unwrap_or(false)
}

/// Run-dir-relative Cursor config dir (`CURSOR_CONFIG_DIR` inside the container).
pub const CURSOR_FORWARD_REL: &str = "tmp/.cursor";

/// Run-dir-relative Linux auth dir (`$XDG_CONFIG_HOME/cursor/auth.json` in the container).
pub const CURSOR_AUTH_FORWARD_REL: &str = "tmp/.config/cursor";

/// Host env var for API-key Cursor auth (Cursor Dashboard → API Keys; works headless).
pub const CURSOR_API_KEY_ENV: &str = "CURSOR_API_KEY";

/// In-container env var for Cursor CLI config (cli-config.json, mcp.json).
pub const CURSOR_CONFIG_DIR_ENV: &str = "CURSOR_CONFIG_DIR";

/// In-container env var so Linux cursor-agent finds auth.json under tmp/.config/cursor/.
pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";

/// The host's Cursor config dir: `$CURSOR_HOME` or `~/.cursor`.
pub fn cursor_home_on_host() -> Option<PathBuf> {
  if let Some(dir) = std::env::var_os("CURSOR_HOME").filter(|d| !d.is_empty()) {
    return Some(PathBuf::from(dir));
  }
  std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cursor"))
}

/// Host `auth.json` when cursor-agent wrote tokens to disk (typical on Linux).
pub fn cursor_auth_file_on_host() -> Option<PathBuf> {
  let home = std::env::var_os("HOME").map(PathBuf::from)?;
  for path in [home.join(".config/cursor/auth.json"), home.join(".cursor/auth.json")] {
    if path.is_file() {
      return Some(path);
    }
  }
  None
}

/// Non-empty `CURSOR_API_KEY` on the host, if set.
pub fn cursor_api_key() -> Option<String> {
  std::env::var(CURSOR_API_KEY_ENV).ok().filter(|s| !s.is_empty())
}

#[cfg(target_os = "macos")]
fn keychain_secret(service: &str) -> Option<String> {
  let out =
    std::process::Command::new("security").args(["find-generic-password", "-s", service, "-w"]).output().ok()?;
  if !out.status.success() {
    return None;
  }
  let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
  (!token.is_empty()).then_some(token)
}

#[cfg(not(target_os = "macos"))]
fn keychain_secret(_service: &str) -> Option<String> {
  None
}

/// macOS stores cursor-agent OAuth tokens in the login keychain after `cursor agent login`.
pub fn cursor_keychain_access_token() -> Option<String> {
  keychain_secret("cursor-access-token")
}

pub fn cursor_keychain_refresh_token() -> Option<String> {
  keychain_secret("cursor-refresh-token")
}

/// macOS stores Claude Code's OAuth credentials in the login keychain — the item is the
/// literal JSON of `.credentials.json` (accessToken + refreshToken + expiresAt + scopes).
/// The interactive TUI treats a credentials file without expiry/scopes as logged-out, so
/// forwarding this full blob (not just an access token) is what makes the TUI skip login.
pub fn claude_keychain_credentials_json() -> Option<String> {
  keychain_secret("Claude Code-credentials").filter(|s| s.contains("claudeAiOauth"))
}

/// Whether the host has credentials cursor containers can use.
pub fn cursor_container_auth_ready() -> bool {
  cursor_api_key().is_some() || cursor_auth_file_on_host().is_some() || cursor_keychain_access_token().is_some()
}

/// Absolute path the repo clone is bind-mounted at, and the image's WORKDIR (where the harness
/// starts). Deliberately a *subdirectory* of the agent user's home (`/home/agent`), not the
/// home itself: the harness and its tools scribble into `$HOME` (`~/.cache`, `~/.config`,
/// `~/.npm`, …), so keeping the clone one level down keeps that scratch out of the repo's
/// working tree. The home is set in the image (see `src/Dockerfile`).
pub const AGENT_REPO: &str = "/home/agent/repo";

/// opencode's data dir (`XDG_DATA_HOME`), RELATIVE to the repo, where scsh drops the forwarded
/// credential. It lives under the gitignored `tmp/`, so neither the auth nor opencode's own
/// session data ever shows up as an untracked file. The image sets `XDG_DATA_HOME` to
/// [`AGENT_REPO`]`/`this.
pub const AGENT_XDG_DATA_REL: &str = "tmp/.xdg-data";

/// Per-run log path the harness tees every line of its output to, RELATIVE to the repo. It
/// lives under the gitignored `tmp/` (so it is never an untracked file); on the host it is
/// therefore `<run_dir>/tmp/scsh-run.log`, where the full intra-container output can be read.
pub const RUN_LOG_REL: &str = "tmp/scsh-run.log";

/// Per-run asciinema recording (asciicast v2, NDJSON) of the harness PTY, RELATIVE to the
/// repo: `${SCSH_RUN_LOG}.cast` in-container, `<run_dir>/tmp/scsh-run.log.cast` on the host.
/// NDJSON means any byte-prefix ending on a newline is itself a valid (partial) recording,
/// so the file can be downloaded and replayed while the skill is still running.
pub const RUN_CAST_REL: &str = "tmp/scsh-run.log.cast";

/// The env var (set in the generated image) that carries the in-container log
/// path the harness command tees its output to.
pub const RUN_LOG_VAR: &str = "SCSH_RUN_LOG";

/// The Dockerfile scsh builds every skill container from. The source of truth is the
/// sibling [`src/Dockerfile`](./Dockerfile) — a static, platform-agnostic file embedded at
/// compile time. It needs no Rust-side substitution: UID/GID/TZ are `ARG`s passed as build
/// args, and every architecture-specific download resolves the target arch *inside* the
/// build (`dpkg --print-architecture` -> amd64|arm64), so the one file builds on x86_64 and
/// arm64 alike. The image is generic (opencode + a dev toolchain + a non-root `agent` user,
/// no skill `CMD`), so it serves every skill; `main.rs` streams it to the builder's stdin.
pub fn dockerfile() -> String {
  include_str!("Dockerfile").to_string()
}

/// The builder host's IANA timezone (e.g. `Europe/Berlin`), baked into the image so a skill's
/// timestamps match the machine that built it. Tries `$TZ`, then the `/etc/localtime` symlink
/// target, then `/etc/timezone`; falls back to `UTC`.
pub fn host_timezone() -> String {
  if let Ok(tz) = std::env::var("TZ") {
    let tz = tz.trim();
    if !tz.is_empty() {
      return tz.to_string();
    }
  }
  if let Ok(target) = std::fs::read_link("/etc/localtime") {
    let s = target.to_string_lossy();
    if let Some(idx) = s.find("zoneinfo/") {
      let tz = s[idx + "zoneinfo/".len()..].trim_matches('/');
      if !tz.is_empty() {
        return tz.to_string();
      }
    }
  }
  if let Ok(contents) = std::fs::read_to_string("/etc/timezone") {
    let tz = contents.trim();
    if !tz.is_empty() {
      return tz.to_string();
    }
  }
  "UTC".to_string()
}

/// Whether harness commands run at full debug verbosity. On by default — the live board,
/// `tmp/scsh-run.log`, and the session browser want turn-by-turn output. Opt out with
/// `SCSH_QUIET=1`: harnesses then log at their default level (output is still teed to the log).
pub fn harness_verbose_enabled() -> bool {
  !matches!(std::env::var("SCSH_QUIET").ok().as_deref(), Some("1") | Some("true"))
}

/// Extra `-e` pairs injected into every skill container so harnesses log generously.
pub fn harness_container_env(harness: Harness) -> Vec<(String, String)> {
  harness_container_env_verbose(harness, harness_verbose_enabled())
}

fn harness_container_env_verbose(harness: Harness, verbose: bool) -> Vec<(String, String)> {
  match harness {
    Harness::Opencode => vec![("OPENCODE_CLIENT".to_string(), "scsh".to_string())],
    Harness::Claude if verbose => {
      vec![("DEBUG".to_string(), "1".to_string()), ("CLAUDE_CODE_DEBUG_LOG_LEVEL".to_string(), "verbose".to_string())]
    }
    Harness::Claude => Vec::new(),
    // Codex is a Rust CLI; RUST_LOG enables its tracing output (stderr → the teed run log).
    Harness::Codex if verbose => vec![("RUST_LOG".to_string(), "codex_core=info,codex_exec=info".to_string())],
    Harness::Codex => Vec::new(),
    // Grok's verbosity comes from --debug/--debug-file flags; no env needed.
    Harness::Grok => Vec::new(),
    // Point cursor-agent at forwarded config + auth under the repo's gitignored tmp/.
    Harness::Cursor => vec![
      (CURSOR_CONFIG_DIR_ENV.to_string(), format!("{AGENT_REPO}/{CURSOR_FORWARD_REL}")),
      (XDG_CONFIG_HOME_ENV.to_string(), format!("{AGENT_REPO}/tmp/.config")),
    ],
  }
}

/// The shell command a harness runs *inside the container* for one skill.
/// Output is always teed to [`RUN_LOG_VAR`] for the daemon; `SCSH_QUIET=1` drops the debug flags.
/// `effort` is the `.scsh.yml` reasoning-effort level (codex and grok only; expansion
/// guarantees it is `None` for harnesses without an effort knob).
pub fn harness_command(
  harness: Harness, model: Option<&str>, effort: Option<&str>, skill_source: &str, result: &str,
  term: crate::config::Terminal,
) -> String {
  harness_command_verbose(harness, model, effort, skill_source, result, harness_verbose_enabled(), term)
}

fn harness_command_verbose(
  harness: Harness, model: Option<&str>, effort: Option<&str>, skill_source: &str, result: &str, verbose: bool,
  term: crate::config::Terminal,
) -> String {
  match harness {
    Harness::Opencode => {
      let instruction = format!(
        "run skill {skill_source}. Follow .skills/{skill_source}/SKILL.md exactly. \
         Write the required result file to the path in the SCSH_RESULT environment variable. \
         Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
      );
      let mut cmd = String::from("opencode");
      if verbose {
        cmd.push_str(" --print-logs --log-level DEBUG");
      }
      if let Some(m) = model {
        cmd.push_str(" -m ");
        cmd.push_str(&shell_quote(m));
      }
      cmd.push_str(" run ");
      cmd.push_str(&shell_quote(&instruction));
      wrap_harness_shell(harness, skill_source, model, &cmd, verbose, term)
    }
    Harness::Claude => {
      let prompt = format!(
        "Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
         Write the required result file to the path in the SCSH_RESULT environment variable. \
         Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
      );
      // Full interactive TUI (no -p): the recording shows the real Claude Code screen, and
      // no dialog blocks it. `bypassPermissions` auto-approves EVERY tool (bash, edits, fetch,
      // MCP, …) — scsh runs arbitrary skills, so a per-tool allowlist would not be enough. Its
      // consent screen is suppressed by forwarding a MINIMAL `.claude.json` (see main's
      // forward_claude_auth): the full ~49 KB host config re-triggered the consent, a tiny one
      // (login identity + onboarding/trust/bypass-accepted) does not. All config, no scraping.
      let mut tui = String::from("claude --permission-mode bypassPermissions");
      if let Some(m) = model {
        tui.push_str(" --model ");
        tui.push_str(&shell_quote(m));
      }
      tui.push(' ');
      tui.push_str(&shell_quote(&prompt));
      wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::SlashExit, result, term)
    }
    Harness::Codex => {
      let prompt = format!(
        "Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
         Write the required result file to the path in the SCSH_RESULT environment variable. \
         Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
      );
      // Full interactive TUI (no `exec`): the recording shows the real Codex screen. The
      // container IS the sandbox (ephemeral, --rm), so codex's own sandbox/approvals are
      // bypassed; the repo mount is pre-trusted in the forwarded config.toml (see main's
      // forward_codex), so no dialog blocks.
      let mut tui = String::from("codex --dangerously-bypass-approvals-and-sandbox");
      if let Some(m) = model {
        tui.push_str(" -m ");
        tui.push_str(&shell_quote(m));
      }
      if let Some(e) = effort {
        tui.push_str(" -c ");
        tui.push_str(&shell_quote(&format!("model_reasoning_effort={e}")));
      }
      tui.push(' ');
      tui.push_str(&shell_quote(&prompt));
      wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::DoubleCtrlC, result, term)
    }
    Harness::Grok => {
      let prompt = format!(
        "Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
         Write the required result file to the path in the SCSH_RESULT environment variable. \
         Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
      );
      // Single-turn headless run; the ephemeral container is the sandbox, so grok's own
      // approvals are bypassed (same posture as the other harnesses).
      let mut cmd = String::from("grok -p ");
      cmd.push_str(&shell_quote(&prompt));
      cmd.push_str(" --permission-mode bypassPermissions --always-approve");
      if let Some(m) = model {
        cmd.push_str(" -m ");
        cmd.push_str(&shell_quote(m));
      }
      if let Some(e) = effort {
        cmd.push_str(" --effort ");
        cmd.push_str(&shell_quote(e));
      }
      if verbose {
        cmd.push_str(" --debug --debug-file \"${");
        cmd.push_str(RUN_LOG_VAR);
        cmd.push_str("}.debug\"");
      }
      wrap_harness_shell(harness, skill_source, model, &cmd, verbose, term)
    }
    Harness::Cursor => {
      let prompt = format!(
        "Run the skill defined in .skills/{skill_source}/SKILL.md. Follow its instructions exactly. \
         Write the required result file to the path in the SCSH_RESULT environment variable. \
         Do not git fetch, pull, push, or clone — scsh preloaded a full local clone; use only refs already present."
      );
      // Full interactive TUI (no -p): the recording shows the real cursor-agent screen.
      // The ephemeral container is the sandbox; --force auto-approves. cursor's `--trust`
      // is print-mode-only, and its TUI workspace-trust prompt has no flag or seedable
      // config key — cursor records trust as a marker file under $HOME (NOT the forwarded
      // config dir), so it is created in-container just before the TUI starts. The repo
      // path slug is `/`-stripped, `/`->`-` of AGENT_REPO.
      let trust_dir = format!("$HOME/.cursor/projects/{}", AGENT_REPO.trim_start_matches('/').replace('/', "-"));
      // No `exec`: the wrapping shell must survive cursor-agent to record its exit status.
      let mut tui = format!(
        "mkdir -p {trust_dir} && : > {trust_dir}/.workspace-trusted && cursor-agent --force --sandbox disabled"
      );
      if let Some(m) = model {
        tui.push_str(" --model ");
        tui.push_str(&shell_quote(&cursor_model_with_effort(m, effort)));
      }
      tui.push(' ');
      tui.push_str(&shell_quote(&prompt));
      wrap_tui_shell(harness, skill_source, model, &tui, TuiQuit::DoubleCtrlC, result, term)
    }
  }
}

/// How to politely close a harness TUI once the skill's result file exists. The value is
/// passed verbatim to `scsh-tui-record`, which maps it to the harness's quit keystrokes.
#[derive(Debug, Clone, Copy)]
enum TuiQuit {
  /// Type `/exit` + Enter (Claude Code).
  SlashExit,
  /// Ctrl-C twice, one second apart (codex, cursor-agent quit-confirm flows).
  DoubleCtrlC,
}

impl TuiQuit {
  /// The `scsh-tui-record` argument selecting this quit style.
  fn as_arg(self) -> &'static str {
    match self {
      TuiQuit::SlashExit => "slash-exit",
      TuiQuit::DoubleCtrlC => "double-ctrl-c",
    }
  }
}

/// Build the `scsh-tui-record` invocation that records a harness's interactive TUI.
///
/// The heavy lifting lives in the `scsh-tui-record` script baked into the base image (see
/// `src/Dockerfile`), so this stays a clean argv, not an inline shell program. The script
/// runs the harness TUI inside a `term.cols` x `term.rows` tmux session, records the
/// attached screen with asciinema to `${SCSH_RUN_LOG}.cast`, and — when the skill's
/// `result` file appears (the run's completion signal) — sends the harness its quit keys
/// and ends the recording. There is deliberately NO screen-scraping: every harness is
/// configured (flags + seeded config) so no consent/trust/login dialog ever appears; a
/// harness that still blocks is a setup bug that should surface, not be auto-clicked.
///
/// The output still tees to the run log, and scsh's container timeout remains the hard stop.
fn wrap_tui_shell(
  harness: Harness, skill_source: &str, model: Option<&str>, tui_cmd: &str, quit: TuiQuit, result: &str,
  term: crate::config::Terminal,
) -> String {
  let model_label = model.unwrap_or("(harness default)");
  // `scsh-tui-record` records the harness's exit status to `${SCSH_RUN_LOG}.exit` via an EXIT
  // trap it wraps around this command, and a per-signal trace to `${SCSH_RUN_LOG}.tuidebug`.
  // scsh otherwise never sees the exit (asciinema and the tmux pane both swallow it), which makes
  // a harness that dies abnormally (crash, signal, OOM → 137/143/130) indistinguishable from one
  // that merely wrote no result. A trap is used rather than a bare `; echo $?` so a catchable
  // signal still records — an ABSENT .exit then uniquely means an uncatchable SIGKILL.
  format!(
    "{{ echo \"scsh: harness={} skill={skill_source} model={model_label} tui=tmux \
log=${{{log_var}}} cast=${{{log_var}}}.cast\" >&2; \
scsh-tui-record {cols} {rows} {quit} {result_q} {tui_q}; }} 2>&1 | tee \"${{{log_var}}}\"",
    harness.as_str(),
    log_var = RUN_LOG_VAR,
    cols = term.cols,
    rows = term.rows,
    quit = quit.as_arg(),
    result_q = shell_quote(result),
    tui_q = shell_quote(tui_cmd),
  )
}

/// Cursor `--model` slugs use hyphen suffixes (`claude-opus-4-8-low`, `gpt-5.5-high`), not
/// bracket overrides. composer-2.5 only exposes `composer-2.5` and `composer-2.5-fast`.
fn cursor_model_with_effort(model: &str, effort: Option<&str>) -> String {
  if model.contains('[') {
    return model.to_string();
  }
  let Some(effort) = effort else {
    return model.to_string();
  };
  if model == "composer-2.5" || model.starts_with("composer-2.5-") {
    return match effort {
      "high" => "composer-2.5-fast".to_string(),
      _ => "composer-2.5".to_string(),
    };
  }
  let suffix = match effort {
    "xhigh" if model.starts_with("gpt-5.5") => "extra-high",
    other => other,
  };
  if model.ends_with(&format!("-{suffix}")) {
    return model.to_string();
  }
  format!("{model}-{suffix}")
}

/// Run the harness under `/bin/sh -c`, banner on stderr, all output teed to [`RUN_LOG_VAR`].
///
/// The harness itself runs inside `asciinema rec` — a real PTY of `term.cols` x `term.rows`
/// — which records the raw terminal stream to `${SCSH_RUN_LOG}.cast` ([`RUN_CAST_REL`])
/// while passing it through to the tee. asciinema 2.x does not propagate the child's exit
/// status, which changes nothing here: the pipeline already returns tee's status, and skill
/// failure is detected by the absence of the result file, not by exit code.
fn wrap_harness_shell(
  harness: Harness, skill_source: &str, model: Option<&str>, inner: &str, verbose: bool, term: crate::config::Terminal,
) -> String {
  let model_label = model.unwrap_or("(harness default)");
  // Verbose claude/grok write a separate --debug-file, and verbose codex a final-message
  // file; append them to the teed stream at the end so the run log is self-contained.
  let post = match harness {
    Harness::Claude if verbose => format!(
      "if [ -f \"${{{log_var}}}.debug\" ]; then echo \"scsh: --- claude debug log ---\" >&2; cat \"${{{log_var}}}.debug\" >&2; fi",
      log_var = RUN_LOG_VAR,
    ),
    Harness::Codex if verbose => format!(
      "if [ -f \"${{{log_var}}}.last\" ]; then echo \"scsh: --- codex final message ---\" >&2; cat \"${{{log_var}}}.last\" >&2; fi",
      log_var = RUN_LOG_VAR,
    ),
    Harness::Grok if verbose => format!(
      "if [ -f \"${{{log_var}}}.debug\" ]; then echo \"scsh: --- grok debug log ---\" >&2; cat \"${{{log_var}}}.debug\" >&2; fi",
      log_var = RUN_LOG_VAR,
    ),
    _ => String::new(),
  };
  let recorded = format!(
    "asciinema rec -q --cols {cols} --rows {rows} -c {inner_q} \"${{{log_var}}}.cast\"",
    cols = term.cols,
    rows = term.rows,
    inner_q = shell_quote(inner),
    log_var = RUN_LOG_VAR,
  );
  format!(
    "{{ echo \"scsh: harness={} skill={skill_source} model={model_label} log=${{{log_var}}} cast=${{{log_var}}}.cast\" >&2; {recorded}; {post} }} 2>&1 | tee \"${{{log_var}}}\"",
    harness.as_str(),
    log_var = RUN_LOG_VAR,
  )
}

/// How a given runtime accepts the generated Dockerfile.
///
/// docker and podman read it from stdin (`build … -`), which keeps it fully
/// in-memory and dodges build-context path confinement (e.g. snap-packaged
/// Docker can't read `/tmp`). Apple's `container` has no stdin build mode — it
/// requires a context directory — so for it scsh writes the in-memory Dockerfile
/// to an ephemeral context dir that is removed right after the build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildMethod {
  Stdin,
  ContextDir,
}

/// The filename scsh writes inside the ephemeral context dir (the universal
/// default Dockerfile name, so no `-f` flag is needed).
pub const CONTEXT_DOCKERFILE_NAME: &str = "Dockerfile";

/// Pick the build method for a runtime.
pub fn build_method(runtime: &str) -> BuildMethod {
  if runtime == "container" {
    BuildMethod::ContextDir
  } else {
    BuildMethod::Stdin
  }
}

/// OCI label scsh stamps on every harness image at build time. Compared on later runs
/// to skip rebuilding when the embedded Dockerfile and build args are unchanged.
pub const BUILD_FINGERPRINT_LABEL: &str = "scsh.build.fingerprint";

/// The `--build-arg` pair that pins the agent's UID/GID to the host user's.
fn build_args(uid: u32, gid: u32, tz: &str) -> Vec<String> {
  vec![
    "--build-arg".into(),
    format!("AGENT_UID={uid}"),
    "--build-arg".into(),
    format!("AGENT_GID={gid}"),
    "--build-arg".into(),
    format!("TZ={tz}"),
  ]
}

fn build_labels(fingerprint: &str) -> Vec<String> {
  vec!["--label".into(), format!("{BUILD_FINGERPRINT_LABEL}={fingerprint}")]
}

/// Deterministic sha256 over the Dockerfile, `--target`, and the build args that affect the image.
pub fn image_build_fingerprint(dockerfile: &str, target: &str, uid: u32, gid: u32, tz: &str) -> String {
  let blob = format!("target={target}\nuid={uid}\ngid={gid}\ntz={tz}\n---\n{dockerfile}");
  crate::sha256::sha256_hex(blob.as_bytes())
}

/// Read the fingerprint label from an existing harness image, if present.
pub fn image_inspect_fingerprint(runtime: &str, tag: &str) -> Option<String> {
  use std::process::Command;
  let out = if runtime == "container" {
    Command::new("container").args(["image", "inspect", tag]).output().ok()?
  } else {
    let format = format!(r#"{{{{index .Config.Labels "{BUILD_FINGERPRINT_LABEL}"}}}}"#);
    Command::new(runtime).args(["image", "inspect", tag, "--format", &format]).output().ok()?
  };
  if !out.status.success() {
    return None;
  }
  let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
  if runtime == "container" {
    parse_label_from_container_inspect(&s, BUILD_FINGERPRINT_LABEL)
  } else if s.is_empty() {
    None
  } else {
    Some(s)
  }
}

/// True when `tag` exists and carries the expected build fingerprint (skip rebuild).
pub fn image_is_up_to_date(runtime: &str, tag: &str, fingerprint: &str) -> bool {
  image_inspect_fingerprint(runtime, tag).as_deref() == Some(fingerprint)
}

fn parse_label_from_container_inspect(json: &str, key: &str) -> Option<String> {
  let needle = format!(r#""{key}":""#);
  let start = json.find(&needle)? + needle.len();
  let rest = &json[start..];
  let end = rest.find('"')?;
  Some(rest[..end].to_string())
}

/// Build argv for the stdin method: the Dockerfile is sent on stdin (`-`).
pub fn build_command_stdin(
  runtime: &str, tag: &str, target: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str,
) -> Vec<String> {
  let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
  v.extend(build_args(uid, gid, tz));
  v.extend(build_labels(fingerprint));
  v.push("-".into());
  v
}

pub fn build_command_context(
  runtime: &str, tag: &str, target: &str, context_dir: &str, uid: u32, gid: u32, tz: &str, fingerprint: &str,
) -> Vec<String> {
  let mut v = vec![runtime.into(), "build".into(), "-t".into(), tag.into(), "--target".into(), target.into()];
  v.extend(build_args(uid, gid, tz));
  v.extend(build_labels(fingerprint));
  v.push(context_dir.into());
  v
}

/// One harness image scsh may build from the shared Dockerfile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageBuildSpec {
  pub harness: Harness,
  pub tag: String,
  pub target: String,
  pub fingerprint: String,
}

pub fn image_build_spec(harness: Harness, dockerfile: &str, uid: u32, gid: u32, tz: &str) -> ImageBuildSpec {
  let target = image_target(harness);
  ImageBuildSpec {
    harness,
    tag: image_tag(harness),
    target: target.to_string(),
    fingerprint: image_build_fingerprint(dockerfile, target, uid, gid, tz),
  }
}

/// True when the runtime exposes `buildx bake` (multi-target build in one command).
#[allow(dead_code)] // retained for tests; runs build base then per-harness instead.
pub fn runtime_supports_bake(runtime: &str) -> bool {
  std::process::Command::new(runtime)
    .args(["buildx", "version"])
    .stdout(std::process::Stdio::null())
    .stderr(std::process::Stdio::null())
    .status()
    .map(|s| s.success())
    .unwrap_or(false)
}

/// Build argv for one `buildx bake` that tags every listed harness target.
#[allow(dead_code)] // retained for tests; runs build base then per-harness instead.
pub fn build_command_bake(runtime: &str, bake_targets: &[String]) -> Vec<String> {
  let mut v = vec![runtime.into(), "buildx".into(), "bake".into(), "--load".into(), "-f".into(), "-".into()];
  v.extend(bake_targets.iter().cloned());
  v
}

/// JSON bake definition: one context dir, multiple Dockerfile `--target`s sharing `scsh-base`.
#[allow(dead_code)] // retained for tests; runs build base then per-harness instead.
pub fn bake_definition_json(context_dir: &str, specs: &[ImageBuildSpec], uid: u32, gid: u32, tz: &str) -> String {
  use crate::json::quote;
  let mut entries = Vec::with_capacity(specs.len());
  for spec in specs {
    entries.push(format!(
      r#"    {}: {{
      "context": {},
      "dockerfile": "Dockerfile",
      "target": {},
      "tags": [{}],
      "args": {{
        "AGENT_UID": "{uid}",
        "AGENT_GID": "{gid}",
        "TZ": {}
      }},
      "labels": {{
        {}: {}
      }}
    }}"#,
      quote(&spec.target),
      quote(context_dir),
      quote(&spec.target),
      quote(&spec.tag),
      quote(tz),
      quote(BUILD_FINGERPRINT_LABEL),
      quote(&spec.fingerprint),
    ));
  }
  format!("{{\n  \"target\": {{\n{}\n  }}\n}}", entries.join(",\n"))
}

/// How the caller repo reaches the container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoMountMode {
  /// Linux-friendly path: bind-mount the host run dir at [`AGENT_REPO`].
  Full,
  /// macOS Apple Container: repo is cloned inside the container from a host git daemon;
  /// only the gitignored `tmp/` tree is bind-mounted for results and forwarded auth.
  TmpOnly,
}

/// Run argv: run the freshly built image, removing the container afterwards. For
/// rootless podman, `--userns=keep-id` maps the host UID to the same UID inside the
/// container so the `agent` user can read/write the mount; docker (and Apple
/// `container`) map the UID directly and need no such flag.
pub fn run_command(
  runtime: &str, tag: &str, run_dir: &str, name: &str, env: &[(String, String)], volumes: &[(&str, &str)],
  command: &str, repo_mount: RepoMountMode,
) -> Vec<String> {
  let mut v = vec![runtime.into(), "run".into(), "--rm".into(), "--name".into(), name.into()];
  if runtime == "podman" {
    v.push("--userns=keep-id".into());
  }
  for (key, value) in env {
    v.push("-e".into());
    v.push(format!("{key}={value}"));
  }
  for (host, mount) in volumes {
    v.push("-v".into());
    v.push(format!("{host}:{mount}"));
  }
  match repo_mount {
    RepoMountMode::Full => {
      v.push("-v".into());
      v.push(format!("{run_dir}:{AGENT_REPO}"));
    }
    RepoMountMode::TmpOnly => {
      v.push("-v".into());
      v.push(format!("{run_dir}/tmp:{AGENT_REPO}/tmp"));
    }
  }
  v.push(tag.into());
  v.push("/bin/sh".into());
  v.push("-c".into());
  v.push(command.into());
  v
}

/// True when a named container still exists (running or stopped) for the given runtime.
/// docker, podman, and Apple `container` all support `inspect <name>`, but Apple's exits 0
/// with an empty `[]` for a missing container — so require a non-empty JSON result too.
pub fn container_named_exists(runtime: &str, name: &str) -> bool {
  use std::process::{Command, Stdio};
  if name.is_empty() {
    return false;
  }
  let Ok(out) = Command::new(runtime).args(["inspect", name]).stderr(Stdio::null()).output() else {
    return false;
  };
  if !out.status.success() {
    return false;
  }
  let body = String::from_utf8_lossy(&out.stdout);
  let body = body.trim();
  !(body.is_empty() || body == "[]" || body == "null")
}

/// Probe every runtime scsh might use — for orphan prune jobs with no runtime recorded.
pub fn container_named_exists_any(name: &str) -> bool {
  for rt in runtime_candidates(cfg!(target_os = "macos")) {
    if which(rt).is_some() && container_named_exists(rt, name) {
      return true;
    }
  }
  false
}

pub fn opencode_auth_in(xdg_data_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
  let base = match xdg_data_home {
    Some(x) if !x.is_empty() => PathBuf::from(x),
    _ => PathBuf::from(home?).join(".local").join("share"),
  };
  Some(base.join("opencode").join("auth.json"))
}

/// Host opencode config dir (`$XDG_CONFIG_HOME/opencode` or `~/.config/opencode`).
pub fn opencode_config_dir(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
  let base = match xdg_config_home {
    Some(x) if !x.is_empty() => PathBuf::from(x),
    _ => PathBuf::from(home?).join(".config"),
  };
  Some(base.join("opencode"))
}

pub fn opencode_config_json_in(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
  let path = opencode_config_dir(xdg_config_home, home)?.join("opencode.json");
  path.is_file().then_some(path)
}

pub fn opencode_config_jsonc_in(xdg_config_home: Option<&OsStr>, home: Option<&OsStr>) -> Option<PathBuf> {
  let path = opencode_config_dir(xdg_config_home, home)?.join("opencode.jsonc");
  path.is_file().then_some(path)
}

pub fn opencode_auth_ready() -> bool {
  opencode_auth_in(std::env::var_os("XDG_DATA_HOME").as_deref(), std::env::var_os("HOME").as_deref())
    .is_some_and(|p| p.is_file())
}

pub fn claude_oauth_token() -> Option<String> {
  std::env::var(CLAUDE_OAUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty())
}

fn claude_credentials_file_on_host() -> Option<PathBuf> {
  let home = std::env::var_os("HOME")?;
  let path = PathBuf::from(home).join(".claude").join(".credentials.json");
  path.is_file().then_some(path)
}

/// Whether the host has credentials containers can use: `CLAUDE_CODE_OAUTH_TOKEN`,
/// `~/.claude/.credentials.json`, or the macOS login keychain.
pub fn claude_container_auth_ready() -> bool {
  claude_oauth_token().is_some()
    || claude_credentials_file_on_host().is_some()
    || claude_keychain_credentials_json().is_some()
}

pub fn check_harness_host(harness: Harness) -> Result<(), String> {
  match harness {
    Harness::Opencode => {
      if opencode_auth_ready() {
        Ok(())
      } else {
        Err("opencode harness unavailable (auth not found at ~/.local/share/opencode/auth.json — run `opencode auth login`)".into())
      }
    }
    Harness::Claude => {
      if claude_container_auth_ready() {
        Ok(())
      } else {
        Err(
          "claude harness unavailable (no CLAUDE_CODE_OAUTH_TOKEN, no ~/.claude/.credentials.json, and no macOS \
           keychain credentials — log in with `claude`, or run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN)"
            .into(),
        )
      }
    }
    Harness::Codex => {
      if codex_container_auth_ready() {
        Ok(())
      } else {
        Err(
          "codex harness unavailable (no ~/.codex/auth.json and OPENAI_API_KEY is not set \
           — run `codex login`, or export OPENAI_API_KEY in your shell)"
            .into(),
        )
      }
    }
    Harness::Grok => {
      if grok_container_auth_ready() {
        Ok(())
      } else {
        Err(
          "grok harness unavailable (no ~/.grok/auth.json and XAI_API_KEY is not set \
           — run `grok login` (or `grok login --device-auth`), or export XAI_API_KEY in your shell)"
            .into(),
        )
      }
    }
    Harness::Cursor => {
      if cursor_container_auth_ready() {
        Ok(())
      } else {
        Err(
          "cursor harness unavailable (no cursor auth on host — run `cursor agent login`, \
           or export CURSOR_API_KEY in your shell)"
            .into(),
        )
      }
    }
  }
}

/// Host-side opencode model list, loaded once per `scsh run` when needed.
pub struct OpencodeModelProbe {
  available: Option<std::collections::HashSet<String>>,
}

impl OpencodeModelProbe {
  /// Run `opencode models <provider>` for each provider required by **selected** skills'
  /// explicit opencode models (profile-scoped — not every model in `.scsh.yml`).
  pub fn for_selected(skills: &[crate::config::ResolvedInvocation]) -> Self {
    let requested = requested_opencode_models(skills);
    if requested.is_empty() {
      return Self { available: None };
    }
    if which("opencode").is_none() || !opencode_auth_ready() {
      return Self { available: None };
    }
    Self { available: Some(load_opencode_models_for(&requested).unwrap_or_default()) }
  }

  pub fn check_model(&self, model: &str) -> Result<(), String> {
    match &self.available {
      Some(set) if set.contains(model) => Ok(()),
      Some(_) => Err(format!("opencode model '{model}' not listed by `opencode models` on this host")),
      None => Ok(()),
    }
  }
}

/// Explicit opencode `model:` values on selected invocations (deduplicated).
fn requested_opencode_models(skills: &[crate::config::ResolvedInvocation]) -> std::collections::HashSet<String> {
  skills
    .iter()
    .filter(|s| s.harness == Harness::Opencode)
    .filter_map(|s| s.model.as_deref())
    .map(str::to_string)
    .collect()
}

/// Provider segment of an opencode model id (`openai/gpt-5.5` → `openai`).
fn opencode_model_provider(model: &str) -> &str {
  model.split('/').next().unwrap_or(model)
}

/// Unique providers for a set of requested model ids, stable order.
fn opencode_providers_for_models(models: &std::collections::HashSet<String>) -> Vec<String> {
  let mut providers: Vec<String> = models.iter().map(|m| opencode_model_provider(m).to_string()).collect();
  providers.sort_unstable();
  providers.dedup();
  providers
}

/// Harness auth plus, for opencode skills with an explicit `model:`, a host `opencode models` check.
pub fn check_skill_host(harness: Harness, model: Option<&str>, probe: &OpencodeModelProbe) -> Result<(), String> {
  check_harness_host(harness)?;
  if harness == Harness::Opencode {
    if let Some(m) = model {
      probe.check_model(m)?;
    }
  }
  Ok(())
}

fn load_opencode_models_for(
  requested: &std::collections::HashSet<String>,
) -> Result<std::collections::HashSet<String>, String> {
  let mut all = std::collections::HashSet::new();
  for provider in opencode_providers_for_models(requested) {
    let output = std::process::Command::new("opencode")
      .args(["models", &provider])
      .output()
      .map_err(|e| format!("could not run `opencode models {provider}`: {e}"))?;
    if !output.status.success() {
      let stderr = String::from_utf8_lossy(&output.stderr);
      let detail = stderr.trim();
      let msg =
        if detail.is_empty() { format!("opencode models {provider} exited with an error") } else { detail.to_string() };
      return Err(msg);
    }
    all.extend(parse_opencode_models(&String::from_utf8_lossy(&output.stdout)));
  }
  Ok(all)
}

fn parse_opencode_models(stdout: &str) -> std::collections::HashSet<String> {
  stdout.lines().map(|line| line.trim()).filter(|line| !line.is_empty()).map(|line| line.to_string()).collect()
}

/// Host opencode paths for `scsh list --verbose` (real runs copy into the run clone first).
pub fn opencode_host_mounts() -> Vec<(String, String)> {
  opencode_host_mounts_from(
    std::env::var_os("XDG_DATA_HOME").as_deref(),
    std::env::var_os("XDG_CONFIG_HOME").as_deref(),
    std::env::var_os("HOME").as_deref(),
  )
}

pub fn opencode_host_mounts_from(
  xdg_data_home: Option<&OsStr>, xdg_config_home: Option<&OsStr>, home: Option<&OsStr>,
) -> Vec<(String, String)> {
  let mut out = Vec::new();
  if let Some(auth) = opencode_auth_in(xdg_data_home, home).filter(|p| p.is_file()) {
    out.push((auth.to_string_lossy().into_owned(), OPENCODE_AUTH_MOUNT.to_string()));
  }
  if let Some(cfg) = opencode_config_json_in(xdg_config_home, home) {
    out.push((cfg.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSON_MOUNT.to_string()));
  }
  if let Some(cfg) = opencode_config_jsonc_in(xdg_config_home, home) {
    out.push((cfg.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSONC_MOUNT.to_string()));
  }
  out
}

/// Bind-mount opencode auth/config copied into a run clone.
pub fn opencode_forward_mounts(forward_root: &Path) -> Vec<(String, String)> {
  let mut out = Vec::new();
  let auth = forward_root.join("xdg/opencode/auth.json");
  if auth.is_file() {
    out.push((auth.to_string_lossy().into_owned(), OPENCODE_AUTH_MOUNT.to_string()));
  }
  let json = forward_root.join("config/opencode/opencode.json");
  if json.is_file() {
    out.push((json.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSON_MOUNT.to_string()));
  }
  let jsonc = forward_root.join("config/opencode/opencode.jsonc");
  if jsonc.is_file() {
    out.push((jsonc.to_string_lossy().into_owned(), OPENCODE_CONFIG_JSONC_MOUNT.to_string()));
  }
  out
}

/// Volume mounts shown by `scsh list --verbose` (host paths; real runs use the same bind-mounts).
/// Claude, codex, grok, and cursor need no mounts: their auth/config is COPIED into the run
/// clone's gitignored `tmp/` (the images' `CLAUDE_CONFIG_DIR` / `CODEX_HOME` / `GROK_HOME` /
/// `CURSOR_CONFIG_DIR`), which rides along with the repo mount in both mount modes.
pub fn harness_volumes(harness: Harness) -> Vec<(String, String)> {
  match harness {
    Harness::Opencode => opencode_host_mounts(),
    Harness::Claude | Harness::Codex | Harness::Grok | Harness::Cursor => Vec::new(),
  }
}

/// Render an argv as a copy-pasteable shell command (for `scsh list --verbose`).
pub fn shell_join(args: &[String]) -> String {
  args.iter().map(|a| shell_quote(a)).collect::<Vec<_>>().join(" ")
}

fn shell_quote(s: &str) -> String {
  let safe = !s.is_empty()
    && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '=' | '+'));
  if safe {
    s.to_string()
  } else {
    format!("'{}'", s.replace('\'', r"'\''"))
  }
}

// ---------------------------------------------------------------------------
// UTC timestamps and the /tmp run-dir / backup names
// ---------------------------------------------------------------------------

/// Convert a count of days since 1970-01-01 to a `(year, month, day)` triple in
/// the proleptic Gregorian calendar — Howard Hinnant's `civil_from_days`. This
/// is what lets scsh format a UTC timestamp with only the standard library.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
  let z = z + 719_468;
  let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
  let doe = z - era * 146_097; // [0, 146096]
  let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
  let y = yoe + era * 400;
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
  let mp = (5 * doy + 2) / 153; // [0, 11]
  let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
  let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
  (if m <= 2 { y + 1 } else { y }, m, d)
}

/// Format Unix `epoch_secs` as a UTC `YYYYMMDD-HHMMSS` stamp (no separators
/// beyond the dash), matching scsh's run-dir and backup naming convention.
pub fn format_utc_timestamp(epoch_secs: u64) -> String {
  let days = (epoch_secs / 86_400) as i64;
  let tod = epoch_secs % 86_400;
  let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
  let (y, m, d) = civil_from_days(days);
  format!("{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}")
}

/// Apple Containers (and Docker's normative pattern) cap container IDs at 64 characters.
pub const CONTAINER_ID_MAX_LEN: usize = 64;

/// Six lowercase `[a-z]` letters — the Apple-container run-dir stamp in place of UTC time.
pub fn random_nonce_6() -> String {
  let mut buf = [0u8; 6];
  let filled =
    std::fs::File::open("/dev/urandom").and_then(|mut f| std::io::Read::read_exact(&mut f, &mut buf)).is_ok();
  if !filled {
    let nanos =
      std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) as u64;
    let seed = nanos ^ ((std::process::id() as u64) << 32);
    for (i, b) in buf.iter_mut().enumerate() {
      *b = ((seed.wrapping_mul(1_103_515_245).wrapping_add(i as u64)) % 26) as u8;
    }
  }
  buf.iter().map(|b| (b'a' + (b % 26)) as char).collect()
}

/// Shorten `s` to at most `max_len` by keeping the start and end with `..` in the middle.
pub fn truncate_middle(s: &str, max_len: usize) -> String {
  if max_len == 0 {
    return String::new();
  }
  if s.len() <= max_len {
    return s.to_string();
  }
  if max_len <= 2 {
    return s.chars().take(max_len).collect();
  }
  let keep = max_len - 2;
  let first_k = keep.div_ceil(2);
  let last_n = keep / 2;
  let bytes = s.as_bytes();
  let first = &s[..first_k];
  let last = std::str::from_utf8(&bytes[bytes.len() - last_n..]).unwrap_or("");
  format!("{first}..{last}")
}

fn apple_container_run_dir_name_with_nonce(skill: &str, nonce: &str) -> String {
  let prefix = format!("scsh-{nonce}-run-");
  let budget = CONTAINER_ID_MAX_LEN.saturating_sub(prefix.len());
  let skill_part = truncate_middle(skill, budget);
  format!("{prefix}{skill_part}")
}

/// Whether `name` looks like a per-run scratch dir under `/tmp` (UTC stamp or Apple nonce).
pub fn is_scsh_run_dir_name(name: &str) -> bool {
  if !name.starts_with("scsh-") {
    return false;
  }
  if name.contains("-utc-run-") {
    return true;
  }
  let rest = match name.strip_prefix("scsh-") {
    Some(r) => r,
    None => return false,
  };
  let (nonce, _) = match rest.split_once("-run-") {
    Some(pair) => pair,
    None => return false,
  };
  nonce.len() == 6 && nonce.chars().all(|c| c.is_ascii_lowercase())
}

/// Name of the per-run scratch directory created under `/tmp`.
///
/// Docker/podman: `scsh-YYYYMMDD-HHMMSS-utc-run-<skill>`.
/// Apple `container`: `scsh-<nonce>-run-<skill>` (≤ [`CONTAINER_ID_MAX_LEN`] chars; the skill
/// segment is middle-truncated with `..` when needed).
pub fn run_dir_name(epoch_secs: u64, skill: &str, runtime: &str) -> String {
  let skill = sanitize_component(skill);
  if runtime == "container" {
    apple_container_run_dir_name_with_nonce(&skill, &random_nonce_6())
  } else {
    format!("scsh-{}-utc-run-{}", format_utc_timestamp(epoch_secs), skill)
  }
}

/// Name an existing file is moved to before scsh overwrites it with a fresh
/// result: `<name>.bak.YYYYMMDD-HHMMSS-utc`.
pub fn backup_name(file_name: &str, epoch_secs: u64) -> String {
  format!("{file_name}.bak.{}-utc", format_utc_timestamp(epoch_secs))
}

/// Sanitize a skill name into a filesystem-safe path component (lowercased,
/// non-`[a-z0-9._-]` mapped to `-`, edges trimmed). Empty input becomes `skill`.
/// Also used for the `scsh/incoming/<skill>-…` branch names (the same charset is a
/// valid git ref component).
pub fn sanitize_component(s: &str) -> String {
  let mapped: String = s
    .chars()
    .map(|c| {
      let c = c.to_ascii_lowercase();
      if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
        c
      } else {
        '-'
      }
    })
    .collect();
  let trimmed = mapped.trim_matches(|c| matches!(c, '.' | '_' | '-'));
  if trimmed.is_empty() {
    "skill".to_string()
  } else {
    trimmed.to_string()
  }
}

// ---------------------------------------------------------------------------
// Repo sync: host push IN, host fetch OUT (never GitHub from inside the container)
// ---------------------------------------------------------------------------

/// Bare repo directory name under a run dir — host `git push` target for push IN.
pub const TRANSPORT_BARE: &str = "transport.git";

/// Bare repo directory name under a run dir — container `git push` target for pull OUT.
pub const PULL_BARE: &str = "pull.git";

/// Container env: optional override for the host address of the per-run `git daemon`.
/// When unset, the container entry resolves it from `ip route` (default gateway).
pub const GIT_TRANSPORT_HOST_ENV: &str = "SCSH_GIT_HOST";

/// Container env: port for the per-run `git daemon`.
pub const GIT_TRANSPORT_PORT_ENV: &str = "SCSH_GIT_PORT";

/// Shell snippet run inside the container: host IP for the per-run git daemon.
/// Uses the container's default-route gateway (vmnet bridge on Apple Container).
/// `SCSH_GIT_HOST` overrides when set.
pub const GIT_TRANSPORT_HOST_SHELL: &str =
  "host=${SCSH_GIT_HOST:-$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')}";

/// Shell guard: fail fast when the gateway cannot be determined.
pub const GIT_TRANSPORT_HOST_GUARD: &str =
  "[ -n \"$host\" ] || { echo \"scsh: could not determine host gateway for git transport (set SCSH_GIT_HOST)\" >&2; exit 1; }";

/// Whether scsh moves git state via local push/fetch + git daemon instead of bind-mounting
/// `.git` across macOS→Linux (Apple Container). On macOS Apple Container this is always
/// enabled — bind-mounting `.git` corrupts objects. Elsewhere override with `SCSH_GIT_TRANSPORT=0|1`.
pub fn uses_git_transport(runtime: &str) -> bool {
  if cfg!(target_os = "macos") && runtime == "container" {
    return true;
  }
  match std::env::var("SCSH_GIT_TRANSPORT").ok().as_deref() {
    Some("0") | Some("false") => false,
    Some("1") | Some("true") => true,
    _ => false,
  }
}

/// Pick a free TCP port on all interfaces for a short-lived `git daemon`.
pub fn pick_ephemeral_port() -> Result<u16, String> {
  use std::net::TcpListener;
  let listener = TcpListener::bind("0.0.0.0:0").map_err(|e| format!("could not bind an ephemeral port: {e}"))?;
  listener.local_addr().map(|a| a.port()).map_err(|e| format!("could not read ephemeral port: {e}"))
}

/// `git clone` argv: host-side push IN when bind-mounting (Linux host → Linux container).
pub fn clone_command(src: &str, dst: &str) -> Vec<String> {
  vec!["git".into(), "clone".into(), src.into(), dst.into()]
}

/// `git fsck` argv: verify clone integrity after host-side push IN / clone.
pub fn fsck_command(repo: &str) -> Vec<String> {
  vec!["git".into(), "-C".into(), repo.into(), "fsck".into(), "--no-progress".into()]
}

/// Create an empty bare repository at `path` (parent dirs created as needed).
pub fn init_bare_repo(path: &Path) -> Result<(), String> {
  if path.is_dir() {
    return Ok(());
  }
  if let Some(parent) = path.parent() {
    std::fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?;
  }
  use std::process::Command;
  Command::new("git")
    .args(["init", "--bare"])
    .arg(path)
    .stdout(std::process::Stdio::null())
    .stderr(std::process::Stdio::null())
    .status()
    .map(|s| s.success())
    .unwrap_or(false)
    .then_some(())
    .ok_or_else(|| format!("git init --bare failed for {}", path.display()))
}

/// Host push IN: mirror every local `refs/heads/*` branch into the bare transport repo.
/// Code-review prep uses `git branch -f main <base>`; pushing heads (not stale
/// `refs/remotes/origin/*`) ensures `origin/main..HEAD` resolves inside the container.
pub fn push_transport_refs(root: &Path, bare: &Path) -> Result<(), String> {
  init_bare_repo(bare)?;
  let bare_s = bare.to_string_lossy();
  let Some(heads) = git_stdout(root, &["for-each-ref", "--format=%(refname)", "refs/heads"]) else {
    return Err(format!("could not read local branches in {}", root.display()));
  };
  let mut pushed = false;
  for line in heads.lines() {
    let refname = line.trim();
    if refname.is_empty() {
      continue;
    }
    let spec = format!("{refname}:{refname}");
    if !git_ok(root, &["push", "--quiet", &bare_s, &spec]) {
      return Err(format!("git push {refname} to {} failed", bare.display()));
    }
    pushed = true;
  }
  if !pushed {
    return Err(format!("no local branches to push from {}", root.display()));
  }
  if let Some(branch) = git_stdout(root, &["rev-parse", "--abbrev-ref", "HEAD"]) {
    let branch = branch.trim();
    if !branch.is_empty() && branch != "HEAD" {
      let head_ref = format!("refs/heads/{branch}");
      if !git_bare_ok(bare, &["symbolic-ref", "HEAD", &head_ref]) {
        return Err(format!("could not set HEAD on {}", bare.display()));
      }
    }
  }
  Ok(())
}

/// Path scsh fetches commits from after a run: the run clone, or `pull.git` when git transport
/// moved the repo only inside the container.
pub fn commits_fetch_path(run_dir: &Path) -> PathBuf {
  let pull = run_dir.join(PULL_BARE);
  if pull.is_dir() {
    pull
  } else {
    run_dir.to_path_buf()
  }
}

/// Shell wrapper run inside the container before the harness: clone from the host git daemon,
/// materialize `origin/*` locals, optionally set commit identity, run the harness, optionally
/// push commits back to the host bare `pull.git`.
pub fn git_transport_entry(harness: &str, push_commits: bool, commit_name: &str, commit_email: &str) -> String {
  let mut script = format!(
    "set -e\n\
     {host_shell}\n\
     {host_guard}\n\
     git clone \"git://${{host}}:${{{port}}}/transport.git\" /home/agent/.scsh-clone\n\
     (cd /home/agent/.scsh-clone && tar -cf - .) | (cd {repo} && tar -xf -)\n\
     rm -rf /home/agent/.scsh-clone\n\
     cd {repo}\n\
     git rev-parse --verify origin/main >/dev/null 2>&1 || {{ echo \"scsh: origin/main missing after git transport clone (point local main at the review base)\" >&2; exit 1; }}\n\
     cur=$(git rev-parse --abbrev-ref HEAD)\n\
     for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do\n\
       branch=${{ref#origin/}}\n\
       [ \"$branch\" = HEAD ] && continue\n\
       [ \"$branch\" = \"$cur\" ] && continue\n\
       git branch --force \"$branch\" \"origin/$branch\" >/dev/null 2>&1 || true\n\
     done\n",
    host_shell = GIT_TRANSPORT_HOST_SHELL,
    host_guard = GIT_TRANSPORT_HOST_GUARD,
    port = GIT_TRANSPORT_PORT_ENV,
    repo = AGENT_REPO,
  );
  if push_commits {
    script.push_str(&format!(
      "git config user.email {}\ngit config user.name {}\n",
      shell_quote(commit_email),
      shell_quote(commit_name),
    ));
  }
  script.push_str(harness);
  if push_commits {
    script.push_str("\ngit push \"git://${host}:${SCSH_GIT_PORT}/pull.git\" HEAD");
  }
  script
}

fn git_ok(dir: &Path, args: &[&str]) -> bool {
  use std::process::Command;
  Command::new("git")
    .arg("-C")
    .arg(dir)
    .args(args)
    .stdout(std::process::Stdio::null())
    .stderr(std::process::Stdio::null())
    .status()
    .map(|s| s.success())
    .unwrap_or(false)
}

fn git_bare_ok(bare: &Path, args: &[&str]) -> bool {
  use std::process::Command;
  Command::new("git")
    .arg("--git-dir")
    .arg(bare)
    .args(args)
    .stdout(std::process::Stdio::null())
    .stderr(std::process::Stdio::null())
    .status()
    .map(|s| s.success())
    .unwrap_or(false)
}

fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
  use std::process::Command;
  let out = Command::new("git").arg("-C").arg(dir).args(args).output().ok()?;
  out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}

/// Given the lines of `git for-each-ref --format='%(refname:short)'
/// refs/remotes/origin` and the clone's current branch, return the local branch
/// names to create so every remote branch becomes a local one. `origin/HEAD`
/// (the symbolic default pointer) and the already-checked-out branch are skipped.
pub fn local_branches_to_create(for_each_ref: &str, current_branch: &str) -> Vec<String> {
  let mut out = Vec::new();
  for line in for_each_ref.lines() {
    let line = line.trim();
    let branch = match line.strip_prefix("origin/") {
      Some(b) => b,
      None => continue,
    };
    if branch == "HEAD" || branch == current_branch || branch.is_empty() {
      continue;
    }
    if !out.iter().any(|b: &String| b == branch) {
      out.push(branch.to_string());
    }
  }
  out
}

#[cfg(test)]
mod tests {
  use super::*;
  use std::ffi::OsString;
  use std::sync::atomic::{AtomicUsize, Ordering};

  static COUNTER: AtomicUsize = AtomicUsize::new(0);

  fn tmp(tag: &str) -> PathBuf {
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
    let mut p = std::env::temp_dir();
    p.push(format!("scsh-ut-{tag}-{}-{nanos}-{n}", std::process::id()));
    std::fs::create_dir_all(&p).unwrap();
    p
  }

  #[cfg(unix)]
  fn make_exec(p: &Path) {
    use std::os::unix::fs::PermissionsExt;
    std::fs::write(p, "#!/bin/sh\n").unwrap();
    let mut perms = std::fs::metadata(p).unwrap().permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(p, perms).unwrap();
  }

  #[test]
  fn candidates_depend_on_os() {
    assert_eq!(runtime_candidates(true), &["container", "docker", "podman"]);
    assert_eq!(runtime_candidates(false), &["docker", "podman"]);
  }

  #[cfg(unix)]
  #[test]
  fn which_in_finds_executable_only() {
    let d = tmp("which");
    let exe = d.join("mytool");
    make_exec(&exe);
    let plain = d.join("notexe");
    std::fs::write(&plain, "data").unwrap();
    let path = OsString::from(d.to_str().unwrap());
    assert_eq!(which_in("mytool", &path), Some(exe));
    assert_eq!(which_in("notexe", &path), None);
    assert_eq!(which_in("missing", &path), None);
  }

  #[cfg(unix)]
  #[test]
  fn detect_prefers_docker_on_linux() {
    let d = tmp("detect-linux");
    make_exec(&d.join("docker"));
    make_exec(&d.join("podman"));
    let path = OsString::from(d.to_str().unwrap());
    assert_eq!(detect_runtime_in(false, &path).unwrap().name, "docker");
  }

  #[cfg(unix)]
  #[test]
  fn detect_falls_back_to_podman() {
    let d = tmp("detect-podman");
    make_exec(&d.join("podman"));
    let path = OsString::from(d.to_str().unwrap());
    assert_eq!(detect_runtime_in(false, &path).unwrap().name, "podman");
  }

  #[cfg(unix)]
  #[test]
  fn detect_prefers_apple_container_on_macos() {
    let d = tmp("detect-macos");
    make_exec(&d.join("container"));
    make_exec(&d.join("docker"));
    let path = OsString::from(d.to_str().unwrap());
    assert_eq!(detect_runtime_in(true, &path).unwrap().name, "container");
  }

  #[cfg(unix)]
  #[test]
  fn detect_none_when_empty() {
    let d = tmp("detect-empty");
    let path = OsString::from(d.to_str().unwrap());
    assert!(detect_runtime_in(false, &path).is_none());
  }

  #[test]
  fn snap_confined_paths_are_detected() {
    assert!(is_snap_confined(Path::new("/snap/bin/docker")));
    assert!(!is_snap_confined(Path::new("/usr/bin/docker")));
    assert!(!is_snap_confined(Path::new("/usr/local/bin/podman")));
  }

  #[cfg(unix)]
  #[test]
  fn detect_skips_snap_docker_for_another_runtime() {
    let d = tmp("detect-confined");
    std::fs::create_dir_all(d.join("snap/bin")).unwrap();
    std::fs::create_dir_all(d.join("bin")).unwrap();
    make_exec(&d.join("snap/bin/docker")); // snap-confined docker, first on PATH
    make_exec(&d.join("bin/podman"));
    let path = OsString::from(format!("{}:{}", d.join("snap/bin").display(), d.join("bin").display()));
    // docker is preferred by order but snap-confined → podman wins.
    assert_eq!(detect_runtime_in(false, &path).unwrap().name, "podman");
    // ...but a snap docker is still better than nothing when it's the only runtime.
    let only = OsString::from(d.join("snap/bin").to_str().unwrap());
    assert_eq!(detect_runtime_in(false, &only).unwrap().name, "docker");
  }

  #[test]
  fn image_tags_are_per_harness() {
    assert_eq!(image_tag(Harness::Opencode), "scsh-opencode:latest");
    assert_eq!(image_tag(Harness::Claude), "scsh-claude:latest");
    assert_eq!(image_tag(Harness::Codex), "scsh-codex:latest");
    assert_eq!(image_tag(Harness::Grok), "scsh-grok:latest");
    assert_eq!(image_tag(Harness::Cursor), "scsh-cursor:latest");
  }

  #[test]
  fn dockerfile_has_shared_base_and_harness_targets() {
    let df = dockerfile();
    assert!(df.contains("FROM debian:bookworm-slim AS scsh-base"));
    assert!(df.contains("FROM scsh-base AS scsh-opencode"));
    assert!(df.contains("FROM scsh-base AS scsh-claude"));
    assert!(df.contains("FROM scsh-base AS scsh-codex"));
    assert!(df.contains("npm install -g opencode-ai"));
    assert!(df.contains("npm install -g @anthropic-ai/claude-code"));
    assert!(df.contains("npm install -g @openai/codex"));
    assert!(!df.contains("CMD ["));
    assert!(df.contains("ENV SCSH_RUN_LOG=/home/agent/repo/tmp/scsh-run.log"));
    assert!(df.contains("ENV SCSH=1"));
  }

  #[test]
  fn dockerfile_codex_stage_points_codex_home_into_repo_tmp() {
    let df = dockerfile();
    assert!(df.contains("ENV CODEX_HOME=/home/agent/repo/tmp/.codex"));
    assert!(df.contains("codex --version"));
  }

  #[test]
  fn dockerfile_grok_stage_points_grok_home_into_repo_tmp() {
    let df = dockerfile();
    assert!(df.contains("FROM scsh-base AS scsh-grok"));
    assert!(df.contains("npm install -g @xai-official/grok"));
    assert!(df.contains("ENV GROK_HOME=/home/agent/repo/tmp/.grok"));
    assert!(df.contains("grok --version"));
  }

  #[test]
  fn dockerfile_cursor_stage_points_cursor_home_into_repo_tmp() {
    let df = dockerfile();
    assert!(df.contains("FROM scsh-base AS scsh-cursor"));
    assert!(df.contains("downloads.cursor.com/lab/"));
    assert!(df.contains("ENV CURSOR_AGENT_HOME=/usr/local/share/cursor-agent"));
    assert!(df.contains("mv \"$tmp/dist-package\" \"$CURSOR_AGENT_HOME\""));
    assert!(df.contains("ENV CURSOR_CONFIG_DIR=/home/agent/repo/tmp/.cursor"));
    assert!(df.contains("ENV XDG_CONFIG_HOME=/home/agent/repo/tmp/.config"));
    assert!(df.contains("cursor-agent --version"));
  }

  #[test]
  fn dockerfile_opencode_stage_has_unattended_env() {
    let df = dockerfile();
    assert!(df.contains("ENV OPENCODE_YOLO=true"));
    assert!(df.contains("opencode --version"));
  }

  #[test]
  fn dockerfile_claude_stage_verifies_cli() {
    assert!(dockerfile().contains("claude --version"));
  }

  #[test]
  fn dockerfile_tui_recorder_relaunches_and_quits_gracefully() {
    let df = dockerfile();
    // A harness that dies without its result file is relaunched in the same pane (a stray
    // SIGTERM has killed cursor-agent ~1.5s in); a harness that ignores its quit keys is
    // waited on and re-asked before the session is force-killed, so `.exit` still gets written.
    assert!(df.contains("pane_relaunch="), "relaunch loop missing from scsh-tui-record");
    assert!(df.contains(r#"[ -f \"$result\" ] || [ \$n -ge 2 ]"#), "relaunch stop conditions missing");
    assert!(df.contains("re-send $quit"), "graceful quit re-send missing");
    assert!(df.contains("killed session (harness ignored quit)"), "force-kill fallback missing");
  }

  #[test]
  fn dockerfile_bakes_the_toolchain_and_excludes_java() {
    let df = dockerfile();
    for tool in [
      "python3",
      "python3-venv",
      "perl",
      "gawk",
      "build-essential",
      "pkg-config",
      "cmake",
      "jq",
      "sqlite3",
      "postgresql-client",
      "protobuf-compiler",
      "shellcheck",
      "git-lfs",
      "openssh-client",
      "iputils-ping",
      "traceroute",
      "netcat-openbsd",
      "astral-sh/uv",
      "mikefarah/yq",
      "dl.k8s.io",
      "cli.github.com",
      "go.dev/dl",
      "sh.rustup.rs",
      "awscli-exe-linux",
      "google-cloud-cli",
    ] {
      assert!(df.contains(tool), "image should install {tool}");
    }
    // Java is deliberately NOT installed (see the README).
    let lower = df.to_lowercase();
    assert!(!lower.contains("openjdk") && !lower.contains("-jdk") && !lower.contains("-jre"), "no Java by design");
    // UTF-8 locale + the builder host's timezone (a build arg).
    assert!(df.contains("ENV LANG=C.UTF-8"));
    assert!(df.contains("ARG TZ=UTC") && df.contains("ENV TZ=${TZ}"));
    // The Go/Rust toolchains the agent uses are on PATH.
    assert!(df.contains("/usr/local/go/bin") && df.contains("/usr/local/cargo/bin"));
  }

  #[test]
  fn dockerfile_is_platform_agnostic() {
    let df = dockerfile();
    // Every architecture-specific layer resolves the target arch at build time rather than
    // hardcoding one — uv/yq/kubectl/gh, Go, AWS, and gcloud each detect it.
    assert!(
      df.matches("dpkg --print-architecture").count() >= 4,
      "each arch-specific layer must resolve arch at build time"
    );
    // Both architecture families are mapped (Debian arch + the vendors' arch spellings).
    for token in ["amd64", "arm64", "x86_64", "aarch64"] {
      assert!(df.contains(token), "arch mapping must cover {token}");
    }
    // gcloud's arm tarball is spelled `-arm`, not `-arm64`.
    assert!(df.contains("google-cloud-cli-linux-${gclarch}"), "gcloud download must be arch-parameterized");
    // No download URL may pin a single architecture.
    for bad in [
      "uv-x86_64-unknown-linux-gnu",
      "yq_linux_amd64",
      "linux/amd64/kubectl",
      "linux-amd64.tar.gz",
      "awscli-exe-linux-x86_64.zip",
      "google-cloud-cli-linux-x86_64.tar.gz",
    ] {
      assert!(!df.contains(bad), "download URL must not hardcode an architecture: {bad}");
    }
  }

  #[test]
  fn dockerfile_matches_the_path_constants() {
    // The embedded Dockerfile is the source of truth, but it must stay consistent with the
    // Rust-side constants other code uses (the repo mount/WORKDIR, the XDG data dir scsh drops
    // the credential into, and the per-run log path).
    let df = dockerfile();
    assert!(df.contains(&format!("WORKDIR {AGENT_REPO}")), "WORKDIR must match AGENT_REPO");
    assert!(
      df.contains(&format!("ENV XDG_DATA_HOME={AGENT_REPO}/{AGENT_XDG_DATA_REL}")),
      "XDG_DATA_HOME must match AGENT_REPO/AGENT_XDG_DATA_REL"
    );
    assert!(
      df.contains(&format!("ENV {RUN_LOG_VAR}={AGENT_REPO}/{RUN_LOG_REL}")),
      "Dockerfile run-log ENV must match RUN_LOG_VAR and RUN_LOG_REL"
    );
  }

  #[test]
  fn dockerfile_keeps_home_separate_from_the_repo_mount() {
    // The repo is mounted at /home/agent/repo while $HOME stays /home/agent, so the harness's
    // home-dir scratch (caches/config) never lands in the cloned repo's working tree.
    let df = dockerfile();
    assert!(df.contains("ENV HOME=/home/agent"), "HOME must be the agent's home");
    assert!(df.contains(&format!("WORKDIR {AGENT_REPO}")));
    assert_ne!("/home/agent", AGENT_REPO, "the mount must not be the home dir");
    assert!(AGENT_REPO.starts_with("/home/agent/"), "the repo mount lives under the home dir");
    // The forwarded credential and the run log both live under the gitignored tmp/.
    assert!(AGENT_XDG_DATA_REL.starts_with("tmp/") && RUN_LOG_REL.starts_with("tmp/"));
  }

  #[test]
  fn dockerfile_creates_agent_user_and_runs_as_it() {
    let df = dockerfile();
    assert!(df.contains("ARG AGENT_UID=1000"));
    assert!(df.contains("ARG AGENT_GID=1000"));
    assert!(df.contains("WORKDIR /home/agent/repo"));
    assert!(df.contains("\nUSER agent\n"));
    // The agent user is created before the image switches to it.
    let agent_at = df.find("-d /home/agent").expect("agent-user layer");
    let user_at = df.find("\nUSER agent\n").expect("a USER layer");
    assert!(agent_at < user_at, "the agent user must exist before USER agent");
  }

  #[test]
  fn harness_command_builds_opencode_invocation() {
    let cmd = harness_command_verbose(
      Harness::Opencode,
      Some("openai/gpt-5.5"),
      None,
      "add",
      "tmp/add.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains("scsh: harness=opencode"));
    assert!(cmd.contains("opencode --print-logs --log-level DEBUG"));
    assert!(cmd.contains("-m openai/gpt-5.5"));
    assert!(cmd.contains(" run "));
    assert!(cmd.contains("run skill add"));
    assert!(cmd.contains("SCSH_RESULT"));
    assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
    let cmd = harness_command_verbose(
      Harness::Opencode,
      None,
      None,
      "multiply",
      "tmp/mul.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains("opencode --print-logs --log-level DEBUG run "));
    let quiet = harness_command_verbose(
      Harness::Opencode,
      None,
      None,
      "multiply",
      "tmp/mul.json",
      false,
      crate::config::Terminal::default(),
    );
    assert!(!quiet.contains("--print-logs"));
    assert!(quiet.contains("scsh: harness=opencode"));
    assert!(quiet.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
  }

  #[test]
  fn harness_command_builds_claude_invocation() {
    let cmd = harness_command_verbose(
      Harness::Claude,
      Some("sonnet"),
      None,
      "add",
      "tmp/add_claude_sonnet_4_6_result.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains(".skills/add/SKILL.md"));
    // Interactive TUI recorded via scsh-tui-record (no inline shell, no screen-scraping).
    // bypassPermissions enables every tool; its consent screen is suppressed by the minimal
    // forwarded .claude.json (host-side), not by any flag here.
    assert!(cmd.contains("scsh-tui-record 200 50 slash-exit tmp/add_claude_sonnet_4_6_result.json "), "got: {cmd}");
    assert!(cmd.contains("claude --permission-mode bypassPermissions --model sonnet"), "got: {cmd}");
    assert!(!cmd.contains("--settings"), "got: {cmd}");
    assert!(!cmd.contains("claude -p"), "got: {cmd}");
    assert!(!cmd.contains("capture-pane"), "got: {cmd}");
    assert!(!cmd.contains("send-keys"), "got: {cmd}");
    assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""), "got: {cmd}");
  }

  #[test]
  fn harness_command_builds_codex_invocation() {
    let cmd = harness_command_verbose(
      Harness::Codex,
      Some("gpt-5.5"),
      None,
      "add",
      "tmp/add_codex_result.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains("scsh: harness=codex"));
    // Interactive TUI (no `exec` subcommand) via scsh-tui-record. Folder-trust is seeded
    // host-side into the forwarded config.toml (not in the command), so no in-command seed.
    assert!(cmd.contains("scsh-tui-record 200 50 double-ctrl-c tmp/add_codex_result.json "), "got: {cmd}");
    assert!(cmd.contains("codex --dangerously-bypass-approvals-and-sandbox"), "got: {cmd}");
    assert!(!cmd.contains("codex exec"), "got: {cmd}");
    assert!(cmd.contains(" -m gpt-5.5"));
    assert!(!cmd.contains("config.toml"), "got: {cmd}");
    assert!(!cmd.contains("capture-pane"), "got: {cmd}");
    assert!(cmd.contains(".skills/add/SKILL.md"));
    assert!(cmd.contains("SCSH_RESULT"));
    assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
    let quiet = harness_command_verbose(
      Harness::Codex,
      None,
      None,
      "multiply",
      "tmp/mul.json",
      false,
      crate::config::Terminal::default(),
    );
    assert!(quiet.contains("codex --dangerously-bypass-approvals-and-sandbox"));
    assert!(!quiet.contains(" -m "));
    assert!(quiet.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
  }

  #[test]
  fn harness_command_builds_grok_invocation() {
    let cmd = harness_command_verbose(
      Harness::Grok,
      Some("grok-build"),
      Some("high"),
      "add",
      "tmp/add_grok.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains("scsh: harness=grok"));
    assert!(cmd.contains("grok -p "));
    assert!(cmd.contains(" --permission-mode bypassPermissions --always-approve"));
    assert!(cmd.contains(" -m grok-build"));
    assert!(cmd.contains(" --effort high"));
    assert!(cmd.contains(" --debug --debug-file \"${SCSH_RUN_LOG}.debug\""));
    assert!(cmd.contains("scsh: --- grok debug log ---"));
    assert!(cmd.contains(".skills/add/SKILL.md"));
    assert!(cmd.contains("SCSH_RESULT"));
    assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
    let quiet = harness_command_verbose(
      Harness::Grok,
      None,
      None,
      "multiply",
      "tmp/mul.json",
      false,
      crate::config::Terminal::default(),
    );
    assert!(quiet.contains("grok -p "));
    assert!(!quiet.contains("--debug"));
    assert!(!quiet.contains(" --effort "));
    assert!(!quiet.contains(" -m "));
  }

  #[test]
  fn harness_command_builds_cursor_invocation() {
    let cmd = harness_command_verbose(
      Harness::Cursor,
      Some("composer-2.5"),
      Some("high"),
      "add",
      "tmp/add_cursor.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains("scsh: harness=cursor"));
    // Interactive TUI via scsh-tui-record. Workspace trust is pre-seeded by creating
    // cursor's marker file in-container (no flag/config key exists), not by scraping.
    assert!(cmd.contains("scsh-tui-record 200 50 double-ctrl-c tmp/add_cursor.json "), "got: {cmd}");
    assert!(cmd.contains("cursor-agent --force --sandbox disabled"), "got: {cmd}");
    assert!(!cmd.contains("cursor-agent -p"), "got: {cmd}");
    assert!(!cmd.contains("--trust"), "got: {cmd}");
    assert!(cmd.contains(" --model composer-2.5-fast"));
    assert!(cmd.contains(".cursor/projects/home-agent-repo/.workspace-trusted"), "got: {cmd}");
    assert!(!cmd.contains("capture-pane"), "got: {cmd}");
    assert!(!cmd.contains("send-keys"), "got: {cmd}");
    assert!(cmd.contains(".skills/add/SKILL.md"));
    assert!(cmd.ends_with("2>&1 | tee \"${SCSH_RUN_LOG}\""));
    let quiet = harness_command_verbose(
      Harness::Cursor,
      None,
      None,
      "multiply",
      "tmp/mul.json",
      false,
      crate::config::Terminal::default(),
    );
    assert!(quiet.contains("cursor-agent --force --sandbox disabled"));
    assert!(!quiet.contains(" --model "));
  }

  #[test]
  fn cursor_model_with_effort_maps_to_cursor_agent_slugs() {
    assert_eq!(cursor_model_with_effort("claude-opus-4-8[effort=low]", Some("high")), "claude-opus-4-8[effort=low]");
    assert_eq!(cursor_model_with_effort("composer-2.5", Some("high")), "composer-2.5-fast");
    assert_eq!(cursor_model_with_effort("composer-2.5", Some("low")), "composer-2.5");
    assert_eq!(cursor_model_with_effort("claude-opus-4-8", Some("low")), "claude-opus-4-8-low");
    assert_eq!(cursor_model_with_effort("gpt-5.5", Some("xhigh")), "gpt-5.5-extra-high");
  }

  #[test]
  fn harness_recorded_at_configured_pty_size() {
    // TUI harnesses (claude/codex/cursor) record via scsh-tui-record with the PTY size as
    // its first two args; the recording path is always ${SCSH_RUN_LOG}.cast.
    let term = crate::config::Terminal { cols: 120, rows: 30 };
    for h in [Harness::Claude, Harness::Codex, Harness::Cursor] {
      let cmd = harness_command_verbose(h, Some("m"), None, "add", "tmp/add.json", false, term);
      assert!(cmd.contains("scsh-tui-record 120 30 "), "harness {h:?} got: {cmd}");
      assert!(cmd.contains("cast=${SCSH_RUN_LOG}.cast"), "harness {h:?} got: {cmd}");
    }
    // Headless harnesses (opencode/grok) record inline via asciinema at the same size.
    for h in [Harness::Opencode, Harness::Grok] {
      let cmd = harness_command_verbose(h, None, None, "add", "tmp/add.json", false, term);
      assert!(cmd.contains("asciinema rec -q --cols 120 --rows 30 -c "), "harness {h:?} got: {cmd}");
    }
  }

  #[test]
  fn harness_command_codex_passes_reasoning_effort() {
    let cmd = harness_command_verbose(
      Harness::Codex,
      Some("gpt-5.5"),
      Some("xhigh"),
      "add",
      "tmp/add.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(cmd.contains(" -c model_reasoning_effort=xhigh"));
    let without = harness_command_verbose(
      Harness::Codex,
      Some("gpt-5.5"),
      None,
      "add",
      "tmp/add.json",
      true,
      crate::config::Terminal::default(),
    );
    assert!(!without.contains("model_reasoning_effort"));
  }

  #[test]
  fn harness_container_env_depends_on_verbosity() {
    assert_eq!(harness_container_env_verbose(Harness::Opencode, true).len(), 1);
    assert_eq!(harness_container_env_verbose(Harness::Opencode, false).len(), 1);
    assert_eq!(harness_container_env_verbose(Harness::Claude, true).len(), 2);
    assert!(harness_container_env_verbose(Harness::Claude, false).is_empty());
    let codex = harness_container_env_verbose(Harness::Codex, true);
    assert_eq!(codex.len(), 1);
    assert_eq!(codex[0].0, "RUST_LOG");
    assert!(harness_container_env_verbose(Harness::Codex, false).is_empty());
    let cursor = harness_container_env_verbose(Harness::Cursor, false);
    assert_eq!(cursor.len(), 2);
    assert_eq!(cursor[0].0, "CURSOR_CONFIG_DIR");
    assert_eq!(cursor[1].0, "XDG_CONFIG_HOME");
  }

  #[test]
  fn harness_verbose_disabled_when_scsh_quiet() {
    let key = "SCSH_QUIET";
    let prev = std::env::var_os(key);
    std::env::set_var(key, "1");
    assert!(!harness_verbose_enabled());
    match prev {
      Some(v) => std::env::set_var(key, v),
      None => std::env::remove_var(key),
    }
  }

  #[test]
  fn build_method_depends_on_runtime() {
    assert_eq!(build_method("container"), BuildMethod::ContextDir);
    assert_eq!(build_method("docker"), BuildMethod::Stdin);
    assert_eq!(build_method("podman"), BuildMethod::Stdin);
  }

  #[test]
  fn bake_definition_json_lists_every_target() {
    let df = dockerfile();
    let specs = vec![
      image_build_spec(Harness::Opencode, &df, 501, 20, "UTC"),
      image_build_spec(Harness::Claude, &df, 501, 20, "UTC"),
    ];
    let json = bake_definition_json("/tmp/ctx", &specs, 501, 20, "UTC");
    assert!(json.contains("\"scsh-opencode\""));
    assert!(json.contains("\"scsh-claude\""));
    assert!(json.contains("\"scsh-opencode:latest\""));
    assert!(json.contains("\"scsh-claude:latest\""));
    assert!(json.contains("\"/tmp/ctx\""));
  }

  #[test]
  fn build_command_bake_names_each_target() {
    let cmd = build_command_bake("docker", &["scsh-opencode".into(), "scsh-claude".into()]);
    let want: Vec<String> = vec![
      "docker".into(),
      "buildx".into(),
      "bake".into(),
      "--load".into(),
      "-f".into(),
      "-".into(),
      "scsh-opencode".into(),
      "scsh-claude".into(),
    ];
    assert_eq!(cmd, want);
  }

  #[test]
  fn base_image_fingerprint_matches_scsh_base_target() {
    let df = dockerfile();
    assert_eq!(
      base_image_fingerprint(&df, 501, 20, "UTC"),
      image_build_fingerprint(&df, BASE_IMAGE_TARGET, 501, 20, "UTC")
    );
    assert_ne!(base_image_fingerprint(&df, 501, 20, "UTC"), image_build_fingerprint(&df, "scsh-codex", 501, 20, "UTC"));
  }

  #[test]
  fn image_build_fingerprint_is_stable_and_target_specific() {
    let df = dockerfile();
    let a = image_build_fingerprint(&df, "scsh-opencode", 501, 20, "UTC");
    let b = image_build_fingerprint(&df, "scsh-opencode", 501, 20, "UTC");
    let c = image_build_fingerprint(&df, "scsh-claude", 501, 20, "UTC");
    assert_eq!(a, b);
    assert_ne!(a, c);
    assert_eq!(a.len(), 64);
  }

  #[test]
  fn parse_label_from_container_inspect_json() {
    let json =
      r#"{"variants":[{"config":{"config":{"Labels":{"scsh.generated":"true","scsh.build.fingerprint":"abc123"}}}}}]"#;
    assert_eq!(parse_label_from_container_inspect(json, BUILD_FINGERPRINT_LABEL).as_deref(), Some("abc123"));
    assert!(parse_label_from_container_inspect(json, "missing").is_none());
  }

  #[test]
  fn commands_have_expected_shape() {
    let fp = image_build_fingerprint("FROM scratch", "scsh-opencode", 1006, 1007, "Europe/Berlin");
    let label = format!("{BUILD_FINGERPRINT_LABEL}={fp}");
    assert_eq!(
      build_command_stdin("docker", "scsh-opencode:latest", "scsh-opencode", 1006, 1007, "Europe/Berlin", &fp),
      vec![
        "docker".into(),
        "build".into(),
        "-t".into(),
        "scsh-opencode:latest".into(),
        "--target".into(),
        "scsh-opencode".into(),
        "--build-arg".into(),
        "AGENT_UID=1006".into(),
        "--build-arg".into(),
        "AGENT_GID=1007".into(),
        "--build-arg".into(),
        "TZ=Europe/Berlin".into(),
        "--label".into(),
        label,
        "-".into(),
      ]
    );
    assert_eq!(
      run_command(
        "docker",
        "scsh-opencode:latest",
        "/tmp/run",
        "run-s",
        &[],
        &[],
        "opencode run 'run skill s'",
        RepoMountMode::Full,
      ),
      vec![
        "docker",
        "run",
        "--rm",
        "--name",
        "run-s",
        "-v",
        "/tmp/run:/home/agent/repo",
        "scsh-opencode:latest",
        "/bin/sh",
        "-c",
        "opencode run 'run skill s'"
      ]
    );
    assert_eq!(
      run_command(
        "container",
        "scsh-opencode:latest",
        "/tmp/run",
        "run-s",
        &[],
        &[],
        "git clone",
        RepoMountMode::TmpOnly,
      ),
      vec![
        "container",
        "run",
        "--rm",
        "--name",
        "run-s",
        "-v",
        "/tmp/run/tmp:/home/agent/repo/tmp",
        "scsh-opencode:latest",
        "/bin/sh",
        "-c",
        "git clone"
      ]
    );
    assert_eq!(
      run_command(
        "podman",
        "scsh-claude:latest",
        "/tmp/run",
        "run-s",
        &[],
        &[("/home/u/.claude", "/home/agent/.claude:ro")],
        "claude -p hi",
        RepoMountMode::Full,
      ),
      vec![
        "podman",
        "run",
        "--rm",
        "--name",
        "run-s",
        "--userns=keep-id",
        "-v",
        "/home/u/.claude:/home/agent/.claude:ro",
        "-v",
        "/tmp/run:/home/agent/repo",
        "scsh-claude:latest",
        "/bin/sh",
        "-c",
        "claude -p hi"
      ]
    );
    assert_eq!(
      run_command(
        "docker",
        "scsh-opencode:latest",
        "/tmp/run",
        "run-s",
        &[],
        &[("/home/u/.local/share/opencode/auth.json", OPENCODE_AUTH_MOUNT)],
        "opencode run 'run skill s'",
        RepoMountMode::Full,
      ),
      vec![
        "docker",
        "run",
        "--rm",
        "--name",
        "run-s",
        "-v",
        "/home/u/.local/share/opencode/auth.json:/home/agent/repo/tmp/.xdg-data/opencode/auth.json",
        "-v",
        "/tmp/run:/home/agent/repo",
        "scsh-opencode:latest",
        "/bin/sh",
        "-c",
        "opencode run 'run skill s'"
      ]
    );
  }

  #[test]
  fn opencode_forward_mounts_maps_copied_tree() {
    let base = std::env::temp_dir().join(format!("scsh-opencode-forward-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&base);
    std::fs::create_dir_all(base.join("xdg/opencode")).unwrap();
    std::fs::create_dir_all(base.join("config/opencode")).unwrap();
    std::fs::write(base.join("xdg/opencode/auth.json"), "{}").unwrap();
    std::fs::write(base.join("config/opencode/opencode.json"), "{}").unwrap();
    let mounts = opencode_forward_mounts(&base);
    assert_eq!(mounts.len(), 2);
    assert_eq!(mounts[0].1, OPENCODE_AUTH_MOUNT);
    assert_eq!(mounts[1].1, OPENCODE_CONFIG_JSON_MOUNT);
    let _ = std::fs::remove_dir_all(&base);
  }

  #[test]
  fn opencode_host_mounts_empty_when_nothing_on_host() {
    assert!(opencode_host_mounts_from(None, None, None).is_empty());
  }

  #[test]
  fn run_command_forwards_env_as_e_flags() {
    let env = vec![("A".to_string(), "20".to_string()), ("B".to_string(), "22".to_string())];
    assert_eq!(
      run_command(
        "docker",
        "scsh-opencode:latest",
        "/tmp/run",
        "run-s",
        &env,
        &[],
        "opencode run 'run skill s'",
        RepoMountMode::Full,
      ),
      vec![
        "docker",
        "run",
        "--rm",
        "--name",
        "run-s",
        "-e",
        "A=20",
        "-e",
        "B=22",
        "-v",
        "/tmp/run:/home/agent/repo",
        "scsh-opencode:latest",
        "/bin/sh",
        "-c",
        "opencode run 'run skill s'"
      ]
    );
  }

  #[test]
  fn clone_command_is_a_full_local_clone() {
    assert_eq!(clone_command("/repo", "/tmp/dst"), vec!["git", "clone", "/repo", "/tmp/dst"]);
  }

  #[test]
  fn fsck_command_checks_clone_integrity() {
    assert_eq!(fsck_command("/tmp/dst"), vec!["git", "-C", "/tmp/dst", "fsck", "--no-progress"]);
  }

  #[test]
  fn uses_git_transport_on_macos_apple_container_only() {
    let prev = std::env::var("SCSH_GIT_TRANSPORT").ok();
    std::env::remove_var("SCSH_GIT_TRANSPORT");
    if cfg!(target_os = "macos") {
      assert!(uses_git_transport("container"));
    } else {
      assert!(!uses_git_transport("container"));
    }
    assert!(!uses_git_transport("docker"));
    std::env::set_var("SCSH_GIT_TRANSPORT", "0");
    if cfg!(target_os = "macos") {
      assert!(uses_git_transport("container"), "Apple Container always uses git transport");
    } else {
      assert!(!uses_git_transport("container"));
    }
    std::env::set_var("SCSH_GIT_TRANSPORT", "1");
    assert!(uses_git_transport("docker"));
    match prev {
      Some(v) => std::env::set_var("SCSH_GIT_TRANSPORT", v),
      None => std::env::remove_var("SCSH_GIT_TRANSPORT"),
    }
  }

  #[test]
  fn push_transport_refs_maps_origin_branches_to_heads() {
    use std::process::Command;
    let tmp = std::env::temp_dir().join(format!("scsh-push-transport-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    std::fs::create_dir_all(&tmp).unwrap();
    let root = tmp.join("root");
    let bare = tmp.join("bare.git");
    Command::new("git").args(["init", "-q"]).arg(&root).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["config", "user.email", "t@example.com"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["config", "user.name", "t"]).status().unwrap();
    std::fs::write(root.join("f"), "x").unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "init"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["branch", "-M", "main"]).status().unwrap();
    Command::new("git")
      .args(["-C"])
      .arg(&root)
      .args(["remote", "add", "origin", "https://example.invalid/scsh.git"])
      .status()
      .unwrap();
    Command::new("git")
      .args(["-C"])
      .arg(&root)
      .args(["update-ref", "refs/remotes/origin/main", "HEAD"])
      .status()
      .unwrap();
    std::fs::write(root.join("f"), "y").unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "feature"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["checkout", "-q", "-b", "feature"]).status().unwrap();
    push_transport_refs(&root, &bare).unwrap();
    let show = Command::new("git").args(["-C"]).arg(&bare).args(["show-ref"]).output().unwrap();
    let refs = String::from_utf8_lossy(&show.stdout);
    assert!(refs.contains("refs/heads/main"), "expected refs/heads/main in bare, got:\n{refs}");
    assert!(refs.contains("refs/heads/feature"), "expected feature branch in bare, got:\n{refs}");
    assert!(!refs.contains("refs/remotes/origin/main"), "bare should not store remote-tracking refs:\n{refs}");
    let _ = std::fs::remove_dir_all(&tmp);
  }

  #[test]
  fn push_transport_refs_uses_local_main_not_stale_origin() {
    use std::process::Command;
    let tmp = std::env::temp_dir().join(format!("scsh-push-main-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    std::fs::create_dir_all(&tmp).unwrap();
    let root = tmp.join("root");
    let bare = tmp.join("bare.git");
    let work = tmp.join("work");
    Command::new("git").args(["init", "-q"]).arg(&root).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["config", "user.email", "t@example.com"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["config", "user.name", "t"]).status().unwrap();
    std::fs::write(root.join("f"), "stale").unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "stale"]).status().unwrap();
    let stale = Command::new("git").args(["-C"]).arg(&root).args(["rev-parse", "HEAD"]).output().unwrap();
    let stale = String::from_utf8_lossy(&stale.stdout).trim().to_string();
    std::fs::write(root.join("f"), "base").unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "base"]).status().unwrap();
    let base_sha = Command::new("git").args(["-C"]).arg(&root).args(["rev-parse", "HEAD"]).output().unwrap();
    let base_sha = String::from_utf8_lossy(&base_sha.stdout).trim().to_string();
    Command::new("git").args(["-C"]).arg(&root).args(["branch", "-M", "main"]).status().unwrap();
    Command::new("git")
      .args(["-C"])
      .arg(&root)
      .args(["remote", "add", "origin", "https://example.invalid/scsh.git"])
      .status()
      .unwrap();
    Command::new("git")
      .args(["-C"])
      .arg(&root)
      .args(["update-ref", "refs/remotes/origin/main", &stale])
      .status()
      .unwrap();
    std::fs::write(root.join("f"), "feature").unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["add", "f"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["commit", "-qm", "feature"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["checkout", "-q", "-b", "feature"]).status().unwrap();
    Command::new("git").args(["-C"]).arg(&root).args(["branch", "-f", "main", &base_sha]).status().unwrap();
    push_transport_refs(&root, &bare).unwrap();
    Command::new("git").args(["clone", "-q"]).arg(&bare).arg(&work).status().unwrap();
    let origin_main = Command::new("git").args(["-C"]).arg(&work).args(["rev-parse", "origin/main"]).output().unwrap();
    let origin_main = String::from_utf8_lossy(&origin_main.stdout).trim().to_string();
    assert_eq!(origin_main, base_sha, "origin/main must match force-updated local main");
    assert_ne!(origin_main, stale, "must not use stale refs/remotes/origin/main");
    let _ = std::fs::remove_dir_all(&tmp);
  }

  #[test]
  fn git_transport_entry_clones_before_harness() {
    let entry = git_transport_entry("echo hi", false, "bot", "bot@example.com");
    assert!(entry.contains("ip -4 route show default"));
    assert!(entry.contains("git clone"));
    assert!(entry.contains("transport.git"));
    assert!(entry.contains("origin/main missing after git transport clone"));
    assert!(entry.contains("echo hi"));
    assert!(!entry.contains("pull.git"));
    let entry = git_transport_entry("echo hi", true, "bot", "bot@example.com");
    assert!(entry.contains("pull.git"));
    assert!(entry.contains("user.email"));
  }

  #[test]
  fn opencode_model_provider_is_first_path_segment() {
    assert_eq!(opencode_model_provider("openai/gpt-5.5"), "openai");
    assert_eq!(opencode_model_provider("nebius-glm/zai-org/GLM-5.2"), "nebius-glm");
    assert_eq!(opencode_model_provider("standalone"), "standalone");
  }

  #[test]
  fn opencode_providers_for_models_dedupes_and_sorts() {
    let models = std::collections::HashSet::from([
      "openai/gpt-5.5".into(),
      "openai/gpt-5.4-mini-fast".into(),
      "nebius-glm/zai-org/GLM-5.2".into(),
    ]);
    assert_eq!(opencode_providers_for_models(&models), vec!["nebius-glm".to_string(), "openai".to_string()]);
  }

  #[test]
  fn requested_opencode_models_collects_explicit_models_from_selection() {
    let skills = vec![
      crate::config::ResolvedInvocation {
        name: "a".into(),
        skill_source: "add".into(),
        harness: Harness::Opencode,
        model: Some("openai/gpt-5.5".into()),
        effort: None,
        profile: None,
        commits: false,
        timeout: None,
        env: vec![],
        result: "tmp/a.json".into(),
        terminal: crate::config::Terminal::default(),
      },
      crate::config::ResolvedInvocation {
        name: "b".into(),
        skill_source: "add".into(),
        harness: Harness::Claude,
        model: Some("sonnet".into()),
        effort: None,
        profile: None,
        commits: false,
        timeout: None,
        env: vec![],
        result: "tmp/b.json".into(),
        terminal: crate::config::Terminal::default(),
      },
      crate::config::ResolvedInvocation {
        name: "c".into(),
        skill_source: "add".into(),
        harness: Harness::Opencode,
        model: None,
        effort: None,
        profile: None,
        commits: false,
        timeout: None,
        env: vec![],
        result: "tmp/c.json".into(),
        terminal: crate::config::Terminal::default(),
      },
    ];
    let set = requested_opencode_models(&skills);
    assert_eq!(set.len(), 1);
    assert!(set.contains("openai/gpt-5.5"));
  }

  #[test]
  fn parse_opencode_models_collects_trimmed_lines() {
    let set = parse_opencode_models("openai/gpt-5.5\n\nnebius-glm/zai-org/GLM-5.2\n");
    assert_eq!(set.len(), 2);
    assert!(set.contains("openai/gpt-5.5"));
    assert!(set.contains("nebius-glm/zai-org/GLM-5.2"));
  }

  #[test]
  fn opencode_model_probe_checks_listed_models() {
    let probe = OpencodeModelProbe { available: Some(std::collections::HashSet::from(["openai/gpt-5.5".into()])) };
    assert!(probe.check_model("openai/gpt-5.5").is_ok());
    let err = probe.check_model("openai/other").unwrap_err();
    assert!(err.contains("openai/other"));
    assert!(err.contains("opencode models"));
  }

  #[test]
  fn opencode_model_probe_skips_when_not_loaded() {
    let probe = OpencodeModelProbe { available: None };
    assert!(probe.check_model("any/model").is_ok());
  }

  #[test]
  fn opencode_model_probe_rejects_when_model_list_empty() {
    let probe = OpencodeModelProbe { available: Some(std::collections::HashSet::new()) };
    assert!(probe.check_model("openai/anything").is_err());
  }

  #[test]
  fn opencode_model_probe_for_selected_skips_without_explicit_models() {
    let skills = vec![crate::config::ResolvedInvocation {
      name: "add".into(),
      skill_source: "add".into(),
      harness: Harness::Opencode,
      model: None,
      effort: None,
      profile: None,
      commits: false,
      timeout: None,
      env: vec![],
      result: "tmp/add.json".into(),
      terminal: crate::config::Terminal::default(),
    }];
    let probe = OpencodeModelProbe::for_selected(&skills);
    assert!(probe.check_model("openai/anything").is_ok());
  }

  #[test]
  fn claude_container_auth_accepts_oauth_token_env() {
    let key = CLAUDE_OAUTH_TOKEN_ENV;
    let prev = std::env::var_os(key);
    std::env::set_var(key, "test-token");
    assert!(claude_container_auth_ready());
    match prev {
      Some(v) => std::env::set_var(key, v),
      None => std::env::remove_var(key),
    }
  }

  #[test]
  fn check_claude_harness_errors_without_token_or_credentials_file() {
    let key = CLAUDE_OAUTH_TOKEN_ENV;
    let prev = std::env::var_os(key);
    let prev_home = std::env::var_os("HOME");
    let empty_home = std::env::temp_dir().join(format!("scsh-empty-home-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&empty_home);
    std::fs::create_dir_all(&empty_home).unwrap();
    std::env::remove_var(key);
    std::env::set_var("HOME", &empty_home);
    let err = check_harness_host(Harness::Claude).unwrap_err();
    assert!(err.contains("CLAUDE_CODE_OAUTH_TOKEN"));
    assert!(err.contains("setup-token"));
    match prev {
      Some(v) => std::env::set_var(key, v),
      None => std::env::remove_var(key),
    }
    match prev_home {
      Some(v) => std::env::set_var("HOME", v),
      None => std::env::remove_var("HOME"),
    }
    let _ = std::fs::remove_dir_all(&empty_home);
  }

  #[test]
  fn utc_timestamp_formats_known_epochs() {
    assert_eq!(format_utc_timestamp(0), "19700101-000000");
    assert_eq!(format_utc_timestamp(1_700_000_000), "20231114-221320");
  }

  #[test]
  fn run_dir_and_backup_names() {
    assert_eq!(run_dir_name(1_700_000_000, "add", "docker"), "scsh-20231114-221320-utc-run-add");
    // skill names are sanitized for the filesystem.
    assert_eq!(run_dir_name(0, "My Skill!", "docker"), "scsh-19700101-000000-utc-run-my-skill");
    assert_eq!(backup_name("add_result.json", 1_700_000_000), "add_result.json.bak.20231114-221320-utc");
  }

  #[test]
  fn truncate_middle_keeps_ends() {
    assert_eq!(truncate_middle("abcdef", 6), "abcdef");
    assert_eq!(truncate_middle("abcdefgh", 6), "ab..gh");
    assert_eq!(truncate_middle("abcdefgh", 5), "ab..h");
  }

  #[test]
  fn apple_container_run_dir_fits_long_reviewer_names() {
    let skill = "reviewability-reviewer-opencode-glm-5.2";
    let name = apple_container_run_dir_name_with_nonce(skill, "abcdef");
    assert_eq!(name, "scsh-abcdef-run-reviewability-reviewer-opencode-glm-5.2");
    assert!(name.len() <= CONTAINER_ID_MAX_LEN);
    assert!(is_scsh_run_dir_name(&name));
  }

  #[test]
  fn apple_container_run_dir_middle_truncates_when_needed() {
    let skill = "a".repeat(80);
    let name = apple_container_run_dir_name_with_nonce(&skill, "abcdef");
    assert!(name.len() <= CONTAINER_ID_MAX_LEN);
    assert!(name.contains(".."));
    assert!(name.starts_with("scsh-abcdef-run-"));
    assert!(is_scsh_run_dir_name(&name));
  }

  #[test]
  fn is_scsh_run_dir_name_recognizes_both_formats() {
    assert!(is_scsh_run_dir_name("scsh-20231114-221320-utc-run-add"));
    assert!(is_scsh_run_dir_name("scsh-abcdef-run-add"));
    assert!(!is_scsh_run_dir_name("scsh-installskills-1-2"));
    assert!(!is_scsh_run_dir_name("scsh-abcdefg-run-add"));
  }

  #[test]
  fn branch_materialization_skips_head_and_current() {
    let refs = "origin/HEAD\norigin/main\norigin/feature-x\norigin/release\n";
    assert_eq!(local_branches_to_create(refs, "main"), vec!["feature-x", "release"]);
    // nothing to create when only HEAD and the current branch exist.
    assert!(local_branches_to_create("origin/HEAD\norigin/main\n", "main").is_empty());
  }

  #[test]
  fn shell_join_quotes_when_needed() {
    assert_eq!(shell_join(&["docker".into(), "build".into()]), "docker build");
    assert_eq!(shell_join(&["a b".into()]), "'a b'");
  }
}
