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

require 'contrast/components/interface'

module Contrast
  module Agent
    # Exclusions are ways for the User to tell the Agent to ignore sections of
    # the Application. If a request or an event matches one of these, the
    # functions of the Agent are suppressed for that request or event.
    class ExclusionMatcher
      include Contrast::Components::Interface
      access_component :logging

      # Create a matcher around an exclusion sent from TeamServer.
      #
      # @param excl [Contrast::Api::Settings::Exclusion]
      # @return [Contrast::Agent::ExclusionMatcher]
      def initialize excl
        @exclusion = excl
        @protect = @exclusion.protect
        @assess = @exclusion.assess

        handle_wildcard_input
        handle_wildcard_url
        handle_wildcard_code
      end

      # According to the docs for exclusions, user input applies to all inputs if
      # the name supplied is an '*' or '.*'. The name matcher does NOT support
      # regexp beyond this.
      # https://docs.contrastsecurity.com/admin-policymgmt.html#exclude
      def handle_wildcard_input
        return unless @exclusion.input_name

        @wildcard_input = @exclusion.input_name == '.*' ||
            @exclusion.input_name == Contrast::Utils::ObjectShare::ASTERISK
      end

      # According to the docs for exclusions, urls apply to all urls if the url
      # supplied is '/.*' or if the URL mode is all. Otherwise, the URL supplied is
      # to be treated as a regular expression that must match the entire URL
      # against which it is tested.
      # https://docs.contrastsecurity.com/admin-policymgmt.html#exclude
      def handle_wildcard_url
        @wildcard_url = match_all?
        return if @wildcard_url
        return unless @exclusion.urls&.any?

        @wildcard_url ||= @exclusion.urls.any? { |test| test == '/.*' }
        return if @wildcard_url

        @urls = []
        @exclusion.urls.each do |url|
          url_pattern = build_regexp(url, true, true)
          @urls << url_pattern if url_pattern
        end
      end

      # According to the docs for exclusions, code applies to the entire stacktrace
      # of the caller, and can act as a regexp. Per our user instructions in the
      # Contrast UI, these comparisons must be done at the end of the input.
      # https://docs.contrastsecurity.com/admin-policymgmt.html#exclude
      def handle_wildcard_code
        return unless @exclusion.denylist&.any?

        @wildcard_exclusions = []
        @exclusion.denylist.each do |code|
          class_name, method_name = code.split(Contrast::Utils::ObjectShare::COLON)
          class_pattern = build_regexp(class_name, false, true)
          method_pattern = build_regexp(method_name)
          next unless class_pattern && method_pattern

          @wildcard_exclusions << [class_pattern, method_pattern]
        end
      end

      def build_regexp pattern, start_anchor = false, end_anchor = false
        pattern = Contrast::Utils::ObjectShare::CARROT + pattern if start_anchor
        pattern += Contrast::Utils::ObjectShare::DOLLAR_SIGN if end_anchor
        Regexp.compile(pattern)
      rescue RegexpError => e
        logger.error('Unable to generate a pattern for exclusion matching.', e, pattern: pattern)
      end

      def protect?
        @protect
      end

      def assess?
        @assess
      end

      def code?
        @exclusion.type == Contrast::Api::Settings::Exclusion::ExclusionType::CODE
      end

      def name
        @exclusion.name
      end

      def match_all?
        @exclusion.urls.nil? || @exclusion.urls.empty?
      end

      # Determine if the given rule is excluded by this exclusion.
      # In this case, the `protection_rules` being empty means apply to all rules,
      # not no rules
      #
      # @param rule - the id of the rule which we're checking for exclusion
      def protection_rule? rule
        protect? &&
            (@exclusion.protection_rules.empty? ||
                @exclusion.protection_rules.include?(rule)
            )
      end

      # Determine if the given rule is excluded by this exclusion.
      # In this case, the `assessment_rules` being empty means apply to all rules,
      # not no rules
      #
      # @param rule - the id of the rule which we're checking for exclusion
      def assess_rule? rule
        assess? &&
            (@exclusion.assessment_rules.empty? ||
                @exclusion.assessment_rules.include?(rule)
            )
      end

      def match_code? stack_trace
        return false unless code?
        return false if @wildcard_exclusions&.empty?

        @wildcard_exclusions.each do |code|
          class_name = code[0]
          method_name = code[1]
          stack_trace.each do |location|
            next unless location.base_label.match?(method_name)
            next unless location.path.match?(class_name)

            return true
          end
        end

        false
      end
    end
  end
end