import gi

gi.require_version("GtkSource", "5")
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, GtkSource

from gtkspellcheck import SpellChecker

import locale
import logging
import re
from typing import Callable, Optional

import iotas.config_manager


class EditorTextView(GtkSource.View):
    __gtype_name__ = "EditorTextView"

    __gsignals__ = {
        "size-allocated": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    # k is the token, v is the continuation
    BULLET_LIST_TOKENS = {
        "- [ ] ": "- [ ] ",
        "- [x] ": "- [ ] ",
        "- ": "- ",
        "+ ": "+ ",
        "* ": "* ",
    }

    SOURCEVIEW_NO_SPELLCHECK_TAG = "gtksourceview:context-classes:no-spell-check"

    def __init__(self):
        super().__init__()

        self.__css_provider = None
        self.__maximum_width = -1
        self.__longpress_gesture = None
        self.__longpress_popover = None
        self.__font_family = "monospace"

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.FONT_SIZE}", self.__on_font_size_changed
        )

        controller = Gtk.EventControllerKey()
        controller.connect("key-pressed", self.__on_key_pressed)
        self.add_controller(controller)

        self.__spellchecker = None
        if iotas.config_manager.get_spelling_enabled():
            self.__init_spellchecker()

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.SPELLING_ENABLED}", self.__on_spelling_toggled
        )

        # TODO Temporary hack to provide access to spelling on mobile
        self.__setup_longpress_touch_menu()

    def do_size_allocate(self, width: int, height: int, baseline: int) -> None:
        """Allocates widget with a transformation that translates the origin to the position in
        allocation.

        :param int width: Width of the allocation
        :param int height: Height of the allocation
        :param int allocation: The baseline of the child
        """
        GtkSource.View.do_size_allocate(self, width, height, baseline)

        # Update side margins
        width = self.get_allocated_width()
        if width < 1:
            return
        if self.__maximum_width > 0 and width > self.__maximum_width:
            x_margin = (width - self.__maximum_width) / 2
        else:
            x_margin = 0
        if self.get_left_margin() != x_margin:
            self.set_left_margin(x_margin)
            self.set_right_margin(x_margin)

        # Update top and bottom margin
        min_width_for_y_margin = 400
        max_width_for_y_margin = self.__maximum_width if self.__maximum_width > 500 else 800
        max_y_margin = 26
        if width <= min_width_for_y_margin:
            y_margin = 0
        elif width < max_width_for_y_margin:
            v_range = max_width_for_y_margin - min_width_for_y_margin
            y_margin = int((width - min_width_for_y_margin) / v_range * float(max_y_margin))
        else:
            y_margin = max_y_margin
        y_margin += 10
        if self.props.top_margin != y_margin:
            self.set_top_margin(y_margin)
            self.set_bottom_margin(y_margin)
        self.emit("size-allocated")

    def scroll_to_insertion_point(self) -> None:
        """Scroll to the insertion point."""
        buffer = self.get_buffer()
        self.scroll_to_mark(buffer.get_insert(), 0.25, True, 1, 0.5)

    @GObject.Property(type=bool, default=False)
    def spellchecker_enabled(self) -> int:
        if self.__spellchecker is not None:
            return self.__spellchecker.enabled

    @spellchecker_enabled.setter
    def spellchecker_enabled(self, value: bool) -> None:
        if self.__spellchecker is not None:
            self.__spellchecker.enabled = value

    @GObject.Property(type=int, default=-1)
    def maximum_width(self) -> int:
        return self.__maximum_width

    @maximum_width.setter
    def maximum_width(self, value: int) -> None:
        self.__maximum_width = value

    def __on_font_size_changed(self, _settings: Gio.Settings, _key: str) -> None:
        self.__update_font()

    # TODO Part of temporary hack to provide access to spelling menu on mobile
    def __on_longpress(self, _gesture: Gtk.GestureLongPress, x: float, y: float) -> None:
        # Long press menu is only currently used for spelling on mobile, don't show many if
        # spelling disabled
        if self.__spellchecker is None or not self.__spellchecker.enabled:
            return
        buffer_x, buffer_y = self.window_to_buffer_coords(2, int(x), int(y))
        iter = self.get_iter_at_location(buffer_x, buffer_y)[1]
        self.__spellchecker.move_click_mark(iter)
        self.__spellchecker.populate_menu(self.__longpress_popover.get_menu_model())
        rect = Gdk.Rectangle()
        rect.x = x
        rect.y = y
        rect.width = rect.height = 1
        self.__longpress_popover.set_pointing_to(rect)
        self.__longpress_popover.popup()

    def __on_spelling_toggled(self, _obj: GObject.Object, _spec: GObject.ParamSpec) -> None:
        if iotas.config_manager.get_spelling_enabled():
            self.__init_spellchecker()
            self.spellchecker_enabled = True
        else:
            self.spellchecker_enabled = False

    def __on_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter):
            if self.__process_newline():
                return Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab):
            self.__handle_tab(state != Gdk.ModifierType.SHIFT_MASK)
            return Gdk.EVENT_STOP

        return Gdk.EVENT_PROPAGATE

    def update_font_family(self, font_family: str) -> None:
        """Update font family from system preference.

        :param str font_family: The font family
        """
        self.__font_family = font_family
        self.__update_font()

    def __update_font(self) -> None:
        if not self.__css_provider:
            self.__css_provider = Gtk.CssProvider()
            self.get_style_context().add_provider(
                self.__css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
            )

        size = iotas.config_manager.get_font_size()
        style = f"""
        .editor-textview {{
            font-size: {size}pt;
            font-family: {self.__font_family}, monospace;
        }}"""
        self.__css_provider.load_from_data(style, -1)

    def __init_spellchecker(self) -> None:
        if self.__spellchecker is not None:
            return

        pref_language = iotas.config_manager.get_spelling_language()
        if pref_language is not None:
            language = pref_language
            logging.debug(f'Attempting to use spelling language from preference "{pref_language}"')
        else:
            language = locale.getdefaultlocale()[0]
            logging.debug(f'Attempting to use locale default spelling language "{language}"')

        self.__spellchecker = SpellChecker(self, language, collapse=False)
        self.spellchecker_enabled = False
        self.__spellchecker.batched_rechecking = True
        if pref_language is not None:
            self.__verify_preferred_language_in_use(pref_language)
        self.__spellchecker.connect("notify::language", self.__spelling_language_changed)

        buffer = self.get_buffer()
        table = buffer.get_tag_table()

        def process_added_tag(tag):
            if tag.get_property("name") == self.SOURCEVIEW_NO_SPELLCHECK_TAG:
                self.__spellchecker.append_ignore_tag(self.SOURCEVIEW_NO_SPELLCHECK_TAG)

        def tag_added(tag, *args):
            if isinstance(tag, Gtk.TextTag):
                process_added_tag(tag)
            elif len(args) > 0 and isinstance(args[0], Gtk.TextTag):
                process_added_tag(args[0])

        table.connect("tag-added", tag_added)

    def __verify_preferred_language_in_use(self, pref_language: str) -> None:
        language_in_use = self.__spellchecker.language
        if language_in_use != pref_language:
            logging.warning(
                f'Spelling language from preference "{pref_language}" not found, clearing'
                " preference"
            )

            logging.info("Available languages:")
            for code, name in self.__spellchecker.languages:
                logging.info(" - %s (%5s)" % (name, code))

            iotas.config_manager.set_spelling_language("")

    def __spelling_language_changed(self, _obj: GObject.Object, _spec: GObject.ParamSpec) -> None:
        language = self.__spellchecker.language
        logging.info(f'New spelling language "{language}"')
        iotas.config_manager.set_spelling_language(language)

    # TODO Part of temporary hack to provide access to spelling menu on mobile
    def __setup_longpress_touch_menu(self) -> None:
        gesture = Gtk.GestureLongPress.new()
        gesture.set_touch_only(True)

        gesture.connect("pressed", self.__on_longpress)

        self.add_controller(gesture)

        self.__longpress_popover = Gtk.PopoverMenu.new_from_model(Gio.Menu())
        self.__longpress_popover.set_parent(self)

    def __check_line_for_bullet_list_item(self, searchterm: str) -> re.Match:
        term = r"^\s*("
        escaped_tokens = []
        for token in self.BULLET_LIST_TOKENS.keys():
            escaped_tokens.append(re.escape(token))
        term += "|".join(escaped_tokens)
        term += ")"
        return re.search(term, searchterm)

    def __check_line_for_ordered_list_item(self, searchterm: str) -> re.Match:
        term = r"^(\s*)([a-zA-Z]{1}|[0-9]+)([\.\)]){1}[ ]+"
        return re.search(term, searchterm)

    def __process_newline(self) -> bool:
        handled = False
        buffer = self.get_buffer()
        mark = buffer.get_insert()
        insert_iter = buffer.get_iter_at_mark(mark)

        line_start = insert_iter.copy()
        line_start.set_line_offset(0)
        previous_line = line_start.get_text(insert_iter)

        # TODO look at changing to use the GtkSourceLanguage context?
        regex_match = self.__check_line_for_bullet_list_item(previous_line)
        if regex_match is not None:
            handled = self.__extend_bullet_list(regex_match.group(), insert_iter, line_start)
        else:
            regex_match = self.__check_line_for_ordered_list_item(previous_line)
            if regex_match is not None:
                handled = self.__extend_ordered_list(regex_match.groups(), insert_iter, line_start)

        if handled:
            # Attempt to track cursor in viewport
            mark = buffer.get_insert()
            insert_iter = buffer.get_iter_at_mark(mark)
            buffer_coords = self.get_iter_location(insert_iter)
            visible_rect = self.get_visible_rect()
            if buffer_coords.y + buffer_coords.height >= visible_rect.y + visible_rect.height:
                # Without adding as an idle task we sometimes end up with an insufficient scroll
                GLib.idle_add(self.emit, "move-viewport", Gtk.ScrollStep.STEPS, 1)

        return handled

    def __extend_bullet_list(
        self, matched_list_item: str, insert_iter: Gtk.TextIter, line_start: Gtk.TextIter
    ) -> bool:
        buffer = self.get_buffer()
        previous_line = line_start.get_text(insert_iter)

        # If the list already continues after our newline, don't add the next item markup.
        # Caters for accidentally deleting a line break in the middle of a list and then
        # pressing enter to revert that.
        cur_line_end = insert_iter.copy()
        if not cur_line_end.ends_line():
            cur_line_end.forward_to_line_end()
        cur_line_text = insert_iter.get_text(cur_line_end)
        if self.__check_line_for_bullet_list_item(cur_line_text) is not None:
            return False

        def generate_sequence_previous_item() -> Optional[str]:
            return matched_list_item

        empty_list_line = self.inserted_empty_item_at_end_of_list(
            previous_line, matched_list_item, line_start, generate_sequence_previous_item
        )
        if empty_list_line:
            # An empty list line has been entered, remove it from the list end
            buffer.delete(line_start, insert_iter)
            buffer.insert_at_cursor("\n")
        else:
            dict_key = matched_list_item.lstrip()
            spacing = matched_list_item[0 : len(matched_list_item) - len(dict_key)]
            new_entry = f"\n{spacing}{self.BULLET_LIST_TOKENS[dict_key]}"
            buffer.insert_at_cursor(new_entry)
        return True

    def __extend_ordered_list(
        self, regex_groups: tuple, insert_iter: Gtk.TextIter, line_start: Gtk.TextIter
    ) -> bool:
        buffer = self.get_buffer()
        spacing = regex_groups[0]
        index = regex_groups[1]
        marker = regex_groups[2]
        previous_line = line_start.get_text(insert_iter)

        def generate_sequence_previous_item() -> Optional[str]:
            # Verify there's more than one list item. This allows inserting lines with list start
            # tokens and nothing else.
            previous_index = EditorTextView.calculate_ordered_list_index(index, -1)
            if previous_index is None:
                return None
            else:
                return self.format_ordered_list_item(spacing, previous_index, marker)

        empty_list_line = self.inserted_empty_item_at_end_of_list(
            previous_line, "".join(regex_groups), line_start, generate_sequence_previous_item
        )
        if empty_list_line:
            # An empty list line has been entered, remove it from the list end
            buffer.delete(line_start, insert_iter)
            buffer.insert_at_cursor("\n")
        else:
            sequence_next = self.calculate_ordered_list_index(index, 1)
            # Handle value of Z, ending sequence
            if sequence_next is None:
                return False
            new_entry = "\n" + self.format_ordered_list_item(spacing, sequence_next, marker)
            buffer.insert(insert_iter, new_entry)

        return True

    def __handle_tab(self, increase: bool) -> None:
        buffer = self.get_buffer()
        if buffer.get_has_selection():
            begin, end = buffer.get_selection_bounds()
            begin.order(end)
            multi_line = "\n" in buffer.get_text(begin, end, False)
            if multi_line:
                line_iter = begin.copy()
                line_mark = buffer.create_mark(None, line_iter)
                end_mark = buffer.create_mark(None, end)
                while line_iter.compare(end) < 0:
                    self.__modify_single_line_indent(line_iter, increase, multi_line)
                    line_iter = buffer.get_iter_at_mark(line_mark)
                    line_iter.forward_line()
                    buffer.delete_mark(line_mark)
                    line_mark = buffer.create_mark(None, line_iter)
                    end = buffer.get_iter_at_mark(end_mark)
                buffer.delete_mark(line_mark)
                buffer.delete_mark(end_mark)
            else:
                self.__modify_single_line_indent(begin, increase, multi_line)
        else:
            mark = buffer.get_insert()
            begin = buffer.get_iter_at_mark(mark)
            self.__modify_single_line_indent(begin, increase, False)

    def __modify_single_line_indent(
        self, location: Gtk.TextIter, increase: bool, multi_line: bool
    ) -> None:
        buffer = self.get_buffer()
        line_start = location.copy()
        line_start.set_line_offset(0)
        line_end = location.copy()
        if not line_end.ends_line():
            line_end.forward_to_line_end()
        line_contents = buffer.get_text(line_start, line_end, False)

        # Don't process empty lines
        if multi_line and line_contents == "":
            return

        list_item = (
            self.__check_line_for_bullet_list_item(line_contents) is not None
            or self.__check_line_for_ordered_list_item(line_contents) is not None
        )
        indent_chars = "  " if list_item else "\t"

        if increase:
            if multi_line or list_item:
                buffer.insert(line_start, indent_chars)
            else:
                buffer.insert(location, indent_chars)
        else:
            if line_contents.startswith(indent_chars):
                end_deletion = line_start.copy()
                end_deletion.forward_chars(len(indent_chars))
                buffer.delete(line_start, end_deletion)

    @staticmethod
    def inserted_empty_item_at_end_of_list(
        line_text: str,
        matched_list_item: str,
        previous_line_start: Gtk.TextIter,
        fetch_sequence_previous: Callable[[], Optional[str]],
    ) -> bool:
        """Determine whether we have inserted an empty item at the end of a list

        The list needs to have at least one previous item.

        :param str line_text: The full line text
        :param str matched_list_item: Our matched list marker
        :param Gtk.TextIter previous_line_start: The start of the previous line
        :param Callable[], Optional[str]] fetch_sequence_previous: A function to fetch a previous
            line's marker
        :return: Whether we've inserted an empty line
        :rtype: bool
        """

        # Check if entered line contains only the list item markup
        if line_text.strip() != matched_list_item.strip():
            return False

        # Fetch what a previous item in the sequence would have been, for the check below. The
        # callable is used to prevent calculating this before we even know if match above will
        # pass (while sharing logic for bullet and ordered lists).
        sequence_previous_item = fetch_sequence_previous()
        if sequence_previous_item is None:
            return False

        # Verify there's more than one list item. This allows inserting lines with list start
        # tokens and nothing else.
        two_prev_start = previous_line_start.copy()
        two_prev_start.backward_line()
        two_prev_start.set_line_offset(0)
        two_prev_line = two_prev_start.get_text(previous_line_start)
        return two_prev_line.startswith(sequence_previous_item)

    @staticmethod
    def calculate_ordered_list_index(reference: str, direction: int) -> Optional[str]:
        """Calculate an item in an ordered list sequence.

        :param str reference: The element to start from
        :param str direction: The direction, 1 or -1
        :return: The item
        :rtype: Optional[str]
        """
        sequence_next = None
        if reference.isdigit():
            sequence_next = str(int(reference) + direction)
            if sequence_next == 0:
                sequence_next = None
        elif direction > 0 and reference.upper() != "Z":
            sequence_next = chr(ord(reference) + 1)
        elif direction < 0 and reference.upper() != "A":
            sequence_next = chr(ord(reference) - 1)
        return sequence_next

    @staticmethod
    def format_ordered_list_item(spacing: str, index: str, delimiter: str) -> str:
        """Generate string for list item.

        :param str spacing: Pre marker spacing
        :param str index: Item index
        :param str delimiter: The delimiter before the item text
        :return: The build string
        :rtype: str
        """
        return f"{spacing}{index}{delimiter} "
