from gi.repository import GObject, Gio, Gtk

from typing import Any, Callable, List, Optional

from iotas.category import Category
import iotas.config_manager
from iotas.index_row import IndexRow
from iotas.note import Note
from iotas.note_list_model import NoteListModelTimeFiltered
from iotas.note_manager import NoteManager


@Gtk.Template(resource_path="/org/gnome/World/Iotas/ui/index_note_list.ui")
class IndexNoteList(Gtk.Box):
    __gtype_name__ = "IndexNoteList"
    __gsignals__ = {
        "note-opened": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "note-checkbox-activated": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
        "note-checkbox-deactivated": (GObject.SignalFlags.RUN_FIRST, None, (Note,)),
    }

    MINIMUM_SEARCH_PAGE_NOTE_COUNT = 15
    ROW_HEIGHT = 80

    _favourites_listbox = Gtk.Template.Child()
    _favourites_section = Gtk.Template.Child()
    _today_listbox = Gtk.Template.Child()
    _today_section = Gtk.Template.Child()
    _yesterday_listbox = Gtk.Template.Child()
    _yesterday_section = Gtk.Template.Child()
    _week_listbox = Gtk.Template.Child()
    _week_section = Gtk.Template.Child()
    _month_listbox = Gtk.Template.Child()
    _month_section = Gtk.Template.Child()
    _last_month_listbox = Gtk.Template.Child()
    _last_month_section = Gtk.Template.Child()
    _older_notes_loader = Gtk.Template.Child()
    _older_notes_listbox = Gtk.Template.Child()
    _older_notes_section = Gtk.Template.Child()
    _older_notes_label = Gtk.Template.Child()
    _show_more_button = Gtk.Template.Child()

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

        self.__selecting = False
        self.__searching = False
        self.__search_full_set = []
        self.__older_notes_displayed = False
        self.__search_base_note_count = self.MINIMUM_SEARCH_PAGE_NOTE_COUNT

        self.__listboxes = []
        self.__listboxes.append(self._favourites_listbox)
        self.__listboxes.append(self._today_listbox)
        self.__listboxes.append(self._yesterday_listbox)
        self.__listboxes.append(self._week_listbox)
        self.__listboxes.append(self._month_listbox)
        self.__listboxes.append(self._last_month_listbox)

        self.bind_property(
            "searching",
            self._older_notes_label,
            "visible",
            GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.INVERT_BOOLEAN,
        )

    def setup(
        self,
        note_manager: NoteManager,
        listbox_keypress_func: Callable,
    ) -> None:
        """Perform initial setup.

        :param NoteManager note_manager: The note manager
        :param Callable listbox_keypress_func: Function to call on listbox keypress
        """

        self.__favourites_model = note_manager.favourites_model
        self.__today_model = note_manager.today_model
        self.__yesterday_model = note_manager.yesterday_model
        self.__week_model = note_manager.week_model
        self.__month_model = note_manager.month_model
        self.__last_month_model = note_manager.last_month_model
        self.__older_notes_model = None

        self._favourites_listbox.bind_model(self.__favourites_model, self.__create_row, None)
        self._today_listbox.bind_model(self.__today_model, self.__create_row, None)
        self._yesterday_listbox.bind_model(self.__yesterday_model, self.__create_row, None)
        self._week_listbox.bind_model(self.__week_model, self.__create_row, None)
        self._month_listbox.bind_model(self.__month_model, self.__create_row, None)
        self._last_month_listbox.bind_model(self.__last_month_model, self.__create_row, None)

        def update_visibility(
            model: Gio.ListModel,
            _pos: int,
            _removed: int,
            _added: int,
            section: Gtk.Widget,
        ) -> None:
            section.set_visible(not self.searching and len(model) > 0)

        self.__favourites_model.connect(
            "items-changed", update_visibility, self._favourites_section
        )
        self.__today_model.connect("items-changed", update_visibility, self._today_section)
        self.__yesterday_model.connect("items-changed", update_visibility, self._yesterday_section)
        self.__week_model.connect("items-changed", update_visibility, self._week_section)
        self.__month_model.connect("items-changed", update_visibility, self._month_section)
        self.__last_month_model.connect(
            "items-changed", update_visibility, self._last_month_section
        )

        for listbox in self.__listboxes:
            controller = Gtk.EventControllerKey()
            controller.connect("key-pressed", listbox_keypress_func)
            listbox.add_controller(controller)
            listbox.connect("row-activated", self.__on_row_activated)
            listbox.connect("row-selected", self.__on_row_selected)

    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.__search_base_note_count = max(
            self.MINIMUM_SEARCH_PAGE_NOTE_COUNT, int((height / self.ROW_HEIGHT) + 5)
        )

    def clear_selections(self) -> None:
        """Clear all selections in the list."""
        self.__clear_selections_excluding()

    def populate_older_notes(
        self,
        older_notes_model: NoteListModelTimeFiltered,
        listbox_keypress_func: Callable,
        display_section: bool = True,
    ) -> bool:
        """Populate notes older than two months.

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

        :param NoteListModelTimeFiltered older_notes_model: Model for the older notes
        :param Callable listbox_keypress_func: Function to call on listbox keypress
        :param bool display_section: 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)
        :return: Whether the model contains any notes after population
        :rtype: bool

        """

        loaded_notes = False
        if display_section:
            self.__older_notes_displayed = True

        if self.__older_notes_model is None:
            self.__older_notes_model = older_notes_model
            self._older_notes_listbox.bind_model(self.__older_notes_model, self.__create_row, None)
            self.__listboxes.append(self._older_notes_listbox)
            self.__older_notes_model.connect("items-changed", self.__older_notes_items_changed)
            controller = Gtk.EventControllerKey()
            controller.connect("key-pressed", listbox_keypress_func)
            self._older_notes_listbox.add_controller(controller)
            self._older_notes_listbox.connect("row-activated", self.__on_row_activated)
            self._older_notes_listbox.connect("row-selected", self.__on_row_selected)

        if display_section:
            self._older_notes_loader.set_visible(False)
        if len(self.__older_notes_model) > 0:
            if display_section:
                self._older_notes_section.set_visible(True)
            loaded_notes = True

        return loaded_notes

    def refresh_section_visibility(self, category: Category) -> None:
        """Refresh visibility of list sections.

        :param Category category: The current category
        """
        self._favourites_section.set_visible(
            not self.searching and len(self.__favourites_model) > 0
        )
        self._today_section.set_visible(not self.searching and len(self.__today_model) > 0)
        self._yesterday_section.set_visible(not self.searching and len(self.__yesterday_model) > 0)
        self._week_section.set_visible(not self.searching and len(self.__week_model) > 0)
        self._month_section.set_visible(not self.searching and len(self.__month_model) > 0)
        self._last_month_section.set_visible(
            not self.searching and len(self.__last_month_model) > 0
        )
        # When not searching only display older notes (before previous month) when it's been
        # actively chosen or we're showing a category
        show = (self.__older_notes_displayed or category.special_purpose is None) and len(
            self.__older_notes_model
        ) > 0
        self._older_notes_section.set_visible(self.searching or show)
        if self.searching:
            self._older_notes_loader.set_visible(False)
        else:
            older_notes_visible = self.__older_notes_displayed or category.special_purpose is None
            self._older_notes_loader.set_visible(not older_notes_visible)

    def update_older_notes_loader_visibility_for_search(self, searching: bool) -> None:
        """Updates whether the older notes loading section is visible, during search.

        :param bool searching: Whether searching
        """
        if searching:
            self._older_notes_loader.set_visible(False)
        else:
            if not self.older_notes_displayed:
                self._older_notes_loader.set_visible(True)

    def get_selected_note(self) -> Optional[Note]:
        """Fetch the currently selected note.

        :return: The selected note, if applicable
        :rtype: Optional[Note]
        """
        selected = None
        for listbox in self.__listboxes:
            if listbox.get_parent().get_visible():
                row = listbox.get_selected_row()
                if row:
                    selected = row.get_child().note
                    break
        return selected

    def move_focus_to_list_top(self) -> None:
        """Move focus to the top of the first visible section."""
        self.get_root().using_keyboard_navigation = True

        for listbox in self.__listboxes:
            if listbox.get_parent().get_visible():
                row = listbox.get_row_at_index(0)
                row.grab_focus()
                listbox.select_row(row)
                break

    def refocus_selected_row(self) -> bool:
        """Refocus the selected row.

        :return: Whether a selected row was found
        :rtype: bool
        """
        focused = False
        for listbox in self.__listboxes:
            if listbox.get_parent().get_visible():
                row = listbox.get_selected_row()
                if row:
                    row.grab_focus()
                    focused = True
                    break
        return focused

    def grab_focus(self) -> None:
        """Pull the focus back to the list."""
        if not self.refocus_selected_row():
            self.move_focus_to_list_top()

    def clear_all_checkboxes(self):
        """Clear all row checkboxes."""
        for listbox in self.__listboxes:
            child = listbox.get_first_child()
            while child is not None:
                index_row = child.get_first_child()
                index_row.set_checkbox_value(False)
                child = child.get_next_sibling()

    def update_category_labels(self, current_category: Category) -> None:
        """Update category labels for each row.

        :param Category current_category: The current category
        """
        style = iotas.config_manager.get_index_category_style()
        for listbox in self.__listboxes:
            if not listbox.get_parent().get_visible():
                continue
            child = listbox.get_first_child()
            while child is not None:
                index_row = child.get_first_child()
                index_row.update_category(current_category, style)
                child = child.get_next_sibling()

    def select_and_focus_note(self, note: Note):
        """Attempt to select and focus the provided note.

        :param Note note: The note
        """
        self.clear_selections()
        found = False
        for listbox in self.__listboxes:
            if not listbox.get_parent().get_visible():
                continue
            child = listbox.get_first_child()
            while child is not None and not found:
                if child.get_first_child().note == note:
                    listbox.select_row(child)
                    child.grab_focus()
                    found = True
                child = child.get_next_sibling()

            if found:
                break

    def focus_next_list_row(self, focused_row: Gtk.Widget) -> None:
        """Focus the next list row, moving to another section if necessary.

        :param Gtk.Widget focused_row: The currently focused row
        """
        listbox = focused_row.get_parent()
        bottom_row = listbox.get_last_child()
        if focused_row == bottom_row:
            self.__focus_next_list_box(listbox)
        else:
            next_row = focused_row.get_next_sibling()
            listbox.select_row(next_row)
            next_row.grab_focus()

    def focus_previous_list_row(self, focused_row: Gtk.Widget) -> None:
        """Focus the previous list row, moving to another section if necessary.

        :param Gtk.Widget focused_row: The currently focused row
        """
        listbox = focused_row.get_parent()
        top_row = listbox.get_first_child()
        if focused_row == top_row:
            self.__focus_previous_list_box(listbox)
        else:
            previous_row = focused_row.get_prev_sibling()
            listbox.select_row(previous_row)
            previous_row.grab_focus()

    def restrict_for_search_by_ids(self, ids: Optional[List[int]]) -> None:
        """Restrict by identifiers while searching.

        :param Optional[List[int]] ids: Identifiers
        """
        if ids and len(ids) > self.__search_base_note_count:
            self.__search_full_set = ids
            self._show_more_button.set_visible(True)
            ids = ids[: self.__search_base_note_count]
        else:
            self._show_more_button.set_visible(False)
        self.__older_notes_model.restrict_by_id(ids)

    def show_remaining_search_results(self) -> None:
        """Show remaining search results below first "page"."""
        if len(self.__search_full_set) > 0:
            self.__older_notes_model.restrict_by_id(self.__search_full_set)
            self.__search_full_set = []
            self._show_more_button.set_visible(False)

    def open_first_search_result(self) -> None:
        """Open the top search result."""
        listbox = self._older_notes_listbox
        if listbox.get_visible():
            row = listbox.get_row_at_index(0)
            if row is None:
                return
            row.grab_focus()
            listbox.select_row(row)
            note = row.get_child().note
            self.emit("note-opened", note)

    def display_older_notes(self) -> None:
        """Display notes older than two months."""
        if self.older_notes_displayed:
            return
        elif not self.older_notes_loaded:
            return
        self.older_notes_displayed = True
        self._older_notes_loader.set_visible(False)
        if len(self.__older_notes_model) > 0:
            self._older_notes_section.set_visible(True)

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

    @older_notes_displayed.setter
    def older_notes_displayed(self, value: bool) -> None:
        self.__older_notes_displayed = value

    @GObject.Property(type=bool, default=False)
    def older_notes_loaded(self) -> bool:
        return self.__older_notes_model is not None

    @GObject.Property(type=bool, default=False)
    def older_notes_loaded_but_empty(self) -> bool:
        return self.__older_notes_model is not None and len(self.__older_notes_model) is None

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

    @searching.setter
    def searching(self, value: bool) -> None:
        self.__searching = value

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

    @selecting.setter
    def selecting(self, value: bool) -> None:
        self.__selecting = value

    @GObject.Property(type=bool, default=False)
    def have_more_search_results(self) -> bool:
        return len(self.__search_full_set) > 0

    def __create_row(self, note: Note, _user_data: Any) -> Gtk.Widget:
        row = IndexRow()
        style = iotas.config_manager.get_index_category_style()
        row.populate(note, style)
        row.connect("context-click", self.__on_row_context_click)
        row.connect("checkbox-toggled", self.__on_row_checkbox_toggled)
        self.bind_property("selecting", row, "child-revealed", GObject.BindingFlags.SYNC_CREATE)
        return row

    def __older_notes_items_changed(
        self, _model: Gio.ListModel, _pos: int, _removed: int, _added: int
    ) -> None:
        visible = len(self.__older_notes_model) > 0 and (
            self.__older_notes_displayed or self.searching
        )
        self._older_notes_section.set_visible(visible)

    def __focus_next_list_box(self, previous_listbox: Gtk.ListBox) -> None:
        found = False
        for listbox in self.__listboxes:
            if not found and listbox == previous_listbox:
                found = True
                continue
            if found and listbox.get_parent().get_visible():
                row = listbox.get_row_at_index(0)
                row.grab_focus()
                listbox.select_row(row)
                previous_listbox.unselect_all()
                break

    def __focus_previous_list_box(self, previous_listbox: Gtk.ListBox) -> None:
        found = False
        for listbox in reversed(self.__listboxes):
            if not found and listbox == previous_listbox:
                found = True
                continue
            if found and listbox.get_parent().get_visible():
                row = listbox.get_last_child()
                row.grab_focus()
                listbox.select_row(row)
                previous_listbox.unselect_all()
                break

    def __on_row_activated(self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow) -> None:
        """Open the note in the selected row in the editor.

        :param Gtk.ListBox listbox: Listbox for activated row
        :param Gtk.ListBoxRow row: The row
        """
        note = row.get_child().note if row else None
        if self.selecting:
            row.get_child().toggle_selected()
        else:
            self.emit("note-opened", note)
            self.__clear_selections_excluding(listbox)

    def __on_row_selected(self, listbox: Gtk.ListBox, row: Optional[Gtk.ListBoxRow]) -> None:
        if row:
            self.__clear_selections_excluding(listbox)

    def __on_row_context_click(self, row: IndexRow) -> None:
        if self.selecting:
            row.toggle_selected()
        else:
            note = row.get_parent().get_first_child().note
            self.select_and_focus_note(note)
            row.set_checkbox_value(True)

    def __on_row_checkbox_toggled(self, row: IndexRow, enabled: bool) -> None:
        if enabled:
            self.emit("note-checkbox-activated", row.note)
        else:
            self.emit("note-checkbox-deactivated", row.note)

    def __clear_selections_excluding(self, excluding: Optional[Gtk.ListBox] = None) -> None:
        for listbox in self.__listboxes:
            if excluding is None or listbox is not excluding:
                listbox.unselect_all()
