module RSpec
  module Matchers
    module BuiltIn
      # @api private
      # Provides the implementation for `change`.
      # Not intended to be instantiated directly.
      class Change < BaseMatcher
        # @api public
        # Specifies the delta of the expected change.
        def by(expected_delta)
          ChangeRelatively.new(@change_details, expected_delta, :by) do |actual_delta|
            values_match?(expected_delta, actual_delta)
          end
        end

        # @api public
        # Specifies a minimum delta of the expected change.
        def by_at_least(minimum)
          ChangeRelatively.new(@change_details, minimum, :by_at_least) do |actual_delta|
            actual_delta >= minimum
          end
        end

        # @api public
        # Specifies a maximum delta of the expected change.
        def by_at_most(maximum)
          ChangeRelatively.new(@change_details, maximum, :by_at_most) do |actual_delta|
            actual_delta <= maximum
          end
        end

        # @api public
        # Specifies the new value you expect.
        def to(value)
          ChangeToValue.new(@change_details, value)
        end

        # @api public
        # Specifies the original value.
        def from(value)
          ChangeFromValue.new(@change_details, value)
        end

        # @private
        def matches?(event_proc)
          @event_proc = event_proc
          return false unless Proc === event_proc
          raise_block_syntax_error if block_given?
          @change_details.perform_change(event_proc)
          @change_details.changed?
        end

        def does_not_match?(event_proc)
          raise_block_syntax_error if block_given?
          !matches?(event_proc) && Proc === event_proc
        end

        # @api private
        # @return [String]
        def failure_message
          "expected #{@change_details.message} to have changed, but #{positive_failure_reason}"
        end

        # @api private
        # @return [String]
        def failure_message_when_negated
          "expected #{@change_details.message} not to have changed, but #{negative_failure_reason}"
        end

        # @api private
        # @return [String]
        def description
          "change #{@change_details.message}"
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def initialize(receiver=nil, message=nil, &block)
          @change_details = ChangeDetails.new(receiver, message, &block)
        end

        def raise_block_syntax_error
          raise SyntaxError, "The block passed to the `change` matcher must " \
            "use `{ ... }` instead of do/end"
        end

        def positive_failure_reason
          return "was not given a block" unless Proc === @event_proc
          "is still #{description_of @change_details.actual_before}"
        end

        def negative_failure_reason
          return "was not given a block" unless Proc === @event_proc
          "did change from #{description_of @change_details.actual_before} to #{description_of @change_details.actual_after}"
        end
      end

      # Used to specify a relative change.
      # @api private
      class ChangeRelatively < BaseMatcher
        def initialize(change_details, expected_delta, relativity, &comparer)
          @change_details = change_details
          @expected_delta = expected_delta
          @relativity     = relativity
          @comparer       = comparer
        end

        # @private
        def failure_message
          "expected #{@change_details.message} to have changed #{@relativity.to_s.gsub("_", " ")} #{description_of @expected_delta}, but #{failure_reason}"
        end

        # @private
        def matches?(event_proc)
          @event_proc = event_proc
          return false unless Proc === event_proc
          @change_details.perform_change(event_proc)
          @comparer.call(@change_details.actual_delta)
        end

        # @private
        def does_not_match?(_event_proc)
          raise NotImplementedError, "`expect { }.not_to change { }.#{@relativity}()` is not supported"
        end

        # @private
        def description
          "change #{@change_details.message} #{@relativity.to_s.gsub("_", " ")} #{description_of @expected_delta}"
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def failure_reason
          return "was not given a block" unless Proc === @event_proc
          "was changed by #{description_of @change_details.actual_delta}"
        end
      end

      # @api private
      # Base class for specifying a change from and/or to specific values.
      class SpecificValuesChange < BaseMatcher
        # @private
        MATCH_ANYTHING = ::Object.ancestors.last

        def initialize(change_details, from, to)
          @change_details  = change_details
          @expected_before = from
          @expected_after  = to
        end

        # @private
        def matches?(event_proc)
          @event_proc = event_proc
          return false unless Proc === event_proc
          @change_details.perform_change(event_proc)
          @change_details.changed? && matches_before? && matches_after?
        end

        # @private
        def description
          "change #{@change_details.message} #{change_description}"
        end

        # @private
        def failure_message
          return not_given_a_block_failure unless Proc === @event_proc
          return before_value_failure      unless matches_before?
          return did_not_change_failure    unless @change_details.changed?
          after_value_failure
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def matches_before?
          values_match?(@expected_before, @change_details.actual_before)
        end

        def matches_after?
          values_match?(@expected_after, @change_details.actual_after)
        end

        def before_value_failure
          "expected #{@change_details.message} to have initially been #{description_of @expected_before}, but was #{description_of @change_details.actual_before}"
        end

        def after_value_failure
          "expected #{@change_details.message} to have changed to #{description_of @expected_after}, but is now #{description_of @change_details.actual_after}"
        end

        def did_not_change_failure
          "expected #{@change_details.message} to have changed #{change_description}, but did not change"
        end

        def did_change_failure
          "expected #{@change_details.message} not to have changed, but did change from #{description_of @change_details.actual_before} to #{description_of @change_details.actual_after}"
        end

        def not_given_a_block_failure
          "expected #{@change_details.message} to have changed #{change_description}, but was not given a block"
        end
      end

      # @api private
      # Used to specify a change from a specific value
      # (and, optionally, to a specific value).
      class ChangeFromValue < SpecificValuesChange
        def initialize(change_details, expected_before)
          @description_suffix = nil
          super(change_details, expected_before, MATCH_ANYTHING)
        end

        # @api public
        # Specifies the new value you expect.
        def to(value)
          @expected_after     = value
          @description_suffix = " to #{description_of value}"
          self
        end

        # @private
        def does_not_match?(event_proc)
          if @description_suffix
            raise NotImplementedError, "`expect { }.not_to change { }.to()` is not supported"
          end

          @event_proc = event_proc
          return false unless Proc === event_proc
          @change_details.perform_change(event_proc)
          !@change_details.changed? && matches_before?
        end

        # @private
        def failure_message_when_negated
          return not_given_a_block_failure unless Proc === @event_proc
          return before_value_failure unless matches_before?
          did_change_failure
        end

      private

        def change_description
          "from #{description_of @expected_before}#{@description_suffix}"
        end
      end

      # @api private
      # Used to specify a change to a specific value
      # (and, optionally, from a specific value).
      class ChangeToValue < SpecificValuesChange
        def initialize(change_details, expected_after)
          @description_suffix = nil
          super(change_details, MATCH_ANYTHING, expected_after)
        end

        # @api public
        # Specifies the original value.
        def from(value)
          @expected_before    = value
          @description_suffix = " from #{description_of value}"
          self
        end

        # @private
        def does_not_match?(_event_proc)
          raise NotImplementedError, "`expect { }.not_to change { }.to()` is not supported"
        end

      private

        def change_description
          "to #{description_of @expected_after}#{@description_suffix}"
        end
      end

      # @private
      # Encapsulates the details of the before/after values.
      class ChangeDetails
        attr_reader :message, :actual_before, :actual_after

        def initialize(receiver=nil, message=nil, &block)
          if receiver && !message
            raise(
              ArgumentError,
              "`change` requires either an object and message " \
              "(`change(obj, :msg)`) or a block (`change { }`). " \
              "You passed an object but no message."
            )
          end
          @message    = message ? "##{message}" : "result"
          @value_proc = block || lambda { receiver.__send__(message) }
        end

        def perform_change(event_proc)
          @actual_before = evaluate_value_proc
          event_proc.call
          @actual_after = evaluate_value_proc
        end

        def changed?
          @actual_before != @actual_after
        end

        def actual_delta
          @actual_after - @actual_before
        end

      private

        def evaluate_value_proc
          case val = @value_proc.call
          when IO # enumerable, but we don't want to dup it.
            val
          when Enumerable, String
            val.dup
          else
            val
          end
        end
      end
    end
  end
end
