# frozen_string_literal: true

require "active_support/core_ext/time/calculations"

module ActiveSupport
  # = \File Update Checker
  #
  # FileUpdateChecker specifies the API used by \Rails to watch files
  # and control reloading. The API depends on four methods:
  #
  # * +initialize+ which expects two parameters and one block as
  #   described below.
  #
  # * +updated?+ which returns a boolean if there were updates in
  #   the filesystem or not.
  #
  # * +execute+ which executes the given block on initialization
  #   and updates the latest watched files and timestamp.
  #
  # * +execute_if_updated+ which just executes the block if it was updated.
  #
  # After initialization, a call to +execute_if_updated+ must execute
  # the block only if there was really a change in the filesystem.
  #
  # This class is used by \Rails to reload the I18n framework whenever
  # they are changed upon a new request.
  #
  #   i18n_reloader = ActiveSupport::FileUpdateChecker.new(paths) do
  #     I18n.reload!
  #   end
  #
  #   ActiveSupport::Reloader.to_prepare do
  #     i18n_reloader.execute_if_updated
  #   end
  class FileUpdateChecker
    # It accepts two parameters on initialization. The first is an array
    # of files and the second is an optional hash of directories. The hash must
    # have directories as keys and the value is an array of extensions to be
    # watched under that directory.
    #
    # This method must also receive a block that will be called once a path
    # changes. The array of files and list of directories cannot be changed
    # after FileUpdateChecker has been initialized.
    def initialize(files, dirs = {}, &block)
      unless block
        raise ArgumentError, "A block is required to initialize a FileUpdateChecker"
      end

      gem_paths = Gem.path
      @files = files.reject { |file| File.expand_path(file).start_with?(*gem_paths) }.freeze

      @globs = compile_glob(dirs)&.reject { |dir| dir.start_with?(*gem_paths) }

      @block = block

      @watched    = nil
      @updated_at = nil

      @last_watched   = watched
      @last_update_at = updated_at(@last_watched)
    end

    # Check if any of the entries were updated. If so, the watched and/or
    # updated_at values are cached until the block is executed via +execute+
    # or +execute_if_updated+.
    def updated?
      current_watched = watched
      if @last_watched.size != current_watched.size
        @watched = current_watched
        true
      else
        current_updated_at = updated_at(current_watched)
        if @last_update_at < current_updated_at
          @watched    = current_watched
          @updated_at = current_updated_at
          true
        else
          false
        end
      end
    end

    # Executes the given block and updates the latest watched files and
    # timestamp.
    def execute
      @last_watched   = watched
      @last_update_at = updated_at(@last_watched)
      @block.call
    ensure
      @watched = nil
      @updated_at = nil
    end

    # Execute the block given if updated.
    def execute_if_updated
      if updated?
        yield if block_given?
        execute
        true
      else
        false
      end
    end

    private
      def watched
        @watched || begin
          all = @files.select { |f| File.exist?(f) }
          all.concat(Dir[*@globs]) if @globs
          all.tap(&:uniq!)
        end
      end

      def updated_at(paths)
        @updated_at || max_mtime(paths) || Time.at(0)
      end

      # This method returns the maximum mtime of the files in +paths+, or +nil+
      # if the array is empty.
      #
      # Files with a mtime in the future are ignored. Such abnormal situation
      # can happen for example if the user changes the clock by hand. It is
      # healthy to consider this edge case because with mtimes in the future
      # reloading is not triggered.
      def max_mtime(paths)
        time_now = Time.at(0, Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond), :nanosecond)
        max_mtime = nil

        # Time comparisons are performed with #compare_without_coercion because
        # AS redefines these operators in a way that is much slower and does not
        # bring any benefit in this particular code.
        #
        # Read t1.compare_without_coercion(t2) < 0 as t1 < t2.
        paths.each do |path|
          mtime = File.mtime(path)

          next if time_now.compare_without_coercion(mtime) < 0

          if max_mtime.nil? || max_mtime.compare_without_coercion(mtime) < 0
            max_mtime = mtime
          end
        end

        max_mtime
      end

      def compile_glob(hash)
        hash.freeze # Freeze so changes aren't accidentally pushed
        return if hash.empty?

        hash.map do |key, value|
          "#{escape(key)}/**/*#{compile_ext(value)}"
        end
      end

      def escape(key)
        key.gsub(",", '\,')
      end

      def compile_ext(array)
        array = Array(array)
        return if array.empty?
        ".{#{array.join(",")}}"
      end
  end
end
