use anyhow::{Context as _, Result, anyhow, bail};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use chrono::{DateTime, Utc};
use futures::{AsyncReadExt, FutureExt as _, channel::oneshot, future::Shared};
use http_client::{Host, HttpClient, Url};
use log::Level;
use semver::Version;
use serde::Deserialize;
use smol::io::BufReader;
use smol::{fs, lock::Mutex};
use std::collections::HashMap;
use std::fmt::Display;
use std::{
    env::{self, consts},
    ffi::OsString,
    io,
    net::{IpAddr, Ipv4Addr},
    path::{Path, PathBuf},
    process::Output,
    sync::Arc,
};
use util::ResultExt;
use util::archive::extract_zip;

const NODE_CA_CERTS_ENV_VAR: &str = "NODE_EXTRA_CA_CERTS";

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct NodeBinaryOptions {
    pub allow_path_lookup: bool,
    pub allow_binary_download: bool,
    pub use_paths: Option<(PathBuf, PathBuf)>,
}

/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
#[derive(Clone, Debug)]
pub struct NpmCommand {
    pub path: PathBuf,
    pub args: Vec<String>,
    pub env: HashMap<String, String>,
}

pub enum VersionStrategy<'a> {
    /// Install if current version doesn't match pinned version
    Pin(&'a Version),
    /// Install if current version is older than latest version
    Latest(&'a Version),
}

#[derive(Clone)]
pub struct NodeRuntime(Arc<Mutex<NodeRuntimeState>>);

struct NodeRuntimeState {
    http: Arc<dyn HttpClient>,
    instance: Option<Box<dyn NodeRuntimeTrait>>,
    last_options: Option<NodeBinaryOptions>,
    options: watch::Receiver<Option<NodeBinaryOptions>>,
    shell_env_loaded: Shared<oneshot::Receiver<()>>,
}

impl NodeRuntime {
    pub fn new(
        http: Arc<dyn HttpClient>,
        shell_env_loaded: Option<oneshot::Receiver<()>>,
        options: watch::Receiver<Option<NodeBinaryOptions>>,
    ) -> Self {
        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
            http,
            instance: None,
            last_options: None,
            options,
            shell_env_loaded: shell_env_loaded.unwrap_or(oneshot::channel().1).shared(),
        })))
    }

    pub fn unavailable() -> Self {
        NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
            http: Arc::new(http_client::BlockedHttpClient),
            instance: None,
            last_options: None,
            options: watch::channel(Some(NodeBinaryOptions::default())).1,
            shell_env_loaded: oneshot::channel().1.shared(),
        })))
    }

    async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
        let mut state = self.0.lock().await;

        let options = loop {
            if let Some(options) = state.options.borrow().as_ref() {
                break options.clone();
            }
            match state.options.changed().await {
                Ok(()) => {}
                // failure case not cached
                Err(err) => {
                    return Box::new(UnavailableNodeRuntime {
                        error_message: err.to_string().into(),
                    }) as Box<dyn NodeRuntimeTrait>;
                }
            }
        };

        if state.last_options.as_ref() != Some(&options) {
            state.instance.take();
        }
        if let Some(instance) = state.instance.as_ref() {
            return instance.boxed_clone();
        }

        if let Some((node, npm)) = options.use_paths.as_ref() {
            let instance = match SystemNodeRuntime::new(node.clone(), npm.clone()).await {
                Ok(instance) => {
                    log::info!("using Node.js from `node.path` in settings: {:?}", instance);
                    Box::new(instance)
                }
                Err(err) => {
                    // failure case not cached, since it's cheap to check again
                    return Box::new(UnavailableNodeRuntime {
                        error_message: format!(
                            "failure checking Node.js from `node.path` in settings ({}): {:?}",
                            node.display(),
                            err
                        )
                        .into(),
                    });
                }
            };
            state.instance = Some(instance.boxed_clone());
            state.last_options = Some(options);
            return instance;
        }

        let system_node_error = if options.allow_path_lookup {
            state.shell_env_loaded.clone().await.ok();
            match SystemNodeRuntime::detect().await {
                Ok(instance) => {
                    log::info!("using Node.js found on PATH: {:?}", instance);
                    state.instance = Some(instance.boxed_clone());
                    state.last_options = Some(options);
                    return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
                }
                Err(err) => Some(err),
            }
        } else {
            None
        };

        let instance = if options.allow_binary_download {
            let (log_level, why_using_managed) = match system_node_error {
                Some(err @ DetectError::Other(_)) => (Level::Warn, err.to_string()),
                Some(err @ DetectError::NotInPath(_)) => (Level::Info, err.to_string()),
                None => (
                    Level::Info,
                    "`node.ignore_system_version` is `true` in settings".to_string(),
                ),
            };
            match ManagedNodeRuntime::install_if_needed(&state.http).await {
                Ok(instance) => {
                    log::log!(
                        log_level,
                        "using Zed managed Node.js at {} since {}",
                        instance.installation_path.display(),
                        why_using_managed
                    );
                    Box::new(instance) as Box<dyn NodeRuntimeTrait>
                }
                Err(err) => {
                    // failure case is cached, since downloading + installing may be expensive. The
                    // downside of this is that it may fail due to an intermittent network issue.
                    //
                    // TODO: Have `install_if_needed` indicate which failure cases are retryable
                    // and/or have shared tracking of when internet is available.
                    Box::new(UnavailableNodeRuntime {
                        error_message: format!(
                            "failure while downloading and/or installing Zed managed Node.js, \
                            restart Zed to retry: {}",
                            err
                        )
                        .into(),
                    }) as Box<dyn NodeRuntimeTrait>
                }
            }
        } else if let Some(system_node_error) = system_node_error {
            // failure case not cached, since it's cheap to check again
            //
            // TODO: When support is added for setting `options.allow_binary_download`, update this
            // error message.
            return Box::new(UnavailableNodeRuntime {
                error_message: format!(
                    "failure while checking system Node.js from PATH: {}",
                    system_node_error
                )
                .into(),
            });
        } else {
            // failure case is cached because it will always happen with these options
            //
            // TODO: When support is added for setting `options.allow_binary_download`, update this
            // error message.
            Box::new(UnavailableNodeRuntime {
                error_message: "`node` settings do not allow any way to use Node.js"
                    .to_string()
                    .into(),
            })
        };

        state.instance = Some(instance.boxed_clone());
        state.last_options = Some(options);
        instance
    }

    pub async fn binary_path(&self) -> Result<PathBuf> {
        self.instance().await.binary_path()
    }

    pub async fn run_npm_subcommand(
        &self,
        directory: Option<&Path>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<Output> {
        let http = self.0.lock().await.http.clone();
        self.instance()
            .await
            .run_npm_subcommand(directory, http.proxy(), subcommand, args)
            .await
    }

    pub async fn npm_package_installed_version(
        &self,
        local_package_directory: &Path,
        name: &str,
    ) -> Result<Option<Version>> {
        self.instance()
            .await
            .npm_package_installed_version(local_package_directory, name)
            .await
    }

    pub async fn npm_command(
        &self,
        prefix_dir: Option<&Path>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<NpmCommand> {
        let http = self.0.lock().await.http.clone();
        self.instance()
            .await
            .npm_command(prefix_dir, http.proxy(), subcommand, args)
            .await
    }

    pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
        let http = self.0.lock().await.http.clone();
        let instance = self.instance().await;
        let output = instance
            .run_npm_subcommand(
                None,
                http.proxy(),
                "info",
                &[
                    name,
                    "--json",
                    "--fetch-retry-mintimeout",
                    "2000",
                    "--fetch-retry-maxtimeout",
                    "5000",
                    "--fetch-timeout",
                    "5000",
                ],
            )
            .await?;

        let info: NpmInfo = serde_json::from_slice(&output.stdout)?;
        let before = npm_config_before(instance.as_ref(), http.proxy())
            .await
            .context("getting npm before config")
            .log_err()
            .flatten();
        let latest_dist_tag = info.dist_tags.latest.clone();
        let selected_version = select_npm_package_version(name, info, before.as_deref())?;
        log::debug!(
            "selected latest npm package version package={name:?} before={before:?} dist_tag_latest={latest_dist_tag:?} selected={selected_version}"
        );
        Ok(selected_version)
    }

    pub async fn npm_install_packages(
        &self,
        directory: &Path,
        packages: &[(&str, &str)],
    ) -> Result<()> {
        if packages.is_empty() {
            return Ok(());
        }

        log::debug!(
            "installing npm packages directory={} packages={packages:?}",
            directory.display()
        );

        let packages: Vec<_> = packages
            .iter()
            .map(|(name, version)| format!("{name}@{version}"))
            .collect();

        let arguments: Vec<_> = packages
            .iter()
            .map(|p| p.as_str())
            .chain([
                "--save-exact",
                "--fetch-retry-mintimeout",
                "2000",
                "--fetch-retry-maxtimeout",
                "5000",
                "--fetch-timeout",
                "5000",
            ])
            .collect();

        // This is also wrong because the directory is wrong.
        self.run_npm_subcommand(Some(directory), "install", &arguments)
            .await?;
        Ok(())
    }

    pub async fn npm_install_latest_packages(
        &self,
        directory: &Path,
        package_names: &[&str],
    ) -> Result<()> {
        // Let npm apply user config such as `before` and `min-release-age` during resolution.
        log::debug!(
            "installing latest npm packages directory={} packages={package_names:?}",
            directory.display()
        );
        let packages = package_names
            .iter()
            .map(|package_name| (*package_name, "latest"))
            .collect::<Vec<_>>();
        self.npm_install_packages(directory, &packages).await
    }

    pub async fn should_install_npm_package(
        &self,
        package_name: &str,
        local_executable_path: &Path,
        local_package_directory: &Path,
        version_strategy: VersionStrategy<'_>,
    ) -> bool {
        // In the case of the local system not having the package installed,
        // or in the instances where we fail to parse package.json data,
        // we attempt to install the package.
        if fs::metadata(local_executable_path).await.is_err() {
            log::debug!(
                "npm package cache miss package={package_name:?} reason=missing-executable executable={}",
                local_executable_path.display()
            );
            return true;
        }

        let Some(installed_version) = self
            .npm_package_installed_version(local_package_directory, package_name)
            .await
            .log_err()
            .flatten()
        else {
            log::debug!(
                "npm package cache miss package={package_name:?} reason=missing-installed-version package_dir={}",
                local_package_directory.display()
            );
            return true;
        };

        let version_strategy_label = match &version_strategy {
            VersionStrategy::Pin(version) => format!("pin:{version}"),
            VersionStrategy::Latest(version) => format!("latest:{version}"),
        };
        let should_install =
            should_install_npm_package_version(&installed_version, version_strategy);
        log::debug!(
            "npm package cache check package={package_name:?} installed={installed_version} strategy={version_strategy_label} should_install={should_install}"
        );
        should_install
    }
}

