# frozen_string_literal: true

module RuboCop
  module Cop
    module Lint
      # This cop checks for shadowed arguments.
      #
      # This cop has `IgnoreImplicitReferences` configuration option.
      # It means argument shadowing is used in order to pass parameters
      # to zero arity `super` when `IgnoreImplicitReferences` is `true`.
      #
      # @example
      #
      #   # bad
      #   do_something do |foo|
      #     foo = 42
      #     puts foo
      #   end
      #
      #   def do_something(foo)
      #     foo = 42
      #     puts foo
      #   end
      #
      #   # good
      #   do_something do |foo|
      #     foo = foo + 42
      #     puts foo
      #   end
      #
      #   def do_something(foo)
      #     foo = foo + 42
      #     puts foo
      #   end
      #
      #   def do_something(foo)
      #     puts foo
      #   end
      #
      # @example IgnoreImplicitReferences: false (default)
      #
      #   # bad
      #   def do_something(foo)
      #     foo = 42
      #     super
      #   end
      #
      #   def do_something(foo)
      #     foo = super
      #     bar
      #   end
      #
      # @example IgnoreImplicitReferences: true
      #
      #   # good
      #   def do_something(foo)
      #     foo = 42
      #     super
      #   end
      #
      #   def do_something(foo)
      #     foo = super
      #     bar
      #   end
      #
      class ShadowedArgument < Cop
        MSG = 'Argument `%<argument>s` was shadowed by a local variable ' \
              'before it was used.'.freeze

        def_node_search :uses_var?, '(lvar %)'

        def join_force?(force_class)
          force_class == VariableForce
        end

        def after_leaving_scope(scope, _variable_table)
          scope.variables.each_value do |variable|
            check_argument(variable)
          end
        end

        private

        def check_argument(argument)
          return unless argument.method_argument? || argument.block_argument?
          # Block local variables, i.e., variables declared after ; inside
          # |...| aren't really arguments.
          return if argument.explicit_block_local_variable?

          shadowing_assignment(argument) do |node|
            message = format(MSG, argument: argument.name)

            add_offense(node, message: message)
          end
        end

        def shadowing_assignment(argument)
          return unless argument.referenced?

          assignment_without_argument_usage(argument) do |node, location_known|
            assignment_without_usage_pos = node.source_range.begin_pos

            references = argument_references(argument)

            # If argument was referenced before it was reassigned
            # then it's not shadowed
            next if references.any? do |reference|
              next true if !reference.explicit? && ignore_implicit_references?

              reference_pos(reference.node) <= assignment_without_usage_pos
            end

            yield location_known ? node : argument.declaration_node
          end
        end

        # Find the first argument assignment, which doesn't reference the
        # argument at the rhs. If the assignment occurs inside a branch or
        # block, it is impossible to tell whether it's executed, so precise
        # shadowing location is not known.
        #
        def assignment_without_argument_usage(argument)
          argument.assignments.reduce(true) do |location_known, assignment|
            assignment_node = assignment.meta_assignment_node || assignment.node

            # Shorthand assignments always use their arguments
            next false if assignment_node.shorthand_asgn?

            node_within_block_or_conditional =
              node_within_block_or_conditional?(assignment_node.parent,
                                                argument.scope.node)

            unless uses_var?(assignment_node, argument.name)
              # It's impossible to decide whether a branch or block is executed,
              # so the precise reassignment location is undecidable.
              next false if node_within_block_or_conditional

              yield(assignment.node, location_known)
              break
            end

            location_known
          end
        end

        def reference_pos(node)
          node = node.parent.masgn_type? ? node.parent : node

          node.source_range.begin_pos
        end

        # Check whether the given node is nested into block or conditional.
        #
        def node_within_block_or_conditional?(node, stop_search_node)
          return false if node == stop_search_node

          node.conditional? || node.block_type? ||
            node_within_block_or_conditional?(node.parent, stop_search_node)
        end

        # Get argument references without assignments' references
        #
        def argument_references(argument)
          assignment_references = argument
                                  .assignments
                                  .flat_map(&:references)
                                  .map(&:source_range)

          argument.references.reject do |ref|
            next false unless ref.explicit?

            assignment_references.include?(ref.node.source_range)
          end
        end

        def ignore_implicit_references?
          cop_config['IgnoreImplicitReferences']
        end
      end
    end
  end
end
