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

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
from typing import Optional

from iotas.config_manager import ConfigManager
from iotas import list_formatter


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

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

    SOURCEVIEW_NO_SPELLCHECK_TAG = "gtksourceview:context-classes:no-spell-check"
    LINK_TAGS = {"inline-link", "automatic-link", "bare-link"}

    STANDARD_X_MARGIN = 36
    MINIMUM_MARGIN = 10

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

        self.__css_provider: Optional[Gtk.CssProvider] = None
        self.__config_manager = ConfigManager.get_default()
        self.__line_length = -1
        self.__header_bar_height = 0
        self.__longpress_popover: Gtk.PopoverMenu
        self.__font_family = "monospace"
        self.__width_margins_set_for = None
        self.__height_margins_set_for: Optional[int] = None

        self.__cursor_pointer = Gdk.Cursor.new_from_name("pointer")
        self.__showing_pointer_cursor = False
        self.__control_down = False
        self.__mouse_x: Optional[int] = None
        self.__mouse_y: Optional[int] = None

        self.__config_manager.connect_changed(ConfigManager.FONT_SIZE, self.__update_font)

        self.__config_manager.connect_changed(
            ConfigManager.EDITOR_HEADER_BAR_VISIBILTY,
            lambda: self.__update_width_dependent_margins(),
        )

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

        controller = Gtk.EventControllerMotion()
        controller.connect("motion", self.__on_motion)
        controller.connect("leave", self.__on_motion_leave)
        self.add_controller(controller)

        self.__spellchecker = None
        if self.__config_manager.spelling_enabled:
            self.__init_spellchecker()

        self.__config_manager.connect_changed(
            ConfigManager.SPELLING_ENABLED,
            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_all_margins())

    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_width_dependent_margins()
        if height != self.__height_margins_set_for:
            self.__update_bottom_margin(height)

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

        # Push the insertion point down the view a little so that when the replace headerbar is
        # visible the point should still be below that.
        adjustment.set_value(location.y - self.get_height() / 4)

    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) -> bool:
        if self.__spellchecker is not None:
            return self.__spellchecker.enabled
        else:
            return False

    @spellchecker_enabled.setter
    def set_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 set_line_length(self, value: int) -> None:
        self.__line_length = value
        # Ensure margin resizing works during resize (but only if we're initialised)
        if self.__width_margins_set_for:
            self.__update_width_dependent_margins()

    @GObject.Property(type=int, default=0)
    def header_bar_height(self) -> int:
        return self.__header_bar_height

    @header_bar_height.setter
    def set_header_bar_height(self, value: int) -> None:
        self.__header_bar_height = value

    @GObject.Property(type=int, default=0)
    def padded_header_bar_height(self) -> int:
        return self.__header_bar_height + self.MINIMUM_MARGIN

    @GObject.Property(type=bool, default=False)
    def accommodate_double_header_bar(self) -> bool:
        return self.__accommodate_double_header_bar

    @accommodate_double_header_bar.setter
    def set_accommodate_double_header_bar(self, value: bool) -> None:
        self.__accommodate_double_header_bar = value
        if self.__width_margins_set_for:
            self.__update_width_dependent_margins()

    # 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 self.__config_manager.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:
        buffer = self.get_buffer()
        if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter):
            if list_formatter.check_and_extend_list(buffer):
                # 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 Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab):
            if state == Gdk.ModifierType.SHIFT_MASK:
                list_formatter.decrease_indentation(buffer)
            else:
                list_formatter.increase_indentation(buffer)
            return Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Control_L, Gdk.KEY_Control_R):
            if not self.__control_down:
                self.__control_down = True
                self.__refresh_mouse_cursor()

        return Gdk.EVENT_PROPAGATE

    def __on_key_released(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if keyval in (Gdk.KEY_Control_L, Gdk.KEY_Control_R):
            if self.__control_down:
                self.__control_down = False
                self.__refresh_mouse_cursor()

        return Gdk.EVENT_PROPAGATE

    def __on_motion(
        self,
        controller: Gtk.EventControllerMotion,
        x: float,
        y: float,
    ) -> bool:
        self.__mouse_x, self.__mouse_y = self.window_to_buffer_coords(
            Gtk.TextWindowType.WIDGET, int(x), int(y)
        )
        self.__refresh_mouse_cursor()
        return Gdk.EVENT_PROPAGATE

    def __on_motion_leave(
        self,
        controller: Gtk.EventControllerMotion,
    ) -> bool:
        self.__mouse_x = None
        self.__mouse_y = None
        self.__refresh_mouse_cursor()
        return Gdk.EVENT_PROPAGATE

    def __refresh_mouse_cursor(self) -> None:
        show_cursor = False
        if self.__control_down and self.__mouse_x is not None:
            success, iter = self.get_iter_at_location(self.__mouse_x, self.__mouse_y)
            if success:
                buffer = self.get_buffer()
                classes = set(buffer.get_context_classes_at_iter(iter))
                show_cursor = len(classes.intersection(self.LINK_TAGS)) > 0

        if self.__showing_pointer_cursor != show_cursor:
            self.set_cursor(self.__cursor_pointer if show_cursor else None)
            self.__showing_pointer_cursor = show_cursor

    def __update_all_margins(self) -> None:
        self.__update_width_dependent_margins()
        self.__update_bottom_margin(self.get_height())

    def __update_width_dependent_margins(self) -> None:
        width_range_progress = self.__get_width_margin_range_progress()
        self.__update_top_margin(width_range_progress)
        self.__update_side_margins(width_range_progress)
        self.emit("margins-updated")

    def __get_width_margin_range_progress(self) -> float:
        width = self.get_width()
        minimum_width_for_margin = 400
        max_width_for_margin = self.__line_length if self.__line_length > 500 else 800

        if width <= minimum_width_for_margin:
            progress = 0.0
        elif width < max_width_for_margin:
            value_range = max_width_for_margin - minimum_width_for_margin
            in_range = self.get_width() - minimum_width_for_margin
            progress = in_range / value_range
        else:
            progress = 1.0
        return progress

    def __update_top_margin(self, range_progress: float) -> None:
        width = self.get_width()
        self.__width_margins_set_for = width

        y_margin_base = self.__header_bar_height

        y_range = float(self.__header_bar_height - self.MINIMUM_MARGIN)
        y_margin_base = self.MINIMUM_MARGIN + int(range_progress * y_range)

        if self.__accommodate_double_header_bar:
            y_margin_base = max(y_margin_base, self.__header_bar_height)

        # If the header bar is non hiding (set visible for the fullscreen state) there will be
        # padding added in the Editor's __update_toolbar_underlay_margins to ensure the text avoids
        # the headerbar. Otherwise we add it here.
        if self.__config_manager.editor_header_bar_visible_for_window_state:
            y_margin = y_margin_base
        else:
            y_margin = y_margin_base + self.padded_header_bar_height

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

    def __update_bottom_margin(self, height: int) -> None:
        self.__height_margins_set_for = height
        new_margin = int(height * 0.4)
        if self.props.bottom_margin != new_margin:
            self.set_bottom_margin(new_margin)

    def __update_side_margins(self, range_progress: float) -> None:
        width = self.get_width()
        if width < 1:
            return

        if self.__line_length > 0 and width - 2 * self.STANDARD_X_MARGIN > self.__line_length:
            x_margin = (width - self.__line_length) / 2
        else:
            x_range = float(self.STANDARD_X_MARGIN - self.MINIMUM_MARGIN)
            x_margin = self.MINIMUM_MARGIN + int(range_progress * x_range)

        if self.get_left_margin() != x_margin:
            self.set_left_margin(x_margin)
            self.set_right_margin(x_margin)

    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 = self.__config_manager.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 = self.__config_manager.spelling_language
        if pref_language is not None:
            language = pref_language
            logging.debug(f'Attempting to use spelling language from preference "{pref_language}"')
        else:
            default_language, _encoding = locale.getdefaultlocale()
            if default_language:
                language = default_language
                logging.debug(f'Attempting to use locale default spelling language "{language}"')
            else:
                logging.warning("Couldn't determine locale default 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

        assert self.__spellchecker  # mypy

        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:
        assert self.__spellchecker
        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))

            self.__config_manager.spelling_language = ""

    def __spelling_language_changed(self) -> None:
        assert self.__spellchecker
        language = self.__spellchecker.language
        logging.info(f'New spelling language "{language}"')
        self.__config_manager.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)