fn should_install_npm_package_version(
    installed_version: &Version,
    version_strategy: VersionStrategy<'_>,
) -> bool {
    match version_strategy {
        VersionStrategy::Pin(pinned_version) => installed_version != pinned_version,
        VersionStrategy::Latest(latest_version) => installed_version < latest_version,
    }
}

enum ArchiveType {
    TarGz,
    Zip,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct NpmInfo {
    #[serde(default)]
    dist_tags: NpmInfoDistTags,
    versions: Vec<Version>,
    #[serde(default, deserialize_with = "deserialize_npm_info_time")]
    time: HashMap<String, String>,
}

#[derive(Debug, Deserialize, Default)]
pub struct NpmInfoDistTags {
    latest: Option<Version>,
}

// Some registries put non-string values in the `time` map: JFrog Artifactory emits
// `"unpublished": null`, and npm itself reports `unpublished` as an object when a
// package has had versions unpublished. Only version keys map to the RFC 3339 strings
// we read, so keep the string entries and drop the rest rather than failing to parse
// the entire `npm info` response (which would block language server installation).
fn deserialize_npm_info_time<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let entries = HashMap::<String, serde_json::Value>::deserialize(deserializer)?;
    Ok(entries
        .into_iter()
        .filter_map(|(key, value)| match value {
            serde_json::Value::String(value) => Some((key, value)),
            _ => None,
        })
        .collect())
}

