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

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use deno_cache_dir::GlobalOrLocalHttpCache;
use deno_core::anyhow::Context;
use deno_core::anyhow::bail;
use deno_core::error::AnyError;
use deno_core::url::Url;
use deno_graph::ModuleGraph;
use deno_graph::packages::PackageSpecifiers;
use deno_npm_installer::graph::NpmCachingStrategy;
use sys_traits::FsCanonicalize;
use sys_traits::FsCreateDirAll;
use walkdir::WalkDir;

use crate::args::CleanFlags;
use crate::args::Flags;
use crate::colors;
use crate::display;
use crate::factory::CliFactory;
use crate::graph_container::CollectSpecifiersOptions;
use crate::graph_container::ModuleGraphContainer;
use crate::graph_container::ModuleGraphUpdatePermit;
use crate::graph_util::BuildGraphWithNpmOptions;
use crate::sys::CliSys;
use crate::util::fs::FsCleaner;
use crate::util::progress_bar::ProgressBar;
use crate::util::progress_bar::ProgressBarStyle;
use crate::util::progress_bar::ProgressMessagePrompt;

pub async fn clean(
  flags: Arc<Flags>,
  clean_flags: CleanFlags,
) -> Result<(), AnyError> {
  if !clean_flags.except_paths.is_empty() {
    return clean_except(flags, &clean_flags.except_paths, clean_flags.dry_run)
      .await;
  }

  let factory = CliFactory::from_flags(flags);
  let deno_dir = factory.deno_dir()?;
  if deno_dir.root.exists() {
    let no_of_files = walkdir::WalkDir::new(&deno_dir.root).into_iter().count();
    let progress_bar = ProgressBar::new(ProgressBarStyle::ProgressBars);
    let progress_guard =
      progress_bar.update_with_prompt(ProgressMessagePrompt::Cleaning, "");
    progress_guard.set_total_size(no_of_files.try_into().unwrap());
    let mut cleaner = FsCleaner::new(Some(progress_guard));

    // On a dry run, tally up what would be removed without deleting anything;
    // the reported output is otherwise identical to a real clean.
    if clean_flags.dry_run {
      cleaner.tally(&deno_dir.root)?;
    } else {
      cleaner.rm_rf(&deno_dir.root)?;
    }

    // Drop the guard so that progress bar disappears.
    drop(cleaner.progress_guard.take());

    log::info!(
      "{} {} {}",
      colors::green("Removed"),
      deno_dir.root.display(),
      colors::gray(&format!(
        "({} files, {})",
        cleaner.files_removed + cleaner.dirs_removed,
        display::human_size(cleaner.bytes_removed as f64)
      ))
    );

    if clean_flags.dry_run {
      log::info!("{}", colors::yellow("Aborting due to --dry-run flag"));
    } else {
      report_locked_files(&cleaner.failed);
    }
  }

  Ok(())
}

/// Warns about cache files that could not be removed because another process is
/// holding them open. On Windows this resolves the offending process(es) via
/// the Restart Manager and points the user at a running Deno language server,
/// which is the most common cause (see issue #33903).
fn report_locked_files(failed: &[(PathBuf, std::io::Error)]) {
  if failed.is_empty() {
    return;
  }
  let paths = failed
    .iter()
    .map(|(path, _)| path.clone())
    .collect::<Vec<_>>();
  let processes = crate::util::windows::processes_locking_files(&paths);
  log::warn!("{}", format_locked_files_message(&paths, &processes));
}

