//! Shim run logic with trait-based dependency injection.
//!
//! This module contains the main entry point for the shim, refactored to use
//! injected traits instead of direct `crate::` imports from `turborepo-lib`.

use std::{env, process, process::Stdio, sync::Arc, time::Duration};

use camino::Utf8PathBuf;
use dunce::canonicalize as fs_canonicalize;
use miette::Diagnostic;
use shared_child::SharedChild;
use thiserror::Error;
use tiny_gradient::{GradientStr, RGB};
use tracing::{debug, warn};
use turbo_updater::{UpdateCheckConfig, display_update_check};
use turbopath::AbsoluteSystemPathBuf;
use turborepo_repository::{
    inference::{RepoMode, RepoState},
    package_manager,
    package_manager::PackageManager,
};
use turborepo_ui::ColorConfig;
use which::which;

use crate::{
    ChildSpawner, ConfigProvider, ShimConfigurationOptions, TurboRunner, VersionProvider,
    local_turbo_config::LocalTurboConfig,
    local_turbo_state::{LocalTurboState, turbo_version_has_shim},
    parser::ShimArgs,
};

const TURBO_GLOBAL_WARNING_DISABLED: &str = "TURBO_GLOBAL_WARNING_DISABLED";
/// Set by the shim when running as global turbo so the CLI can
/// re-emit the warning through `turborepo_log` after logger init.
pub const GLOBAL_WARNING_ENV_VAR: &str = "__TURBO_GLOBAL_WARNING";

/// Environment variable name for the invocation directory.
/// This is set by the shim to communicate the original invocation directory
/// to the CLI when spawning local turbo or running the global turbo.
pub const INVOCATION_DIR_ENV_VAR: &str = "TURBO_INVOCATION_DIR";

/// Runtime container for injected shim dependencies.
///
/// This struct holds all the trait implementations needed by the shim to
/// execute. By using trait objects, we avoid circular dependencies with
/// `turborepo-lib`.
///
/// # Type Parameters
///
/// * `R` - Implementation of `TurboRunner` for running the CLI
/// * `C` - Implementation of `ConfigProvider` for loading configuration
/// * `S` - Implementation of `ChildSpawner` for spawning child processes
/// * `V` - Implementation of `VersionProvider` for getting the current version
pub struct ShimRuntime<R, C, S, V>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    /// The turbo runner implementation
    pub runner: R,
    /// The config provider implementation
    pub config_provider: C,
    /// The child spawner implementation
    pub child_spawner: S,
    /// The version provider implementation
    pub version_provider: V,
}

impl<R, C, S, V> ShimRuntime<R, C, S, V>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    /// Create a new ShimRuntime with the given implementations.
    pub fn new(runner: R, config_provider: C, child_spawner: S, version_provider: V) -> Self {
        Self {
            runner,
            config_provider,
            child_spawner,
            version_provider,
        }
    }
}

