# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

from gi.repository import GObject, Gtk, GtkSource

from enum import IntEnum, auto
import logging
import math
import re
from typing import Callable, Optional

from iotas.ordered_list_utils import (
    check_line_for_ordered_list_item,
    calculate_ordered_list_index,
    format_ordered_list_item,
)
from iotas.text_utils import (
    get_iters_at_sourceview_context_class_extents,
    iter_forward_to_context_class_removed,
    parse_any_url_at_iter,
    parse_markdown_automatic_link,
    parse_markdown_inline_link,
    str_is_url,
)


class LinkStateInfo(GObject.Object):
    """State information for a markup link in the buffer."""

    creating: bool = True
    """Creating a new link"""

    url_angle_bracketed: bool = False
    """Editing link with URL angle-bracket wrapped"""

    automatic_link: bool = False
    """Creating a new link from an automatic link"""

    text: str = ""
    """Link text"""

    link: str = ""
    """Link URL"""

    title: str = ""
    """Link title, which gets stored and recreated"""

    source_text: str = ""
    """Source text for a link being edited. Used for verification if a note is server updated."""

    start_offset: int = -1
    """Start offset for a link being edited. Used for verification if a note is server updated."""

    end_offset: int = -1
    """End offset for a link being edited. Used for verification if a note is server updated."""


class _ListType(IntEnum):
    """Markup list type."""

    UNORDERED = auto()
    ORDERED = auto()
    TASK = auto()