#[derive(Debug, Deserialize)]
struct NpmConfig {
    #[serde(default)]
    before: Option<String>,
}

async fn npm_config_before(
    node_runtime: &dyn NodeRuntimeTrait,
    proxy: Option<&Url>,
) -> Result<Option<String>> {
    // `npm config get before` renders Date values for display. The JSON config output keeps the
    // computed cutoff in the same ISO format used by `npm info --json` release times.
    let output = node_runtime
        .run_npm_subcommand(None, proxy, "config", &["list", "--json"])
        .await?;
    let config: NpmConfig = serde_json::from_slice(&output.stdout)?;
    Ok(config
        .before
        .filter(|before| !before.trim().is_empty() && before != "null"))
}

fn select_npm_package_version(
    package_name: &str,
    mut info: NpmInfo,
    before: Option<&str>,
) -> Result<Version> {
    if let Some(before) = before
        && !info.time.is_empty()
    {
        let before_timestamp = DateTime::parse_from_rfc3339(before)
            .with_context(|| format!("parsing npm before config timestamp {before:?}"))?
            .with_timezone(&Utc);
        let latest_version = info.dist_tags.latest.as_ref();

        if let Some(version) = latest_version
            && npm_version_was_published_before(version, &info.time, &before_timestamp)?
        {
            return Ok(version.clone());
        }

        for version in info.versions.iter().rev() {
            if is_allowed_npm_version_before(
                version,
                latest_version,
                &info.time,
                &before_timestamp,
            )? {
                return Ok(version.clone());
            }
        }

        bail!("no version found for npm package {package_name} before {before}");
    }

    info.dist_tags
        .latest
        .or_else(|| info.versions.pop())
        .with_context(|| format!("no version found for npm package {package_name}"))
}

