require "./history"
require "./expression_editor"
require "./char_reader"
require "./auto_completion"
require "./search"

module Reply
  # Reader for your REPL.
  #
  # Create a subclass of it and override methods to customize behavior.
  #
  # ```
  # class MyReader < Reply::Reader
  #   def prompt(io, line_number, color)
  #     io << "reply> "
  #   end
  # end
  # ```
  #
  # Run the REPL with `run`:
  #
  # ```
  # reader = MyReader.new
  #
  # reader.run do |expression|
  #   # Eval expression here
  #   puts " => #{expression}"
  # end
  # ```
  #
  # Or with `read_next`:
  # ```
  # loop do
  #   expression = reader.read_next
  #   break unless expression
  #
  #   # Eval expression here
  #   puts " => #{expression}"
  # end
  # ```
  class Reader
    # General architecture:
    #
    # ```
    # SDTIN -> CharReader -> Reader -> ExpressionEditor -> STDOUT
    #                        ^    ^
    #                        |    |
    #                   History  AutoCompletion
    #                   +Search
    # ```

    getter history = History.new
    getter editor : ExpressionEditor
    @auto_completion : AutoCompletion
    @char_reader = CharReader.new
    @search = Search.new
    getter line_number = 1

    delegate :color?, :color=, :lines, :output, :output=, to: @editor
    delegate :word_delimiters, :word_delimiters=, to: @editor

    def initialize
      @editor = ExpressionEditor.new do |expr_line_number, color|
        String.build do |io|
          prompt(io, @line_number + expr_line_number, color)
        end
      end

      @auto_completion = AutoCompletion.new(&->auto_complete(String, String))
      @auto_completion.set_display_title(&->auto_completion_display_title(IO, String))
      @auto_completion.set_display_entry(&->auto_completion_display_entry(IO, String, String))
      @auto_completion.set_display_selected_entry(&->auto_completion_display_selected_entry(IO, String))

      @editor.set_header do |io, previous_height|
        @auto_completion.display_entries(io, color?, max_height: {10, Term::Size.height - 1}.min, min_height: previous_height)
      end

      @editor.set_footer do |io, _previous_height|
        @search.footer(io, color?)
      end

      @editor.set_highlight(&->highlight(String))

      if file = self.history_file
        @history.load(file)
      end
    end

    # Override to customize the prompt.
    #
    # Toggle the colorization following *color*.
    #
    # default: `$:001> `
    def prompt(io : IO, line_number : Int32, color : Bool)
      io << "$:"
      io << sprintf("%03d", line_number)
      io << "> "
    end

    # Override to enable expression highlighting.
    #
    # default: uncolored `expression`
    def highlight(expression : String)
      expression
    end

    # Override this method to makes the interface continue on multiline, depending of the expression.
    #
    # default: `false`
    def continue?(expression : String)
      false
    end

    # Override to enable reformatting after submitting.
    #
    # default: unchanged `expression`
    def format(expression : String)
      nil
    end

    # Override to return the expected indentation level in function of expression before cursor.
    #
    # default: `0`
    def indentation_level(expression_before_cursor : String)
      0
    end

    # Override to select which expression is saved in history.
    #
    # default: `!expression.blank?`
    def save_in_history?(expression : String)
      !expression.blank?
    end

    # Override to indicate the `Path|String|IO` where the history is saved. If `nil`, the history is not persistent.
    #
    # default: `nil`
    def history_file
      nil
    end

    # Override with `true` to disable the reverse i-search (ctrl-r).
    #
    # default: `true` (disabled) if `history_file` not set.
    def disable_search?
      history_file.nil?
    end

    # Override to integrate auto-completion.
    #
    # *current_word* is picked following `word_delimiters`.
    # It expects to return `Tuple` with:
    # * a title : `String`
    # * the auto-completion results : `Array(String)`
    #
    # default: `{"", [] of String}`
    def auto_complete(current_word : String, expression_before : String)
      return "", [] of String
    end

    # Override to customize how title is displayed.
    #
    # default: `title` underline + `":"`
    def auto_completion_display_title(io : IO, title : String)
      @auto_completion.default_display_title(io, title)
    end

    # Override to customize how entry is displayed.
    #
    # Entry is split in two (`entry_matched` + `entry_remaining`). `entry_matched` correspond
    # to the part already typed when auto-completion was triggered.
    #
    # default: `entry_matched` bright + `entry_remaining` normal.
    def auto_completion_display_entry(io : IO, entry_matched : String, entry_remaining : String)
      @auto_completion.default_display_entry(io, entry_matched, entry_remaining)
    end

    # Override to customize how the selected entry is displayed.
    #
    # default: `entry` bright on dark grey
    def auto_completion_display_selected_entry(io : IO, entry : String)
      @auto_completion.default_display_selected_entry(io, entry)
    end

    # Override to retrigger auto completion when condition is met.
    #
    # default: `false`
    def auto_completion_retrigger_when(current_word : String) : Bool
      false
    end

    # Override to enable line re-indenting.
    #
    # This methods is called each time a character is entered.
    #
    # You should return either:
    # * `nil`: keep the line as it
    # * `Int32` value: re-indent the line by an amount equal to the returned value, relatively to `indentation_level`.
    #   (0 to follow `indentation_level`)
    #
    # See `example/crystal_repl`.
    #
    # default: `nil`
    def reindent_line(line : String)
      nil
    end

    def read_next(from io : IO = STDIN) : String? # ameba:disable Metrics/CyclomaticComplexity
      @editor.prompt_next

      loop do
        read = @char_reader.read_char(from: io)

        @editor.width, @editor.height = Term::Size.size
        case read
        in Char             then on_char(read)
        in String           then on_string(read)
        in .enter?          then on_enter { |line| return line }
        in .up?             then on_up
        in .ctrl_p?         then on_up
        in .down?           then on_down
        in .ctrl_n?         then on_down
        in .left?           then on_left
        in .ctrl_b?         then on_left
        in .right?          then on_right
        in .ctrl_f?         then on_right
        in .ctrl_up?        then on_ctrl_up { |line| return line }
        in .ctrl_down?      then on_ctrl_down { |line| return line }
        in .ctrl_left?      then on_ctrl_left { |line| return line }
        in .ctrl_right?     then on_ctrl_right { |line| return line }
        in .tab?            then on_tab
        in .shift_tab?      then on_tab(shift_tab: true)
        in .escape?         then on_escape
        in .alt_enter?      then on_enter(alt_enter: true) { }
        in .ctrl_enter?     then on_enter(ctrl_enter: true) { }
        in .alt_backspace?  then @editor.update { word_back }
        in .ctrl_backspace? then @editor.update { word_back }
        in .backspace?      then on_back
        in .home?, .ctrl_a? then on_begin
        in .end?, .ctrl_e?  then on_end
        in .delete?         then @editor.update { delete }
        in .ctrl_k?         then @editor.update { delete_after_cursor }
        in .ctrl_u?         then @editor.update { delete_before_cursor }
        in .alt_f?          then @editor.move_word_forward
        in .alt_b?          then @editor.move_word_backward
        in .ctrl_delete?    then @editor.update { delete_word }
        in .alt_d?          then @editor.update { delete_word }
        in .ctrl_c?         then on_ctrl_c
        in .ctrl_r?         then on_ctrl_r
        in .ctrl_d?
          if @editor.empty?
            output.puts
            return nil
          else
            @editor.update { delete }
          end
        in .eof?, .ctrl_x?
          output.puts
          return nil
        end

        if (read.is_a?(CharReader::Sequence) && (read.ctrl_r? || read.backspace?)) || read.is_a?(Char) || read.is_a?(String)
        else
          @search.close
          @editor.update
        end

        if read.is_a?(CharReader::Sequence) && (read.tab? || read.enter? || read.alt_enter? || read.shift_tab? || read.escape? || read.backspace? || read.ctrl_c?)
        else
          if @auto_completion.open?
            replacement = auto_complete_insert_char(read)
            # Replace the current_word by the replacement word
            @editor.update do
              @editor.current_word = replacement if replacement
            end
          end
        end
      end
    end

    def read_loop(& : String -> _)
      loop do
        yield read_next || break
      end
    end

    # Reset the line number and close auto-completion results.
    def reset
      @line_number = 1
      @auto_completion.close
    end

    # Clear the history and the `history_file`.
    def clear_history
      @history.clear
      if file = self.history_file
        @history.save(file)
      end
    end

    private def on_char(char)
      return search_and_replace(@search.query + char) if @search.open?

      @editor.update do
        @editor << char
        line = @editor.current_line.rstrip(' ')

        if @editor.x == line.size
          # Re-indent line after typing a char.
          if shift = self.reindent_line(line)
            indent = self.indentation_level(@editor.expression_before_cursor)
            new_indent = (indent + shift).clamp 0..
            @editor.current_line = "  "*new_indent + @editor.current_line.lstrip(' ')
          end
        end
      end
    end

    private def on_string(string)
      return search_and_replace(@search.query + string) if @search.open?

      @editor.update do
        @editor << string
      end
    end

    private def on_enter(alt_enter = false, ctrl_enter = false, &)
      @auto_completion.close
      if @search.open?
        @search.close
        @editor.update
        return
      end

      if alt_enter || ctrl_enter || (@editor.cursor_on_last_line? && continue?(@editor.expression))
        @editor.update do
          insert_new_line(indent: self.indentation_level(@editor.expression_before_cursor))
        end
      else
        submit_expr
        yield @editor.expression
      end
    end

    private def on_up
      has_moved = @editor.move_cursor_up

      if !has_moved && (new_lines = @history.up(@editor.lines))
        @editor.replace(new_lines)
        @editor.move_cursor_to_end
      end
    end

    private def on_down
      has_moved = @editor.move_cursor_down

      if !has_moved && (new_lines = @history.down(@editor.lines))
        @editor.replace(new_lines)
        @editor.move_cursor_to_end_of_line(y: 0)
      end
    end

    private def on_left
      @editor.move_cursor_left
    end

    private def on_right
      @editor.move_cursor_right
    end

    private def on_back
      return search_and_replace(@search.query.rchop) if @search.open?

      auto_complete_remove_char if @auto_completion.open?
      @editor.update { back }
    end

    # If overridden, can yield an expression to giveback to `run`.
    # This is made because the `PryInterface` in `IC` can override these hotkeys and yield
    # command like `step`/`next`.
    #
    # TODO: It need a proper design to override hotkeys.
    private def on_ctrl_up(& : String ->)
      @editor.scroll_down
    end

    private def on_ctrl_down(& : String ->)
      @editor.scroll_up
    end

    private def on_ctrl_left(& : String ->)
      @editor.move_word_backward
    end

    private def on_ctrl_right(& : String ->)
      @editor.move_word_forward
    end

    private def on_ctrl_c
      @auto_completion.close
      @search.close
      @editor.end_editing
      output.puts "^C"
      @history.set_to_last
      @editor.prompt_next
    end

    private def on_ctrl_r
      return if disable_search?

      @auto_completion.close
      @search.open
      search_and_replace(reuse_index: true)
    end

    private def on_tab(shift_tab = false)
      if @auto_completion.open?
        if shift_tab
          replacement = @auto_completion.selection_previous
        else
          replacement = @auto_completion.selection_next
        end
      else
        replacement = compute_completions
      end

      # Replace the current_word by the replacement word
      if replacement
        @editor.update { @editor.current_word = replacement }
      end
    end

    private def on_escape
      @auto_completion.close
      @search.close
      @editor.update
    end

    private def on_begin
      @editor.move_cursor_to_begin
    end

    private def on_end
      @editor.move_cursor_to_end
    end

    private def compute_completions : String?
      line = @editor.current_line

      # Retrieve the word under the cursor
      word_begin, word_end = @editor.current_word_begin_end
      current_word = line[word_begin..word_end]

      expr = @editor.expression_before_cursor(x: word_begin)

      # Compute auto-completion, return `replacement` (`nil` if no entry, full name if only one entry, or the begin match of entries otherwise)
      replacement = @auto_completion.complete_on(current_word, expr)

      if replacement
        if @auto_completion.entries.size >= 2
          @auto_completion.open
        else
          @auto_completion.name_filter = replacement
        end
      end

      replacement
    end

    private def auto_complete_insert_char(char) : String?
      if char.is_a? Char && !char.in?(@editor.word_delimiters)
        @auto_completion.name_filter = current_word = @editor.current_word

        return compute_completions if auto_completion_retrigger_when(current_word + char)
      elsif @editor.expression_scrolled? || char.is_a?(String)
        @auto_completion.close
      else
        @auto_completion.clear
      end
      nil
    end

    private def auto_complete_remove_char
      char = @editor.current_line[@editor.x - 1]?
      if !char.in?(@editor.word_delimiters)
        @auto_completion.name_filter = @editor.current_word[...-1]
      else
        @auto_completion.clear
      end
    end

    private def search_and_replace(query = nil, reuse_index = false)
      @search.query = query if query

      from_index = reuse_index ? @history.index - 1 : @history.size - 1

      result = @search.search(@history, from_index)
      if result
        @editor.replace(result.result)

        @editor.move_cursor_to(result.x + @search.query.size, result.y)
      else
        @editor.replace([""])
      end
    end

    private def submit_expr(*, history = true)
      formated = format(@editor.expression).try &.split('\n')
      @editor.end_editing(replacement: formated)

      @line_number += @editor.lines.size
      if history && save_in_history?(@editor.expression)
        @history << @editor.lines
      else
        @history.set_to_last
      end
      if file = self.history_file
        @history.save(file)
      end
    end
  end
end
