//! Crun OCI runtime command builder.
//!
//! This module provides a consistent interface for invoking crun commands
//! with the correct configuration (cgroup-manager, etc.).

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

use crate::paths;

/// Default PATH for container execution.
///
/// This is passed explicitly when using `crun exec --env` because crun doesn't
/// preserve the container's PATH for command lookup when custom env vars are set.
pub const DEFAULT_CONTAINER_PATH: &str =
    "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";

/// Ensure PATH is included in environment variables for crun exec.
///
/// When crun exec is called with `--env`, it doesn't search PATH for executables
/// unless PATH is explicitly set. This function ensures PATH is always present.
fn ensure_path_in_env(env: &[(String, String)]) -> Vec<(String, String)> {
    let has_path = env.iter().any(|(k, _)| k == "PATH");
    if has_path {
        env.to_vec()
    } else {
        let mut result = env.to_vec();
        result.push(("PATH".to_string(), DEFAULT_CONTAINER_PATH.to_string()));
        result
    }
}

/// Builder for crun commands with consistent configuration.
///
/// This ensures all crun invocations use the same cgroup-manager setting
/// and other common options.
pub struct CrunCommand {
    cmd: Command,
    /// Trailing positional arguments (e.g. the container id for `crun run`, or
    /// the container id followed by the command for `crun exec`). Appended at
    /// the very end in `spawn`/`output`/`status` so options added later (e.g.
    /// `--console-socket` via `console_socket()`) still land before them.
    pending_positionals: Vec<String>,
}

impl CrunCommand {
    /// Create a new crun command with standard configuration.
    ///
    /// Uses `--root` to store container state on the persistent storage disk
    /// instead of the default `/run/crun`, which may not be writable when the
    /// rootfs is an overlayfs with an initramfs lower layer.
    fn new() -> Self {
        Self::new_with_cgroup(paths::CRUN_CGROUP_MANAGER)
    }

    /// Like [`new`](Self::new) but with an explicit cgroup manager. Pods pass
    /// `"cgroupfs"` (after delegating a writable cgroup2) so crun creates a
    /// per-container cgroup and enforces resource limits; everything else uses
    /// the `disabled` default (libkrun mounts cgroup2 read-only by default).
    fn new_with_cgroup(manager: &str) -> Self {
        let mut cmd = Command::new(paths::CRUN_PATH);
        cmd.args(["--root", paths::CRUN_ROOT_DIR]);
        cmd.args(["--cgroup-manager", manager]);
        Self {
            cmd,
            pending_positionals: Vec::new(),
        }
    }

    /// Create a container: `crun create --bundle <path> <id>`
    ///
    /// This puts the container in "created" state, ready for `crun start`.
    /// Stdio defaults to null because capturing pipes can block when child
    /// processes inherit file descriptors.
    pub fn create(bundle_dir: &Path, container_id: &str) -> Self {
        let mut c = Self::new();
        c.cmd.args([
            "create",
            "--bundle",
            &bundle_dir.to_string_lossy(),
            container_id,
        ]);
        c.cmd.stdin(Stdio::null());
        c.cmd.stdout(Stdio::null());
        c.cmd.stderr(Stdio::null());
        c
    }

    /// Run a container: `crun run [options] --bundle <path> <id>`
    ///
    /// Creates, starts, waits, and deletes the container in one operation.
    /// The container id is deferred so later builder calls (e.g.
    /// `console_socket`) can still insert options before the positional.
    pub fn run(bundle_dir: &Path, container_id: &str) -> Self {
        Self::run_managed(bundle_dir, container_id, paths::CRUN_CGROUP_MANAGER)
    }

    /// [`run`](Self::run) with the cgroupfs manager, so crun creates a
    /// per-container cgroup and enforces the spec's resource limits (memory/
    /// pids → OOM). Only safe once cgroup2 is writable and delegated.
    pub fn run_cgroupfs(bundle_dir: &Path, container_id: &str) -> Self {
        Self::run_managed(bundle_dir, container_id, "cgroupfs")
    }

    fn run_managed(bundle_dir: &Path, container_id: &str, manager: &str) -> Self {
        let mut c = Self::new_with_cgroup(manager);
        c.cmd
            .args(["run", "--bundle", &bundle_dir.to_string_lossy()]);
        c.pending_positionals = vec![container_id.to_string()];
        c
    }

