require 'pathname'
require 'padrino-core/reloader/rack'
require 'padrino-core/reloader/storage'

module Padrino
  ##
  # High performance source code reloader middleware
  #
  module Reloader
    ##
    # This reloader is suited for use in a many environments because each file
    # will only be checked once and only one system call to stat(2) is made.
    #
    # Please note that this will not reload files in the background, and does so
    # only when explicitly invoked.
    #
    extend self

    # The modification times for every file in a project.
    MTIMES = {}

    ##
    # Specified folders can be excluded from the code reload detection process.
    # Default excluded directories at Padrino.root are: test, spec, features, tmp, config, db and public
    #
    def exclude
      @_exclude ||= Set.new(%w[test spec tmp features config public db].map { |path| Padrino.root(path) })
    end

    ##
    # Specified constants can be excluded from the code unloading process.
    #
    def exclude_constants
      @_exclude_constants ||= Set.new
    end

    ##
    # Specified constants can be configured to be reloaded on every request.
    # Default included constants are: [none]
    #
    def include_constants
      @_include_constants ||= Set.new
    end

    ##
    # Reload apps and files with changes detected.
    #
    def reload!
      rotation do |file|
        next unless file_changed?(file)
        reload_special(file) || reload_regular(file)
      end
    end

    ##
    # Remove files and classes loaded with stat
    #
    def clear!
      MTIMES.clear
      Storage.clear!
    end

    ##
    # Returns true if any file changes are detected.
    #
    def changed?
      rotation do |file|
        break true if file_changed?(file)
      end
    end

    ##
    # We lock dependencies sets to prevent reloading of protected constants
    #
    def lock!
      klasses = Storage.send(:object_classes) do |klass|
        constant_name(klass)&.split('::')&.first
      end
      klasses |= Padrino.mounted_apps.map(&:app_class)
      exclude_constants.merge(klasses)
    end

    ##
    # A safe Kernel::require which issues the necessary hooks depending on results
    #
    def safe_load(file, options = {})
      began_at = Time.now
      file     = figure_path(file)
      return unless options[:force] || file_changed?(file)
      return require(file) if feature_excluded?(file)

      Storage.prepare(file) # might call #safe_load recursively
      logger.devel(file_new?(file) ? :loading : :reload, began_at, file)
      begin
        with_silence { require(file) }
        Storage.commit(file)
        update_modification_time(file)
      rescue Exception => e
        unless options[:cyclic]
          logger.exception e, :short
          logger.error "Failed to load #{file}; removing partially defined constants"
        end
        Storage.rollback(file)
        raise
      end
    end

    ##
    # Removes the specified class and constant.
    #
    def remove_constant(const)
      return if constant_excluded?(const)
      base, _, object = const.to_s.rpartition('::')
      base = base.empty? ? Object : base.constantize
      base.send :remove_const, object
      logger.devel "Removed constant #{const} from #{base}"
    rescue NameError
      # no const to remove
    end

    ##
    # Remove a feature from $LOADED_FEATURES so it can be required again.
    #
    def remove_feature(file)
      $LOADED_FEATURES.delete(file) unless feature_excluded?(file)
    end

    ##
    # Returns the list of special tracked files for Reloader.
    #
    def special_files
      @special_files ||= Set.new
    end

    ##
    # Sets the list of special tracked files for Reloader.
    #
    def special_files=(files)
      @special_files = Set.new(files)
    end

    private

    ##
    # Returns absolute path of the file.
    #
    def figure_path(file)
      return file if Pathname.new(file).absolute?
      $LOAD_PATH.each do |path|
        found = File.join(path, file)
        return File.expand_path(found) if File.file?(found)
      end
      file
    end

    ##
    # Reloads the file if it's special. For now it's only I18n locale files.
    #
    def reload_special(file)
      return unless special_files.any? { |special_file| File.identical?(special_file, file) }
      if defined?(I18n)
        began_at = Time.now
        I18n.reload!
        update_modification_time(file)
        logger.devel :reload, began_at, file
      end
      true
    end

    ##
    # Reloads ruby file and applications dependent on it.
    #
    def reload_regular(file)
      apps = mounted_apps_of(file)
      if apps.empty?
        reloadable_apps.each do |app|
          app.app_obj.reload! if app.app_obj.dependencies.include?(file)
        end
        safe_load(file)
      else
        apps.each { |app| app.app_obj.reload! }
        update_modification_time(file)
      end
    end

    ###
    # Macro for mtime update.
    #
    def update_modification_time(file)
      MTIMES[file] = File.mtime(file)
    end

    ###
    # Returns true if the file is new or it's modification time changed.
    #
    def file_changed?(file)
      file_new?(file) || File.mtime(file) > MTIMES[file]
    end

    ###
    # Returns true if the file is new.
    #
    def file_new?(file)
      MTIMES[file].nil?
    end

    ##
    # Searches Ruby files in your +Padrino.load_paths+ , Padrino::Application.load_paths
    # and monitors them for any changes.
    #
    def rotation
      files_for_rotation.each do |file|
        file = File.expand_path(file)
        next if Reloader.exclude.any? { |base| file.start_with?(base) } || !File.file?(file)
        yield file
      end
      nil
    end

    ##
    # Creates an array of paths for use in #rotation.
    #
    def files_for_rotation
      files = Set.new
      Padrino.dependency_paths.each do |path|
        files += Dir.glob(path)
      end
      reloadable_apps.each do |app|
        files << app.app_file
        files += Dir.glob(app.app_obj.prerequisites)
        files += app.app_obj.dependencies
      end
      files + special_files
    end

    ##
    # Tells if a feature should be excluded from Reloader tracking.
    #
    def feature_excluded?(file)
      !file.start_with?(Padrino.root) || exclude.any? { |excluded_path| file.start_with?(excluded_path) }
    end

    ##
    # Tells if a constant should be excluded from Reloader routines.
    #
    def constant_excluded?(const)
      external_constant?(const) || (exclude_constants - include_constants).any? { |excluded_constant| constant_name(const).start_with?(excluded_constant) }
    end

    ##
    # Tells if a constant is defined only outside of Padrino project path.
    # If a constant has any methods defined inside of the project path it's
    # considered internal and will be included in further testing.
    #
    def external_constant?(const)
      sources = object_sources(const)
      # consider methodless constants not external
      return false if sources.empty?
      sources.none? { |source| source.start_with?(Padrino.root) }
    end

    ##
    # Gets all the sources in which target class is defined.
    #
    # Note: Method#source_location is for Ruby 1.9.3+ only.
    #
    def object_sources(target)
      sources = Set.new
      target.methods.each do |method_name|
        next unless method_name.is_a?(Symbol)

        method_object = target.method(method_name)
        sources << method_object.source_location.first if method_object.owner == target.singleton_class
      end

      target.instance_methods.each do |method_name|
        next unless method_name.is_a?(Symbol)

        method_object = target.instance_method(method_name)
        sources << method_object.source_location.first if method_object.owner == target
      end
      sources
    end

    ##
    # Return the mounted_apps providing the app location.
    # Can be an array because in one app.rb we can define multiple Padrino::Application.
    #
    def mounted_apps_of(file)
      Padrino.mounted_apps.select { |app| File.identical?(file, app.app_file) }
    end

    ##
    # Return the apps that allow reloading.
    #
    def reloadable_apps
      Padrino.mounted_apps.select do |app|
        next unless app.app_file.start_with?(Padrino.root)
        app.app_obj.respond_to?(:reload) && app.app_obj.reload?
      end
    end

    ##
    # Disables output, yields block, switches output back.
    #
    def with_silence
      verbosity_level, $-v = $-v, nil
      yield
    ensure
      $-v = verbosity_level
    end

    def constant_name(constant)
      constant._orig_klass_name
    rescue NoMethodError
      constant.name
    end
  end
end