/// Errors that can occur during shim execution (excluding CLI errors).
#[derive(Debug, Error, Diagnostic)]
pub enum Error {
    /// Error from argument parsing
    #[error(transparent)]
    #[diagnostic(transparent)]
    Args(#[from] crate::parser::Error),

    /// Error from repository inference
    #[error(transparent)]
    Inference(#[from] turborepo_repository::inference::Error),

    /// Failed to execute local turbo process
    #[error("Failed to execute local `turbo` process.")]
    LocalTurboProcess(#[source] std::io::Error),

    /// Failed to resolve local turbo path
    #[error("Failed to resolve local `turbo` path: {0}")]
    LocalTurboPath(String),

    /// Failed to find npx
    #[error("Failed to find `npx`: {0}")]
    Which(#[from] which::Error),

    /// Failed to execute turbo via npx
    #[error("Failed to execute `turbo` via `npx`.")]
    NpxTurboProcess(#[source] std::io::Error),

    /// Failed to resolve repository root
    #[error("Failed to resolve repository root: {0}")]
    RepoRootPath(AbsoluteSystemPathBuf),

    /// Path error
    #[error(transparent)]
    Path(#[from] turbopath::PathError),
}

/// Result type for shim execution that distinguishes between shim errors and
/// CLI errors.
///
/// This allows the caller to handle CLI errors with their proper type,
/// preserving the full miette Diagnostic chain (including related errors).
pub enum ShimResult<E> {
    /// Successful execution with exit code
    Ok(i32),
    /// Shim-level error (arg parsing, inference, process spawning, etc.)
    ShimError(Error),
    /// CLI-level error (from the TurboRunner)
    CliError(E),
}

/// Main entry point for the shim with injected dependencies.
///
/// This function handles the complete shim logic including arg parsing:
/// 1. Normalizes environment variables
/// 2. Parses command-line arguments
/// 3. Sets up error reporting
/// 4. Determines whether to run local or global turbo
/// 5. Executes the appropriate turbo binary
///
/// # Arguments
///
/// * `runtime` - The runtime container with all injected dependencies
///
/// # Returns
///
/// A `ShimResult` containing either the exit code, a shim error, or a CLI
/// error.
pub fn run<R, C, S, V>(runtime: &ShimRuntime<R, C, S, V>) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    normalize_config_dir_env_vars();
    let args = match ShimArgs::parse() {
        Ok(args) => args,
        Err(e) => return ShimResult::ShimError(e.into()),
    };
    run_with_args(runtime, args)
}

/// Main entry point for the shim with injected dependencies and pre-parsed
/// args.
///
/// This function handles the complete shim logic:
/// 1. Sets up error reporting based on color configuration
/// 2. Determines whether to run local or global turbo
/// 3. Executes the appropriate turbo binary
///
/// # Arguments
///
/// * `runtime` - The runtime container with all injected dependencies
/// * `args` - Pre-parsed shim arguments
///
/// # Returns
///
/// A `ShimResult` containing either the exit code, a shim error, or a CLI
/// error.
#[tracing::instrument(skip_all)]
pub fn run_with_args<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    args: ShimArgs,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    let color_config = args.color_config();

    if color_config.should_strip_ansi {
        // Let's not crash just because we failed to set up the hook
        let _ = miette::set_hook(Box::new(|_| {
            Box::new(
                miette::MietteHandlerOpts::new()
                    .show_related_errors_as_nested()
                    .color(false)
                    .unicode(false)
                    .build(),
            )
        }));
    } else {
        let _ = miette::set_hook(Box::new(|_| {
            Box::new(
                miette::MietteHandlerOpts::new()
                    .show_related_errors_as_nested()
                    .build(),
            )
        }));
    }

    debug!(
        "Global turbo version: {}",
        runtime.version_provider.get_version()
    );

    // If skip_infer is passed, we're probably running local turbo with
    // global turbo having handled the inference. We can run without any
    // concerns.
    if args.skip_infer {
        return run_cli(runtime, None, color_config);
    }

    // If the TURBO_BINARY_PATH is set, we do inference but we do not use
    // it to execute local turbo. We simply use it to set the `--single-package`
    // and `--cwd` flags.
    if is_turbo_binary_path_set() {
        let repo_state = {
            let _span = tracing::info_span!("repo_inference").entered();
            match RepoState::infer(&args.cwd) {
                Ok(state) => state,
                Err(e) => return ShimResult::ShimError(e.into()),
            }
        };
        debug!("Repository Root: {}", repo_state.root);
        return run_cli(runtime, Some(repo_state), color_config);
    }

    let repo_result = {
        let _span = tracing::info_span!("repo_inference").entered();
        RepoState::infer(&args.cwd)
    };
    match repo_result {
        Ok(repo_state) => {
            debug!("Repository Root: {}", repo_state.root);
            run_correct_turbo(runtime, repo_state, args, color_config)
        }
        Err(err) => {
            // If we cannot infer, we still run global turbo. This allows for global
            // commands like login/logout/link/unlink to still work
            debug!("Repository inference failed: {}", err);
            debug!("Running command as global turbo");
            run_cli(runtime, None, color_config)
        }
    }
}

/// Helper to run the CLI through the runtime's runner.
fn run_cli<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    repo_state: Option<RepoState>,
    ui: ColorConfig,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    match runtime.runner.run(repo_state, ui) {
        Ok(code) => ShimResult::Ok(code),
        Err(e) => ShimResult::CliError(e),
    }
}

/// Attempts to run correct turbo by finding nearest package.json,
/// then finding local turbo installation. If the current binary is the
/// local turbo installation, then we run current turbo. Otherwise we
/// kick over to the local turbo installation.
fn run_correct_turbo<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    repo_state: RepoState,
    shim_args: ShimArgs,
    ui: ColorConfig,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    let package_manager = repo_state.package_manager.as_ref();

    if let Some(turbo_state) = LocalTurboState::infer(&repo_state.root) {
        let config = runtime
            .config_provider
            .get_config(&repo_state.root, shim_args.root_turbo_json.as_ref());

        try_check_for_updates(&shim_args, turbo_state.version(), &config, package_manager);

        if turbo_state.local_is_self() {
            unsafe {
                env::set_var(INVOCATION_DIR_ENV_VAR, shim_args.invocation_dir.as_path());
            }
            debug!("Currently running turbo is local turbo.");
            run_cli(runtime, Some(repo_state), ui)
        } else {
            spawn_local_turbo(runtime, &repo_state, turbo_state, shim_args)
        }
    } else if let Some(local_config) = LocalTurboConfig::infer(&repo_state) {
        debug!(
            "Found configuration for turbo version {}",
            local_config.turbo_version()
        );
        spawn_npx_turbo(
            runtime,
            &repo_state,
            local_config.turbo_version(),
            shim_args,
        )
    } else {
        let version = runtime.version_provider.get_version();
        let config = runtime
            .config_provider
            .get_config(&repo_state.root, shim_args.root_turbo_json.as_ref());
        try_check_for_updates(&shim_args, version, &config, package_manager);

        // cli::run checks for this env var, rather than an arg, so that we can support
        // calling old versions without passing unknown flags.
        unsafe {
            env::set_var(INVOCATION_DIR_ENV_VAR, shim_args.invocation_dir.as_path());
        }
        debug!("Running command as global turbo");

        let should_warn_on_global = env::var(TURBO_GLOBAL_WARNING_DISABLED)
            .map_or(true, |disable| !matches!(disable.as_str(), "1" | "true"));

        let declared_version = repo_state
            .root_package_json
            .dependencies
            .as_ref()
            .and_then(|deps| deps.get("turbo"))
            .or_else(|| {
                repo_state
                    .root_package_json
                    .dev_dependencies
                    .as_ref()
                    .and_then(|deps| deps.get("turbo"))
            });

        if should_warn_on_global {
            let message = if let Some(declared_version) = declared_version {
                format!(
                    "No locally installed `turbo` found in your repository. Using globally \
                     installed version ({version}), which can cause unexpected \
                     behavior.\n\nInstalling the version in your repository ({declared_version}) \
                     before calling `turbo` will result in more predictable behavior across \
                     environments."
                )
            } else {
                format!(
                    "No locally installed `turbo` found in your repository. Using globally \
                     installed version ({version}). Using a specified version in your repository \
                     will result in more predictable behavior."
                )
            };
            debug!(%message, "global turbo warning");
            unsafe {
                env::set_var(GLOBAL_WARNING_ENV_VAR, &message);
            }
        }
        run_cli(runtime, Some(repo_state), ui)
    }
}

fn spawn_local_turbo<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    repo_state: &RepoState,
    local_turbo_state: LocalTurboState,
    mut shim_args: ShimArgs,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    let local_turbo_path = match fs_canonicalize(local_turbo_state.binary()) {
        Ok(path) => path,
        Err(_) => {
            return ShimResult::ShimError(Error::LocalTurboPath(
                local_turbo_state.binary().to_string_lossy().to_string(),
            ));
        }
    };
    debug!(
        "Running local turbo binary in {}\n",
        local_turbo_path.display()
    );
    let cwd = match fs_canonicalize(&repo_state.root) {
        Ok(path) => path,
        Err(_) => return ShimResult::ShimError(Error::RepoRootPath(repo_state.root.clone())),
    };

    let raw_args = modify_args_for_local(&mut shim_args, repo_state, local_turbo_state.version());

    // We spawn a process that executes the local turbo
    // that we've found in node_modules/.bin/turbo.
    let mut command = process::Command::new(local_turbo_path);
    command
        .args(&raw_args)
        // rather than passing an argument that local turbo might not understand, set
        // an environment variable that can be optionally used
        .env(INVOCATION_DIR_ENV_VAR, shim_args.invocation_dir.as_path())
        .current_dir(cwd)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());

