# frozen_string_literal: true

require 'set'
require 'securerandom'

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

  UNDEFINED = Object.new.freeze
  private_constant :UNDEFINED

  #: camelize(String, String) -> String
  attr_accessor :inflector

  #: call(String) -> void | debug(String) -> void | nil
  attr_accessor :logger

  # Absolute paths of the root directories, mapped to their respective root namespaces:
  #
  #   '/Users/fxn/blog/app/channels' => Object,
  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
  #   ...
  #
  # Stored in a hash to preserve order, easily handle duplicates, and have a
  # fast lookup by directory.
  #
  # This is a private collection maintained by the loader. The public
  # interface for it is `push_dir` and `dirs`.
  #
  #: Hash[String, Module]
  attr_reader :roots
  internal :roots

  # Basename of files that define namespaces. For example, if `nsfile` is
  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
  #
  #: String?
  attr_reader :nsfile

  # Absolute paths of files, directories, or glob patterns to be ignored.
  #
  #: Set[String]
  attr_reader :ignored_glob_patterns
  private :ignored_glob_patterns

  # The actual collection of absolute file and directory names at the time the
  # ignored glob patterns were expanded. Computed on setup, and recomputed on
  # reload.
  #
  #: Set[String]
  attr_reader :ignored_paths
  private :ignored_paths

  # Absolute paths of directories or glob patterns to be collapsed.
  #
  #: Set[String]
  attr_reader :collapse_glob_patterns
  private :collapse_glob_patterns

  # The actual collection of absolute directory names at the time the collapse
  # glob patterns were expanded. Computed on setup and recomputed on reload.
  #
  #: Set[String]
  attr_reader :collapse_dirs
  private :collapse_dirs

  # Absolute paths of directories that are parents of collapsed directories.
  # This is a cache to optimize some tree walks. Computed on setup and
  # recomputed on reload.
  #
  #: Set[String]
  attr_reader :collapse_parents
  private :collapse_parents

  # Absolute paths of files or directories not to be eager loaded.
  #
  #: Set[String]
  attr_reader :eager_load_exclusions
  private :eager_load_exclusions

  # User-oriented callbacks to be fired on setup and on reload.
  #
  #: Array[{ () -> void }]
  attr_reader :on_setup_callbacks
  private :on_setup_callbacks

  # User-oriented callbacks to be fired when a constant is loaded.
  #
  #: Hash[String, Array[{ (top, String) -> void }]]
  #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
  attr_reader :on_load_callbacks
  private :on_load_callbacks

  # User-oriented callbacks to be fired before constants are removed.
  #
  #: Hash[String, Array[{ (top, String) -> void }]]
  #| Hash[Symbol, Array[{ (String, top, String) -> void }]]
  attr_reader :on_unload_callbacks
  private :on_unload_callbacks

  #: () -> void
  def initialize
    @inflector              = Zeitwerk::Inflector.new
    @logger                 = self.class.default_logger
    @tag                    = SecureRandom.hex(3)
    @initialized_at         = Time.now
    @roots                  = {}
    @nsfile                 = nil
    @ignored_glob_patterns  = Set.new
    @ignored_paths          = Set.new
    @collapse_glob_patterns = Set.new
    @collapse_dirs          = Set.new
    @collapse_parents       = Set.new
    @eager_load_exclusions  = Set.new
    @reloading_enabled      = false
    @on_setup_callbacks     = []
    @on_load_callbacks      = {}
    @on_unload_callbacks    = {}
  end

  # Pushes `path` to the list of root directories.
  #
  # Raises `Zeitwerk::Error` if `path` does not exist, or if another loader in
  # the same process already manages that directory or one of its ascendants or
  # descendants.
  #
  #: (String | Pathname, namespace: Module) -> void ! Zeitwerk::Error
  def push_dir(path, namespace: Object)
    unless namespace.is_a?(Module) # Note that Class < Module.
      raise Zeitwerk::Error, "#{namespace.inspect} is not a class or module object, should be"
    end

    unless real_mod_name(namespace)
      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
    end

    abspath = File.expand_path(path)
    if @fs.dir?(abspath)
      raise_if_conflicting_root_dir(abspath)
      roots[abspath] = namespace
    else
      raise Zeitwerk::Error, "the root directory #{abspath} does not exist"
    end
  end

  # Returns the loader's tag.
  #
  # Implemented as a method instead of via attr_reader for symmetry with the
  # writer below.
  #
  #: () -> String
  def tag
    @tag
  end

  # Sets a tag for the loader, useful for logging.
  #
  #: (to_s() -> String) -> void
  def tag=(tag)
    @tag = tag.to_s
  end

  #: (String?) -> void ! TypeError, ArgumentError
  def nsfile=(nsfile)
    unless nsfile.nil?
      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
    end

    @nsfile = nsfile
  end

  # If `namespaces` is falsey (default), returns an array with the absolute
  # paths of the root directories as strings. If truthy, returns a hash table
  # instead. Keys are the absolute paths of the root directories as strings,
  # values are their corresponding namespaces, class or module objects.
  #
  # If `ignored` is falsey (default), ignored root directories are filtered out.
  #
  # These are read-only collections, please add to them with `push_dir`.
  #
  #: (?namespaces: boolish, ?ignored: boolish) -> Array[String] | Hash[String, Module]
  def dirs(namespaces: false, ignored: false)
    if namespaces
      if ignored || ignored_paths.empty?
        roots.clone
      else
        roots.reject { |root_dir, _namespace| ignored_path?(root_dir) }
      end
    else
      if ignored || ignored_paths.empty?
        roots.keys
      else
        roots.keys.reject { |root_dir| ignored_path?(root_dir) }
      end
    end.freeze
  end

  # You need to call this method before setup in order to be able to reload.
  # There is no way to undo this, either you want to reload or you don't.
  #
  #: () -> void ! Zeitwerk::Error
  def enable_reloading
    mutex.synchronize do
      break if @reloading_enabled

      if @setup
        raise Zeitwerk::Error, 'cannot enable reloading after setup'
      else
        @reloading_enabled = true
      end
    end
  end

  #: () -> bool
  def reloading_enabled?
    @reloading_enabled
  end

  # Let eager load ignore the given files or directories. The constants defined
  # in those files are still autoloadable.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def do_not_eager_load(*paths)
    mutex.synchronize { eager_load_exclusions.merge(expand_paths(paths)) }
  end

  # Configure files, directories, or glob patterns to be totally ignored.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def ignore(*glob_patterns)
    glob_patterns = expand_paths(glob_patterns)
    mutex.synchronize do
      ignored_glob_patterns.merge(glob_patterns)
      ignored_paths.merge(expand_glob_patterns(glob_patterns))
    end
  end

  # Configure directories or glob patterns to be collapsed.
  #
  #: (*(String | Pathname | Array[String | Pathname])) -> void
  def collapse(*glob_patterns)
    glob_patterns = expand_paths(glob_patterns)
    mutex.synchronize do
      collapse_glob_patterns.merge(glob_patterns)
      new_collapse_dirs = expand_glob_patterns(glob_patterns)
      collapse_dirs.merge(new_collapse_dirs)
      new_collapse_dirs.each do |dir|
        collapse_parents << File.dirname(dir)
      end
    end
  end

  # Configure a block to be called after setup and on each reload.
  # If setup was already done, the block runs immediately.
  #
  #: () { () -> void } -> void
  def on_setup(&block)
    mutex.synchronize do
      on_setup_callbacks << block
      block.call if @setup
    end
  end

  # Configure a block to be invoked once a certain constant path is loaded.
  # Supports multiple callbacks, and if there are many, they are executed in
  # the order in which they were defined.
  #
  #   loader.on_load('SomeApiClient') do |klass, _abspath|
  #     klass.endpoint = 'https://api.dev'
  #   end
  #
  # Can also be configured for any constant loaded:
  #
  #   loader.on_load do |cpath, value, abspath|
  #     # ...
  #   end
  #
  #: (String) { (top, String) -> void } -> void ! TypeError | NameError
  #| { (String, top, String) -> void } -> void
  def on_load(cpath = UNDEFINED, &block)
    key = if cpath.equal?(UNDEFINED)
      :ANY
    elsif !cpath.is_a?(String)
      raise TypeError, 'on_load only accepts strings'
    else
      @cpv.validate!(cpath)
    end

    mutex.synchronize do
      (on_load_callbacks[key] ||= []) << block
    end
  end

  # Configure a block to be invoked right before a certain constant is removed.
  # Supports multiple callbacks, and if there are many, they are executed in the
  # order in which they were defined.
  #
  #   loader.on_unload('Country') do |klass, _abspath|
  #     klass.clear_cache
  #   end
  #
  # Can also be configured for any removed constant:
  #
  #   loader.on_unload do |cpath, value, abspath|
  #     # ...
  #   end
  #
  #: (String) { (top, String) -> void } -> void ! TypeError | NameError
  #| { (String, top, String) -> void } -> void
  def on_unload(cpath = UNDEFINED, &block)
    key = if cpath.equal?(UNDEFINED)
      :ANY
    elsif !cpath.is_a?(String)
      raise TypeError, 'on_unload only accepts strings'
    else
      @cpv.validate!(cpath)
    end

    mutex.synchronize do
      (on_unload_callbacks[key] ||= []) << block
    end
  end

  # Logs to `$stdout`, handy shortcut for debugging.
  #
  #: () -> void
  def log!
    @logger = ->(msg) { puts msg }
  end

  # Returns true if the argument has been configured to be ignored, or is a
  # descendant of an ignored directory.
  #
  #: (String) -> bool
  internal def ignores?(abspath)
    # Common use case.
    return false if ignored_paths.empty?

    @fs.walk_up(abspath) do |path|
      return true  if ignored_path?(path)
      return false if root_dir?(path)
    end

    false
  end

  #: (String) -> bool
  internal def ignored_path?(abspath)
    ignored_paths.member?(abspath)
  end

  #: () -> Array[String]
  private def actual_roots
    roots.reject do |root_dir, _root_namespace|
      !@fs.dir?(root_dir) || ignored_path?(root_dir)
    end
  end

  #: (String) -> bool
  internal def root_dir?(dir)
    roots.key?(dir)
  end

  #: (String) -> bool
  internal def collapse?(dir)
    collapse_dirs.member?(dir)
  end

  #: (String) -> bool
  internal def collapse_parent?(dir)
    collapse_parents.member?(dir)
  end

  #: (String) -> bool
  private def excluded_from_eager_load?(abspath)
    # Optimize this common use case.
    return false if eager_load_exclusions.empty?

    @fs.walk_up(abspath) do |path|
      return true  if eager_load_exclusions.member?(path)
      return false if root_dir?(path)
    end

    false
  end

  #: (String | Pathname | Array[String | Pathname]) -> Array[String]
  private def expand_paths(paths)
    paths.flatten.map! { |path| File.expand_path(path) }
  end

  #: (Array[String]) -> Array[String]
  private def expand_glob_patterns(glob_patterns)
    # Note that Dir.glob works with regular file names just fine. That is,
    # glob patterns technically need no wildcards.
    glob_patterns.flat_map { |glob_pattern| Dir.glob(glob_pattern) }
  end

  #: () -> void
  private def recompute_ignored_paths
    ignored_paths.replace(expand_glob_patterns(ignored_glob_patterns))
  end

  #: () -> void
  private def recompute_collapse_dirs
    collapse_dirs.replace(expand_glob_patterns(collapse_glob_patterns))
  end

  #: () -> void
  private def recompute_collapse_parents
    collapse_parents.clear
    collapse_dirs.each do |dir|
      collapse_parents << File.dirname(dir)
    end
  end
end