    /// Run a container with its console handed back over a socket:
    /// `crun run --bundle <path> --console-socket <sock> <id>`.
    ///
    /// crun creates the container's PTY and sends its master fd to `console_socket`
    /// (via SCM_RIGHTS), instead of relaying through crun's own stdio. This is the
    /// only way the agent can own the real console — and therefore drive window
    /// size / resize, which crun does not propagate in stdio-relay mode. crun's own
    /// stdio is unused, so it's nulled.
    pub fn run_with_console(bundle_dir: &Path, container_id: &str, console_socket: &Path) -> Self {
        Self::run_with_console_managed(
            bundle_dir,
            container_id,
            console_socket,
            paths::CRUN_CGROUP_MANAGER,
        )
    }

    /// [`run_with_console`](Self::run_with_console) with the cgroupfs manager
    /// (pod containers; see [`run_cgroupfs`](Self::run_cgroupfs)).
    pub fn run_with_console_cgroupfs(
        bundle_dir: &Path,
        container_id: &str,
        console_socket: &Path,
    ) -> Self {
        Self::run_with_console_managed(bundle_dir, container_id, console_socket, "cgroupfs")
    }

    fn run_with_console_managed(
        bundle_dir: &Path,
        container_id: &str,
        console_socket: &Path,
        manager: &str,
    ) -> Self {
        let mut c = Self::new_with_cgroup(manager);
        c.cmd.args([
            "run",
            "--bundle",
            &bundle_dir.to_string_lossy(),
            "--console-socket",
            &console_socket.to_string_lossy(),
            container_id,
        ]);
        c.cmd.stdin(Stdio::null());
        c.cmd.stdout(Stdio::null());
        // Pipe stderr so the caller can surface crun's reason if the console
        // handshake fails (and it falls back to a stdio PTY).
        c.cmd.stderr(Stdio::piped());
        c
    }

    /// `crun exec --tty --console-socket <sock> [--env ...] [--cwd <wd>] <id> <cmd...>`.
    /// The TTY counterpart to [`Self::exec`] that takes the console over a socket
    /// so the agent owns the real PTY master (for resize). crun's stdio is nulled.
    pub fn exec_with_console(
        container_id: &str,
        env: &[(String, String)],
        command: &[String],
        workdir: Option<&str>,
        console_socket: &Path,
    ) -> Self {
        let mut c = Self::new();
        c.cmd.arg("exec").arg("--tty");
        c.cmd.arg("--console-socket").arg(console_socket);
        let env_with_path = crate::cuda::augment_exec_env(ensure_path_in_env(env));
        for (key, value) in &env_with_path {
            c.cmd.arg("--env").arg(format!("{}={}", key, value));
        }
        if let Some(wd) = workdir {
            c.cmd.args(["--cwd", wd]);
        }
        // Defer the container id + command so `.user()` (and any later option)
        // can still insert flags before the positionals in `spawn`.
        c.pending_positionals = std::iter::once(container_id.to_string())
            .chain(command.iter().cloned())
            .collect();
        c.cmd.stdin(Stdio::null());
        c.cmd.stdout(Stdio::null());
        c.cmd.stderr(Stdio::piped());
        c
    }

    /// Run the exec/run as `--user UID[:GID]`. No-op when `user` is None. Used so
    /// `crun exec` honours the container's configured user (image `config.User` /
    /// CRI RunAsUser) — without it, exec defaults to uid 0.
    ///
    /// NOTE: crun's `--user` CLI flag accepts only a NUMERIC uid[:gid]; a username
    /// is rejected with "invalid USERSPEC specified" (unlike the OCI runtime
    /// spec's `process.user`, which `crun run` resolves). Callers must pass an
    /// already-resolved numeric spec — see `oci::resolve_exec_user_spec` (#632).
    /// Inserted before the deferred positional arguments.
    pub fn user(mut self, user: Option<&str>) -> Self {
        if let Some(u) = user.filter(|s| !s.is_empty()) {
            self.cmd.arg("--user").arg(u);
        }
        self
    }

    /// Start a container: `crun start <id>`
    pub fn start(container_id: &str) -> Self {
        let mut c = Self::new();
        c.cmd.args(["start", container_id]);
        c
    }