    spawn_child_turbo(runtime, command, Error::LocalTurboProcess)
}

fn spawn_npx_turbo<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    repo_state: &RepoState,
    turbo_version: &str,
    mut shim_args: ShimArgs,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    debug!("Running turbo@{turbo_version} via npx");
    let npx_path = match which("npx") {
        Ok(path) => path,
        Err(e) => return ShimResult::ShimError(e.into()),
    };
    let cwd = match fs_canonicalize(&repo_state.root) {
        Ok(path) => path,
        Err(_) => return ShimResult::ShimError(Error::RepoRootPath(repo_state.root.clone())),
    };

    let raw_args = modify_args_for_local(&mut shim_args, repo_state, turbo_version);

    let mut command = process::Command::new(npx_path);
    command.arg("-y");
    command.arg(format!("turbo@{turbo_version}"));

    // rather than passing an argument that local turbo might not understand, set
    // an environment variable that can be optionally used
    command
        .args(&raw_args)
        .env(INVOCATION_DIR_ENV_VAR, shim_args.invocation_dir.as_path())
        .current_dir(cwd)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());

    spawn_child_turbo(runtime, command, Error::NpxTurboProcess)
}

fn modify_args_for_local(
    shim_args: &mut ShimArgs,
    repo_state: &RepoState,
    local_version: &str,
) -> Vec<String> {
    let supports_skip_infer_and_single_package = turbo_version_has_shim(local_version);
    let already_has_single_package_flag = shim_args
        .remaining_turbo_args
        .contains(&"--single-package".to_string());
    let should_add_single_package_flag = repo_state.mode == RepoMode::SinglePackage
        && !already_has_single_package_flag
        && supports_skip_infer_and_single_package;

    debug!(
        "supports_skip_infer_and_single_package {:?}",
        supports_skip_infer_and_single_package
    );

    let mut raw_args: Vec<_> = if supports_skip_infer_and_single_package {
        vec!["--skip-infer".to_string()]
    } else {
        Vec::new()
    };

    raw_args.append(&mut shim_args.remaining_turbo_args);

    // We add this flag after the raw args to avoid accidentally passing it
    // as a global flag instead of as a run flag.
    if should_add_single_package_flag {
        raw_args.push("--single-package".to_string());
    }

    raw_args.push("--".to_string());
    raw_args.append(&mut shim_args.forwarded_args);

    raw_args
}

