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

mod esbuild;
mod externals;
mod html;
mod provider;
mod transform;
mod wasm;

use std::borrow::Cow;
use std::cell::RefCell;
use std::ops::Deref;
use std::path::Path;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;

use deno_ast::EmitOptions;
use deno_ast::MediaType;
use deno_ast::ModuleKind;
use deno_ast::ModuleSpecifier;
use deno_bundle_runtime::BundleFormat;
use deno_bundle_runtime::BundlePlatform;
use deno_bundle_runtime::PackageHandling;
use deno_bundle_runtime::SourceMapType;
use deno_config::workspace::TsTypeLib;
use deno_core::anyhow::Context as _;
use deno_core::error::AnyError;
use deno_core::futures::FutureExt as _;
use deno_core::parking_lot::Mutex;
use deno_core::parking_lot::RwLock;
use deno_core::serde_json;
use deno_core::url::Url;
use deno_error::JsError;
use deno_error::JsErrorClass;
use deno_graph::GraphKind;
use deno_graph::ModuleErrorKind;
use deno_graph::ModuleGraph;
use deno_graph::Position;
use deno_path_util::resolve_url_or_path;
use deno_resolver::cache::ParsedSourceCache;
use deno_resolver::graph::ResolveWithGraphError;
use deno_resolver::graph::ResolveWithGraphOptions;
use deno_resolver::loader::LoadCodeSourceError;
use deno_resolver::loader::LoadCodeSourceErrorKind;
use deno_resolver::loader::LoadPreparedModuleErrorKind;
use deno_resolver::loader::LoadedModuleOrAsset;
use deno_resolver::loader::LoadedModuleSource;
use deno_resolver::loader::RequestedModuleType;
use deno_resolver::npm::managed::ResolvePkgFolderFromDenoModuleError;
use deno_runtime::deno_permissions::CheckSpecifierKind;
use deno_runtime::deno_permissions::OpenAccessKind;
use deno_runtime::deno_permissions::PermissionsContainer;
use deno_semver::npm::NpmPackageReqReference;
use esbuild_client::EsbuildFlagsBuilder;
use esbuild_client::EsbuildService;
use esbuild_client::protocol;
use esbuild_client::protocol::BuildResponse;
use indexmap::IndexMap;
use indexmap::IndexSet;
use node_resolver::NodeResolutionKind;
use node_resolver::ResolutionMode;
use node_resolver::errors::PackageNotFoundError;
use node_resolver::errors::PackageSubpathResolveError;
pub use provider::CliBundleProvider;
use sys_traits::PathsInErrorsExt;

use crate::args::BundleFlags;
use crate::args::Flags;
use crate::args::TypeCheckMode;
use crate::factory::CliFactory;
use crate::file_fetcher::CliFileFetcher;
use crate::graph_container::CheckSpecifiersOptions;
use crate::graph_container::CollectSpecifiersOptions;
use crate::graph_container::MainModuleGraphContainer;
use crate::graph_container::ModuleGraphContainer;
use crate::graph_container::ModuleGraphUpdatePermit;
use crate::module_loader::CliDenoResolverModuleLoader;
use crate::module_loader::CliEmitter;
use crate::module_loader::ModuleLoadPreparer;
use crate::module_loader::PrepareModuleLoadOptions;
use crate::node::CliNodeResolver;
use crate::npm::CliNpmResolver;
use crate::resolver::CliCjsTracker;
use crate::resolver::CliResolver;
use crate::tools::bundle::externals::ExternalsMatcher;
use crate::util::file_watcher::WatcherRestartMode;
use crate::util::fs::canonicalize_path;

static DISABLE_HACK: LazyLock<bool> =
  LazyLock::new(|| std::env::var("NO_DENO_BUNDLE_HACK").is_err());

pub async fn prepare_inputs(
  resolver: &CliResolver,
  npm_resolver: &CliNpmResolver,
  node_resolver: &CliNodeResolver,
  init_cwd: &Path,
  bundle_flags: &BundleFlags,
  plugin_handler: &mut DenoPluginHandler,
) -> Result<BundlerInput, AnyError> {
  let resolved_entrypoints = resolve_entrypoints(
    resolver,
    init_cwd,
    &bundle_flags.entrypoints,
    !plugin_handler.is_runtime_api,
  )?;

  // Partition into HTML and non-HTML entrypoints
  let mut html_paths = Vec::new();
  let mut script_entry_urls = Vec::new();
  for url in &resolved_entrypoints {
    if is_html_entrypoint(url) {
      html_paths.push(deno_path_util::url_to_file_path(url)?);
    } else {
      script_entry_urls.push(url.clone());
    }
  }

  if html_paths.is_empty() {
    plugin_handler
      .prepare_module_load(&resolved_entrypoints)
      .await?;

    let roots = resolve_roots(
      resolved_entrypoints,
      init_cwd,
      npm_resolver,
      node_resolver,
    );
    plugin_handler.prepare_module_load(&roots).await?;
    let graph = plugin_handler.module_graph_container.graph();
    let mut fully_resolved_roots = IndexSet::with_capacity(graph.roots.len());
    for root in &graph.roots {
      fully_resolved_roots.insert(graph.resolve(root).clone());
    }
    *plugin_handler.resolved_roots.write() = Arc::new(fully_resolved_roots);

    Ok(BundlerInput::Entrypoints(
      roots.into_iter().map(|e| ("".into(), e.into())).collect(),
    ))
  } else {
    // require an outdir when any HTML is present
    if bundle_flags.output_dir.is_none() {
      return Err(deno_core::anyhow::anyhow!(
        "--outdir is required when bundling HTML entrypoints",
      ));
    }
    if bundle_flags.output_path.is_some() {
      return Err(deno_core::anyhow::anyhow!(
        "--output is not supported with HTML entrypoints; use --outdir",
      ));
    }

    // Prepare HTML pages and temp entry modules
    let mut html_pages = Vec::new();
    let mut to_cache_urls = Vec::new();
    let mut entries: Vec<(String, String)> = Vec::new();
    let virtual_modules = Arc::new(VirtualModules::new());

    for html_path in &html_paths {
      let entry = html::load_html_entrypoint(
        init_cwd,
        html_path,
        plugin_handler
          .is_runtime_api
          .then_some(&plugin_handler.permissions),
      )?;

      let virtual_module_path =
        deno_path_util::url_from_file_path(&entry.virtual_module_path)?;
      let virtual_module_path = virtual_module_path.to_string();
      virtual_modules.insert(
        virtual_module_path.clone(),
        VirtualModule::new(
          entry.temp_module.as_bytes().to_vec(),
          esbuild_client::BuiltinLoader::Js,
        ),
      );

      for script in &entry.scripts {
        if let Some(path) = &script.resolved_path {
          let url = deno_path_util::url_from_file_path(path)?;
          to_cache_urls.push(url);
        }
      }

      entries.push(("".into(), virtual_module_path));
      html_pages.push(entry);
    }

    plugin_handler.virtual_modules = Some(virtual_modules);

    // Prepare non-HTML entries too
    let _ = plugin_handler.prepare_module_load(&script_entry_urls).await;
    let roots =
      resolve_roots(script_entry_urls, init_cwd, npm_resolver, node_resolver);
    let _ = plugin_handler.prepare_module_load(&roots).await;
    for url in roots {
      entries.push(("".into(), url.into()));
    }

    // Pre-cache modules referenced by HTML pages
    let _ = plugin_handler.prepare_module_load(&to_cache_urls).await;

    let graph = plugin_handler.module_graph_container.graph();
    let mut fully_resolved_roots = IndexSet::with_capacity(graph.roots.len());
    for root in &graph.roots {
      fully_resolved_roots.insert(graph.resolve(root).clone());
    }
    *plugin_handler.resolved_roots.write() = Arc::new(fully_resolved_roots);

    Ok(BundlerInput::EntrypointsWithHtml {
      entries,
      html_pages,
    })
  }
}

pub async fn bundle_init(
  mut flags: Arc<Flags>,
  bundle_flags: &BundleFlags,
  caller_permissions: Option<PermissionsContainer>,
) -> Result<EsbuildBundler, AnyError> {
  {
    let flags_mut = Arc::make_mut(&mut flags);
    flags_mut.unstable_config.sloppy_imports = true;
  }
  let factory = CliFactory::from_flags(flags.clone());

  let esbuild_path = ensure_esbuild_downloaded(&factory).await?;

  let resolver = factory.resolver().await?.clone();
  let module_load_preparer = factory.module_load_preparer().await?.clone();
  // Module loads (including remote imports) are checked against the caller's
  // permissions when invoked via `Deno.bundle()`, and against the CLI's own
  // permissions when invoked via `deno bundle`.
  let (permissions, is_runtime_api) = match caller_permissions {
    Some(permissions) => (permissions, true),
    None => (factory.root_permissions_container()?.clone(), false),
  };
  let npm_resolver = factory.npm_resolver().await?;
  let node_resolver = factory.node_resolver().await?;
  let cli_options = factory.cli_options()?;
  let module_loader = factory.resolver_factory()?.module_loader()?;
  let init_cwd = cli_options.initial_cwd().to_path_buf();
  let module_graph_container =
    factory.main_module_graph_container().await?.clone();

  let (on_end_tx, on_end_rx) = tokio::sync::mpsc::channel(10);
  #[allow(
    clippy::arc_with_non_send_sync,
    reason = "fine because only used in output positions"
  )]
  let mut plugin_handler = Arc::new(DenoPluginHandler {
    file_fetcher: factory.file_fetcher()?.clone(),
    resolver: resolver.clone(),
    module_load_preparer,
    resolved_roots: Arc::new(RwLock::new(Arc::new(IndexSet::new()))),
    module_graph_container,
    permissions,
    is_runtime_api,
    module_loader: module_loader.clone(),
    externals_matcher: if bundle_flags.external.is_empty() {
      None
    } else {
      Some(ExternalsMatcher::new(&bundle_flags.external, &init_cwd))
    },
    on_end_tx,
    parsed_source_cache: factory.parsed_source_cache()?.clone(),
    cjs_tracker: factory.cjs_tracker()?.clone(),
    emitter: factory.emitter()?.clone(),
    pkg_json_resolver: factory.pkg_json_resolver()?.clone(),
    deferred_resolve_errors: Default::default(),
    virtual_modules: None,
    initial_cwd: deno_path_util::url_from_directory_path(
      cli_options.initial_cwd(),
    )?,
  });

  let input = prepare_inputs(
    &resolver,
    npm_resolver,
    node_resolver,
    &init_cwd,
    bundle_flags,
    Arc::get_mut(&mut plugin_handler).unwrap(),
  )
  .await?;

  let esbuild = EsbuildService::new(
    esbuild_path,
    esbuild::ESBUILD_VERSION,
    plugin_handler.clone(),
    Default::default(),
  )
  .await
  .context("failed to start esbuild")?;
  let client = esbuild.client().clone();

  tokio::spawn(async move {
    let res = esbuild.wait_for_exit().await;
    log::warn!("esbuild exited: {:?}", res);
  });
  let esbuild_flags = configure_esbuild_flags(
    bundle_flags,
    matches!(input, BundlerInput::EntrypointsWithHtml { .. }),
  );
  let bundler = EsbuildBundler::new(
    client,
    plugin_handler.clone(),
    match bundle_flags.watch {
      true => BundlingMode::Watch,
      false => BundlingMode::OneShot,
    },
    on_end_rx,
    init_cwd.clone(),
    esbuild_flags,
    input.clone(),
  );

  Ok(bundler)
}

pub async fn bundle(
  mut flags: Arc<Flags>,
  bundle_flags: BundleFlags,
) -> Result<(), AnyError> {
  {
    let flags_mut = Arc::make_mut(&mut flags);
    flags_mut.unstable_config.sloppy_imports = true;
  }
  // `--check[=all]` is documented in `bundle --help`; honour it before we
  // hand the graph to esbuild so type errors surface alongside (and not
  // after) the bundle output (denoland/deno#30159).
  if !matches!(flags.type_check_mode, TypeCheckMode::None) {
    let check_factory = CliFactory::from_flags(flags.clone());
    let main_graph_container =
      check_factory.main_module_graph_container().await?;
    let specifiers = main_graph_container.collect_specifiers(
      &bundle_flags.entrypoints,
      CollectSpecifiersOptions {
        include_ignored_specified: false,
      },
    )?;
    main_graph_container
      .check_specifiers(
        &specifiers,
        CheckSpecifiersOptions {
          allow_unknown_media_types: true,
          ..Default::default()
        },
      )
      .await?;
  }
  let bundler = bundle_init(flags.clone(), &bundle_flags, None).await?;
  let init_cwd = bundler.cwd.clone();
  let start = std::time::Instant::now();
  let response = bundler.build().await?;
  let end = std::time::Instant::now();
  let duration = end.duration_since(start);

  if bundle_flags.watch {
    if !response.errors.is_empty() || !response.warnings.is_empty() {
      handle_esbuild_errors_and_warnings(
        &response,
        &init_cwd,
        &bundler.plugin_handler.take_deferred_resolve_errors(),
      );
      if !response.errors.is_empty() {
        deno_core::anyhow::bail!("bundling failed");
      }
    }
    return bundle_watch(
      flags,
      bundler,
      bundle_flags.minify,
      bundle_flags.platform,
      bundle_flags.output_dir.as_ref().map(Path::new),
    )
    .await;
  }

  handle_esbuild_errors_and_warnings(
    &response,
    &init_cwd,
    &bundler.plugin_handler.take_deferred_resolve_errors(),
  );

  if response.errors.is_empty() {
    let metafile = metafile_from_response(&response)?;
    let output_infos = process_result(
      &response,
      &init_cwd,
      should_replace_require_shim(bundle_flags.platform),
      bundle_flags.minify,
      bundler.input.clone(),
      bundle_flags.output_dir.as_ref().map(Path::new),
      None,
    )?;

    if bundle_flags.declaration {
      emit_bundle_declarations(flags.clone(), &bundle_flags, &init_cwd).await?;
    }

    if bundle_flags.output_dir.is_some() || bundle_flags.output_path.is_some() {
      print_finished_message(&metafile, &output_infos, duration)?;
    }
  }

  if !response.errors.is_empty() {
    deno_core::anyhow::bail!("bundling failed");
  }

  Ok(())
}