    /// Execute a command in a running container.
    ///
    /// Supports optional working directory and TTY allocation.
    /// Automatically ensures PATH is set if not provided, because crun doesn't
    /// search PATH for executables when `--env` is used.
    ///
    /// The container id and command are deferred (see `pending_positionals`) so
    /// later builder calls — notably `console_socket()` for the interactive TTY
    /// path — still insert their options before the positional arguments.
    pub fn exec(
        container_id: &str,
        env: &[(String, String)],
        command: &[String],
        workdir: Option<&str>,
        tty: bool,
    ) -> Self {
        let mut c = Self::new();
        c.cmd.arg("exec");
        if tty {
            c.cmd.arg("--tty");
        }
        // Ensure PATH is set for command lookup; forward CUDA zero-copy opt-in.
        let env_with_path = crate::cuda::augment_exec_env(ensure_path_in_env(env));
        for (key, value) in &env_with_path {
            c.cmd.arg("--env").arg(format!("{}={}", key, value));
        }
        if let Some(wd) = workdir {
            c.cmd.args(["--cwd", wd]);
        }
        c.pending_positionals = std::iter::once(container_id.to_string())
            .chain(command.iter().cloned())
            .collect();
        c
    }

    /// Execute a command DETACHED inside a running container:
    /// `crun exec --detach <id> <cmd…>`. crun starts the process in the
    /// container's namespaces and returns immediately; the process keeps
    /// running as a child of the container's PID 1 (the keep-alive `tail -f`)
    /// for the machine's lifetime — this is how a background exec (a dev
    /// server, an agent) survives across foreground execs, sharing the
    /// container view every other exec sees. Stdio is detached (no pipes to
    /// wait on), so the caller does not block.
    pub fn exec_detached(
        container_id: &str,
        env: &[(String, String)],
        command: &[String],
        workdir: Option<&str>,
        pid_file: Option<&Path>,
    ) -> Self {
        let mut c = Self::new();
        c.cmd.arg("exec").arg("--detach");
        if let Some(pf) = pid_file {
            // crun writes the detached process's PID here so the caller can
            // report it back (the run-background contract returns a PID).
            c.cmd.arg("--pid-file").arg(pf);
        }
        let env_with_path = crate::cuda::augment_exec_env(ensure_path_in_env(env));
        for (key, value) in &env_with_path {
            c.cmd.arg("--env").arg(format!("{}={}", key, value));
        }
        if let Some(wd) = workdir {
            c.cmd.args(["--cwd", wd]);
        }
        c.pending_positionals = std::iter::once(container_id.to_string())
            .chain(command.iter().cloned())
            .collect();
        c
    }

    /// Execute a command in a running container from a full OCI Process spec:
    /// `crun exec --process <file> <id>`. Unlike the flag-based [`Self::exec`],
    /// this honors every field of the process — notably `user.additionalGids`
    /// (CRI SupplementalGroups), which crun exec has no flag for. Non-TTY
    /// (piped) stdio; the process file must set `terminal: false`.
    pub fn exec_with_process(container_id: &str, process_file: &Path) -> Self {
        let mut c = Self::new();
        c.cmd.arg("exec").arg("--process").arg(process_file);
        c.pending_positionals = vec![container_id.to_string()];
        c
    }

    /// Kill a container: `crun kill <id> <signal>`
    pub fn kill(container_id: &str, signal: &str) -> Self {
        let mut c = Self::new();
        c.cmd.args(["kill", container_id, signal]);
        c
    }

    /// Kill every process in a container: `crun kill --all <id> <signal>`
    ///
    /// Used by the pod-container path (`PodSignal { all: true }`). May fail
    /// when no per-container cgroup exists (cgroup manager is disabled);
    /// callers fall back to signalling the process tree directly.
    pub fn kill_all(container_id: &str, signal: &str) -> Self {
        let mut c = Self::new();
        c.cmd.args(["kill", "--all", container_id, signal]);
        c
    }

    /// Delete a container: `crun delete [-f] <id>`
    pub fn delete(container_id: &str, force: bool) -> Self {
        let mut c = Self::new();
        if force {
            c.cmd.args(["delete", "-f", container_id]);
        } else {
            c.cmd.args(["delete", container_id]);
        }
        c
    }

    /// Get container state: `crun state <id>`
    pub fn state(container_id: &str) -> Self {
        let mut c = Self::new();
        c.cmd.args(["state", container_id]);
        c
    }