fn format_locked_files_message(
  paths: &[PathBuf],
  processes: &[crate::util::windows::LockingProcess],
) -> String {
  const MAX_LISTED: usize = 10;
  let mut message = format!(
    "{} could not be removed because they are in use by another process:",
    if paths.len() == 1 {
      "1 file".to_string()
    } else {
      format!("{} files", paths.len())
    }
  );
  for path in paths.iter().take(MAX_LISTED) {
    message.push_str(&format!("\n  {}", path.display()));
  }
  if paths.len() > MAX_LISTED {
    message.push_str(&format!("\n  ... and {} more", paths.len() - MAX_LISTED));
  }

  if processes.is_empty() {
    message.push_str(
      "\n\nThis is usually an editor running the Deno language server holding \
       the cache open. Close it (or any running `deno` process) and run `deno \
       clean` again.",
    );
  } else {
    message.push_str("\n\nHeld open by:");
    for process in processes {
      let label = match &process.exe {
        Some(exe) => exe.display().to_string(),
        None => process.name.clone(),
      };
      let hint = if process.is_deno {
        " (a running Deno process, likely the language server in your editor)"
      } else {
        ""
      };
      message.push_str(&format!("\n  {} (PID {}){}", label, process.pid, hint));
    }
    message
      .push_str("\n\nClose the process(es) above and run `deno clean` again.");
  }

  message
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Found {
  Match,
  Prefix,
}

#[derive(Clone, Debug, Default)]
struct PathNode {
  exact: bool,
  children: BTreeMap<OsString, usize>,
}

#[derive(Debug)]
struct PathTrie {
  root: usize,
  nodes: Vec<PathNode>,
  rewrites: Vec<(PathBuf, PathBuf)>,
}

impl PathTrie {
  fn new() -> Self {
    Self {
      root: 0,
      nodes: vec![PathNode {
        exact: false,
        children: Default::default(),
      }],
      rewrites: vec![],
    }
  }

  fn add_rewrite(&mut self, from: PathBuf, to: PathBuf) {
    self.rewrites.push((from, to));
  }

  fn rewrite<'a>(&self, s: Cow<'a, Path>) -> Cow<'a, Path> {
    let normalized = deno_path_util::normalize_path(s);
    for (from, to) in &self.rewrites {
      if normalized.starts_with(from) {
        return Cow::Owned(to.join(normalized.strip_prefix(from).unwrap()));
      }
    }
    normalized
  }

  fn insert(&mut self, s: PathBuf) {
    let normalized = self.rewrite(Cow::Owned(s));
    let components = normalized.components().map(|c| c.as_os_str());
    let mut node = self.root;

    for component in components {
      if let Some(nd) = self.nodes[node].children.get(component).copied() {
        node = nd;
      } else {
        let id = self.nodes.len();
        self.nodes.push(PathNode::default());
        self.nodes[node]
          .children
          .insert(component.to_os_string(), id);
        node = id;
      }
    }

    self.nodes[node].exact = true;
  }

  fn find(&self, s: &Path) -> Option<Found> {
    let normalized = self.rewrite(Cow::Borrowed(s));
    let components = normalized.components().map(|c| c.as_os_str());
    let mut node = self.root;

    for component in components {
      if let Some(nd) = self.nodes[node].children.get(component).copied() {
        node = nd;
      } else {
        return None;
      }
    }

    Some(if self.nodes[node].exact {
      Found::Match
    } else {
      Found::Prefix
    })
  }
}

fn try_get_canonicalized_root_dir<Sys: FsCanonicalize + FsCreateDirAll>(
  sys: &Sys,
  root_dir: &Path,
) -> Result<PathBuf, std::io::Error> {
  match sys.fs_canonicalize(root_dir) {
    Ok(path) => Ok(path),
    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
      sys.fs_create_dir_all(root_dir)?;
      sys.fs_canonicalize(root_dir)
    }
    Err(err) => Err(err),
  }
}