fn is_allowed_npm_version_before(
    version: &Version,
    latest_version: Option<&Version>,
    published_at_by_version: &HashMap<String, String>,
    before: &DateTime<Utc>,
) -> Result<bool> {
    if !version.pre.is_empty()
        || latest_version.is_some_and(|latest_version| version > latest_version)
    {
        return Ok(false);
    }

    npm_version_was_published_before(version, published_at_by_version, before)
}

fn npm_version_was_published_before(
    version: &Version,
    published_at_by_version: &HashMap<String, String>,
    before: &DateTime<Utc>,
) -> Result<bool> {
    let Some(published_at) = published_at_by_version.get(&version.to_string()) else {
        return Ok(false);
    };
    let published_at = DateTime::parse_from_rfc3339(published_at)
        .with_context(|| format!("parsing npm release timestamp for version {version}"))?
        .with_timezone(&Utc);
    Ok(&published_at <= before)
}

#[async_trait::async_trait]
trait NodeRuntimeTrait: Send + Sync {
    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait>;
    fn binary_path(&self) -> Result<PathBuf>;

    async fn run_npm_subcommand(
        &self,
        directory: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<Output>;

    async fn npm_command(
        &self,
        prefix_dir: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<NpmCommand>;

    async fn npm_package_installed_version(
        &self,
        local_package_directory: &Path,
        name: &str,
    ) -> Result<Option<Version>>;
}

#[derive(Clone)]
struct ManagedNodeRuntime {
    installation_path: PathBuf,
}

impl ManagedNodeRuntime {
    const VERSION: &str = "v24.11.0";

    #[cfg(not(windows))]
    const NODE_PATH: &str = "bin/node";
    #[cfg(windows)]
    const NODE_PATH: &str = "node.exe";

    #[cfg(not(windows))]
    const NPM_PATH: &str = "bin/npm";
    #[cfg(windows)]
    const NPM_PATH: &str = "node_modules/npm/bin/npm-cli.js";

    async fn install_if_needed(http: &Arc<dyn HttpClient>) -> Result<Self> {
        log::info!("Node runtime install_if_needed");

        let os = match consts::OS {
            "macos" => "darwin",
            "linux" => "linux",
            "windows" => "win",
            other => bail!("Running on unsupported os: {other}"),
        };

        let arch = match consts::ARCH {
            "x86_64" => "x64",
            "aarch64" => "arm64",
            other => bail!("Running on unsupported architecture: {other}"),
        };

        let version = Self::VERSION;
        let folder_name = format!("node-{version}-{os}-{arch}");
        let node_containing_dir = paths::data_dir().join("node");
        let node_dir = node_containing_dir.join(folder_name);
        let node_binary = node_dir.join(Self::NODE_PATH);
        let npm_file = node_dir.join(Self::NPM_PATH);
        let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());

        let valid = if fs::metadata(&node_binary).await.is_ok() {
            let result = util::command::new_command(&node_binary)
                .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
                .arg(npm_file)
                .arg("--version")
                .args(["--cache".into(), node_dir.join("cache")])
                .args(["--userconfig".into(), node_dir.join("blank_user_npmrc")])
                .args(["--globalconfig".into(), node_dir.join("blank_global_npmrc")])
                .output()
                .await;
            match result {
                Ok(output) => {
                    if output.status.success() {
                        true
                    } else {
                        log::warn!(
                            "Zed managed Node.js binary at {} failed check with output: {:?}",
                            node_binary.display(),
                            output
                        );
                        false
                    }
                }
                Err(err) => {
                    log::warn!(
                        "Zed managed Node.js binary at {} failed check, so re-downloading it. \
                        Error: {}",
                        node_binary.display(),
                        err
                    );
                    false
                }
            }
        } else {
            false
        };

        if !valid {
            _ = fs::remove_dir_all(&node_containing_dir).await;
            fs::create_dir(&node_containing_dir)
                .await
                .context("error creating node containing dir")?;

            let archive_type = match consts::OS {
                "macos" | "linux" => ArchiveType::TarGz,
                "windows" => ArchiveType::Zip,
                other => bail!("Running on unsupported os: {other}"),
            };

            let version = Self::VERSION;
            let file_name = format!(
                "node-{version}-{os}-{arch}.{extension}",
                extension = match archive_type {
                    ArchiveType::TarGz => "tar.gz",
                    ArchiveType::Zip => "zip",
                }
            );

            let url = format!("https://nodejs.org/dist/{version}/{file_name}");
            log::info!("Downloading Node.js binary from {url}");
            let mut response = http
                .get(&url, Default::default(), true)
                .await
                .context("error downloading Node binary tarball")?;
            log::info!("Download of Node.js complete, extracting...");

            let body = response.body_mut();
            match archive_type {
                ArchiveType::TarGz => {
                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
                    let archive = Archive::new(decompressed_bytes);
                    archive.unpack(&node_containing_dir).await?;
                }
                ArchiveType::Zip => extract_zip(&node_containing_dir, body).await?,
            }
            log::info!("Extracted Node.js to {}", node_containing_dir.display())
        }

        // Note: Not in the `if !valid {}` so we can populate these for existing installations
        _ = fs::create_dir(node_dir.join("cache")).await;
        _ = fs::write(node_dir.join("blank_user_npmrc"), []).await;
        _ = fs::write(node_dir.join("blank_global_npmrc"), []).await;

        anyhow::Ok(ManagedNodeRuntime {
            installation_path: node_dir,
        })
    }
}

fn path_with_node_binary_prepended(node_binary: &Path) -> Option<OsString> {
    let existing_path = env::var_os("PATH");
    let node_bin_dir = node_binary.parent().map(|dir| dir.as_os_str());
    match (existing_path, node_bin_dir) {
        (Some(existing_path), Some(node_bin_dir)) => {
            if let Ok(joined) = env::join_paths(
                [PathBuf::from(node_bin_dir)]
                    .into_iter()
                    .chain(env::split_paths(&existing_path)),
            ) {
                Some(joined)
            } else {
                Some(existing_path)
            }
        }
        (Some(existing_path), None) => Some(existing_path),
        (None, Some(node_bin_dir)) => Some(node_bin_dir.to_owned()),
        _ => None,
    }
}

#[async_trait::async_trait]
impl NodeRuntimeTrait for ManagedNodeRuntime {
    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
        Box::new(self.clone())
    }

