# frozen_string_literal: true

module RuboCop
  module Cop
    module Style
      # Checks whether some constant value isn't a
      # mutable literal (e.g. array or hash).
      #
      # When the `Recursive` option is enabled, mutable literals nested inside
      # arrays and hashes are also frozen, so an offense on the outermost
      # unfrozen literal will autocorrect every nested mutable literal as well.
      # When the outer literal already has `.freeze` appended, the cop descends
      # into it and reports each outermost unfrozen literal underneath. The
      # option is disabled by default to preserve existing behavior; opt in to
      # get strict nested freezing.
      #
      # Strict mode can be used to freeze all constants, rather than
      # just literals.
      # Strict mode is considered an experimental feature. It has not been
      # updated with an exhaustive list of all methods that will produce
      # frozen objects so there is a decent chance of getting some false
      # positives. Luckily, there is no harm in freezing an already
      # frozen object.
      #
      # From Ruby 3.0, this cop honours the magic comment
      # 'shareable_constant_value'. When this magic comment is set to any
      # acceptable value other than none, it will suppress the offenses
      # raised by this cop. It enforces frozen state.
      #
      # NOTE: `Regexp` and `Range` literals are frozen objects since Ruby 3.0.
      #
      # NOTE: From Ruby 3.0, interpolated strings are not frozen when
      # `# frozen-string-literal: true` is used, so this cop enforces explicit
      # freezing for such strings.
      #
      # NOTE: From Ruby 3.0, this cop allows explicit freezing of constants when
      # the `shareable_constant_value` directive is used.
      #
      # @safety
      #   This cop's autocorrection is unsafe since any mutations on objects that
      #   are made frozen will change from being accepted to raising `FrozenError`,
      #   and will need to be manually refactored.
      #
      # @example EnforcedStyle: literals (default)
      #   # bad
      #   CONST = [1, 2, 3]
      #
      #   # good
      #   CONST = [1, 2, 3].freeze
      #
      #   # good
      #   CONST = <<~TESTING.freeze
      #     This is a heredoc
      #   TESTING
      #
      #   # good
      #   CONST = Something.new
      #
      #
      # @example Recursive: false (default)
      #   # good - only the outer container needs to be frozen
      #   CONST = [{ a: [], b: 'foo' }].freeze
      #
      # @example Recursive: true
      #   # bad - nested mutable literals must be frozen too
      #   CONST = [{ a: [], b: 'foo' }].freeze
      #
      #   # good
      #   CONST = [{ a: [].freeze, b: 'foo'.freeze }.freeze].freeze
      #
      # @example EnforcedStyle: strict
      #   # bad
      #   CONST = Something.new
      #
      #   # bad
      #   CONST = Struct.new do
      #     def foo
      #       puts 1
      #     end
      #   end
      #
      #   # good
      #   CONST = Something.new.freeze
      #
      #   # good
      #   CONST = Struct.new do
      #     def foo
      #       puts 1
      #     end
      #   end.freeze
      #
      #   # good - `Data.define` declares an immutable value type
      #   CONST = Data.define(:foo, :bar)
      #
      # @example
      #   # Magic comment - shareable_constant_value: literal
      #
      #   # bad
      #   CONST = [1, 2, 3]
      #
      #   # good
      #   # shareable_constant_value: literal
      #   CONST = [1, 2, 3]
      #
      class MutableConstant < Base
        # Handles magic comment shareable_constant_value with O(n ^ 2) complexity
        # n - number of lines in the source
        # Iterates over all lines before a CONSTANT
        # until it reaches shareable_constant_value
        module ShareableConstantValue
          module_function

          def recent_shareable_value?(node)
            shareable_constant_comment = magic_comment_in_scope node
            return false if shareable_constant_comment.nil?

            shareable_constant_value = MagicComment.parse(shareable_constant_comment)
                                                   .shareable_constant_value
            shareable_constant_value_enabled? shareable_constant_value
          end

          # Identifies the most recent magic comment with valid shareable constant values
          # that's in scope for this node
          def magic_comment_in_scope(node)
            processed_source_till_node(node).reverse_each.find do |line|
              MagicComment.parse(line).valid_shareable_constant_value?
            end
          end

          private

          def processed_source_till_node(node)
            processed_source.lines[0..(node.last_line - 1)]
          end

          def shareable_constant_value_enabled?(value)
            %w[literal experimental_everything experimental_copy].include? value
          end
        end
        private_constant :ShareableConstantValue

        include ShareableConstantValue
        include FrozenStringLiteral
        include ConfigurableEnforcedStyle
        extend AutoCorrector

        MSG = 'Freeze mutable objects assigned to constants.'

        def on_casgn(node)
          if node.expression.nil? # This is only the case for `CONST += ...` or similar
            parent = node.parent
            return unless parent.or_asgn_type? # We only care about `CONST ||= ...`

            on_assignment(parent.children.last)
          else
            on_assignment(node.expression)
          end
        end

        private

        def on_assignment(value)
          nodes = mutable_nodes(value) do |node|
            if style == :strict
              strict_check(node)
            else
              literal_check(node)
            end
          end

          nodes.each do |node|
            add_offense(node) { |corrector| autocorrect(corrector, node) }
          end
        end

        def mutable_nodes(value, &block)
          if recursive? && explicitly_frozen_literal?(value)
            literal_children(value.receiver).flat_map { |c| mutable_nodes(c, &block) }
          else
            node_offending = yield(value)

            if node_offending
              [value]
            else
              []
            end
          end
        end

        def strict_check(value)
          return if immutable_literal?(value)
          return if operation_produces_immutable_object?(value)
          return if frozen_string_literal?(value)
          return if shareable_constant_value?(value)

          true
        end

        def literal_check(value)
          return unless mutable_or_unfrozen_range?(value)
          return if frozen_string_literal?(value)
          return if shareable_constant_value?(value)

          true
        end

        def mutable_or_unfrozen_range?(value)
          mutable_literal?(value) ||
            (target_ruby_version <= 2.7 && range_enclosed_in_parentheses?(value))
        end

        def autocorrect(corrector, node)
          expr = node.source_range

          splat_value = splat_value(node)
          if splat_value
            correct_splat_expansion(corrector, expr, splat_value)
            corrector.insert_after(expr, '.freeze')
            return
          end

          if node.array_type? && !node.bracketed?
            corrector.wrap(expr, '[', ']')
          elsif requires_parentheses?(node)
            corrector.wrap(expr, '(', ')')
          end

          corrector.insert_after(expr, '.freeze')

          freeze_nested_literals(corrector, node) if recursive?
        end

        # Recursively freezes every nested mutable literal inside an array or
        # hash literal. Already-frozen subtrees are not re-frozen, but their
        # children are still inspected for unfrozen literals deeper down.
        def freeze_nested_literals(corrector, node)
          literal_children(node).each do |child|
            if explicitly_frozen_literal?(child)
              freeze_nested_literals(corrector, child.receiver)
            elsif freezable_nested_literal?(child)
              autocorrect(corrector, child)
            end
          end
        end

        def freezable_nested_literal?(node)
          return false if frozen_string_literal?(node)
          return false if shareable_constant_value?(node)

          mutable_literal?(node)
        end

        # Returns the child literals of an array or hash node that may
        # themselves need freezing. For hashes, both keys and values are
        # included. Percent-literal arrays (e.g. `%w(a b)`) are skipped because
        # `.freeze` cannot be appended to their contents.
        def literal_children(node)
          case node.type
          when :array
            return [] if node.percent_literal?

            node.children
          when :hash
            node.children.flat_map { |child| child.pair_type? ? child.children : [] }
          else
            []
          end
        end

        def explicitly_frozen_literal?(node)
          return false unless node.send_type? && node.method?(:freeze)

          node.receiver && mutable_literal?(node.receiver)
        end

        def recursive?
          cop_config.fetch('Recursive', false)
        end

        def mutable_literal?(value)
          return false if frozen_regexp_or_range_literals?(value)

          value.mutable_literal?
        end

        def immutable_literal?(node)
          frozen_regexp_or_range_literals?(node) || node.immutable_literal?
        end

        def shareable_constant_value?(node)
          return false if target_ruby_version < 3.0

          recent_shareable_value? node
        end

        def frozen_regexp_or_range_literals?(node)
          target_ruby_version >= 3.0 && node.type?(:regexp, :range)
        end

        def requires_parentheses?(node)
          node.range_type? || (node.send_type? && node.loc.dot.nil?)
        end

        def correct_splat_expansion(corrector, expr, splat_value)
          if range_enclosed_in_parentheses?(splat_value)
            corrector.replace(expr, "#{splat_value.source}.to_a")
          else
            corrector.replace(expr, "(#{splat_value.source}).to_a")
          end
        end

        # @!method splat_value(node)
        def_node_matcher :splat_value, <<~PATTERN
          (array (splat $_))
        PATTERN

        # Some of these patterns may not actually return an immutable object,
        # but we want to consider them immutable for this cop.
        # @!method operation_produces_immutable_object?(node)
        def_node_matcher :operation_produces_immutable_object?, <<~PATTERN
          {
            (const _ _)
            (send (const {nil? cbase} :Struct) :new ...)
            (block (send (const {nil? cbase} :Struct) :new ...) ...)
            (send (const {nil? cbase} :Data) :define ...)
            (block (send (const {nil? cbase} :Data) :define ...) ...)
            (send _ :freeze)
            (send {float int} {:+ :- :* :** :/ :% :<<} _)
            (send _ {:+ :- :* :** :/ :%} {float int})
            (send _ {:== :=== :!= :<= :>= :< :>} _)
            (send (const {nil? cbase} :ENV) :[] _)
            (or (send (const {nil? cbase} :ENV) :[] _) _)
            (send _ {:count :length :size} ...)
            (block (send _ {:count :length :size} ...) ...)
          }
        PATTERN

        # @!method range_enclosed_in_parentheses?(node)
        def_node_matcher :range_enclosed_in_parentheses?, <<~PATTERN
          (begin (range _ _))
        PATTERN
      end
    end
  end
end
