//! Auto-install host runtimes (rust toolchain, node.js, ruby) via
//! Homebrew when they are missing. macOS / Linux only — the wider
//! install pipeline (`detect_platform`) refuses other OSes, so there is
//! no Windows path here.
//!
//! When `brew` itself is missing, hard-fail with the install URL — we
//! explicitly do NOT auto-install Homebrew (would re-introduce the
//! curl-pipe pattern this phase retires).
//!
//! ## Testability
//!
//! `RuntimeBootstrap::ensure` is parameterised over a [`BootstrapEnv`]
//! trait that abstracts the four side-effecting operations:
//! `has_command`, `run_status`, `run_output`, `prepend_path`,
//! `assert_online`. Production wires up `RealEnv`; unit tests inject a
//! `MockEnv` that records the call sequence and scripts responses. The
//! C-RUSTUP regression test pins the post-install-runs-before-final-check
//! invariant explicitly.

use std::cell::RefCell;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};

use super::installer::InstallCtx;
use super::prompt::{Decision, decide};

const BREW_INSTALL_URL: &str = "https://brew.sh";

/// Optional per-runtime post-install hook (e.g. `rustup-init -y` after
/// brew installs the initializer).
type PostInstall = fn(&InstallCtx<'_>, &dyn BootstrapEnv) -> Result<()>;

/// Side-effects `RuntimeBootstrap::ensure` needs from the OS. Production
/// uses [`RealEnv`]; tests use a mock that records calls.
pub(super) trait BootstrapEnv {
    fn has_command(&self, name: &str) -> bool;
    /// Run a subprocess, return whether it exited 0.
    fn run_status(&self, cmd: &str, args: &[&str]) -> Result<bool>;
    /// Run a subprocess, return (success, captured stdout bytes).
    fn run_output(&self, cmd: &str, args: &[&str]) -> Result<(bool, Vec<u8>)>;
    /// Record an additional directory to prepend to PATH for subsequent
    /// subprocess spawns within this bootstrap session. Does NOT mutate the
    /// process environment — the augmented PATH is passed per-spawn via
    /// `Command::env("PATH", ...)`.
    fn prepend_path(&self, dir: &Path);
    fn assert_online(&self, action: &str) -> Result<()>;
    /// Return the augmented PATH `OsString` for explicit per-spawn injection.
    /// Installers MUST pass this via `Command::env("PATH", env.augmented_path())`
    /// so keg-only brew binaries discovered during bootstrap remain reachable
    /// for every subsequent spawn within the same session (C2 fix).
    fn augmented_path(&self) -> OsString;
}

/// Production [`BootstrapEnv`]. Tracks extra PATH dirs in-process without
/// mutating the global process environment (`std::env::set_var` is UB after
/// any thread or async runtime is alive). Each `Command` spawn receives the
/// augmented PATH explicitly via `Command::env("PATH", ...)`.
///
/// Note: `!Sync` due to `RefCell`. `RealEnv` is single-threaded by design —
/// bootstrap is a sequential, CLI-bound operation. If a future refactor passes
/// `&RealEnv` across threads, the `RefCell` will panic at runtime; switch to
/// `Mutex<Vec<PathBuf>>` if concurrent access is ever needed.
pub(super) struct RealEnv {
    /// Extra directories prepended to PATH for subprocesses spawned during
    /// this bootstrap session (e.g. keg-only brew prefix bin dirs).
    extra_path_dirs: RefCell<Vec<PathBuf>>,
}

impl RealEnv {
    pub(super) fn new() -> Self {
        Self {
            extra_path_dirs: RefCell::new(Vec::new()),
        }
    }

    /// Build the augmented PATH `OsString` by prepending all recorded extra
    /// dirs to the current process PATH. Called per-spawn so the value is
    /// always up-to-date without touching the global env.
    fn build_augmented_path(&self) -> OsString {
        let dirs = self.extra_path_dirs.borrow();
        let base = std::env::var_os("PATH").unwrap_or_default();
        if dirs.is_empty() {
            return base;
        }
        let mut parts: Vec<OsString> = dirs.iter().map(|d| d.as_os_str().to_owned()).collect();
        parts.push(base);
        let sep = OsString::from(":");
        let mut out = OsString::new();
        for (i, part) in parts.into_iter().enumerate() {
            if i > 0 {
                out.push(&sep);
            }
            out.push(part);
        }
        out
    }
}

impl BootstrapEnv for RealEnv {
    fn has_command(&self, name: &str) -> bool {
        // Search across the augmented PATH, not just the process PATH, so
        // newly-added brew keg-only bin dirs are visible within the same
        // bootstrap session.
        let path = self.build_augmented_path();
        let path_str = path.to_string_lossy();
        path_str
            .split(':')
            .any(|dir| std::path::Path::new(dir).join(name).is_file())
            // Fall back to the standard which-based check for the unaugmented
            // PATH (handles shell builtins / PATH entries already in env).
            || ckg_fs::has_command(name)
    }
    fn run_status(&self, cmd: &str, args: &[&str]) -> Result<bool> {
        let status = Command::new(cmd)
            .args(args)
            .env("PATH", self.build_augmented_path())
            .status()
            .with_context(|| format!("spawning {cmd}"))?;
        Ok(status.success())
    }
    fn run_output(&self, cmd: &str, args: &[&str]) -> Result<(bool, Vec<u8>)> {
        let out = Command::new(cmd)
            .args(args)
            .env("PATH", self.build_augmented_path())
            .output()
            .with_context(|| format!("spawning {cmd}"))?;
        Ok((out.status.success(), out.stdout))
    }
    fn prepend_path(&self, dir: &Path) {
        let dir = dir.to_path_buf();
        let mut dirs = self.extra_path_dirs.borrow_mut();
        if !dirs.contains(&dir) {
            dirs.push(dir);
        }
    }
    fn assert_online(&self, action: &str) -> Result<()> {
        super::assert_online(action)
    }
    fn augmented_path(&self) -> OsString {
        self.build_augmented_path()
    }
}

/// Per-runtime bootstrap recipe. Constructors `for_rust` / `for_node` /
/// `for_ruby` build the right struct; callers run `.ensure(ctx)?`.
pub(super) struct RuntimeBootstrap {
    label: &'static str,
    binary: &'static str,
    brew_pkg: &'static str,
    /// Optional post-install hook (rust uses this for `rustup-init -y`).
    post_install: Option<PostInstall>,
}

impl RuntimeBootstrap {
    pub(super) fn for_rust() -> Self {
        Self {
            label: "rust toolchain",
            binary: "rustup",
            brew_pkg: "rustup-init",
            post_install: Some(post_install_rust),
        }
    }

    pub(super) fn for_node() -> Self {
        Self {
            label: "node.js",
            binary: "npm",
            brew_pkg: "node",
            post_install: None,
        }
    }

    pub(super) fn for_ruby() -> Self {
        Self {
            label: "ruby",
            binary: "gem",
            brew_pkg: "ruby",
            post_install: None,
        }
    }

    pub(super) fn ensure(&self, ctx: &InstallCtx<'_>) -> Result<()> {
        self.ensure_with(ctx, &RealEnv::new())
    }

    fn ensure_with(&self, ctx: &InstallCtx<'_>, env: &dyn BootstrapEnv) -> Result<()> {
        if env.has_command(self.binary) {
            return Ok(());
        }

        ensure_brew_on_path(self.label, env)?;

        if let Decision::Decline = decide(ctx.yes, self.label, self.brew_pkg)? {
            bail!(
                "cannot install scip without {} — re-run with `-y` or accept the prompt",
                self.label
            );
        }

        env.assert_online(&format!("install {} via brew", self.label))?;
        eprintln!("{}: brew install {}", self.label, self.brew_pkg);
        if !env.run_status("brew", &["install", self.brew_pkg])? {
            bail!("brew install {} failed", self.brew_pkg);
        }

        // Some brew packages are keg-only (e.g. `ruby` on macOS conflicts
        // with the system ruby) and don't symlink their bin into the
        // standard `<brew --prefix>/bin`. Ask brew for this package's
        // prefix and prepend `<prefix>/bin` to PATH. Harmless for
        // non-keg packages.
        expose_brew_pkg_bin(self.brew_pkg, env);

        // Post-install MUST run before the final `has_command` check:
        // for rust, brew installs `rustup-init` (the initializer) and
        // only the post-install hook then runs `rustup-init -y` to
        // produce the actual `rustup` driver in ~/.cargo/bin. Checking
        // for `rustup` before the hook would always bail.
        if let Some(hook) = self.post_install {
            hook(ctx, env)?;
        }

        if !env.has_command(self.binary) {
            bail!(
                "brew install of {} succeeded but `{}` not found on PATH — \
                 open a new shell{}",
                self.brew_pkg,
                self.binary,
                path_hint(self.binary)
            );
        }

        Ok(())
    }
}

/// Confirm `brew` is callable. If not on PATH but installed at a known
/// prefix, surface a precise hint instead of the generic install URL.
fn ensure_brew_on_path(label: &str, env: &dyn BootstrapEnv) -> Result<()> {
    if env.has_command("brew") {
        return Ok(());
    }
    for candidate in [
        "/opt/homebrew/bin/brew",
        "/usr/local/bin/brew",
        "/home/linuxbrew/.linuxbrew/bin/brew",
    ] {
        if Path::new(candidate).is_file() {
            bail!(
                "{label} not installed and `brew` exists at {candidate} but is not on PATH. \
                 Add it (e.g. `eval \"$({candidate} shellenv)\"`), then re-run."
            );
        }
    }
    bail!(
        "{label} not installed and `brew` not found on PATH. \
         Install Homebrew ({BREW_INSTALL_URL}), then re-run."
    );
}

fn path_hint(binary: &str) -> String {
    match binary {
        "rustup" => " or add `~/.cargo/bin` to PATH".into(),
        _ => " or add the brew prefix bin dir to PATH".into(),
    }
}

/// Run `brew --prefix <pkg>` and, if it returns a usable path, prepend
/// `<prefix>/bin` to this process's `$PATH`. Best-effort — silent on
/// failure so the caller's `has_command` check is the authoritative
/// signal.
fn expose_brew_pkg_bin(pkg: &str, env: &dyn BootstrapEnv) {
    let Ok((ok, out)) = env.run_output("brew", &["--prefix", pkg]) else {
        return;
    };
    if !ok {
        return;
    }
    let prefix = String::from_utf8_lossy(&out).trim().to_string();
    if prefix.is_empty() {
        return;
    }
    let bin = std::path::PathBuf::from(prefix).join("bin");
    env.prepend_path(&bin);
}

/// Post-install hook for the rust runtime: run `rustup-init -y
/// --no-modify-path --default-toolchain stable`. Skipped if `rustup` is
/// already on PATH.
///
/// `rustup component add rust-analyzer` lives in `rustup.rs::install` —
/// not here — so the user-has-rustup-already path (bootstrap's
/// early-return) still installs the component.
fn post_install_rust(_ctx: &InstallCtx<'_>, env: &dyn BootstrapEnv) -> Result<()> {
    if env.has_command("rustup") {
        return Ok(());
    }
    if !env.has_command("rustup-init") {
        bail!("rustup bootstrap finished but neither `rustup-init` nor `rustup` is on PATH");
    }
    eprintln!("rust toolchain: rustup-init -y --no-modify-path --default-toolchain stable");
    if !env.run_status(
        "rustup-init",
        &["-y", "--no-modify-path", "--default-toolchain", "stable"],
    )? {
        bail!("rustup-init failed");
    }
    // rustup installs into ~/.cargo/bin; surface it for this process so
    // the subsequent `rustup component add rust-analyzer` resolves.
    if let Some(home) = dirs::home_dir() {
        env.prepend_path(&home.join(".cargo").join("bin"));
    }
    Ok(())
}


#[cfg(test)]
#[path = "bootstrap_tests.rs"]
mod tests;
