require 'mspec/expectations/expectations'
require 'mspec/helpers/singleton_class'

class Object
  alias_method :__mspec_object_id__, :object_id
end

module Mock
  def self.reset
    @mocks = @stubs = @objects = nil
  end

  def self.objects
    @objects ||= {}
  end

  def self.mocks
    @mocks ||= Hash.new { |h,k| h[k] = [] }
  end

  def self.stubs
    @stubs ||= Hash.new { |h,k| h[k] = [] }
  end

  def self.replaced_name(obj, sym)
    :"__mspec_#{obj.__mspec_object_id__}_#{sym}__"
  end

  def self.replaced_key(obj, sym)
    [replaced_name(obj, sym), sym]
  end

  def self.has_key?(keys, sym)
    !!keys.find { |k| k.first == sym }
  end

  def self.replaced?(sym)
    has_key?(mocks.keys, sym) or has_key?(stubs.keys, sym)
  end

  def self.clear_replaced(key)
    mocks.delete key
    stubs.delete key
  end

  def self.mock_respond_to?(obj, sym, include_private = false)
    name = replaced_name(obj, :respond_to?)
    if replaced? name
      obj.__send__ name, sym, include_private
    else
      obj.respond_to? sym, include_private
    end
  end

  def self.install_method(obj, sym, type=nil)
    meta = obj.singleton_class

    key = replaced_key obj, sym
    sym = sym.to_sym

    if (sym == :respond_to? or mock_respond_to?(obj, sym, true)) and !replaced?(key.first)
      meta.__send__ :alias_method, key.first, sym
    end

    meta.class_eval <<-END
      def #{sym}(*args, &block)
        Mock.verify_call self, :#{sym}, *args, &block
      end
    END

    proxy = MockProxy.new type

    if proxy.mock?
      MSpec.expectation
      MSpec.actions :expectation, MSpec.current.state
    end

    if proxy.stub?
      stubs[key].unshift proxy
    else
      mocks[key] << proxy
    end
    objects[key] = obj

    proxy
  end

  def self.name_or_inspect(obj)
    obj.instance_variable_get(:@name) || obj.inspect
  end

  def self.verify_count
    mocks.each do |key, proxies|
      obj = objects[key]
      proxies.each do |proxy|
        qualifier, count = proxy.count
        pass = case qualifier
        when :at_least
          proxy.calls >= count
        when :at_most
          proxy.calls <= count
        when :exactly
          proxy.calls == count
        when :any_number_of_times
          true
        else
          false
        end
        unless pass
          SpecExpectation.fail_with(
            "Mock '#{name_or_inspect obj}' expected to receive '#{key.last}' " \
            "#{qualifier.to_s.sub('_', ' ')} #{count} times",
            "but received it #{proxy.calls} times")
        end
      end
    end
  end

  def self.verify_call(obj, sym, *args, &block)
    compare = *args
    behaves_like_ruby_1_9 = *[]
    if (behaves_like_ruby_1_9)
      compare = compare.first if compare.length <= 1
    end

    key = replaced_key obj, sym
    [mocks, stubs].each do |proxies|
      proxies[key].each do |proxy|
        pass = case proxy.arguments
        when :any_args
          true
        when :no_args
          compare.nil?
        else
          proxy.arguments == compare
        end

        if proxy.yielding?
          if block
            proxy.yielding.each do |args_to_yield|
              if block.arity == -1 || block.arity == args_to_yield.size
                block.call(*args_to_yield)
              else
                SpecExpectation.fail_with(
                  "Mock '#{name_or_inspect obj}' asked to yield " \
                  "|#{proxy.yielding.join(', ')}| on #{sym}\n",
                  "but a block with arity #{block.arity} was passed")
              end
            end
          else
            SpecExpectation.fail_with(
              "Mock '#{name_or_inspect obj}' asked to yield " \
              "|[#{proxy.yielding.join('], [')}]| on #{sym}\n",
              "but no block was passed")
          end
        end

        if pass
          proxy.called

          if proxy.raising?
            raise proxy.raising
          else
            return proxy.returning
          end
        end
      end
    end

    if sym.to_sym == :respond_to?
      mock_respond_to? obj, compare
    else
      SpecExpectation.fail_with("Mock '#{name_or_inspect obj}': method #{sym}\n",
                            "called with unexpected arguments (#{Array(compare).join(' ')})")
    end
  end

  def self.cleanup
    objects.each do |key, obj|
      if obj.kind_of? MockIntObject
        clear_replaced key
        next
      end

      replaced = key.first
      sym = key.last
      meta = obj.singleton_class

      if mock_respond_to? obj, replaced, true
        meta.__send__ :alias_method, sym, replaced
        meta.__send__ :remove_method, replaced
      else
        meta.__send__ :remove_method, sym
      end

      clear_replaced key
    end
  ensure
    reset
  end
end
