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

from gi.repository import GObject, Gio, Graphene, Gtk

from typing import Any, Callable, Generator, NamedTuple, Optional

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


class SectionInfo(NamedTuple):
    model: Gio.ListModel
    listbox: Gtk.ListBox
    section: Gtk.Box


class FocusedRow(NamedTuple):
    listbox: Gtk.ListBox
    indexx: int


@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,)),
        "search-model-emptied": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    MINIMUM_SEARCH_PAGE_NOTE_COUNT = 15
    ROW_HEIGHT = 80

    _favourites_listbox: Gtk.ListBox = Gtk.Template.Child()
    _favourites_section: Gtk.Box = Gtk.Template.Child()
    _today_listbox: Gtk.ListBox = Gtk.Template.Child()
    _today_section: Gtk.Box = Gtk.Template.Child()
    _yesterday_listbox: Gtk.ListBox = Gtk.Template.Child()
    _yesterday_section: Gtk.Box = Gtk.Template.Child()
    _week_listbox: Gtk.ListBox = Gtk.Template.Child()
    _week_section: Gtk.Box = Gtk.Template.Child()
    _month_listbox: Gtk.ListBox = Gtk.Template.Child()
    _month_section: Gtk.Box = Gtk.Template.Child()
    _last_month_listbox: Gtk.ListBox = Gtk.Template.Child()
    _last_month_section: Gtk.Box = Gtk.Template.Child()
    _older_notes_loader: Gtk.Box = Gtk.Template.Child()
    _older_notes_listbox: Gtk.ListBox = Gtk.Template.Child()
    _older_notes_section: Gtk.Box = Gtk.Template.Child()
    _show_more_button: Gtk.Box = Gtk.Template.Child()
    _search_listbox: Gtk.ListBox = Gtk.Template.Child()
    _search_section: Gtk.Box = Gtk.Template.Child()

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

        self.__config_manager = ConfigManager.get_default()

        self.__selecting = False
        self.__searching = False
        self.__search_full_set: list[int] = []
        self.__older_notes_displayed = False
        self.__search_base_note_count = self.MINIMUM_SEARCH_PAGE_NOTE_COUNT
        self.__main_sections: list[SectionInfo] = []
        self.__extra_listboxes: list[Gtk.Listbox] = []

    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.__listbox_keypress_func = listbox_keypress_func

        self.__populate_sections_list(note_manager)
        self.__older_notes_model: Optional[NoteListModelTimeFiltered] = None
        self.__search_model: Optional[NoteListModelSearch] = None

        for section in self.__main_sections:
            section.listbox.bind_model(section.model, self.__create_row, None)
            section.model.connect("items-changed", self.__on_model_items_changed, section.section)
            self.__add_listbox_keypress_controller(section.listbox)

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

    def populate_older_notes(self, model: NoteListModelTimeFiltered) -> bool:
        """Populate and display notes older than two months.

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

        :param NoteListModelTimeFiltered model: Model for the older notes
        :return: Whether the model contains any notes after population
        :rtype: bool
        """
        if self.__older_notes_model is None:
            self.__older_notes_model = model
            self.__extra_listboxes.append(self._older_notes_listbox)
            self.__add_listbox_keypress_controller(self._older_notes_listbox)

        return self.__display_older_notes()

    def populate_search(self, model: NoteListModelSearch) -> None:
        """Populate search model, binding to list box etc.

        :param NoteListModelSearch model: Search model
        """
        if not self.__search_model:
            self.__search_model = model
            self._search_listbox.bind_model(model, self.__create_row, None)
            self.__extra_listboxes.append(self._search_listbox)
            self.__add_listbox_keypress_controller(self._search_listbox)
            model.connect("items-changed", self.__on_search_items_changed)

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

        :param Category category: The current category
        """
        if self.searching:
            for section in self.__main_sections:
                section.section.set_visible(False)
            self._older_notes_section.set_visible(False)
            self._older_notes_loader.set_visible(False)
            self._search_section.set_visible(True)
        else:
            for section in self.__main_sections:
                section.section.set_visible(len(section.model) > 0)
            self._older_notes_section.set_visible(
                self.older_notes_displayed
                and self.__older_notes_model is not None
                and len(self.__older_notes_model) > 0
            )
            self._older_notes_loader.set_visible(not self.older_notes_displayed)
            self._search_section.set_visible(False)

    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.__visible_listboxes():
            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."""
        for listbox in self.__visible_listboxes():
            row = listbox.get_row_at_index(0)
            if row is None:
                return
            row.grab_focus()
            listbox.select_row(row)
            break

    def move_focus_to_first_visible(self, below: float) -> bool:
        """Move focus to first row visible below threshold.

        :param float below: Minimum vertical position
        """
        done = False
        for listbox in self.__visible_listboxes():
            i = 0
            row = listbox.get_row_at_index(i)
            while row is not None and not done:
                common, pt = row.compute_point(self, Graphene.point_zero())
                if common and pt.y >= below:
                    row.grab_focus()
                    listbox.select_row(row)
                    done = True
                else:
                    i += 1
                    row = listbox.get_row_at_index(i)
            if done:
                break

        return done

    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.__visible_listboxes():
            row = listbox.get_selected_row()
            if row:
                row.grab_focus()
                focused = True
                break
        return focused

    def get_current_selection(self) -> Optional[FocusedRow]:
        """Get currently focused row.

        :return: Selected listbox and row index, optional
        :rtype: FocusedRow
        """
        for listbox in self.__visible_listboxes():
            row = listbox.get_selected_row()
            if row:
                return FocusedRow(listbox, row.get_index())

        return None

    def place_focus_after_change(self, focused_row: FocusedRow) -> None:
        """Refocus row or sibling.

        Attempts in order:
        1. Any row in the listbox at the same index
        2. The last row in the listbox (if the listbox has a smaller number of rows)
        3. The first row in the following visible listbox
        4. The last row in the preceding visible listbox
        """
        prev_listbox = focused_row.listbox
        prev_index = focused_row.indexx
        destination = None
        if prev_listbox.get_visible():
            at_index = prev_listbox.get_row_at_index(prev_index)
            if at_index:
                destination = at_index
            else:
                destination = prev_listbox.get_last_child()
        if destination:
            destination.grab_focus()
            prev_listbox.select_row(destination)
        else:
            if not self.__focus_next_list_box(prev_listbox):
                self.__focus_previous_list_box(prev_listbox)

    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.__all_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, index_category: Category) -> None:
        """Update category labels for each row.

        :param Category index_category: The current category
        """
        style = self.__config_manager.index_category_style
        for listbox in self.__visible_listboxes():
            category = None if listbox == self._search_listbox else index_category
            child = listbox.get_first_child()
            while child is not None:
                index_row = child.get_first_child()
                index_row.update_category_label(category, style)
                child = child.get_next_sibling()

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

        :param Note note: The note
        """
        self.clear_selections()
        found = False
        for listbox in self.__visible_listboxes():
            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]], show_all: bool = False) -> None:
        """Restrict by identifiers while searching.

        :param Optional[list[int]] ids: Identifiers
        :param bool show_all: Whether to show the full result set (instead of the most recent with
            an expandable section)
        """
        assert self.__search_model is not None
        if not show_all and 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.__search_full_set = []
            self._show_more_button.set_visible(False)
        self.__search_model.invalidate(ids)

    def show_remaining_search_results(self) -> None:
        """Show remaining search results below first "page"."""
        assert self.__search_model is not None
        if len(self.__search_full_set) > 0:
            self.__search_model.invalidate(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._search_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 disconnect_older_notes_section(self) -> None:
        """Disconnect listbox and signal for notes older than two months."""
        if not self.older_notes_displayed:
            return
        assert self.__older_notes_model is not None

        self.__older_notes_model.disconnect_by_func(self.__on_older_notes_items_changed)
        self._older_notes_listbox.bind_model(None, self.__create_row, None)
        self.older_notes_displayed = False

    def get_model(self, listbox: Gtk.ListBox) -> Gio.ListModel:
        """Get model for listbox.

        :param Gtk.ListBox listbox: Listbox to fetch for
        :return: Model
        :rtype: Gio.ListModel
        """
        model = None
        for info in self.__main_sections:
            if info.listbox == listbox:
                model = info.model
                break
        if model is not None:
            return model
        elif listbox == self._older_notes_listbox:
            return self.__older_notes_model
        elif listbox == self._search_listbox:
            return self.__search_model

    def iter_visible_notes(self) -> Generator[Note, None, None]:
        """Iterate visible notes.

        :return: Notes
        :rtype: Generator[Note, None, None]
        """
        for listbox in self.__visible_listboxes():
            child = listbox.get_first_child()
            while child is not None:
                row = child.get_first_child()
                row.set_checkbox_value(True)
                yield row.note
                child = child.get_next_sibling()

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

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

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

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

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

    @selecting.setter
    def set_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

    @GObject.Property(type=bool, default=False)
    def showing_expanded_search_set(self) -> bool:
        """Whether search is active showing a set which has been expanded.

        As in Show More has already been pressed.

        :return: Whether searching and expanded
        :rtype: bool
        """
        return (
            self.searching
            and not self._show_more_button.get_visible()
            and self.__search_model is not None
            and self.__search_model.get_n_items() > self.__search_base_note_count
        )

    def __create_row(self, note: Note, _user_data: Any) -> Gtk.Widget:
        row = IndexRow()
        style = self.__config_manager.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 __focus_next_list_box(self, previous_listbox: Gtk.ListBox) -> bool:
        found = False
        transferred = False
        for listbox in self.__all_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()
                transferred = True
                break
        return transferred

    def __focus_previous_list_box(self, previous_listbox: Gtk.ListBox) -> bool:
        found = False
        transferred = False
        for listbox in reversed(list(self.__all_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()
                transferred = True
                break
        return transferred

    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 __on_model_items_changed(
        self,
        model: Gio.ListModel,
        _position: int,
        _removed: int,
        _added: int,
        section: Gtk.Widget,
    ) -> None:
        section.set_visible(not self.searching and len(model) > 0)

    def __on_older_notes_items_changed(
        self, model: Gio.ListModel, _position: int, _removed: int, _added: int
    ) -> None:
        self._older_notes_section.set_visible(
            not self.searching and self.older_notes_displayed and len(model) > 0
        )

    def __on_search_items_changed(
        self, model: Gio.ListModel, _position: int, _removed: int, _added: int
    ) -> None:
        if self.searching and len(model) > 0:
            self.emit("search-model-emptied")

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

    def __add_listbox_keypress_controller(self, listbox: Gtk.ListBox) -> None:
        controller = Gtk.EventControllerKey()
        controller.connect("key-pressed", self.__listbox_keypress_func)
        listbox.add_controller(controller)
        listbox.connect("row-activated", self.__on_row_activated)
        listbox.connect("row-selected", self.__on_row_selected)

    def __bind_older_notes(self) -> None:
        assert self.__older_notes_model is not None
        self._older_notes_listbox.bind_model(self.__older_notes_model, self.__create_row, None)
        self.__older_notes_model.connect("items-changed", self.__on_older_notes_items_changed)

    def __populate_sections_list(self, note_manager: NoteManager) -> None:
        self.__main_sections.append(
            SectionInfo(
                note_manager.favourites_model,
                self._favourites_listbox,
                self._favourites_section,
            )
        )
        self.__main_sections.append(
            SectionInfo(
                note_manager.today_model,
                self._today_listbox,
                self._today_section,
            )
        )
        self.__main_sections.append(
            SectionInfo(
                note_manager.yesterday_model,
                self._yesterday_listbox,
                self._yesterday_section,
            )
        )
        self.__main_sections.append(
            SectionInfo(
                note_manager.week_model,
                self._week_listbox,
                self._week_section,
            )
        )
        self.__main_sections.append(
            SectionInfo(
                note_manager.month_model,
                self._month_listbox,
                self._month_section,
            )
        )
        self.__main_sections.append(
            SectionInfo(
                note_manager.last_month_model,
                self._last_month_listbox,
                self._last_month_section,
            )
        )

    def __visible_listboxes(self) -> Generator[Gtk.ListBox, None, None]:
        for listbox in self.__all_listboxes():
            if listbox.get_parent().get_visible():
                yield listbox

    def __all_listboxes(self) -> Generator[Gtk.ListBox, None, None]:
        for section in self.__main_sections:
            yield section.listbox
        for listbox in self.__extra_listboxes:
            yield listbox

    def __display_older_notes(self) -> bool:
        if self.older_notes_displayed:
            return True
        elif self.__older_notes_model is None:
            return False

        self.older_notes_displayed = True
        self.__bind_older_notes()
        self._older_notes_loader.set_visible(False)

        if len(self.__older_notes_model) > 0:
            self._older_notes_section.set_visible(True)
            return True
        else:
            return False
