import gi

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

from gtkspellcheck import SpellChecker

import locale
import logging
import re

import iotas.config_manager


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

    # k is the token, v is the continuation
    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.__font_family = "monospace"
        self.__longpress_gesture = None
        self.__longpress_popover = None

        # Use system setting for monospace font family
        self.__dbus_proxy = Gio.DBusProxy.new_for_bus_sync(
            bus_type=Gio.BusType.SESSION,
            flags=Gio.DBusProxyFlags.NONE,
            info=None,
            name="org.freedesktop.portal.Desktop",
            object_path="/org/freedesktop/portal/desktop",
            interface_name="org.freedesktop.portal.Settings",
            cancellable=None,
        )
        if self.__dbus_proxy is None:
            logging.warning("Unable to establish D-Bus proxy for FreeDesktop.org font setting")
        else:
            self.__load_font_family_from_setting(False)
            self.__dbus_proxy.connect_object("g-signal", self.__desktop_setting_changed, None)

        key = iotas.config_manager.FONT_SIZE
        iotas.config_manager.settings.connect(f"changed::{key}", self.__on_font_size_changed)
        key = iotas.config_manager.USE_MONOSPACE_FONT
        if self.__dbus_proxy is not None:
            iotas.config_manager.settings.connect(f"changed::{key}", self.__on_font_family_changed)
        self.__update_font()

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

        key = iotas.config_manager.SPELLING_ENABLED
        iotas.config_manager.settings.connect(f"changed::{key}", 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)

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

    def __on_font_family_changed(self, _settings: Gio.Settings, _key: str) -> None:
        self.__load_font_family_from_setting()

    # 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,
    ):
        if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter):
            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(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)

    def __fetch_font_setting_name(self):
        return (
            "monospace-font-name"
            if iotas.config_manager.get_use_monospace_font()
            else "document-font-name"
        )

    def __process_desktop_settings(self, variant: GLib.Variant) -> None:
        if variant.get_type_string() != "(a{sa{sv}})":
            return
        setting_name = self.__fetch_font_setting_name()
        for v in variant:
            for key, value in v.items():
                if key == "org.gnome.desktop.interface":
                    if setting_name in value:
                        self.__update_font_from_system_setting(value[setting_name])

    def __desktop_setting_changed(
        self, _sender_name: str, _signal_name: str, _parameters: str, data: GLib.Variant
    ) -> None:
        if _parameters != "SettingChanged":
            return
        (path, setting_name, value) = data
        key_name = self.__fetch_font_setting_name()
        if path == "org.gnome.desktop.interface" and setting_name == key_name:
            if value.strip() != "":
                self.__update_font_from_system_setting(value)

    def __load_font_family_from_setting(self, apply_update: bool = True) -> None:
        if self.__dbus_proxy is None:
            return
        try:
            variant = self.__dbus_proxy.call_sync(
                method_name="ReadAll",
                parameters=GLib.Variant("(as)", ("org.gnome.desktop.*",)),
                flags=Gio.DBusCallFlags.NO_AUTO_START,
                timeout_msec=-1,
                cancellable=None,
            )
        except GLib.GError as e:
            logging.warning("Unable to access D-Bus FreeDesktop.org font family setting: %s", e)
            return
        self.__process_desktop_settings(variant)
        if apply_update:
            self.__update_font()

    def __update_font_from_system_setting(self, font: str) -> None:
        font_description = Pango.font_description_from_string(font)
        self.__font_family = font_description.get_family()
        self.__update_font()

    # 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 __line_is_list_item(self, searchterm: str) -> re.Match:
        term = r"^\s*("
        escaped_tokens = []
        for token in self.LIST_TOKENS.keys():
            escaped_tokens.append(re.escape(token))
        term += "|".join(escaped_tokens)
        term += ")"
        return re.search(term, searchterm)

    def __process_newline(self) -> None:
        buffer = self.get_buffer()
        buffer.insert_at_cursor("\n")
        mark = buffer.get_insert()
        location = buffer.get_iter_at_mark(mark)
        line_start = location.copy()
        line_start.backward_line()
        line_start.set_line_offset(0)
        prev_line = line_start.get_text(location)

        # TODO look at changing to use the GtkSourceLanguage context
        res = self.__line_is_list_item(prev_line)
        if res is None:
            return
        matched_list_item = res.group()

        # 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 = location.copy()
        if not cur_line_end.ends_line():
            cur_line_end.forward_to_line_end()
        cur_line_text = location.get_text(cur_line_end)
        if self.__line_is_list_item(cur_line_text) is not None:
            return

        empty_list_line = self.inserted_empty_item_at_end_of_list(
            prev_line, matched_list_item, line_start
        )
        if empty_list_line:
            # An empty list line has been entered, remove it from the list end
            buffer.delete(line_start, location)
            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"{spacing}{self.LIST_TOKENS[dict_key]}"
            buffer.insert(location, new_entry)

    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.__line_is_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
    ) -> bool:
        # Check if previous line contains only the list item markup
        if line_text.strip() != matched_list_item.strip():
            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(matched_list_item)
