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

require 'contrast/agent/reporting/reporting_utilities/response_extractor'

module Contrast
  module Agent
    module Reporting
      # This module holds utilities required by the reporting service response handler
      module ResponseHandlerUtils
        include Contrast::Agent::Reporting::ResponseExtractor

        ANALYZE_WHEN = %w[200 204].cs__freeze
        ERROR_CODES = {
            message_not_sent: '400',
            access_forbidden: '401',
            access_forbidden_no_action: '403',
            application_do_not_exist: '404',
            unprocessable_entity: '422',
            too_many_requests: '429'
        }.cs__freeze
        APP_NON_EXISTENT_MSG = 'Application does not exist! Either it has not been created or has '\
                               'been deleted or archived. '\
                               'Disabling permanently.'
        SUSPEND_MSG = 'Reporter is temporarily suspended.'
        UNSUCCESSFULLY_RECEIVED_MSG = 'The Reporter was unable to send message.'
        FORBIDDEN_MSG = 'Access was forbidden for current Report because the request authentication '\
                        'information was not provided'
        FORBIDDEN_NO_ACTION_MSG = 'Report access was forbidden because the supplied credentials failed '\
                                  'to authenticate the Agent'
        UNPROCESSABLE_ENTITY_MSG = 'Reporter received Unprocessable Entity response. Disabling permanently.'
        RETRY_AFTER_MSG = "There are too many requests of this type being sent by this Agent. #{ SUSPEND_MSG }"

        private

        # check if response code is valid before analyze it
        #
        # @param response [Net::HTTP::Response, nil]
        # @return [Boolean]
        def analyze_response? response
          # Code descriptions:
          # 200:
          # Message successfully received and there are new settings
          # 204:
          # Message successfully received and it's up to Contrast Server to decide what is done with the data.
          # 304:
          # Message successfully received and there are no new settings. Use your current ones.
          #
          # ERRORS:
          # 400:
          # Message unsuccessfully received. The Contrast Server was unable to process the message properly.
          # 401:
          # Access was forbidden because the request authentication information was not provided.
          # headers:
          # www-Authenticate => Indicate that the API-Key and Authorization header are both required,
          #                     in the standard format per RFC 2616
          # 403:
          # Access was forbidden because the supplied credentials failed to authenticate the Agent.
          # 404:
          # The application does not exist - either it has not been created or has been deleted or archived.
          # If possible, the Agent should no longer analyze this application. For those Agents with multiple
          # applications in a single process, at a minimum, cease reporting about this application.
          # 422:
          # Unprocessable Entity - The application startup is rejected because some piece of data is incorrect.
          # The session_id could reference a non-existent session or the metadata (not session_metadata) could
          # fail a constraint check. TeamServer should indicate this is an error message which the Agent should
          # log. The Agent should no longer analyze this application.
          # {
          #   "error": "string"
          # }
          # 429:
          # There are too many requests of this type being sent by this Agent. Back off for the time listed in
          # the Retry-After header. In this case, it is on the Agent to determine if it is safe to hold onto the
          # data to attempt to send again or if it needs to be dropped. The Contrast Server can choose what to do
          # with message from improperly throttled Agents, including dropping them.
          # header:
          # Retry-After => how long, in seconds, to wait before attempting to send another request to this endpoint,
          #                in the standard format per RFC 2616
          # used for in observed routes message.
          return false unless response && (response_code = response&.code)
          return true if ANALYZE_WHEN.include?(response_code)

          handle_error(response) if ERROR_CODES.value?(response_code)
          # There was error, so analyze the Error and nothing more.
          false
        end

        # Analyze the headers of the response code. They have information about the
        # retry timeout and some response bodies contains error messages.
        #
        # @param response [String] the response code from Net::HTTPResponse, which is obnoxiousy a String, not an
        # Integer
        # @param message [String] Message to log.
        # @param mode [Symbol, nil]
        def handle_response_errors response, message, mode
          # Set the current mode status.
          @_mode.status = mode
          ready_after, error_message, auth_error = extract_response_info(response)
          # log, suspend, disable:
          if mode == @_mode.running
            log_debug_msg(message,
                          response: response.__id__,
                          request: Contrast::Agent::REQUEST_TRACKER.current&.request&.type,
                          error_message: error_message || 'none',
                          auth_error: auth_error || 'none')
          end
          suspend_reporting(message, ready_after, error_message) if mode == @_mode.resending
          return unless mode == @_mode.disabled

          stop_reporting(message, application: Contrast::APP_CONTEXT.app_name, error_message: error_message)
        rescue StandardError => e
          logger.debug('Could not handle Response error information', error: e)
        end

        # Extract what we've received.
        #
        # @param response [Net::HTTP::Response, nil]
        # @return [Array<String, Integer>] all collected error info.
        def extract_response_info response
          # Extract what we got from the response:
          ready_after = response['Ready-After'] if response.to_hash.keys.map(&:downcase).include?('ready-after')
          if response.to_hash.keys.map(&:downcase).include?('www-authenticate')
            auth_error = response['www-Authenticate']
          end
          error_message = response.message
          [ready_after.to_i, error_message, auth_error]
        end

        # Cease reporting about this application
        #
        # @param message [String] Message to log
        # @param info_hash [Hash] information about the context to log.
        def stop_reporting message, info_hash
          Contrast::Agent.reporter&.stop!
          log_debug_msg(message, info_hash)
          ::Contrast::AGENT.disable!
        end

        # Applies the settings from the TS response
        #
        # @param response [Contrast::Agent::Reporting::Response]
        def update_agent_settings response
          return unless response

          ::Contrast::SETTINGS.update_from_server_features(response) if response.server_features
          ::Contrast::SETTINGS.update_from_application_settings(response) if response.application_settings
        end

        # Process the given Reactions from the application settings based on what
        # TeamServer has indicated. Each Reaction will result in a log message
        # and, optionally, an action.
        #
        # @param response [Contrast::Agent::Reporting::Response]
        def update_reaction response
          return unless response.application_settings&.reactions&.any?

          response.application_settings.reactions.each do |reaction|
            # The enums are all uppercase, we need to downcase them before attempting to log.
            level = reaction.level.nil? ? :error : reaction.level.downcase
            logger.with_level(level, reaction.message) if reaction.message

            case reaction.operation
            when Contrast::Agent::Reporting::Settings::Reaction::OPERATIONS[1]
              # DISABLED
              Contrast::Agent::DisableReaction.run(reaction, level)
            when Contrast::Agent::Reporting::Settings::Reaction::OPERATIONS[0]
              # NOOP
            else
              logger.warn('ReactionProcessor received a reaction with an unknown operation',
                          operation: reaction.operation)
            end
          end
        end

        # This can't go in the Settings component because protect and assess depend on settings
        # I don't think it should go into contrast_service because that only handles connection specific data.
        #
        # @param response [Contrast::Agent::Reporting::Response]
        def update_ruleset response
          logger.info('Updating features from TeamServer')
          return unless response&.server_features || response&.application_settings
          return unless ::Contrast::AGENT.enabled?

          logger.trace_with_time('Rebuilding rule modes from TeamServer') do
            ::Contrast::SETTINGS.build_protect_rules if ::Contrast::PROTECT.enabled?
            ::Contrast::AGENT.reset_ruleset
            logger.info('Current rule settings:')
            ::Contrast::PROTECT.rules.each { |k, v| logger.info('Protect Rule mode set', rule: k, mode: v.mode) }
            logger.info('Disabled Assess Rules', rules: ::Contrast::ASSESS.disabled_rules)
          end
        end

        # Converts response from Net to Reporting Response object
        #
        # @param response [Net::HTTP::Response, nil]
        # @return response [Contrast::Agent::Reporting::Response]
        def convert_response response
          response_body = response&.body
          return unless response_body

          response_data = JSON.parse(response_body)
          return unless response_data.cs__is_a?(Hash)

          response_data = response_data.deep_symbolize_keys
          # check if response contains application settings or Feature settings
          if response_data[:settings]
            # the response contains ApplicationSettings
            app_settings = build_application_settings(response_data)
            logger.trace('Agent: Received updated application settings', raw: response_data, processed: app_settings)
            app_settings
          else
            # the response contains FeatureSettings
            server_features = build_feature_settings(response_data)
            logger.trace('Agent: Received updated application settings', raw: response_data, processed: server_features)
            server_features
          end
        rescue StandardError => e
          logger.error('Unable to convert response', e)
          nil
        end

        # @param response_data [Hash]
        # @return res [Contrast::Agent::Reporting::Response]
        def build_application_settings response_data
          res = Contrast::Agent::Reporting::Response.application_response
          extract_assess(response_data, res)
          extract_protect(response_data, res)
          extract_exclusions(response_data, res)
          extract_reactions(response_data, res)
          extract_sensitive_data_policy(response_data, res)
          res
        end

        # @param response_data [Hash]
        # @return res [Contrast::Agent::Reporting::Response]
        def build_feature_settings response_data
          res = Contrast::Agent::Reporting::Response.server_response
          extract_assess_server_features(response_data, res)
          extract_protect_server_features(response_data, res)
          extract_protect_lists(response_data, res)
          res.server_features.log_level = response_data[:logLevel]
          res.server_features.log_file = response_data[:logFile]
          res.server_features.telemetry = response_data[:telemetry]
          res
        end
      end
    end
  end
end