async fn emit_bundle_declarations(
  flags: Arc<Flags>,
  bundle_flags: &BundleFlags,
  init_cwd: &Path,
) -> Result<(), AnyError> {
  let factory = CliFactory::from_flags(flags);
  let cli_options = factory.cli_options()?;
  let real_sys = factory.sys();
  let sys = real_sys.with_paths_in_errors();

  // Resolve entrypoints to specifiers
  let resolver = factory.resolver().await?;
  let entrypoint_specifiers =
    resolve_entrypoints(resolver, init_cwd, &bundle_flags.entrypoints, true)?;

  // Determine root names with media types
  let root_names: Vec<(ModuleSpecifier, MediaType)> = entrypoint_specifiers
    .iter()
    .map(|s| (s.clone(), MediaType::from_specifier(s)))
    .collect();

  let specifiers: Vec<ModuleSpecifier> =
    root_names.iter().map(|(s, _)| s.clone()).collect();

  // Build the module graph for type checking
  let mut graph = ModuleGraph::new(GraphKind::All);
  let module_graph_builder = factory.module_graph_builder().await?;
  module_graph_builder
    .build_graph_roots_with_npm_resolution(
      &mut graph,
      specifiers,
      crate::graph_util::BuildGraphWithNpmOptions {
        is_dynamic: false,
        loader: None,
        npm_caching: cli_options.default_npm_caching_strategy(),
      },
    )
    .await?;

  // Run type checker to emit declaration files
  let type_checker = factory.type_checker().await?;
  let result = type_checker.emit_declarations(
    Arc::new(graph),
    root_names,
    cli_options.ts_type_lib_window(),
  )?;

  if result.diagnostics.has_diagnostic() {
    deno_core::anyhow::bail!(
      "Type checking failed when generating declarations:\n{}",
      result.diagnostics
    );
  }

  // Index emitted .d.ts files by their original source specifier
  // TSC emits keys like "file:///path/to/file.d.ts" - map them back to source paths
  let mut dts_by_source: std::collections::HashMap<String, String> =
    std::collections::HashMap::new();
  for (file_name, content) in &result.emitted_files {
    // Map "file:///path/to/mod.d.ts" -> normalized source path
    if let Ok(specifier) = Url::parse(file_name)
      && let Ok(file_path) = specifier.to_file_path()
    {
      let source_path = file_path.to_string_lossy().to_string();
      dts_by_source.insert(source_path, content.clone());
    }
  }

  // For each entry point, produce a single rolled-up .d.ts
  for entry_specifier in &entrypoint_specifiers {
    let entry_path = entry_specifier.to_file_path().map_err(|_| {
      deno_core::anyhow::anyhow!(
        "Entry point must be a file: {}",
        entry_specifier
      )
    })?;

    // Find the .d.ts for this entry point
    let dts_path = to_dts_path(&entry_path);
    let entry_dts = dts_by_source
      .get(&dts_path.to_string_lossy().to_string())
      .ok_or_else(|| {
        deno_core::anyhow::anyhow!(
          "No declaration file emitted for entry point: {}",
          entry_specifier
        )
      })?;

    // Flatten: resolve all `export ... from "..."` re-exports by inlining
    // the referenced declarations from other .d.ts files.
    let flattened =
      flatten_declarations(entry_dts, &entry_path, &dts_by_source);

    // Determine output path for the rolled-up .d.ts
    let dts_output_path = if let Some(ref output) = bundle_flags.output_path {
      let js_path = init_cwd.join(output);
      to_dts_path(&js_path)
    } else if let Some(ref outdir) = bundle_flags.output_dir {
      let outdir = PathBuf::from(outdir);
      let stem = entry_path.file_stem().unwrap_or_default();
      outdir.join(format!("{}.d.ts", stem.to_string_lossy()))
    } else {
      to_dts_path(&entry_path)
    };

    if let Some(parent) = dts_output_path.parent() {
      sys.fs_create_dir_all(parent).with_context(|| {
        format!("Failed to create directory {}", parent.display())
      })?;
    }

    sys
      .fs_write(&dts_output_path, flattened.as_bytes())
      .with_context(|| {
        format!("Failed to write {}", dts_output_path.display())
      })?;
    log::info!(
      "{} {}",
      deno_terminal::colors::green("Emit"),
      dts_output_path.display()
    );
  }

  Ok(())
}

/// Convert a source file path to its corresponding declaration file path.
///
/// Mirrors TSC's declaration emit: `.mts`/`.mjs` sources produce `.d.mts`,
/// `.cts`/`.cjs` produce `.d.cts`, and everything else produces `.d.ts`. Using
/// the wrong extension here would make the rolled-up output reference a
/// declaration file that was never emitted.
fn to_dts_path(source_path: &Path) -> PathBuf {
  let stem = source_path.file_stem().unwrap_or_default();
  let parent = source_path.parent().unwrap_or(Path::new(""));
  let dts_ext = match source_path.extension().and_then(|e| e.to_str()) {
    Some("mts" | "mjs") => "d.mts",
    Some("cts" | "cjs") => "d.cts",
    _ => "d.ts",
  };
  parent.join(format!("{}.{}", stem.to_string_lossy(), dts_ext))
}

/// Join multi-line `export { ... } from "..."` and `import type { ... } from "..."`
/// statements into single lines so that line-based parsing can handle them.
fn join_multiline_statements(content: &str) -> String {
  let mut result = Vec::new();
  let mut accumulator: Option<String> = None;

  for line in content.lines() {
    if let Some(ref mut acc) = accumulator {
      // We're inside a multi-line statement, keep accumulating
      acc.push(' ');
      acc.push_str(line.trim());
      if line.contains(';') {
        // Statement is complete
        result.push(acc.clone());
        accumulator = None;
      }
    } else {
      let trimmed = line.trim();
      // Detect start of a multi-line export/import that has `{` but no closing `}`
      let is_export_or_import =
        trimmed.starts_with("export ") || trimmed.starts_with("import ");
      if is_export_or_import && trimmed.contains('{') && !trimmed.contains('}')
      {
        accumulator = Some(line.trim().to_string());
      } else {
        result.push(line.to_string());
      }
    }
  }

  // If there's an unterminated accumulator, flush it
  if let Some(acc) = accumulator {
    result.push(acc);
  }

  result.join("\n")
}

/// Warn that a relative re-export could not be inlined, so the rolled-up
/// declaration file keeps a dangling `from "./..."` reference.
fn warn_dangling_reexport(reexport_path: &str, source_path: &Path) {
  log::warn!(
    "{} could not inline re-exported declarations from {:?} in {}; the generated .d.ts will keep a dangling relative reference",
    deno_terminal::colors::yellow("warning:"),
    reexport_path,
    source_path.display(),
  );
}

/// Flatten a .d.ts file by resolving all `export ... from "..."` re-exports,
/// inlining the referenced declarations from other emitted .d.ts files.
///
/// This produces a single self-contained .d.ts file with no relative imports.
fn flatten_declarations(
  entry_dts_content: &str,
  entry_source_path: &Path,
  dts_by_source: &std::collections::HashMap<String, String>,
) -> String {
  let entry_dir = entry_source_path.parent().unwrap_or(Path::new(""));
  let mut output_lines: Vec<String> = Vec::new();
  // Track which declarations have already been inlined to avoid duplicates
  let mut inlined_files: std::collections::HashSet<String> =
    std::collections::HashSet::new();

  // The set of files whose declarations actually get inlined into the output,
  // i.e. everything transitively reachable from the entry via `export ... from`
  // chains. An `import ... from "./relative"` line may only be dropped when its
  // target is in this set; otherwise the imported symbol would have no
  // declaration in the rolled-up output. (Membership in `dts_by_source` is not
  // enough: a module can be emitted yet never inlined because nothing
  // re-exports it.)
  let reexport_closure =
    collect_reexport_closure(entry_dts_content, entry_dir, dts_by_source);

  let joined = join_multiline_statements(entry_dts_content);

  for line in joined.lines() {
    let trimmed = line.trim();

    // Skip amd-module directives
    if trimmed.starts_with("/// <amd-module") {
      continue;
    }

    // Handle: export { Name1, Name2 } from "./relative";
    // Handle: export type { Name1, Name2 } from "./relative";
    if let Some(from_path) = extract_reexport_path(trimmed) {
      // Resolve the relative path to absolute
      let resolved = resolve_relative_dts_path(entry_dir, &from_path);
      let resolved_str = resolved.to_string_lossy().to_string();

      if let Some(referenced_dts) = dts_by_source.get(&resolved_str) {
        if !inlined_files.contains(&resolved_str) {
          inlined_files.insert(resolved_str.clone());
          // Inline all declarations from the referenced file
          inline_declarations(
            referenced_dts,
            &resolved,
            dts_by_source,
            &reexport_closure,
            &mut output_lines,
            &mut inlined_files,
          );
        }
        // The re-export line is replaced by the inlined content
        continue;
      }
      // A relative re-export whose declaration file was not emitted. Keeping
      // the line leaves a dangling `from "./..."` in the supposedly
      // self-contained output, so warn rather than silently shipping it.
      warn_dangling_reexport(&from_path, entry_source_path);
    }

    // Drop an `import ... from "./relative"` whose target is inlined into the
    // output (its declarations are already present). A target that is not in
    // the closure is left as-is rather than deleted, so we never strip an
    // import whose symbol has no inlined declaration.
    if let Some(import_path) = extract_import_path(trimmed) {
      let resolved = resolve_relative_dts_path(entry_dir, &import_path);
      let resolved_str = resolved.to_string_lossy().to_string();
      if reexport_closure.contains(&resolved_str) {
        continue;
      }
    }

    output_lines.push(line.to_string());
  }

  output_lines.join("\n") + "\n"
}

/// Collect the set of `.d.ts` files (keyed as in `dts_by_source`) that are
/// transitively reachable from the entry through `export ... from "./relative"`
/// re-export chains. These are exactly the files whose declarations get inlined
/// into the rolled-up output by [`inline_declarations`].
fn collect_reexport_closure(
  entry_dts_content: &str,
  entry_dir: &Path,
  dts_by_source: &std::collections::HashMap<String, String>,
) -> std::collections::HashSet<String> {
  let mut closure = std::collections::HashSet::new();
  let mut stack: Vec<(String, PathBuf)> =
    vec![(entry_dts_content.to_string(), entry_dir.to_path_buf())];

  while let Some((content, dir)) = stack.pop() {
    let joined = join_multiline_statements(&content);
    for line in joined.lines() {
      let trimmed = line.trim();
      if let Some(from_path) = extract_reexport_path(trimmed) {
        let resolved = resolve_relative_dts_path(&dir, &from_path);
        let resolved_str = resolved.to_string_lossy().to_string();
        if !closure.contains(&resolved_str)
          && let Some(referenced_dts) = dts_by_source.get(&resolved_str)
        {
          closure.insert(resolved_str.clone());
          let child_dir =
            resolved.parent().unwrap_or(Path::new("")).to_path_buf();
          stack.push((referenced_dts.clone(), child_dir));
        }
      }
    }
  }

  closure
}

/// Extract the relative path from an `export ... from "..."` line.
/// Returns None if the line is not a re-export or uses a non-relative path.
fn extract_reexport_path(line: &str) -> Option<String> {
  // Match patterns like:
  //   export { X } from "./foo";
  //   export { X, Y } from "./foo.ts";
  //   export type { X } from "./foo";
  let trimmed = line.trim();
  if !trimmed.starts_with("export ") || !trimmed.contains(" from ") {
    return None;
  }
  // Extract the path between quotes after "from"
  let from_idx = trimmed.find(" from ")?;
  let after_from = &trimmed[from_idx + 6..];
  let quote_char = after_from.chars().next()?;
  if quote_char != '"' && quote_char != '\'' {
    return None;
  }
  let end_quote = after_from[1..].find(quote_char)?;
  let path = &after_from[1..1 + end_quote];

  // Only resolve relative paths (starting with ./ or ../)
  if path.starts_with("./") || path.starts_with("../") {
    Some(path.to_string())
  } else {
    None
  }
}

