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

use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;

use anyhow::Error as AnyError;
use deno_error::JsErrorBox;
use deno_npm::NpmPackageExtraInfo;
use deno_npm::NpmPackageId;
use deno_npm::NpmResolutionPackage;
use deno_npm::resolution::NpmResolutionSnapshot;
use deno_semver::SmallStackString;
use deno_semver::Version;
use deno_semver::package::PackageNv;
use deno_semver::package::PackageReq;
use sys_traits::FsMetadata;

use crate::CachedNpmPackageExtraInfoProvider;
use crate::LifecycleScriptsConfig;
use crate::PackagesAllowedScripts;

#[derive(Debug)]
pub struct PackageWithScript<'a> {
  pub package: &'a NpmResolutionPackage,
  pub scripts: HashMap<SmallStackString, String>,
  pub package_folder: PathBuf,
  /// The working directories to expose as `INIT_CWD` when running the scripts.
  ///
  /// These are the directories of the workspace members that depend on the
  /// package, so that workspace-aware lifecycle scripts (e.g. `@sveltejs/kit`'s
  /// `postinstall`) operate on each member. The scripts run once per entry.
  /// When empty, the scripts run once with the global init cwd.
  pub init_cwds: Vec<PathBuf>,
}

/// Resolves the on-disk folder of a package for the node_modules layout
/// being installed. Returning `None` for a package means it has no folder
/// in the layout (e.g. an optional dependency for another platform).
pub type ResolvePkgFolderFn<'a> =
  &'a (dyn Fn(&NpmPackageId) -> Option<PathBuf> + 'a);

pub struct LifecycleScriptsExecutorOptions<'a> {
  pub init_cwd: &'a Path,
  pub process_state: &'a str,
  pub root_node_modules_dir_path: &'a Path,
  /// Resolves the on-disk folder of a package for the node_modules layout
  /// being installed, so the executor can locate the dependency bins that
  /// lifecycle scripts invoke. When `None`, the executor falls back to its
  /// own npm resolver, which assumes the isolated (`.deno`) layout — the
  /// hoisted installer must provide this to map packages to their hoisted
  /// locations.
  pub resolve_pkg_folder: Option<ResolvePkgFolderFn<'a>>,
  pub on_ran_pkg_scripts:
    &'a dyn Fn(&NpmResolutionPackage) -> Result<(), JsErrorBox>,
  pub snapshot: &'a NpmResolutionSnapshot,
  pub system_packages: &'a [NpmResolutionPackage],
  pub additional_packages: &'a [&'a NpmResolutionPackage],
  pub packages_with_scripts: &'a [PackageWithScript<'a>],
  pub extra_info_provider: &'a CachedNpmPackageExtraInfoProvider,
}

pub struct LifecycleScriptsWarning {
  message: String,

  did_warn_fn: DidWarnFn,
}

impl std::fmt::Debug for LifecycleScriptsWarning {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    f.debug_struct("LifecycleScriptsWarning")
      .field("message", &self.message)
      .finish()
  }
}

type DidWarnFn =
  Box<dyn FnOnce(&dyn sys_traits::boxed::FsOpenBoxed) + Send + Sync>;

impl LifecycleScriptsWarning {
  pub(crate) fn new(message: String, did_warn_fn: DidWarnFn) -> Self {
    Self {
      message,
      did_warn_fn,
    }
  }

  pub fn into_message(
    self,
    sys: &dyn sys_traits::boxed::FsOpenBoxed,
  ) -> String {
    (self.did_warn_fn)(sys);
    self.message
  }
}

#[derive(Debug)]
pub struct NullLifecycleScriptsExecutor;

#[async_trait::async_trait(?Send)]
impl LifecycleScriptsExecutor for NullLifecycleScriptsExecutor {
  async fn execute(
    &self,
    _options: LifecycleScriptsExecutorOptions<'_>,
  ) -> Result<(), AnyError> {
    Ok(())
  }
}

#[async_trait::async_trait(?Send)]
pub trait LifecycleScriptsExecutor: Sync + Send {
  async fn execute(
    &self,
    options: LifecycleScriptsExecutorOptions<'_>,
  ) -> Result<(), AnyError>;
}

