# Copyright (c) 2023 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/framework/rails/patch/support'
require 'contrast/utils/string_utils'
require 'contrast/utils/duck_utils'

module Contrast
  module Framework
    module Rails
      # Used when Rails is present to define framework specific behavior
      class Support # rubocop:disable Metrics/ClassLength
        extend Contrast::Framework::BaseSupport
        extend Contrast::Framework::Rails::Patch::Support
        include Contrast::Components::Logger::InstanceMethods
        extend Contrast::Components::Logger::InstanceMethods

        class << self
          RAILS_MODULE_NAME_VERSION = Gem::Version.new('6.0.0')

          def detection_class
            'Rails'
          end

          def version
            ::Rails.version
          end

          def application_name
            app_class = ::Rails.application.cs__class
            # Rails version 6.0.0 deprecated Rails::Application#parent_name, in Rails 6.1.0 that method will be removed
            # entirely and instead we need to use parent_module_name
            return app_class.parent_module_name if Gem::Version.new(::Rails.version) >= RAILS_MODULE_NAME_VERSION

            app_class.parent_name
          end

          def application_root
            ::Rails.root
          end

          def server_type
            'rails'
          end

          # @return [Array<Contrast::Agent::Reporting::DiscoveredRoute>]
          def collect_routes
            find_all_routes(::Rails.application, [])
          end

          # Find the current route, based on the provided Request wrapper
          #
          # @param request[Contrast::Agent::Request]
          # @return [Contrast::Agent::Reporting::RouteCoverage, nil] the route coverage object or nil if no route
          def current_route_coverage request
            return unless ::Rails.cs__respond_to?(:application)

            # ActionDispatch::Journey::Path::Pattern::MatchData, Hash, ActionDispatch::Journey::Route, Array<String>
            match, _params, route, path = get_full_route(request.rack_request)
            unless route
              logger.debug("Unable to determine the current route of this request: #{ request.rack_request }")
              return
            end

            original_url = request.rack_request.path_info
            mounted_app = route&.app&.app
            # Route is either the final rails route, or a router that points to a Sinatra controller.
            if mounted_app && Contrast::Framework::Sinatra::Support.sinatra_controller?(mounted_app)
              return mounted_new_sinatra_route(request, match, path, route, original_url)
            end
            if mounted_app && Contrast::Framework::Grape::Support.grape_controller?(mounted_app)
              return mounted_new_grape_route(request, match, path, route, original_url)
            end

            new_route_coverage = Contrast::Agent::Reporting::RouteCoverage.new
            new_route_coverage&.attach_rails_data(route, original_url)
            new_route_coverage
          rescue StandardError => e
            logger.error('Unable to determine the current route of this request due to exception: ', e)
            nil
          end

          # Copy a request for modification.
          #
          # @param env [::ActionDispatch::Request] original env.
          # @return [::ActionDispatch::Request] a copy of original env with rails env merged.
          def retrieve_request env
            rails_env = ::Rails.application.env_config.merge(env)
            ::ActionDispatch::Request.new(rails_env || env)
          end

          AC_INSTANCE = 'action_controller.instance'
          def streaming? env
            return false unless defined?(::ActionController::Live)

            env[AC_INSTANCE].cs__class.included_modules.include?(::ActionController::Live)
          end

          private

          # Determine if route is a Rails engine route.
          #
          # @param route [Object] app or route that points to a ::Rails::Engine
          # @return [Boolean, nil] whether the router is an engine or not.
          def engine_route? route
            return false unless route&.app&.app
            return false unless route.app.is_a?(::ActionDispatch::Routing::Mapper::Constraints) ||
                route.app.is_a?(::ActionDispatch::Routing::RouteSet::Dispatcher)

            clazz = route.app.app.is_a?(Class) ? route.app.app : route.app.app.cs__class
            clazz < ::Rails::Engine
          end

          # Recursively get final route traversing engines as required. Because this can only be called once, we store
          # this match for the duration of our request context.
          #
          # @param request [::Rack::Request] the rack request as will be handed to rails controller.
          # @param top_router [::ActionDispatch::Journey::Router] the current router relative to the previous.
          # @param path [Array<String>] the chunks of path that have been seen.
          # @return [Array<Object>] the final set of rails route classes.
          #   ActionDispatch::Journey::Path::Pattern::MatchData, Hash, ActionDispatch::Journey::Route, Array<String>
          def get_full_route request, top_router = ::Rails.application.routes.router, path = []
            return if (route_matches = top_router.send(:find_routes, request)).empty?

            match, params, route = route_matches.first

            # If the current routing node points to a sub-app (::Rails::Engine), dive deeper.
            # Have sub-app route the remainder of the url.
            if engine_route?(route)
              new_req = retrieve_request(request.env)
              new_req.path_info = new_req.path_info.gsub(match.to_s, '')
              # solves the issue when requiring base path '/' without the slash
              new_req.path_info = '/' if Contrast::Utils::DuckUtils.empty_duck?(new_req.path_info)
              get_full_route(new_req, route.app.app.routes.router, path << match.to_s)
            else
              [match, params, route, path]
            end
          end

          # Rails engine routes need to be detected by inspecting Engine class route set
          #
          # @param app [Rails::Application]
          # @param route_list [Array<Contrast::Agent::Reporting::DiscoveredRoute>] the list of discovered routes to
          #   which to append and return
          # @return [Array<Contrast::Agent::Reporting::DiscoveredRoute>]
          def find_all_routes app, route_list
            return route_list unless app.cs__respond_to?(:routes) && app.routes.cs__respond_to?(:routes)

            app.routes.routes.each do |route|
              if route.cs__respond_to?(:app) && route.app.cs__class == ActionDispatch::Routing::RouteSet::Dispatcher
                route_list << Contrast::Agent::Reporting::DiscoveredRoute.from_action_dispatch_journey(route)
              elsif route.app.app.cs__respond_to?(:routes)
                route_list += find_all_routes(route.app.app, [])
              end
            end
            logger.debug("Routes Found: #{ route_list }")
            route_list
          end

          # @return [Contrast::Agent::Reporting::RouteCoverage, nil]
          def mounted_new_sinatra_route request, match, path, route, original_url
            new_req = unmounted_route(request, match, path)
            Contrast::Framework::Sinatra::Support.current_route_coverage(new_req, route.app.app, original_url)
          end

          # @return [Contrast::Agent::Reporting::RouteCoverage, nil]
          def mounted_new_grape_route request, match, path, route, original_url
            new_req = unmounted_route(request, match, path)
            Contrast::Framework::Grape::Support.current_route_coverage(new_req, route.app.app, original_url)
          end

          # Create a request copied from current request, but with the base path removed from path_info, as that's
          # the mount.
          #
          # @param request[Contrast::Agent::Request]
          # @param match []
          # @param path [String] the path of this request
          # @return [::ActionDispatch::Request]
          def unmounted_route request, match, path
            new_req = ::ActionDispatch::Request.new(request.env)
            new_req.path_info = new_req.path_info.gsub((path << match).join, '')
            new_req
          end
        end
      end
    end
  end
end