/// Resolve a relative .d.ts path from the given directory.
fn resolve_relative_dts_path(base_dir: &Path, relative: &str) -> PathBuf {
  let mut resolved = base_dir.join(relative);
  // The reference might be "./lib.ts" but the actual .d.ts is at the
  // path with .d.ts extension
  resolved = to_dts_path(&resolved);
  deno_path_util::normalize_path(Cow::Owned(resolved)).into_owned()
}

/// Extract a relative path from an `import ... from "..."` line.
fn extract_import_path(line: &str) -> Option<String> {
  let trimmed = line.trim();
  if !trimmed.starts_with("import ") || !trimmed.contains(" from ") {
    return None;
  }
  let from_idx = trimmed.find(" from ")?;
  let after_from = &trimmed[from_idx + 6..];
  let quote_char = after_from.chars().next()?;
  if quote_char != '"' && quote_char != '\'' {
    return None;
  }
  let end_quote = after_from[1..].find(quote_char)?;
  let path = &after_from[1..1 + end_quote];
  if path.starts_with("./") || path.starts_with("../") {
    Some(path.to_string())
  } else {
    None
  }
}

/// Inline declarations from a .d.ts file into the output, recursively
/// resolving any re-exports in the referenced file.
fn inline_declarations(
  dts_content: &str,
  source_path: &Path,
  dts_by_source: &std::collections::HashMap<String, String>,
  reexport_closure: &std::collections::HashSet<String>,
  output_lines: &mut Vec<String>,
  inlined_files: &mut std::collections::HashSet<String>,
) {
  let source_dir = source_path.parent().unwrap_or(Path::new(""));

  let joined = join_multiline_statements(dts_content);

  for line in joined.lines() {
    let trimmed = line.trim();

    // Skip amd-module directives
    if trimmed.starts_with("/// <amd-module") {
      continue;
    }

    // Recursively resolve re-exports from this file too
    if let Some(from_path) = extract_reexport_path(trimmed) {
      let resolved = resolve_relative_dts_path(source_dir, &from_path);
      let resolved_str = resolved.to_string_lossy().to_string();

      if let Some(referenced_dts) = dts_by_source.get(&resolved_str) {
        if !inlined_files.contains(&resolved_str) {
          inlined_files.insert(resolved_str.clone());
          inline_declarations(
            referenced_dts,
            &resolved,
            dts_by_source,
            reexport_closure,
            output_lines,
            inlined_files,
          );
        }
        continue;
      }
      // A relative re-export whose declaration file was not emitted; warn so a
      // dangling reference in the output does not pass by unnoticed.
      warn_dangling_reexport(&from_path, source_path);
    }

    // Strip `import type ... from "./relative"` / `import { ... } from
    // "./relative"` lines only when the referenced file is actually inlined
    // into this rolled-up output (it's in the re-export closure). The imported
    // symbol's declaration is then present directly. A target that is emitted
    // but never inlined is intentionally left untouched, so we don't delete an
    // import whose symbol would otherwise vanish.
    if let Some(import_path) = extract_import_path(trimmed) {
      let resolved = resolve_relative_dts_path(source_dir, &import_path);
      let resolved_str = resolved.to_string_lossy().to_string();
      if reexport_closure.contains(&resolved_str) {
        continue; // skip - declarations are inlined
      }
    }

    output_lines.push(line.to_string());
  }
}

/// Bundle a single entrypoint for `deno compile --bundle` and return the
/// resulting JavaScript bytes in memory. The Deno-specific `createRequire`
/// shim is applied to the output so CJS `require()` calls keep working in
/// the compiled binary.
///
/// `external` is the set of import patterns (typically npm package names) to
/// leave unbundled. For `deno compile --bundle` this is populated with the
/// names of packages that ship native `.node` addons so their internal
/// `require('./X.node')` calls keep their original `__dirname` context at
/// runtime.
pub async fn bundle_for_compile(
  flags: Arc<Flags>,
  entrypoint: String,
  external: Vec<String>,
  minify: bool,
) -> Result<Vec<u8>, AnyError> {
  let bundle_flags = BundleFlags {
    entrypoints: vec![entrypoint],
    output_path: None,
    output_dir: None,
    declaration: false,
    external,
    format: BundleFormat::Esm,
    minify,
    keep_names: false,
    code_splitting: false,
    inline_imports: true,
    packages: PackageHandling::Bundle,
    sourcemap: None,
    platform: BundlePlatform::Deno,
    watch: false,
  };

  // Force bundle-style resolver config for the duration of bundling.
  // Without this, module-graph creation skips deep CJS files inside
  // `node_modules` (jiti.cjs is the canonical case) and the parent
  // `.mjs` trips an esbuild parse error when its import target can't
  // be loaded. This flips the same three resolver knobs `deno bundle`
  // sets (`bundle_mode`, `node_code_translator_mode` = Disabled,
  // `allow_json_imports` = Always) without otherwise swapping the
  // subcommand, so the rest of compilation keeps Compile semantics.
  // Disabling the code translator means the CJS analyzer's
  // extensionless `.node` auto-resolution no longer runs, which is why
  // the resolver grows a `.node` fallback (see `resolution.rs`).
  let mut flags = flags;
  {
    let flags_mut = Arc::make_mut(&mut flags);
    flags_mut.internal.force_bundle_mode = true;
  }

  let bundler = bundle_init(flags, &bundle_flags, None).await?;
  let response = bundler.build().await?;

  handle_esbuild_errors_and_warnings(
    &response,
    &bundler.cwd,
    &bundler.plugin_handler.take_deferred_resolve_errors(),
  );
  if !response.errors.is_empty() {
    deno_core::anyhow::bail!("bundling failed");
  }

  let output_files = collect_output_files(
    response.output_files.as_deref(),
    &bundler.cwd,
    bundler.input.clone(),
    None,
  )?;

  for file in &output_files {
    let processed = maybe_process_contents(
      file,
      should_replace_require_shim(bundle_flags.platform),
      bundle_flags.minify,
    )?;
    if !processed.is_js {
      continue;
    }
    return Ok(
      processed
        .into_contents()
        .unwrap_or_else(|| file.contents.to_vec()),
    );
  }

  deno_core::anyhow::bail!("esbuild produced no JavaScript output")
}

fn metafile_from_response(
  response: &BuildResponse,
) -> Result<esbuild_client::Metafile, AnyError> {
  Ok(serde_json::from_str::<esbuild_client::Metafile>(
    response.metafile.as_deref().ok_or_else(|| {
      deno_core::anyhow::anyhow!("expected a metafile to be present")
    })?,
  )?)
}

async fn bundle_watch(
  flags: Arc<Flags>,
  bundler: EsbuildBundler,
  minified: bool,
  platform: BundlePlatform,
  output_dir: Option<&Path>,
) -> Result<(), AnyError> {
  let (initial_roots, always_watch) = match &bundler.input {
    BundlerInput::Entrypoints(entries) => (
      entries
        .iter()
        .filter_map(|(_, root)| {
          let url = Url::parse(root).ok()?;
          deno_path_util::url_to_file_path(&url).ok()
        })
        .collect::<Vec<_>>(),
      vec![],
    ),
    BundlerInput::EntrypointsWithHtml {
      entries,
      html_pages,
    } => {
      let mut roots = entries
        .iter()
        .filter_map(|(_, root)| {
          let url = Url::parse(root).ok()?;
          deno_path_util::url_to_file_path(&url).ok()
        })
        .collect::<Vec<_>>();
      let always = html_pages
        .iter()
        .map(|p| p.path.clone())
        .collect::<Vec<_>>();
      roots.extend(always.iter().cloned());
      (roots, always)
    }
  };
  let always_watch = Rc::new(always_watch);
  let current_roots = Rc::new(RefCell::new(initial_roots.clone()));
  let bundler = Rc::new(tokio::sync::Mutex::new(bundler));
  let mut print_config =
    crate::util::file_watcher::PrintConfig::new_with_banner(
      "Watcher", "Bundle", true,
    );
  print_config.print_finished = false;
  crate::util::file_watcher::watch_recv(
    flags,
    print_config,
    WatcherRestartMode::Automatic,
    move |_flags, watcher_communicator, changed_paths| {
      watcher_communicator.show_path_changed(changed_paths.clone());
      let bundler = Rc::clone(&bundler);
      let current_roots = current_roots.clone();
      let always_watch = always_watch.clone();
      Ok(async move {
        let mut bundler = bundler.lock().await;
        let start = std::time::Instant::now();
        if let Some(changed_paths) = changed_paths {
          bundler.reload_specifiers(&changed_paths).await?;
        }
        let input = bundler.input.clone();
        let response = bundler.rebuild().await?;
        handle_esbuild_errors_and_warnings(
          &response,
          &bundler.cwd,
          &bundler.plugin_handler.take_deferred_resolve_errors(),
        );
        if response.errors.is_empty() {
          let metafile = metafile_from_response(&response)?;
          let output_infos = process_result(
            &response,
            &bundler.cwd,
            should_replace_require_shim(platform),
            minified,
            input,
            output_dir,
            None,
          )?;
          print_finished_message(&metafile, &output_infos, start.elapsed())?;

          let mut new_watched = get_input_paths_for_watch(&response);
          new_watched.extend(always_watch.iter().cloned());
          *current_roots.borrow_mut() = new_watched.clone();
          let _ = watcher_communicator.watch_paths(new_watched);
        } else {
          let _ =
            watcher_communicator.watch_paths(current_roots.borrow().clone());
        }

        // `deno bundle --watch` has no per-run exit code (and disables the
        // "finished" message above), so report 0 to the watcher.
        Ok(0)
      })
    },
  )
  .boxed_local()
  .await?;

  Ok(())
}

pub fn should_replace_require_shim(platform: BundlePlatform) -> bool {
  *DISABLE_HACK && matches!(platform, BundlePlatform::Deno)
}

