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

require 'json'
require 'singleton'

require 'contrast'
require 'contrast/components/logger'
require 'contrast/agent/patching/policy/module_policy'
require 'contrast/agent/patching/policy/method_policy'

module Contrast
  module Agent
    module Patching
      module Policy
        # This is just a holder for our policy. Takes the policy JSON and converts it into hashes that we can access
        # nicely.
        #
        # @abstract
        class Policy
          include Singleton
          include Contrast::Components::Logger::InstanceMethods

          # Indicates the folder in `resources` where this policy lives.
          def self.policy_folder
            raise(NoMethodError, 'specify policy_folder for patching')
          end

          # Indicates is this feature has been disabled by the configuration, read at startup, and therefore can never
          # be enabled.
          def disabled_globally?
            raise(NoMethodError, 'specify disabled_globally? conditions for patching')
          end

          def node_type
            raise(NoMethodError, 'specify the concrete node type for this poilcy')
          end

          attr_reader :sources, :propagators, :triggers, :providers

          SOURCES_KEY =          'sources'
          PROPAGATION_KEY =      'propagators'
          RULES_KEY = 'rules'
          TRIGGERS_KEY = 'triggers'

          def self.policy_json
            File.join(policy_folder, 'policy.json').cs__freeze
          end

          def initialize
            @sources = []
            @propagators = []
            @triggers = []
            @providers = {}

            json = Contrast::Utils::ResourceLoader.load(cs__class.policy_json)
            from_hash_string(json)
          end

          # Our policy for patching rules is a 'dope ass' JSON file. Rather than hard code in a bunch of things to
          # monkey patch, we let the JSON file define the conditions in which modifications are applied. This let's us
          # be flexible and extensible.
          def from_hash_string string
            # The default behavior of the agent is to load the policy on startup, as at this point we do not know in
            # which mode we'll be run.
            #
            # If the configuration file explicitly disables a feature, we know that we will not ever be able to enable
            # it, so in that case, we can skip policy loading.
            return if disabled_globally?

            policy_data = JSON.parse(string)

            policy_data[RULES_KEY].each do |rule_hash|
              rule_hash[TRIGGERS_KEY].each do |trigger_hash|
                trigger_node = node_type.new(trigger_hash, rule_hash)
                add_node(trigger_node)
              end
            end
          end

          def add_node node, node_type = :trigger
            unless node
              logger.error('Node was nil when adding node to policy')
              return
            end

            begin
              node.validate
            rescue ArgumentError => e
              logger.error('Node failed validation', e)
              return
            end

            case node_type
            when :source
              @sources << node
            when :propagator
              @propagators << node
            when :trigger
              @triggers << node
            when :dynamic_source
              module_names << node.class_name
              @sources << node
            else
              logger.error('Invalid node_type provided when adding node to policy', node_type: node_type)
            end
          end

          def module_names
            @_module_names ||= Set.new([sources, propagators, triggers].flatten.map!(&:class_name))
          end

          def find_triggers_by_rule rule_id
            triggers.select { |trigger| trigger.rule_id == rule_id }
          end

          def find_source_node class_name, method_name, instance_method
            sources.find do |source|
              source.class_name == class_name &&
                  source.method_name == method_name &&
                  source.instance_method == instance_method
            end
          end

          def find_propagator_node class_name, method_name, instance_method
            propagators.find do |propagator|
              propagator.class_name == class_name &&
                  propagator.method_name == method_name &&
                  propagator.instance_method == instance_method
            end
          end

          def find_node rule_id, class_name, method_name, instance_method
            find_triggers_by_rule(rule_id).find do |node|
              node.class_name == class_name &&
                  node.method_name == method_name &&
                  node.instance_method == instance_method
            end
          end
        end
      end
    end
  end
end