module Ameba::Rule
  # Marks a rule as deprecated, with an optional reason.
  annotation Deprecated
  end

  # List of names of the special rules, which
  # behave differently than usual rules.
  SPECIAL = {
    Lint::Syntax.rule_name,
    Lint::UnneededDisableDirective.rule_name,
  }

  # Represents a base of all rules. In other words, all rules
  # inherit from this class:
  #
  # ```
  # class Ameba::Rule::MyRule < Ameba::Rule::Base
  #   def test(source)
  #     if invalid?(source)
  #       issue_for line, column, "Something wrong"
  #     end
  #   end
  #
  #   private def invalid?(source)
  #     # ...
  #   end
  # end
  # ```
  #
  # Enforces rules to implement an abstract `#test` method which
  # is designed to test the source passed in. If source has issues
  # that are tested by this rule, it should add an issue.
  abstract class Base
    include Config::RuleConfig

    # This method is designed to test the source passed in. If source has issues
    # that are tested by this rule, it should add an issue.
    #
    # By default it uses a node visitor to traverse all the nodes in the source.
    #
    # NOTE: Must be overridden for other type of rules.
    def test(source : Source)
      AST::NodeVisitor.new(self, source)
    end

    # NOTE: Can't be abstract
    def test(source : Source, node : Crystal::ASTNode, *opts)
    end

    # A convenient addition to `#test` method that does the same
    # but returns a passed in `source` as an addition.
    #
    # ```
    # source = MyRule.new.catch(source)
    # source.valid?
    # ```
    def catch(source : Source)
      source.tap { test(source) }
    end

    # Returns a name of this rule, which is basically a class name.
    #
    # ```
    # module Ameba
    #   class Rule::MyRule < Rule::Base
    #     def test(source)
    #     end
    #   end
    # end
    #
    # Ameba::Rule::MyRule.new.name # => "MyRule"
    # ```
    def name
      {{ @type }}.rule_name
    end

    # Returns a group this rule belongs to.
    #
    # ```
    # module Ameba
    #   class Rule::MyGroup::MyRule < Rule::Base
    #     # ...
    #   end
    # end
    #
    # Ameba::Rule::MyGroup::MyRule.new.group # => "MyGroup"
    # ```
    def group
      {{ @type }}.group_name
    end

    # Checks whether the source is excluded from this rule.
    # It searches for a path in `excluded` property which matches
    # the one of the given source.
    #
    # ```
    # my_rule.excluded?(source) # => true or false
    # ```
    def excluded?(source, root = Dir.current)
      !!excluded.try &.any? do |path|
        path = Path.posix(path).to_native.expand(root)

        source.fullpath == path.to_s ||
          Dir[path.to_posix].includes?(source.fullpath)
      end
    end

    # Returns `true` if this rule is special and behaves differently than
    # usual rules.
    #
    # ```
    # my_rule.special? # => true or false
    # ```
    def special?
      name.in?(SPECIAL)
    end

    def_equals_and_hash name

    # Adds an issue to the *source*
    macro issue_for(*args, **kwargs, &block)
      source.add_issue(self, {{ args.splat(", ") }}{{ kwargs.double_splat }}) {{ block }}
    end

    # Returns the name for this rule.
    #
    # ```
    # Ameba::Rule::Lint::Syntax.rule_name # => "Lint/Syntax"
    # ```
    def self.rule_name
      name.lchop("Ameba::Rule::").gsub("::", '/')
    end

    # Returns the group name for this rule.
    #
    # ```
    # Ameba::Rule::Lint::Syntax.group_name # => "Lint"
    # ```
    def self.group_name
      rule_name.split('/')[0...-1].join('/')
    end

    protected def self.subclasses
      {{ @type.subclasses }}
    end

    protected def self.abstract?
      {{ @type.abstract? }}
    end

    protected def self.inherited_rules
      subclasses.each_with_object([] of Base.class) do |klass, obj|
        klass.abstract? ? obj.concat(klass.inherited_rules) : (obj << klass)
      end
    end

    private macro read_type_doc(filepath = __FILE__)
      {{ run("../../contrib/read_type_doc",
           @type.name.split("::").last,
           filepath,
         ).chomp.stringify }}.presence
    end

    macro inherited
      # Returns `true` if this rule is deprecated, `false` otherwise.
      class_getter? deprecated = false

      # Returns the deprecation reason for this rule, if there is any.
      class_getter deprecation_reason : String?

      {% if ann = @type.annotation(::Ameba::Rule::Deprecated) %}
        @@deprecated = true
        @@deprecation_reason = {{ ann.args[0] }}.presence
      {% end %}

      # Returns the documentation URL for this rule.
      #
      # ```
      # Ameba::Rule::Lint::Syntax.documentation_url
      # # => "https://crystal-ameba.org/api/master/Ameba/Rule/Lint/Syntax.html"
      # ```
      class_getter documentation_url : String do
        version = Ameba.version
        version = version.dev? ? "master" : version.simple.to_s

        "https://crystal-ameba.org/api/%s/Ameba/Rule/%s.html" % {
          version, rule_name,
        }
      end

      # Returns the documentation for this rule, if there is any.
      #
      # ```
      # module Ameba
      #   # This is a test rule.
      #   # Does nothing.
      #   class Rule::MyRule < Rule::Base
      #     def test(source)
      #     end
      #   end
      # end
      #
      # Ameba::Rule::MyRule.parsed_doc # => "This is a test rule.\nDoes nothing."
      # ```
      class_getter parsed_doc : String? = read_type_doc
    end
  end

  # Returns a list of all available rules.
  #
  # ```
  # Ameba::Rule.rules # => [Rule1, Rule2, ....]
  # ```
  def self.rules
    Base.inherited_rules
  end
end
