module RSpec
  module Rails
    module Matchers
      # Matchers to help with specs for routing code.
      module RoutingMatchers
        extend RSpec::Matchers::DSL

        # @private
        class RouteToMatcher < RSpec::Rails::Matchers::BaseMatcher
          def initialize(scope, *expected)
            @scope = scope
            @expected = expected[1] || {}
            if Hash === expected[0]
              @expected.merge!(expected[0])
            else
              controller, action = expected[0].split('#')
              @expected.merge!(controller: controller, action: action)
            end
          end

          def matches?(verb_to_path_map)
            @actual = verb_to_path_map
            # assert_recognizes does not consider ActionController::RoutingError an
            # assertion failure, so we have to capture that and Assertion here.
            match_unless_raises ActiveSupport::TestCase::Assertion, ActionController::RoutingError do
              path, query = *verb_to_path_map.values.first.split('?')
              @scope.assert_recognizes(
                @expected,
                { method: verb_to_path_map.keys.first, path: path },
                Rack::Utils.parse_nested_query(query)
              )
            end
          end

          def failure_message
            rescued_exception.message
          end

          def failure_message_when_negated
            "expected #{@actual.inspect} not to route to #{@expected.inspect}"
          end

          def description
            "route #{@actual.inspect} to #{@expected.inspect}"
          end
        end

        # Delegates to `assert_recognizes`. Supports short-hand controller/action
        # declarations (e.g. `"controller#action"`).
        #
        # @example
        #
        #     expect(get: "/things/special").to route_to(
        #       controller: "things",
        #       action:     "special"
        #     )
        #
        #     expect(get: "/things/special").to route_to("things#special")
        #
        # @see https://api.rubyonrails.org/classes/ActionDispatch/Assertions/RoutingAssertions.html#method-i-assert_recognizes
        def route_to(*expected)
          RouteToMatcher.new(self, *expected)
        end

        # @private
        class BeRoutableMatcher < RSpec::Rails::Matchers::BaseMatcher
          def initialize(scope)
            @scope = scope
          end

          def matches?(path)
            @actual = path
            match_unless_raises ActionController::RoutingError do
              @routing_options = @scope.routes.recognize_path(
                path.values.first, method: path.keys.first
              )
            end
          end

          def failure_message
            "expected #{@actual.inspect} to be routable"
          end

          def failure_message_when_negated
            "expected #{@actual.inspect} not to be routable, but it routes to #{@routing_options.inspect}"
          end

          def description
            "be routable"
          end
        end

        # Passes if the route expression is recognized by the Rails router based on
        # the declarations in `config/routes.rb`. Delegates to
        # `RouteSet#recognize_path`.
        #
        # @example You can use route helpers provided by rspec-rails.
        #     expect(get:  "/a/path").to be_routable
        #     expect(post: "/another/path").to be_routable
        #     expect(put:  "/yet/another/path").to be_routable
        def be_routable
          BeRoutableMatcher.new(self)
        end

        # Helpers for matching different route types.
        module RouteHelpers
          # @!method get
          # @!method post
          # @!method put
          # @!method patch
          # @!method delete
          # @!method options
          # @!method head
          #
          # Shorthand method for matching this type of route.
          %w[get post put patch delete options head].each do |method|
            define_method method do |path|
              { method.to_sym => path }
            end
          end
        end
      end
    end
  end
end