fn spawn_child_turbo<R, C, S, V>(
    runtime: &ShimRuntime<R, C, S, V>,
    command: process::Command,
    err: fn(std::io::Error) -> Error,
) -> ShimResult<R::Error>
where
    R: TurboRunner,
    C: ConfigProvider,
    S: ChildSpawner,
    V: VersionProvider,
{
    let child: Arc<SharedChild> = match runtime.child_spawner.spawn(command) {
        Ok(child) => child,
        Err(e) => return ShimResult::ShimError(err(e)),
    };

    // The child turbo process shares our process group and will receive
    // SIGINT directly from the kernel when the user presses Ctrl+C.
    // Ignore SIGINT in the shim *after* spawning so the child inherits
    // the default disposition (and can register its own handler), while
    // the shim stays alive to collect the child's exit status.
    #[cfg(unix)]
    unsafe {
        libc::signal(libc::SIGINT, libc::SIG_IGN);
    }

    let exit_status = match child.wait() {
        Ok(status) => status,
        Err(e) => return ShimResult::ShimError(err(e)),
    };
    let exit_code = exit_status.code().unwrap_or_else(|| {
        debug!("child turbo failed to report exit code");
        #[cfg(unix)]
        {
            use std::os::unix::process::ExitStatusExt;
            let signal = exit_status.signal();
            let core_dumped = exit_status.core_dumped();
            debug!(
                "child turbo caught signal {:?}. Core dumped? {}",
                signal, core_dumped
            );
        }
        2
    });

    ShimResult::Ok(exit_code)
}

