require 'io/console'

module CLI
  module UI
    module Prompt
      class InteractiveOptions
        DONE = "Done"
        CHECKBOX_ICON = { false => "☐", true => "☑" }

        # Prompts the user with options
        # Uses an interactive session to allow the user to pick an answer
        # Can use arrows, y/n, numbers (1/2), and vim bindings to control
        #
        # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
        #
        # ==== Example Usage:
        #
        # Ask an interactive question
        #   CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
        #
        def self.call(options, multiple: false)
          list = new(options, multiple: multiple)
          selected = list.call
          if multiple
            selected.map { |s| options[s - 1] }
          else
            options[selected - 1]
          end
        end

        # Initializes a new +InteractiveOptions+
        # Usually called from +self.call+
        #
        # ==== Example Usage:
        #
        #   CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
        #
        def initialize(options, multiple: false)
          @options = options
          @active = 1
          @marker = '>'
          @answer = nil
          @state = :root
          @multiple = multiple
          # 0-indexed array representing if selected
          # @options[0] is selected if @chosen[0]
          @chosen = Array.new(@options.size) { false } if multiple
          @redraw = true
        end

        # Calls the +InteractiveOptions+ and asks the question
        # Usually used from +self.call+
        #
        def call
          CLI::UI.raw { print(ANSI.hide_cursor) }
          while @answer.nil?
            render_options
            process_input_until_redraw_required
            reset_position
          end
          clear_output
          @answer
        ensure
          CLI::UI.raw do
            print(ANSI.show_cursor)
          end
        end

        private

        def reset_position
          # This will put us back at the beginning of the options
          # When we redraw the options, they will be overwritten
          CLI::UI.raw do
            num_lines.times { print(ANSI.previous_line) }
          end
        end

        def clear_output
          CLI::UI.raw do
            # Write over all lines with whitespace
            num_lines.times { puts(' ' * CLI::UI::Terminal.width) }
          end
          reset_position
        end

        def num_lines
          options = presented_options.map(&:first)
          # @options will be an array of questions but each option can be multi-line
          # so to get the # of lines, you need to join then split

          # empty_option_count is needed since empty option titles are omitted
          # from the line count when reject(&:empty?) is called

          empty_option_count = options.count(&:empty?)
          joined_options = options.join("\n")
          joined_options.split("\n").reject(&:empty?).size + empty_option_count
        end

        ESC = "\e"

        def up
          min_pos = @multiple ? 0 : 1
          @active = @active - 1 >= min_pos ? @active - 1 : @options.length
          @redraw = true
        end

        def down
          min_pos = @multiple ? 0 : 1
          @active = @active + 1 <= @options.length ? @active + 1 : min_pos
          @redraw = true
        end

        # n is 1-indexed selection
        # n == 0 if "Done" was selected in @multiple mode
        def select_n(n)
          if @multiple
            if n == 0
              @answer = []
              @chosen.each_with_index do |selected, i|
                @answer << i + 1 if selected
              end
            else
              @active = n
              @chosen[n - 1] = !@chosen[n - 1]
            end
          elsif n == 0
            # Ignore pressing "0" when not in multiple mode
          else
            @active = n
            @answer = n
          end
          @redraw = true
        end

        def select_bool(char)
          return unless (@options - %w(yes no)).empty?
          opt = @options.detect { |o| o.start_with?(char) }
          @active = @options.index(opt) + 1
          @answer = @options.index(opt) + 1
          @redraw = true
        end

        def select_current
          select_n(@active)
        end

        def process_input_until_redraw_required
          @redraw = false
          wait_for_user_input until @redraw
        end

        # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
        def wait_for_user_input
          char = read_char
          case @state
          when :root
            case char
            when :timeout                  ; raise Interrupt # Timeout, use interrupt to simulate
            when ESC                       ; @state = :esc
            when 'k'                       ; up
            when 'j'                       ; down
            when '0'                       ; select_n(char.to_i)
            when ('1'..@options.size.to_s) ; select_n(char.to_i)
            when 'y', 'n'                  ; select_bool(char)
            when " ", "\r", "\n"           ; select_current  # <enter>
            when "\u0003"                  ; raise Interrupt # Ctrl-c
            end
          when :esc
            case char
            when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
            when '['      ; @state = :esc_bracket
            else          ; raise Interrupt # unhandled escape sequence.
            end
          when :esc_bracket
            @state = :root
            case char
            when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
            when 'A'      ; up
            when 'B'      ; down
            else          ; raise Interrupt # unhandled escape sequence.
            end
          end
        end
        # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon

        def read_char
          raw_tty! do
            getc = $stdin.getc
            getc ? getc.chr : :timeout
          end
        rescue IOError
          "\e"
        end

        def raw_tty!
          if ENV['TEST'] || !$stdin.tty?
            yield
          else
            $stdin.raw { yield }
          end
        end

        def presented_options(recalculate: false)
          return @presented_options unless recalculate

          @presented_options = @options.zip(1..Float::INFINITY)
          @presented_options.unshift([DONE, 0]) if @multiple

          while num_lines > max_options
            # try to keep the selection centered in the window:
            if distance_from_selection_to_end > distance_from_start_to_selection
              # selection is closer to top than bottom, so trim a row from the bottom
              ensure_last_item_is_continuation_marker
              @presented_options.delete_at(-2)
            else
              # selection is closer to bottom than top, so trim a row from the top
              ensure_first_item_is_continuation_marker
              @presented_options.delete_at(1)
            end
          end

          @presented_options
        end

        def distance_from_selection_to_end
          last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last
          last_visible_option_number - @active
        end

        def distance_from_start_to_selection
          first_visible_option_number = @presented_options[0].last || @presented_options[1].last
          @active - first_visible_option_number
        end

        def ensure_last_item_is_continuation_marker
          @presented_options.push(["...", nil]) if @presented_options.last.last
        end

        def ensure_first_item_is_continuation_marker
          @presented_options.unshift(["...", nil]) if @presented_options.first.last
        end

        def max_options
          @max_options ||= CLI::UI::Terminal.height - 2 # Keeps a one line question visible
        end

        def render_options
          max_num_length = (@options.size + 1).to_s.length

          presented_options(recalculate: true).each do |choice, num|
            is_chosen = @multiple && num && @chosen[num - 1]

            padding = ' ' * (max_num_length - num.to_s.length)
            message = "  #{num}#{num ? '.' : ' '}#{padding}"

            format = "%s"
            # If multiple, bold only selected. If not multiple, bold everything
            format = "{{bold:#{format}}}" if !@multiple || is_chosen
            format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
            format = " #{format}"

            message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
            message += choice.split("\n").map { |l| sprintf(format, l) }.join("\n")

            if num == @active
              message = message.split("\n").map.with_index do |l, idx|
                idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
              end.join("\n")
            end

            CLI::UI.with_frame_color(:blue) do
              puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line
            end
          end
        end
      end
    end
  end
end