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

require 'contrast/agent/excluder/excluder'
require 'contrast/agent/reporting/settings/sensitive_data_masking'
require 'contrast/components/config'
require 'contrast/components/logger'

module Contrast
  module Components
    # This component encapsulates the statefulness of settings.
    # When we say 'settings', we're referring specifically to external
    # directives (likely provided by TeamServer) about product operation.
    # 'Settings' is not a generic term for 'configurable stuff'.
    module Settings
      APPLICATION_STATE_BASE = Struct.new(:modes_by_id).new(Hash.new(:NO_ACTION))
      PROTECT_STATE_BASE = Struct.new(:enabled, :rules).new(false, {})
      ASSESS_STATE_BASE = Struct.new(:enabled, :sampling_settings, :disabled_assess_rules, :session_id).new(false, nil,
                                                                                                            [], nil) do
        def sampling_settings= new_val
          @sampling_settings = new_val
          Contrast::Utils::Assess::SamplingUtil.instance.update
        end
      end
      SENSITIVE_DATA_MASKING_BASE = Contrast::Agent::Reporting::Settings::SensitiveDataMasking.new

      # This is a class.
      class Interface # rubocop:disable Metrics/ClassLength
        include Contrast::Components::Logger::InstanceMethods
        extend Contrast::Components::Config

        # tainted_columns are database columns that receive unsanitized input.
        attr_reader :tainted_columns # This can probably go into assess_state?
        # Current state for Assess.
        # enabled [Boolean] Indicate if the assess feature set is enabled for this server or not.
        #
        # sampling [Hash<AssessSampling>] Hash of AssessSampling Used to control the sampling feature in the agent: {
        #  baseline          [Integer] The number of baseline requests to take before switching to sampling
        #                               for the window.
        #  enabled           [Boolean] If the sampling feature should be used or not.
        #  frequency         [Integer] The number of requests to skip before observing during the sampling
        #                               window after the baseline.
        #  responseFrequency [Integer]
        #  window            [Integer]
        # }
        #
        # disabled_assess_rules [array<AssessRuleID(String)>] Assess rules to disable for this application.
        attr_reader :assess_state
        # Current State for Protect.
        # enabled [Boolean] Indicate if the protect feature set is enabled for this server or not.
        #
        # Protection rules are returned as:
        # rules [Hash<RULE_ID => MODE>, nil] Hash with rule_id as key and mode as value
        attr_reader :protect_state
        # Current Application State.
        #
        # modes_by_id [Hash<Rule_id => Mode] Returns Hash with rules and their current mode.
        attr_reader :application_state
        # This the structure that will hold the masking rules send from TS.
        #
        # @return [Contrast::Agent::Reporting::Settings::SensitiveDataMasking]:
        #           mask_http_body [Boolean] Policy flag to enable the use of masking on request body.
        #           rules          [Array<Contrast::Agent::Reporting::Settings::SensitiveDataMaskingRule>]
        #                          Rules to follow when using the masking. Each rules contains Id [String]
        #                          and Keywords [Array<String>].
        attr_reader :sensitive_data_masking
        # @return [Integer] the time, in ms, that application settings last changed
        attr_reader :last_app_update_ms
        # @return [Integer] the time, in ms, that server settings last changed
        attr_reader :last_server_update_ms
        # @return [Contrast::Agent::Excluder] a wrapper around the exclusion rules for the application
        attr_reader :excluder
        # @return [String] The last update but in string format used to build request header.
        # This value should be sent be TS in the Last-Modified header to sync and save resources if the
        # two dates are the same.
        # format: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
        attr_reader :server_settings_last_httpdate
        # @return [String] The last update but in string format used to build request header.
        # This value should be sent be TS in the Last-Modified header to sync and save resources if the
        # two dates are the same.
        # format: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
        attr_reader :app_settings_last_httpdate

        def initialize
          reset_state
        end

        # @param features_response [Contrast::Agent::Reporting::Response]
        def update_from_server_features features_response # rubocop:disable Metrics/AbcSize
          return unless (server_features = features_response&.server_features)

          log_file = server_features.log_file
          log_level = server_features.log_level
          # Update logger:
          Contrast::Logger::Log.instance.update(log_file, log_level) if log_file || log_level
          # Update AgentLib Logger
          update_agent_lib_log(log_level.to_s)
          # Update CEFlogger:
          unless server_features.security_logger.settings_blank?
            cef_logger.build_logger(server_features.security_logger.log_level, server_features.security_logger.log_file)
          end
          # TODO: RUBY-99999 Update Bot-Blocker from server settings - check enable value.
          # For now all protection rules are rebuild on Application update. Bot blocker uses the default
          # enable from the base rule, and update it's mode on app settings update.
          # Here we receive also bots for that rule.
          unless settings_empty?(server_features.protect.enabled?)
            @protect_state.enabled = server_features.protect.enabled?
            store_in_config(%i[protect enable], server_features.protect.enabled?)
          end
          update_assess_server_features(server_features.assess)
          @last_server_update_ms = Contrast::Utils::Timer.now_ms
          # update via response header. We receive header from TS with last update info, setting the
          # next request's header with the same time will save needless update of settings if there
          # are no new server features updates after the said time.
          @server_settings_last_httpdate = header_server_last_update
        rescue StandardError => e
          logger.warn('The following error occurred from server update: ', e: e)
        end

        # Update AgentLib log level
        def update_agent_lib_log new_log_level
          agent_lib_log_level = Contrast::AgentLib::InterfaceBase::LOG_LEVEL[0] if new_log_level.empty?
          agent_lib_log_level ||= Contrast::AgentLib::InterfaceBase::LOG_LEVEL.key(new_log_level.upcase)

          # detect if the provided level is invalid and log if it is
          # by default if we pass invalid log level - it will leave the last active
          unless Contrast::AgentLib::InterfaceBase::LOG_LEVEL.value?(new_log_level.upcase)
            cur_active = Contrast::AGENT_LIB.log_level
            logger.debug('The provided level was invalid, so the logger stays to the last active: ',
                         active: cur_active,
                         provided_level: new_log_level)
          end

          Contrast::AGENT_LIB.change_log_options(true, agent_lib_log_level)
        end

        # Update Assess server features
        #
        # @param assess [Contrast::Agent::Reporting::Settings::AssessServerFeature]
        def update_assess_server_features assess
          return if settings_empty?(assess.enabled?)

          @assess_state.enabled = assess.enabled?
          store_in_config(%i[assess enable], assess.enabled?)
          @assess_state.sampling_settings = assess.sampling

          Contrast::Components::Sampling::Interface::CONFIG_VALUES.each do |field|
            lookup_field = field == 'enable' ? :enabled : field.to_sym
            store_in_config(Contrast::Components::Sampling::Interface::CANON_NAME.split('.') + [field.to_sym],
                            assess.sampling.send(lookup_field))
          end
        end

        # @param settings_response [Contrast::Agent::Reporting::Response]
        def update_from_application_settings settings_response
          return unless (app_settings = settings_response&.application_settings)

          @application_state.modes_by_id = app_settings.protect.protection_rules_to_settings_hash
          update_exclusion_matchers(app_settings.exclusions)
          app_settings.protect.virtual_patches = app_settings.protect.virtual_patches unless
            settings_empty?(app_settings.protect.virtual_patches)
          update_sensitive_data_policy(app_settings.sensitive_data_masking)
          @assess_state.disabled_assess_rules = app_settings.assess.disabled_rules
          new_session_id = app_settings.assess.session_id
          @assess_state.session_id = new_session_id if new_session_id && !new_session_id.blank?
          @last_app_update_ms = Contrast::Utils::Timer.now_ms
          @app_settings_last_httpdate = header_application_last_update
        end

        # Wipe state to zero.
        def reset_state
          @protect_state = PROTECT_STATE_BASE.dup
          update_assess_state
          @application_state = APPLICATION_STATE_BASE.dup
          @tainted_columns = {}
          @sensitive_data_masking = SENSITIVE_DATA_MASKING_BASE.dup
          @excluder = Contrast::Agent::Excluder.new
        end

        # We save the session_id, reset and set it again if available.
        # This done so that reporting between updates won't trigger argument error
        # for missing session_id given one is already set and used with the first application
        # create response received from TS.
        #
        # @return [Struct]
        def update_assess_state
          current_session_id = @assess_state&.session_id
          @assess_state = ASSESS_STATE_BASE.dup
          # There is application settings update for the session id if new is received.
          # Here we make sure not to delete the already set one.
          @assess_state&.session_id = current_session_id unless current_session_id&.empty?
          @assess_state
        end

        def build_protect_rules
          @protect_state.rules = {}

          # Rules. They add themselves on initialize.
          Contrast::Agent::Protect::Rule::BotBlocker.new
          cmdi = Contrast::Agent::Protect::Rule::CmdInjection.new
          cmdi.sub_rules
          Contrast::Agent::Protect::Rule::Deserialization.new
          Contrast::Agent::Protect::Rule::NoSqli.new
          path = Contrast::Agent::Protect::Rule::PathTraversal.new
          path.sub_rules
          sqli = Contrast::Agent::Protect::Rule::Sqli.new
          sqli.sub_rules
          Contrast::Agent::Protect::Rule::UnsafeFileUpload.new
          Contrast::Agent::Protect::Rule::Xss.new
          Contrast::Agent::Protect::Rule::Xxe.new
        end

        # @param exclusions [Contrast::Agent::Reporting::Settings::Exclusions]
        def update_exclusion_matchers exclusions
          matchers = []
          exclusions.url_exclusions.each do |exclusion|
            matchers << Contrast::Agent::ExclusionMatcher.new(exclusion)
          end
          exclusions.input_exclusions.each do |exclusion|
            matchers << Contrast::Agent::ExclusionMatcher.new(exclusion)
          end
          @excluder = Contrast::Agent::Excluder.new(matchers)
        end

        # Update the sensitive data masking policy from settings,
        # received from TS. In case the settings are empty,
        # keep current ones.
        #
        # @param sensitive_data_masking [Contrast::Agent::Reporting::Settings::SensitiveDataMasking]
        # Ts Response settings for sensitive data masking policy
        def update_sensitive_data_policy sensitive_data_masking
          @sensitive_data_masking.mask_http_body = sensitive_data_masking.mask_http_body? unless
            settings_empty?(sensitive_data_masking.mask_http_body?)
          @sensitive_data_masking.mask_attack_vector = sensitive_data_masking.mask_attack_vector? unless
            settings_empty?(sensitive_data_masking.mask_attack_vector?)
          return if settings_empty?(sensitive_data_masking.rules)

          @sensitive_data_masking.rules = sensitive_data_masking.rules
          # update with the newly received rules.
          Contrast::Agent::Reporting::Masker.send(:update_dictionary)
        end

        private

        # check if settings are empty and return true if so.
        #
        # @param settings [String, Boolean, Array, Hash]
        # @return true | false
        def settings_empty? settings
          return false if !!settings == settings
          return true if settings.nil? || settings.empty?

          false
        end

        # update server last updated via response header.
        # Used to build the next request header.
        #
        # @return [String]
        def header_server_last_update
          Contrast::Agent.reporter.client.response_handler.last_server_modified
        end

        # update application last updated via response header.
        # Used to build the next request header.
        #
        # @return [String]
        def header_application_last_update
          Contrast::Agent.reporter.client.response_handler.last_application_modified
        end

        # Update the stored config values to ensure that we know about the correct values,
        # and that the sources are correct for entries updated from the UI.
        #
        # @param parts [Array] the path to the setting in config
        # @param value [String, Integer, Array, nil] the value for the configuration setting
        def store_in_config parts, value
          level = Contrast::CONFIG.config.loaded_config
          parts[0...-1].each do |segment|
            level[segment] ||= {}
            level = level[segment]
          end
          return unless level.cs__is_a?(Hash)

          level[parts[-1]] = value
          Contrast::CONFIG.sources.set(parts.join('.'), Contrast::Components::Config::Sources::CONTRAST_UI)
        end
      end
    end
  end
end