async fn clean_except(
  flags: Arc<Flags>,
  entrypoints: &[String],
  dry_run: bool,
) -> Result<(), AnyError> {
  let mut state = FsCleaner::default();

  let factory = CliFactory::from_flags(flags.clone());
  let sys = factory.sys();
  let options = factory.cli_options()?;
  let main_graph_container = factory.main_module_graph_container().await?;
  let roots = main_graph_container.collect_specifiers(
    entrypoints,
    CollectSpecifiersOptions {
      include_ignored_specified: true,
    },
  )?;
  let http_cache = factory.global_http_cache()?;
  let local_or_global_http_cache = factory.http_cache()?.clone();
  let deno_dir = factory.deno_dir()?.clone();
  let deno_dir_root_canonical =
    try_get_canonicalized_root_dir(&sys, &deno_dir.root)
      .unwrap_or(deno_dir.root.clone());

  let mut permit = main_graph_container.acquire_update_permit().await;
  let graph = permit.graph_mut();
  graph.packages = PackageSpecifiers::default();
  let graph_builder = factory.module_graph_builder().await?;
  graph_builder
    .build_graph_roots_with_npm_resolution(
      graph,
      roots.clone(),
      BuildGraphWithNpmOptions {
        loader: None,
        is_dynamic: false,
        npm_caching: NpmCachingStrategy::Manual,
      },
    )
    .await?;

  let npm_resolver = factory.npm_resolver().await?;

  let mut keep = HashSet::new();
  let mut npm_reqs = Vec::new();

  let mut keep_paths_trie = PathTrie::new();
  if deno_dir_root_canonical != deno_dir.root {
    keep_paths_trie
      .add_rewrite(deno_dir.root.clone(), deno_dir_root_canonical.clone());
  }
  for (_, entry) in graph.walk(
    roots.iter(),
    deno_graph::WalkOptions {
      check_js: deno_graph::CheckJsOption::False,
      follow_dynamic: true,
      kind: graph.graph_kind(),
      prefer_fast_check_graph: false,
    },
  ) {
    match entry {
      deno_graph::ModuleEntryRef::Module(module) => match module {
        deno_graph::Module::Js(js_module) => {
          keep.insert(&js_module.specifier);
        }
        deno_graph::Module::Json(json_module) => {
          keep.insert(&json_module.specifier);
        }
        deno_graph::Module::Wasm(wasm_module) => {
          keep.insert(&wasm_module.specifier);
        }
        deno_graph::Module::Npm(npm_module) => {
          if let Some(managed) = npm_resolver.as_managed() {
            let id = managed
              .resolution()
              .resolve_pkg_id_from_pkg_req(npm_module.pkg_req_ref.req())
              .unwrap();
            npm_reqs
              .extend(managed.resolution().resolve_pkg_reqs_from_pkg_id(&id));
          }
        }
        deno_graph::Module::Node(_) => {}
        deno_graph::Module::External(_) => {}
      },
      deno_graph::ModuleEntryRef::Err(_) => {}
      deno_graph::ModuleEntryRef::Redirect(_) => {}
    }
  }

  for url in &keep {
    if (url.scheme() == "http" || url.scheme() == "https")
      && let Ok(path) = http_cache.local_path_for_url(url)
    {
      keep_paths_trie.insert(path);
    }
    if let Some(path) = deno_dir
      .gen_cache
      .get_cache_filename_with_extension(url, "js")
    {
      let path = deno_dir.gen_cache.location.join(path);
      keep_paths_trie.insert(path);
    }
  }

  let npm_cache = factory.npm_cache()?;
  let snap = npm_resolver.as_managed().unwrap().resolution().snapshot();
  // TODO(nathanwhit): remove once we don't need packuments for creating the snapshot from lockfile
  for package in snap.all_system_packages(&options.npm_system_info()) {
    keep_paths_trie.insert(
      npm_cache
        .package_name_folder(&package.id.nv.name)
        .join("registry.json"),
    );
  }
  let snap = snap.subset(&npm_reqs);
  let node_modules_path = npm_resolver.root_node_modules_path();
  let mut node_modules_keep = HashSet::new();
  for package in snap.all_system_packages(&options.npm_system_info()) {
    if node_modules_path.is_some() {
      node_modules_keep.insert(package.get_package_cache_folder_id());
    }
    keep_paths_trie.insert(npm_cache.package_folder_for_id(
      &deno_npm::NpmPackageCacheFolderId {
        nv: package.id.nv.clone(),
        copy_index: package.copy_index,
      },
    ));
  }

  if dry_run {
    #[allow(clippy::print_stderr, reason = "actually want to output")]
    {
      eprintln!("would remove:");
    }
  }

  let jsr_url = crate::args::jsr_url();
  add_jsr_meta_paths(graph, &mut keep_paths_trie, jsr_url, &|url| {
    http_cache
      .local_path_for_url(url)
      .map_err(Into::into)
      .map(Some)
  })?;
  walk_removing(
    &mut state,
    walkdir::WalkDir::new(&deno_dir.root)
      .contents_first(false)
      .min_depth(2),
    &keep_paths_trie,
    &deno_dir.root,
    dry_run,
  )?;
  let mut node_modules_cleaned = FsCleaner::default();

  if let Some(dir) = node_modules_path {
    clean_node_modules(
      &mut node_modules_cleaned,
      &node_modules_keep,
      dir,
      dry_run,
    )?;
  }

  let mut vendor_cleaned = FsCleaner::default();
  if let Some(vendor_dir) = options.vendor_dir_path()
    && let GlobalOrLocalHttpCache::Local(cache) = local_or_global_http_cache
  {
    let mut trie = PathTrie::new();
    if deno_dir_root_canonical != deno_dir.root {
      trie.add_rewrite(deno_dir.root.clone(), deno_dir_root_canonical);
    }
    let cache = cache.clone();
    add_jsr_meta_paths(graph, &mut trie, jsr_url, &|url| match cache
      .local_path_for_url(url)
    {
      Ok(path) => Ok(path),
      Err(err) => {
        log::warn!(
          "failed to get local path for jsr meta url {}: {}",
          url,
          err
        );
        Ok(None)
      }
    })?;
    for url in keep {
      if url.scheme() == "http" || url.scheme() == "https" {
        match cache.local_path_for_url(url) {
          Ok(Some(path)) => {
            trie.insert(path);
          }
          Ok(None) => {}
          Err(err) => {
            log::warn!("failed to get local path for url {}: {}", url, err);
          }
        }
      }
    }

    walk_removing(
      &mut vendor_cleaned,
      WalkDir::new(vendor_dir).contents_first(false),
      &trie,
      vendor_dir,
      dry_run,
    )?;
  }

  if !dry_run {
    log_stats(&state, &deno_dir.root);

    if let Some(dir) = node_modules_path {
      log_stats(&node_modules_cleaned, dir);
    }
    if let Some(dir) = options.vendor_dir_path() {
      log_stats(&vendor_cleaned, dir);
    }

    let mut failed = state.failed;
    failed.extend(node_modules_cleaned.failed);
    failed.extend(vendor_cleaned.failed);
    report_locked_files(&failed);
  }

  Ok(())
}

