module Dry
  # Build an equalizer module for the inclusion in other class
  #
  # @api public
  def self.Equalizer(*keys, **options)
    Dry::Equalizer.new(*keys, **options)
  end

  # Define equality, equivalence and inspection methods
  class Equalizer < Module
    # Initialize an Equalizer with the given keys
    #
    # Will use the keys with which it is initialized to define #cmp?,
    # #hash, and #inspect
    #
    # @param [Array<Symbol>] keys
    # @param [Hash] options
    # @option options [Boolean] :inspect whether to define #inspect method
    # @option options [Boolean] :immutable whether to memoize #hash method
    #
    # @return [undefined]
    #
    # @api private
    def initialize(*keys, **options)
      @keys = keys.uniq
      define_methods(**options)
      freeze
    end

    private

    # Hook called when module is included
    #
    # @param [Module] descendant
    #   the module or class including Equalizer
    #
    # @return [self]
    #
    # @api private
    def included(descendant)
      super
      descendant.include Methods
    end

    # Define the equalizer methods based on #keys
    #
    # @param [Boolean] inspect whether to define #inspect method
    # @param [Boolean] immutable whether to memoize #hash method
    #
    # @return [undefined]
    #
    # @api private
    def define_methods(inspect: true, immutable: false)
      define_cmp_method
      define_hash_method(immutable: immutable)
      define_inspect_method if inspect
    end

    # Define an #cmp? method based on the instance's values identified by #keys
    #
    # @return [undefined]
    #
    # @api private
    def define_cmp_method
      keys = @keys
      define_method(:cmp?) do |comparator, other|
        keys.all? do |key|
          __send__(key).public_send(comparator, other.__send__(key))
        end
      end
      private :cmp?
    end

    # Define a #hash method based on the instance's values identified by #keys
    #
    # @return [undefined]
    #
    # @api private
    def define_hash_method(immutable:)
      calculate_hash = ->(obj) { @keys.map { |key| obj.__send__(key) }.push(obj.class).hash }
      if immutable
        define_method(:hash) do
          @__hash__ ||= calculate_hash.call(self)
        end
        define_method(:freeze) do
          hash
          super()
        end
      else
        define_method(:hash) do
          calculate_hash.call(self)
        end
      end
    end

    # Define an inspect method that reports the values of the instance's keys
    #
    # @return [undefined]
    #
    # @api private
    def define_inspect_method
      keys = @keys
      define_method(:inspect) do
        klass = self.class
        name  = klass.name || klass.inspect
        "#<#{name}#{keys.map { |key| " #{key}=#{__send__(key).inspect}" }.join}>"
      end
    end

    # The comparison methods
    module Methods
      # Compare the object with other object for equality
      #
      # @example
      #   object.eql?(other)  # => true or false
      #
      # @param [Object] other
      #   the other object to compare with
      #
      # @return [Boolean]
      #
      # @api public
      def eql?(other)
        instance_of?(other.class) && cmp?(__method__, other)
      end

      # Compare the object with other object for equivalency
      #
      # @example
      #   object == other  # => true or false
      #
      # @param [Object] other
      #   the other object to compare with
      #
      # @return [Boolean]
      #
      # @api public
      def ==(other)
        other.is_a?(self.class) && cmp?(__method__, other)
      end
    end
  end
end
