# frozen_string_literal: true

require "strings-ansi"
require "unicode/display_width"
require "unicode_utils/each_grapheme"

module Strings
  module Wrap
    DEFAULT_WIDTH = 80
    NEWLINE = "\n"
    SPACE = " "
    LINE_BREAK = %r{\r\n|\r|\n}.freeze
    LINE_BREAKS = "\r\n+|\r+|\n+"

    # Wrap a text into lines no longer than wrap_at length.
    # Preserves existing lines and existing word boundaries.
    #
    # @example
    #   Strings::Wrap.wrap("Some longish text", 8)
    #   # => "Some \nlongish \ntext"
    #
    # @api public
    def wrap(text, wrap_at = DEFAULT_WIDTH, separator: NEWLINE)
      if text.scan(/[[:print:]]/).length < wrap_at.to_i || wrap_at.to_i.zero?
        return text
      end

      ansi_stack = []
      text.lines.map do |line|
        format_line(line, wrap_at, ansi_stack).join(separator)
      end.join
    end
    module_function :wrap

    # Format line to be maximum of wrap_at length
    #
    # @param [String] text_line
    #   the line to format
    # @param [Integer] wrap_at
    #   the maximum length to wrap the line
    #
    # @return [Array[String]]
    #   the wrapped lines
    #
    # @api private
    def format_line(text_line, wrap_at, ansi_stack)
      lines = []
      line  = []
      word  = []
      ansi  = []
      ansi_matched = false
      word_length = 0
      line_length = 0
      char_length = 0 # visible char length
      text_length = display_width(text_line)
      total_length = 0

      UnicodeUtils.each_grapheme(text_line) do |char|
        # we found ansi let's consume
        if char == Strings::ANSI::CSI || ansi.length > 0
          ansi << char
          if Strings::ANSI.only_ansi?(ansi.join)
            ansi_matched = true
          elsif ansi_matched
            ansi_stack << [ansi[0...-1].join, line_length + word_length]
            ansi_matched = false

            if ansi.last == Strings::ANSI::CSI
              ansi = [ansi.last]
            else
              ansi = []
            end
          end
          next if ansi.length > 0
        end

        char_length = display_width(char)
        total_length += char_length
        if line_length + word_length + char_length <= wrap_at
          if char == SPACE || total_length == text_length
            line << word.join + char
            line_length += word_length + char_length
            word = []
            word_length = 0
          else
            word << char
            word_length += char_length
          end
          next
        end

        if char == SPACE # ends with space
          lines << insert_ansi(line.join, ansi_stack)
          line = []
          line_length = 0
          word << char
          word_length += char_length
        elsif word_length + char_length <= wrap_at
          lines << insert_ansi(line.join, ansi_stack)
          line = [word.join + char]
          line_length = word_length + char_length
          word = []
          word_length = 0
        else # hyphenate word - too long to fit a line
          lines << insert_ansi(word.join, ansi_stack)
          line_length = 0
          word = [char]
          word_length = char_length
        end
      end
      lines << insert_ansi(line.join, ansi_stack) unless line.empty?
      lines << insert_ansi(word.join, ansi_stack) unless word.empty?
      lines
    end
    module_function :format_line

    # Insert ANSI code into string
    #
    # Check if there are any ANSI states, if present
    # insert ANSI codes at given positions unwinding the stack.
    #
    # @param [String] string
    #   the string to insert ANSI codes into
    #
    # @param [Array[Array[String, Integer]]] ansi_stack
    #   the ANSI codes to apply
    #
    # @return [String]
    #
    # @api private
    def insert_ansi(string, ansi_stack = [])
      return string if ansi_stack.empty?
      return string if string.empty?

      new_stack = []
      output          = string.dup
      length          = string.size
      matched_reset   = false
      ansi_reset      = Strings::ANSI::RESET

      # Reversed so that string index don't count ansi
      ansi_stack.reverse_each do |ansi|
        if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
          matched_reset = true
          output.insert(ansi[1], ansi_reset)
          next
        elsif !matched_reset # ansi without reset
          matched_reset = false
          new_stack << ansi # keep the ansi
          next if ansi[1] == length
          if output.end_with?(NEWLINE)
            output.insert(-2, ansi_reset)
          else
            output.insert(-1, ansi_reset) # add reset at the end
          end
        end

        output.insert(ansi[1], ansi[0])
      end

      ansi_stack.replace(new_stack)

      output
    end
    module_function :insert_ansi

    # Visible width of a string
    #
    # @api private
    def display_width(string)
      Unicode::DisplayWidth.of(Strings::ANSI.sanitize(string))
    end
    module_function :display_width
  end # Wrap
end # Strings
