# frozen_string_literal: true

module SyntaxSuggest
  # Turns a "invalid block(s)" into useful context
  #
  # There are three main phases in the algorithm:
  #
  # 1. Sanitize/format input source
  # 2. Search for invalid blocks
  # 3. Format invalid blocks into something meaninful
  #
  # This class handles the third part.
  #
  # The algorithm is very good at capturing all of a syntax
  # error in a single block in number 2, however the results
  # can contain ambiguities. Humans are good at pattern matching
  # and filtering and can mentally remove extraneous data, but
  # they can't add extra data that's not present.
  #
  # In the case of known ambiguious cases, this class adds context
  # back to the ambiguitiy so the programmer has full information.
  #
  # Beyond handling these ambiguities, it also captures surrounding
  # code context information:
  #
  #   puts block.to_s # => "def bark"
  #
  #   context = CaptureCodeContext.new(
  #     blocks: block,
  #     code_lines: code_lines
  #   )
  #
  #   lines = context.call.map(&:original)
  #   puts lines.join
  #   # =>
  #     class Dog
  #       def bark
  #     end
  #
  class CaptureCodeContext
    attr_reader :code_lines

    def initialize(blocks:, code_lines:)
      @blocks = Array(blocks)
      @code_lines = code_lines
      @visible_lines = @blocks.map(&:visible_lines).flatten
      @lines_to_output = @visible_lines.dup
    end

    def call
      @blocks.each do |block|
        capture_first_kw_end_same_indent(block)
        capture_last_end_same_indent(block)
        capture_before_after_kws(block)
        capture_falling_indent(block)
      end

      @lines_to_output.select!(&:not_empty?)
      @lines_to_output.uniq!
      @lines_to_output.sort!

      @lines_to_output
    end

    # Shows the context around code provided by "falling" indentation
    #
    # Converts:
    #
    #       it "foo" do
    #
    # into:
    #
    #   class OH
    #     def hello
    #       it "foo" do
    #     end
    #   end
    #
    #
    def capture_falling_indent(block)
      AroundBlockScan.new(
        block: block,
        code_lines: @code_lines
      ).on_falling_indent do |line|
        @lines_to_output << line
      end
    end

    # Shows surrounding kw/end pairs
    #
    # The purpose of showing these extra pairs is due to cases
    # of ambiguity when only one visible line is matched.
    #
    # For example:
    #
    #     1  class Dog
    #     2    def bark
    #     4    def eat
    #     5    end
    #     6  end
    #
    # In this case either line 2 could be missing an `end` or
    # line 4 was an extra line added by mistake (it happens).
    #
    # When we detect the above problem it shows the issue
    # as only being on line 2
    #
    #     2    def bark
    #
    # Showing "neighbor" keyword pairs gives extra context:
    #
    #     2    def bark
    #     4    def eat
    #     5    end
    #
    def capture_before_after_kws(block)
      return unless block.visible_lines.count == 1

      around_lines = AroundBlockScan.new(code_lines: @code_lines, block: block)
        .start_at_next_line
        .capture_neighbor_context

      around_lines -= block.lines

      @lines_to_output.concat(around_lines)
    end

    # When there is an invalid block with a keyword
    # missing an end right before another end,
    # it is unclear where which keyword is missing the
    # end
    #
    # Take this example:
    #
    #   class Dog       # 1
    #     def bark      # 2
    #       puts "woof" # 3
    #   end             # 4
    #
    # However due to https://github.com/ruby/syntax_suggest/issues/32
    # the problem line will be identified as:
    #
    #  > class Dog       # 1
    #
    # Because lines 2, 3, and 4 are technically valid code and are expanded
    # first, deemed valid, and hidden. We need to un-hide the matching end
    # line 4. Also work backwards and if there's a mis-matched keyword, show it
    # too
    def capture_last_end_same_indent(block)
      return if block.visible_lines.length != 1
      return unless block.visible_lines.first.is_kw?

      visible_line = block.visible_lines.first
      lines = @code_lines[visible_line.index..block.lines.last.index]

      # Find first end with same indent
      # (this would return line 4)
      #
      #   end             # 4
      matching_end = lines.detect { |line| line.indent == block.current_indent && line.is_end? }
      return unless matching_end

      @lines_to_output << matching_end

      # Work backwards from the end to
      # see if there are mis-matched
      # keyword/end pairs
      #
      # Return the first mis-matched keyword
      # this would find line 2
      #
      #     def bark      # 2
      #       puts "woof" # 3
      #   end             # 4
      end_count = 0
      kw_count = 0
      kw_line = @code_lines[visible_line.index..matching_end.index].reverse.detect do |line|
        end_count += 1 if line.is_end?
        kw_count += 1 if line.is_kw?

        !kw_count.zero? && kw_count >= end_count
      end
      return unless kw_line
      @lines_to_output << kw_line
    end

    # The logical inverse of `capture_last_end_same_indent`
    #
    # When there is an invalid block with an `end`
    # missing a keyword right after another `end`,
    # it is unclear where which end is missing the
    # keyword.
    #
    # Take this example:
    #
    #   class Dog       # 1
    #       puts "woof" # 2
    #     end           # 3
    #   end             # 4
    #
    # the problem line will be identified as:
    #
    #  > end            # 4
    #
    # This happens because lines 1, 2, and 3 are technically valid code and are expanded
    # first, deemed valid, and hidden. We need to un-hide the matching keyword on
    # line 1. Also work backwards and if there's a mis-matched end, show it
    # too
    def capture_first_kw_end_same_indent(block)
      return if block.visible_lines.length != 1
      return unless block.visible_lines.first.is_end?

      visible_line = block.visible_lines.first
      lines = @code_lines[block.lines.first.index..visible_line.index]
      matching_kw = lines.reverse.detect { |line| line.indent == block.current_indent && line.is_kw? }
      return unless matching_kw

      @lines_to_output << matching_kw

      kw_count = 0
      end_count = 0
      orphan_end = @code_lines[matching_kw.index..visible_line.index].detect do |line|
        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?

        end_count >= kw_count
      end

      return unless orphan_end
      @lines_to_output << orphan_end
    end
  end
end