    fn binary_path(&self) -> Result<PathBuf> {
        Ok(self.installation_path.join(Self::NODE_PATH))
    }

    async fn run_npm_subcommand(
        &self,
        directory: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<Output> {
        let attempt = || async {
            let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
            let mut command = util::command::new_command(npm_command.path);
            command.args(npm_command.args);
            command.envs(npm_command.env);
            if let Some(directory) = directory {
                command.current_dir(directory);
            }
            command.output().await.map_err(|e| anyhow!("{e}"))
        };

        let mut output = attempt().await;
        if output.is_err() {
            output = attempt().await;
            anyhow::ensure!(
                output.is_ok(),
                "failed to launch npm subcommand {subcommand} subcommand\nerr: {:?}",
                output.err()
            );
        }

        if let Ok(output) = &output {
            anyhow::ensure!(
                output.status.success(),
                "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr)
            );
        }

        output.map_err(|e| anyhow!("{e}"))
    }

    async fn npm_command(
        &self,
        prefix_dir: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<NpmCommand> {
        let node_binary = self.installation_path.join(Self::NODE_PATH);
        let npm_file = self.installation_path.join(Self::NPM_PATH);

        anyhow::ensure!(
            smol::fs::metadata(&node_binary).await.is_ok(),
            "missing node binary file"
        );
        anyhow::ensure!(
            smol::fs::metadata(&npm_file).await.is_ok(),
            "missing npm file"
        );

        let command_args = build_npm_command_args(
            Some(&npm_file),
            prefix_dir,
            &self.installation_path.join("cache"),
            Some(&self.installation_path.join("blank_user_npmrc")),
            Some(&self.installation_path.join("blank_global_npmrc")),
            proxy,
            subcommand,
            args,
        );
        let command_env = npm_command_env(Some(&node_binary));

        Ok(NpmCommand {
            path: node_binary,
            args: command_args,
            env: command_env,
        })
    }

    async fn npm_package_installed_version(
        &self,
        local_package_directory: &Path,
        name: &str,
    ) -> Result<Option<Version>> {
        read_package_installed_version(local_package_directory.join("node_modules"), name).await
    }
}

#[derive(Debug, Clone)]
pub struct SystemNodeRuntime {
    node: PathBuf,
    npm: PathBuf,
    scratch_dir: PathBuf,
}

impl SystemNodeRuntime {
    const MIN_VERSION: semver::Version = Version::new(22, 0, 0);
    async fn new(node: PathBuf, npm: PathBuf) -> Result<Self> {
        let output = util::command::new_command(&node)
            .arg("--version")
            .output()
            .await
            .with_context(|| format!("running node from {:?}", node))?;
        if !output.status.success() {
            anyhow::bail!(
                "failed to run node --version. stdout: {}, stderr: {}",
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr),
            );
        }
        let version_str = String::from_utf8_lossy(&output.stdout);
        let version = semver::Version::parse(version_str.trim().trim_start_matches('v'))?;
        if version < Self::MIN_VERSION {
            anyhow::bail!(
                "node at {} is too old. want: {}, got: {}",
                node.to_string_lossy(),
                Self::MIN_VERSION,
                version
            )
        }

        let scratch_dir = paths::data_dir().join("node");
        fs::create_dir(&scratch_dir).await.ok();
        fs::create_dir(scratch_dir.join("cache")).await.ok();

        Ok(Self {
            node,
            npm,
            scratch_dir,
        })
    }

    async fn detect() -> std::result::Result<Self, DetectError> {
        let node = which::which("node").map_err(DetectError::NotInPath)?;
        let npm = which::which("npm").map_err(DetectError::NotInPath)?;
        Self::new(node, npm).await.map_err(DetectError::Other)
    }
}

enum DetectError {
    NotInPath(which::Error),
    Other(anyhow::Error),
}

impl Display for DetectError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DetectError::NotInPath(err) => {
                write!(f, "system Node.js wasn't found on PATH: {}", err)
            }
            DetectError::Other(err) => {
                write!(f, "checking system Node.js failed with error: {}", err)
            }
        }
    }
}

