import datetime
import itertools
import logging
import math
from typing import Any, Callable, Iterable, List, Optional, Tuple, cast

from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango

from ..adapters import AdapterManager, AlbumSearchQuery, CacheMissError, Result, api_objects as API
from ..config import AppConfiguration
from ..ui import util
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage


def _to_type(query_type: AlbumSearchQuery.Type) -> str:
    return {
        AlbumSearchQuery.Type.RANDOM: "random",
        AlbumSearchQuery.Type.NEWEST: "newest",
        AlbumSearchQuery.Type.FREQUENT: "frequent",
        AlbumSearchQuery.Type.RECENT: "recent",
        AlbumSearchQuery.Type.STARRED: "starred",
        AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME: "alphabetical",
        AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST: "alphabetical",
        AlbumSearchQuery.Type.YEAR_RANGE: "year_range",
        AlbumSearchQuery.Type.GENRE: "genre",
    }[query_type]


def _from_str(type_str: str) -> AlbumSearchQuery.Type:
    return {
        "random": AlbumSearchQuery.Type.RANDOM,
        "newest": AlbumSearchQuery.Type.NEWEST,
        "frequent": AlbumSearchQuery.Type.FREQUENT,
        "recent": AlbumSearchQuery.Type.RECENT,
        "starred": AlbumSearchQuery.Type.STARRED,
        "alphabetical_by_name": AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
        "alphabetical_by_artist": AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
        "year_range": AlbumSearchQuery.Type.YEAR_RANGE,
        "genre": AlbumSearchQuery.Type.GENRE,
    }[type_str]