pub trait LifecycleScriptsStrategy {
  fn can_run_scripts(&self) -> bool {
    true
  }

  fn warn_on_scripts_not_run(
    &self,
    packages: &[(&NpmResolutionPackage, PathBuf)],
  ) -> Result<(), std::io::Error>;

  fn has_warned(&self, package: &NpmResolutionPackage) -> bool;

  fn has_run(&self, package: &NpmResolutionPackage) -> bool;
}

/// Builds a map from a package id to the workspace member directories that
/// declare it as a direct dependency. This is used to run a package's lifecycle
/// scripts once per declaring member with `INIT_CWD` pointing at that member, so
/// that workspace-aware scripts such as `@sveltejs/kit`'s `postinstall` operate
/// on each member.
///
/// When `deno install` runs in a workspace whose root is a `deno.json` (no root
/// `package.json`), tools that read the root `package.json` `workspaces` field
/// (as npm/svelte-kit do) cannot discover the members. Running the script per
/// member with the member as `INIT_CWD` reproduces what those tools would do in
/// a `package.json` workspace.
///
/// Packages that are also declared directly by the workspace root are omitted
/// so they keep running once with the global init cwd (npm's behaviour).
pub fn member_dep_init_cwds(
  deps_provider: &crate::package_json::NpmInstallDepsProvider,
  snapshot: &NpmResolutionSnapshot,
  workspace_root: Option<&Path>,
) -> HashMap<NpmPackageId, Vec<PathBuf>> {
  use std::collections::BTreeSet;
  let mut declarers: HashMap<NpmPackageId, BTreeSet<PathBuf>> = HashMap::new();
  let mut root_declared: HashSet<NpmPackageId> = HashSet::new();
  for remote_pkg in deps_provider.remote_pkgs() {
    let Ok(pkg) = snapshot.resolve_pkg_from_pkg_req(&remote_pkg.req) else {
      continue;
    };
    if workspace_root == Some(remote_pkg.base_dir.as_path()) {
      root_declared.insert(pkg.id.clone());
    } else {
      declarers
        .entry(pkg.id.clone())
        .or_default()
        .insert(remote_pkg.base_dir.clone());
    }
  }
  declarers
    .into_iter()
    .filter(|(id, _)| !root_declared.contains(id))
    .map(|(id, dirs)| (id, dirs.into_iter().collect()))
    .collect()
}

pub fn has_lifecycle_scripts(
  sys: &impl FsMetadata,
  extra: &NpmPackageExtraInfo,
  package_path: &Path,
) -> bool {
  if let Some(install) = extra.scripts.get("install") {
    {
      // default script
      if !is_broken_default_install_script(sys, install, package_path) {
        return true;
      }
    }
  }
  extra.scripts.contains_key("preinstall")
    || extra.scripts.contains_key("postinstall")
}

// npm defaults to running `node-gyp rebuild` if there is a `binding.gyp` file
// but it always fails if the package excludes the `binding.gyp` file when they publish.
// (for example, `fsevents` hits this)
pub fn is_broken_default_install_script(
  sys: &impl FsMetadata,
  script: &str,
  package_path: &Path,
) -> bool {
  script == "node-gyp rebuild"
    && !sys.fs_exists_no_err(package_path.join("binding.gyp"))
}

pub struct LifecycleScripts<'a, TSys: FsMetadata> {
  sys: &'a TSys,
  packages_with_scripts: Vec<PackageWithScript<'a>>,
  packages_with_scripts_not_run: Vec<(&'a NpmResolutionPackage, PathBuf)>,

  config: &'a LifecycleScriptsConfig,
  strategy: Box<dyn LifecycleScriptsStrategy + 'a>,
}

