module Ameba::Rule::Style
  # A rule that enforces consistent naming of `Nil` in type unions.
  #
  # For example, this is considered invalid:
  #
  # ```
  # foo : String | Nil = nil
  # ```
  #
  # And should be replaced by the following:
  #
  # ```
  # foo : String? = nil
  # ```
  #
  # Enable the `ExplicitNil` option to enforce the opposite behavior.
  #
  # The `OnlyTwoElementUnions` option mixes the two approaches, requiring short version (`?`)
  # in two-element unions and verbose version (`| Nil`) in multi-element unions.
  #
  # YAML configuration example:
  #
  # ```
  # Style/VerboseNilType:
  #   Enabled: true
  #   ExplicitNil: false
  #   OnlyTwoElementUnions: false
  # ```
  class VerboseNilType < Base
    include AST::Util

    properties do
      since_version "1.7.0"
      description "Enforces consistent naming of `Nil` in type unions"
      explicit_nil false
      only_two_element_unions false
    end

    MSG_VERBOSE = "Prefer `?` instead of `| Nil` in unions"
    MSG_SHORT   = "Prefer `| Nil` instead of `?` in unions"

    private NIL_TYPE_PATTERN    = /(\s*\|\s*Nil(?=\W|$))|((?<=\W|^)Nil\s*\|\s*)/
    private SINGLE_TYPE_PATTERN = /\((\w+)\)/

    def test(source, node : Crystal::Union)
      return unless has_nil?(node)
      return unless node_source = node_source(node, source.lines)

      # https://github.com/crystal-lang/crystal/issues/11071
      return if node_source.includes?(".class")

      # `String?` -> `String | Nil`
      if explicit_nil? || (only_two_element_unions? && type_count(node) > 2)
        return unless node_source.ends_with?('?')

        issue_for(node, MSG_SHORT) do |corrector|
          corrector.replace(node, "%s | Nil" % node_source.rstrip('?'))
        end
        return
      end

      # `String | Nil` -> `String?`
      return unless node_source.matches?(NIL_TYPE_PATTERN)

      # Unions that _do not_ contain generic types are safe to modify using
      # simple find-and-replace, due to the fact that their types are being
      # flattened in the end, so removing `Nil` type from anywhere in the
      # union and appending `?` should be semantically the same and should
      # not affect the type of the union.
      #
      # If union contains generic type however, we need to be careful, as
      # simple find-and-replace might change the type of the union -
      # and that's why we skip the auto-correction of those.
      if has_generic?(node)
        issue_for(node, MSG_VERBOSE)
        return
      end

      issue_for(node, MSG_VERBOSE) do |corrector|
        corrected_code = "%s?" % node_source
          .gsub(NIL_TYPE_PATTERN, "")
          .gsub('?', "")

        # handle `(String | ((Symbol)))` cases
        while corrected_code.matches?(SINGLE_TYPE_PATTERN)
          corrected_code = corrected_code
            .gsub(SINGLE_TYPE_PATTERN, "\\1")
        end
        corrector.replace(node, corrected_code)
      end
    end

    private def type_count(node : Crystal::Union)
      node.types.sum do |type|
        type.is_a?(Crystal::Union) ? type.types.size : 1
      end
    end

    private def has_generic?(node : Crystal::Union)
      node.types.any? { |type| has_generic?(type) }
    end

    private def has_generic?(node)
      node.is_a?(Crystal::Generic)
    end

    private def has_nil?(node : Crystal::Union)
      node.types.any? { |type| has_nil?(type) }
    end

    private def has_nil?(node)
      path_named?(node, "Nil")
    end
  end
end
