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

require 'contrast/framework/base_support'

module Contrast
  module Framework
    module Sinatra
      # Used when Sinatra is present to define framework specific behavior
      class Support
        extend Contrast::Framework::BaseSupport
        class << self
          def detection_class
            'Sinatra'
          end

          def version
            ::Sinatra::VERSION
          end

          def application_name
            app_class&.cs__name
          end

          def application_root
            app_instance&.root
          end

          def server_type
            'sinatra'
          end

          # Given an object, determine if it is a Sinatra controller with routes.
          #
          # @param app [Object] suspected Sinatra app.
          # @return [Boolean]
          def sinatra_controller? app
            # Sinatra is loaded?
            return false unless defined?(::Sinatra) && defined?(::Sinatra::Base)
            # App is a subclass of or actually is ::Sinatra::Base.
            return false unless (app.cs__respond_to?(:<) && app < ::Sinatra::Base) || app == ::Sinatra::Base

            # App has routes.
            !app.routes.empty?
          end

          # Find all classes that subclass ::Sinatra::Base. Gather their routes.
          #
          # @return [Array<Contrast::Api::Dtm::RouteCoverage>] the routes found as Dtms.
          def collect_routes
            return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless defined?(::Sinatra) && defined?(::Sinatra::Base)

            routes = []
            sinatra_controllers.each do |controller|
              controller.routes.each_pair do |method, route_triplets|
                # Sinatra stores its routes as a triplet: [Mustermann::Sinatra, [], Proc]
                route_triplets.map(&:first).each do |route_pattern|
                  routes << Contrast::Api::Dtm::RouteCoverage.from_sinatra_route(controller, method, route_pattern)
                end
              end
            end
            routes
          end

          # Given the current request return a RouteCoverage dtm.
          #
          # @param request [Contrast::Agent::Request] a contrast tracked request.
          # @param controller [::Sinatra::Base] optionally use this controller instead of global ::Sinatra::Base.
          # @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 = ::Sinatra::Base, full_route = nil
            return unless sinatra_controller?(controller)

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

            # Find route match--checking superclasses if necessary.
            final_controller, route_pattern = _route_recurse(controller, method, _cleaned_route(request))
            return unless !final_controller.nil? && !route_pattern.nil?

            full_route ||= request.path_info

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

          # Search object space for sinatra controllers--any class that subclasses ::Sinatra::Base.
          #
          # @return [Array<::Sinatra::Base>] sinatra controlelrs
          def sinatra_controllers
            [::Sinatra::Base] + ObjectSpace.each_object(Class).select { |clazz| sinatra_controller?(clazz) }
          end

          def retrieve_request env
            ::Sinatra::Request.new(env)
          end

          private

          # Given a controller and a route to match against, find the route_pattern and class that will serve the
          # route. This is recursive as Sinatra's routing is recursive from subclass to super.
          #
          # @param controller [Sinatra::Base, #routes] a Sinatra application.
          # @param method [::Rack::REQUEST_METHOD] GET, POST, PUT, etc...
          # @param method [String] the relative route passed from Rack.
          # @return [Array[Sinatra::Base, Mustermann::Sinatra], nil] Either the controller that
          # will handle the route along with the route pattern or nil if no match.
          def _route_recurse controller, method, route
            return if controller.nil? || controller.cs__class == NilClass

            route_patterns = controller.routes.fetch(method, []).map(&:first)
            route_pattern = route_patterns&.find do |matcher|
              matcher.params(route) # ::Mustermann::Sinatra match.
            end

            return controller, route_pattern if route_pattern

            # Check routes defined in superclass if present.
            return _route_recurse(controller.superclass, method, route) if controller.superclass&.instance_variable_get(:@routes)
          end

          # Get route and do some cleanup matching that of Sinatra::Base#process_route.
          #
          # @param request [Contrast::Agent::Request] a contrast tracked request.
          # @return [String] the extracted and cleaned relative route.
          def _cleaned_route request
            settings = ::Sinatra::Base.settings
            route = request.env[::Rack::PATH_INFO]
            return '/' if route.empty? && !settings.empty_path_info?

            !settings.strict_paths? && route.end_with?('/') ? route[0..-2] : route
          end

          # Almost an alias to app_instance.
          #
          # @return [::Sinatra::Base] the current controller class as routed by Rack.
          def app_class
            return unless defined?(::Sinatra) && defined?(::Sinatra::Base)

            app_instance.cs__class
          end

          # Search the object space for the controller handling this request which will be
          # the class inheriting from ::Sinatra::Base with @app=nil since it is the final servicer
          # in the request/middleware chain.
          #
          # @return [::Sinatra::Base] the current controller as routed by Rack.
          def app_instance
            return unless defined?(::Sinatra) && defined?(::Sinatra::Base)

            @_app_instance ||= begin
              sinatra_layers = ObjectSpace.each_object(::Sinatra::Base).to_a
              sinatra_layers.find { |layer| layer.app.nil? }
            end
          end
        end
      end
    end
  end
end