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

from gettext import gettext as _
from gettext import ngettext
from gi.repository import Adw, Gdk, GObject, Gio, GLib, Gtk

import logging
from typing import Callable, Optional

from iotas.category import Category, CategorySpecialPurpose
from iotas.category_manager import CategoryManager
from iotas.config_manager import ConfigManager
import iotas.const as const
from iotas.first_start_page import FirstStartPage
from iotas.index_menu_button import IndexMenuButton
from iotas.index_note_list import IndexNoteList, FocusedRow
from iotas.index_search_header_bar import IndexSearchHeaderBar, IndexSearchState
from iotas.note import Note
from iotas.note_manager import NoteManager
from iotas.selection_header_bar import SelectionHeaderBar
from iotas.sidebar import Sidebar
from iotas.sync_manager import SyncManager
from iotas.theme_selector import ThemeSelector


@Gtk.Template(resource_path="/org/gnome/World/Iotas/ui/index.ui")
class Index(Adw.BreakpointBin):
    __gtype_name__ = "Index"

    __gsignals__ = {
        # Parameters are the note and whether to open immediately (disabling animation)
        "note-opened": (GObject.SignalFlags.RUN_FIRST, None, (Note, bool)),
        "reauthenticate": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "export": (GObject.SignalFlags.RUN_FIRST, None, ()),
        "header-bar-height": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
    }

    UNDO_NOTE_DELETION_DURATION = 5.0
    BUMPED_OUT_OF_EDITOR_NOTIFICATION_DURATION = 5.0
    SYNC_UPDATE_NOTIFICATION_DURATION = 2.0
    NOTES_APP_MISSING_NOTIFICATION_DURATION = 8.0
    BREAKPOINT_WIDTH = 700

    _overlay_view: Adw.OverlaySplitView = Gtk.Template.Child()
    _breakpoint: Adw.Breakpoint = Gtk.Template.Child()

    _stack: Gtk.Stack = Gtk.Template.Child()
    _sidebar: Sidebar = Gtk.Template.Child()
    _note_list_scroll: Gtk.ScrolledWindow = Gtk.Template.Child()
    _note_list: IndexNoteList = Gtk.Template.Child()
    _first_start: FirstStartPage = Gtk.Template.Child()
    _loading: Adw.StatusPage = Gtk.Template.Child()
    _empty: Adw.StatusPage = Gtk.Template.Child()
    _search_info: Adw.StatusPage = Gtk.Template.Child()
    _search_empty: Adw.StatusPage = Gtk.Template.Child()

    _header_stack: Gtk.Stack = Gtk.Template.Child()
    _main_header_bar: Adw.HeaderBar = Gtk.Template.Child()
    _content_sidebar_button: Gtk.Button = Gtk.Template.Child()
    _search_header_bar: IndexSearchHeaderBar = Gtk.Template.Child()
    _selection_header_bar: SelectionHeaderBar = Gtk.Template.Child()
    _title: Gtk.Label = Gtk.Template.Child()

    _toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()

    _menu_button: IndexMenuButton = Gtk.Template.Child()
    _new_button: Gtk.Button = Gtk.Template.Child()

    _misc_banner: Adw.Banner = Gtk.Template.Child()
    _offline_banner: Adw.Banner = Gtk.Template.Child()
    _app_id_change_reauthenticate_banner: Adw.Banner = Gtk.Template.Child()
    _generic_reauthenticate_banner: Adw.Banner = Gtk.Template.Child()

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

        self.__note_manager: NoteManager
        self.__category_manager: CategoryManager
        self.__sync_manager: SyncManager
        self.__config_manager = ConfigManager.get_default()

        self.__active = False
        self.__initialised = False
        self.__import_syncing = False
        self.__search_after_init: Optional[str] = None
        self.__searching_before_selection = False
        self.__header_bar_height_sent = False

        self.__add_fav_action: Gio.SimpleAction
        self.__remove_fav_action: Gio.SimpleAction
        self.__cancel_action: Gio.SimpleAction
        self.__select_all_action: Gio.SimpleAction

        self.__syncing_toast: Optional[Adw.Toast] = None
        self.__focused_row_pre_changes: Optional[FocusedRow] = None
        self.__open_note_row: Optional[FocusedRow] = None

        self.__search_model = None

        # Startup bootstrap category
        self.__category = Category("", 0)
        self.__category.special_purpose = CategorySpecialPurpose.ALL
        self._title.set_label(Category.ALL_TITLE)

        self._note_list.connect(
            "note-opened", lambda _o, note: self.__open_note(note, immediate=False)
        )
        self._note_list.connect("note-checkbox-activated", self.__on_note_checkbox_activated)
        self._note_list.connect(
            "note-checkbox-deactivated",
            lambda _o, note: self._selection_header_bar.set_note_selected(note, False),
        )

        self._selection_header_bar.connect("categories-changed", self.__on_notes_categories_changed)
        self._selection_header_bar.connect("export", self.__on_notes_export)
        self._selection_header_bar.connect("delete", self.__on_notes_delete)
        self._selection_header_bar.connect(
            "set-favourite", lambda _o: self.__update_favourite_on_selection(True)
        )
        self._selection_header_bar.connect(
            "clear-favourite", lambda _o: self.__update_favourite_on_selection(False)
        )
        self._selection_header_bar.connect("abort", lambda _o: self.__cancel_selection())

        self._menu_button.get_popover().add_child(ThemeSelector(), "theme")

        self.connect("realize", self.__on_realize)

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

        self._search_header_bar.connect(
            "changed",
            lambda _o: self.__apply_search(focused_row=None, invalidate_scroll_and_expansion=True),
        )

        self.__config_manager.connect_changed(
            ConfigManager.INDEX_CATEGORY_STYLE,
            lambda: self._note_list.update_category_labels(self.__category),
        )

        self.__config_manager.connect_changed(
            ConfigManager.PERSIST_SIDEBAR,
            self.__update_for_sidebar_pin_change,
        )

        self._breakpoint.connect("apply", self.__on_breakpoint_apply)
        self._breakpoint.connect("unapply", self.__on_breakpoint_unapply)

        self._overlay_view.connect(
            "notify::show-sidebar", lambda _o, _v: self.__update_header_buttons_for_state()
        )

    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
        """
        # Using the index headerbar stack to get a rough header bar height for the editor,
        # accommodating for text size preferences.
        Adw.BreakpointBin.do_size_allocate(self, width, height, baseline)
        height = self._header_stack.get_height()
        if height and not self.__header_bar_height_sent:
            self.__header_bar_height_sent = True
            self.emit("header-bar-height", height)

    def setup(
        self,
        note_manager: NoteManager,
        category_manager: CategoryManager,
        sync_manager: SyncManager,
    ) -> None:
        """Perform initial setup.

        :param NoteManager note_manager: Note manager
        :param CategoryManager category_manager: Category manager
        :param SyncManager sync_manager: Remote sync manager
        """
        self.__sync_manager = sync_manager

        self.__note_manager = note_manager
        self.__note_manager.connect(
            "initial-load-complete", lambda _o: self.__on_initial_load_from_db_complete()
        )
        self.__note_manager.connect("new-note-persisted", self.__on_new_note_persisted)

        self.__category_manager = category_manager

        self.__setup_actions()

        self.__sync_manager.set_managers(self.__note_manager, self.__category_manager)

        self._sidebar.setup(
            self.__sync_manager,
            self.__category_manager,
            self.__on_category_activated,
        )
        self._search_header_bar.setup(self._note_list, self.__note_manager)
        self._selection_header_bar.setup(self.__category_manager.tree_store)
        self._note_list.setup(self.__note_manager, self.__on_listbox_key_pressed)
        self._selection_header_bar.bind_property(
            "active", self._note_list, "selecting", GObject.BindingFlags.SYNC_CREATE
        )
        self._search_header_bar.bind_property(
            "active", self._note_list, "searching", GObject.BindingFlags.SYNC_CREATE
        )

        if self.__config_manager.first_start:
            self._stack.set_visible_child(self._first_start)
            self._first_start.initialise_device()
        else:
            self._stack.set_visible_child(self._loading)

        self.__sync_manager.connect("ready", self.__on_sync_ready)
        self.__sync_manager.connect("started", self.__on_sync_started)
        self.__sync_manager.connect("have-changes", self.__on_sync_have_changes)
        self.__sync_manager.connect("finished", self.__on_sync_finished)
        self.__sync_manager.connect("missing-password", self.__on_missing_password)
        self.__sync_manager.connect(
            "notes-capability-missing", self.__on_missing_server_notes_capability
        )

        self.__sync_manager.init_auth()

        self.__sync_manager.bind_property(
            "offline", self._offline_banner, "revealed", GObject.BindingFlags.SYNC_CREATE
        )

    def update_for_dialog_visibility(self, visible: bool) -> None:
        """Update if dialogs visible, disabling actions.

        :param bool visible: Whether a dialog is visible
        """
        if self.active:
            for action in (self.__cancel_action, self.__select_all_action):
                action.set_enabled(not visible)

    def update_layout_for_initial_size(self, width: int, height: int) -> None:
        """Update layout for initial window size.

        :param int width: Window width
        :param int height: Window height
        """
        self.__update_for_sidebar_pin_change(width)
        self._note_list.update_search_pagesize_for_height(height)

    def update_search_pagesize_for_height(self, height: int) -> None:
        """Recalculate number of results to show on first search page.

        :param int height: The new window height
        """
        self._note_list.update_search_pagesize_for_height(height)

    def show_note_conflict_alert(self) -> None:
        """Let the user know a sync conflict has occurred with the note being edited."""
        # Translators: Description, notification
        toast = Adw.Toast.new(_("Sync conflict with note being edited"))
        toast.set_timeout(self.BUMPED_OUT_OF_EDITOR_NOTIFICATION_DURATION)
        self._toast_overlay.add_toast(toast)

    def show_editing_note_deleted_alert(self) -> None:
        """Let the user know the note being edited was remotely deleted."""
        # Translators: Description, notification
        toast = Adw.Toast.new(_("The note being edited was remotely deleted"))
        toast.set_timeout(self.BUMPED_OUT_OF_EDITOR_NOTIFICATION_DURATION)
        self._toast_overlay.add_toast(toast)

    def show_secret_service_failure_alert(self) -> None:
        text = _(
            # Translators: Description, notification. "Secret Service" and "gnome-keyring" should
            # likely not be translated.
            "Failure accessing Secret Service. Ensure you have a provider like gnome-keyring which "
            "has a default keyring setup that is unlocked."
        )
        self._misc_banner.set_title(text)
        self._misc_banner.set_revealed(True)

    def refresh_after_note_closed(self, note: Note) -> None:
        """Refresh index after note has been closed.

        :param Note note: The closed note
        """
        if self.__searching():
            self.__apply_search()
        else:
            if self.__open_note_row:
                self.__focused_row_pre_changes = self.__open_note_row
                self.__open_note_row = None
            self.__invalidate_view(check_older_notes=False)
            if not self.__handle_emptied_category(note):
                self._note_list.update_category_labels(self.__category)

        if self.get_root().using_keyboard_navigation:
            if not note.locally_deleted:
                self._note_list.refocus_selected_row()
        else:
            self._note_list.clear_selections()

    def search_from_cli(self, search_term: str) -> None:
        """Initiate a search, from the CLI.

        :param str search_term: The search term
        """
        if self.__initialised:
            self.__start_search_from_cli(search_term)
        else:
            self.__search_after_init = search_term

    def delete_notes(self, notes: list[Note]) -> None:
        """Delete the specified notes, flushing any undo-pending notes first.

        :param list[Note] notes: Notes to delete
        """
        focused_row = self._note_list.get_current_selection()
        if self.__selecting():
            self.__cancel_selection()
        self.__sync_manager.flush_pending_deletions()
        self.__note_manager.delete_notes(notes, provide_undo=True)
        self.__update_for_note_deletions(notes, focused_row)

    def get_selection(self) -> list[Note]:
        """Get selected note/s.

        :return: Selected notes
        :rtype: list[Note]
        """
        if self._selection_header_bar.active:
            return self._selection_header_bar.get_selected()
        else:
            note = self._note_list.get_selected_note()
            if note:
                return [note]
            else:
                return []

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

    @active.setter
    def set_active(self, value: bool) -> None:
        self.__active = value
        self.__enable_actions(value)

    @Gtk.Template.Callback()
    def _on_reauthenticate(self, _button: Gtk.Button) -> None:
        self.emit("reauthenticate")

    @Gtk.Template.Callback()
    def _on_misc_banner_button(self, _button: Gtk.Button) -> None:
        if self._misc_banner.get_revealed():
            self._misc_banner.set_revealed(False)

    @Gtk.Template.Callback()
    def _on_edge_overshot(self, _obj: Gtk.ScrolledWindow, pos: Gtk.PositionType) -> None:
        if pos == Gtk.PositionType.BOTTOM:
            if self.__searching():
                if self._note_list.have_more_search_results:
                    self._note_list.show_remaining_search_results()
            else:
                if not self._note_list.older_notes_displayed:
                    self.__load_older_notes()

    def __setup_actions(self) -> None:
        action_group = Gio.SimpleActionGroup.new()
        app = Gio.Application.get_default()

        action = Gio.SimpleAction.new("toggle-sidebar")
        action.connect("activate", lambda _a, _p: self.__toggle_sidebar())
        action_group.add_action(action)
        app.set_accels_for_action("index.toggle-sidebar", ["F9"])

        action = Gio.SimpleAction.new("category-context-dependent")
        action.connect("activate", lambda _a, _p: self.__on_category_shortcut())
        action_group.add_action(action)
        app.set_accels_for_action("index.category-context-dependent", ["<Control>e"])

        action = Gio.SimpleAction.new("create-note")
        action.connect("activate", lambda _o, _v: self.__create_and_open_note())
        action_group.add_action(action)
        app.set_accels_for_action("index.create-note", ["<Control>n"])

        action = Gio.SimpleAction.new("create-note-with-content", GLib.VariantType("s"))
        action.connect("activate", self.__on_create_note_with_content)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("export-notes")
        action.connect("activate", lambda _a, _p: self.emit("export"))
        action_group.add_action(action)
        app.set_accels_for_action("index.export-notes", ["<Shift><Control>s"])

        action = Gio.SimpleAction.new("delete-from-index")
        action.connect("activate", lambda _a, _p: self.__delete_note_from_keyboard())
        action_group.add_action(action)
        app.set_accels_for_action("index.delete-from-index", ["<Shift>Delete"])

        action = Gio.SimpleAction.new("load-older-notes")
        action.connect("activate", lambda _a, _p: self.__load_older_notes())
        action_group.add_action(action)

        action = Gio.SimpleAction.new("show-more")
        action.connect("activate", lambda _a, _p: self._note_list.show_remaining_search_results())
        action_group.add_action(action)

        action = Gio.SimpleAction.new("enter-search")
        action.connect("activate", lambda _a, _p: self.__enter_search(clear_text=True))
        action_group.add_action(action)
        app.set_accels_for_action("index.enter-search", ["<Control>f"])

        action = Gio.SimpleAction.new("select-notes")
        action.connect("activate", lambda _a, _p: self.__start_selection())
        action_group.add_action(action)
        app.set_accels_for_action("index.select-notes", ["<Control>s"])

        action = Gio.SimpleAction.new("cancel")
        action.connect("activate", lambda _a, _p: self.__cancel_out_of_context(False))
        action_group.add_action(action)
        self.__cancel_action = action
        app.set_accels_for_action("index.cancel", ["Escape", "<Alt>Left"])

        action = Gio.SimpleAction.new("reset-filters")
        action.connect("activate", lambda _a, _p: self.__cancel_out_of_context(True))
        action_group.add_action(action)
        app.set_accels_for_action(
            "index.reset-filters", ["<Control><Shift>BackSpace", "<Control><Shift>Delete"]
        )

        action = Gio.SimpleAction.new("selection-toggle-favourite")
        action.connect("activate", lambda _a, _p: self.__on_toggle_favourite())
        action_group.add_action(action)
        app.set_accels_for_action("index.selection-toggle-favourite", ["<Control>g"])

        action = Gio.SimpleAction.new("show-menu")
        action.connect("activate", lambda _a, _p: self._menu_button.popup())
        action_group.add_action(action)
        app.set_accels_for_action("index.show-menu", ["F10"])

        action = Gio.SimpleAction.new("select-all")
        action.connect("activate", lambda _a, _p: self.__select_all())
        action_group.add_action(action)
        self.__select_all_action = action
        app.set_accels_for_action("index.select-all", ["<Control>a"])

        self.__action_group = action_group
        app.get_active_window().insert_action_group("index", action_group)

    def __enable_actions(self, enabled: bool) -> None:
        """Toggle whether index actions are enabled.

        :param bool enabled: New value
        """
        actions = self.__action_group.list_actions()
        for action in actions:
            self.__action_group.lookup_action(action).set_enabled(enabled)

    def __on_realize(self, _widget: Gtk.Widget) -> None:
        # Preset filters to avoid momentarily populating all at startup
        self.__note_manager.update_filters(
            self.__category, self._note_list.older_notes_displayed, check_older_notes=False
        )
        self.__note_manager.initiate_model_from_db()
        self.__category_manager.populate()

    def __on_create_note_with_content(
        self,
        _obj: GObject.Object,
        param: GObject.ParamSpec,
    ) -> None:
        """Create a new note with content and edit it."""
        self.__create_and_open_note(param.get_string())

    def __on_notes_categories_changed(self, _obj: SelectionHeaderBar) -> None:
        focused_row = self._note_list.get_current_selection()
        changeset = self._selection_header_bar.get_categories_changeset()
        self.__cancel_selection()
        for note, old_category in changeset:
            self.__note_manager.persist_note_category(note, old_category)
        if self.__searching():
            self.__apply_search(focused_row)
        else:
            self.__invalidate_view(check_older_notes=False)
            self.__handle_emptied_category()
            if focused_row:
                self._note_list.place_focus_after_change(focused_row)

    def __on_notes_export(self, _obj: SelectionHeaderBar) -> None:
        self.emit("export")

    def __on_notes_delete(self, _obj: SelectionHeaderBar) -> None:
        notes = self._selection_header_bar.get_selected()
        self.delete_notes(notes)

    def __on_note_checkbox_activated(self, _obj: GObject.Object, note: Note) -> None:
        if not self.__selecting():
            self.__start_selection()
        self._selection_header_bar.set_note_selected(note, True)

    def __on_category_activated(self, category: Category, close_sidebar: bool = True) -> None:
        self.__category = category

        self._note_list.disconnect_older_notes_section()

        # Handle clicking on category while searching or selecting with a persistent sidebar
        if self.__searching():
            self.__end_search()
            # return as __end_search itself calls __on_category_activated
            return
        elif self.__selecting():
            self.__searching_before_selection = False
            self.__cancel_selection()

        self._title.set_label(category.display_name)
        self._note_list_scroll.get_vadjustment().set_value(0)
        self.__invalidate_view(check_older_notes=True)

        self._note_list.refresh_section_visibility(self.__category)
        self._note_list.update_category_labels(self.__category)
        if close_sidebar:
            self.__close_sidebar()
        if self.get_root().using_keyboard_navigation:
            self._note_list.move_focus_to_list_top()

    def __on_sync_ready(self, _obj: GObject.Object, new_setup: bool) -> None:
        if new_setup:
            self.__import_syncing = True
            if self.__searching():
                self.__end_search()
        if self._app_id_change_reauthenticate_banner.get_revealed():
            self._app_id_change_reauthenticate_banner.set_revealed(False)
        if self._generic_reauthenticate_banner.get_revealed():
            self._generic_reauthenticate_banner.set_revealed(False)

    def __on_sync_started(self, _obj: GObject.Object) -> None:
        if self.active and self.__config_manager.show_syncing_debug_notification:
            # Another toast misuse replacing a revealer notification. Debug only at least.
            # TODO in future look at replacing this with a (debug only) spinner.

            # Translators: Description, notification
            self.__syncing_toast = Adw.Toast.new(_("Syncing"))

            # A cludge, only passable for this debug functionality.
            self.__syncing_toast.set_timeout(60.0)
            self._toast_overlay.add_toast(self.__syncing_toast)

    def __on_sync_have_changes(self, _obj: GObject.Object) -> None:
        if self.active:
            self.__focused_row_pre_changes = self._note_list.get_current_selection()

    def __on_sync_finished(self, _obj: SyncManager, changes: int) -> None:
        if self.active:
            if self.__syncing_toast:
                self.__syncing_toast.dismiss()
                self.__syncing_toast = None
            if self.__import_syncing:
                self.__on_category_activated(self.__category, close_sidebar=True)
                self.__import_syncing = False
            elif changes > 0:
                self.__invalidate_view(check_older_notes=False)
                self.__focused_row_pre_changes = None
                self._note_list.update_category_labels(self.__category)

                # Translators: Description, notification, {} is a number
                msg = ngettext("{} change", "{} changes", changes).format(changes)
                toast = Adw.Toast.new(msg)
                toast.set_timeout(self.SYNC_UPDATE_NOTIFICATION_DURATION)
                self._toast_overlay.add_toast(toast)

    def __on_missing_password(self, _obj: GObject.Object) -> None:
        app = Gio.Application.get_default()
        previous_version = app.previous_version
        if self.__sync_manager.configured_but_no_password:
            if previous_version != "" and previous_version < const.APP_ID_CHANGE_VERSION:
                self._app_id_change_reauthenticate_banner.set_revealed(True)
            else:
                self._generic_reauthenticate_banner.set_revealed(True)

    def __on_missing_server_notes_capability(self, _obj: GObject.Object) -> None:
        # Translators: Description, notification
        msg = _("Sync failure. Is the Nextcloud Notes app installed on the server?")
        toast = Adw.Toast.new(msg)
        toast.set_timeout(self.NOTES_APP_MISSING_NOTIFICATION_DURATION)
        self._toast_overlay.add_toast(toast)

    def __on_index_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down, Gdk.KEY_Up, Gdk.KEY_KP_Up):
            self.get_root().using_keyboard_navigation = True
            if self._overlay_view.get_pin_sidebar() or not self._overlay_view.get_show_sidebar():
                if self._note_list.move_focus_to_first_visible(
                    self._note_list_scroll.get_vadjustment().get_value()
                ):
                    return Gdk.EVENT_STOP
            else:
                # If we have an auto-expanded sidebar use the shortcut to send the focus
                self._sidebar.take_focus()
                return Gdk.EVENT_STOP
        elif not self.__searching() and not self.__selecting():
            if self._search_header_bar.check_if_starting(controller, keyval, state):
                self.__enter_search(clear_text=False)
                return Gdk.EVENT_STOP
        elif self.__searching() and not self.__selecting():
            if self._search_header_bar.check_if_starting(controller, keyval, state, clear=False):
                self._search_header_bar.focus_entry()
                return Gdk.EVENT_STOP
            elif self._search_header_bar.check_for_open_first_result_shortcut(
                controller, keyval, state
            ):
                # Check that we have some results, a little indirect
                if self._stack.get_visible_child() == self._note_list_scroll:
                    self._note_list.open_first_search_result()
                    return Gdk.EVENT_STOP

        return Gdk.EVENT_PROPAGATE

    def __on_listbox_key_pressed(
        self,
        controller: Gtk.EventControllerKey,
        keyval: int,
        keycode: int,
        state: Gdk.ModifierType,
    ) -> bool:
        if keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down):
            self.get_root().using_keyboard_navigation = True
            self._note_list.focus_next_list_row(self.__get_focused())
            return Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up):
            self.get_root().using_keyboard_navigation = True
            self._note_list.focus_previous_list_row(self.__get_focused())
            return Gdk.EVENT_STOP
        elif not self.__searching() and not self.__selecting():
            if self._search_header_bar.check_if_starting(controller, keyval, state):
                self.__enter_search(clear_text=False)
                return Gdk.EVENT_STOP
        elif self.__searching() and not self.__selecting():
            if self._search_header_bar.check_if_starting(controller, keyval, state, clear=False):
                self._search_header_bar.focus_entry()
                return Gdk.EVENT_STOP

        return Gdk.EVENT_PROPAGATE

    def __on_toggle_favourite(self) -> None:
        if self.__selecting():
            self._selection_header_bar.toggle_favourite_on_selection()
        else:
            note = None
            window = self.get_root()
            focused = window.get_focus()
            if isinstance(focused, Gtk.ListBoxRow):
                note = focused.get_first_child().note
            if note:
                self.__update_favourite_on_notes([note], not note.favourite)

    def __on_category_shortcut(self) -> None:
        if self.__selecting():
            self._selection_header_bar.edit_category_for_selection()
        else:
            self.__toggle_sidebar()

    def __on_breakpoint_apply(self, _bp: Adw.Breakpoint) -> None:
        if self._overlay_view.get_pin_sidebar():
            self._overlay_view.set_pin_sidebar(False)
            self._overlay_view.set_collapsed(True)

        self._overlay_view.set_show_sidebar(False)
        self.__update_header_buttons_for_state()

    def __on_breakpoint_unapply(self, _bp: Adw.Breakpoint) -> None:
        if self.__config_manager.pin_sidebar:
            self._overlay_view.set_pin_sidebar(True)
            self._overlay_view.set_collapsed(False)
            self._overlay_view.set_show_sidebar(True)

        self.__update_header_buttons_for_state()

    def __load_older_notes(self, after: Optional[Callable] = None) -> None:
        # Somewhat clunky misuse of toast to replace previous revealer notification
        # Translators: Description, notification
        toast = Adw.Toast.new(_("Loading"))
        toast.set_timeout(300.0)
        self._toast_overlay.add_toast(toast)

        def load(toast: Adw.Toast, after: Optional[Callable]) -> None:
            if self.__note_manager.older_notes_model is None:
                self.__load_older_notes_worker()
            else:
                self._note_list.populate_older_notes(self.__note_manager.older_notes_model)
            self.__invalidate_view(check_older_notes=False)
            self._note_list.update_category_labels(self.__category)
            toast.dismiss()
            if after is not None:
                after()

        GLib.timeout_add(175, load, toast, after)

    def __create_and_open_note(self, content: Optional[str] = None) -> None:
        note = self.__note_manager.create_note(self.__category)
        # If we're creating a note from a selection persist it straight away
        if content is not None and content != "":
            note.content = content
            note.update_title_from_top_line()
            self.__note_manager.persist_note_while_editing(note, update_excerpt=True)
        self.__open_note(note, immediate=False)

    def __delete_note_from_keyboard(self) -> None:
        if self.__selecting():
            self._selection_header_bar.handle_delete_keyboard_shortcut()
        else:
            note = None
            window = self.get_root()
            focused = window.get_focus()
            if isinstance(focused, Gtk.ListBoxRow):
                note = focused.get_first_child().note
            if note:
                self.delete_notes([note])

    def __update_for_note_deletions(
        self, notes: list[Note], focused_row: Optional[FocusedRow]
    ) -> None:
        """Perform updates for deleted notes.

        :param list[Note] notes: The deleted notes
        :param FocusedRow focused_row: Row that was focused before the deletion, optional
        """
        if len(notes) > 1:
            # Translators: Description, notification, {} is a positive number
            msg = _("{} notes deleted").format(len(notes))
        else:
            # Translators: Description, notification
            msg = _("Note deleted")

        toast = Adw.Toast.new(msg)
        toast.set_timeout(self.UNDO_NOTE_DELETION_DURATION)
        # Translators: Button
        toast.set_button_label(_("Undo"))
        toast.connect("button-clicked", lambda _o: self.__undo_deletion())
        toast.connect("dismissed", lambda _o: self.__note_manager.on_deletion_undo_elapsed())
        self._toast_overlay.add_toast(toast)

        if self.__searching():
            self.__apply_search(focused_row)
        else:
            self.__invalidate_view(check_older_notes=False)
            self.__handle_emptied_category()
            if focused_row:
                self._note_list.place_focus_after_change(focused_row)

    def __invalidate_view(self, check_older_notes: bool) -> None:
        """Refresh the view."""
        # Update display of empty state, list, etc
        if self.__searching():
            self.__apply_search()
        else:
            self.__note_manager.invalidate_sort()
            older_loaded = self.__note_manager.update_filters(
                self.__category, self._note_list.older_notes_displayed, check_older_notes
            )
            if older_loaded:
                self._note_list.populate_older_notes(self.__note_manager.older_notes_model)

            if (
                self.__focused_row_pre_changes
                and len(self._note_list.get_model(self.__focused_row_pre_changes.listbox)) == 0
            ):
                self._note_list.place_focus_after_change(self.__focused_row_pre_changes)

            count = self.__note_manager.get_filtered_note_count(
                self._note_list.older_notes_displayed
            )
            if not self.__config_manager.first_start:
                if count == 0:
                    self._stack.set_visible_child(self._empty)
                elif self._stack.get_visible_child() in (
                    self._empty,
                    self._first_start,
                    self._loading,
                ):
                    self._stack.set_visible_child(self._note_list_scroll)

    def __start_search_from_cli(self, search_term: str) -> None:
        self._search_header_bar.text = search_term
        self.__enter_search(clear_text=False, avoid_select_all=True)

    def __enter_search(self, clear_text, avoid_select_all: bool = False) -> None:
        """Enter the search UI."""
        if self.__searching():
            # Grab focus and select entry text
            self._note_list.clear_selections()
            self._search_header_bar.enter(False, not avoid_select_all)
            return

        self.__close_sidebar()

        self._search_header_bar.enter(clear_text, False)

        all_category = self._sidebar.select_and_fetch_category(None, CategorySpecialPurpose.ALL)
        assert all_category
        self.__category = all_category

        self.__set_search_visible(True)

        if self.__note_manager.search_model is None:
            GLib.idle_add(self.__init_search_model)

    def __init_search_model(self) -> None:
        model = self.__note_manager.initiate_search_model()
        self._note_list.populate_search(model)

    def __apply_search(
        self,
        focused_row: Optional[FocusedRow] = None,
        invalidate_scroll_and_expansion: bool = False,
    ) -> None:
        if not self.__searching():
            return

        # If trying to search without caching completed delay the search
        if self.__note_manager.search_model is None:
            GLib.timeout_add(500, self.__apply_search, focused_row, invalidate_scroll_and_expansion)
            logging.debug("Delaying search due to model not ready")
            return

        show_all = (
            not invalidate_scroll_and_expansion and self._note_list.showing_expanded_search_set
        )
        result = self._search_header_bar.search(show_all)
        if result == IndexSearchState.RESULTS:
            self._stack.set_visible_child(self._note_list_scroll)
            if invalidate_scroll_and_expansion:
                self._note_list_scroll.get_vadjustment().set_value(0)
            elif focused_row:
                self._note_list.place_focus_after_change(focused_row)
        elif result == IndexSearchState.EMPTY:
            self._stack.set_visible_child(self._search_empty)
        elif result == IndexSearchState.NO_TERM:
            self._stack.set_visible_child(self._search_info)
            self._note_list.restrict_for_search_by_ids(None)

    def __start_selection(self) -> None:
        self.__close_sidebar()
        self.__searching_before_selection = self.__searching()
        self._header_stack.set_visible_child(self._selection_header_bar)
        self._selection_header_bar.activate()
        self.__update_enabled_actions(False)

    def __select_all(self) -> None:
        if self.__searching():
            if self._note_list.have_more_search_results:
                self._note_list.show_remaining_search_results()
        else:
            if not self._note_list.older_notes_displayed:
                self.__load_older_notes(self.__select_all)
                return

        if not self.__selecting():
            self.__start_selection()
        for note in self._note_list.iter_visible_notes():
            self._selection_header_bar.set_note_selected(note, True)

    def __cancel_selection(self) -> None:
        if self.__searching_before_selection:
            self._header_stack.set_visible_child(self._search_header_bar)
        else:
            self._header_stack.set_visible_child(self._main_header_bar)
        self._selection_header_bar.active = False
        self._note_list.clear_all_checkboxes()
        self.__update_enabled_actions(True)

        if self.get_root().using_keyboard_navigation:
            self._note_list.refocus_selected_row()
        else:
            self._note_list.clear_selections()

    def __searching(self) -> bool:
        return self._search_header_bar.active

    def __selecting(self) -> bool:
        return self._selection_header_bar.active

    def __set_search_visible(self, visible: bool) -> None:
        self._note_list.refresh_section_visibility(self.__category)
        if visible:
            self._header_stack.set_visible_child(self._search_header_bar)
            self._stack.set_visible_child(self._search_info)
        else:
            self._header_stack.set_visible_child(self._main_header_bar)
            self._stack.set_visible_child(self._note_list_scroll)
            if self.__note_manager.search_model is not None:
                self._note_list.restrict_for_search_by_ids(None)

    def __end_search(self) -> None:
        self._search_header_bar.exit()
        note = self._note_list.get_selected_note()
        self._note_list_scroll.get_vadjustment().set_value(0)
        self.__set_search_visible(False)
        # Could look at removing the category updating and filter invalidating parts of this call
        self.__on_category_activated(self.__category, close_sidebar=True)
        if note is not None:
            self._note_list.select_and_focus_note(note)
        else:
            self._note_list.move_focus_to_list_top()

    def __update_enabled_actions(self, enable: bool) -> None:
        for action_name in ("toggle-sidebar", "create-note", "enter-search", "select-notes"):
            action = self.__action_group.lookup(action_name)
            action.set_property("enabled", enable)

    def __load_older_notes_worker(self) -> None:
        model = self.__note_manager.older_notes_model
        if model is None:
            model = self.__note_manager.initiate_older_notes_model()
        self.__note_manager.filter_older_notes_by_date()

        loaded_notes = self._note_list.populate_older_notes(model)
        if loaded_notes:
            if self._stack.get_visible_child() in (self._empty, self._first_start):
                self._stack.set_visible_child(self._note_list_scroll)

    def __on_initial_load_from_db_complete(self) -> None:
        self.__invalidate_view(check_older_notes=True)
        self._note_list.refresh_section_visibility(self.__category)
        self._note_list.clear_selections()

        self.__initialised = True
        GLib.idle_add(self._note_list.update_category_labels, self.__category)
        if self.__search_after_init is not None:
            GLib.idle_add(self.__start_search_from_cli, self.__search_after_init)

    def __on_new_note_persisted(self, _obj: GObject.Object) -> None:
        self.__invalidate_view(check_older_notes=False)
        self._note_list.update_category_labels(self.__category)

    def __open_note(self, note: Note, immediate: bool) -> None:
        self.__open_note_row = self._note_list.get_current_selection()
        self.emit("note-opened", note, immediate)

    def __undo_deletion(self) -> None:
        if self.__note_manager.undo_deletion():
            self.__invalidate_view(check_older_notes=False)

    def __get_focused(self) -> Optional[Gtk.Widget]:
        window = self.get_root()
        return window.get_focus()

    def __handle_emptied_category(self, note: Optional[Note] = None) -> bool:
        # If we're not filtered by category this won't be an issue
        if not self.__is_showing_category():
            return False

        # Including "older notes" here as we always show older notes when displaying a category
        view_empty = self.__note_manager.get_filtered_note_count(include_older_notes=True) == 0
        if view_empty:
            if note is not None and note.category != "":
                new_category = self._sidebar.select_and_fetch_category(note.category)
            else:
                new_category = self._sidebar.select_and_fetch_category(
                    None, CategorySpecialPurpose.ALL
                )
            # Largely form mypy
            if not new_category:
                logging.warning("Failed to find category?")
                return False
            self.__on_category_activated(new_category, close_sidebar=True)
            return True

        return False

    def __update_favourite_on_selection(self, value: bool) -> None:
        """Change whether selected notes are in favourites.

        :param bool value: The new value
        """
        notes = self._selection_header_bar.get_selected()
        self.__update_favourite_on_notes(notes, value)

    def __update_favourite_on_notes(self, notes: list[Note], value: bool) -> None:
        """Change whether notes are in favourites.

        :param list[Note] notes: Notes to update
        :param bool value: The new value
        """
        focused_row = self._note_list.get_current_selection()
        self.__cancel_selection()
        if self.__note_manager.set_and_persist_favourite_for_notes(notes, value):
            self.__sync_manager.sync_now()
            if self.__searching():
                self.__apply_search()
            else:
                self.__invalidate_view(check_older_notes=False)
                if focused_row:
                    self._note_list.place_focus_after_change(focused_row)

    def __cancel_out_of_context(self, including_category: bool) -> None:
        if including_category:
            if (
                self.__category.special_purpose is None
                or self.__category.special_purpose != CategorySpecialPurpose.ALL
            ):
                category, _ = self.__category_manager.get_special_purpose_category(
                    CategorySpecialPurpose.ALL
                )
                # close_sidebar is False as we're closing it below
                self.__on_category_activated(category, close_sidebar=False)
            else:
                self._note_list.disconnect_older_notes_section()
                self._note_list.refresh_section_visibility(self.__category)
                self._note_list_scroll.get_vadjustment().set_value(0)
        closed_the_sidebar = self.__close_sidebar()
        if not closed_the_sidebar:
            if self.__selecting():
                self.__cancel_selection()
            elif self.__searching():
                self.__end_search()

    def __close_sidebar(self) -> bool:
        if self.__config_manager.pin_sidebar and self.get_width() >= self.BREAKPOINT_WIDTH:
            return False
        elif not self._overlay_view.get_show_sidebar():
            return False

        self._overlay_view.set_show_sidebar(False)
        if self.get_root().using_keyboard_navigation:
            self._note_list.grab_focus()
        self.__update_header_buttons_for_state()
        return True

    def __toggle_sidebar(self) -> None:
        if self.__config_manager.pin_sidebar and self.get_width() >= self.BREAKPOINT_WIDTH:
            # Pinned sidebar is open, let's just throw the focus around (as this block can only be
            # entered via keyboard shortcut)
            self.get_root().using_keyboard_navigation = True
            if self._sidebar.has_focus_within():
                self._note_list.grab_focus()
            else:
                self._sidebar.take_focus()
        else:
            if self._overlay_view.get_show_sidebar():
                self._overlay_view.set_show_sidebar(False)
                if self.get_root().using_keyboard_navigation:
                    self._note_list.grab_focus()
            else:
                self._overlay_view.set_show_sidebar(True)
                self._sidebar.take_focus()

    def __update_for_sidebar_pin_change(self, width: int = -1) -> None:
        if width == -1:
            width = self.get_width()
        if width >= self.BREAKPOINT_WIDTH:
            pin = self.__config_manager.pin_sidebar
            self._overlay_view.set_pin_sidebar(pin)
            self._overlay_view.set_show_sidebar(pin)
            self._overlay_view.set_collapsed(not pin)
        else:
            self._overlay_view.set_pin_sidebar(False)
            self._overlay_view.set_show_sidebar(False)
            self._overlay_view.set_collapsed(True)
        self.__update_header_buttons_for_state(width)

    def __update_header_buttons_for_state(self, width: int = -1) -> None:
        if width == -1:
            width = self.get_width()
            # Used during initialisation
            if width == 0:
                width = self.get_property("width-request")
        if width >= self.BREAKPOINT_WIDTH and self.__config_manager.pin_sidebar:
            show_sidebar_close = False
            show_content_sidebar_button = False
            show_sidebar_menu = True
        else:
            show_sidebar_close = True
            show_content_sidebar_button = True
            show_sidebar_menu = False
        self._content_sidebar_button.set_visible(show_content_sidebar_button)
        self._sidebar.show_buttons(show_sidebar_close, show_sidebar_menu)
        self._menu_button.set_visible(not show_sidebar_menu)

    def __perform_initial_sync(self) -> None:
        self.__initial_sync_delay_complete = True
        self.__sync_manager.sync_now()

    def __is_showing_category(self) -> bool:
        return self.__category.special_purpose is None
