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

import logging
from typing import List, Optional

from iotas.category import Category, CategorySpecialPurpose
from iotas.category_manager import CategoryManager
import iotas.config_manager
from iotas import const
from iotas.index_search_header_bar import IndexSearchState
from iotas.note import Note
from iotas.note_manager import NoteManager
from iotas.selection_header_bar import SelectionHeaderBar
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, ()),
    }

    UNDO_NOTE_DELETION_DURATION = 5.0
    SYNC_CONFLICT_NOTIFICATION_DURATION = 5.0
    SYNC_UPDATE_NOTIFICATION_DURATION = 2.0
    BREAKPOINT_WIDTH = 700

    _split_view = Gtk.Template.Child()
    _breakpoint = Gtk.Template.Child()

    _stack = Gtk.Template.Child()
    _sidebar = Gtk.Template.Child()
    _note_list_scroll = Gtk.Template.Child()
    _note_list = Gtk.Template.Child()
    _first_start = Gtk.Template.Child()
    _empty = Gtk.Template.Child()
    _search_info = Gtk.Template.Child()
    _search_empty = Gtk.Template.Child()

    _header_stack = Gtk.Template.Child()
    _main_header_bar = Gtk.Template.Child()
    _content_sidebar_button = Gtk.Template.Child()
    _search_header_bar = Gtk.Template.Child()
    _selection_header_bar = Gtk.Template.Child()
    _title = Gtk.Template.Child()

    _notification = Gtk.Template.Child()
    _timed_notification = Gtk.Template.Child()

    _menu_button = Gtk.Template.Child()
    _new_button = Gtk.Template.Child()

    _offline_banner = Gtk.Template.Child()
    _app_id_change_reauthenticate_banner = Gtk.Template.Child()
    _generic_reauthenticate_banner = Gtk.Template.Child()

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

        self.__note_manager = None
        self.__category_manager = None
        self.__sync_manager = None

        self.__active = False
        self.__add_fav_action = None
        self.__remove_fav_action = None
        self.__initialised = False
        self.__search_after_init = None
        self.__searching_before_selection = False
        self.__cancel_action = 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", self.__on_note_opened)
        self._note_list.connect("note-checkbox-activated", self.__on_note_checkbox_activated)
        self._note_list.connect("note-checkbox-deactivated", self.__on_note_checkbox_deactivated)

        if not iotas.config_manager.get_first_start():
            # Translators: Description, notification
            self._notification.show(_("Loading"), immediate=True)

        self._selection_header_bar.connect("categories-changed", self.__on_notes_categories_changed)
        self._selection_header_bar.connect("delete", self.__on_notes_deleted)
        self._selection_header_bar.connect("set-favourite", self.__on_notes_set_favourite)
        self._selection_header_bar.connect("clear-favourite", self.__on_notes_clear_favourite)
        self._selection_header_bar.connect("abort", self.__on_category_header_bar_abort)

        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", self.__on_search_changed)

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.INDEX_CATEGORY_STYLE}", self.__category_style_changed
        )

        iotas.config_manager.settings.connect(
            f"changed::{iotas.config_manager.PERSIST_SIDEBAR}", self.__on_persist_sidebar_changed
        )

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

    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", 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 iotas.config_manager.get_first_start():
            self._stack.set_visible_child(self._first_start)
        else:
            self._stack.set_visible(False)

        self.__sync_manager.connect("ready", self.__on_sync_ready)
        self.__sync_manager.connect("started", self.__on_sync_started)
        self.__sync_manager.connect("finished", self.__on_sync_finished)
        self.__sync_manager.connect("missing-password", self.__on_missing_password)

        self.__sync_manager.init_auth()

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

    def update_for_note_deletions(self, notes: List[Note]) -> None:
        """Perform updates for deleted notes.

        :param List[Note] notes: The deleted notes
        """
        if len(notes) > 1:
            # Translators: Description, notification, {} is a positive number
            msg = _("{} notes deleted").format(len(notes))
        else:
            # Translators: Description, notification
            msg = _("Note deleted")
        self._timed_notification.show(
            msg,
            self.UNDO_NOTE_DELETION_DURATION,
            # Translators: Button
            _("Undo"),
            self.__undo_deletion,
            self.__note_manager.on_deletion_undo_elapsed,
        )
        if self.__searching():
            self.__apply_search()
        else:
            self.__invalidate_view()
            self.__handle_emptied_category()

    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:
            self.__cancel_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_persistence_change(width)
        if not iotas.config_manager.get_persist_sidebar():
            self._split_view.set_collapsed(True)
        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."""
        self._timed_notification.show(
            # Translators: Description, notification
            _("Sync conflict with note being edited"),
            self.SYNC_CONFLICT_NOTIFICATION_DURATION,
        )

    def show_secret_service_failure_alert(self) -> None:
        # Somewhat a clunky placeholder approach for now
        self._timed_notification.show(
            _(
                # 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."
            ),
            0.0,
            # Translators: Button
            _("OK"),
        )

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

        :param Note note: The closed note
        """
        if self.__searching():
            self.__apply_search()
        else:
            self.__invalidate_view()
            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

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

    @active.setter
    def 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_edge_overshot(self, _obj: Gtk.ScrolledWindow, pos: Gtk.PositionType) -> None:
        if pos == Gtk.PositionType.BOTTOM:
            if not self.__searching() and not self._note_list.older_notes_displayed:
                self.__load_older_notes()
            elif self.__searching() and self._note_list.have_more_search_results:
                self._note_list.show_remaining_search_results()
                self.__note_manager.update_filters(self.__category)

    def __on_search_changed(self, _object: GObject.Object) -> None:
        self.__apply_search()

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

        action = Gio.SimpleAction.new("toggle-sidebar")
        action.connect("activate", self.__on_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", 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", self.__on_create_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("delete-from-index")
        action.connect("activate", self.__on_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", self.__on_load_older_notes)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("show-more")
        action.connect("activate", self.__on_show_more)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("enter-search")
        action.connect("activate", self.__on_enter_search)
        action_group.add_action(action)
        app.set_accels_for_action("index.enter-search", ["<Control>f"])

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

        action = Gio.SimpleAction.new("cancel")
        action.connect("activate", self.__cancel)
        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", self.__reset_filters)
        action_group.add_action(action)
        app.set_accels_for_action("index.reset-filters", ["<Control>BackSpace", "<Control>Delete"])

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

        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_load_older_notes(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__load_older_notes()

    def __on_show_more(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self._note_list.show_remaining_search_results()
        self.__note_manager.update_filters(self.__category)

    def __on_realize(self, _widget: Gtk.Widget) -> None:
        self.__note_manager.update_filters(self.__category)
        self.__note_manager.initiate_model_from_db()
        self.__category_manager.populate()

    def __on_create_note(
        self,
        _obj: GObject.Object,
        _value: GObject.ParamSpec,
    ) -> None:
        """Create a new note and edit it."""
        self.__create_and_open_note()

    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:
        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()
        else:
            self.__invalidate_view()
            self.__handle_emptied_category()

    def __on_notes_clear_favourite(self, _obj: SelectionHeaderBar) -> None:
        notes = self._selection_header_bar.get_selected()
        self.__update_favourites(notes, False)
        self.__cancel_selection()

    def __on_notes_set_favourite(self, _obj: SelectionHeaderBar) -> None:
        notes = self._selection_header_bar.get_selected()
        self.__update_favourites(notes, True)
        self.__cancel_selection()

    def __on_notes_deleted(self, _obj: SelectionHeaderBar) -> None:
        notes = self._selection_header_bar.get_selected()
        self.__delete_notes(notes)
        self.__cancel_selection()

    def __on_note_opened(self, _obj: GObject.Object, note: Note) -> None:
        self.emit("note-opened", note, False)

    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_note_checkbox_deactivated(self, _obj: GObject.Object, note: Note) -> None:
        self._selection_header_bar.set_note_selected(note, False)

    def __on_category_header_bar_abort(self, _obj: SelectionHeaderBar) -> None:
        self.__cancel_selection()

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

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

        self._title.set_label(category.display_name)
        self.__invalidate_view()
        if self.__category.special_purpose is None and not self._note_list.older_notes_loaded:
            self.__load_older_notes_and_notify(
                displayed=False, time_filtered=True, update_section_visibility=True
            )
        else:
            self._note_list.refresh_section_visibility(self.__category)
        self._note_list.update_category_labels(self.__category)
        if close_sidebar and (
            not iotas.config_manager.get_persist_sidebar()
            or self.get_width() < self.BREAKPOINT_WIDTH
        ):
            self.__close_sidebar()
        if self.get_root().using_keyboard_navigation:
            self._note_list.move_focus_to_list_top()

    def __on_enter_search(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__enter_search(clear_text=True)

    def __on_enter_selection(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__start_selection()

    def __on_sync_ready(self, _obj: GObject.Object, new_setup: bool) -> None:
        if new_setup and 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 iotas.config_manager.get_show_syncing_debug_notification():
            # Translators: Description, notification
            self._notification.show(_("Syncing"))

    def __on_sync_finished(self, _obj: SyncManager, changes: int) -> None:
        if self.active:
            self._notification.hide()
            if changes > 0:
                self.__invalidate_view()
                self._note_list.update_category_labels(self.__category)

                if changes == 1:
                    # Translators: Description, notification
                    msg = _("1 change")
                else:
                    # Translators: Description, notification, {} is a number larger than 1
                    msg = _("{} changes").format(changes)
                self._timed_notification.show(
                    msg, self.SYNC_UPDATE_NOTIFICATION_DURATION, show_icon=True
                )

    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_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):
            if (
                not iotas.config_manager.get_persist_sidebar()
                and not self._split_view.get_collapsed()
            ):
                # If we have an auto-expanded sidebar use the shortcut to send the focus
                self.get_root().using_keyboard_navigation = True
                self._sidebar.take_focus()
            else:
                self._note_list.move_focus_to_list_top()
            return Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up):
            self._note_list.move_focus_to_list_top()
            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 litle 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._note_list.focus_next_list_row(self.__get_focused())
            return Gdk.EVENT_STOP
        elif keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up):
            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_delete_note_from_keyboard(
        self, _action: Gio.SimpleAction, _param: GLib.Variant
    ) -> 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 __on_toggle_favourite(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        if self.__selecting():
            self._selection_header_bar.toggle_favourite_on_selection()

    def __on_category_shortcut(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        if self.__selecting():
            self._selection_header_bar.edit_category_for_selection()
        else:
            self.__toggle_sidebar()

    def __on_toggle_sidebar(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        self.__toggle_sidebar()

    def __on_breakpoint_apply(self, _bp: Adw.Breakpoint) -> None:
        self._split_view.set_collapsed(True)
        self.__update_header_buttons_for_state()

    def __on_breakpoint_unapply(self, _bp: Adw.Breakpoint) -> None:
        if iotas.config_manager.get_persist_sidebar():
            self._split_view.set_collapsed(False)
        else:
            self._split_view.set_collapsed(True)
        self.__update_header_buttons_for_state()

    def __load_older_notes(self) -> None:
        if not self._note_list.older_notes_loaded:
            self.__load_older_notes_and_notify()
        else:
            self._note_list.display_older_notes()
        self.__invalidate_view()
        self._note_list.update_category_labels(self.__category)

    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)
        self.emit("note-opened", note, False)

    def __invalidate_view(self) -> None:
        """Refresh the view."""
        self.__note_manager.invalidate_sort()
        self.__note_manager.update_filters(self.__category)

        # Update display of empty state, list, etc
        if self.__searching():
            if self._note_list.older_notes_loaded_but_empty:
                self._stack.set_visible_child(self._search_empty)
        else:
            include_older = self._note_list.older_notes_displayed or self.__is_showing_category()
            count = self.__note_manager.get_filtered_note_count(include_older)
            if not iotas.config_manager.get_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._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._search_header_bar.enter(clear_text, False)

        all_category = self._sidebar.select_and_fetch_category(None, CategorySpecialPurpose.ALL)
        self.__category = all_category
        if not iotas.config_manager.get_persist_sidebar() and not self._split_view.get_collapsed():
            self._split_view.set_collapsed(True)

        self.__set_search_visible(True)
        if not self._note_list.older_notes_loaded:
            self.__load_older_notes_and_notify(displayed=False, time_filtered=False)

    def __apply_search(self) -> None:
        if not self.__searching():
            return

        # If trying to search without caching completed delay the search
        if not self._note_list.older_notes_loaded:
            GLib.timeout_add(500, self.__apply_search)
            logging.debug("Delaying search due to model not ready")
            return

        result = self._search_header_bar.search()
        if result == IndexSearchState.RESULTS:
            self.__invalidate_view()
            self._note_list.update_category_labels(self.__category)
            self._stack.set_visible_child(self._note_list_scroll)
            self._note_list_scroll.get_vadjustment().set_value(0)
        elif result == IndexSearchState.EMPTY:
            self._stack.set_visible_child(self._search_empty)
        elif result == IndexSearchState.INFO:
            self._stack.set_visible_child(self._search_info)
            self._note_list.restrict_for_search_by_ids(None)

    def __start_selection(self) -> None:
        if not iotas.config_manager.get_persist_sidebar() and not self._split_view.get_collapsed():
            self._split_view.set_collapsed(True)
        self.__searching_before_selection = self.__searching()
        self._header_stack.set_visible_child(self._selection_header_bar)
        # Prevent focus jumping to top row of listbox and resulting scroll
        if not self.get_root().using_keyboard_navigation:
            self._content_sidebar_button.grab_focus()
        self._selection_header_bar.activate()
        self.__update_enabled_actions(False)

    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()
            # Prevent focus jumping to top row of listbox and resulting scroll
            self._content_sidebar_button.grab_focus()

    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:
        if visible:
            self._header_stack.set_visible_child(self._search_header_bar)
        else:
            self._header_stack.set_visible_child(self._main_header_bar)
        self._note_list.refresh_section_visibility(self.__category)
        self._note_list.update_older_notes_loader_visibility_for_search(visible)
        if visible:
            self._stack.set_visible_child(self._search_info)
        else:
            self._stack.set_visible_child(self._note_list_scroll)
            if self._note_list.older_notes_loaded:
                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)
        self.__on_category_activated(self.__category)
        if self.get_root().using_keyboard_navigation:
            if note is not None:
                self._note_list.select_and_focus_note(note)
            else:
                self._note_list.move_focus_to_list_top()
        else:
            # Prevent focus loss, type to search stopping to work
            self._content_sidebar_button.grab_focus()

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

    def __load_older_notes_and_notify(
        self,
        displayed: bool = True,
        time_filtered: bool = True,
        update_section_visibility: bool = False,
    ) -> None:
        """Load the remaining bulk of notes.

        Populate the remaining notes which haven't been shown in the index as they are beyond the
        two month old mark.

        :param bool displayed: The user has requested this load action and the notes should be
            shown in the main index. If false the notes are being loaded for searching and won't
            be displayed in the main index yet (which retains performance in the main list)
        :param bool time_filtered: Whether to initially filter the notes by time (false for
            search)
        :param bool update_section_visibility: Whether section visibility should be updated
        """

        def precache() -> None:
            GLib.idle_add(
                self.__load_older_notes_worker,
                displayed,
                time_filtered,
                update_section_visibility,
            )

        # Translators: Description, notification
        self._notification.show(_("Loading"), immediate=True, callback=precache)

    def __load_older_notes_worker(
        self,
        displayed: bool = True,
        time_filtered: bool = True,
        update_section_visibility: bool = False,
    ) -> None:
        self.__older_notes_model = self.__note_manager.initiate_older_notes_model()
        if time_filtered:
            self.__note_manager.filter_older_notes_by_date()

        loaded_notes = self._note_list.populate_older_notes(
            self.__note_manager.older_notes_model, self.__on_listbox_key_pressed, displayed
        )
        if update_section_visibility:
            self._note_list.refresh_section_visibility(self.__category)

        if loaded_notes:
            if self._stack.get_visible_child() in (self._empty, self._first_start):
                self._stack.set_visible_child(self._note_list_scroll)
        self._notification.hide()

    def __on_initial_load_from_db_complete(
        self, _obj: GObject.Object, older_notes_loaded: bool
    ) -> None:
        self.__invalidate_view()

        # Load all notes at startup if there aren't many in total, or are few in the
        # last two months
        if older_notes_loaded:
            self.__older_notes_model = self.__note_manager.older_notes_model
            self._note_list.populate_older_notes(
                self.__note_manager.older_notes_model, self.__on_listbox_key_pressed
            )

        self._stack.set_visible(True)

        self._note_list.refresh_section_visibility(self.__category)

        self._notification.hide()

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

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

    def __on_deletion_undo_elapsed(self) -> None:
        self.__note_manager.on_deletion_undo_elapsed(self.__sync_manager.authenticated)

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

    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(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
                )
            self.__on_category_activated(new_category)
            return True

        return False

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

        :param List[Note] notes: Notes to modify
        :param bool value: The new value
        """
        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()

    def __cancel(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        """Handle a user cancel action (eg. hitting escape)

        If searching returns to normal view.
        """
        self.__cancel_out_of_context(False)

    def __reset_filters(self, _action: Gio.SimpleAction, _param: GLib.Variant) -> None:
        """Handle a user request to reset the index."""
        self.__cancel_out_of_context(True)

    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, False)
        if not iotas.config_manager.get_persist_sidebar() and not self._split_view.get_collapsed():
            self.__close_sidebar()
        elif self.__selecting():
            self.__cancel_selection()
        elif self.__searching():
            self.__end_search()

    def __close_sidebar(self) -> None:
        if self.get_width() < self.BREAKPOINT_WIDTH:
            self._split_view.set_show_content(True)
        else:
            self._split_view.set_collapsed(True)
        if self.get_root().using_keyboard_navigation:
            self._note_list.grab_focus()
        self.__update_header_buttons_for_state()

    def __toggle_sidebar(self) -> None:
        if iotas.config_manager.get_persist_sidebar() and self.get_width() >= self.BREAKPOINT_WIDTH:
            # Persistent sidebar is open, let's just throw the focus around
            if self._sidebar.has_focus_within():
                self.get_root().using_keyboard_navigation = True
                self._note_list.grab_focus()
            else:
                self._sidebar.take_focus()
        else:
            if self.get_width() < self.BREAKPOINT_WIDTH:
                # Thin / within breakpoint window, toggle sidebar and content
                self._split_view.set_show_content(not self._split_view.get_show_content())
            else:
                # Wider / beyond breakpoint window
                if self._split_view.get_collapsed():
                    self._sidebar.take_focus()
                    self._split_view.set_collapsed(False)
                else:
                    self._split_view.set_collapsed(True)
                    if self.get_root().using_keyboard_navigation:
                        self._note_list.grab_focus()
            self.__update_header_buttons_for_state()

    def __category_style_changed(self, _obj: Gio.Settings, _key: str) -> None:
        self._note_list.update_category_labels(self.__category)

    def __on_persist_sidebar_changed(self, _obj: Gio.Settings, _key: str) -> None:
        self.__update_for_sidebar_persistence_change()

    def __update_for_sidebar_persistence_change(self, width: int = -1) -> None:
        if width == -1:
            width = self.get_width()
        persist = iotas.config_manager.get_persist_sidebar()
        if persist:
            self._split_view.set_collapsed(width < self.BREAKPOINT_WIDTH)
        else:
            self._split_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")
        persist = iotas.config_manager.get_persist_sidebar()
        if persist:
            show_content_button = width < self.BREAKPOINT_WIDTH
            show_sidebar_close = width < self.BREAKPOINT_WIDTH
        else:
            show_content_button = self._split_view.get_collapsed() and (
                width >= self.BREAKPOINT_WIDTH or self._split_view.get_show_content()
            )
            show_sidebar_close = not show_content_button
        show_sidebar_menu = persist and not self._split_view.get_collapsed()
        self._content_sidebar_button.set_visible(show_content_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 __delete_notes(self, notes: List[Note]) -> None:
        """Delete the specified notes, flushing any undo-pending notes first.

        :param List[Note] notes: Notes to delete
        """
        self.__sync_manager.flush_pending_deletions()
        self.__note_manager.delete_notes(notes)
        self.update_for_note_deletions(notes)

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