class AlbumsPanel(Gtk.Box):
    __gsignals__ = {
        "song-clicked": (
            GObject.SignalFlags.RUN_FIRST,
            GObject.TYPE_NONE,
            (int, object, object),
        ),
        "refresh-window": (
            GObject.SignalFlags.RUN_FIRST,
            GObject.TYPE_NONE,
            (object, bool),
        ),
    }

    offline_mode = False
    populating_genre_combo = False
    grid_order_token: int = 0
    album_sort_direction: str = "ascending"
    album_page_size: int = 30
    album_page: int = 0
    grid_pages_count: int = 0

    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        actionbar = Gtk.ActionBar()

        # Sort by
        actionbar.add(Gtk.Label(label="Sort"))
        self.sort_type_combo, self.sort_type_combo_store = self.make_combobox(
            (
                ("random", "randomly", True),
                ("genre", "by genre", AdapterManager.can_get_genres()),
                ("newest", "by most recently added", True),
                ("frequent", "by most played", True),
                ("recent", "by most recently played", True),
                ("alphabetical", "alphabetically", True),
                ("starred", "by starred only", True),
                ("year_range", "by year", True),
            ),
            self.on_type_combo_changed,
        )
        actionbar.pack_start(self.sort_type_combo)

        self.alphabetical_type_combo, _ = self.make_combobox(
            (("by_name", "by album name", True), ("by_artist", "by artist name", True)),
            self.on_alphabetical_type_change,
        )
        actionbar.pack_start(self.alphabetical_type_combo)

        self.genre_combo, self.genre_combo_store = self.make_combobox((), self.on_genre_change)
        actionbar.pack_start(self.genre_combo)

        next_decade = (datetime.datetime.now().year // 10) * 10 + 10

        self.from_year_label = Gtk.Label(label="from")
        actionbar.pack_start(self.from_year_label)
        self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
        self.from_year_spin_button.connect("value-changed", self.on_year_changed)
        actionbar.pack_start(self.from_year_spin_button)

        self.to_year_label = Gtk.Label(label="to")
        actionbar.pack_start(self.to_year_label)
        self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
        self.to_year_spin_button.connect("value-changed", self.on_year_changed)
        actionbar.pack_start(self.to_year_spin_button)

        self.sort_toggle = IconButton(
            "view-sort-descending-symbolic", "Sort descending", relief=True
        )
        self.sort_toggle.connect("clicked", self.on_sort_toggle_clicked)
        actionbar.pack_start(self.sort_toggle)

        # Add the page widget.
        page_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
        self.prev_page = IconButton(
            "go-previous-symbolic", "Go to the previous page", sensitive=False
        )
        self.prev_page.connect("clicked", self.on_prev_page_clicked)
        page_widget.add(self.prev_page)
        page_widget.add(Gtk.Label(label="Page"))
        self.page_entry = Gtk.Entry()
        self.page_entry.set_width_chars(1)
        self.page_entry.set_max_width_chars(1)
        self.page_entry.connect("changed", self.on_page_entry_changed)
        self.page_entry.connect("insert-text", self.on_page_entry_insert_text)
        page_widget.add(self.page_entry)
        page_widget.add(Gtk.Label(label="of"))
        self.page_count_label = Gtk.Label(label="-")
        page_widget.add(self.page_count_label)
        self.next_page = IconButton("go-next-symbolic", "Go to the next page", sensitive=False)
        self.next_page.connect("clicked", self.on_next_page_clicked)
        page_widget.add(self.next_page)
        actionbar.set_center_widget(page_widget)

        self.refresh_button = IconButton(
            "view-refresh-symbolic", "Refresh list of albums", relief=True
        )
        self.refresh_button.connect("clicked", self.on_refresh_clicked)
        actionbar.pack_end(self.refresh_button)

        actionbar.pack_end(Gtk.Label(label="albums per page"))
        self.show_count_dropdown, _ = self.make_combobox(
            ((x, x, True) for x in ("20", "30", "40", "50")),
            self.on_show_count_dropdown_change,
        )
        actionbar.pack_end(self.show_count_dropdown)
        actionbar.pack_end(Gtk.Label(label="Show"))

        self.add(actionbar)

        scrolled_window = Gtk.ScrolledWindow()
        self.grid = AlbumsGrid()
        self.grid.connect(
            "song-clicked",
            lambda _, *args: self.emit("song-clicked", *args),
        )
        self.grid.connect(
            "refresh-window",
            lambda _, *args: self.emit("refresh-window", *args),
        )
        self.grid.connect("cover-clicked", self.on_grid_cover_clicked)
        self.grid.connect("num-pages-changed", self.on_grid_num_pages_changed)
        scrolled_window.add(self.grid)
        self.add(scrolled_window)

    def make_combobox(
        self,
        items: Iterable[Tuple[str, str, bool]],
        on_change: Callable[[Gtk.ComboBox], None],
    ) -> Tuple[Gtk.ComboBox, Gtk.ListStore]:
        store = Gtk.ListStore(str, str, bool)
        for item in items:
            store.append(item)

        combo = Gtk.ComboBox.new_with_model(store)
        combo.set_id_column(0)
        combo.connect("changed", on_change)

        renderer_text = Gtk.CellRendererText()
        combo.pack_start(renderer_text, True)
        combo.add_attribute(renderer_text, "text", 1)
        combo.add_attribute(renderer_text, "sensitive", 2)

        return combo, store

    def populate_genre_combo(
        self,
        app_config: AppConfiguration | None = None,
        force: bool = False,
    ):
        if not AdapterManager.can_get_genres():
            self.updating_query = False
            return

        def get_genres_done(f: Result):
            try:
                genre_names = (g.name for g in f.result() or [])
                new_store = [(name, name, True) for name in sorted(genre_names)]

                util.diff_song_store(self.genre_combo_store, new_store)

                if app_config:
                    current_genre_id = self.get_id(self.genre_combo)
                    genre = app_config.state.current_album_search_query.genre
                    if genre and current_genre_id != (genre_name := genre.name):
                        self.genre_combo.set_active_id(genre_name)
            finally:
                self.updating_query = False

        try:
            force = force and (
                app_config is not None
                and (state := app_config.state) is not None
                and state.current_album_search_query.type == AlbumSearchQuery.Type.GENRE
            )
            genres_future = AdapterManager.get_genres(force=force)
            genres_future.add_done_callback(lambda f: GLib.idle_add(get_genres_done, f))
        except Exception:
            self.updating_query = False

    def update(self, app_config: AppConfiguration | None = None, force: bool = False):
        self.updating_query = True

        supported_type_strings = {
            _to_type(t) for t in AdapterManager.get_supported_artist_query_types()
        }
        for i, el in enumerate(self.sort_type_combo_store):
            self.sort_type_combo_store[i][2] = el[0] in supported_type_strings

        # (En|Dis)able getting genres.
        self.sort_type_combo_store[1][2] = AdapterManager.can_get_genres()

        if app_config:
            self.current_query = app_config.state.current_album_search_query
            self.offline_mode = app_config.offline_mode

        self.alphabetical_type_combo.set_active_id(
            {
                AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME: "by_name",
                AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST: "by_artist",
            }.get(self.current_query.type)
            or "by_name"
        )
        self.sort_type_combo.set_active_id(_to_type(self.current_query.type))

        if year_range := self.current_query.year_range:
            self.from_year_spin_button.set_value(year_range[0])
            self.to_year_spin_button.set_value(year_range[1])

        # Update the page display
        if app_config:
            self.album_page = app_config.state.album_page
            self.album_page_size = app_config.state.album_page_size
            self.refresh_button.set_sensitive(not app_config.offline_mode)

        self.prev_page.set_sensitive(self.album_page > 0)
        self.page_entry.set_text(str(self.album_page + 1))

        # Show/hide the combo boxes.
        def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
            for element in elements:
                if self.current_query.type in sort_type:
                    element.show()
                else:
                    element.hide()

        show_if(
            (
                AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
                AlbumSearchQuery.Type.ALPHABETICAL_BY_ARTIST,
            ),
            self.alphabetical_type_combo,
        )
        show_if((AlbumSearchQuery.Type.GENRE,), self.genre_combo)
        show_if(
            (AlbumSearchQuery.Type.YEAR_RANGE,),
            self.from_year_label,
            self.from_year_spin_button,
            self.to_year_label,
            self.to_year_spin_button,
        )

        # (En|Dis)able the sort button
        self.sort_toggle.set_sensitive(self.current_query.type != AlbumSearchQuery.Type.RANDOM)

        if app_config:
            self.album_sort_direction = app_config.state.album_sort_direction
            self.sort_toggle.set_icon(f"view-sort-{self.album_sort_direction}-symbolic")
            self.sort_toggle.set_tooltip_text(
                "Change sort order to " + self._get_opposite_sort_dir(self.album_sort_direction)
            )

            self.show_count_dropdown.set_active_id(str(app_config.state.album_page_size))

        # Has to be last because it resets self.updating_query
        self.populate_genre_combo(app_config, force=force)

        # At this point, the current query should be totally updated.
        if app_config:
            self.grid_order_token = self.grid.update_params(app_config)
        self.grid.update(self.grid_order_token, app_config, force=force)

    def _get_opposite_sort_dir(self, sort_dir: str) -> str:
        return ("ascending", "descending")[0 if sort_dir == "descending" else 1]

    def get_id(self, combo: Gtk.ComboBox) -> Optional[str]:
        tree_iter = combo.get_active_iter()
        if tree_iter is not None:
            return combo.get_model()[tree_iter][0]
        return None

    def on_sort_toggle_clicked(self, _):
        self.emit(
            "refresh-window",
            {
                "album_sort_direction": self._get_opposite_sort_dir(self.album_sort_direction),
                "album_page": 0,
                "selected_album_id": None,
            },
            False,
        )

    def on_refresh_clicked(self, _):
        self.emit("refresh-window", {}, True)

    class _Genre(API.Genre):
        def __init__(self, name: str):
            self.name = name

    def on_grid_num_pages_changed(self, grid: Any, pages: int):
        self.grid_pages_count = pages
        pages_str = str(self.grid_pages_count)
        self.page_count_label.set_text(pages_str)
        self.next_page.set_sensitive(self.album_page < self.grid_pages_count - 1)
        num_digits = len(pages_str)
        self.page_entry.set_width_chars(num_digits)
        self.page_entry.set_max_width_chars(num_digits)

    def on_type_combo_changed(self, combo: Gtk.ComboBox):
        id = self.get_id(combo)
        assert id
        if id == "alphabetical":
            id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
        self.emit_if_not_updating(
            "refresh-window",
            {
                "current_album_search_query": AlbumSearchQuery(
                    _from_str(id),
                    self.current_query.year_range,
                    self.current_query.genre,
                ),
                "album_page": 0,
                "selected_album_id": None,
            },
            False,
        )

    def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
        id = "alphabetical_" + cast(str, self.get_id(combo))
        self.emit_if_not_updating(
            "refresh-window",
            {
                "current_album_search_query": AlbumSearchQuery(
                    _from_str(id),
                    self.current_query.year_range,
                    self.current_query.genre,
                ),
                "album_page": 0,
                "selected_album_id": None,
            },
            False,
        )

    def on_genre_change(self, combo: Gtk.ComboBox):
        genre = self.get_id(combo)
        assert genre
        self.emit_if_not_updating(
            "refresh-window",
            {
                "current_album_search_query": AlbumSearchQuery(
                    self.current_query.type,
                    self.current_query.year_range,
                    AlbumsPanel._Genre(genre),
                ),
                "album_page": 0,
                "selected_album_id": None,
            },
            False,
        )

    def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
        year = int(entry.get_value())
        assert self.current_query.year_range
        if self.to_year_spin_button == entry:
            new_year_tuple = (self.current_query.year_range[0], year)
        else:
            new_year_tuple = (year, self.current_query.year_range[1])

        self.emit_if_not_updating(
            "refresh-window",
            {
                "current_album_search_query": AlbumSearchQuery(
                    self.current_query.type, new_year_tuple, self.current_query.genre
                ),
                "album_page": 0,
                "selected_album_id": None,
            },
            False,
        )

        return False

    def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
        if len(text := entry.get_text()) > 0:
            self.emit_if_not_updating(
                "refresh-window",
                {"album_page": int(text) - 1, "selected_album_id": None},
                False,
            )
        return False

    def on_page_entry_insert_text(
        self, entry: Gtk.Entry, text: str, length: int, position: int
    ) -> bool:
        if self.updating_query:
            return False
        if not text.isdigit():
            entry.emit_stop_by_name("insert-text")
            return True
        page_num = int(entry.get_text() + text)
        if self.grid_pages_count is None or self.grid_pages_count < page_num:
            entry.emit_stop_by_name("insert-text")
            return True
        return False

    def on_prev_page_clicked(self, _):
        self.emit_if_not_updating(
            "refresh-window",
            {"album_page": self.album_page - 1, "selected_album_id": None},
            False,
        )

    def on_next_page_clicked(self, _):
        self.emit_if_not_updating(
            "refresh-window",
            {"album_page": self.album_page + 1, "selected_album_id": None},
            False,
        )

    def on_grid_cover_clicked(self, grid: Any, id: str):
        self.emit(
            "refresh-window",
            {"selected_album_id": id},
            False,
        )

    def on_show_count_dropdown_change(self, combo: Gtk.ComboBox):
        show_count = int(self.get_id(combo) or 30)
        self.emit(
            "refresh-window",
            {"album_page_size": show_count, "album_page": 0},
            False,
        )

    def emit_if_not_updating(self, *args):
        if self.updating_query:
            return
        self.emit(*args)


class AlbumsGrid(Gtk.Overlay):
    """Defines the albums panel."""

    __gsignals__ = {
        "cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
        "refresh-window": (
            GObject.SignalFlags.RUN_FIRST,
            GObject.TYPE_NONE,
            (object, bool),
        ),
        "song-clicked": (
            GObject.SignalFlags.RUN_FIRST,
            GObject.TYPE_NONE,
            (int, object, object),
        ),
        "num-pages-changed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (int,)),
    }

    class _AlbumModel(GObject.Object):
        def __init__(self, album: API.Album):
            self.album = album
            super().__init__()

        @property
        def id(self) -> str:
            assert self.album.id
            return self.album.id

        def __repr__(self) -> str:
            return f"<AlbumsGrid._AlbumModel {self.album}>"

    current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
    current_models: List[_AlbumModel] = []
    latest_applied_order_ratchet: int = 0
    order_ratchet: int = 0
    offline_mode: bool = False

    currently_selected_index: Optional[int] = None
    currently_selected_id: Optional[str] = None
    sort_dir: str = ""
    page_size: int = 30
    page: int = 0
    num_pages: Optional[int] = None
    next_page_fn = None
    provider_id: Optional[str] = None

    def update_params(self, app_config: AppConfiguration) -> int:
        # If there's a diff, increase the ratchet.
        if (
            self.current_query.strhash()
            != (search_query := app_config.state.current_album_search_query).strhash()
        ):
            self.order_ratchet += 1
            self.current_query = search_query

        if self.offline_mode != (offline_mode := app_config.offline_mode):
            self.order_ratchet += 1
            self.offline_mode = offline_mode

        if self.provider_id != (provider_id := app_config.current_provider_id):
            self.order_ratchet += 1
            self.provider_id = provider_id

        return self.order_ratchet

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.items_per_row = 4

        scrolled_window = Gtk.ScrolledWindow()
        grid_detail_grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        self.error_container = Gtk.Box()
        grid_detail_grid_box.add(self.error_container)

        def create_flowbox(**kwargs) -> Gtk.FlowBox:
            flowbox = Gtk.FlowBox(
                **kwargs,
                hexpand=True,
                row_spacing=5,
                column_spacing=5,
                margin_top=5,
                homogeneous=True,
                valign=Gtk.Align.START,
                halign=Gtk.Align.CENTER,
                selection_mode=Gtk.SelectionMode.SINGLE,
            )
            flowbox.set_max_children_per_line(7)
            return flowbox

        self.grid_top = create_flowbox()
        self.grid_top.connect("child-activated", self.on_child_activated)
        self.grid_top.connect("size-allocate", self.on_grid_resize)

        self.list_store_top = Gio.ListStore()
        self.grid_top.bind_model(self.list_store_top, self._create_cover_art_widget)

        grid_detail_grid_box.add(self.grid_top)

        self.detail_box_revealer = Gtk.Revealer(valign=Gtk.Align.END)
        self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box")
        self.detail_box.pack_start(Gtk.Box(), True, True, 0)

        self.detail_box_inner = Gtk.Box()
        self.detail_box.pack_start(self.detail_box_inner, False, False, 0)

        self.detail_box.pack_start(Gtk.Box(), True, True, 0)
        self.detail_box_revealer.add(self.detail_box)
        grid_detail_grid_box.add(self.detail_box_revealer)

        self.grid_bottom = create_flowbox(vexpand=True)
        self.grid_bottom.connect("child-activated", self.on_child_activated)

        self.list_store_bottom = Gio.ListStore()
        self.grid_bottom.bind_model(self.list_store_bottom, self._create_cover_art_widget)

        grid_detail_grid_box.add(self.grid_bottom)

        scrolled_window.add(grid_detail_grid_box)
        self.add(scrolled_window)

        self.spinner = Gtk.Spinner(
            name="grid-spinner",
            active=True,
            halign=Gtk.Align.CENTER,
            valign=Gtk.Align.CENTER,
        )
        self.add_overlay(self.spinner)

    def update(
        self, order_token: int, app_config: AppConfiguration | None = None, force: bool = False
    ):
        if order_token < self.latest_applied_order_ratchet:
            return

        force_grid_reload_from_master = False
        if app_config:
            self.currently_selected_id = app_config.state.selected_album_id

            if (
                self.sort_dir != app_config.state.album_sort_direction
                or self.page_size != app_config.state.album_page_size
                or self.page != app_config.state.album_page
            ):
                force_grid_reload_from_master = True
            self.sort_dir = app_config.state.album_sort_direction
            self.page_size = app_config.state.album_page_size
            self.page = app_config.state.album_page

        self.update_grid(
            order_token,
            use_ground_truth_adapter=force,
            force_grid_reload_from_master=force_grid_reload_from_master,
        )

        # Update the detail panel.
        children = self.detail_box_inner.get_children()
        if len(children) > 0 and hasattr(children[0], "update"):
            children[0].update(app_config=app_config, force=force)

    error_dialog = None

    def update_grid(
        self,
        order_token: int,
        use_ground_truth_adapter: bool = False,
        force_grid_reload_from_master: bool = False,
    ):
        if not AdapterManager.can_get_artists():
            self.spinner.hide()
            return

        force_grid_reload_from_master = (
            force_grid_reload_from_master
            or use_ground_truth_adapter
            or self.latest_applied_order_ratchet < order_token
        )

        def do_update_grid(selected_index: Optional[int]):
            if self.sort_dir == "descending" and selected_index:
                selected_index = len(self.current_models) - selected_index - 1

            self.reflow_grids(
                force_reload_from_master=force_grid_reload_from_master,
                selected_index=selected_index,
                models=self.current_models,
            )
            self.spinner.hide()

        def reload_store(f: Result[Iterable[API.Album]]):
            # Don't override more recent results
            if order_token < self.latest_applied_order_ratchet:
                return
            self.latest_applied_order_ratchet = order_token

            is_partial = False
            try:
                albums = list(f.result())
            except CacheMissError as e:
                albums = cast(Optional[List[API.Album]], e.partial_data) or []
                is_partial = True
            except Exception as e:
                if self.error_dialog:
                    self.spinner.hide()
                    return
                # TODO (#122): make this non-modal
                self.error_dialog = Gtk.MessageDialog(
                    transient_for=self.get_toplevel(),
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.OK,
                    text="Failed to retrieve albums",
                )
                self.error_dialog.format_secondary_markup(
                    # TODO (#204) make this error better.
                    f"Getting albums by {self.current_query.type} failed due to the "
                    f"following error\n\n{e}"
                )
                logging.exception("Failed to retrieve albums")
                self.error_dialog.run()
                self.error_dialog.destroy()
                self.error_dialog = None
                self.spinner.hide()
                return

            for c in self.error_container.get_children():
                self.error_container.remove(c)
            if is_partial and (
                len(albums) == 0 or self.current_query.type != AlbumSearchQuery.Type.RANDOM
            ):
                load_error = LoadError(
                    "Album list",
                    "load albums",
                    has_data=albums is not None and len(albums) > 0,
                    offline_mode=self.offline_mode,
                )
                self.error_container.pack_start(load_error, True, True, 0)
                self.error_container.show_all()
            else:
                self.error_container.hide()

            selected_index = None
            self.current_models = []
            for i, album in enumerate(albums):
                model = AlbumsGrid._AlbumModel(album)

                if model.id == self.currently_selected_id:
                    selected_index = i

                self.current_models.append(model)

            self.emit(
                "num-pages-changed",
                math.ceil(len(self.current_models) / self.page_size),
            )
            do_update_grid(selected_index)

        if force_grid_reload_from_master:
            albums_result = AdapterManager.get_albums(
                self.current_query, use_ground_truth_adapter=use_ground_truth_adapter
            )
            if albums_result.data_is_available:
                # Don't idle add if the data is already available.
                albums_result.add_done_callback(reload_store)
            else:
                self.spinner.show()
                albums_result.add_done_callback(lambda f: GLib.idle_add(reload_store, f))
        else:
            selected_index = None
            for i, album in enumerate(self.current_models):
                if album.id == self.currently_selected_id:
                    selected_index = i
            self.emit(
                "num-pages-changed",
                math.ceil(len(self.current_models) / self.page_size),
            )
            do_update_grid(selected_index)

    # Event Handlers
    # =========================================================================
    def on_child_activated(self, flowbox: Gtk.FlowBox, child: Gtk.Widget):
        click_top = flowbox == self.grid_top
        selected_index = child.get_index()

        if click_top:
            page_offset = self.page_size * self.page
            if self.currently_selected_index is not None and (
                selected_index == self.currently_selected_index - page_offset
            ):
                self.emit("cover-clicked", None)
            else:
                self.emit("cover-clicked", self.list_store_top[selected_index].id)
        else:
            self.emit("cover-clicked", self.list_store_bottom[selected_index].id)

    def on_grid_resize(self, flowbox: Gtk.FlowBox, rect: Gdk.Rectangle):
        # TODO (#124): this doesn't work at all consistency, especially with themes that
        #              add extra padding.
        # 200     + (10      * 2) + (5      * 2) = 230
        # picture + (padding * 2) + (margin * 2)
        new_items_per_row = min((rect.width // 230), 7)
        if new_items_per_row != self.items_per_row:
            self.items_per_row = new_items_per_row
            self.detail_box_inner.set_size_request(self.items_per_row * 230 - 10, -1)

            self.reflow_grids(
                force_reload_from_master=True,
                selected_index=self.currently_selected_index,
            )

    # Helper Methods
    # =========================================================================
    def _make_label(self, text: str, name: str) -> Gtk.Label:
        return Gtk.Label(
            name=name,
            label=text,
            tooltip_text=text,
            ellipsize=Pango.EllipsizeMode.END,
            max_width_chars=22,
            halign=Gtk.Align.START,
        )

    def _create_cover_art_widget(self, item: _AlbumModel) -> Gtk.Box:
        widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # Cover art image
        artwork = SpinnerImage(
            loading=False,
            image_name="grid-artwork",
            spinner_name="grid-artwork-spinner",
            image_size=200,
        )
        widget_box.pack_start(artwork, False, False, 0)

        # Header for the widget
        header_label = self._make_label(item.album.name, "grid-header-label")
        widget_box.pack_start(header_label, False, False, 0)

        # Extra info for the widget
        info_text = util.dot_join(
            item.album.artist.name if item.album.artist else "-", item.album.year
        )
        if info_text:
            info_label = self._make_label(info_text, "grid-info-label")
            widget_box.pack_start(info_label, False, False, 0)

        # Download the cover art.
        def on_artwork_downloaded(filename: Result[str]):
            artwork.set_from_file(filename.result())
            artwork.set_loading(False)

        cover_art_filename_future = AdapterManager.get_cover_art_uri(item.album.cover_art, "file")
        if cover_art_filename_future.data_is_available:
            on_artwork_downloaded(cover_art_filename_future)
        else:
            artwork.set_loading(True)
            cover_art_filename_future.add_done_callback(
                lambda f: GLib.idle_add(on_artwork_downloaded, f)
            )

        widget_box.show_all()
        return widget_box

    def reflow_grids(
        self,
        force_reload_from_master: bool = False,
        selected_index: int | None = None,
        models: List[_AlbumModel] | None = None,
    ):
        # Calculate the page that the currently_selected_index is in. If it's a
        # different page, then update the window.
        if selected_index is not None:
            page_of_selected_index = selected_index // self.page_size
            if page_of_selected_index != self.page:
                self.emit("refresh-window", {"album_page": page_of_selected_index}, False)
                return
        page_offset = self.page_size * self.page

        # Calculate the look-at window.
        if models:
            if self.sort_dir == "ascending":
                window = models[page_offset : (page_offset + self.page_size)]
            else:
                reverse_sorted_models = reversed(models)
                # remove to the offset
                for _ in range(page_offset):
                    next(reverse_sorted_models, page_offset)
                window = list(itertools.islice(reverse_sorted_models, self.page_size))
        else:
            window = list(self.list_store_top) + list(self.list_store_bottom)

        # Determine where the cuttoff is between the top and bottom grids.
        entries_before_fold = self.page_size
        if selected_index is not None and self.items_per_row:
            relative_selected_index = selected_index - page_offset
            entries_before_fold = (
                (relative_selected_index // self.items_per_row) + 1
            ) * self.items_per_row

        # Unreveal the current album details first
        if selected_index is None:
            self.detail_box_revealer.set_reveal_child(False)

        if force_reload_from_master:
            # Just remove everything and re-add all of the items. It's not worth trying
            # to diff in this case.
            self.list_store_top.splice(
                0,
                len(self.list_store_top),
                window[:entries_before_fold],
            )
            self.list_store_bottom.splice(
                0,
                len(self.list_store_bottom),
                window[entries_before_fold:],
            )
        elif selected_index or entries_before_fold != self.page_size:
            # This case handles when the selection changes and the entries need to be
            # re-allocated to the top and bottom grids
            # Move entries between the two stores.
            top_store_len = len(self.list_store_top)
            bottom_store_len = len(self.list_store_bottom)
            diff = abs(entries_before_fold - top_store_len)

            if diff > 0:
                if entries_before_fold - top_store_len > 0:
                    # Move entries from the bottom store.
                    self.list_store_top.splice(top_store_len, 0, self.list_store_bottom[:diff])
                    self.list_store_bottom.splice(0, min(diff, bottom_store_len), [])
                else:
                    # Move entries to the bottom store.
                    self.list_store_bottom.splice(0, 0, self.list_store_top[-diff:])
                    self.list_store_top.splice(top_store_len - diff, diff, [])

        if selected_index is not None:
            relative_selected_index = selected_index - page_offset
            to_select = self.grid_top.get_child_at_index(relative_selected_index)
            if not to_select:
                return
            self.grid_top.select_child(to_select)

            if self.currently_selected_index == selected_index:
                return

            for c in self.detail_box_inner.get_children():
                self.detail_box_inner.remove(c)

            model = self.list_store_top[relative_selected_index]
            detail_element = AlbumWithSongs(model.album, cover_art_size=300)
            detail_element.connect(
                "song-clicked",
                lambda _, *args: self.emit("song-clicked", *args),
            )
            detail_element.connect("song-selected", lambda *a: None)

            self.detail_box_inner.pack_start(detail_element, True, True, 0)
            self.detail_box_inner.show_all()
            self.detail_box_revealer.set_reveal_child(True)

            # TODO (#88): scroll so that the grid_top is visible, and the
            # detail_box is visible, with preference to the grid_top. May need
            # to add another flag for this function.
        else:
            self.grid_top.unselect_all()
            self.grid_bottom.unselect_all()

        self.currently_selected_index = selected_index