    /// Set stdin to null.
    pub fn stdin_null(mut self) -> Self {
        self.cmd.stdin(Stdio::null());
        self
    }

    /// Set stdin to piped.
    pub fn stdin_piped(mut self) -> Self {
        self.cmd.stdin(Stdio::piped());
        self
    }

    /// Set stdin from a raw fd (e.g., PTY slave).
    ///
    /// # Safety
    /// The fd must be a valid open file descriptor. Ownership is transferred.
    #[cfg(unix)]
    pub unsafe fn stdin_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
        use std::os::unix::io::FromRawFd;
        self.cmd.stdin(Stdio::from_raw_fd(fd));
        self
    }

    /// Set stdout from a raw fd (e.g., PTY slave).
    ///
    /// # Safety
    /// The fd must be a valid open file descriptor. Ownership is transferred.
    #[cfg(unix)]
    pub unsafe fn stdout_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
        use std::os::unix::io::FromRawFd;
        self.cmd.stdout(Stdio::from_raw_fd(fd));
        self
    }

    /// Set stderr from a raw fd (e.g., PTY slave).
    ///
    /// # Safety
    /// The fd must be a valid open file descriptor. Ownership is transferred.
    #[cfg(unix)]
    pub unsafe fn stderr_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
        use std::os::unix::io::FromRawFd;
        self.cmd.stderr(Stdio::from_raw_fd(fd));
        self
    }

    /// Capture stdout.
    pub fn stdout_piped(mut self) -> Self {
        self.cmd.stdout(Stdio::piped());
        self
    }

    /// Capture stderr.
    pub fn stderr_piped(mut self) -> Self {
        self.cmd.stderr(Stdio::piped());
        self
    }

    /// Capture both stdout and stderr.
    pub fn capture_output(self) -> Self {
        self.stdout_piped().stderr_piped()
    }

    /// Discard both stdout and stderr.
    pub fn discard_output(mut self) -> Self {
        self.cmd.stdout(Stdio::null());
        self.cmd.stderr(Stdio::null());
        self
    }

    /// Append any deferred positional arguments right before the command is
    /// launched, so options added by the caller (e.g. `--console-socket`) land
    /// before them.
    fn apply_pending(&mut self) {
        for arg in std::mem::take(&mut self.pending_positionals) {
            self.cmd.arg(arg);
        }
    }

    /// Spawn the command.
    pub fn spawn(mut self) -> std::io::Result<std::process::Child> {
        self.apply_pending();
        self.cmd.spawn()
    }

    /// Run and wait for output.
    pub fn output(mut self) -> std::io::Result<std::process::Output> {
        self.apply_pending();
        self.cmd.output()
    }

    /// Run and wait for status.
    pub fn status(mut self) -> std::io::Result<std::process::ExitStatus> {
        self.apply_pending();
        self.cmd.status()
    }
}

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

    #[test]
    fn test_default_container_path_value() {
        assert!(DEFAULT_CONTAINER_PATH.contains("/usr/bin"));
        assert!(DEFAULT_CONTAINER_PATH.contains("/bin"));
    }

    #[test]
    fn test_ensure_path_in_env_adds_path_when_missing() {
        let env = vec![("HOME".to_string(), "/root".to_string())];
        let result = ensure_path_in_env(&env);
        assert_eq!(result.len(), 2);
        assert!(result
            .iter()
            .any(|(k, v)| k == "PATH" && v == DEFAULT_CONTAINER_PATH));
    }

    #[test]
    fn test_ensure_path_in_env_preserves_existing_path() {
        let custom_path = "/custom/bin:/other/bin";
        let env = vec![("PATH".to_string(), custom_path.to_string())];
        let result = ensure_path_in_env(&env);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0], ("PATH".to_string(), custom_path.to_string()));
    }

    #[test]
    fn test_ensure_path_in_env_empty_input() {
        let env: Vec<(String, String)> = vec![];
        let result = ensure_path_in_env(&env);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].0, "PATH");
    }

    #[test]
    fn test_ensure_path_in_env_case_sensitive() {
        // "path" (lowercase) should not be treated as PATH
        let env = vec![("path".to_string(), "/lowercase".to_string())];
        let result = ensure_path_in_env(&env);
        assert_eq!(result.len(), 2);
        assert!(result.iter().any(|(k, _)| k == "PATH"));
    }
}
