module RSpec
  module Matchers
    module BuiltIn
      class YieldProbe
        def self.probe(block)
          probe = new
          assert_valid_expect_block!(block)
          block.call(probe)
          probe.assert_used!
          probe
        end

        attr_accessor :num_yields, :yielded_args

        def initialize
          @used = false
          self.num_yields, self.yielded_args = 0, []
        end

        def to_proc
          @used = true

          probe = self
          Proc.new do |*args|
            probe.num_yields += 1
            probe.yielded_args << args
          end
        end

        def single_yield_args
          yielded_args.first
        end

        def yielded_once?(matcher_name)
          case num_yields
          when 1 then true
          when 0 then false
          else
            raise "The #{matcher_name} matcher is not designed to be used with a " +
                  "method that yields multiple times. Use the yield_successive_args " +
                  "matcher for that case."
          end
        end

        def successive_yield_args
          yielded_args.map do |arg_array|
            arg_array.size == 1 ? arg_array.first : arg_array
          end
        end

        def assert_used!
          return if @used
          raise "You must pass the argument yielded to your expect block on " +
                "to the method-under-test as a block. It acts as a probe that " +
                "allows the matcher to detect whether or not the method-under-test " +
                "yields, and, if so, how many times, and what the yielded arguments " +
                "are."
        end

        def self.assert_valid_expect_block!(block)
          return if block.arity == 1
          raise "Your expect block must accept an argument to be used with this " +
                "matcher. Pass the argument as a block on to the method you are testing."
        end
      end

      class YieldControl < BaseMatcher
        def initialize
          @expectation_type = nil
          @expected_yields_count = nil
        end

        def matches?(block)
          probe = YieldProbe.probe(block)

          if @expectation_type
            probe.num_yields.send(@expectation_type, @expected_yields_count)
          else
            probe.yielded_once?(:yield_control)
          end
        end

        def once
          exactly(1)
          self
        end

        def twice
          exactly(2)
          self
        end

        def exactly(number)
          set_expected_yields_count(:==, number)
          self
        end

        def at_most(number)
          set_expected_yields_count(:<=, number)
          self
        end

        def at_least(number)
          set_expected_yields_count(:>=, number)
          self
        end

        def times
          self
        end

        def failure_message_for_should
          'expected given block to yield control'.tap do |failure_message|
            failure_message << relativity_failure_message
          end
        end

        def failure_message_for_should_not
          'expected given block not to yield control'.tap do |failure_message|
            failure_message << relativity_failure_message
          end
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def set_expected_yields_count(relativity, n)
          @expectation_type = relativity
          @expected_yields_count = case n
                                   when Numeric then n
                                   when :once then 1
                                   when :twice then 2
                                   end
        end

        def relativity_failure_message
          return '' unless @expected_yields_count
          " #{human_readable_expecation_type}#{human_readable_count}"
        end

        def human_readable_expecation_type
          case @expectation_type
          when :<= then 'at most '
          when :>= then 'at least '
          else ''
          end
        end

        def human_readable_count
          case @expected_yields_count
          when 1 then "once"
          when 2 then "twice"
          else "#{@expected_yields_count} times"
          end
        end
      end

      class YieldWithNoArgs < BaseMatcher

        def matches?(block)
          @probe = YieldProbe.probe(block)
          @probe.yielded_once?(:yield_with_no_args) && @probe.single_yield_args.empty?
        end

        def failure_message_for_should
          "expected given block to yield with no arguments, but #{failure_reason}"
        end

        def failure_message_for_should_not
          "expected given block not to yield with no arguments, but did"
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def failure_reason
          if @probe.num_yields.zero?
            "did not yield"
          else
            "yielded with arguments: #{@probe.single_yield_args.inspect}"
          end
        end
      end

      class YieldWithArgs
        include MatchAliases

        def initialize(*args)
          @expected = args
        end

        def matches?(block)
          @probe = YieldProbe.probe(block)
          @actual = @probe.single_yield_args
          @probe.yielded_once?(:yield_with_args) && args_match?
        end

        def failure_message_for_should
          "expected given block to yield with arguments, but #{positive_failure_reason}"
        end

        def failure_message_for_should_not
          "expected given block not to yield with arguments, but #{negative_failure_reason}"
        end

        def description
          desc = "yield with args"
          desc << "(" + @expected.map { |e| e.inspect }.join(", ") + ")" unless @expected.empty?
          desc
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def positive_failure_reason
          if @probe.num_yields.zero?
            "did not yield"
          else
            @positive_args_failure
          end
        end

        def negative_failure_reason
          if all_args_match?
            "yielded with expected arguments" +
              "\nexpected not: #{@expected.inspect}" +
              "\n         got: #{@actual.inspect} (compared using === and ==)"
          else
            "did"
          end
        end

        def args_match?
          if @expected.empty? # expect {...}.to yield_with_args
            @positive_args_failure = "yielded with no arguments" if @actual.empty?
            return !@actual.empty?
          end

          unless match = all_args_match?
            @positive_args_failure = "yielded with unexpected arguments" +
              "\nexpected: #{@expected.inspect}" +
              "\n     got: #{@actual.inspect} (compared using === and ==)"
          end

          match
        end

        def all_args_match?
          return false if @expected.size != @actual.size

          @expected.zip(@actual).all? do |expected, actual|
            expected === actual || actual == expected
          end
        end
      end

      class YieldSuccessiveArgs
        include MatchAliases

        def initialize(*args)
          @expected = args
        end

        def matches?(block)
          @probe = YieldProbe.probe(block)
          @actual = @probe.successive_yield_args
          args_match?
        end

        def failure_message_for_should
          "expected given block to yield successively with arguments, but yielded with unexpected arguments" +
            "\nexpected: #{@expected.inspect}" +
            "\n     got: #{@actual.inspect} (compared using === and ==)"
        end

        def failure_message_for_should_not
          "expected given block not to yield successively with arguments, but yielded with expected arguments" +
              "\nexpected not: #{@expected.inspect}" +
              "\n         got: #{@actual.inspect} (compared using === and ==)"
        end

        def description
          desc = "yield successive args"
          desc << "(" + @expected.map { |e| e.inspect }.join(", ") + ")"
          desc
        end

        # @private
        def supports_block_expectations?
          true
        end

      private

        def args_match?
          return false if @expected.size != @actual.size

          @expected.zip(@actual).all? do |expected, actual|
            expected === actual || actual == expected
          end
        end
      end
    end
  end
end