impl<'a, TSys: FsMetadata> LifecycleScripts<'a, TSys> {
  pub fn new<TLifecycleScriptsStrategy: LifecycleScriptsStrategy + 'a>(
    sys: &'a TSys,
    config: &'a LifecycleScriptsConfig,
    strategy: TLifecycleScriptsStrategy,
  ) -> Self {
    Self {
      sys,
      config,
      packages_with_scripts: Vec::new(),
      packages_with_scripts_not_run: Vec::new(),
      strategy: Box::new(strategy),
    }
  }

  pub fn can_run_scripts(&self, package_nv: &PackageNv) -> bool {
    fn matches_nv(req: &PackageReq, package_nv: &PackageNv) -> bool {
      // we shouldn't support this being a tag because it's too complicated
      debug_assert!(req.version_req.tag().is_none());
      package_nv.name == req.name
        && req.version_req.matches(&package_nv.version)
    }

    if !self.strategy.can_run_scripts() {
      return false;
    }
    let matches_allowed = match &self.config.allowed {
      PackagesAllowedScripts::All => true,
      PackagesAllowedScripts::Some(allow_list) => {
        allow_list.iter().any(|req| matches_nv(req, package_nv))
      }
      PackagesAllowedScripts::None => false,
    };
    matches_allowed
      && !self
        .config
        .denied
        .iter()
        .any(|req| matches_nv(req, package_nv))
  }

  pub fn has_run_scripts(&self, package: &NpmResolutionPackage) -> bool {
    self.strategy.has_run(package)
  }

  /// Register a package for running lifecycle scripts, if applicable.
  ///
  /// `package_path` is the path containing the package's code (its root dir).
  /// `package_meta_path` is the path to serve as the base directory for lifecycle
  /// script-related metadata (e.g. to store whether the scripts have been run already)
  pub fn add(
    &mut self,
    package: &'a NpmResolutionPackage,
    extra: &NpmPackageExtraInfo,
    package_path: Cow<'_, Path>,
    init_cwds: Vec<PathBuf>,
  ) {
    if has_lifecycle_scripts(self.sys, extra, &package_path) {
      if self.can_run_scripts(&package.id.nv) {
        if !self.has_run_scripts(package) {
          self.packages_with_scripts.push(PackageWithScript {
            package,
            scripts: extra.scripts.clone(),
            package_folder: package_path.into_owned(),
            init_cwds,
          });
        }
      } else if !self.has_run_scripts(package)
        && (self.config.explicit_install || !self.strategy.has_warned(package))
        && !(self.config.denied.iter().any(|d| {
          package.id.nv.name == d.name
            && d.version_req.matches(&package.id.nv.version)
        }))
      {
        // Skip adding `esbuild` as it is known that it can work properly without lifecycle script
        // being run, and it's also very popular - any project using Vite would raise warnings.
        {
          let nv = &package.id.nv;
          if nv.name == "esbuild"
            && nv.version >= Version::parse_standard("0.18.0").unwrap()
          {
            return;
          }
        }

        self
          .packages_with_scripts_not_run
          .push((package, package_path.into_owned()));
      }
    }
  }

  pub fn warn_not_run_scripts(&self) -> Result<(), std::io::Error> {
    if !self.packages_with_scripts_not_run.is_empty() {
      self
        .strategy
        .warn_on_scripts_not_run(&self.packages_with_scripts_not_run)?;
    }
    Ok(())
  }

  pub fn packages_with_scripts(&self) -> &[PackageWithScript<'a>] {
    &self.packages_with_scripts
  }
}

pub static LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR: &str =
  "DENO_INTERNAL_IS_LIFECYCLE_SCRIPT";

pub fn is_running_lifecycle_script(sys: &impl sys_traits::EnvVar) -> bool {
  sys.env_var(LIFECYCLE_SCRIPTS_RUNNING_ENV_VAR).is_ok()
}