fn log_stats(cleaner: &FsCleaner, dir: &Path) {
  if cleaner.bytes_removed == 0
    && cleaner.dirs_removed == 0
    && cleaner.files_removed == 0
  {
    return;
  }
  log::info!(
    "{} {}",
    colors::green("Removed"),
    colors::gray(&format!(
      "{} files, {} from {}",
      cleaner.files_removed + cleaner.dirs_removed,
      display::human_size(cleaner.bytes_removed as f64),
      dir.display()
    ))
  );
}

fn add_jsr_meta_paths(
  graph: &ModuleGraph,
  path_trie: &mut PathTrie,
  jsr_url: &Url,
  url_to_path: &dyn Fn(&Url) -> Result<Option<PathBuf>, AnyError>,
) -> Result<(), AnyError> {
  for package in graph.packages.mappings().values() {
    let Ok(base_url) = jsr_url.join(&format!("{}/", &package.name)) else {
      continue;
    };
    let keep = url_to_path(&base_url.join("meta.json").unwrap())?;
    if let Some(keep) = keep {
      path_trie.insert(keep);
    }
    let keep = url_to_path(
      &base_url
        .join(&format!("{}_meta.json", package.version))
        .unwrap(),
    )?;
    if let Some(keep) = keep {
      path_trie.insert(keep);
    }
  }
  Ok(())
}

// TODO(nathanwhit): use strategy pattern instead of branching on dry_run
fn walk_removing(
  cleaner: &mut FsCleaner,
  walker: WalkDir,
  trie: &PathTrie,
  base: &Path,
  dry_run: bool,
) -> Result<(), AnyError> {
  let mut walker = walker.into_iter();
  while let Some(entry) = walker.next() {
    let entry = entry?;
    if let Some(found) = trie.find(entry.path()) {
      if entry.file_type().is_dir() && matches!(found, Found::Match) {
        walker.skip_current_dir();
        continue;
      }
      continue;
    }
    if !entry.path().starts_with(base) {
      panic!(
        "would have removed a file outside of the base directory: base: {}, path: {}",
        base.display(),
        entry.path().display()
      );
    }
    if entry.file_type().is_dir() {
      if dry_run {
        #[allow(clippy::print_stderr, reason = "actually want to output")]
        {
          eprintln!(" {}", entry.path().display());
        }
      } else {
        cleaner.rm_rf(entry.path())?;
      }
      walker.skip_current_dir();
    } else if dry_run {
      #[allow(clippy::print_stderr, reason = "actually want to output")]
      {
        eprintln!(" {}", entry.path().display());
      }
    } else {
      cleaner.remove_file(entry.path(), Some(entry.metadata()?))?;
    }
  }

  Ok(())
}

