// Copyright 2018-2026 the Deno authors. MIT license.

use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;

use deno_package_json::PackageJson;
use deno_package_json::PackageJsonDepValue;
use deno_package_json::PackageJsonRc;
use deno_path_util::url_to_file_path;
use deno_semver::StackString;
use deno_semver::Version;
use deno_semver::package::PackageReq;
use node_resolver::InNpmPackageChecker;
use node_resolver::NpmPackageFolderResolver;
use node_resolver::PackageJsonResolverRc;
use node_resolver::UrlOrPathRef;
use node_resolver::cache::NodeResolutionSys;
use node_resolver::errors::PackageFolderResolveError;
use node_resolver::errors::PackageFolderResolveIoError;
use node_resolver::errors::PackageJsonLoadError;
use node_resolver::errors::PackageNotFoundError;
use sys_traits::FsCanonicalize;
use sys_traits::FsDirEntry;
use sys_traits::FsMetadata;
use sys_traits::FsRead;
use sys_traits::FsReadDir;
use thiserror::Error;
use url::Url;

use super::local::normalize_pkg_name_for_node_modules_deno_folder;
use crate::npm::join_package_name_to_path;
use crate::npm::version_req_matches_including_pre;

#[derive(Debug, Error, deno_error::JsError)]
pub enum ByonmResolvePkgFolderFromDenoReqError {
  #[class(generic)]
  #[error("Could not find \"{}\" in a node_modules folder. Deno expects the node_modules/ directory to be up to date. Did you forget to run `deno install`?", .0)]
  MissingAlias(StackString),
  #[class(inherit)]
  #[error(transparent)]
  PackageJson(#[from] PackageJsonLoadError),
  #[class(generic)]
  #[error("Could not find a matching package for 'npm:{}' in the node_modules directory. Ensure you have all your JSR and npm dependencies listed in your deno.json or package.json, then run `deno install`. Alternatively, turn on auto-install by specifying `\"nodeModulesDir\": \"auto\"` in your deno.json file.", .0)]
  UnmatchedReq(PackageReq),
  #[class(inherit)]
  #[error(transparent)]
  Io(#[from] std::io::Error),
}

pub struct ByonmNpmResolverCreateOptions<TSys: FsRead + FsMetadata> {
  pub root_node_modules_dir: Option<PathBuf>,
  /// When set, the ancestor walk for node_modules resolution will not
  /// go above this directory. Used by deno_rt for self-extracting binaries
  /// to prevent escaping the VFS root.
  pub search_stop_dir: Option<PathBuf>,
  pub sys: NodeResolutionSys<TSys>,
  pub pkg_json_resolver: PackageJsonResolverRc<TSys>,
}

#[sys_traits::auto_impl]
pub trait ByonmNpmResolverSys:
  FsCanonicalize + FsRead + FsMetadata + FsReadDir
{
}

#[allow(clippy::disallowed_types, reason = "definition")]
pub type ByonmNpmResolverRc<TSys> =
  deno_maybe_sync::MaybeArc<ByonmNpmResolver<TSys>>;

#[derive(Debug)]
pub struct ByonmNpmResolver<TSys: ByonmNpmResolverSys> {
  sys: NodeResolutionSys<TSys>,
  pkg_json_resolver: PackageJsonResolverRc<TSys>,
  root_node_modules_dir: Option<PathBuf>,
  search_stop_dir: Option<PathBuf>,
}

impl<TSys: ByonmNpmResolverSys + Clone> Clone for ByonmNpmResolver<TSys> {
  fn clone(&self) -> Self {
    Self {
      sys: self.sys.clone(),
      pkg_json_resolver: self.pkg_json_resolver.clone(),
      root_node_modules_dir: self.root_node_modules_dir.clone(),
      search_stop_dir: self.search_stop_dir.clone(),
    }
  }
}

impl<TSys: ByonmNpmResolverSys> ByonmNpmResolver<TSys> {
  pub fn new(options: ByonmNpmResolverCreateOptions<TSys>) -> Self {
    Self {
      root_node_modules_dir: options.root_node_modules_dir,
      search_stop_dir: options.search_stop_dir,
      sys: options.sys,
      pkg_json_resolver: options.pkg_json_resolver,
    }
  }

  pub fn root_node_modules_path(&self) -> Option<&Path> {
    self.root_node_modules_dir.as_deref()
  }

  /// Finds the ancestor package.json that contains the specified dependency.
  pub fn find_ancestor_package_json_with_dep(
    &self,
    dep_name: &str,
    referrer: &Url,
  ) -> Option<PackageJsonRc> {
    let referrer_path = url_to_file_path(referrer).ok()?;
    for result in self
      .pkg_json_resolver
      .get_closest_package_jsons(&referrer_path)
    {
      let Ok(pkg_json) = result else {
        continue;
      };
      if let Some(deps) = &pkg_json.dependencies
        && deps.contains_key(dep_name)
      {
        return Some(pkg_json);
      }
      if let Some(deps) = &pkg_json.dev_dependencies
        && deps.contains_key(dep_name)
      {
        return Some(pkg_json);
      }
    }
    None
  }

  pub fn resolve_pkg_folder_from_deno_module_req(
    &self,
    req: &PackageReq,
    referrer: &Url,
  ) -> Result<PathBuf, ByonmResolvePkgFolderFromDenoReqError> {
    fn node_resolve_dir<TSys: FsCanonicalize + FsMetadata>(
      sys: &NodeResolutionSys<TSys>,
      alias: &str,
      start_dir: &Path,
    ) -> std::io::Result<Option<PathBuf>> {
      for ancestor in start_dir.ancestors() {
        let node_modules_folder = ancestor.join("node_modules");
        // When using the cache, eagerly check for the node_modules directory in order
        // to reduce cache entries and get an answer more quickly on negative lookups.
        if !sys.has_cache() || sys.is_dir(Cow::Borrowed(&node_modules_folder)) {
          let sub_dir =
            join_package_name_to_path(Cow::Owned(node_modules_folder), alias);
          if sys.is_dir(Cow::Borrowed(&sub_dir)) {
            return Ok(Some(
              deno_path_util::fs::canonicalize_path_maybe_not_exists(
                sys, &sub_dir,
              )?,
            ));
          }
        }
      }
      Ok(None)
    }

    // now attempt to resolve if it's found in any package.json
    let maybe_pkg_json_and_alias =
      self.resolve_pkg_json_and_alias_for_req(req, referrer)?;
    if let Some((pkg_json, alias)) = &maybe_pkg_json_and_alias {
      // now try node resolution
      if let Some(resolved) =
        node_resolve_dir(&self.sys, alias, pkg_json.dir_path())?
      {
        // Verify the resolved package version actually satisfies the
        // requirement. The package.json dep may intersect the req (e.g.,
        // dep is ^1.3.5, req is @1.3.6) but the physically installed
        // version might not satisfy it (e.g., 1.3.5 is installed).
        // In that case, fall through to the .deno/ search.
        //
        // When the req is a tag (e.g. `latest`), we can't compare against
        // a parsed version, so trust the alias resolution.
        let version_matches = req.version_req.tag().is_some()
          || self
            .pkg_json_resolver
            .load_package_json(&resolved.join("package.json"))
            .ok()
            .flatten()
            .and_then(|pkg| {
              let version =
                Version::parse_from_npm(pkg.version.as_ref()?).ok()?;
              Some(version_req_matches_including_pre(
                &req.version_req,
                &version,
              ))
            })
            .unwrap_or(true);
        if version_matches {
          return Ok(resolved);
        }
      }
    }

    // check if node_modules/.deno/ matches this constraint
    if let Some(folder) = self.resolve_folder_in_root_node_modules(req) {
      return Ok(folder);
    }

    match maybe_pkg_json_and_alias {
      Some((_, alias)) => {
        Err(ByonmResolvePkgFolderFromDenoReqError::MissingAlias(alias))
      }
      None => Err(ByonmResolvePkgFolderFromDenoReqError::UnmatchedReq(
        req.clone(),
      )),
    }
  }

  fn resolve_pkg_json_and_alias_for_req(
    &self,
    req: &PackageReq,
    referrer: &Url,
  ) -> Result<
    Option<(PackageJsonRc, StackString)>,
    ByonmResolvePkgFolderFromDenoReqError,
  > {
    fn resolve_alias_from_pkg_json(
      req: &PackageReq,
      pkg_json: &PackageJson,
    ) -> Result<Option<StackString>, ByonmResolvePkgFolderFromDenoReqError>
    {
      let deps = pkg_json.resolve_local_package_json_deps();
      for (key, value) in
        deps.dependencies.iter().chain(deps.dev_dependencies.iter())
      {
        if let Ok(value) = value {
          match value {
            PackageJsonDepValue::File(_) => {
              // skip
            }
            PackageJsonDepValue::Req(dep_req) => {
              if dep_req.name == req.name
                && dep_req.version_req.intersects(&req.version_req)
              {
                return Ok(Some(key.clone()));
              }
            }
            PackageJsonDepValue::Workspace { .. } => {
              if key.as_str() == req.name
                && req.version_req.tag() == Some("workspace")
              {
                return Ok(Some(key.clone()));
              }
            }
            PackageJsonDepValue::Catalog(_) => {
              // Catalog deps are resolved to their actual version during
              // install. At BYONM resolution time, check by alias name.
              if key.as_str() == req.name {
                return Ok(Some(key.clone()));
              }
            }
          }
        }
      }
      Ok(None)
    }

    // attempt to resolve the npm specifier from the referrer's package.json,
    let maybe_referrer_path = url_to_file_path(referrer).ok();
    if let Some(file_path) = maybe_referrer_path {
      for result in self.pkg_json_resolver.get_closest_package_jsons(&file_path)
      {
        let pkg_json = result?;
        if let Some(alias) =
          resolve_alias_from_pkg_json(req, pkg_json.as_ref())?
        {
          return Ok(Some((pkg_json, alias)));
        }
      }
    }

    // fall fallback to the project's package.json
    if let Some(root_node_modules_dir) = &self.root_node_modules_dir {
      let root_pkg_json_path =
        root_node_modules_dir.parent().unwrap().join("package.json");
      if let Some(pkg_json) = self
        .pkg_json_resolver
        .load_package_json(&root_pkg_json_path)?
        && let Some(alias) =
          resolve_alias_from_pkg_json(req, pkg_json.as_ref())?
      {
        return Ok(Some((pkg_json, alias)));
      }
    }

    // now try to resolve based on the closest node_modules directory
    let maybe_referrer_path = url_to_file_path(referrer).ok();
    let search_node_modules = |node_modules: &Path| {
      if req.version_req.tag().is_some() {
        return None;
      }

      let pkg_folder = node_modules.join(&req.name);
      if let Ok(Some(dep_pkg_json)) = self
        .pkg_json_resolver
        .load_package_json(&pkg_folder.join("package.json"))
        && dep_pkg_json.name.as_deref() == Some(req.name.as_str())
      {
        let matches_req = dep_pkg_json
          .version
          .as_ref()
          .and_then(|v| Version::parse_from_npm(v).ok())
          .map(|version| {
            version_req_matches_including_pre(&req.version_req, &version)
          })
          .unwrap_or(true);
        if matches_req {
          return Some((dep_pkg_json, req.name.clone()));
        }
      }
      None
    };
    if let Some(file_path) = &maybe_referrer_path {
      for dir_path in file_path.as_path().ancestors().skip(1) {
        if let Some(result) =
          search_node_modules(&dir_path.join("node_modules"))
        {
          return Ok(Some(result));
        }
      }
    }

    // and finally check the root node_modules directory
    if let Some(root_node_modules_dir) = &self.root_node_modules_dir {
      let already_searched = maybe_referrer_path
        .as_ref()
        .and_then(|referrer_path| {
          root_node_modules_dir
            .parent()
            .map(|search_stop_dir| referrer_path.starts_with(search_stop_dir))
        })
        .unwrap_or(false);
      if !already_searched
        && let Some(result) = search_node_modules(root_node_modules_dir)
      {
        return Ok(Some(result));
      }
    }

    Ok(None)
  }

  fn resolve_folder_in_root_node_modules(
    &self,
    req: &PackageReq,
  ) -> Option<PathBuf> {
    // now check if node_modules/.deno/ matches this constraint
    let root_node_modules_dir = self.root_node_modules_dir.as_ref()?;
    let node_modules_deno_dir = root_node_modules_dir.join(".deno");
    let Ok(entries) = self.sys.fs_read_dir(&node_modules_deno_dir) else {
      return None;
    };
    let search_prefix = format!(
      "{}@",
      normalize_pkg_name_for_node_modules_deno_folder(&req.name)
    );
    let mut best_version = None;

    // example entries:
    // - @denotest+add@1.0.0
    // - @denotest+add@1.0.0_1
    for entry in entries {
      let Ok(entry) = entry else {
        continue;
      };
      let Ok(file_type) = entry.file_type() else {
        continue;
      };
      if !file_type.is_dir() {
        continue;
      }
      let entry_name = entry.file_name().to_string_lossy().into_owned();
      let Some(version_and_copy_idx) = entry_name.strip_prefix(&search_prefix)
      else {
        continue;
      };
      let version = version_and_copy_idx
        .rsplit_once('_')
        .map(|(v, _)| v)
        .unwrap_or(version_and_copy_idx);
      let Ok(version) = Version::parse_from_npm(version) else {
        continue;
      };
      if let Some(tag) = req.version_req.tag() {
        let initialized_file =
          node_modules_deno_dir.join(&entry_name).join(".initialized");
        let Ok(contents) = self.sys.fs_read_to_string_lossy(&initialized_file)
        else {
          continue;
        };
        let mut tags = contents.split(',').map(str::trim);
        if tags.any(|t| t == tag) {
          if let Some((best_version_version, _)) = &best_version {
            if version > *best_version_version {
              best_version = Some((version, entry_name));
            }
          } else {
            best_version = Some((version, entry_name));
          }
        }
      } else if req.version_req.matches(&version) {
        // Prefer an exact version match over the highest matching version.
        // When a user specifies `npm:pkg@1.3.5`, the version_text is "1.3.5"
        // and we should return that exact version if it exists, not a higher
        // one that also satisfies the range.
        let is_exact = Version::parse_from_npm(req.version_req.version_text())
          .is_ok_and(|v| v == version);
        if is_exact {
          best_version = Some((version, entry_name));
          break;
        }
        if let Some((best_version_version, _)) = &best_version {
          if version > *best_version_version {
            best_version = Some((version, entry_name));
          }
        } else {
          best_version = Some((version, entry_name));
        }
      }
    }

    best_version.map(|(_version, entry_name)| {
      join_package_name_to_path(
        Cow::Owned(node_modules_deno_dir.join(entry_name).join("node_modules")),
        &req.name,
      )
    })
  }
}

impl<TSys: FsCanonicalize + FsMetadata + FsRead + FsReadDir>
  NpmPackageFolderResolver for ByonmNpmResolver<TSys>
{
  fn resolve_package_folder_from_package(
    &self,
    name: &str,
    referrer: &UrlOrPathRef,
  ) -> Result<PathBuf, PackageFolderResolveError> {
    fn inner<TSys: FsMetadata + FsCanonicalize>(
      sys: &NodeResolutionSys<TSys>,
      name: &str,
      referrer: &UrlOrPathRef,
      search_stop_dir: Option<&Path>,
    ) -> Result<PathBuf, PackageFolderResolveError> {
      let maybe_referrer_file = referrer.path().ok();
      let maybe_start_folder =
        maybe_referrer_file.as_ref().and_then(|f| f.parent());
      if let Some(start_folder) = maybe_start_folder {
        let start_folder = search_stop_dir
          .is_some()
          .then(|| sys.fs_canonicalize(start_folder).ok().map(Cow::Owned))
          .flatten()
          .unwrap_or(Cow::Borrowed(start_folder));
        for current_folder in start_folder.ancestors() {
          let node_modules_folder = if current_folder.ends_with("node_modules")
          {
            Cow::Borrowed(current_folder)
          } else {
            Cow::Owned(current_folder.join("node_modules"))
          };

          // When using the cache, eagerly check for the node_modules directory in order
          // to reduce cache entries and get an answer more quickly on negative lookups.
          if !sys.has_cache() || sys.is_dir(Cow::Borrowed(&node_modules_folder))
          {
            let sub_dir = join_package_name_to_path(node_modules_folder, name);
            if sys.is_dir(Cow::Borrowed(&sub_dir)) {
              return Ok(sub_dir);
            }
          }

          // prevent code like deno rt from going outside the vfs
          if search_stop_dir == Some(current_folder) {
            break;
          }
        }
      }

      Err(
        PackageNotFoundError {
          package_name: name.to_string(),
          referrer: referrer.display(),
          referrer_extra: None,
        }
        .into(),
      )
    }

    let path =
      inner(&self.sys, name, referrer, self.search_stop_dir.as_deref())?;
    self.sys.fs_canonicalize(&path).map_err(|err| {
      PackageFolderResolveIoError {
        package_name: name.to_string(),
        referrer: referrer.display(),
        source: err,
      }
      .into()
    })
  }

  fn resolve_types_package_folder(
    &self,
    types_package_name: &str,
    _maybe_package_version: Option<&Version>,
    maybe_referrer: Option<&UrlOrPathRef>,
  ) -> Option<PathBuf> {
    self
      .resolve_package_folder_from_package(types_package_name, maybe_referrer?)
      .ok()
  }
}

#[derive(Debug, Clone)]
pub struct ByonmInNpmPackageChecker;

impl InNpmPackageChecker for ByonmInNpmPackageChecker {
  fn in_npm_package(&self, specifier: &Url) -> bool {
    specifier.scheme() == "file"
      && specifier
        .path()
        .to_ascii_lowercase()
        .contains("/node_modules/")
  }
}