#[async_trait::async_trait]
impl NodeRuntimeTrait for SystemNodeRuntime {
    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
        Box::new(self.clone())
    }

    fn binary_path(&self) -> Result<PathBuf> {
        Ok(self.node.clone())
    }

    async fn run_npm_subcommand(
        &self,
        directory: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> anyhow::Result<Output> {
        let npm_command = self.npm_command(directory, proxy, subcommand, args).await?;
        let mut command = util::command::new_command(npm_command.path);
        command.args(npm_command.args);
        command.envs(npm_command.env);
        if let Some(directory) = directory {
            command.current_dir(directory);
        }
        let output = command.output().await?;
        anyhow::ensure!(
            output.status.success(),
            "failed to execute npm {subcommand} subcommand:\nstdout: {:?}\nstderr: {:?}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        Ok(output)
    }

    async fn npm_command(
        &self,
        prefix_dir: Option<&Path>,
        proxy: Option<&Url>,
        subcommand: &str,
        args: &[&str],
    ) -> Result<NpmCommand> {
        let command_args = build_npm_command_args(
            None,
            prefix_dir,
            &self.scratch_dir.join("cache"),
            None,
            None,
            proxy,
            subcommand,
            args,
        );
        let command_env = npm_command_env(Some(&self.node));

        Ok(NpmCommand {
            path: self.npm.clone(),
            args: command_args,
            env: command_env,
        })
    }

    async fn npm_package_installed_version(
        &self,
        local_package_directory: &Path,
        name: &str,
    ) -> Result<Option<Version>> {
        read_package_installed_version(local_package_directory.join("node_modules"), name).await
        // todo: allow returning a globally installed version (requires callers not to hard-code the path)
    }
}

pub async fn read_package_installed_version(
    node_module_directory: PathBuf,
    name: &str,
) -> Result<Option<Version>> {
    let package_json_path = node_module_directory.join(name).join("package.json");

    let mut file = match fs::File::open(package_json_path).await {
        Ok(file) => file,
        Err(err) => {
            if err.kind() == io::ErrorKind::NotFound {
                return Ok(None);
            }

            Err(err)?
        }
    };

    #[derive(Deserialize)]
    struct PackageJson {
        version: Version,
    }

    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    let package_json: PackageJson = serde_json::from_str(&contents)?;
    Ok(Some(package_json.version))
}

#[derive(Clone)]
pub struct UnavailableNodeRuntime {
    error_message: Arc<String>,
}

#[async_trait::async_trait]
impl NodeRuntimeTrait for UnavailableNodeRuntime {
    fn boxed_clone(&self) -> Box<dyn NodeRuntimeTrait> {
        Box::new(self.clone())
    }
    fn binary_path(&self) -> Result<PathBuf> {
        bail!("{}", self.error_message)
    }

    async fn run_npm_subcommand(
        &self,
        _: Option<&Path>,
        _: Option<&Url>,
        _: &str,
        _: &[&str],
    ) -> anyhow::Result<Output> {
        bail!("{}", self.error_message)
    }

    async fn npm_command(
        &self,
        _: Option<&Path>,
        _proxy: Option<&Url>,
        _subcommand: &str,
        _args: &[&str],
    ) -> Result<NpmCommand> {
        bail!("{}", self.error_message)
    }

    async fn npm_package_installed_version(
        &self,
        _local_package_directory: &Path,
        _: &str,
    ) -> Result<Option<Version>> {
        bail!("{}", self.error_message)
    }
}

fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
    let mut proxy = proxy.cloned()?;
    // Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
    // NodeRuntime without environment information can not parse `localhost`
    // correctly.
    // TODO: map to `[::1]` if we are using ipv6
    if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
    {
        // When localhost is a valid Host, so is `127.0.0.1`
        let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
    }

    Some(proxy.as_str().to_string())
}

