module RSpec
  module Mocks
    # @private
    class MethodDouble
      # @private TODO: drop in favor of FrozenError in ruby 2.5+
      FROZEN_ERROR_MSG = /can't modify frozen/

      # @private
      attr_reader :method_name, :object, :expectations, :stubs, :method_stasher

      # @private
      def initialize(object, method_name, proxy)
        @method_name = method_name
        @object = object
        @proxy = proxy

        @original_visibility = nil
        @method_stasher = InstanceMethodStasher.new(object, method_name)
        @method_is_proxied = false
        @expectations = []
        @stubs = []
      end

      def original_implementation_callable
        # If original method is not present, uses the `method_missing`
        # handler of the object. This accounts for cases where the user has not
        # correctly defined `respond_to?`, and also 1.8 which does not provide
        # method handles for missing methods even if `respond_to?` is correct.
        @original_implementation_callable ||= original_method || method_missing_block
      end

      alias_method :save_original_implementation_callable!, :original_implementation_callable

      def original_method
        @original_method ||=
          @method_stasher.original_method ||
          @proxy.original_method_handle_for(method_name)
      end

      # @private
      def method_missing_block
        block = Proc.new do |*args, &b|
          @object.__send__(:method_missing, @method_name, *args, &b)
        end
        block.ruby2_keywords if block.respond_to?(:ruby2_keywords)

        block
      end

      # @private
      def visibility
        @proxy.visibility_for(@method_name)
      end

      # @private
      def object_singleton_class
        class << @object; self; end
      end

      # @private
      def configure_method
        @original_visibility = visibility
        @method_stasher.stash unless @method_is_proxied
        define_proxy_method
      end

      # @private
      def define_proxy_method
        return if @method_is_proxied

        save_original_implementation_callable!
        definition_target.class_exec(self, method_name, @original_visibility || visibility) do |method_double, method_name, visibility|
          define_method(method_name) do |*args, &block|
            method_double.proxy_method_invoked(self, *args, &block)
          end
          # This can't be `if respond_to?(:ruby2_keywords, true)`,
          # see https://github.com/rspec/rspec-mocks/pull/1385#issuecomment-755340298
          ruby2_keywords(method_name) if Module.private_method_defined?(:ruby2_keywords)
          __send__(visibility, method_name)
        end

        @method_is_proxied = true
      rescue RuntimeError, TypeError => e
        # TODO: drop in favor of FrozenError in ruby 2.5+
        #  RuntimeError (and FrozenError) for ruby 2.x
        #  TypeError for ruby 1.x
        if (defined?(FrozenError) && e.is_a?(FrozenError)) || FROZEN_ERROR_MSG === e.message
          raise ArgumentError, "Cannot proxy frozen objects, rspec-mocks relies on proxies for method stubbing and expectations."
        end
        raise
      end

      # The implementation of the proxied method. Subclasses may override this
      # method to perform additional operations.
      #
      # @private
      def proxy_method_invoked(_obj, *args, &block)
        @proxy.message_received method_name, *args, &block
      end
      ruby2_keywords :proxy_method_invoked if respond_to?(:ruby2_keywords, true)

      # @private
      def restore_original_method
        return unless @method_is_proxied

        remove_method_from_definition_target
        @method_stasher.restore if @method_stasher.method_is_stashed?
        restore_original_visibility

        @method_is_proxied = false
      rescue RuntimeError, TypeError => e
        # TODO: drop in favor of FrozenError in ruby 2.5+
        #  RuntimeError (and FrozenError) for ruby 2.x
        #  TypeError for ruby 1.x
        if (defined?(FrozenError) && e.is_a?(FrozenError)) || FROZEN_ERROR_MSG === e.message
          return show_frozen_warning
        end
        raise
      end

      # @private
      def show_frozen_warning
        RSpec.warn_with(
          "WARNING: rspec-mocks was unable to restore the original `#{@method_name}` " \
          "method on #{@object.inspect} because it has been frozen.  If you reuse this " \
          "object, `#{@method_name}` will continue to respond with its stub implementation.",
          :call_site                      => nil,
          :use_spec_location_as_call_site => true
        )
      end

      # @private
      def restore_original_visibility
        return unless @original_visibility &&
          MethodReference.method_defined_at_any_visibility?(object_singleton_class, @method_name)

        object_singleton_class.__send__(@original_visibility, method_name)
      end

      # @private
      def verify
        expectations.each { |e| e.verify_messages_received }
      end

      # @private
      def reset
        restore_original_method
        clear
      end

      # @private
      def clear
        expectations.clear
        stubs.clear
      end

      # The type of message expectation to create has been extracted to its own
      # method so that subclasses can override it.
      #
      # @private
      def message_expectation_class
        MessageExpectation
      end

      # @private
      def add_expectation(error_generator, expectation_ordering, expected_from, opts, &implementation)
        configure_method
        expectation = message_expectation_class.new(error_generator, expectation_ordering,
                                                    expected_from, self, :expectation, opts, &implementation)
        expectations << expectation
        expectation
      end

      # @private
      def build_expectation(error_generator, expectation_ordering)
        expected_from = IGNORED_BACKTRACE_LINE
        message_expectation_class.new(error_generator, expectation_ordering, expected_from, self)
      end

      # @private
      def add_stub(error_generator, expectation_ordering, expected_from, opts={}, &implementation)
        configure_method
        stub = message_expectation_class.new(error_generator, expectation_ordering, expected_from,
                                             self, :stub, opts, &implementation)
        stubs.unshift stub
        stub
      end

      # A simple stub can only return a concrete value for a message, and
      # cannot match on arguments. It is used as an optimization over
      # `add_stub` / `add_expectation` where it is known in advance that this
      # is all that will be required of a stub, such as when passing attributes
      # to the `double` example method. They do not stash or restore existing method
      # definitions.
      #
      # @private
      def add_simple_stub(method_name, response)
        setup_simple_method_double method_name, response, stubs
      end

      # @private
      def add_simple_expectation(method_name, response, error_generator, backtrace_line)
        setup_simple_method_double method_name, response, expectations, error_generator, backtrace_line
      end

      # @private
      def setup_simple_method_double(method_name, response, collection, error_generator=nil, backtrace_line=nil)
        define_proxy_method

        me = SimpleMessageExpectation.new(method_name, response, error_generator, backtrace_line)
        collection.unshift me
        me
      end

      # @private
      def add_default_stub(*args, &implementation)
        return if stubs.any?
        add_stub(*args, &implementation)
      end

      # @private
      def remove_stub
        raise_method_not_stubbed_error if stubs.empty?
        remove_stub_if_present
      end

      # @private
      def remove_stub_if_present
        expectations.empty? ? reset : stubs.clear
      end

      # @private
      def raise_method_not_stubbed_error
        RSpec::Mocks.error_generator.raise_method_not_stubbed_error(method_name)
      end

      # In Ruby 2.0.0 and above prepend will alter the method lookup chain.
      # We use an object's singleton class to define method doubles upon,
      # however if the object has had its singleton class (as opposed to
      # its actual class) prepended too then the the method lookup chain
      # will look in the prepended module first, **before** the singleton
      # class.
      #
      # This code works around that by providing a mock definition target
      # that is either the singleton class, or if necessary, a prepended module
      # of our own.
      #
      if Support::RubyFeatures.module_prepends_supported?

        private

        # We subclass `Module` in order to be able to easily detect our prepended module.
        RSpecPrependedModule = Class.new(Module)

        def definition_target
          @definition_target ||= usable_rspec_prepended_module || object_singleton_class
        end

        def usable_rspec_prepended_module
          @proxy.prepended_modules_of_singleton_class.each do |mod|
            # If we have one of our modules prepended before one of the user's
            # modules that defines the method, use that, since our module's
            # definition will take precedence.
            return mod if RSpecPrependedModule === mod

            # If we hit a user module with the method defined first,
            # we must create a new prepend module, even if one exists later,
            # because ours will only take precedence if it comes first.
            return new_rspec_prepended_module if mod.method_defined?(method_name)
          end

          nil
        end

        def new_rspec_prepended_module
          RSpecPrependedModule.new.tap do |mod|
            object_singleton_class.__send__ :prepend, mod
          end
        end

      else

        private

        def definition_target
          object_singleton_class
        end

      end

    private

      def remove_method_from_definition_target
        definition_target.__send__(:remove_method, @method_name)
      rescue NameError
        # This can happen when the method has been monkeyed with by
        # something outside RSpec. This happens, for example, when
        # `file.write` has been stubbed, and then `file.reopen(other_io)`
        # is later called, as `File#reopen` appears to redefine `write`.
        #
        # Note: we could avoid rescuing this by checking
        # `definition_target.instance_method(@method_name).owner == definition_target`,
        # saving us from the cost of the expensive exception, but this error is
        # extremely rare (it was discovered on 2014-12-30, only happens on
        # RUBY_VERSION < 2.0 and our spec suite only hits this condition once),
        # so we'd rather avoid the cost of that check for every method double,
        # and risk the rare situation where this exception will get raised.
        RSpec.warn_with(
          "WARNING: RSpec could not fully restore #{@object.inspect}." \
          "#{@method_name}, possibly because the method has been redefined " \
          "by something outside of RSpec."
        )
      end
    end
  end
end