/// Groups packages with lifecycle scripts into topological layers using
/// Kahn's algorithm. Packages in the same layer have no inter-dependencies
/// (considering only packages that have lifecycle scripts), so they can
/// run in parallel. Later layers depend on earlier ones.
///
/// This considers transitive dependencies through the full snapshot, not
/// just direct dependencies. For example, if A depends on B (no scripts)
/// which depends on C (has scripts), A will be placed in a later layer
/// than C.
pub fn compute_lifecycle_script_layers<'a>(
  packages: &'a [PackageWithScript<'a>],
  snapshot: &'a NpmResolutionSnapshot,
  additional_packages: &'a [&'a NpmResolutionPackage],
) -> Vec<Vec<&'a PackageWithScript<'a>>> {
  if packages.len() <= 1 {
    return vec![packages.iter().collect()];
  }

  let start = std::time::Instant::now();
  let script_pkg_ids: HashSet<&NpmPackageId> =
    packages.iter().map(|p| &p.package.id).collect();
  let pkg_by_id: HashMap<&NpmPackageId, &PackageWithScript> =
    packages.iter().map(|p| (&p.package.id, p)).collect();
  let additional_pkg_by_id: HashMap<&NpmPackageId, &NpmResolutionPackage> =
    additional_packages
      .iter()
      .map(|package| (&package.id, *package))
      .collect();

  // for each package, find transitive deps that have lifecycle scripts
  // (walking through intermediate packages that don't have scripts)
  let mut in_degree: HashMap<&NpmPackageId, usize> = HashMap::new();
  let mut dependents: HashMap<&NpmPackageId, Vec<&NpmPackageId>> =
    HashMap::new();
  for pkg in packages {
    let transitive_script_deps = find_transitive_script_deps(
      pkg.package,
      &script_pkg_ids,
      snapshot,
      &additional_pkg_by_id,
    );
    in_degree.insert(&pkg.package.id, transitive_script_deps.len());
    for dep_id in transitive_script_deps {
      dependents.entry(dep_id).or_default().push(&pkg.package.id);
    }
  }

  // if no package has any script deps, everything is one layer
  if in_degree.values().all(|&deg| deg == 0) {
    return vec![packages.iter().collect()];
  }

  // peel off layers using Kahn's algorithm
  let mut layers = Vec::new();
  let mut queue: VecDeque<&NpmPackageId> = in_degree
    .iter()
    .filter(|(_, deg)| **deg == 0)
    .map(|(&id, _)| id)
    .collect();

  while !queue.is_empty() {
    let layer: Vec<&PackageWithScript> =
      queue.iter().map(|id| pkg_by_id[id]).collect();
    let mut next_queue = VecDeque::new();
    for id in queue.drain(..) {
      if let Some(deps) = dependents.get(id) {
        for &dep_id in deps {
          let deg = in_degree.get_mut(dep_id).unwrap();
          *deg -= 1;
          if *deg == 0 {
            next_queue.push_back(dep_id);
          }
        }
      }
    }
    layers.push(layer);
    queue = next_queue;
  }

  log::debug!(
    "Computed lifecycle script layers in {}ms.",
    start.elapsed().as_millis()
  );

  layers
}

/// Finds all transitive dependency package IDs that have lifecycle scripts,
/// walking through intermediate packages that may not have scripts themselves.
fn find_transitive_script_deps<'a>(
  package: &'a NpmResolutionPackage,
  script_pkg_ids: &HashSet<&'a NpmPackageId>,
  snapshot: &'a NpmResolutionSnapshot,
  additional_pkg_by_id: &HashMap<&'a NpmPackageId, &'a NpmResolutionPackage>,
) -> HashSet<&'a NpmPackageId> {
  let mut result = HashSet::new();
  let mut visited = HashSet::new();
  let mut stack: Vec<&NpmPackageId> = package.dependencies.values().collect();

  while let Some(dep_id) = stack.pop() {
    if !visited.insert(dep_id) {
      continue;
    }
    if script_pkg_ids.contains(dep_id) {
      result.insert(dep_id);
      // don't walk further — this script package forms a layer boundary
      continue;
    }
    // walk through non-script packages to find transitive script deps
    if let Some(dep_pkg) = snapshot
      .package_from_id(dep_id)
      .or_else(|| additional_pkg_by_id.get(dep_id).copied())
    {
      stack.extend(dep_pkg.dependencies.values());
    }
  }

  result
}

#[cfg(test)]
mod tests {
  use std::collections::HashMap;
  use std::path::PathBuf;