fn get_input_paths_for_watch(response: &BuildResponse) -> Vec<PathBuf> {
  let metafile = serde_json::from_str::<esbuild_client::Metafile>(
    response
      .metafile
      .as_deref()
      .expect("metafile is required for watch mode"),
  )
  .unwrap();

  metafile
    .inputs
    .keys()
    .cloned()
    .map(PathBuf::from)
    .collect::<Vec<_>>()
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BundlingMode {
  OneShot,
  Watch,
}

#[derive(Debug, Clone)]
pub enum BundlerInput {
  Entrypoints(Vec<(String, String)>),
  EntrypointsWithHtml {
    entries: Vec<(String, String)>,
    html_pages: Vec<html::HtmlEntrypoint>,
  },
}

pub type EsbuildFlags = Vec<String>;

pub struct EsbuildBundler {
  client: esbuild_client::ProtocolClient,
  plugin_handler: Arc<DenoPluginHandler>,
  on_end_rx: tokio::sync::mpsc::Receiver<esbuild_client::OnEndArgs>,
  mode: BundlingMode,
  cwd: PathBuf,
  flags: EsbuildFlags,
  input: BundlerInput,
}

impl EsbuildBundler {
  pub fn new(
    client: esbuild_client::ProtocolClient,
    plugin_handler: Arc<DenoPluginHandler>,
    mode: BundlingMode,
    on_end_rx: tokio::sync::mpsc::Receiver<esbuild_client::OnEndArgs>,
    cwd: PathBuf,
    flags: EsbuildFlags,
    input: BundlerInput,
  ) -> EsbuildBundler {
    EsbuildBundler {
      client,
      plugin_handler,
      on_end_rx,
      mode,
      cwd,
      flags,
      input,
    }
  }

  // When doing a watch build, we're actually enabling the
  // "context" mode of esbuild. That leaves esbuild running and
  // waits for a rebuild to be triggered. The initial build request
  // doesn't actually do anything, it's just registering the args/flags
  // we're going to use for all of the rebuilds.
  fn make_build_request(&self) -> protocol::BuildRequest {
    let entries = match &self.input {
      BundlerInput::Entrypoints(entries) => entries.clone(),
      BundlerInput::EntrypointsWithHtml { entries, .. } => entries.clone(),
    };
    protocol::BuildRequest {
      entries,
      key: 0,
      flags: self.flags.clone(),
      write: false,
      stdin_contents: None.into(),
      stdin_resolve_dir: None.into(),
      abs_working_dir: self.cwd.to_string_lossy().into_owned(),
      context: matches!(self.mode, BundlingMode::Watch),
      mangle_cache: None,
      node_paths: vec![],
      plugins: Some(vec![protocol::BuildPlugin {
        name: "deno".into(),
        on_start: false,
        on_end: matches!(self.mode, BundlingMode::Watch),
        on_resolve: (vec![protocol::OnResolveSetupOptions {
          id: 0,
          filter: ".*".into(),
          namespace: "".into(),
        }]),
        on_load: vec![protocol::OnLoadSetupOptions {
          id: 0,
          filter: ".*".into(),
          namespace: "".into(),
        }],
      }]),
    }
  }

  async fn build(&self) -> Result<BuildResponse, AnyError> {
    let response: BuildResponse = self
      .client
      .send_build_request(self.make_build_request())
      .await
      .context("failed to send build request to esbuild")?
      .map_err(|e| message_to_error(&e, &self.cwd))?;

    Ok(response)
  }

  async fn rebuild(&mut self) -> Result<BuildResponse, AnyError> {
    match self.mode {
      BundlingMode::OneShot => {
        panic!("rebuild not supported for one-shot mode")
      }
      BundlingMode::Watch => {
        log::trace!("sending rebuild request");
        let _response = self
          .client
          .send_rebuild_request(0)
          .await
          .context("failed to send rebuild request to esbuild")?
          .map_err(|e| message_to_error(&e, &self.cwd))?;
        let response = self.on_end_rx.recv().await.ok_or_else(|| {
          deno_core::anyhow::anyhow!(
            "esbuild exited before the rebuild completed"
          )
        })?;
        Ok(response.into())
      }
    }
  }

  async fn reload_specifiers(
    &mut self,
    changed_paths: &[PathBuf],
  ) -> Result<(), AnyError> {
    self.reload_html_entrypoints(changed_paths)?;
    self.plugin_handler.reload_specifiers(changed_paths).await?;
    Ok(())
  }

  fn reload_html_entrypoints(
    &mut self,
    changed_paths: &[PathBuf],
  ) -> Result<(), AnyError> {
    let BundlerInput::EntrypointsWithHtml { html_pages, .. } = &mut self.input
    else {
      return Ok(());
    };

    if changed_paths.is_empty() {
      return Ok(());
    }

    for page in html_pages.iter_mut() {
      if !changed_paths
        .iter()
        .any(|changed| changed == &page.path || changed == &page.canonical_path)
      {
        continue;
      }

      let updated = html::load_html_entrypoint(
        &self.cwd,
        &page.path,
        self
          .plugin_handler
          .is_runtime_api
          .then_some(&self.plugin_handler.permissions),
      )?;
      let virtual_module_url =
        deno_path_util::url_from_file_path(&updated.virtual_module_path)?
          .to_string();
      self.plugin_handler.update_virtual_module(
        &virtual_module_url,
        VirtualModule::new(
          updated.temp_module.as_bytes().to_vec(),
          esbuild_client::BuiltinLoader::Js,
        ),
      );
      *page = updated;
    }

    Ok(())
  }
}

fn message_to_error(
  message: &esbuild_client::protocol::Message,
  current_dir: &Path,
) -> AnyError {
  deno_core::anyhow::anyhow!("{}", format_message(message, current_dir))
}

// TODO(nathanwhit): MASSIVE HACK
// See tests::specs::bundle::requires_node_builtin for why this is needed.
// Without this hack, that test would fail with "Dynamic require of "util" is not supported"
fn replace_require_shim(contents: &str, minified: bool) -> String {
  if minified {
    let re = lazy_regex::regex!(
      r#"var (\w+)\s*=\((\w+)\s*=>typeof require<"u"\?require:typeof Proxy<"u"\?new Proxy\((\w+)\,\{get:\(\w+,\w+\)=>\(typeof require<"u"\?require:\w+\)\[\w+\]\}\):(\w+)\)\(function\(\w+\)\{if\(typeof require<"u"\)return require\.apply\(this\,arguments\);throw Error\('Dynamic require of "'\+\w+\+'" is not supported'\)\}\);"#
    );
    re.replace(contents, |c: &regex::Captures<'_>| {
      let var_name = c.get(1).unwrap().as_str();
      format!("import{{createRequire as __deno_internal_createRequire}} from \"node:module\";var {var_name}=__deno_internal_createRequire(import.meta.url);")
    }).into_owned()
  } else {
    let re = lazy_regex::regex!(
      r#"var __require = (/\* @__PURE__ \*/)?\s*\(\(\w+\) => typeof require !== "undefined" \? require : typeof Proxy !== "undefined" \? new Proxy\(\w+, \{\s*  get: \(\w+, \w+\) => \(typeof require !== "undefined" \? require : \w+\)\[\w+\]\s*\}\) : \w+\)\(function\(\w+\) \{\s*  if \(typeof require !== "undefined"\) return require\.apply\(this, arguments\);\s*  throw Error\('Dynamic require of "' \+ \w+ \+ '" is not supported'\);\s*\}\);"#
    );
    re.replace_all(
      contents,
      r#"import { createRequire as __deno_internal_createRequire } from "node:module";
var __require = __deno_internal_createRequire(import.meta.url);
"#,
    )
    .into_owned()
  }
}