/// Checks for `TURBO_BINARY_PATH` variable. If it is set,
/// we do not try to find local turbo, we simply run the command as
/// the current binary. This is due to legacy behavior of `TURBO_BINARY_PATH`
/// that lets users dynamically set the path of the turbo binary. Because
/// that conflicts with finding a local turbo installation and
/// executing that binary, these two features are fundamentally incompatible.
fn is_turbo_binary_path_set() -> bool {
    env::var("TURBO_BINARY_PATH").is_ok()
}

fn normalize_config_dir_env_vars() {
    // Normalize relative config dir env vars to absolute paths early in CLI startup
    for var in ["TURBO_CONFIG_DIR_PATH", "VERCEL_CONFIG_DIR_PATH"] {
        if let Ok(val) = env::var(var) {
            match turbopath::AbsoluteSystemPathBuf::new(val.as_str()) {
                Ok(_) => {
                    // already absolute, nothing to do
                }
                Err(turbopath::PathError::NotAbsolute(_)) => {
                    match turbopath::AbsoluteSystemPathBuf::from_cwd(Utf8PathBuf::from(val)) {
                        Ok(abs) => unsafe { env::set_var(var, abs.as_str()) },
                        Err(_) => {
                            // invalid value; leave as-is so downstream error
                            // handling can report it
                        }
                    }
                }
                Err(_) => {
                    // invalid value; leave as-is so downstream error handling
                    // can report it
                }
            }
        }
    }
}

fn try_check_for_updates(
    args: &ShimArgs,
    current_version: &str,
    config: &ShimConfigurationOptions,
    package_manager: Result<&PackageManager, &package_manager::Error>,
) {
    let package_manager = package_manager.unwrap_or(&PackageManager::Npm);

    if args.should_check_for_update() {
        // custom footer for update message
        let footer = format!(
            "Follow {username} for updates: {url}",
            username = "@turborepo".gradient([RGB::new(0, 153, 247), RGB::new(241, 23, 18)]),
            url = "https://x.com/turborepo"
        );

        let interval = if args.force_update_check {
            // force update check
            Some(Duration::ZERO)
        } else {
            // use default (24 hours)
            None
        };
        // check for updates
        let _ = display_update_check(UpdateCheckConfig {
            package_name: "turbo",
            github_repo: "https://github.com/vercel/turborepo",
            footer: Some(&footer),
            current_version,
            // use default for timeout (800ms)
            timeout: None,
            interval,
            package_manager,
            config_no_update: config.no_update_notifier(),
        });
    }
}

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

    // Mock implementations for testing
    struct MockRunner;
    impl TurboRunner for MockRunner {
        type Error = std::io::Error;
        fn run(
            &self,
            _repo_state: Option<RepoState>,
            _ui: ColorConfig,
        ) -> Result<i32, Self::Error> {
            Ok(0)
        }
    }

    struct MockConfigProvider;
    impl ConfigProvider for MockConfigProvider {
        fn get_config(
            &self,
            _root: &turbopath::AbsoluteSystemPath,
            _root_turbo_json: Option<&AbsoluteSystemPathBuf>,
        ) -> ShimConfigurationOptions {
            ShimConfigurationOptions::default()
        }
    }

    struct MockVersionProvider;
    impl VersionProvider for MockVersionProvider {
        fn get_version(&self) -> &'static str {
            "2.0.0"
        }
    }

    #[test]
    fn test_shim_runtime_creation() {
        let runtime = ShimRuntime::new(
            MockRunner,
            MockConfigProvider,
            DefaultChildSpawner,
            MockVersionProvider,
        );
        assert_eq!(runtime.version_provider.get_version(), "2.0.0");
    }

    #[test]
    fn test_is_turbo_binary_path_set() {
        // This tests the function when env var is not set
        unsafe {
            std::env::remove_var("TURBO_BINARY_PATH");
        }
        assert!(!is_turbo_binary_path_set());
    }
}
