module VER
  class Text
    class Mark < Struct.new(:buffer, :name)
      include Position

      MATCHING_BRACE_RIGHT = {
        '(' => ')',
        '{' => '}',
        '[' => ']',
        '<' => '>'
      }
      MATCHING_BRACE_LEFT = MATCHING_BRACE_RIGHT.invert

      def initialize(buffer, name, index = nil)
        self.buffer = buffer
        self.name = name
        self.index = index unless index.nil?
      end

      # @return [Tk::TclString] tcl representation of {name}
      def to_tcl
        name.to_tcl
      end

      # @return [String] representation of {name}
      def to_s
        name.to_s
      end

      # @return [String] representation of {name} and {buffer}
      def inspect
        format('#<VER::Text::Mark %p on %p>', name, buffer)
      end

      # Number of characters from the start of the line.
      # Counting characters starts at zero.
      #
      # @return [Integer] Number of characters from the line-start.
      def char
        index.char
      end

      # The number of lines from the top of {buffer}.
      # Counting lines starts at one.
      #
      # @return [Integer] Number of lines from top of buffer.
      def line
        index.line
      end

      def line=(line)
        set("#{line}.#{char}")
      end

      def char=(char)
        set("#{line}.#{char}")
      end

      def gravity=(direction)
        buffer.mark_gravity(self, direction)
      end

      def gravity
        buffer.mark_gravity(self)
      end

      def set(index)
        buffer.mark_set(self, index)
      end
      alias index= set

      def unset
        buffer.mark_unset(self)
      end

      def next
        buffer.mark_next(self)
      end

      def previous
        buffer.mark_previous(self)
      end

      def index
        buffer.index(self)
      end

      # Create a Range from a "virtual" movement.
      # This means that you pass a block that changes the position of the insert
      # mark, and this method will return a Range consists of the old and new
      # position.
      # The method yields self, so you can use the Symbol#to_proc shortcut as
      # well as predefined blocks or other methods that take the Insert as
      # argument.
      #
      # @example usage
      #   insert = buffer.at_insert
      #   insert.virtual(&:next_char).delete # delete to next character
      #   insert.virtual{|ins| ins.last_char }.copy # copy until end of line
      def virtual(*args)
        pos = index
        yield(self, *args)
        mark = index
        set(pos)
        Range.new(buffer, *[pos, mark].sort)
      end

      def virtual_motion(motion, *args)
        virtual do |mark|
          if motion.respond_to?(:call)
            motion.call(buffer, *args)
          else
            mark.send(motion, *args)
          end
        end
      end

      def copying(motion, *args)
        virtual_motion(motion, *args).copy
      end

      def changing(motion, *args)
        virtual_motion(motion, *args).change
      end

      # if the motion is up-down, we might want to kill whole lines?
      # that's what vim does, but i don't find it very intuitive or easy to
      # implement.
      def killing(motion, *args)
        virtual_motion(motion, *args).kill
      end

      def deleting(motion, *args)
        virtual_motion(motion, *args).delete
      end

      def toggle_casing(motion, *args)
        virtual_motion(motion, *args).toggle_case!
      end

      def lower_casing(motion, *args)
        virtual_motion(motion, *args).lower_case!
      end

      def upper_casing(motion, *args)
        virtual_motion(motion, *args).upper_case!
      end

      def encoding_rot13(motion, *args)
        virtual_motion(motion, *args).encode_rot13!
      end

      # {word_right_end} goes to the last character, that is, the insert mark is
      # between the second to last and last character.
      # This means that the range to delete is off by one, account for it here.
      def change_next_word_end
        range = buffer.at_insert.virtual(&:next_word_end)
        range.last += '1 chars'
        range.kill
        buffer.minor_mode(:control, :insert)
      end

      def change_at(motion, *args)
        if motion.respond_to?(:call)
          motion.call(buffer, *args)
        else
          send(motion, *args)
        end

        buffer.minor_mode(:control, :insert)
      end

      def insert(string, *rest)
        buffer.insert(self, string, *rest)
      end

      def <<(string)
        buffer.insert(self, string)
        self
      end

      def delete(amount)
        case amount
        when Integer
          buffer.delete(self, self + "#{amount} chars")
        when Float, String, Symbol
          buffer.delete(self, amount)
        else
          raise ArgumentError
        end
      end

      def insert_selection
        self << Tk::Selection.get(type: 'UTF8_STRING')
      end

      def insert_tab
        self << "\t"
      end

      def insert_newline
        if buffer.options.autoindent
          insert_indented_newline
        else
          self << "\n"
        end
      end

      def insert_indented_newline
        buffer.undo_record do |record|
          indent1 = get('linestart', 'lineend')[/^\s*/]
          record.insert(self, "\n")

          indent2 = get('linestart', 'lineend')[/^\s*/]
          record.replace(
            "#{self} linestart",
            "#{self} linestart + #{indent2.size} chars",
            indent1
          )

          Methods::Control.clean_line(buffer, "#{self} - 1 line", record)
        end
      end

      def insert_newline_below
        return insert_indented_newline_below if buffer.options.autoindent
        set "#{self} lineend"
        self << "\n"
      end

      def insert_indented_newline_below
        line = get('linestart', 'lineend')
        indent = line[/^\s*/]
        set "#{self} lineend"
        self << "\n#{indent}"
      end

      def insert_newline_above
        if char > 1
          set(self - '1 line')
          insert_newline_below
        else
          buffer.undo_record do |record|
            record.insert("#{self} linestart", "\n")
            set "#{self} - 1 line"
            Methods::Control.clean_line(buffer, "#{self} - 1 line", record)
          end
        end
      end

      # Set mark to be +count+ display-chars to the right.
      # Stays on the same line.
      def next_char(count = buffer.prefix_count)
        return if self == lineend
        set(self + "#{count} displaychars")
      end

      def next_word(count = buffer.prefix_count)
        forward_jump(count, &method(:word_char_type))
      end

      def next_chunk(count = buffer.prefix_count)
        forward_jump(count, &method(:chunk_char_type))
      end

      # Jump to the last character of the word the insert cursor is over
      # currently.
      def next_word_end(count = buffer.prefix_count)
        forward_jump_end(count, &method(:word_char_type))
      end

      # Jump to the last character of the chunk the insert cursor is over
      # currently.
      def next_chunk_end(count = buffer.prefix_count)
        forward_jump_end(count, &method(:chunk_char_type))
      end

      # Set mark to be +count+ display-chars to the left.
      # Jumps to previous line when on first character of a line.
      def prev_char(count = buffer.prefix_count)
        return if self == linestart
        set(self - "#{count} displaychars")
      end

      def prev_word(count = buffer.prefix_count)
        backward_jump(count, &method(:word_char_type))
      end

      def prev_chunk(count = buffer.prefix_count)
        backward_jump(count, &method(:chunk_char_type))
      end

      def prev_word_end(count = buffer.prefix_count)
        set(index_at_word_left_end(count))
      end

      def first_line(line = buffer.prefix_count)
        go_first_nonblank(buffer.index("#{line}.0"))
      end

      def last_line(line = buffer.prefix_count(:end))
        if line == :end
          go_first_nonblank(buffer.index('end - 1 chars'))
        else
          go_first_nonblank(buffer.index("#{line}.0"))
        end
      end

      def go_first_nonblank(index)
        line = index.get('linestart', 'lineend')

        if first_nonblank = (line =~ /\S/)
          set("#{index.line}.#{first_nonblank}")
        else
          set("#{index.line}.0")
        end
      end

      def go_char(char = buffer.prefix_count(0))
        set("#{self} linestart + #{char} display chars")
      end

      def go_line_char(line = nil, char = nil)
        set("#{line || self.line}.#{char || self.char}")
      end

      # Go to {count} percentage in the file, on the first non-blank in the line
      # linewise. To compute the new line number this formula is used:
      # ({count} * number-of-lines + 99) / 100
      def go_percent(count = buffer.prefix_count(nil))
        raise ArgumentError unless count
        number_of_lines = buffer.count('1.0', 'end', :lines)
        line = (count * number_of_lines + 99) / 100
        go_first_nonblank(buffer.index("#{line}.0"))
      end

      def down_nonblank(count = buffer.prefix_count)
        offset = (count - 1).abs
        go_first_nonblank(buffer.index("insert + #{offset} lines"))
      end

      def prev_line_nonblank(count = buffer.prefix_count)
        go_first_nonblank(buffer.index("insert - #{count} lines"))
      end

      def next_line_nonblank(count = buffer.prefix_count)
        go_first_nonblank(buffer.index("insert + #{count} lines"))
      end

      def start_of_buffer
        set('1.0')
      end

      def end_of_buffer(count = nil)
        if count
          set("#{count}.0")
        else
          set(:end)
        end
      end

      # Move to the end of the line where mark is located.
      #
      # With +count+ it moves to the end of line +count+ lines below.
      def last_char(count = buffer.prefix_count)
        set("#{self} + #{count - 1} lines lineend")
      end

      # Move to the middle of the display line.
      # Vim moves to the middle of the screen width...
      # not so useful, but in order to be compatible, do that instead.
      def middle_of_line
        x, y, width, height, baseline = *buffer.dlineinfo(self)
        middle = width / 2
        set("@#{middle},#{y}")
      end

      # Move to the beginning of the line in which insert mark is located.
      #
      # With +count+ it will move to the beginning of the display line, which
      # takes line wraps into account.
      def start_of_line(alternative = buffer.prefix_arg)
        if alternative
          set("#{self} display linestart")
        else
          set("#{self} linestart")
        end
      end

      # Move to the non-blank character of the line in which insert mark is located.
      def home_of_line
        char = get('linestart', 'lineend').index(/\S/) || 0
        self.char = char
      end

      def end_of_sentence(count = buffer.prefix_count)
        buffer.search_all(/\.\s/, self) do |_match, _from, to|
          set("#{to} - 1 chars")
          count -= 1
          return if count <= 0
        end
      end

      def matching_brace(count = buffer.prefix_count)
        from, to = matching_brace_indices(count)
        index = self.index

        if index < from
          set(from)
        elsif index == from
          set(to)
        elsif index > to
          set(to)
        elsif index == to
          set(from)
        else
          if from.delta(index) < to.delta(index)
            set(from)
          else
            set(to)
          end
        end
      end

      def matching_brace_indices(count = 1)
        needle = Regexp.union(MATCHING_BRACE_RIGHT.to_a.flatten.sort.uniq)
        from = buffer.search_all(needle, self) { |_match, range| break range }
        return unless from

        opening = from.get

        if closing = MATCHING_BRACE_RIGHT[opening]
          start = from + '1 chars'
          search = buffer.method(:search_all)
        elsif closing = MATCHING_BRACE_LEFT[opening]
          start = from.index
          search = buffer.method(:rsearch_all)
        else
          return
        end

        balance = count
        needle = Regexp.union(opening, closing)
        search.call(needle, start) do |match, range|
          case match
          when opening
            balance += 1
          when closing
            balance -= 1
          end

          return [from, range].sort if balance == 0
        end
      end

      def word_char_type(char)
        case char
        when /\w/ then :word
        when /\S/ then :special
        when /\s/ then :space
        else
          Kernel.raise format('No matching char type for: %p', char)
        end
      end

      def chunk_char_type(char)
        case char
        when /\S/ then :nonspace
        when /\s/ then :space
        else
          Kernel.raise format('No matching chunk type for: %p ', char)
        end
      end

      def forward_jump(count)
        count.times do
          original_type = type = yield(get)
          changed = 0

          begin
            original_pos = index
            buffer.execute_only(:mark, :set, self, "#{self} + 1 chars")
            break if original_pos == index

            type = yield(get)
            changed += 1 if type != original_type
            original_type = type
          end until changed > 0 && type != :space
        end
      rescue StandardError => ex
        VER.error(ex)
      end

      def forward_jump_end(count)
        offset = 1
        last = buffer.index('end')

        count.times do
          pos = buffer.index("#{self} + #{offset} chars")

          return if pos == last

          type = yield(pos.get)

          while type == :space
            offset += 1
            pos = buffer.index("#{self} + #{offset} chars")
            break if pos == last
            type = yield(pos.get)
          end

          lock = type

          while type == lock && type != :space
            offset += 1
            pos = buffer.index("#{self} + #{offset} chars")
            break if pos == last
            type = yield(pos.get)
          end
        end

        set("#{self} + #{offset - 1} chars")
      rescue StandardError => ex
        VER.error(ex)
      end

      def backward_jump(count)
        count.times do
          original_type = type = yield(get)
          changed = 0

          begin
            original_pos = index
            buffer.execute_only(:mark, :set, self, "#{self} - 1 chars")
            break if original_pos == index

            type = yield(get)
            changed += 1 if type != original_type
            original_type = type
          end until changed > 0 && type != :space

          type = yield(get('- 1 chars'))

          while type == original_type
            original_pos = index
            buffer.execute_only(:mark, :set, self, "#{self} - 1 chars")
            break if original_pos == index

            type = yield(get('- 1 chars'))
          end
        end
      rescue StandardError => ex
        VER.error(ex)
      end
    end
  end
end