  use deno_npm::NpmPackageId;
  use deno_npm::NpmResolutionPackage;
  use deno_npm::NpmResolutionPackageSystemInfo;
  use deno_npm::resolution::NpmResolutionSnapshot;
  use deno_npm::resolution::SerializedNpmResolutionSnapshot;
  use deno_npm::resolution::SerializedNpmResolutionSnapshotPackage;
  use deno_semver::StackString;
  use deno_semver::package::PackageReq;

  use super::PackageWithScript;
  use super::compute_lifecycle_script_layers;

  fn pkg_id(s: &str) -> NpmPackageId {
    NpmPackageId::from_serialized(s).unwrap()
  }

  fn deps(pairs: &[(&str, &str)]) -> HashMap<StackString, NpmPackageId> {
    pairs
      .iter()
      .map(|(k, v)| (StackString::from(*k), pkg_id(v)))
      .collect()
  }

  fn pkg(
    id: &str,
    dependencies: &[(&str, &str)],
  ) -> SerializedNpmResolutionSnapshotPackage {
    SerializedNpmResolutionSnapshotPackage {
      id: pkg_id(id),
      system: Default::default(),
      dist: None,
      dependencies: deps(dependencies),
      optional_dependencies: Default::default(),
      optional_peer_dependencies: Default::default(),
      extra: None,
      is_deprecated: false,
      has_bin: false,
      has_scripts: false,
    }
  }

  fn resolution_pkg(
    id: &str,
    dependencies: &[(&str, &str)],
    has_scripts: bool,
  ) -> NpmResolutionPackage {
    NpmResolutionPackage {
      id: pkg_id(id),
      copy_index: 0,
      system: NpmResolutionPackageSystemInfo::default(),
      dist: None,
      dependencies: deps(dependencies),
      optional_dependencies: Default::default(),
      optional_peer_dependencies: Default::default(),
      extra: None,
      is_deprecated: false,
      has_bin: false,
      has_scripts,
    }
  }

  fn make_snapshot(
    root: &[(&str, &str)],
    packages: Vec<SerializedNpmResolutionSnapshotPackage>,
  ) -> NpmResolutionSnapshot {
    let serialized = SerializedNpmResolutionSnapshot {
      root_packages: root
        .iter()
        .map(|(k, v)| (PackageReq::from_str(k).unwrap(), pkg_id(v)))
        .collect(),
      packages,
    };
    NpmResolutionSnapshot::new(serialized.into_valid().unwrap())
  }