// Force esbuild's CommonJS-to-ESM interop helper (`__toESM`) into "node mode"
// for every platform.
//
// esbuild emits a node-mode interop call (`__toESM(require_x(), 1)`) only when
// it can tell the importing module is *explicitly* ESM (a `.mjs`/`.mts` file or
// a file under a `"type": "module"` package.json). It learns this while walking
// package.json during its own resolve step. In `deno bundle` resolution and
// loading are handled by our plugin, and esbuild's plugin protocol has no way
// to communicate a module's type, so esbuild falls back to browser-mode interop
// which respects the `__esModule` marker and does not synthesize `.default`.
//
// For packages whose ESM wrapper default-imports a CJS entry that sets
// `module.exports.__esModule = true` (e.g. tslib's `modules/index.js`), that
// means `import_x.default` ends up undefined and module init throws. This is a
// property of how the source module resolves its default import (Node interop
// semantics, which is what such packages are authored against), not of the
// runtime target, so it applies regardless of `--platform`. We rewrite the
// helper's `<isNodeMode> || !mod || !mod.__esModule` condition to always pick
// the node-mode branch. See denoland/deno#34524 and denoland/deno#34837.
//
// The variable names differ between minified and non-minified output, but the
// `.__esModule` access and surrounding shape are stable, so a single regex
// covers both.
fn force_node_cjs_interop(contents: &str) -> String {
  let re =
    lazy_regex::regex!(r#"\w+\s*\|\|\s*!\w+\s*\|\|\s*!\w+\.__esModule\s*\?"#);
  re.replace(contents, "1 ?").into_owned()
}

fn format_location(
  location: &esbuild_client::protocol::Location,
  current_dir: &Path,
) -> String {
  let url =
    deno_path_util::resolve_url_or_path(location.file.as_str(), current_dir)
      .map(|url| deno_terminal::colors::cyan(url.into()))
      .unwrap_or(deno_terminal::colors::cyan(location.file.clone()));

  format!(
    "{}:{}:{}",
    url,
    deno_terminal::colors::yellow(location.line),
    deno_terminal::colors::yellow(location.column)
  )
}

fn format_note(
  note: &esbuild_client::protocol::Note,
  current_dir: &Path,
) -> String {
  format!(
    "{}: {}{}",
    deno_terminal::colors::magenta("note"),
    note.text,
    if let Some(location) = &note.location {
      format!("\n    {}", format_location(location, current_dir))
    } else {
      String::new()
    }
  )
}

// not very efficient, but it's only for error messages
fn add_indent(s: &str, indent: &str) -> String {
  let lines = s
    .lines()
    .map(|line| format!("{}{}", indent, line))
    .collect::<Vec<_>>();
  lines.join("\n")
}

fn format_message(
  message: &esbuild_client::protocol::Message,
  current_dir: &Path,
) -> String {
  format!(
    "{}{}{}{}",
    message.text,
    if message.id.is_empty() {
      String::new()
    } else {
      format!("[{}] ", message.id)
    },
    if let Some(location) = &message.location {
      if !message.text.contains(" at ") {
        format!("\n    at {}", format_location(location, current_dir))
      } else {
        String::new()
      }
    } else {
      String::new()
    },
    if !message.notes.is_empty() {
      let mut s = String::new();
      for note in &message.notes {
        s.push('\n');
        s.push_str(&add_indent(&format_note(note, current_dir), "    "));
      }
      s
    } else {
      String::new()
    }
  )
}
#[derive(Debug, boxed_error::Boxed, JsError)]
struct BundleError(Box<BundleErrorKind>);

#[derive(Debug, thiserror::Error, JsError)]
#[class(generic)]
enum BundleErrorKind {
  #[error(transparent)]
  Resolver(#[from] deno_resolver::graph::ResolveWithGraphError),
  #[error(transparent)]
  Url(#[from] deno_core::url::ParseError),
  #[error(transparent)]
  ResolveNpmPkg(#[from] ResolvePkgFolderFromDenoModuleError),
  #[error(transparent)]
  SubpathResolve(#[from] PackageSubpathResolveError),
  #[error(transparent)]
  PathToUrlError(#[from] deno_path_util::PathToUrlError),
  #[error(transparent)]
  UrlToPathError(#[from] deno_path_util::UrlToFilePathError),
  #[error(transparent)]
  Io(#[from] std::io::Error),
  #[error(transparent)]
  ResolveUrlOrPathError(#[from] deno_path_util::ResolveUrlOrPathError),
  #[error(transparent)]
  PrepareModuleLoad(#[from] crate::module_loader::PrepareModuleLoadError),
  #[error(transparent)]
  ResolveReqWithSubPath(#[from] deno_resolver::npm::ResolveReqWithSubPathError),
  #[error(transparent)]
  PackageReqReferenceParse(
    #[from] deno_semver::package::PackageReqReferenceParseError,
  ),
  #[allow(dead_code, reason = "not used yet")]
  #[error("Http cache error")]
  HttpCache,
}

fn requested_type_from_map(
  map: &IndexMap<String, String>,
) -> RequestedModuleType<'_> {
  let type_ = map.get("type").map(|s| s.as_str());
  match type_ {
    Some("json") => RequestedModuleType::Json,
    Some("bytes") => RequestedModuleType::Bytes,
    Some("text") => RequestedModuleType::Text,
    Some(other) => RequestedModuleType::Other(other),
    None => RequestedModuleType::None,
  }
}

#[derive(Clone)]
pub struct VirtualModule {
  contents: Vec<u8>,
  loader: esbuild_client::BuiltinLoader,
}

impl VirtualModule {
  pub fn new(contents: Vec<u8>, loader: esbuild_client::BuiltinLoader) -> Self {
    Self { contents, loader }
  }
}

pub struct VirtualModules {
  modules: RwLock<IndexMap<String, VirtualModule>>,
}

impl VirtualModules {
  pub fn new() -> Self {
    Self {
      modules: RwLock::new(IndexMap::new()),
    }
  }

  pub fn insert(&self, path: String, contents: VirtualModule) {
    self.modules.write().insert(path, contents);
  }

  pub fn get(&self, path: &str) -> Option<VirtualModule> {
    self.modules.read().get(path).cloned()
  }

  pub fn contains(&self, path: &str) -> bool {
    self.modules.read().contains_key(path)
  }
}

pub struct DeferredResolveError {
  path: String,
  error: ResolveWithGraphError,
}

/// Path prefix used to mark modules disabled via a `browser` map (`"foo": false`).
/// `\0` keeps it from colliding with any real filesystem path.
const BROWSER_DISABLED_PREFIX: &str = "\0deno-browser-disabled:";

pub struct DenoPluginHandler {
  file_fetcher: Arc<CliFileFetcher>,
  resolver: Arc<CliResolver>,
  module_load_preparer: Arc<ModuleLoadPreparer>,
  resolved_roots: Arc<RwLock<Arc<IndexSet<ModuleSpecifier>>>>,
  module_graph_container: Arc<MainModuleGraphContainer>,
  permissions: PermissionsContainer,
  /// Whether reads and writes must respect the calling program's permissions.
  /// The directly invoked `deno bundle` command is a trusted CLI path.
  is_runtime_api: bool,
  module_loader: Arc<CliDenoResolverModuleLoader>,
  externals_matcher: Option<ExternalsMatcher>,
  on_end_tx: tokio::sync::mpsc::Sender<esbuild_client::OnEndArgs>,
  deferred_resolve_errors: Arc<Mutex<Vec<DeferredResolveError>>>,
  virtual_modules: Option<Arc<VirtualModules>>,
  parsed_source_cache: Arc<ParsedSourceCache>,
  cjs_tracker: Arc<CliCjsTracker>,
  emitter: Arc<CliEmitter>,
  pkg_json_resolver: Arc<crate::node::CliPackageJsonResolver>,
  initial_cwd: Url,
}

impl DenoPluginHandler {
  fn take_deferred_resolve_errors(&self) -> Vec<DeferredResolveError> {
    std::mem::take(&mut *self.deferred_resolve_errors.lock())
  }

  fn update_virtual_module(&self, path: &str, module: VirtualModule) {
    if let Some(virtual_modules) = &self.virtual_modules {
      virtual_modules.insert(path.to_string(), module);
    }
  }

  /// Determines whether the resolved file has side effects by consulting
  /// the closest `package.json`'s `sideEffects` field. Returns `Some(false)`
  /// when the file is known to be side-effect-free (which lets esbuild
  /// tree-shake unused exports), and `None` otherwise (esbuild's default
  /// is to assume side effects).
  fn resolve_side_effects(&self, resolved: &str) -> Option<bool> {
    // Only inspect concrete local file paths. URLs (jsr:, https:, data:, etc.)
    // are handled elsewhere and don't have a package.json context.
    if resolved.starts_with("jsr:")
      || resolved.starts_with("https:")
      || resolved.starts_with("http:")
      || resolved.starts_with("data:")
      || resolved.starts_with("node:")
      || resolved.starts_with("bun:")
      || resolved.starts_with("npm:")
    {
      return None;
    }
    let file_path = std::path::Path::new(resolved);
    if !file_path.is_absolute() {
      return None;
    }
    let pkg_json = self
      .pkg_json_resolver
      .get_closest_package_json(file_path)
      .ok()
      .flatten()?;
    match pkg_json.side_effects.as_ref()? {
      deno_package_json::PackageJsonSideEffects::Bool(true) => None,
      deno_package_json::PackageJsonSideEffects::Bool(false) => Some(false),
      deno_package_json::PackageJsonSideEffects::Patterns(patterns) => {
        let rel = file_path.strip_prefix(pkg_json.dir_path()).ok()?;
        let rel_str = rel.to_string_lossy().replace('\\', "/");
        for pattern in patterns {
          if side_effects_pattern_matches(pattern, &rel_str) {
            return None;
          }
        }
        Some(false)
      }
    }
  }
}

/// Matches a path (relative to the package root, using forward slashes)
/// against a `sideEffects` entry from a `package.json`.
///
/// Per webpack's convention, a pattern without a leading `./`, `/`, or `*`
/// is implicitly treated as `**/<pattern>`.
fn side_effects_pattern_matches(pattern: &str, rel_path: &str) -> bool {
  let normalized = if let Some(rest) = pattern.strip_prefix("./") {
    rest.to_string()
  } else if pattern.starts_with('/') {
    pattern.trim_start_matches('/').to_string()
  } else {
    format!("**/{pattern}")
  };
  match glob::Pattern::new(&normalized) {
    Ok(p) => p.matches_with(
      rel_path,
      glob::MatchOptions {
        case_sensitive: true,
        require_literal_separator: false,
        require_literal_leading_dot: false,
      },
    ),
    Err(_) => false,
  }
}

#[async_trait::async_trait(?Send)]
impl esbuild_client::PluginHandler for DenoPluginHandler {
  async fn on_resolve(
    &self,
    args: esbuild_client::OnResolveArgs,
  ) -> Result<Option<esbuild_client::OnResolveResult>, AnyError> {
    log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_resolve"));

    if let Some(virtual_modules) = &self.virtual_modules
      && virtual_modules.contains(&args.path)
    {
      return Ok(Some(esbuild_client::OnResolveResult {
        path: Some(args.path),
        plugin_name: Some("deno".to_string()),
        namespace: Some("deno".to_string()),
        ..Default::default()
      }));
    }

    if let Some(matcher) = &self.externals_matcher
      && matcher.is_pre_resolve_match(&args.path)
    {
      return Ok(Some(esbuild_client::OnResolveResult {
        external: Some(true),
        path: Some(args.path),
        plugin_name: Some("deno".to_string()),
        plugin_data: None,
        ..Default::default()
      }));
    }

    // Same-document fragment references in CSS — `url(#default#VML)`,
    // `url(#gradient)` for SVG paint servers, etc. — are not file imports
    // and must be left as-is in the output. Without this, `bundle_resolve`
    // tries to resolve them as package import specifiers and fails (see
    // denoland/deno#32232).
    if matches!(
      args.kind,
      esbuild_client::protocol::ImportKind::UrlToken
        | esbuild_client::protocol::ImportKind::ImportRule
    ) && args.path.starts_with('#')
    {
      return Ok(Some(esbuild_client::OnResolveResult {
        external: Some(true),
        path: Some(args.path),
        plugin_name: Some("deno".to_string()),
        plugin_data: None,
        ..Default::default()
      }));
    }

    let result = self.bundle_resolve(
      &args.path,
      args.importer.as_deref(),
      args.resolve_dir.as_deref(),
      args.kind,
      args.with,
    );

    let result = match result {
      Ok(r) => r,
      Err(e) => {
        // A `browser` map entry of `false` disables the module: return a
        // sentinel path that `on_load` recognizes and turns into an empty
        // module, rather than surfacing the resolver error.
        if let Some(orig) = browser_map_disabled_specifier(&e) {
          return Ok(Some(esbuild_client::OnResolveResult {
            path: Some(format!("{BROWSER_DISABLED_PREFIX}{orig}")),
            namespace: Some("deno".into()),
            plugin_name: Some("deno".to_string()),
            ..Default::default()
          }));
        }
        return Ok(Some(esbuild_client::OnResolveResult {
          errors: Some(vec![esbuild_client::protocol::PartialMessage {
            id: "deno_error".into(),
            plugin_name: "deno".into(),
            text: e.to_string(),
            ..Default::default()
          }]),
          ..Default::default()
        }));
      }
    };

    Ok(result.map(|r| {
      // TODO(nathanwhit): remap the resolved path to be relative
      // to the output file. It will be tricky to figure out which
      // output file this import will end up in. We may have to use the metafile and rewrite at the end
      let is_external = r.starts_with("node:")
        || r.starts_with("bun:")
        // Always externalize native addons regardless of `--external`.
        // esbuild has no loader for `.node` files, and the user-facing
        // `*.node` pre-resolve pattern doesn't catch paths that arrive
        // here only after `.node`-extension auto-resolution (e.g. CJS
        // `require('./build/Release/foo')`).
        || r.ends_with(".node")
        || self
          .externals_matcher
          .as_ref()
          .map(|matcher| matcher.is_post_resolve_match(&r))
          .unwrap_or(false);

      let side_effects = if is_external {
        None
      } else {
        self.resolve_side_effects(&r)
      };

      esbuild_client::OnResolveResult {
        namespace: if r.starts_with("jsr:")
          || r.starts_with("https:")
          || r.starts_with("http:")
          || r.starts_with("data:")
        {
          Some("deno".into())
        } else {
          None
        },
        external: Some(is_external),
        path: Some(r),
        plugin_name: Some("deno".to_string()),
        plugin_data: None,
        side_effects,
        ..Default::default()
      }
    }))
  }

  async fn on_load(
    &self,
    args: esbuild_client::OnLoadArgs,
  ) -> Result<Option<esbuild_client::OnLoadResult>, AnyError> {
    log::debug!("{}: {args:?}", deno_terminal::colors::cyan("on_load"));
    if args.path.starts_with(BROWSER_DISABLED_PREFIX) {
      return Ok(Some(esbuild_client::OnLoadResult {
        contents: Some(b"module.exports = {};\n".to_vec()),
        loader: Some(esbuild_client::BuiltinLoader::Js),
        ..Default::default()
      }));
    }
    if let Some(virtual_modules) = &self.virtual_modules
      && let Some(module) = virtual_modules.get(&args.path)
    {
      let contents = module.contents.clone();
      let loader = module.loader;
      return Ok(Some(esbuild_client::OnLoadResult {
        contents: Some(contents),
        loader: Some(loader),
        ..Default::default()
      }));
    }
    let result = self
      .bundle_load(&args.path, &requested_type_from_map(&args.with))
      .await;
    let result = match result {
      Ok(r) => r,
      Err(e) => {
        if e.is_unsupported_media_type() {
          return Ok(None);
        }
        return Ok(Some(esbuild_client::OnLoadResult {
          errors: Some(vec![esbuild_client::protocol::PartialMessage {
            plugin_name: "deno".into(),
            text: e.to_string(),
            ..Default::default()
          }]),
          plugin_name: Some("deno".to_string()),
          ..Default::default()
        }));
      }
    };
    log::trace!(
      "{}: {:?}",
      deno_terminal::colors::magenta("on_load"),
      result.as_ref().map(|(code, loader)| format!(
        "{}: {:?}",
        String::from_utf8_lossy(code),
        loader
      ))
    );
    if let Some((code, loader)) = result {
      Ok(Some(esbuild_client::OnLoadResult {
        contents: Some(code),
        loader: Some(loader),
        ..Default::default()
      }))
    } else {
      Ok(None)
    }
  }

  async fn on_start(
    &self,
    _args: esbuild_client::OnStartArgs,
  ) -> Result<Option<esbuild_client::OnStartResult>, AnyError> {
    Ok(None)
  }

  async fn on_end(
    &self,
    _args: esbuild_client::OnEndArgs,
  ) -> Result<Option<esbuild_client::OnEndResult>, AnyError> {
    self.on_end_tx.send(_args).await?;
    Ok(None)
  }
}

fn import_kind_to_resolution_mode(
  kind: esbuild_client::protocol::ImportKind,
) -> ResolutionMode {
  match kind {
    protocol::ImportKind::EntryPoint
    | protocol::ImportKind::ImportStatement
    | protocol::ImportKind::ComposesFrom
    | protocol::ImportKind::DynamicImport
    | protocol::ImportKind::ImportRule
    | protocol::ImportKind::UrlToken => ResolutionMode::Import,
    protocol::ImportKind::RequireCall
    | protocol::ImportKind::RequireResolve => ResolutionMode::Require,
  }
}

#[derive(Debug, boxed_error::Boxed, deno_error::JsError)]
pub struct BundleLoadError(pub Box<BundleLoadErrorKind>);

#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum BundleLoadErrorKind {
  #[class(inherit)]
  #[error(transparent)]
  Fetch(#[from] deno_resolver::file_fetcher::FetchError),
  #[class(inherit)]
  #[error(transparent)]
  LoadCodeSource(#[from] LoadCodeSourceError),
  #[class(inherit)]
  #[error(transparent)]
  ResolveUrlOrPath(#[from] deno_path_util::ResolveUrlOrPathError),
  #[class(inherit)]
  #[error(transparent)]
  Permission(#[from] deno_runtime::deno_permissions::PermissionCheckError),
  #[class(inherit)]
  #[error(transparent)]
  ResolveWithGraph(#[from] ResolveWithGraphError),
  #[class(generic)]
  #[error("Failed to parse Wasm module: {0}")]
  WasmParse(String),
  #[class(generic)]
  #[error("UTF-8 conversion error")]
  Utf8(#[from] std::str::Utf8Error),
  #[class(generic)]
  #[error("UTF-8 conversion error")]
  StringUtf8(#[from] std::string::FromUtf8Error),
  #[class(generic)]
  #[error("Parse error")]
  Parse(#[from] deno_ast::ParseDiagnostic),
  #[class(generic)]
  #[error("Emit error")]
  Emit(#[from] deno_ast::EmitError),
  #[class(generic)]
  #[error("Prepare module load error")]
  PrepareModuleLoad(#[from] crate::module_loader::PrepareModuleLoadError),

  #[class(generic)]
  #[error("Package.json load error")]
  PackageJsonLoadError(#[from] node_resolver::errors::PackageJsonLoadError),

  #[class(generic)]
  #[error("Emit parsed source helper error")]
  EmitParsedSourceHelperError(
    #[from] deno_resolver::emit::EmitParsedSourceHelperError,
  ),
}

impl BundleLoadError {
  pub fn is_unsupported_media_type(&self) -> bool {
    match self.as_kind() {
      BundleLoadErrorKind::LoadCodeSource(e) => match e.as_kind() {
        LoadCodeSourceErrorKind::LoadPreparedModule(e) => match e.as_kind() {
          LoadPreparedModuleErrorKind::Graph(e) => matches!(
            e.original().as_module_error_kind(),
            Some(ModuleErrorKind::UnsupportedMediaType { .. }),
          ),
          _ => false,
        },
        _ => false,
      },
      _ => false,
    }
  }
}

/// Walk a `BundleError` looking for a `BrowserMapDisabled` error returned
/// by `node_resolver`. Returns the original specifier so the bundle can
/// emit an empty-module stub in place of the resolution.
fn browser_map_disabled_specifier(error: &BundleError) -> Option<String> {
  let BundleErrorKind::Resolver(resolve_err) = error.as_kind() else {
    return None;
  };
  let deno_resolver::graph::ResolveWithGraphErrorKind::Resolve(e) =
    resolve_err.as_kind()
  else {
    return None;
  };
  let deno_resolver::DenoResolveErrorKind::Node(node_err) = e.as_kind() else {
    return None;
  };
  let node_resolver::errors::NodeResolveErrorKind::BrowserMapDisabled(disabled) =
    node_err.as_kind()
  else {
    return None;
  };
  Some(disabled.specifier.clone())
}

/// Whether a specifier looks like a bare (npm/node-style) specifier, i.e. not
/// a relative path, absolute path, package-internal `#` import, or a URL with a
/// scheme (`file:`, `data:`, `node:`, `npm:`, `jsr:`, `http(s):`, ...).
fn looks_like_bare_specifier(specifier: &str) -> bool {
  !specifier.is_empty()
    && !specifier.starts_with('.')
    && !specifier.starts_with('/')
    && !specifier.starts_with('#')
    // Load-bearing on Windows: `Url::parse("C:/foo")` succeeds (scheme `c`), so
    // a drive-letter absolute path is correctly treated as not bare here. Don't
    // "simplify" this away or Windows absolute paths regress into the fallback.
    && Url::parse(specifier).is_err()
}

fn maybe_ignorable_resolution_error(
  error: &ResolveWithGraphError,
) -> Option<String> {
  if let deno_resolver::graph::ResolveWithGraphErrorKind::Resolve(e) =
    error.as_kind()
    && let deno_resolver::DenoResolveErrorKind::Node(node_err) = e.as_kind()
    && let node_resolver::errors::NodeResolveErrorKind::PackageResolve(pkg_err) =
      node_err.as_kind()
    && let node_resolver::errors::PackageResolveErrorKind::PackageFolderResolve(
      pkg_folder_err,
    ) = pkg_err.as_kind()
    && let node_resolver::errors::PackageFolderResolveErrorKind::PackageNotFound(
      PackageNotFoundError { package_name, .. },
    ) = pkg_folder_err.as_kind()
  {
    Some(package_name.to_string())
  } else if let deno_resolver::graph::ResolveWithGraphErrorKind::Resolution(
    deno_graph::ResolutionError::ResolverError {
      error: resolve_error,
      specifier,
      ..
    },
  ) = error.as_kind()
    && let deno_graph::source::ResolveError::Other(other_err) =
      resolve_error.deref()
    && let Some(import_map_err) = other_err
      .get_ref()
      .downcast_ref::<import_map::ImportMapError>()
    && let import_map::ImportMapErrorKind::UnmappedBareSpecifier(..) =
      import_map_err.as_kind()
  {
    Some(specifier.to_string())
  } else {
    None
  }
}

impl DenoPluginHandler {
  async fn reload_specifiers(
    &self,
    specifiers: &[PathBuf],
  ) -> Result<(), AnyError> {
    let mut graph_permit =
      self.module_graph_container.acquire_update_permit().await;
    let graph = graph_permit.graph_mut();
    let mut specifiers_vec = Vec::with_capacity(specifiers.len());
    for specifier in specifiers {
      let specifier = deno_path_util::url_from_file_path(specifier)?;
      // Raw imports are represented as external assets in the module graph.
      // Their contents are fetched by `bundle_load` instead of being stored
      // in the graph, so reloading them as ordinary graph roots loses their
      // requested module type (for example, `type: "css"`). Esbuild will call
      // `on_load` again for the changed file and fetch its current contents.
      if matches!(
        graph.get(&specifier),
        Some(deno_graph::Module::External(module)) if module.was_asset_load
      ) {
        continue;
      }
      specifiers_vec.push(specifier);
    }
    if !specifiers_vec.is_empty() {
      self
        .module_load_preparer
        .reload_specifiers(
          graph,
          specifiers_vec,
          false,
          self.permissions.clone(),
        )
        .await?;
    }
    graph_permit.commit();
    Ok(())
  }

  fn bundle_resolve(
    &self,
    path: &str,
    importer: Option<&str>,
    resolve_dir: Option<&str>,
    kind: esbuild_client::protocol::ImportKind,
    with: IndexMap<String, String>,
  ) -> Result<Option<String>, BundleError> {
    log::debug!(
      "bundle_resolve: {:?} {:?} {:?} {:?} {:?}",
      path,
      importer,
      resolve_dir,
      kind,
      with
    );
    if path.starts_with("data:") {
      return Ok(None);
    }
    let mut resolve_dir = resolve_dir.unwrap_or("").to_string();
    let resolver = self.resolver.clone();
    if !resolve_dir.ends_with(std::path::MAIN_SEPARATOR) {
      resolve_dir.push(std::path::MAIN_SEPARATOR);
    }
    let resolve_dir_path = Path::new(&resolve_dir);
    let mut referrer =
      resolve_url_or_path(importer.unwrap_or(""), resolve_dir_path)
        .unwrap_or_else(|_| self.initial_cwd.clone());
    if referrer.scheme() == "file" {
      let pth = referrer.to_file_path().unwrap();
      if (pth.is_dir()) && !pth.ends_with(std::path::MAIN_SEPARATOR_STR) {
        referrer.set_path(&format!(
          "{}{}",
          referrer.path(),
          std::path::MAIN_SEPARATOR
        ));
      }
    }

    log::debug!(
      "{}: {} {} {} {:?}",
      deno_terminal::colors::magenta("op_bundle_resolve"),
      path,
      resolve_dir,
      referrer,
      import_kind_to_resolution_mode(kind)
    );

    let graph = self.module_graph_container.graph();
    let result = resolver.resolve_with_graph(
      &graph,
      path,
      &referrer,
      Position::new(0, 0),
      ResolveWithGraphOptions {
        mode: import_kind_to_resolution_mode(kind),
        kind: NodeResolutionKind::Execution,
        maintain_npm_specifiers: false,
      },
    );

    log::debug!(
      "{}: {:?}",
      deno_terminal::colors::cyan("op_bundle_resolve result"),
      result
    );

    match result {
      Ok(specifier) => Ok(Some(file_path_or_url(specifier)?)),
      Err(e) => {
        log::debug!("{}: {:?}", deno_terminal::colors::red("error"), e);
        // The graph may record an unmapped-bare-specifier error for a referrer
        // pulled in from outside the entrypoint's package scope (e.g. a source
        // file reached via a `new Worker(new URL(...))` reference next to a
        // `dist/`-rooted entrypoint). The package is still in the build's npm
        // snapshot, so resolve it by name the way Node/Bun do before giving up.
        if looks_like_bare_specifier(path)
          && let Some(url) = resolver.resolve_bare_specifier_in_npm_snapshot(
            path,
            Some(&referrer),
            import_kind_to_resolution_mode(kind),
            NodeResolutionKind::Execution,
          )
        {
          log::debug!(
            "{}: resolved {} via npm snapshot fallback",
            deno_terminal::colors::cyan("op_bundle_resolve"),
            path
          );
          return Ok(Some(file_path_or_url(url)?));
        }
        if let Some(specifier) = maybe_ignorable_resolution_error(&e) {
          log::debug!(
            "{}: resolution failed, but maybe ignorable",
            deno_terminal::colors::red("warn")
          );
          self
            .deferred_resolve_errors
            .lock()
            .push(DeferredResolveError {
              path: specifier,
              error: e,
            });
          // we return None here because this lets esbuild choose to ignore the failure
          // for fallible imports/requires
          return Ok(None);
        }
        Err(BundleErrorKind::Resolver(e).into())
      }
    }
  }

  async fn prepare_module_load(
    &self,
    specifiers: &[ModuleSpecifier],
  ) -> Result<(), BundleLoadError> {
    let mut graph_permit =
      self.module_graph_container.acquire_update_permit().await;
    let graph: &mut deno_graph::ModuleGraph = graph_permit.graph_mut();
    self
      .module_load_preparer
      .prepare_module_load(
        graph,
        specifiers,
        PrepareModuleLoadOptions {
          is_dynamic: self.is_runtime_api,
          lib: TsTypeLib::default(),
          permissions: self.permissions.clone(),
          file_permission_api_name: self
            .is_runtime_api
            .then_some("Deno.bundle()"),
          ext_overwrite: None,
          allow_unknown_media_types: true,
          allow_sloppy_imports_hints_for_unreferenced_roots: true,
          skip_graph_roots_validation: true,
          file_content_overrides: Default::default(),
          file_header_overrides: Default::default(),
        },
      )
      .await?;
    graph_permit.commit();
    Ok(())
  }

  /// When bundling via `Deno.bundle()`, reading a module returns its
  /// (transpiled) source to the calling program, so it must be gated on the
  /// caller's permissions. `deno bundle` reads its inputs unchecked like the
  /// other CLI subcommands, in which case this is a no-op.
  ///
  /// Graph preparation already performs the check before reading source. This
  /// second boundary covers later esbuild loads and direct asset fetches.
  fn check_read_permission<'a>(
    &self,
    specifier: &'a Url,
  ) -> Result<Cow<'a, Url>, BundleLoadError> {
    if self.is_runtime_api {
      if specifier.scheme() == "file" {
        let path = deno_path_util::url_to_file_path(specifier).map_err(|_| {
          deno_runtime::deno_permissions::PermissionCheckError::InvalidFilePath(
            specifier.clone(),
          )
        })?;
        let checked_path = self.permissions.check_open(
          Cow::Owned(path),
          OpenAccessKind::Read,
          Some("Deno.bundle()"),
        )?;
        let checked_specifier = deno_path_util::url_from_file_path(
          &checked_path,
        )
        .map_err(|_| {
          deno_runtime::deno_permissions::PermissionCheckError::InvalidFilePath(
            specifier.clone(),
          )
        })?;
        return Ok(Cow::Owned(checked_specifier));
      } else {
        self
          .permissions
          .check_specifier(specifier, CheckSpecifierKind::Dynamic)?;
      }
    }
    Ok(Cow::Borrowed(specifier))
  }

  async fn bundle_load(
    &self,
    specifier: &str,
    requested_type: &RequestedModuleType<'_>,
  ) -> Result<Option<(Vec<u8>, esbuild_client::BuiltinLoader)>, BundleLoadError>
  {
    log::debug!(
      "{}: {:?} {:?}",
      deno_terminal::colors::magenta("bundle_load"),
      specifier,
      requested_type
    );

    let specifier = deno_path_util::resolve_url_or_path(
      specifier,
      Path::new(""), // should be absolute already, feels kind of hacky though
    )?;
    if specifier.scheme() == "data" {
      return Ok(None);
    }
    let (specifier, media_type) =
      if let RequestedModuleType::Bytes = requested_type {
        (specifier, MediaType::Unknown)
      } else if let RequestedModuleType::Text = requested_type {
        (specifier, MediaType::Unknown)
      } else if let Some((specifier, media_type, _)) =
        self.specifier_and_type_from_graph(&specifier)?
      {
        (specifier, media_type)
      } else {
        log::debug!(
          "{}: no specifier and type from graph for {}",
          deno_terminal::colors::yellow("warn"),
          specifier
        );

        let (media_type, _) =
          deno_media_type::resolve_media_type_and_charset_from_content_type(
            &specifier, None,
          );
        if media_type == deno_media_type::MediaType::Unknown {
          return Ok(None);
        }
        (specifier, media_type)
      };

    // `specifier` is now the concrete module we're about to read the source of
    // (a `file:` path for local modules, including npm packages resolved into
    // `node_modules`). Gate the read on the caller's permissions.
    let checked_specifier = self.check_read_permission(&specifier)?;

    let graph = self.module_graph_container.graph();
    let module_or_asset = self
      .module_loader
      .load(&graph, &specifier, None, requested_type, None)
      .await;
    let module_or_asset = match module_or_asset {
      Ok(module_or_asset) => module_or_asset,
      Err(e) => match e.as_kind() {
        LoadCodeSourceErrorKind::LoadUnpreparedModule(_) => {
          let file = self
            .file_fetcher
            .fetch(&checked_specifier, &self.permissions)
            .await?;
          let media_type = MediaType::from_specifier_and_headers(
            &specifier,
            file.maybe_headers.as_ref(),
          );
          match requested_type {
            RequestedModuleType::Text | RequestedModuleType::Bytes => {
              return self
                .create_module_response(
                  &graph,
                  &specifier,
                  media_type,
                  &file.source,
                  Some(requested_type),
                )
                .await
                .map(Some);
            }
            RequestedModuleType::None
            | RequestedModuleType::Json
            | RequestedModuleType::Other(_) => {
              if media_type.is_emittable() {
                let str = String::from_utf8_lossy(&file.source);
                let value = str.into();
                let source = self
                  .maybe_transpile(&specifier, media_type, &value, None)
                  .await?;
                return self
                  .create_module_response(
                    &graph,
                    &specifier,
                    media_type,
                    source.as_bytes(),
                    Some(requested_type),
                  )
                  .await
                  .map(Some);
              } else {
                return self
                  .create_module_response(
                    &graph,
                    &specifier,
                    media_type,
                    &file.source,
                    Some(requested_type),
                  )
                  .await
                  .map(Some);
              }
            }
          }
        }
        _ => return Err(e.into()),
      },
    };
    let loaded_code = match module_or_asset {
      LoadedModuleOrAsset::Module(loaded_module) => loaded_module.source,
      LoadedModuleOrAsset::ExternalAsset {
        specifier,
        statically_analyzable: _,
      } => {
        let checked_specifier = self.check_read_permission(&specifier)?;
        LoadedModuleSource::ArcBytes(
          self
            .file_fetcher
            .fetch(&checked_specifier, &self.permissions)
            .await?
            .source,
        )
      }
    };

    Ok(Some(
      self
        .create_module_response(
          &graph,
          &specifier,
          media_type,
          loaded_code.as_bytes(),
          Some(requested_type),
        )
        .await?,
    ))
  }

  async fn create_module_response(
    &self,
    graph: &deno_graph::ModuleGraph,
    specifier: &Url,
    media_type: MediaType,
    source: &[u8],
    requested_type: Option<&RequestedModuleType<'_>>,
  ) -> Result<(Vec<u8>, esbuild_client::BuiltinLoader), BundleLoadError> {
    match requested_type {
      Some(RequestedModuleType::Text) => {
        return Ok((source.to_vec(), esbuild_client::BuiltinLoader::Text));
      }
      Some(RequestedModuleType::Bytes) => {
        return Ok((source.to_vec(), esbuild_client::BuiltinLoader::Binary));
      }
      Some(RequestedModuleType::Json) => {
        return Ok((source.to_vec(), esbuild_client::BuiltinLoader::Json));
      }
      // `import sheet from "./a.css" with { type: "css" }` evaluates to a
      // `CSSStyleSheet` at runtime (see the custom module evaluation callback
      // in `runtime/worker.rs`). esbuild has no builtin loader that mirrors
      // this, so synthesize an equivalent JS module instead of routing the
      // source through esbuild's CSS loader (which would otherwise leave the
      // default import as an empty object).
      Some(RequestedModuleType::Other(ty)) if *ty == "css" => {
        let text = String::from_utf8_lossy(source);
        return Ok((
          render_css_module(&text).into_bytes(),
          esbuild_client::BuiltinLoader::Js,
        ));
      }
      Some(RequestedModuleType::Other(_) | RequestedModuleType::None)
      | None => {}
    }
    if media_type == MediaType::Wasm {
      let code = wasm::render_js_wasm_module(source)
        .map_err(|e| BundleLoadErrorKind::WasmParse(e.to_string()))?;
      return Ok((code.into_bytes(), esbuild_client::BuiltinLoader::Js));
    }
    if matches!(
      media_type,
      MediaType::JavaScript
        | MediaType::TypeScript
        | MediaType::Mjs
        | MediaType::Mts
        | MediaType::Cjs
        | MediaType::Cts
        | MediaType::Jsx
        | MediaType::Tsx
    ) && !graph.roots.contains(specifier)
    {
      let module_graph_container = self.module_graph_container.clone();
      let specifier = specifier.clone();
      let code = source.to_vec();
      let resolved_roots = self.resolved_roots.read().clone();
      let code = tokio::task::spawn_blocking(move || {
        Self::apply_transform(
          &resolved_roots,
          &module_graph_container,
          &specifier,
          media_type,
          &String::from_utf8(code)?,
        )
      })
      .await
      .unwrap()?;
      Ok((code.into_bytes(), media_type_to_loader(media_type)))
    } else {
      Ok((source.to_vec(), media_type_to_loader(media_type)))
    }
  }

  async fn maybe_transpile(
    &self,
    specifier: &Url,
    media_type: MediaType,
    source: &Arc<str>,
    is_known_script: Option<bool>,
  ) -> Result<Arc<str>, BundleLoadError> {
    let parsed_source = self.parsed_source_cache.remove_or_parse_module(
      specifier,
      media_type,
      source.clone(),
    )?;
    let is_cjs = if let Some(is_known_script) = is_known_script {
      self.cjs_tracker.is_cjs_with_known_is_script(
        specifier,
        media_type,
        is_known_script,
      )?
    } else {
      self.cjs_tracker.is_maybe_cjs(specifier, media_type)?
        && parsed_source.compute_is_script()
    };
    let module_kind = ModuleKind::from_is_cjs(is_cjs);
    let source = self
      .emitter
      .maybe_emit_parsed_source(parsed_source, module_kind)
      .await?;
    Ok(source)
  }

  fn apply_transform(
    resolved_roots: &IndexSet<ModuleSpecifier>,
    module_graph_container: &MainModuleGraphContainer,
    specifier: &ModuleSpecifier,
    media_type: deno_ast::MediaType,
    code: &str,
  ) -> Result<String, BundleLoadError> {
    let graph = module_graph_container.graph();
    let mut transform = transform::BundleImportMetaMainTransform::new(
      graph.roots.contains(specifier) || resolved_roots.contains(specifier),
    );
    // A CJS module imported from ESM reaches us as an ESM facade the module
    // loader generated (`import { createRequire } ...; export default mod;`).
    // Parsing that under `MediaType::Cjs` forces script mode (see
    // deno_ast `refine_parse_mode`), which rejects the facade's import/export
    // statements with a parse error. Parse as JavaScript so program mode
    // auto-detects module vs script and handles both the facade and genuine
    // CJS scripts.
    let media_type = match media_type {
      deno_ast::MediaType::Cjs => deno_ast::MediaType::JavaScript,
      other => other,
    };
    let parsed_source = deno_ast::parse_program_with_post_process(
      deno_ast::ParseParams {
        specifier: specifier.clone(),
        text: code.into(),
        media_type,
        capture_tokens: false,
        scope_analysis: false,
        maybe_syntax: None,
      },
      |mut program, _| {
        use deno_ast::swc::ecma_visit::VisitMut;
        transform.visit_mut_program(&mut program);
        program
      },
    )?;
    let code = deno_ast::emit(
      parsed_source.program_ref(),
      &parsed_source.comments().as_single_threaded(),
      &deno_ast::SourceMap::default(),
      &EmitOptions {
        source_map: deno_ast::SourceMapOption::None,
        ..Default::default()
      },
    )?;
    Ok(code.text)
  }

  fn specifier_and_type_from_graph(
    &self,
    specifier: &ModuleSpecifier,
  ) -> Result<
    Option<(
      ModuleSpecifier,
      deno_ast::MediaType,
      esbuild_client::BuiltinLoader,
    )>,
    BundleLoadError,
  > {
    let graph = self.module_graph_container.graph();
    let Some(module) = graph.get(specifier) else {
      return Ok(None);
    };
    let (specifier, media_type, loader) = match module {
      deno_graph::Module::Js(js_module) => (
        js_module.specifier.clone(),
        js_module.media_type,
        media_type_to_loader(js_module.media_type),
      ),
      deno_graph::Module::Json(json_module) => (
        json_module.specifier.clone(),
        deno_ast::MediaType::Json,
        esbuild_client::BuiltinLoader::Json,
      ),
      deno_graph::Module::Wasm(wasm_module) => (
        wasm_module.specifier.clone(),
        deno_ast::MediaType::Wasm,
        esbuild_client::BuiltinLoader::Js,
      ),
      deno_graph::Module::Npm(_) => {
        let req_ref =
          NpmPackageReqReference::from_specifier(specifier).unwrap();
        let url = self.resolver.resolve_managed_npm_req_ref(
          &req_ref,
          None,
          ResolutionMode::Import,
          NodeResolutionKind::Execution,
        )?;
        let (media_type, _charset) =
          deno_media_type::resolve_media_type_and_charset_from_content_type(
            &url, None,
          );
        (url, media_type, media_type_to_loader(media_type))
      }
      deno_graph::Module::Node(_) => {
        return Ok(None);
      }
      deno_graph::Module::External(_) => {
        return Ok(None);
      }
    };
    Ok(Some((specifier, media_type, loader)))
  }
}

fn file_path_or_url(
  url: Url,
) -> Result<String, deno_path_util::UrlToFilePathError> {
  if url.scheme() == "file" {
    Ok(
      deno_path_util::url_to_file_path(&url)?
        .to_string_lossy()
        .into(),
    )
  } else {
    Ok(url.into())
  }
}

/// Renders a `with { type: "css" }` import as a JS module that constructs a
/// constructable `CSSStyleSheet`, mirroring the runtime's custom module
/// evaluation for CSS imports.
fn render_css_module(text: &str) -> String {
  // `serde_json::to_string` produces a valid JS string literal with the CSS
  // text safely escaped.
  let literal = serde_json::to_string(text).unwrap();
  format!(
    "const sheet = new CSSStyleSheet();\nsheet.replaceSync({literal});\nexport default sheet;\n"
  )
}

fn media_type_to_loader(
  media_type: deno_media_type::MediaType,
) -> esbuild_client::BuiltinLoader {
  use deno_ast::MediaType::*;
  match media_type {
    JavaScript | Cjs | Mjs | Mts => esbuild_client::BuiltinLoader::Js,
    TypeScript | Cts | Dts | Dmts | Dcts => esbuild_client::BuiltinLoader::Ts,
    Jsx | Tsx => esbuild_client::BuiltinLoader::Jsx,
    Css => esbuild_client::BuiltinLoader::Css,
    Json => esbuild_client::BuiltinLoader::Json,
    Jsonc => esbuild_client::BuiltinLoader::Text,
    Json5 => esbuild_client::BuiltinLoader::Text,
    Markdown => esbuild_client::BuiltinLoader::Text,
    SourceMap => esbuild_client::BuiltinLoader::Text,
    Html => esbuild_client::BuiltinLoader::Text,
    Sql => esbuild_client::BuiltinLoader::Text,
    Wasm => esbuild_client::BuiltinLoader::Binary,
    Unknown => esbuild_client::BuiltinLoader::Binary,
    // _ => esbuild_client::BuiltinLoader::External,
  }
}

fn resolve_url_or_path_absolute(
  specifier: &str,
  current_dir: &Path,
  canonicalize: bool,
) -> Result<Url, AnyError> {
  if deno_path_util::specifier_has_uri_scheme(specifier) {
    Ok(Url::parse(specifier)?)
  } else {
    let path = current_dir.join(specifier);
    let path = deno_path_util::normalize_path(Cow::Owned(path));
    // Runtime permission checks must see the caller's path spelling before
    // following aliases. `check_open` then returns the canonical path used for
    // the actual read. The trusted CLI path retains its historical eager
    // canonicalization.
    let canonicalized_path = canonicalize_path(&path)?;
    let path = if canonicalize {
      Cow::Owned(canonicalized_path)
    } else {
      path
    };
    Ok(deno_path_util::url_from_file_path(&path)?)
  }
}

fn resolve_entrypoints(
  resolver: &CliResolver,
  init_cwd: &Path,
  entrypoints: &[String],
  canonicalize_entrypoints: bool,
) -> Result<Vec<Url>, AnyError> {
  let entrypoints = entrypoints
    .iter()
    .map(|e| {
      resolve_url_or_path_absolute(e, init_cwd, canonicalize_entrypoints)
    })
    .collect::<Result<Vec<_>, _>>()?;

  let init_cwd_url = Url::from_directory_path(init_cwd).unwrap();

  let mut resolved = Vec::with_capacity(entrypoints.len());

  for e in &entrypoints {
    let r = resolver.resolve(
      e.as_str(),
      &init_cwd_url,
      Position::new(0, 0),
      ResolutionMode::Import,
      NodeResolutionKind::Execution,
    )?;
    resolved.push(r);
  }
  Ok(resolved)
}

fn is_html_entrypoint(url: &Url) -> bool {
  url.scheme() == "file" && url.path().to_ascii_lowercase().ends_with(".html")
}

fn resolve_roots(
  entrypoints: Vec<Url>,
  cwd: &Path,
  npm_resolver: &CliNpmResolver,
  node_resolver: &CliNodeResolver,
) -> Vec<Url> {
  let mut roots = Vec::with_capacity(entrypoints.len());

  for url in entrypoints {
    let root = match NpmPackageReqReference::from_specifier(&url) {
      Ok(v) => {
        let referrer = ModuleSpecifier::from_directory_path(cwd).unwrap();
        let package_folder = npm_resolver
          .resolve_pkg_folder_from_deno_module_req(v.req(), &referrer)
          .unwrap();
        let Ok(node_resolver::BinValue::JsFile(main_module)) =
          node_resolver.resolve_binary_export(&package_folder, v.sub_path())
        else {
          roots.push(url);
          continue;
        };
        Url::from_file_path(&main_module).unwrap()
      }
      _ => url,
    };
    roots.push(root)
  }

  roots
}

/// Ensure that an Esbuild binary for the current os/arch is downloaded
/// and ready to use and then return path to it.
async fn ensure_esbuild_downloaded(
  factory: &CliFactory,
) -> Result<PathBuf, AnyError> {
  let installer_factory = factory.npm_installer_factory()?;
  let deno_dir = factory.deno_dir()?;
  let npmrc = factory.npmrc()?;
  let npm_registry_info = installer_factory.registry_info_provider()?;
  let resolver_factory = factory.resolver_factory()?;
  let workspace_factory = resolver_factory.workspace_factory();

  let esbuild_path = esbuild::ensure_esbuild(
    deno_dir,
    npmrc,
    npm_registry_info,
    workspace_factory.workspace_npm_link_packages()?,
    installer_factory.tarball_cache()?,
    factory.npm_cache()?,
  )
  .await?;
  Ok(esbuild_path)
}

fn configure_esbuild_flags(
  bundle_flags: &BundleFlags,
  is_html: bool,
) -> Vec<String> {
  let mut builder = EsbuildFlagsBuilder::default();

  builder
    .bundle(bundle_flags.inline_imports)
    .minify(bundle_flags.minify)
    .splitting(bundle_flags.code_splitting)
    .externals(bundle_flags.external.clone())
    .tree_shaking(true)
    .format(match bundle_flags.format {
      BundleFormat::Esm => esbuild_client::Format::Esm,
      BundleFormat::Cjs => esbuild_client::Format::Cjs,
      BundleFormat::Iife => esbuild_client::Format::Iife,
    })
    .packages(match bundle_flags.packages {
      PackageHandling::External => esbuild_client::PackagesHandling::External,
      PackageHandling::Bundle => esbuild_client::PackagesHandling::Bundle,
    });

  if bundle_flags.keep_names {
    builder.raw_flag("--keep-names");
  }

  if let Some(sourcemap_type) = bundle_flags.sourcemap {
    builder.sourcemap(match sourcemap_type {
      SourceMapType::Linked => esbuild_client::Sourcemap::Linked,
      SourceMapType::Inline => esbuild_client::Sourcemap::Inline,
      SourceMapType::External => esbuild_client::Sourcemap::External,
    });
  }

  if let Some(outdir) = bundle_flags.output_dir.clone() {
    builder.outdir(outdir);
  } else if let Some(output_path) = bundle_flags.output_path.clone() {
    builder.outfile(output_path);
  }
  builder.metafile(true);

  if is_html {
    builder.platform(esbuild_client::Platform::Browser);
    builder.splitting(true);
    builder.entry_names("[dir]/[name]-[hash]");
    builder.chunk_names("[dir]/[name]-[hash]");
    builder.asset_names("[dir]/[name]-[hash]");
    builder.metafile(true);
  }
  match bundle_flags.platform {
    deno_bundle_runtime::BundlePlatform::Browser => {
      builder.platform(esbuild_client::Platform::Browser);
    }
    deno_bundle_runtime::BundlePlatform::Deno => {}
  }

  builder.build()
}

// extract the path from a message like "Could not resolve "path/to/file.ts""
fn esbuild_resolve_error_path(
  error: &esbuild_client::protocol::Message,
) -> Option<String> {
  let re = lazy_regex::regex!(r#"^Could not resolve "([^"]+)"#);
  re.captures(error.text.as_str())
    .map(|captures| captures.get(1).unwrap().as_str().to_string())
}

fn handle_esbuild_errors_and_warnings(
  response: &BuildResponse,
  init_cwd: &Path,
  deferred_resolve_errors: &[DeferredResolveError],
) {
  for error in &response.errors {
    if let Some(path) = esbuild_resolve_error_path(error)
      && let Some(deferred_resolve_error) =
        deferred_resolve_errors.iter().find(|e| e.path == path)
    {
      let error = protocol::Message {
        // use our own error message, as it has more detail
        text: deferred_resolve_error.error.to_string(),
        ..error.clone()
      };
      log::error!(
        "{}: {}",
        deno_terminal::colors::red_bold("error"),
        format_message(&error, init_cwd)
      );
      continue;
    }
    log::error!(
      "{}: {}",
      deno_terminal::colors::red_bold("error"),
      format_message(error, init_cwd)
    );
  }

  for warning in &response.warnings {
    log::warn!(
      "{}: {}",
      deno_terminal::colors::yellow("bundler warning"),
      format_message(warning, init_cwd)
    );
  }
}

pub struct OutputFileInfo {
  relative_path: PathBuf,
  size: usize,
  is_js: bool,
}

pub struct ProcessedContents {
  contents: Option<Vec<u8>>,
  is_js: bool,
}

impl ProcessedContents {
  pub fn into_contents(self) -> Option<Vec<u8>> {
    self.contents
  }
}

fn is_js(path: &Path) -> bool {
  if let Some(ext) = path.extension() {
    matches!(
      ext.to_string_lossy().as_ref(),
      "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" | "dts"
    )
  } else {
    false
  }
}

pub fn maybe_process_contents(
  file: &OutputFile<'_>,
  should_replace_require_shim: bool,
  minified: bool,
) -> Result<ProcessedContents, AnyError> {
  let path = &file.path;
  let is_js = is_js(path) || path.ends_with("<stdout>");
  if is_js {
    let string = str::from_utf8(&file.contents)?;
    // The `createRequire` shim is Deno-specific (it injects an import from
    // `node:module`), so it is only applied for the Deno platform. The CJS
    // interop fix-up below is platform-independent and always runs.
    let string = if should_replace_require_shim {
      replace_require_shim(string, minified)
    } else {
      string.to_string()
    };
    let string = force_node_cjs_interop(&string);
    Ok(ProcessedContents {
      contents: Some(string.into_bytes()),
      is_js,
    })
  } else {
    Ok(ProcessedContents {
      contents: None,
      is_js,
    })
  }
}

pub struct OutputFile<'a> {
  pub path: PathBuf,
  pub contents: Cow<'a, [u8]>,
  pub hash: Option<String>,
}

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

impl<'a> From<&'a esbuild_client::protocol::BuildOutputFile>
  for OutputFile<'a>
{
  fn from(file: &'a esbuild_client::protocol::BuildOutputFile) -> Self {
    OutputFile {
      path: PathBuf::from(&file.path),
      contents: Cow::Borrowed(&file.contents),
      hash: Some(file.hash.clone()),
    }
  }
}

impl<'a> From<esbuild_client::protocol::BuildOutputFile> for OutputFile<'a> {
  fn from(file: esbuild_client::protocol::BuildOutputFile) -> Self {
    OutputFile {
      path: PathBuf::from(&file.path),
      contents: Cow::Owned(file.contents),
      hash: Some(file.hash),
    }
  }
}

pub fn collect_output_files<'a>(
  response_output_files: Option<&'a [protocol::BuildOutputFile]>,
  cwd: &Path,
  input: BundlerInput,
  outdir: Option<&Path>,
) -> Result<Vec<OutputFile<'a>>, AnyError> {
  let outdir = if let Some(outdir) = outdir {
    if outdir.is_absolute() {
      Some(outdir.to_path_buf())
    } else {
      Some(cwd.join(outdir))
    }
  } else {
    None
  };
  let mut output_files: Vec<OutputFile> = response_output_files
    .map(|fs| {
      fs.iter()
        .map(|f| OutputFile {
          path: PathBuf::from(&f.path),
          contents: Cow::Borrowed(&f.contents),
          hash: Some(f.hash.clone()),
        })
        .collect::<Vec<_>>()
    })
    .unwrap_or_default();

  if let BundlerInput::EntrypointsWithHtml {
    entries: _,
    html_pages,
  } = input
  {
    let outdir = outdir.ok_or_else(|| {
      deno_core::anyhow::anyhow!(
        "--outdir is required when bundling HTML entrypoints",
      )
    })?;

    let mut html_output_files = html::HtmlOutputFiles::new(&mut output_files);
    for page in html_pages {
      page.patch_html_with_response(cwd, &outdir, &mut html_output_files)?;
    }
  }
  Ok(output_files)
}

pub fn process_result(
  response: &BuildResponse,
  cwd: &Path,
  should_replace_require_shim: bool,
  minified: bool,
  input: BundlerInput,
  outdir: Option<&Path>,
  write_permissions: Option<&PermissionsContainer>,
) -> Result<Vec<OutputFileInfo>, AnyError> {
  let output_files =
    collect_output_files(response.output_files.as_deref(), cwd, input, outdir)?;
  let mut exists_cache = std::collections::HashSet::new();
  let mut output_infos = Vec::new();
  for file in output_files.iter() {
    let processed_contents =
      maybe_process_contents(file, should_replace_require_shim, minified)?;
    let path = Path::new(&file.path);
    let relative_path =
      pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf());
    let is_js = processed_contents.is_js;
    let bytes: Cow<'_, [u8]> = processed_contents
      .contents
      .map(Cow::Owned)
      .unwrap_or_else(|| Cow::Borrowed(file.contents.as_ref()));

    if file.path.ends_with("<stdout>") {
      deno_print::drop_write_stdout(bytes.as_ref());
      continue;
    }

    let checked_path = write_permissions
      .map(|permissions| {
        permissions.check_open(
          Cow::Owned(path.to_path_buf()),
          OpenAccessKind::Write,
          Some("Deno.bundle()"),
        )
      })
      .transpose()?;
    let path = checked_path.as_deref().unwrap_or(path);

    if let Some(parent) = path.parent()
      && !exists_cache.contains(parent)
    {
      if !parent.exists() {
        let checked_parent = write_permissions
          .map(|permissions| {
            permissions.check_open(
              Cow::Owned(parent.to_path_buf()),
              OpenAccessKind::WriteNoFollow,
              Some("Deno.bundle()"),
            )
          })
          .transpose()?;
        std::fs::create_dir_all(checked_parent.as_deref().unwrap_or(parent))?;
      }
      exists_cache.insert(parent.to_path_buf());
    }

    output_infos.push(OutputFileInfo {
      relative_path,
      size: bytes.len(),
      is_js,
    });

    std::fs::write(path, bytes.as_ref())?;
  }
  Ok(output_infos)
}

fn print_finished_message(
  metafile: &esbuild_client::Metafile,
  output_infos: &[OutputFileInfo],
  duration: Duration,
) -> Result<(), AnyError> {
  let mut output = String::new();
  output.push_str(&format!(
    "{} {} module{} in {}",
    deno_terminal::colors::green("Bundled"),
    metafile.inputs.len(),
    if metafile.inputs.len() == 1 { "" } else { "s" },
    crate::display::human_elapsed(duration),
  ));

  let longest = output_infos
    .iter()
    .map(|info| info.relative_path.to_string_lossy().len())
    .max()
    .unwrap_or(0);
  for info in output_infos {
    output.push_str(&format!(
      "\n  {} {}",
      if info.is_js {
        deno_terminal::colors::cyan(format!(
          "{:<longest$}",
          info.relative_path.display()
        ))
      } else {
        deno_terminal::colors::magenta(format!(
          "{:<longest$}",
          info.relative_path.display()
        ))
      },
      deno_terminal::colors::gray(
        crate::display::human_size(info.size as f64,)
      )
    ));
  }
  output.push('\n');
  log::info!("{}", output);

  Ok(())
}

#[cfg(test)]
mod entrypoint_tests {
  use deno_core::url::Url;

  use super::is_html_entrypoint;

  #[test]
  fn html_entrypoint_detection_ignores_fragment() {
    assert!(is_html_entrypoint(
      &Url::parse("file:///tmp/index.html").unwrap()
    ));
    assert!(is_html_entrypoint(
      &Url::parse("file:///tmp/INDEX.HTML").unwrap()
    ));
    assert!(!is_html_entrypoint(
      &Url::parse("file:///tmp/secret.txt#.html").unwrap()
    ));
    assert!(!is_html_entrypoint(
      &Url::parse("https://example.com/index.html").unwrap()
    ));
  }
}

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

  use super::flatten_declarations;
  use super::resolve_relative_dts_path;

  fn dts_key(dir: &Path, rel: &str) -> String {
    resolve_relative_dts_path(dir, rel)
      .to_string_lossy()
      .to_string()
  }

  // A re-export chain is inlined into a single self-contained file: the
  // referenced declarations are pulled in and the now-redundant relative
  // `import type` / re-export lines are removed.
  #[test]
  fn flatten_inlines_reexport_chain_and_strips_import() {
    let entry = Path::new("/proj/mod.ts");
    let dir = entry.parent().unwrap();
    let mut map = HashMap::new();
    map.insert(
      dts_key(dir, "./lib.ts"),
      "import type { Config } from \"./types.ts\";\n\
       export declare class Client {\n}\n"
        .to_string(),
    );
    map.insert(
      dts_key(dir, "./types.ts"),
      "export interface Config {\n}\n".to_string(),
    );
    let entry_dts = "export { Client } from \"./lib.ts\";\n\
                     export type { Config } from \"./types.ts\";\n";

    let out = flatten_declarations(entry_dts, entry, &map);

    assert!(out.contains("declare class Client"));
    assert!(out.contains("interface Config"));
    assert!(
      !out.contains("from \"./"),
      "flattened output should have no relative imports:\n{out}"
    );
  }

  // Regression for the over-strip bug: a bare `import` of an in-graph module
  // that is emitted but never re-exported (so its declarations are NOT inlined)
  // must be left intact. Stripping it on the mere presence of the file in the
  // emitted set would leave the imported symbol undefined in the output.
  #[test]
  fn flatten_keeps_import_of_non_reexported_module() {
    let entry = Path::new("/proj/mod.ts");
    let dir = entry.parent().unwrap();
    let mut map = HashMap::new();
    map.insert(
      dts_key(dir, "./lib.ts"),
      "import { Widget } from \"./internal.ts\";\n\
       export declare class Client {\n    widget(): Widget;\n}\n"
        .to_string(),
    );
    // Emitted but only imported (for a return type), never re-exported.
    map.insert(
      dts_key(dir, "./internal.ts"),
      "export declare class Widget {\n}\n".to_string(),
    );
    let entry_dts = "export { Client } from \"./lib.ts\";\n";

    let out = flatten_declarations(entry_dts, entry, &map);

    assert!(out.contains("declare class Client"));
    assert!(
      out.contains("import { Widget }"),
      "import of a non-re-exported module must be preserved so the symbol \
       still resolves:\n{out}"
    );
  }
}
