# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'contrast/framework/base_support'
require 'contrast/components/logger'

module Contrast
  module Framework
    module Grape
      # Used when Grape is present to define framework specific behaviour
      class Support
        extend Contrast::Framework::BaseSupport
        class << self
          include Contrast::Components::Logger::InstanceMethods
          def detection_class
            'Grape::API'
          end

          def version
            ::Grape::VERSION
          end

          def application_name
            app_class&.cs__name
          end

          def application_root
            app_instance&.root
          end

          def server_type
            'grape'
          end

          # Given an object, determine if it is a Grape controller.
          # Which could include cases of ::Grape::API subclass or actual class
          #
          # @param app [Object] suspected Grape app.
          # @return [Boolean]
          def grape_controller? app
            # Grape is loaded?
            return false unless grape_defined?

            # App is a subclass of or actually is ::Grape::API.
            return false unless app.cs__respond_to?(:<=) && app <= ::Grape::API

            true
          end

          # Find all classes that subclass ::Grape::API, Gather their routes
          #
          # @return [Array<Contrast::Api::Dtm::RouteCoverage>, Array]- founded routes as Dtms
          def collect_routes
            return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless grape_defined?

            # Each Grape controller has endpoints and each endpoints has routes
            # and that's why we need to go through each one and create separate RouteCoverage object
            routes = []
            grape_controllers.each do |c|
              c&.endpoints&.each do |endpoint|
                endpoint&.routes&.map do |r|
                  pattern = r.pattern.pattern
                  temp = Contrast::Api::Dtm::RouteCoverage.from_grape_controller(c, r.request_method, pattern, r.path)
                  routes << temp
                end
              end
            end
            routes
          end

          # Given the current request return a RouteCoverage dtm.
          #
          # @param request [Contrast::Agent::Request] a contrast tracked request.
          # @param controller [::Grape::API] optionally use this controller instead of global ::Grape::API.
          # @return [Contrast::Api::Dtm::RouteCoverage, nil] a Dtm describing the route
          # matched to the request if a match was found.
          def current_route request, controller = ::Grape::API, full_route = nil
            return unless grape_controller?(controller)

            method = request.env[::Rack::REQUEST_METHOD] # GET, PUT, POST, etc...

            # Find final controller - actually we gotta match the route to the scanned application
            # Initially Grape compiles all routes on startup, so we can use the url from the request
            # and create the observed route
            # Class < Grape::API, Grape::Router::Route
            final_controller, route_pattern = _route_recurse(method, _cleaned_route(request), grape_controllers)
            return unless final_controller

            full_route ||= request.env[::Rack::PATH_INFO]

            Contrast::Api::Dtm::RouteCoverage.from_grape_controller(final_controller, method, route_pattern, full_route)
          end

          # Given the current request - return a RouteCoverage object

          # @param request [Contrast::Agent::Request] a contrast tracked request.
          # @param controller [::Grape::API] optionally use this controller instead of global ::Grape::API.
          # @return [Contrast::Agent::Reporting::RouteCoverage, nil] a Dtm describing the route
          # matched to the request if a match was found.
          def current_route_coverage request, controller = ::Grape::API, full_route = nil
            return unless grape_controller?(controller)

            method = request.env[::Rack::REQUEST_METHOD] # GET, PUT, POST, etc...

            # Find final controller - actually we gotta match the route to the scanned application
            # Initially Grape compiles all routes on startup, so we can use the url from the request
            # and create the observed route
            # Class < Grape::API, Grape::Router::Route
            final_controller, route_pattern = _route_recurse(method, _cleaned_route(request), grape_controllers)
            return unless final_controller

            full_route ||= request.env[::Rack::PATH_INFO]

            new_route_coverage = Contrast::Agent::Reporting::RouteCoverage.new
            new_route_coverage.attach_rack_based_data final_controller, method, route_pattern, full_route
          end

          # Search object space for grape controllers--any class that subclasses ::Grape::API.
          #
          # @return [Array<::Grape::API>] grape controllers
          def grape_controllers
            ObjectSpace.each_object(Class).select { |klass| klass < ::Grape::API }
          end

          # Grape Request inherits the same as the Sinatra, so we can easily call it as it's called in Sinatra
          def retrieve_request env
            ::Grape::Request.new(env)
          end

          private

          # Determine if, at the time of our Framework Support determination, Grape has been defined.
          #
          # @return [Boolean]
          def grape_defined?
            @_grape_defined = !!(defined?(::Grape) && defined?(::Grape::API)) if @_grape_defined.nil?
            @_grape_defined
          end

          # @param method [::Rack::REQUEST_METHOD] GET, POST, PUT, etc...
          # @param route [String] the relative route passed from Rack.
          # @param controllers [Array<::Grape::API>] All Grape controllers found
          # @return [Array[::Grape::API]], nil] Either the controller that
          # will handle the route along with the route pattern or nil if no match.
          def _route_recurse method, route, controllers = grape_controllers
            # return if there aren't any controllers
            return unless controllers&.any?

            # Here we can go through the all detected controllers
            # and find the one that's routes include the current one
            # Grape controller actually has endpoints and each endpoint
            # has routes and that's why we need to do it that way
            controller = controllers.pop
            return _route_recurse method, route, controllers unless controller

            contr_routes = controller.endpoints&.map(&:routes)&.flatten || []
            route_pattern = contr_routes&.find do |r|
              r.pattern.to_regexp.match(route) # ::Mustermann::Grape match
            end
            return controller, route_pattern unless route_pattern.nil?

            _route_recurse method, route, controllers
          end

          # Get route and do some cleaning
          #
          # @param request [Contrast::Agent::Request] a contrast tracked request.
          # @return [String] the extracted and cleaned relative route.
          def _cleaned_route request
            route = request.env[::Rack::PATH_INFO]
            return '/' if route.empty?

            route.end_with?('/') ? route[0..-2] : route
          end

          def app_class
            return unless grape_defined?

            app_instance.cs__class
          end

          # Search the object space for the controller handling this request which will be
          # the class inheriting from ::Grape::API with @app=nil
          #
          # @return [::Grape::API] the current controller as routed by Rack.
          def app_instance
            return unless grape_defined?

            @_app_instance ||= begin
              grape_layers = ObjectSpace.each_object(::Grape::API).to_a
              grape_layers.find { |layer| layer.app.nil? }
            end
          end
        end
      end
    end
  end
end