fn clean_node_modules(
  cleaner: &mut FsCleaner,
  keep_pkgs: &HashSet<deno_npm::NpmPackageCacheFolderId>,
  dir: &Path,
  dry_run: bool,
) -> Result<(), AnyError> {
  if !dir.ends_with("node_modules") || !dir.is_dir() {
    bail!("expected a node_modules directory, got: {}", dir.display());
  }
  let base = dir.join(".deno");
  if !base.exists() {
    return Ok(());
  }

  let keep_ids = keep_pkgs
    .iter()
    .map(deno_resolver::npm::get_package_folder_id_folder_name)
    .collect::<HashSet<_>>();

  // remove the actual packages from node_modules/.deno
  let entries = match std::fs::read_dir(&base) {
    Ok(entries) => entries,
    Err(err)
      if matches!(
        err.kind(),
        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
      ) =>
    {
      return Ok(());
    }
    Err(err) => {
      return Err(err).with_context(|| {
        format!(
          "failed to clean node_modules directory at {}",
          dir.display()
        )
      });
    }
  };

  // TODO(nathanwhit): this probably shouldn't reach directly into this code
  let sys = CliSys::default();
  let mut setup_cache = deno_npm_installer::LocalSetupCache::load(
    sys.clone(),
    base.join(".setup-cache.bin"),
  );

  for entry in entries {
    let entry = entry?;
    if !entry.file_type()?.is_dir() {
      continue;
    }
    let file_name = entry.file_name();
    let file_name = file_name.to_string_lossy();
    if keep_ids.contains(file_name.as_ref()) || file_name == "node_modules" {
      continue;
    } else if dry_run {
      #[allow(clippy::print_stderr, reason = "actually want to output")]
      {
        eprintln!(" {}", entry.path().display());
      }
    } else {
      cleaner.rm_rf(&entry.path())?;
    }
  }

  let mut remove_symlink = |path: &Path| -> std::io::Result<()> {
    if dry_run {
      #[allow(clippy::print_stderr, reason = "actually want to output")]
      {
        eprintln!(" {}", path.display());
      }
      Ok(())
    } else {
      cleaner.remove_file(path, None)
    }
  };

  // remove top level symlinks from node_modules/<package> to node_modules/.deno/<package>
  // where the target doesn't exist (because it was removed above)
  deno_npm_installer::remove_unused_node_modules_symlinks(
    &sys,
    dir,
    &keep_ids,
    &mut |name, path| {
      setup_cache.remove_root_symlink(name);
      remove_symlink(path)
    },
  )
  .map_err(AnyError::from)?;

  // remove symlinks from node_modules/.deno/node_modules/<package> to node_modules/.deno/<package>
  // where the target doesn't exist (because it was removed above)
  let deno_nm = base.join("node_modules");
  deno_npm_installer::remove_unused_node_modules_symlinks(
    &sys,
    &deno_nm,
    &keep_ids,
    &mut |name, path| {
      setup_cache.remove_deno_symlink(name);
      remove_symlink(path)
    },
  )
  .map_err(AnyError::from)?;
  if !dry_run {
    setup_cache.save();
  }

  Ok(())
}

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

  use super::Found::*;
  use super::format_locked_files_message;
  use crate::util::windows::LockingProcess;

  #[test]
  fn locked_files_message_without_processes() {
    let paths = vec![PathBuf::from("/deno/dep_analysis_cache_v2")];
    let message = format_locked_files_message(&paths, &[]);
    assert!(message.starts_with(
      "1 file could not be removed because they are in use by another process:"
    ));
    assert!(message.contains("dep_analysis_cache_v2"));
    assert!(message.contains("Deno language server"));
  }

  #[test]
  fn locked_files_message_lists_holding_processes() {
    let paths = vec![
      PathBuf::from("/deno/dep_analysis_cache_v2"),
      PathBuf::from("/deno/node_analysis_cache_v2"),
    ];
    let processes = vec![
      LockingProcess {
        pid: 1234,
        name: "deno".to_string(),
        exe: Some(PathBuf::from("C:/Program Files/deno/deno.exe")),
        is_deno: true,
      },
      LockingProcess {
        pid: 5678,
        name: "Some Editor".to_string(),
        exe: None,
        is_deno: false,
      },
    ];
    let message = format_locked_files_message(&paths, &processes);
    assert!(message.starts_with("2 files could not be removed"));
    assert!(message.contains("Held open by:"));
    assert!(message.contains("deno.exe (PID 1234)"));
    assert!(message.contains("likely the language server"));
    assert!(message.contains("Some Editor (PID 5678)"));
    assert!(message.contains("Close the process(es) above"));
  }

  #[test]
  fn locked_files_message_truncates_long_lists() {
    let paths = (0..15)
      .map(|i| PathBuf::from(format!("/deno/file_{i}")))
      .collect::<Vec<_>>();
    let message = format_locked_files_message(&paths, &[]);
    assert!(message.starts_with("15 files could not be removed"));
    assert!(message.contains("... and 5 more"));
  }

  #[test]
  fn path_trie() {
    let mut trie = super::PathTrie::new();

    #[cfg(unix)]
    {
      trie.add_rewrite(
        Path::new("/RewriteMe").into(),
        Path::new("/Actual").into(),
      );
    }
    #[cfg(windows)]
    {
      trie.add_rewrite(
        Path::new("C:/RewriteMe").into(),
        Path::new("C:/Actual").into(),
      );
    }

    let paths = {
      #[cfg(unix)]
      {
        [
          "/foo/bar/deno",
          "/foo/bar/deno/1",
          "/foo/bar/deno/2",
          "/foo/baz",
          "/Actual/thing/quux",
        ]
      }
      #[cfg(windows)]
      {
        [
          r"C:\foo\bar\deno",
          r"C:\foo\bar\deno\1",
          r"C:\foo\bar\deno\2",
          r"C:\foo\baz",
          r"D:\thing",
          r"C:\Actual\thing\quux",
        ]
      }
    };

    let cases = {
      #[cfg(unix)]
      {
        [
          ("/", Some(Prefix)),
          ("/foo", Some(Prefix)),
          ("/foo/", Some(Prefix)),
          ("/foo/bar", Some(Prefix)),
          ("/foo/bar/deno", Some(Match)),
          ("/foo/bar/deno/1", Some(Match)),
          ("/foo/bar/deno/2", Some(Match)),
          ("/foo/baz", Some(Match)),
          ("/fo", None),
          ("/foo/baz/deno", None),
          ("/Actual/thing/quux", Some(Match)),
          ("/RewriteMe/thing/quux", Some(Match)),
          ("/RewriteMe/thing", Some(Prefix)),
        ]
      }
      #[cfg(windows)]
      {
        [
          (r"C:\", Some(Prefix)),
          (r"C:\foo", Some(Prefix)),
          (r"C:\foo\", Some(Prefix)),
          (r"C:\foo\", Some(Prefix)),
          (r"C:\foo\bar", Some(Prefix)),
          (r"C:\foo\bar\deno\1", Some(Match)),
          (r"C:\foo\bar\deno\2", Some(Match)),
          (r"C:\foo\baz", Some(Match)),
          (r"C:\fo", None),
          (r"C:\foo\baz\deno", None),
          (r"D:\", Some(Prefix)),
          (r"E:\", None),
          (r"C:\Actual\thing\quux", Some(Match)),
          (r"C:\RewriteMe\thing\quux", Some(Match)),
          (r"C:\RewriteMe\thing", Some(Prefix)),
        ]
      }
    };

    for pth in paths {
      let path = Path::new(pth);
      trie.insert(path.into());
    }

    for (input, expect) in cases {
      let path = Path::new(input);
      assert_eq!(trie.find(path), expect, "on input: {input}");
    }
  }
}