fn build_npm_command_args(
    entrypoint: Option<&Path>,
    prefix_dir: Option<&Path>,
    cache_dir: &Path,
    user_config: Option<&Path>,
    global_config: Option<&Path>,
    proxy: Option<&Url>,
    subcommand: &str,
    args: &[&str],
) -> Vec<String> {
    let mut command_args = Vec::new();
    if let Some(entrypoint) = entrypoint {
        command_args.push(entrypoint.to_string_lossy().into_owned());
    }
    if let Some(prefix_dir) = prefix_dir {
        command_args.push("--prefix".into());
        command_args.push(prefix_dir.to_string_lossy().into_owned());
    }
    command_args.push(subcommand.to_string());
    command_args.push(format!("--cache={}", cache_dir.display()));
    if let Some(user_config) = user_config {
        command_args.push("--userconfig".into());
        command_args.push(user_config.to_string_lossy().into_owned());
    }
    if let Some(global_config) = global_config {
        command_args.push("--globalconfig".into());
        command_args.push(global_config.to_string_lossy().into_owned());
    }
    if let Some(proxy_arg) = proxy_argument(proxy) {
        command_args.push("--proxy".into());
        command_args.push(proxy_arg);
    }
    command_args.extend(args.into_iter().map(|a| a.to_string()));
    command_args
}

fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
    let mut command_env = HashMap::new();
    if let Some(node_binary) = node_binary {
        let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
        command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
    }

    if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
        if !node_ca_certs.is_empty() {
            command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
        }
    }

    #[cfg(windows)]
    {
        if let Some(val) = env::var("SYSTEMROOT")
            .context("Missing environment variable: SYSTEMROOT!")
            .log_err()
        {
            command_env.insert("SYSTEMROOT".into(), val);
        }
        if let Some(val) = env::var("ComSpec")
            .context("Missing environment variable: ComSpec!")
            .log_err()
        {
            command_env.insert("ComSpec".into(), val);
        }
    }

    command_env
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use anyhow::{Result, bail};
    use http_client::Url;
    use semver::Version;

    use super::{
        NpmInfo, VersionStrategy, build_npm_command_args, proxy_argument,
        select_npm_package_version, should_install_npm_package_version,
    };

    // Map localhost to 127.0.0.1
    // NodeRuntime without environment information can not parse `localhost` correctly.
    #[test]
    fn test_proxy_argument_map_localhost_proxy() {
        const CASES: [(&str, &str); 4] = [
            // Map localhost to 127.0.0.1
            ("http://localhost:9090/", "http://127.0.0.1:9090/"),
            ("https://google.com/", "https://google.com/"),
            (
                "http://username:password@proxy.thing.com:8080/",
                "http://username:password@proxy.thing.com:8080/",
            ),
            // Test when localhost is contained within a different part of the URL
            (
                "http://username:localhost@localhost:8080/",
                "http://username:localhost@127.0.0.1:8080/",
            ),
        ];

        for (proxy, mapped_proxy) in CASES {
            let proxy = Url::parse(proxy).unwrap();
            let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
            assert_eq!(
                proxy, mapped_proxy,
                "Incorrectly mapped localhost to 127.0.0.1"
            );
        }
    }

    #[test]
    fn test_build_npm_command_args_inserts_prefix_before_subcommand() {
        let args = build_npm_command_args(
            None,
            Some(Path::new("/tmp/zed-prefix")),
            Path::new("/tmp/cache"),
            None,
            None,
            None,
            "exec",
            &["--yes", "--", "agent-package"],
        );

        assert_eq!(
            args,
            vec![
                "--prefix".to_string(),
                "/tmp/zed-prefix".to_string(),
                "exec".to_string(),
                "--cache=/tmp/cache".to_string(),
                "--yes".to_string(),
                "--".to_string(),
                "agent-package".to_string(),
            ]
        );
    }

    #[test]
    fn test_build_npm_command_args_keeps_entrypoint_before_prefix() {
        let args = build_npm_command_args(
            Some(Path::new("/tmp/npm-cli.js")),
            Some(Path::new("/tmp/zed-prefix")),
            Path::new("/tmp/cache"),
            None,
            None,
            None,
            "exec",
            &["--yes"],
        );

        assert_eq!(
            args,
            vec![
                "/tmp/npm-cli.js".to_string(),
                "--prefix".to_string(),
                "/tmp/zed-prefix".to_string(),
                "exec".to_string(),
                "--cache=/tmp/cache".to_string(),
                "--yes".to_string(),
            ]
        );
    }

    #[test]
    fn test_latest_version_strategy_accepts_newer_installed_versions() -> Result<()> {
        let target_version = Version::parse("2.0.0")?;

        assert!(!should_install_npm_package_version(
            &Version::parse("2.0.0")?,
            VersionStrategy::Latest(&target_version)
        ));
        assert!(should_install_npm_package_version(
            &Version::parse("1.0.0")?,
            VersionStrategy::Latest(&target_version)
        ));
        assert!(!should_install_npm_package_version(
            &Version::parse("3.0.0")?,
            VersionStrategy::Latest(&target_version)
        ));

        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_uses_dist_tag_without_before() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "3.0.0" },
                "versions": ["1.0.0", "2.0.0", "3.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-02-01T00:00:00.000Z",
                    "3.0.0": "2024-03-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, None)?,
            Version::parse("3.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_npm_info_skips_non_string_time_entries() -> Result<()> {
        // Registries such as JFrog Artifactory include `"unpublished": null` in `time`;
        // parsing must tolerate this rather than rejecting the whole response.
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0" },
                "versions": ["1.0.0", "2.0.0"],
                "time": {
                    "unpublished": null,
                    "created": "2024-01-01T00:00:00.000Z",
                    "modified": "2024-02-01T00:00:00.000Z",
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-02-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("2.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_uses_latest_before_npm_before_config() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "3.0.0" },
                "versions": ["1.0.0", "2.0.0", "3.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-02-01T00:00:00.000Z",
                    "3.0.0": "2024-03-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("2.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_keeps_allowed_latest_dist_tag() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0" },
                "versions": ["1.0.0", "2.0.0", "3.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-02-01T00:00:00.000Z",
                    "3.0.0": "2024-03-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("2.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_keeps_allowed_prerelease_latest_dist_tag() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0-beta.1" },
                "versions": ["1.0.0", "2.0.0-beta.1"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0-beta.1": "2024-02-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("2.0.0-beta.1")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_ignores_prereleases_before_cutoff() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0" },
                "versions": ["1.0.0", "2.0.0-beta.1", "2.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0-beta.1": "2024-02-01T00:00:00.000Z",
                    "2.0.0": "2024-03-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("1.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_ignores_versions_above_latest_dist_tag() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0" },
                "versions": ["1.0.0", "2.0.0", "3.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-03-01T00:00:00.000Z",
                    "3.0.0": "2024-02-01T00:00:00.000Z"
                }
            }"#,
        )?;

        assert_eq!(
            select_npm_package_version("test-package", info, Some("2024-02-15T00:00:00.000Z"))?,
            Version::parse("1.0.0")?
        );
        Ok(())
    }

    #[test]
    fn test_select_npm_package_version_errors_when_no_version_matches_before() -> Result<()> {
        let info: NpmInfo = serde_json::from_str(
            r#"{
                "dist-tags": { "latest": "2.0.0" },
                "versions": ["1.0.0", "2.0.0"],
                "time": {
                    "1.0.0": "2024-01-01T00:00:00.000Z",
                    "2.0.0": "2024-02-01T00:00:00.000Z"
                }
            }"#,
        )?;

        let Err(error) =
            select_npm_package_version("test-package", info, Some("2023-12-01T00:00:00.000Z"))
        else {
            bail!("expected cutoff to reject all package versions");
        };
        assert_eq!(
            error.to_string(),
            "no version found for npm package test-package before 2023-12-01T00:00:00.000Z"
        );
        Ok(())
    }
}
