# frozen_string_literal: true

module Zeitwerk::Loader::Callbacks
  include Zeitwerk::RealModName
  extend Zeitwerk::Internal

  # Invoked from our decorated Kernel#require when a managed file is autoloaded.
  #
  # @raise [Zeitwerk::NameError]
  # @sig (String) -> void
  internal def on_file_autoloaded(file)
    cref = autoloads.delete(file)

    Zeitwerk::Registry.unregister_autoload(file)

    if cref.defined?
      log("constant #{cref.path} loaded from file #{file}") if logger
      to_unload[cref.path] = [file, cref] if reloading_enabled?
      run_on_load_callbacks(cref.path, cref.get, file) unless on_load_callbacks.empty?
    else
      msg = "expected file #{file} to define constant #{cref.path}, but didn't"
      log(msg) if logger

      # Ruby still keeps the autoload defined, but we remove it because the
      # contract in Zeitwerk is more strict.
      cref.remove

      # Since the expected constant was not defined, there is nothing to unload.
      # However, if the exception is rescued and reloading is enabled, we still
      # need to deleted the file from $LOADED_FEATURES.
      to_unload[cref.path] = [file, cref] if reloading_enabled?

      raise Zeitwerk::NameError.new(msg, cref.cname)
    end
  end

  # Invoked from our decorated Kernel#require when a managed directory is
  # autoloaded.
  #
  # @sig (String) -> void
  internal def on_dir_autoloaded(dir)
    # Module#autoload does not serialize concurrent requires in CRuby < 3.2, and
    # we handle directories ourselves without going through Kernel#require, so
    # the callback needs to account for concurrency.
    #
    # Multi-threading would introduce a race condition here in which thread t1
    # autovivifies the module, and while autoloads for its children are being
    # set, thread t2 autoloads the same namespace.
    #
    # Without the mutex and subsequent delete call, t2 would reset the module.
    # That not only would reassign the constant (undesirable per se) but, worse,
    # the module object created by t2 wouldn't have any of the autoloads for its
    # children, since t1 would have correctly deleted its namespace_dirs entry.
    dirs_autoload_monitor.synchronize do
      if cref = autoloads.delete(dir)
        implicit_namespace = cref.set(Module.new)
        cpath = implicit_namespace.name
        log("module #{cpath} autovivified from directory #{dir}") if logger

        to_unload[cpath] = [dir, cref] if reloading_enabled?

        # We don't unregister `dir` in the registry because concurrent threads
        # wouldn't find a loader associated to it in Kernel#require and would
        # try to require the directory. Instead, we are going to keep track of
        # these to be able to unregister later if eager loading.
        autoloaded_dirs << dir

        on_namespace_loaded(implicit_namespace)

        run_on_load_callbacks(cpath, implicit_namespace, dir) unless on_load_callbacks.empty?
      end
    end
  end

  # Invoked when a class or module is created or reopened, either from the
  # const_added or from module autovivification. If the namespace has matching
  # subdirectories, we descend into them now.
  #
  # @private
  # @sig (Module) -> void
  def on_namespace_loaded(namespace)
    if dirs = namespace_dirs.delete(real_mod_name(namespace))
      dirs.each do |dir|
        define_autoloads_for_dir(dir, namespace)
      end
    end
  end

  private

  # @sig (String, Object) -> void
  def run_on_load_callbacks(cpath, value, abspath)
    # Order matters. If present, run the most specific one.
    callbacks = reloading_enabled? ? on_load_callbacks[cpath] : on_load_callbacks.delete(cpath)
    callbacks&.each { |c| c.call(value, abspath) }

    callbacks = on_load_callbacks[:ANY]
    callbacks&.each { |c| c.call(cpath, value, abspath) }
  end
end