  fn make_pkg_with_script<'a>(
    id: &str,
    snapshot: &'a NpmResolutionSnapshot,
  ) -> PackageWithScript<'a> {
    make_pkg_with_script_from_package(
      snapshot.package_from_id(&pkg_id(id)).unwrap(),
    )
  }

  fn make_pkg_with_script_from_package(
    package: &NpmResolutionPackage,
  ) -> PackageWithScript<'_> {
    PackageWithScript {
      package,
      scripts: HashMap::from([("install".into(), "node-gyp rebuild".into())]),
      package_folder: PathBuf::from(format!("/tmp/{}", package.id)),
      init_cwds: Vec::new(),
    }
  }

  /// extracts sorted package names from each layer for easy assertion
  fn layer_names(layers: &[Vec<&PackageWithScript>]) -> Vec<Vec<String>> {
    layers
      .iter()
      .map(|layer| {
        let mut names: Vec<String> = layer
          .iter()
          .map(|p| p.package.id.nv.name.to_string())
          .collect();
        names.sort();
        names
      })
      .collect()
  }

  #[test]
  fn single_package() {
    let snapshot =
      make_snapshot(&[("a@1", "a@1.0.0")], vec![pkg("a@1.0.0", &[])]);
    let pkgs = vec![make_pkg_with_script("a@1.0.0", &snapshot)];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(layers.len(), 1);
    assert_eq!(layer_names(&layers), vec![vec!["a"]]);
  }

  #[test]
  fn no_interdependencies() {
    // a and b have scripts but don't depend on each other
    let snapshot = make_snapshot(
      &[("a@1", "a@1.0.0"), ("b@1", "b@1.0.0")],
      vec![pkg("a@1.0.0", &[]), pkg("b@1.0.0", &[])],
    );
    let pkgs = vec![
      make_pkg_with_script("a@1.0.0", &snapshot),
      make_pkg_with_script("b@1.0.0", &snapshot),
    ];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(layers.len(), 1);
    assert_eq!(layer_names(&layers), vec![vec!["a", "b"]]);
  }

  #[test]
  fn direct_dependency_chain() {
    // a depends on b, both have scripts => two layers
    let snapshot = make_snapshot(
      &[("a@1", "a@1.0.0")],
      vec![pkg("a@1.0.0", &[("b", "b@1.0.0")]), pkg("b@1.0.0", &[])],
    );
    let pkgs = vec![
      make_pkg_with_script("a@1.0.0", &snapshot),
      make_pkg_with_script("b@1.0.0", &snapshot),
    ];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(layer_names(&layers), vec![vec!["b"], vec!["a"]]);
  }

  #[test]
  fn transitive_through_non_script_package() {
    // a -> b (no scripts) -> c (has scripts)
    // a and c should be in different layers
    let snapshot = make_snapshot(
      &[("a@1", "a@1.0.0")],
      vec![
        pkg("a@1.0.0", &[("b", "b@1.0.0")]),
        pkg("b@1.0.0", &[("c", "c@1.0.0")]),
        pkg("c@1.0.0", &[]),
      ],
    );
    // only a and c have scripts, b does not
    let pkgs = vec![
      make_pkg_with_script("a@1.0.0", &snapshot),
      make_pkg_with_script("c@1.0.0", &snapshot),
    ];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(layer_names(&layers), vec![vec!["c"], vec!["a"]]);
  }

  #[test]
  fn transitive_through_additional_non_script_package() {
    // a -> b (no scripts) -> c (has scripts), where all three packages are
    // synthetic workspace packages that are not present in the npm snapshot.
    let snapshot = make_snapshot(&[], vec![]);
    let additional = [
      resolution_pkg("a@1.0.0", &[("b", "b@1.0.0")], true),
      resolution_pkg("b@1.0.0", &[("c", "c@1.0.0")], false),
      resolution_pkg("c@1.0.0", &[], true),
    ];
    let pkgs = vec![
      make_pkg_with_script_from_package(&additional[0]),
      make_pkg_with_script_from_package(&additional[2]),
    ];
    let additional_refs = additional.iter().collect::<Vec<_>>();
    let layers =
      compute_lifecycle_script_layers(&pkgs, &snapshot, &additional_refs);
    assert_eq!(layer_names(&layers), vec![vec!["c"], vec!["a"]]);
  }

  #[test]
  fn diamond_dependency() {
    // a -> b, a -> c, b -> d, c -> d
    // all have scripts
    // layer 0: d, layer 1: b + c, layer 2: a
    let snapshot = make_snapshot(
      &[("a@1", "a@1.0.0")],
      vec![
        pkg("a@1.0.0", &[("b", "b@1.0.0"), ("c", "c@1.0.0")]),
        pkg("b@1.0.0", &[("d", "d@1.0.0")]),
        pkg("c@1.0.0", &[("d", "d@1.0.0")]),
        pkg("d@1.0.0", &[]),
      ],
    );
    let pkgs = vec![
      make_pkg_with_script("a@1.0.0", &snapshot),
      make_pkg_with_script("b@1.0.0", &snapshot),
      make_pkg_with_script("c@1.0.0", &snapshot),
      make_pkg_with_script("d@1.0.0", &snapshot),
    ];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(
      layer_names(&layers),
      vec![vec!["d"], vec!["b", "c"], vec!["a"]]
    );
  }

  #[test]
  fn empty_packages() {
    let snapshot = make_snapshot(&[], vec![]);
    let pkgs: Vec<PackageWithScript> = vec![];
    let layers = compute_lifecycle_script_layers(&pkgs, &snapshot, &[]);
    assert_eq!(layers.len(), 1);
    assert!(layers[0].is_empty());
  }
}
