require 'kookaburra/mental_model'

class Kookaburra
  class MentalModel
    # This is a custom matcher that matches the RSpec matcher API.
    #
    # @see Kookaburra::TestHelpers#match_mental_model_of
    # @see Kookaburra::TestHelpers#assert_mental_model_of
    class Matcher
      def initialize(mental_model, collection_key)
        @collection_key = collection_key

        mental_model.send(collection_key).tap do |collection|
          @expected   = collection
          @unexpected = collection.deleted
        end
      end

      # Specifies that result should be limited to the given keys from the
      # mental model.
      #
      # Useful if you are looking at a filtered result. That is, your mental
      # model contains elements { A, B, C }, but you only expect to see element
      # A.
      #
      # @param [Array] collection_keys The keys used in your mental model to
      #   reference the data
      # @return [self]
      def only(*collection_keys)
        keepers = @expected.slice(*collection_keys)
        tossers = @expected.except(*collection_keys)

        @expected = keepers
        @unexpected.merge! tossers

        self
      end

      # Reads better than {#only} with no args
      #
      # @return [self]
      def expecting_nothing
        only
      end

      # The result contains everything that was expected to be found and nothing
      # that was unexpected.
      #
      # (Part of the RSpec protocol for custom matchers.)
      #
      # @param [Array] actual This is the data observed that you are attempting
      #   to match against the mental model.
      # @return Boolean
      def matches?(actual)
        @actual = actual
        expected_items_not_found.empty? && unexpected_items_found.empty?
      end

      # Message to be printed when observed reality does not conform to
      # mental model.
      #
      # (Part of the RSpec protocol for custom matchers.)
      #
      # @return String
      def failure_message_for_should
        message = "expected #{@collection_key} to match the user's mental model, but:\n"
        if expected_items_not_found.present?
          message += "expected to be present:         #{pp_array(expected_items)}\n"
          message += "the missing elements were:      #{pp_array(expected_items_not_found)}\n"
        end
        if unexpected_items_found.present?
          message += "expected to not be present:     #{pp_array(unexpected_items)}\n"
          message += "the unexpected extra elements:  #{pp_array(unexpected_items_found)}\n"
        end
        message
      end

      # Message to be printed when observed reality does conform to mental
      # model, but you did not expect it to.  (To be honest, we can't think of
      # why you would want this, but it is included for the sake of RSpec
      # compatibility.)
      #
      # (Part of the RSpec protocol for custom matchers.)
      #
      # @return String
      def failure_message_for_should_not
        "expected #{@collection_key} not to match the user's mental model"
      end

      # (Part of the RSpec protocol for custom matchers.)
      #
      # @return String
      def description
        "match the user's mental model of #{@collection_key}"
      end

      private

      def expected_items;   @expected.values;   end
      def unexpected_items; @unexpected.values; end

      def expected_items_not_found
        difference_between_arrays(expected_items, @actual)
      end

      def unexpected_items_found
        unexpected_items_not_found = difference_between_arrays(unexpected_items, @actual)
        difference_between_arrays(unexpected_items, unexpected_items_not_found)
      end

      # (Swiped from RSpec's array matcher)
      # Returns the difference of arrays, accounting for duplicates.
      # e.g., difference_between_arrays([1, 2, 3, 3], [1, 2, 3]) # => [3]
      def difference_between_arrays(array_1, array_2)
        difference = array_1.dup
        array_2.each do |element|
          if index = difference.index(element)
            difference.delete_at(index)
          end
        end
        difference
      end

      def pp_array(array)
        array = array.sort if array.all? { |e| e.respond_to?(:<=>) }
        array.inspect
      end
    end
  end
end