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

require 'resolv'
require 'timeout'

require 'contrast/utils/object_share'
require 'contrast/utils/string_utils'
require 'contrast/utils/hash_digest'
require 'contrast/components/logger'
require 'contrast/components/scope'
require 'contrast/utils/request_utils'
require 'contrast/utils/duck_utils'

module Contrast
  module Agent
    # This class is the Contrast representation of the Rack::Request object. It
    # provides access to the original Rack::Request object as well as extracts
    # data in a format that the Agent expects, caching those transformations in
    # order to avoid repeatedly creating Strings & thrashing GC.
    class Request
      include Contrast::Utils::RequestUtils
      include Contrast::Components::Logger::InstanceMethods
      include Contrast::Components::Scope::InstanceMethods

      extend Forwardable

      EMPTY_PATH = '/'

      # @return [Rack::Request] The passed to the Agent RackRequest to be wrapped.
      attr_reader :rack_request
      # @return [Contrast::Agent::Reporting::ObservedRoute] the route, used for coverage, of this request
      attr_accessor :observed_route
      # @return [Contrast::Agent::Reporting::RouteDiscovery]
      attr_accessor :discovered_route

      # Delegate calls to the following methods to the attribute @rack_request
      def_delegators :@rack_request, :base_url, :cookies, :env, :ip, :media_type, :path, :port, :query_string,
                     :request_method, :scheme, :url, :user_agent

      # Initialize new Contrast Request
      #
      # @param rack_request [Rack::Request] The passed to the Agent RackRequest to be wrapped.
      def initialize rack_request
        @rack_request = rack_request
      end

      # The HTTP version of this request
      # @return [String]
      def version
        env = rack_request.env
        return '1.1' unless env

        version = env['HTTP_VERSION']
        return '1.1' unless version

        version.split('/')[-1]
      end

      # Returns a normalized form of the URI. In "normal" URIs
      # this will return an unchanged String, but in REST-y
      # URIs this will normalize the digit path tokens, e.g.:
      #
      # /accounts/5/view
      # ...becomes:
      # /accounts/{n}/view
      #
      # Should also handle the ;jsessionid.
      #
      # @return uri [String]
      def normalized_uri
        @_normalized_uri ||= begin
          path = rack_request.path_info || rack_request.path.to_s
          path = EMPTY_PATH if Contrast::Utils::DuckUtils.empty_duck?(path)

          # /foo/bar;jsessionid=123 => /foo/bar
          uri = path.split(Contrast::Utils::ObjectShare::SEMICOLON)[0]
          # /foo/bar?query_string=123 => /foo/bar
          uri = uri.split(Contrast::Utils::ObjectShare::QUESTION_MARK)[0]

          # Replace with tokens:
          # NUM_ => '/{n}/'
          # ID_ =>  '{ID}'
          #
          # replace UUIDs: /123e4567-e89b-42d3-a456-556642440000/ => /{ID}/
          uri.gsub!(UUID_PATTERN, ID_)
          # replace hash patterns: /6f1ed002ab5595859014ebf0951522d9/ => /{ID}/
          uri.gsub!(HASH_PATTERN, ID_)
          # replace windows SID: /S-1-5-21-1843332746-572796286-2118856591-1000/ => /{ID}/
          uri.gsub!(WIN_PATTERN, ID_)
          # replace interior number tokens: /123/ => /{n}/
          uri.gsub!(NUM_PATTERN, NUM_)
          # replace last number tokens: /123 => /{n}
          uri.gsub!(END_PATTERN, NUM_[0..-2])
          uri
        rescue StandardError => e
          logger.error('error normalizing uri', error: e, backtrace: e.backtrace)
          EMPTY_PATH
        end
      end

      # Returns the request file type
      #
      # @return type [Symbol<:XML, :JSON, :NORMAL>]
      def document_type
        @_document_type ||= if /xml/i.match?(media_type) || body&.start_with?('<?xml')
                              :XML
                            elsif /json/i.match?(media_type) || body&.match?(/\s*[{\[]/)
                              :JSON
                            else
                              :NORMAL
                            end
      end

      # Header keys upcased and any underscores replaced with dashes
      #
      # @return headers [Hash]
      def headers
        @_headers ||= with_contrast_scope do
          hash = {}
          env.each do |key, value|
            next unless key

            name = key.to_s
            next unless name.start_with?(Contrast::Utils::ObjectShare::HTTP_SCORE)

            hash[Contrast::Utils::StringUtils.normalized_key(name)] = value
          end
          hash
        end
      end

      # Try and read the body and return memorized object of the body.
      # If body contains file, do not parse it, otherwiseBody might be nil
      #
      # @return @_body [String, nil] The body of the Rack::Request
      def body
        # Memoize a flag indicating whether we've tried to read the body or not
        # (can't use body because it might be nil)
        @_body_read ||= begin
          body = rack_request.body
          if defined?(Rack::Multipart) &&
                defined?(Rack::Multipart::UploadedFile) &&
                body.is_a?(Rack::Multipart::UploadedFile)

            logger.trace('not parsing uploaded file body',
                         file_name: body.original_filename,
                         content_type: body.content_type)
            @_body = nil
          else
            logger.trace('parsing body from request', body_type: body.cs__class.cs__name)
            @_body = Contrast::Utils::StringUtils.force_utf8(read_body(body))
          end

          true
        end

        # Return memoized body (which might be nil)
        @_body
      end

      # flattened hash of request params
      #
      # @return parameters [Hash]
      def parameters
        @_parameters ||= with_contrast_scope { normalize_params(rack_request.params) }
      end

      # returns multipart filenames
      #
      # @return file_names [Hash]
      def file_names
        @_file_names ||= begin
          names = {}
          parsed_data = Rack::Multipart.parse_multipart(rack_request.env)
          traverse_parsed_multipart(parsed_data, names)
        rescue StandardError => _e
          logger.warn('Unable to parse multipart request!')
          {}
        end
      end

      # returns or generates the hash checksum for the request
      #
      # @return @_hash_id [String] Contrast::Utils::HashDigest generated string checksum
      def hash_id
        @_hash_id ||= Contrast::Utils::HashDigest.generate_request_hash(self)
      end

      # Utility method for checking if a request is for a static resource.
      # @return [Boolean] true, if the request is for a well-known static
      #  type as determined by the request suffix or the accept header.
      def static?
        return true if normalized_uri&.match?(STATIC_SUFFIXES)

        accepts = Array(headers['ACCEPT'])&.first&.to_s
        return false unless accepts
        return false if accepts.start_with?('*/*')

        accepts.start_with?(*MEDIA_TYPE_MARKERS)
      end
    end
  end
end