class Formatter(GObject.Object):
    """Markup formatter."""

    def __init__(self) -> None:
        super().__init__()
        self.__buffer: GtkSource.Buffer

    def bold(self) -> None:
        """Apply user bold formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        if "bold" in self.__buffer.get_context_classes_at_iter(insert):
            # In bold, remove
            self.__delete_wrapping_markup("bold", insert, 2)
        elif self.__buffer.get_has_selection() and not self.__have_multi_line_selection():
            # Have single line selection, wrap in bold
            self.__wrap_selection_in_markup("*", 2)
        else:
            text = self.__buffer.get_property("text")
            if self.__in_empty_bold(text, insert.get_offset()):
                # Handle toggle
                self.__delete_chars_around_iter(insert, 2)
            else:
                # Insert empty bold markup around cursor
                self.__insert_and_centre_iter(insert, "****")

    def italic(self) -> None:
        """Apply user italic formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        if "italic" in self.__buffer.get_context_classes_at_iter(insert):
            # In italic, remove
            self.__delete_wrapping_markup("italic", insert, 1)
        elif self.__buffer.get_has_selection() and not self.__have_multi_line_selection():
            # Have single line selection, wrap in italic
            self.__wrap_selection_in_markup("_", 1)
        else:
            text = self.__buffer.get_property("text")
            if self.__in_empty_italics(text, insert.get_offset()):
                # Handle toggle
                self.__delete_chars_around_iter(insert, 1)
            else:
                # Insert empty italic markup around cursor
                self.__insert_and_centre_iter(insert, "__")

    def strikethrough(self) -> None:
        """Apply user strikethrough formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        if "strikethrough" in self.__buffer.get_context_classes_at_iter(insert):
            # In strikethrough, remove
            self.__delete_wrapping_markup("strikethrough", insert, 2)
        elif self.__buffer.get_has_selection() and not self.__have_multi_line_selection():
            # Have single line selection, wrap in strikethrough
            self.__wrap_selection_in_markup("~", 2)
        else:
            text = self.__buffer.get_property("text")
            if self.__in_empty_strikethrough(text, insert.get_offset()):
                # Handle toggle
                self.__delete_chars_around_iter(insert, 2)
            else:
                # Insert empty strikethrough markup around cursor
                self.__insert_and_centre_iter(insert, "~~~~")

    def quote(self) -> None:
        """Apply user quote formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        line_start = insert.copy()
        line_start.set_line_offset(0)

        if self.__have_multi_line_selection():
            # Handle multi-line selection
            minimum_depth = self.__get_minimum_quote_marker_depth_across_selection_lines()
            if minimum_depth == 0:
                # Some lines have no marker, add markers to all
                logging.debug("Adding quote level")
                self.__run_method_on_selection_lines(self.__add_quote_marker_to_line)
            else:
                # All lines have a marker, remove a level
                logging.debug("Removing quote level")
                self.__run_method_on_selection_lines(self.__remove_quote_marker_from_line)
        elif "blockquote" in self.__buffer.get_context_classes_at_iter(line_start):
            # In quote, remove
            logging.debug("Removing quote")
            self.__buffer.begin_user_action()
            self.__remove_quote_marker_from_line(line_start)
            self.__buffer.end_user_action()
        else:
            # Prepend line with quote markup
            logging.debug("Adding quote")
            self.__buffer.begin_user_action()
            self.__add_quote_marker_to_line(line_start)
            self.__buffer.end_user_action()

    def heading(self, new_level: int) -> None:
        """Apply user heading formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, requested level, etc.

        :param int new_level: Requested header level, zero to remove
        """
        insert = self.__get_selection_start_or_insert_iter()
        line_start = insert.copy()
        line_start.set_line_offset(0)

        # Check for existing heading
        heading_start = line_start.copy()
        existing_level = 0
        while not heading_start.ends_line():
            char = self.__get_line_char_at_iter(heading_start)
            if char == "#":
                heading_counter = heading_start.copy()
                while self.__get_line_char_at_iter(heading_counter) == "#":
                    existing_level += 1
                    heading_counter.forward_char()

                break
            heading_start.forward_char()

        if existing_level == new_level:
            logging.info(f"Nothing to do, already have heading level {new_level}")
            return

        self.__buffer.begin_user_action()

        # Remove any existing heading
        if existing_level:
            end = heading_start.copy()
            end.forward_chars(existing_level)
            char = self.__get_line_char_at_iter(end)
            if char.isspace():
                end.forward_char()
            self.__buffer.delete(heading_start, end)
        else:
            heading_start = line_start.copy()

        # Insert new heading
        if new_level:
            self.__buffer.insert(heading_start, "#" * new_level)
            self.__insert_space_if_not_exists(heading_start)

        self.__buffer.end_user_action()

    def get_link_state(self) -> LinkStateInfo:
        """Fetch link state information for context.

        Returned object will vary depending on note contents, cursor location or any selection.

        :return: Link information at cursor/selection
        :rtype: LinkStateInfo
        """
        info = LinkStateInfo()
        mark = self.__buffer.get_insert()
        if self.__buffer.get_has_selection():
            # Use selection
            start, end = self.__buffer.get_selection_bounds()
            selection_start_classes = self.__buffer.get_context_classes_at_iter(start)
            text = self.__buffer.get_text(start, end, include_hidden_chars=False)
            if "\n" in text:
                # Multiline selection, ignore selection and create new link
                logging.debug("Have newline, ignoring selection")
                info.creating = True
            elif "inline-link" in selection_start_classes:
                # Start of selection in existing inline link, parse and edit
                if self.__parse_link(start, info):
                    info.creating = False
                else:
                    # Failed to parse, ignore selection and create new link
                    info.creating = True
            elif "automatic-link" in selection_start_classes:
                # Start of selection in automatic link, parse and edit, converting to inline link
                logging.debug("Have automatic-link")
                info.creating = False
                self.__parse_automatic_link(start, info)
            elif self.__selection_has_context_classes():
                # There are some (language parsing) context classes in the selection, let's just
                # ignore the selection and create new link
                logging.debug("Not using selection for URL text as it appears to have formatting")
                info.creating = True
            elif str_is_url(text, http_only=False):
                # Selected text is URL, populate into link creation dialog
                info.creating = True
                info.link = text
                info.source_text = text
                info.start_offset = start.get_offset()
                info.end_offset = end.get_offset()
                return info
            elif self.__link_scheme_in_text(text):
                # There's a URI scheme somewhere in the selection but we haven't managed to
                # identify the whole selection as a URL. Ignore the selection and create a new link.
                info.creating = True
            else:
                # Populate the selected text into link creation dialog
                logging.debug("Using selection text")
                info.creating = True
                info.text = text
                info.source_text = text
                info.start_offset = start.get_offset()
                info.end_offset = end.get_offset()
        else:
            # No selection
            insert = self.__buffer.get_iter_at_mark(mark)
            classes = self.__buffer.get_context_classes_at_iter(insert)
            if "inline-link" in classes:
                # Existing inline link under cursor, parse and edit
                if self.__parse_link(insert, info):
                    info.creating = False
                else:
                    # Failed to parse, ignore link under cursor and create new link
                    info.creating = True
            elif "automatic-link" in classes:
                # Cursor in automatic link, parse and edit, converting to inline link
                logging.debug("Have automatic-link")
                info.creating = True
                self.__parse_automatic_link(insert, info)
            elif self.__check_in_url_and_populate_state(insert, info):
                # Cursor in middle of URL, populate to link creation dialog
                info.creating = True
            else:
                # Create new link
                info.creating = True
        return info

    def link(self, info: LinkStateInfo) -> None:
        """Apply user link formatting action.

        This applies the result coming out of the link dialog.

        :param LinkStateInfo info: Details on the changes to be made
        """
        self.__buffer.begin_user_action()
        if self.__buffer.get_has_selection():
            insert, _ = self.__buffer.get_selection_bounds()
            insert_mark = self.__buffer.create_mark(None, insert)
            cleanup_mark = True
        else:
            insert_mark = self.__buffer.get_insert()
            cleanup_mark = False

        # If the edit is based on existing buffer text, delete that text after ensuring it hasn't
        # been modified by a remote update
        if info.source_text != "":
            start = self.__buffer.get_iter_at_offset(info.start_offset)
            end = self.__buffer.get_iter_at_offset(info.end_offset)
            text = self.__buffer.get_text(start, end, include_hidden_chars=False)
            if info.source_text != text:
                logging.warning("Note appears to have changed, inserting link instead")
            else:
                self.__buffer.delete(start, end)

        insert = self.__buffer.get_iter_at_mark(insert_mark)
        self.__insert_link(insert, info)
        # Nudge cursor back into link to enable immediate re-edit
        insert.backward_char()
        self.__buffer.place_cursor(insert)
        if cleanup_mark:
            self.__buffer.delete_mark(insert_mark)
        self.__buffer.end_user_action()

    def code(self) -> None:
        """Apply user code formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        context_classes = self.__buffer.get_context_classes_at_iter(insert)
        if "code-block" in context_classes:
            # In code block, remove
            self.__buffer.begin_user_action()
            self.__delete_wrapping_markup("code-block", insert, 3, user_action=False)

            # Also remove the newline inserted on the starting markup, useful quick toggle to undo
            insert = self.__get_selection_start_or_insert_iter()
            prev_char = insert.copy()
            prev_char.backward_char()
            self.__buffer.delete(prev_char, insert)

            self.__buffer.end_user_action()
        elif "code-span" in context_classes:
            # In code span, remove
            backticks = 2 if "2-backticks-code-span" in context_classes else 1
            self.__delete_wrapping_markup("code-span", insert, backticks)
        elif not self.__buffer.get_has_selection() or "indented-code-block" in context_classes:
            # Have no selection, or have indented code block
            # For now indented codeblocks are basically ignored
            if "indented-code-block" in context_classes:
                logging.info(
                    "Your indented codeblock likely isn't being handled as you'd hope, sorry!"
                )
            text = self.__buffer.get_property("text")
            if self.__in_empty_codespan(text, insert.get_offset()):
                # Handle toggle
                self.__delete_chars_around_iter(insert, 1)
            else:
                # Insert code span markup around cursor
                self.__insert_and_centre_iter(insert, "``")
        else:
            # Have selection
            start, end = self.__buffer.get_selection_bounds()
            start_mark = self.__buffer.create_mark(None, start)
            end_mark = self.__buffer.create_mark(None, end)
            if start.get_line() != end.get_line():
                # Multi line selection, add code block markup
                logging.debug("Adding code-block for multi-line selection")
                chars = "```\n"
                # If selection doesn't start a line insert a newline before the start markup
                if not start.starts_line():
                    chars = "\n" + chars
                self.__buffer.begin_user_action()
                self.__buffer.insert(start, chars)
                end = self.__buffer.get_iter_at_mark(end_mark)
                chars = "```"

                if not end.starts_line():
                    # Selection doesn't end a line insert a newline before the end markup
                    chars = "\n" + chars + "\n"
                elif not self.__blank_line_follows(end):
                    # Not following blank line, single newline after
                    chars += "\n"
                self.__buffer.insert(end, chars)
                self.__buffer.end_user_action()

                # Retain selection
                start = self.__buffer.get_iter_at_mark(start_mark)
                end.backward_chars(len(chars))
                self.__buffer.select_range(start, end)
            else:
                # Single line selection, add code span markup
                logging.debug("Adding code-span for single-line selection")
                self.__buffer.begin_user_action()
                self.__buffer.insert(start, "`")
                end = self.__buffer.get_iter_at_mark(end_mark)
                self.__buffer.insert(end, "`")

                # Retain selection
                start = self.__buffer.get_iter_at_mark(start_mark)
                end = self.__buffer.get_iter_at_mark(end_mark)
                end.backward_chars(1)
                self.__buffer.select_range(start, end)
                # Cleanup
                self.__buffer.delete_mark(start_mark)
                self.__buffer.delete_mark(end_mark)
                self.__buffer.end_user_action()

    def unordered_list(self) -> None:
        """Apply user unordered list formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        logging.debug("unordered-list")
        self.__process_list(_ListType.UNORDERED, lambda iter: self.__buffer.insert(iter, "-"))

    def ordered_list(self) -> None:
        """Apply user ordered list formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        logging.debug("ordered-list")

        # If the previous line contains an ordered list item attempt to continue that list
        def add_marker(iter: Gtk.TextIter) -> None:
            # Default
            value = "1."

            line_start = iter.copy()
            line_start.set_line_offset(0)
            if not line_start.is_start():
                # Get previous line text
                previous_line_start = line_start.copy()
                previous_line_start.backward_line()
                line_end = previous_line_start.copy()
                line_end.forward_to_line_end()
                previous_line = self.__buffer.get_text(
                    previous_line_start, line_end, include_hidden_chars=False
                )
                # Check if it contains an ordered list item
                regex_match = check_line_for_ordered_list_item(previous_line)
                if regex_match:
                    _, index, marker = regex_match.groups()
                    spacing = ""
                    # Determine the value of the next item in the sequence
                    sequence_next = calculate_ordered_list_index(index, 1)
                    if sequence_next:
                        value = format_ordered_list_item(spacing, sequence_next, marker).strip()

            self.__buffer.insert(iter, value)

        self.__process_list(_ListType.ORDERED, add_marker)

    def checkbox(self) -> None:
        """Apply user checkbox formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        logging.debug("checkbox")
        self.__process_list(_ListType.TASK, lambda iter: self.__buffer.insert(iter, "- [ ]"))

    def toggle_checkbox(self) -> None:
        """Toggle any checkbox on the current line or selected lines."""
        logging.debug("toggle-checkbox")
        self.__buffer.begin_user_action()

        if self.__buffer.get_has_selection():
            # Have selection, perform toggle for each line in selection
            self.__run_method_on_selection_lines(self.__toggle_checkbox_single_line)
        else:
            # No selection, toggle any checkpoint on cursor line
            mark = self.__buffer.get_insert()
            insert = self.__buffer.get_iter_at_mark(mark)
            line_start = insert.copy()
            line_start.set_line_offset(0)
            self.__toggle_checkbox_single_line(line_start)

        self.__buffer.end_user_action()

    def horizontal_rule(self) -> None:
        """Apply user horizontal rule formatting action.

        This may result in adding or removing formatting, depending on cursor and selection state,
        note contents, etc.
        """
        insert = self.__get_selection_start_or_insert_iter()
        line_start = insert.copy()
        line_start.set_line_offset(0)

        # Check for existing horizontal rule on the cursor line, or the line at the start of the
        # selection
        start = line_start.copy()
        found = False
        while not start.ends_line():
            if "horizontal-rule" in self.__buffer.get_context_classes_at_iter(start):
                found = True
                break
            start.forward_char()

        if found:
            # Horizontal rule found on line, remove
            logging.debug("Removing horizontal rule")
            end = line_start.copy()
            if not iter_forward_to_context_class_removed(end, "horizontal-rule"):
                return
            self.__buffer.delete(start, end)
        else:
            # Add horizontal rule markup
            logging.debug("Adding horizontal rule")
            chars = "- - -"
            if not insert.starts_line():
                # Not at start of line, double newline before
                chars = "\n\n" + chars
            elif not self.__after_blank_line(insert):
                # Not preceding blank line, single newline before
                chars = "\n" + chars

            # Track suffixed chars separately so we can place the cursor back in the markup to
            # allow eg. toggling
            suffix_chars = ""
            if not insert.ends_line():
                # Not at end of line, double newline after
                suffix_chars = "\n\n"
            elif not self.__blank_line_follows(insert):
                # Not following blank line, single newline after
                suffix_chars = "\n"

            self.__buffer.insert(insert, chars + suffix_chars)
            insert.backward_chars(len(suffix_chars))
            self.__buffer.place_cursor(insert)

    def table(self, columns: int, rows: int, view_width: int, character_width: float) -> None:
        """Insert a table.

        :param int columns: Number of columns
        :param int rows: Number of rows (below header)
        :param int view_width: View pixel width
        :param float character_width: Estimated pixel width of single character in font
        """
        logging.info(f"Insert {columns}x{rows} table")
        insert = self.__get_selection_start_or_insert_iter()

        # Attempt to insert a valid table which doesn't wrap for the current view width.
        # First, cap table pixel width for wide views
        view_width = min(view_width, 1000)
        character_width = max(1.0, character_width)
        # Calculate max number of characters for the width
        max_chars = math.floor(view_width / character_width * 0.9)
        per_column_internal_chars = int((max_chars - 1 - columns) / columns)

        # Minimum 3 characters column width, by GFM standard
        column_width = max(3, per_column_internal_chars)
        # Cap column width
        column_width = min(per_column_internal_chars, 20)

        table = ""
        for row in range(rows + 1):
            line = "|"
            for _ in range(columns):
                line += " " * column_width + "|"
            table += line + "\n"
            if row == 0:
                # Add header separator
                line = "|"
                for _ in range(columns):
                    line += "-" * column_width + "|"
                table += line + "\n"
        self.__buffer.insert(insert, table)

    @GObject.Property(type=GtkSource.Buffer)
    def buffer(self) -> GtkSource.Buffer:
        return self.__buffer

    @buffer.setter
    def set_buffer(self, value: GtkSource.Buffer) -> None:
        self.__buffer = value

    def __get_wrapping_characters(
        self, start: Gtk.TextIter, end: Gtk.TextIter, characters: int
    ) -> Optional[str]:
        text = self.__buffer.get_property("text")
        if start.get_offset() < characters or end.get_offset() > len(text) - characters:
            # Handling bad request, being at start or end of buffer
            return None
        return text[start.get_offset() - characters : end.get_offset() + characters]

    def __get_line_char_at_iter(self, location: Gtk.TextIter) -> str:
        if location.ends_line():
            return ""
        else:
            return self.__get_char_at_iter(location)

    def __get_char_at_iter(self, location: Gtk.TextIter) -> str:
        char_end = location.copy()
        char_end.forward_char()
        return self.__buffer.get_text(location, char_end, include_hidden_chars=False)

    def __insert_space_if_not_exists(self, location: Gtk.TextIter) -> None:
        if location.ends_line():
            self.__buffer.insert(location, " ")
        else:
            char = self.__get_line_char_at_iter(location)
            if not char.isspace():
                self.__buffer.insert(location, " ")

    def __in_empty_bold(self, text: str, location: int) -> bool:
        markup_width = 2
        return self.__check_if_expression_occurs_at_location(
            text,
            r"(?<![*_])[*_]{4}(?![*_])",
            location - markup_width,
        )

    def __in_empty_italics(self, text: str, location: int) -> bool:
        markup_width = 1
        return self.__check_if_expression_occurs_at_location(
            text,
            r"(?<![_])[_]{2}(?![_])",
            location - markup_width,
        )

    def __in_empty_strikethrough(self, text: str, location: int) -> bool:
        markup_width = 2
        return self.__check_if_expression_occurs_at_location(
            text,
            r"(?<![~])[~]{4}(?![~])",
            location - markup_width,
        )

    def __in_empty_codespan(self, text: str, location: int) -> bool:
        markup_width = 1
        return self.__check_if_expression_occurs_at_location(
            text,
            r"(?<![`])[`]{2}(?![`])",
            location - markup_width,
        )

    def __check_if_expression_occurs_at_location(
        self, text: str, regex: str, location: int
    ) -> bool:
        """Check if regular expression occurs in text at specified location.

        :param str text: Text to search
        :param str regex: Regex
        :param int location: Location as offset
        :return: Whether matched
        :rtype: bool
        """
        success = False
        match_iter = re.finditer(regex, text)
        for match in match_iter:
            if match.start() == location:
                success = True
                break
        return success

    def __parse_link(self, inside: Gtk.TextIter, info: LinkStateInfo) -> bool:
        """Parse link elements from a known inline link.

        :param Gtk.TextIter inside: Iter inside inline link
        :param LinkStateInfo: Object to populate into
        :return: Success
        :rtype: bool
        """
        result = parse_markdown_inline_link(inside)
        if not result:
            return False

        info.link = result.link
        info.text = result.text
        info.source_text = result.source_text
        info.start_offset = result.start_offset
        info.end_offset = result.end_offset
        info.url_angle_bracketed = result.url_angle_bracketed

        return True

    def __parse_automatic_link(self, inside: Gtk.TextIter, info: LinkStateInfo) -> bool:
        """Parse URL from a known automatic link.

        :param Gtk.TextIter inside: Iter inside inline link
        :param LinkStateInfo: Object to populate into
        :return: Success
        :rtype: bool
        """
        info.creating = True
        result = parse_markdown_automatic_link(inside)
        if result:
            info.automatic_link = True
            info.title = ""
            info.link = result.link
            info.source_text = result.source_text
            info.start_offset = result.start_offset
            info.end_offset = result.end_offset
            return True
        else:
            # Failure very unlikely as we depend on the GtkSourceView language parsing to reach
            # here
            logging.warning("Failed to parse automatic-link")
            return False

    def __delete_wrapping_markup(
        self, cls: str, inside: Gtk.TextIter, width: int, user_action: bool = True
    ) -> None:
        logging.debug(f"Removing wrapping '{cls}'")
        start, end = get_iters_at_sourceview_context_class_extents(cls, inside)
        if not start:
            return
        end_mark = self.__buffer.create_mark(None, end)
        tmp = start.copy()
        tmp.forward_chars(width)
        if user_action:
            self.__buffer.begin_user_action()
        self.__buffer.delete(start, tmp)
        end = self.__buffer.get_iter_at_mark(end_mark)
        tmp = end.copy()
        end.backward_chars(width)
        self.__buffer.delete(end, tmp)
        self.__buffer.delete_mark(end_mark)
        if user_action:
            self.__buffer.end_user_action()

    def __wrap_selection_in_markup(self, char: str, count: int) -> None:
        """Wrap selection in markup.

        :param str char: Character to insert
        :param int count: Number of times to insert the character on each side of the selection
        """
        logging.debug(f"Wrapping selection with '{char*count}'")
        start, end = self.__buffer.get_selection_bounds()
        start_mark = self.__buffer.create_mark(None, start)
        end_mark = self.__buffer.create_mark(None, end)
        self.__buffer.begin_user_action()
        self.__buffer.insert(start, char * count)
        end = self.__buffer.get_iter_at_mark(end_mark)
        self.__buffer.insert(end, char * count)

        # Retain selection
        start = self.__buffer.get_iter_at_mark(start_mark)
        end = self.__buffer.get_iter_at_mark(end_mark)
        end.backward_chars(count)
        self.__buffer.select_range(start, end)
        # Cleanup
        self.__buffer.delete_mark(start_mark)
        self.__buffer.delete_mark(end_mark)
        self.__buffer.end_user_action()

    def __delete_chars_around_iter(self, inside: Gtk.TextIter, either_side_count: int) -> None:
        start = inside.copy()
        start.backward_chars(either_side_count)
        end = start.copy()
        end.forward_chars(either_side_count * 2)
        self.__buffer.delete(start, end)

    def __insert_and_centre_iter(self, location: Gtk.TextIter, value: str) -> None:
        """Insert text and centre cursor in new text.

        :param Gtk.TextIter location: Location to insert
        :param str value: Text to insert
        """
        if value == "":
            return
        insert = location.copy()
        mark = self.__buffer.create_mark(None, location)
        self.__buffer.insert(insert, value)
        insert = self.__buffer.get_iter_at_mark(mark)
        insert.backward_chars(int(len(value) / 2))
        self.__buffer.place_cursor(insert)
        self.__buffer.delete_mark(mark)

    def __get_selection_start_or_insert_iter(self) -> Gtk.TextIter:
        """Fetch an iter at either the start of the selection or cursor.

        :return: The iter
        :rtype: Gtk.TextIter
        """
        if self.__buffer.get_has_selection():
            insert, _ = self.__buffer.get_selection_bounds()
        else:
            insert = self.__buffer.get_iter_at_mark(self.__buffer.get_insert())
        return insert

    def __selection_has_context_classes(self) -> bool:
        """Check whether any context class exists in the selection.

        :return: Whether any context class exists
        :rtype: bool
        """
        if not self.__buffer.get_has_selection():
            logging.warning("No selection?")
            return False

        have_classes = False
        start, end = self.__buffer.get_selection_bounds()
        loc = start.copy()
        while loc.get_offset() != end.get_offset():
            have_classes = len(self.__buffer.get_context_classes_at_iter(loc)) > 0
            if have_classes:
                break
            loc.forward_char()

        return have_classes

    def __check_in_url_and_populate_state(self, iter: Gtk.TextIter, info: LinkStateInfo) -> bool:
        """Check whether location in buffer is in a URL and if so populate it into link info.

        :param Gtk.TextIter iter: Iter
        :param LinkStateInfo: Object to populate into
        :return: Whether URL found
        :rtype: bool
        """
        result = parse_any_url_at_iter(iter, http_only=False)
        if result:
            info.link = result.link
            info.source_text = result.link
            info.start_offset = result.start_offset
            info.end_offset = result.end_offset
            return True
        else:
            return False

    def __link_scheme_in_text(self, text: str) -> bool:
        return "https://" in text or "http://" in text

    def __insert_link(self, insert: Gtk.TextIter, info: LinkStateInfo) -> None:
        link = info.link
        if " " in link or info.url_angle_bracketed:
            link = f"<{link}>"
        title = ""
        if info.title.strip() != "":
            title = f' "{info.title}"'
        self.__buffer.insert(insert, f"[{info.text}]({link}{title})")

    def __process_list(self, list_type: _ListType, add_marker: Callable) -> None:
        """Apply user list action to all lines in a selection or on the cursor line.

        :param _ListType list_type: Type of list the user has actioned
        :param Callable add_mark: Method to call to add marker
        """
        self.__buffer.begin_user_action()

        if self.__buffer.get_has_selection():

            def callback(line_start: Gtk.TextIter):
                self.__single_line_list_marker_action(line_start, list_type, add_marker)

            self.__run_method_on_selection_lines(callback)
        else:
            mark = self.__buffer.get_insert()
            insert = self.__buffer.get_iter_at_mark(mark)
            line_start = insert.copy()
            line_start.set_line_offset(0)
            self.__single_line_list_marker_action(line_start, list_type, add_marker)

        self.__buffer.end_user_action()

    def __run_method_on_selection_lines(self, method: Callable) -> None:
        if not self.__buffer.get_has_selection():
            logging.warning("No selection, incorrect method use")
            return

        insert, end = self.__buffer.get_selection_bounds()
        end_mark = self.__buffer.create_mark(None, end)
        line_start = insert.copy()
        line_start.set_line_offset(0)
        while line_start.compare(end) < 0 and not line_start.is_end():
            insert_mark = self.__buffer.create_mark(None, line_start)
            method(line_start)
            line_start = self.__buffer.get_iter_at_mark(insert_mark)
            line_start.forward_line()
            end = self.__buffer.get_iter_at_mark(end_mark)
            self.__buffer.delete_mark(insert_mark)
        self.__buffer.delete_mark(end_mark)

    def __single_line_list_marker_action(
        self, line_start: Gtk.TextIter, list_type: _ListType, add_marker: Callable
    ) -> None:
        # Set GtkSourceView context class from list type
        cls = "checkbox" if list_type == _ListType.TASK else "list-marker"
        # Build list of the types, excuding our actioned type
        other_types = list(_ListType)
        other_types.remove(list_type)

        # Check for and remove  actioned type
        removed = False
        if cls in self.__buffer.get_context_classes_at_iter(line_start):
            removed = self.__remove_existing_list_marker_on_line(line_start, [list_type])

        # If we haven't removed our actioned marker type from the line add it
        if not removed:
            logging.debug(f"No {list_type.name.lower()} marker removed, adding")
            self.__remove_existing_list_marker_on_line(line_start, other_types)
            add_marker(line_start)
            self.__insert_space_if_not_exists(line_start)

    def __remove_existing_list_marker_on_line(
        self, insert: Gtk.TextIter, types: list[_ListType]
    ) -> bool:
        """Remove any existing list marker for provided list types on line.

        :param Gtk.TextIter: Iter on line
        :param list[_ListType]: Types to remove
        :return: Whether any marker was removed
        :rtype: bool
        """
        if len(types) == 0:
            logging.warning("No list type to remove")
            return False

        # Build the GtkSourceView context classes to remove
        remove_classes = []
        if _ListType.UNORDERED in types or _ListType.ORDERED in types:
            remove_classes.append("list-marker")
        if _ListType.TASK in types:
            remove_classes.append("checkbox")

        line_start = insert.copy()
        line_start.set_line_offset(0)
        markup_start = line_start.copy()

        removed = False
        for cls in remove_classes:
            # Check if have context class at line start
            if cls in self.__buffer.get_context_classes_at_iter(line_start):
                logging.debug("Removing existing marker")
                end = line_start.copy()
                if not iter_forward_to_context_class_removed(end, cls):
                    return False

                # Move past spaces
                first_char = self.__get_char_at_iter(markup_start)
                while first_char.isspace():
                    markup_start.forward_char()
                    first_char = self.__get_char_at_iter(markup_start)

                # Skip this line if we find the marker for a type we're not removing
                if cls == "list-marker":
                    if _ListType.UNORDERED not in types and first_char in ("*", "-", "+"):
                        continue
                    elif _ListType.ORDERED not in types and first_char not in ("*", "-", "+"):
                        continue

                # Delete from the markup start to the end of the context class
                insert_mark = self.__buffer.create_mark(None, markup_start)
                self.__buffer.delete(markup_start, end)
                insert.assign(self.__buffer.get_iter_at_mark(insert_mark))
                self.__buffer.delete_mark(insert_mark)
                removed = True
                break

        return removed

    def __toggle_checkbox_single_line(self, line_start: Gtk.TextIter) -> None:
        # Find checkbox
        check = line_start.copy()
        found = False
        while not check.ends_line():
            check.forward_char()
            if "checkbox-check" in self.__buffer.get_context_classes_at_iter(check):
                found = True
                break

        if not found:
            logging.debug("No checkbox check found to toggle")
            return

        # Toggle
        end = check.copy()
        end.forward_char()
        value = self.__buffer.get_text(check, end, include_hidden_chars=False)
        new_value = "x" if value == " " else " "
        self.__buffer.delete(check, end)
        self.__buffer.insert(check, new_value)

    def __after_blank_line(self, insert: Gtk.TextIter) -> bool:
        if insert.get_line() == 0:
            return False

        line_start = insert.copy()
        line_start.set_line_offset(0)
        location = insert.copy()
        location.backward_line()
        return line_start.get_offset() == location.get_offset() + 1

    def __blank_line_follows(self, insert: Gtk.TextIter) -> bool:
        location = insert.copy()

        # Check for no next line
        location.forward_to_line_end()
        if location.is_end():
            return False

        location.forward_line()
        following_line_offset = location.get_offset()
        location.forward_line()
        # Second match option here caters for end of buffer
        return location.get_offset() in (following_line_offset + 1, following_line_offset)

    def __have_multi_line_selection(self) -> bool:
        if not self.__buffer.get_has_selection():
            return False

        start, end = self.__buffer.get_selection_bounds()
        return start.get_line() != end.get_line()

    def __get_line_quote_marker_depth(self, iter: Gtk.TextIter) -> int:
        if "blockquote" not in self.__buffer.get_context_classes_at_iter(iter):
            return 0

        offset = 0
        loc = iter.copy()
        while not loc.ends_line():
            char = self.__get_line_char_at_iter(loc)
            if char == ">":
                offset += 1
            elif not char.isspace():
                break
            loc.forward_char()
        return offset

    def __get_minimum_quote_marker_depth_across_selection_lines(self) -> int:
        """Get the minimum depth of blockquote (markers) in the selection lines.

        Should only be called with a selection.

        :return: Minimum quote marker depth
        :rtype: int
        """
        if not self.__buffer.get_has_selection():
            logging.warning("No selection, incorrect method use")
            return 0

        offsets = set()
        line_start, end = self.__buffer.get_selection_bounds()
        line_start.set_line_offset(0)
        while line_start.compare(end) < 0 and not line_start.is_end():
            offsets.add(self.__get_line_quote_marker_depth(line_start))
            line_start.forward_line()

        return min(offsets)

    def __add_quote_marker_to_line(self, insert: Gtk.TextIter) -> None:
        self.__move_to_first_quote_marker_on_line(insert)
        self.__buffer.insert(insert, ">")
        self.__insert_space_if_not_exists(insert)

    def __remove_quote_marker_from_line(self, location: Gtk.TextIter) -> None:
        quote_start = location.copy()
        if not self.__move_to_first_quote_marker_on_line(quote_start):
            logging.warning("Couldn't find quote marker")
            return

        # Remove marker
        delete_end = quote_start.copy()
        delete_end.forward_char()
        self.__buffer.delete(quote_start, delete_end)

        # Remove following space if one exists
        if not quote_start.ends_line():
            char = self.__get_line_char_at_iter(quote_start)
            if char.isspace():
                delete_end = quote_start.copy()
                delete_end.forward_char()
                self.__buffer.delete(quote_start, delete_end)

    def __move_to_first_quote_marker_on_line(self, insert: Gtk.TextIter) -> bool:
        found = False
        search = insert.copy()
        while not search.ends_line():
            if self.__get_line_char_at_iter(search) == ">":
                found = True
                break
            search.forward_char()
        if found:
            insert.assign(search)
        return found
