import gi

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

from gtkspellcheck import SpellChecker, NoDictionariesFound

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

import iotas.config_manager
from iotas import const
from iotas.ordered_list_utils import (
    check_line_for_ordered_list_item,
    calculate_ordered_list_index,
    format_ordered_list_item,
)


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

    __gsignals__ = {
        "margins-updated": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

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

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

    STANDARD_MARGIN = 36
    MINIMUM_MARGIN = 10

    def __init__(self) -> None:
        super().__init__()

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

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.FONT_SIZE}", lambda _o, _k: self.__update_font()
        )

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.EDITOR_HEADER_BAR_VISIBILTY}",
            lambda _o, _k: self.__update_margins(self.get_width()),
        )

        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}",
            lambda _o, _v: self.__on_spelling_toggled(),
        )

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

    def setup(self) -> None:
        """Initialisation after window creation."""
        window = self.get_root()
        window.connect(
            "notify::fullscreened", lambda _o, _v: self.__update_margins(self.get_width())
        )

    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 baseline: The baseline of the child
        """
        GtkSource.View.do_size_allocate(self, width, height, baseline)
        if width != self.__width_margins_set_for:
            self.__update_margins(width)

    def jump_to_insertion_point(self) -> None:
        """Jump to the insertion point.

        This jump is used to avoid issues with height allocation in `scroll_to_mark` when the
        buffer contains lines of varying height.
        """
        buffer = self.get_buffer()
        insert_iter = buffer.get_iter_at_mark(buffer.get_insert())
        location = self.get_iter_location(insert_iter)
        adjustment = self.get_vadjustment()
        adjustment.set_value(location.y)

    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()

    @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 line_length(self) -> int:
        return self.__line_length

    @line_length.setter
    def line_length(self, value: int) -> None:
        self.__line_length = value
        # Ensure margin resizing works during resize
        self.__update_margins(self.get_width())

    # 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(Gtk.TextWindowType.TEXT, 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) -> 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_margins(self, width) -> None:
        self.__width_margins_set_for = width

        # Update top and bottom margin
        min_width_for_y_margin = 400
        max_width_for_y_margin = self.__line_length if self.__line_length > 500 else 800
        if width <= min_width_for_y_margin:
            y_margin_base = self.MINIMUM_MARGIN
        elif width < max_width_for_y_margin:
            value_range = max_width_for_y_margin - min_width_for_y_margin
            y_range = float(self.STANDARD_MARGIN - self.MINIMUM_MARGIN)
            in_range = width - min_width_for_y_margin
            y_margin_base = self.MINIMUM_MARGIN + int(in_range / value_range * y_range)
        else:
            y_margin_base = self.STANDARD_MARGIN

        # If the header bar is non hiding (set visible for the fullscreen state) there will be
        # padding elsewhere to ensure the text avoids the headerbar. Otherwise we add it here.
        if iotas.config_manager.get_editor_header_bar_visible_for_window_state():
            y_margin = y_margin_base
        else:
            y_margin = y_margin_base + const.HEADER_BAR_HEIGHT

        if self.props.top_margin != y_margin:
            self.set_top_margin(y_margin)

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

        self.emit("margins-updated")

    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}"')

        try:
            self.__spellchecker = SpellChecker(self, language, collapse=False)
        except NoDictionariesFound:
            # Not communicating via UI for now as systems without any dictionaries are likely to
            # be corner cases, bespoke systems eg. Arch etc
            logging.warning("Disabling spell checker as no dictionaries were found")
            return

        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", lambda _o, _v: 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) -> 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 __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 = 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

        buffer.begin_user_action()
        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)
        buffer.end_user_action()
        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 = calculate_ordered_list_index(index, -1)
            if previous_index is None:
                return None
            else:
                return format_ordered_list_item(spacing, previous_index, marker)

        buffer.begin_user_action()
        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")
            buffer.end_user_action()
        else:
            sequence_next = calculate_ordered_list_index(index, 1)
            # Handle value of Z, ending sequence
            if sequence_next is None:
                buffer.end_user_action()
                return False
            new_entry = "\n" + format_ordered_list_item(spacing, sequence_next, marker)
            buffer.insert(insert_iter, new_entry)

            self.__increment_any_following_ordered_list_items(
                insert_iter, sequence_next, marker, spacing
            )

            buffer.end_user_action()

        return True

    def __increment_any_following_ordered_list_items(
        self, insert_iter: Gtk.TextIter, index: str, marker: str, spacing: str
    ) -> None:
        buffer = self.get_buffer()
        cursor_mark = buffer.create_mark(None, insert_iter)

        entry_cur = format_ordered_list_item(spacing, index, marker)

        while True:
            # Check if the next line continues the list
            if not insert_iter.forward_line():
                break
            line_end = insert_iter.copy()
            line_end.forward_to_line_end()
            text = buffer.get_text(insert_iter, line_end, include_hidden_chars=False)
            if not text.startswith(entry_cur):
                break

            index = calculate_ordered_list_index(index, 1)
            # Handle value of Z, ending sequence
            if not index:
                break
            entry_next = format_ordered_list_item(spacing, index, marker)

            # Delete existing
            end = insert_iter.copy()
            end.forward_chars(len(entry_cur))
            buffer.delete(insert_iter, end)

            # Insert new
            buffer.insert(insert_iter, entry_next)

            entry_cur = entry_next

        buffer.place_cursor(buffer.get_iter_at_mark(cursor_mark))
        buffer.delete_mark(cursor_mark)

    def __handle_tab(self, increase: bool) -> None:
        buffer = self.get_buffer()
        buffer.begin_user_action()
        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)
        buffer.end_user_action()

    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 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)
