# frozen_string_literal: true

module RuboCop
  module RSpec
    # Mixin for `expect_offense` and `expect_no_offenses`
    #
    # This mixin makes it easier to specify strict offense expectations
    # in a declarative and visual fashion. Just type out the code that
    # should generate a offense, annotate code by writing '^'s
    # underneath each character that should be highlighted, and follow
    # the carets with a string (separated by a space) that is the
    # message of the offense. You can include multiple offenses in
    # one code snippet.
    #
    # @example Usage
    #
    #     expect_offense(<<~RUBY)
    #       a do
    #         b
    #       end.c
    #       ^^^^^ Avoid chaining a method call on a do...end block.
    #     RUBY
    #
    # @example Equivalent assertion without `expect_offense`
    #
    #     inspect_source(<<~RUBY)
    #       a do
    #         b
    #       end.c
    #     RUBY
    #
    #     expect(cop.offenses.size).to be(1)
    #
    #     offense = cop.offenses.first
    #     expect(offense.line).to be(3)
    #     expect(offense.column_range).to be(0...5)
    #     expect(offense.message).to eql(
    #       'Avoid chaining a method call on a do...end block.'
    #     )
    #
    # Auto-correction can be tested using `expect_correction` after
    # `expect_offense`.
    #
    # @example `expect_offense` and `expect_correction`
    #
    #   expect_offense(<<~RUBY)
    #     x % 2 == 0
    #     ^^^^^^^^^^ Replace with `Integer#even?`.
    #   RUBY
    #
    #   expect_correction(<<~RUBY)
    #     x.even?
    #   RUBY
    #
    # If you do not want to specify an offense then use the
    # companion method `expect_no_offenses`. This method is a much
    # simpler assertion since it just inspects the source and checks
    # that there were no offenses. The `expect_offense` method has
    # to do more work by parsing out lines that contain carets.
    #
    # If the code produces an offense that could not be auto-corrected, you can
    # use `expect_no_corrections` after `expect_offense`.
    #
    # @example `expect_offense` and `expect_no_corrections`
    #
    #   expect_offense(<<~RUBY)
    #     a do
    #       b
    #     end.c
    #     ^^^^^ Avoid chaining a method call on a do...end block.
    #   RUBY
    #
    #   expect_no_corrections
    #
    # If your code has variables of different lengths, you can use `%{foo}`,
    # `^{foo}`, and `_{foo}` to format your template; you can also abbreviate
    # offense messages with `[...]`:
    #
    #   %w[raise fail].each do |keyword|
    #     expect_offense(<<~RUBY, keyword: keyword)
    #       %{keyword}(RuntimeError, msg)
    #       ^{keyword}^^^^^^^^^^^^^^^^^^^ Redundant `RuntimeError` argument [...]
    #     RUBY
    #
    #   %w[has_one has_many].each do |type|
    #     expect_offense(<<~RUBY, type: type)
    #       class Book
    #         %{type} :chapter, foreign_key: 'book_id'
    #         _{type}           ^^^^^^^^^^^^^^^^^^^^^^ Specifying the default [...]
    #       end
    #     RUBY
    #   end
    #
    # If you need to specify an offense on a blank line, use the empty `^{}` marker:
    #
    # @example `^{}` empty line offense
    #
    #   expect_offense(<<~RUBY)
    #
    #     ^{} Missing frozen string literal comment.
    #     puts 1
    #   RUBY
    module ExpectOffense
      def format_offense(source, **replacements)
        replacements.each do |keyword, value|
          value = value.to_s
          source = source.gsub("%{#{keyword}}", value)
                         .gsub("^{#{keyword}}", '^' * value.size)
                         .gsub("_{#{keyword}}", ' ' * value.size)
        end
        source
      end

      # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
      def expect_offense(source, file = nil, severity: nil, **replacements)
        source = format_offense(source, **replacements)
        RuboCop::Formatter::DisabledConfigFormatter
          .config_to_allow_offenses = {}
        RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {}
        cop.instance_variable_get(:@options)[:auto_correct] = true

        expected_annotations = AnnotatedSource.parse(source)

        if expected_annotations.plain_source == source
          raise 'Use `expect_no_offenses` to assert that no offenses are found'
        end

        @processed_source = parse_source(expected_annotations.plain_source,
                                         file)

        unless @processed_source.valid_syntax?
          raise 'Error parsing example code: ' \
            "#{@processed_source.diagnostics.map(&:render).join("\n")}"
        end

        @offenses = _investigate(cop, @processed_source)
        actual_annotations =
          expected_annotations.with_offense_annotations(@offenses)

        expect(actual_annotations).to eq(expected_annotations), ''
        expect(@offenses.map(&:severity).uniq).to eq([severity]) if severity

        @offenses
      end

      def expect_correction(correction, loop: true)
        raise '`expect_correction` must follow `expect_offense`' unless @processed_source

        iteration = 0
        new_source = loop do
          iteration += 1

          corrected_source = @last_corrector.rewrite

          break corrected_source unless loop
          break corrected_source if @last_corrector.empty?
          break corrected_source if corrected_source == @processed_source.buffer.source

          if iteration > RuboCop::Runner::MAX_ITERATIONS
            raise RuboCop::Runner::InfiniteCorrectionLoop.new(@processed_source.path, [@offenses])
          end

          # Prepare for next loop
          @processed_source = parse_source(corrected_source,
                                           @processed_source.path)
          _investigate(cop, @processed_source)
        end

        expect(new_source).to eq(correction)
      end
      # rubocop:enable Metrics/AbcSize, Metrics/MethodLength

      def expect_no_corrections
        raise '`expect_no_corrections` must follow `expect_offense`' unless @processed_source

        return if @last_corrector.empty?

        # In order to print a nice diff, e.g. what source got corrected to,
        # we need to run the actual corrections

        new_source = @last_corrector.rewrite

        expect(new_source).to eq(@processed_source.buffer.source)
      end

      def expect_no_offenses(source, file = nil)
        offenses = inspect_source(source, file)

        expected_annotations = AnnotatedSource.parse(source)
        actual_annotations =
          expected_annotations.with_offense_annotations(offenses)
        expect(actual_annotations.to_s).to eq(source)
      end

      # Parsed representation of code annotated with the `^^^ Message` style
      class AnnotatedSource
        ANNOTATION_PATTERN = /\A\s*(\^+|\^{}) /.freeze
        ABBREV = "[...]\n"

        # @param annotated_source [String] string passed to the matchers
        #
        # Separates annotation lines from source lines. Tracks the real
        # source line number that each annotation corresponds to.
        #
        # @return [AnnotatedSource]
        def self.parse(annotated_source)
          source      = []
          annotations = []

          annotated_source.each_line do |source_line|
            if ANNOTATION_PATTERN.match?(source_line)
              annotations << [source.size, source_line]
            else
              source << source_line
            end
          end
          annotations.each { |a| a[0] = 1 } if source.empty?

          new(source, annotations)
        end

        # @param lines [Array<String>]
        # @param annotations [Array<(Integer, String)>]
        #   each entry is the annotated line number and the annotation text
        #
        # @note annotations are sorted so that reconstructing the annotation
        #   text via {#to_s} is deterministic
        def initialize(lines, annotations)
          @lines       = lines.freeze
          @annotations = annotations.sort.freeze
        end

        def ==(other)
          other.is_a?(self.class) &&
            other.lines == lines &&
            match_annotations?(other)
        end

        # Dirty hack: expectations with [...] are rewritten when they match
        # This way the diff is clean.
        def match_annotations?(other)
          annotations.zip(other.annotations) do |(_actual_line, actual_annotation),
                                                 (_expected_line, expected_annotation)|
            if expected_annotation&.end_with?(ABBREV) &&
               actual_annotation.start_with?(expected_annotation[0...-ABBREV.length])

              expected_annotation.replace(actual_annotation)
            end
          end

          annotations == other.annotations
        end

        # Construct annotated source string (like what we parse)
        #
        # Reconstruct a deterministic annotated source string. This is
        # useful for eliminating semantically irrelevant annotation
        # ordering differences.
        #
        # @example standardization
        #
        #     source1 = AnnotatedSource.parse(<<-RUBY)
        #     line1
        #     ^ Annotation 1
        #      ^^ Annotation 2
        #     RUBY
        #
        #     source2 = AnnotatedSource.parse(<<-RUBY)
        #     line1
        #      ^^ Annotation 2
        #     ^ Annotation 1
        #     RUBY
        #
        #     source1.to_s == source2.to_s # => true
        #
        # @return [String]
        def to_s
          reconstructed = lines.dup

          annotations.reverse_each do |line_number, annotation|
            reconstructed.insert(line_number, annotation)
          end

          reconstructed.join
        end
        alias inspect to_s

        # Return the plain source code without annotations
        #
        # @return [String]
        def plain_source
          lines.join
        end

        # Annotate the source code with the RuboCop offenses provided
        #
        # @param offenses [Array<RuboCop::Cop::Offense>]
        #
        # @return [self]
        def with_offense_annotations(offenses)
          offense_annotations =
            offenses.map do |offense|
              indent     = ' ' * offense.column
              carets     = '^' * offense.column_length
              carets     = '^{}' if offense.column_length.zero?

              [offense.line, "#{indent}#{carets} #{offense.message}\n"]
            end

          self.class.new(lines, offense_annotations)
        end

        protected

        attr_reader :lines, :annotations
      end
    end
  end
end
