# frozen_string_literal: true

module SyntaxSuggest
  # Represents a single line of code of a given source file
  #
  # This object contains metadata about the line such as
  # amount of indentation, if it is empty or not, and
  # lexical data, such as if it has an `end` or a keyword
  # in it.
  #
  # Visibility of lines can be toggled off. Marking a line as invisible
  # indicates that it should not be used for syntax checks.
  # It's functionally the same as commenting it out.
  #
  # Example:
  #
  #   line = CodeLine.from_source("def foo\n").first
  #   line.number => 1
  #   line.empty? # => false
  #   line.visible? # => true
  #   line.mark_invisible
  #   line.visible? # => false
  #
  class CodeLine
    TRAILING_SLASH = ("\\" + $/).freeze

    # Returns an array of CodeLine objects
    # from the source string
    def self.from_source(source, lines: nil)
      lines ||= source.lines
      lex_array_for_line = LexAll.new(source: source, source_lines: lines).each_with_object(Hash.new { |h, k| h[k] = [] }) { |lex, hash| hash[lex.line] << lex }
      lines.map.with_index do |line, index|
        CodeLine.new(
          line: line,
          index: index,
          lex: lex_array_for_line[index + 1]
        )
      end
    end

    attr_reader :line, :index, :lex, :line_number, :indent
    def initialize(line:, index:, lex:)
      @lex = lex
      @line = line
      @index = index
      @original = line
      @line_number = @index + 1
      strip_line = line.dup
      strip_line.lstrip!

      @indent = if (@empty = strip_line.empty?)
        line.length - 1 # Newline removed from strip_line is not "whitespace"
      else
        line.length - strip_line.length
      end

      set_kw_end
    end

    # Used for stable sort via indentation level
    #
    # Ruby's sort is not "stable" meaning that when
    # multiple elements have the same value, they are
    # not guaranteed to return in the same order they
    # were put in.
    #
    # So when multiple code lines have the same indentation
    # level, they're sorted by their index value which is unique
    # and consistent.
    #
    # This is mostly needed for consistency of the test suite
    def indent_index
      @indent_index ||= [indent, index]
    end
    alias_method :number, :line_number

    # Returns true if the code line is determined
    # to contain a keyword that matches with an `end`
    #
    # For example: `def`, `do`, `begin`, `ensure`, etc.
    def is_kw?
      @is_kw
    end

    # Returns true if the code line is determined
    # to contain an `end` keyword
    def is_end?
      @is_end
    end

    # Used to hide lines
    #
    # The search alorithm will group lines into blocks
    # then if those blocks are determined to represent
    # valid code they will be hidden
    def mark_invisible
      @line = ""
    end

    # Means the line was marked as "invisible"
    # Confusingly, "empty" lines are visible...they
    # just don't contain any source code other than a newline ("\n").
    def visible?
      !line.empty?
    end

    # Opposite or `visible?` (note: different than `empty?`)
    def hidden?
      !visible?
    end

    # An `empty?` line is one that was originally left
    # empty in the source code, while a "hidden" line
    # is one that we've since marked as "invisible"
    def empty?
      @empty
    end

    # Opposite of `empty?` (note: different than `visible?`)
    def not_empty?
      !empty?
    end

    # Renders the given line
    #
    # Also allows us to represent source code as
    # an array of code lines.
    #
    # When we have an array of code line elements
    # calling `join` on the array will call `to_s`
    # on each element, which essentially converts
    # it back into it's original source string.
    def to_s
      line
    end

    # When the code line is marked invisible
    # we retain the original value of it's line
    # this is useful for debugging and for
    # showing extra context
    #
    # DisplayCodeWithLineNumbers will render
    # all lines given to it, not just visible
    # lines, it uses the original method to
    # obtain them.
    attr_reader :original

    # Comparison operator, needed for equality
    # and sorting
    def <=>(other)
      index <=> other.index
    end

    # [Not stable API]
    #
    # Lines that have a `on_ignored_nl` type token and NOT
    # a `BEG` type seem to be a good proxy for the ability
    # to join multiple lines into one.
    #
    # This predicate method is used to determine when those
    # two criteria have been met.
    #
    # The one known case this doesn't handle is:
    #
    #     Ripper.lex <<~EOM
    #       a &&
    #        b ||
    #        c
    #     EOM
    #
    # For some reason this introduces `on_ignore_newline` but with BEG type
    def ignore_newline_not_beg?
      @ignore_newline_not_beg
    end

    # Determines if the given line has a trailing slash
    #
    #     lines = CodeLine.from_source(<<~EOM)
    #       it "foo" \
    #     EOM
    #     expect(lines.first.trailing_slash?).to eq(true)
    #
    def trailing_slash?
      last = @lex.last

      # Older versions of prism diverged slightly from Ripper in compatibility mode
      case last&.type
      when :on_sp
        last.token == TRAILING_SLASH
      when :on_tstring_end
        true
      else
        false
      end
    end

    # Endless method detection
    #
    # From https://github.com/ruby/irb/commit/826ae909c9c93a2ddca6f9cfcd9c94dbf53d44ab
    # Detecting a "oneliner" seems to need a state machine.
    # This can be done by looking mostly at the "state" (last value):
    #
    #   ENDFN -> BEG (token = '=' ) -> END
    #
    private def set_kw_end
      oneliner_count = 0
      in_oneliner_def = nil

      kw_count = 0
      end_count = 0

      @ignore_newline_not_beg = false
      @lex.each do |lex|
        kw_count += 1 if lex.is_kw?
        end_count += 1 if lex.is_end?

        if lex.type == :on_ignored_nl
          @ignore_newline_not_beg = !lex.expr_beg?
        end

        if in_oneliner_def.nil?
          in_oneliner_def = :ENDFN if lex.state.allbits?(Ripper::EXPR_ENDFN)
        elsif lex.state.allbits?(Ripper::EXPR_ENDFN)
          # Continue
        elsif lex.state.allbits?(Ripper::EXPR_BEG)
          in_oneliner_def = :BODY if lex.token == "="
        elsif lex.state.allbits?(Ripper::EXPR_END)
          # We found an endless method, count it
          oneliner_count += 1 if in_oneliner_def == :BODY

          in_oneliner_def = nil
        else
          in_oneliner_def = nil
        end
      end

      kw_count -= oneliner_count

      @is_kw = (kw_count - end_count) > 0
      @is_end = (end_count - kw_count) > 0
    end
  end
end
