# frozen_string_literal: true

module RuboCop
  module Cop
    # Commissioner class is responsible for processing the AST and delegating
    # work to the specified cops.
    class Commissioner
      include RuboCop::AST::Traversal

      RESTRICTED_CALLBACKS = %i[on_send on_csend after_send after_csend].freeze
      private_constant :RESTRICTED_CALLBACKS

      # How a Commissioner returns the results of the investigation
      # as a list of Cop::InvestigationReport and any errors caught
      # during the investigation.
      # Immutable
      # Consider creation API private
      InvestigationReport = Struct.new(:processed_source, :cop_reports, :errors) do
        def cops
          @cops ||= cop_reports.map(&:cop)
        end

        def offenses_per_cop
          @offenses_per_cop ||= cop_reports.map(&:offenses)
        end

        def correctors
          @correctors ||= cop_reports.map(&:corrector)
        end

        def offenses
          @offenses ||= offenses_per_cop.flatten(1)
        end

        def merge(investigation)
          InvestigationReport.new(processed_source,
                                  cop_reports + investigation.cop_reports,
                                  errors + investigation.errors)
        end
      end

      attr_reader :errors

      def initialize(cops, forces = [], options = {})
        @cops = cops
        @forces = forces
        @options = options
        initialize_callbacks

        reset
      end

      # Create methods like :on_send, :on_super, etc. They will be called
      # during AST traversal and try to call corresponding methods on cops.
      # A call to `super` is used
      # to continue iterating over the children of a node.
      # However, if we know that a certain node type (like `int`) never has
      # child nodes, there is no reason to pay the cost of calling `super`.
      Parser::Meta::NODE_TYPES.each do |node_type|
        method_name = :"on_#{node_type}"
        next unless method_defined?(method_name)

        # Hacky: Comment-out code as needed
        r = '#' unless RESTRICTED_CALLBACKS.include?(method_name) # has Restricted?
        c = '#' if NO_CHILD_NODES.include?(node_type) # has Children?

        # The `after_` calls are guarded because barely any cop defines an `after_`
        # callback, and the guard is cheaper than the method call it avoids.
        class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
                  def on_#{node_type}(node)                                                                                # def on_send(node)
                    trigger_responding_cops(:on_#{node_type}, node)                                                        #   trigger_responding_cops(:on_send, node)
              #{r}  trigger_restricted_cops(:on_#{node_type}, node)                                                        #   trigger_restricted_cops(:on_send, node)
          #{c}      super(node)                                                                                            #   super(node)
          #{c}      trigger_responding_cops(:after_#{node_type}, node) if @callbacks[:after_#{node_type}]                  #   trigger_responding_cops(:after_send, node) if @callbacks[:after_send]
          #{c}#{r}  trigger_restricted_cops(:after_#{node_type}, node) unless @restricted_map[:after_#{node_type}].empty?  #   trigger_restricted_cops(:after_send, node) unless @restricted_map[:after_send].empty?
                  end                                                                                                      # end
        RUBY
      end

      # @return [InvestigationReport]
      def investigate(processed_source, offset: 0, original: processed_source)
        reset

        begin_investigation(processed_source, offset: offset, original: original)
        if processed_source.valid_syntax?
          invoke(:on_new_investigation, @callbacks[:on_new_investigation])
          invoke_with_argument(:investigate, @forces, processed_source)

          walk(processed_source.ast) unless @cops.empty?
          invoke(:on_investigation_end, @callbacks[:on_investigation_end])
        else
          invoke(:on_other_file, @callbacks[:on_other_file])
        end
        reports = @cops.map { |cop| cop.send(:complete_investigation) }
        InvestigationReport.new(processed_source, reports, @errors)
      end

      private

      def begin_investigation(processed_source, offset:, original:)
        @cops.each do |cop|
          cop.begin_investigation(processed_source, offset: offset, original: original)
        end
      end

      def trigger_responding_cops(callback, node)
        @callbacks[callback]&.each do |cop|
          with_cop_error_handling(cop, node) do
            cop.public_send(callback, node)
          end
        end
      end

      def reset
        @errors = []
      end

      def initialize_callbacks
        @callbacks = build_callbacks(@cops)
        @restricted_map = restrict_callbacks(@callbacks)
      end

      def build_callbacks(cops)
        callbacks = {}
        cops.each do |cop|
          cop.callbacks_needed.each do |callback|
            (callbacks[callback] ||= []) << cop
          end
        end
        callbacks
      end

      def restrict_callbacks(callbacks)
        restricted = {}
        RESTRICTED_CALLBACKS.each do |callback|
          restricted[callback] = restricted_map(callbacks[callback])
        end
        restricted
      end

      def trigger_restricted_cops(event, node)
        name = node.method_name
        @restricted_map[event][name]&.each do |cop|
          with_cop_error_handling(cop, node) do
            cop.public_send(event, node)
          end
        end
      end

      # NOTE: mutates `callbacks` in place
      def restricted_map(callbacks)
        map = {}
        callbacks&.select! do |cop|
          restrictions = cop.class.send :restrict_on_send
          restrictions.each { |name| (map[name] ||= []) << cop }
          restrictions.empty?
        end
        map
      end

      def invoke(callback, cops)
        cops&.each { |cop| with_cop_error_handling(cop) { cop.send(callback) } }
      end

      def invoke_with_argument(callback, cops, arg)
        cops.each { |cop| with_cop_error_handling(cop) { cop.send(callback, arg) } }
      end

      # Allow blind rescues here, since we're absorbing and packaging or
      # re-raising exceptions that can be raised from within the individual
      # cops' `#investigate` methods.
      def with_cop_error_handling(cop, node = nil)
        yield
      rescue StandardError => e
        raise e if @options[:raise_error] # For internal testing

        err = ErrorWithAnalyzedFileLocation.new(cause: e, node: node, cop: cop)
        raise err if @options[:raise_cop_error] # From user-input option

        @errors << err
      end
    end
  end
end
