from datetime import timedelta
from functools import partial
from typing import Any, Callable, Dict, Optional, Set, Tuple, List

import bleach

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

from ..adapters import (
    AdapterManager,
    api_objects as API,
    DownloadProgress,
    Result,
)
from ..config import AppConfiguration, ProviderConfiguration
from ..players import PlayerManager
from . import albums, artists, browse, player_controls, playlists, util
from .common import IconButton, IconToggleButton, IconMenuButton, SpinnerImage
from .actions import run_action
from .providers import ProvidersWindow

class MainWindow(Handy.ApplicationWindow):
    """Defines the main window for Sublime Music."""

    is_desktop = False
    is_initialized = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # self.set_default_size(1342, 756)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # Create the stack
        self.albums_panel = albums.AlbumsPanel()
        self.artists_panel = artists.ArtistsPanel()
        self.browse_panel = browse.BrowsePanel()
        self.playlists_panel = playlists.PlaylistsPanel()
        self.stack = self._create_stack(
            Albums=self.albums_panel,
            Artists=self.artists_panel,
            # Browse=self.browse_panel,
            Playlists=self.playlists_panel,
        )
        self.stack.set_transition_type(Gtk.StackTransitionType.NONE)

        self.sidebar_flap = Handy.Flap(
            orientation=Gtk.Orientation.HORIZONTAL,
            fold_policy=Handy.FlapFoldPolicy.ALWAYS,
            transition_type=Handy.FlapTransitionType.OVER,
            modal=True)

        def stack_changed(*_):
            self.sidebar_flap.set_reveal_flap(False)

            run_action(self, 'app.change-tab', self.stack.get_visible_child_name())
        self.stack.connect("notify::visible-child", stack_changed)

        self.titlebar = self._create_headerbar(self.stack)
        box.add(self.titlebar)

        drawer = Handy.Flap(
            orientation=Gtk.Orientation.VERTICAL,
            fold_policy=Handy.FlapFoldPolicy.ALWAYS,
            flap_position=Gtk.PackType.END,
            transition_type=Handy.FlapTransitionType.SLIDE,
            modal=False)
        notification_container = Gtk.Overlay()

        notification_container.add(self.stack)

        self.notification_revealer = Gtk.Revealer(
            valign=Gtk.Align.END, halign=Gtk.Align.CENTER
        )

        notification_box = Gtk.Box(can_focus=False, valign="start", spacing=10)
        notification_box.get_style_context().add_class("app-notification")

        self.notification_icon = Gtk.Image()
        notification_box.pack_start(self.notification_icon, True, False, 5)

        self.notification_text = Gtk.Label(use_markup=True)
        notification_box.pack_start(self.notification_text, True, False, 5)

        self.notification_actions = Gtk.Box()
        notification_box.pack_start(self.notification_actions, True, False, 0)

        notification_box.add(close_button := IconButton("window-close-symbolic"))
        close_button.connect("clicked", lambda _: self.emit("notification-closed"))

        self.notification_revealer.add(notification_box)

        notification_container.add_overlay(self.notification_revealer)
        drawer.set_content(notification_container)

        # Player state
        self.player_manager = player_controls.Manager(self)

        # Player Controls
        drawer.set_separator(Gtk.Separator())

        squeezer = Handy.Squeezer(vexpand=True, homogeneous=False)

        desktop_controls = player_controls.Desktop(self.player_manager)
        squeezer.add(desktop_controls)

        mobile_handle = player_controls.MobileHandle(self.player_manager)

        # Toggle drawer when handle is pressed
        mobile_handle_events = Gtk.EventBox()
        mobile_handle_events.set_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK)

        click_gesture = Gtk.GestureMultiPress.new(mobile_handle_events)
        click_gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE)
        def toggle_drawer(gesture, num, x, y):
            if (num != 1 or not drawer.get_swipe_to_open() or
                x < 0 or x > mobile_handle_events.get_allocated_width() or
                y < 0 or y > mobile_handle_events.get_allocated_height()):
                return

            drawer.set_reveal_flap(not drawer.get_reveal_flap())
        click_gesture.connect('released', toggle_drawer)

        # Need to keep the gesture object alive
        mobile_handle_events._gesture = click_gesture

        mobile_handle_events.add(mobile_handle)
        squeezer.add(mobile_handle_events)

        # Only show the handle on desktop
        def squeezer_changed(squeezer, _):
            is_desktop = squeezer.get_visible_child() == desktop_controls

            drawer.set_swipe_to_open(not is_desktop)

            # When transitioning, don't play the reveal animation
            dur = drawer.get_reveal_duration()
            drawer.set_reveal_duration(0)

            drawer.set_reveal_flap(False)

            drawer.set_reveal_duration(dur)
        squeezer.connect("notify::visible-child", squeezer_changed)

        drawer.set_handle(squeezer)

        mobile_flap = player_controls.MobileFlap(self.player_manager)
        drawer.set_flap(mobile_flap)

        def drawer_reveal_progress(drawer, _):
            self.player_manager.flap_reveal_progress = drawer.get_reveal_progress()
        drawer.connect("notify::reveal-progress", drawer_reveal_progress)

        self.sidebar_flap.set_content(drawer)

        self.sidebar_flap.set_flap(self._create_sidebar())

        box.pack_start(self.sidebar_flap, True, True, 0)

        self.add(box)

        self.connect("button-release-event", self._on_button_release)

        self._settings_window = SettingsWindow(self)
        self._downloads_window = DownloadsWindow(self)
        self._providers_window = ProvidersWindow(self)

    current_notification_hash = None
    current_other_providers: Tuple[ProviderConfiguration, ...] = ()

    def update(
        self,
        app_config: AppConfiguration,
        player_manager: PlayerManager,
        force: bool = False,
    ):
        self.is_initialized = app_config.current_provider_id is not None

        notification = app_config.state.current_notification
        if notification and (h := hash(notification)) != self.current_notification_hash:
            self.current_notification_hash = h

            if notification.icon:
                self.notification_icon.set_from_icon_name(
                    notification.icon, Gtk.IconSize.DND
                )
            else:
                self.notification_icon.set_from_icon_name(None, Gtk.IconSize.DND)

            self.notification_text.set_markup(notification.markup)

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

            for label, fn in notification.actions:
                self.notification_actions.add(action_button := Gtk.Button(label=label))
                action_button.connect("clicked", lambda _: fn())

            self.notification_revealer.show_all()
            self.notification_revealer.set_reveal_child(True)

        if notification is None:
            self.notification_revealer.set_reveal_child(False)

        self.stack.set_visible_child_name(app_config.state.current_tab)

        if app_config.provider:
            active_panel = self.stack.get_visible_child()
            if hasattr(active_panel, "update"):
                active_panel.update(app_config, force=force)

            self.player_manager.update(app_config, force=force)

        if self._transient_window:
            self._transient_window.update(app_config, player_manager)

        if app_config.provider:
            ui_info = app_config.provider.ground_truth_adapter_type.get_ui_info()
            icon_basename = ui_info.icon_basename
        else:
            icon_basename = "list-add"

        if app_config.provider and AdapterManager.ground_truth_adapter_is_networked:
            if app_config.offline_mode:
                icon_status = "offline"
            elif AdapterManager.get_ping_status():
                icon_status = "connected"
            else:
                icon_status = "error"

            server_menu_button_icon = f"{icon_basename}-{icon_status}-symbolic"
        else:
            server_menu_button_icon = f"{icon_basename}-symbolic"

        self.sidebar_server_menu_button.set_icon(server_menu_button_icon)
        self.headerbar_server_menu_button.set_icon(server_menu_button_icon)

    def update_song_progress(self,
                             progress: Optional[timedelta],
                             duration: Optional[timedelta],
                             cache_progess: Optional[timedelta]):
        self.player_manager.update_song_progress(progress, duration, cache_progess)

    def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
        self._downloads_window.update_song_download_progress(song_id, progress)

    def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
        stack = Gtk.Stack(homogeneous=True, transition_type=Gtk.StackTransitionType.NONE)
        for name, child in kwargs.items():
            stack.add_titled(child, name.lower(), name)
        return stack

    def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar:
        """
        Configure the header bar for the window.
        """
        squeezer = Handy.Squeezer()

        # Desktop header
        desktop_header = Handy.HeaderBar()
        desktop_header.set_show_close_button(True)
        desktop_header.props.title = "Sublime Music"

        # Search
        self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
        self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
        self.search_entry.connect(
            "button-press-event", self._on_search_entry_button_press
        )
        self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus)
        self.search_entry.connect("changed", self._on_search_entry_changed)
        self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
        # desktop_header.pack_start(self.search_entry)

        # Search popup
        self._create_search_popup()

        # Stack switcher
        switcher = Gtk.StackSwitcher(stack=stack)
        desktop_header.set_custom_title(switcher)

        button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)

        # Downloads
        self.downloads_menu_button = IconButton(
            "folder-download-symbolic",
            tooltip_text="Show download status",
            relief=True,
        )
        self.downloads_menu_button.connect("clicked", lambda *_: self._show_transient_window(self._downloads_window))
        button_box.add(self.downloads_menu_button)

        # Preferences
        preferences_button = IconButton(
            "emblem-system-symbolic",
            tooltip_text="Open Sublime Music settings",
            relief=True,
        )
        preferences_button.connect("clicked", lambda *_: self._show_transient_window(self._settings_window))
        button_box.add(preferences_button)

        # Server icon and change server dropdown
        self.headerbar_server_menu_button = IconButton(
            "list-add-symbolic",
            tooltip_text="Server connection settings",
            relief=True,
        )
        self.headerbar_server_menu_button.connect(
            "clicked", lambda *_: self.show_providers_window()
        )
        button_box.add(self.headerbar_server_menu_button)

        desktop_header.pack_end(button_box)

        squeezer.add(desktop_header)

        # Mobile header
        mobile_header = Handy.HeaderBar()
        mobile_header.set_show_close_button(True)

        button = IconToggleButton("open-menu-symbolic")
        self.sidebar_flap.bind_property("reveal-flap", button, "active", GObject.BindingFlags.BIDIRECTIONAL)
        mobile_header.pack_start(button)

        squeezer.add(mobile_header)

        def squeezer_changed(squeezer, _):
            self.is_desktop = squeezer.get_visible_child() == desktop_header

            self.sidebar_flap.set_swipe_to_open(not self.is_desktop)

            # When transitioning, don't play the reveal animation
            dur = self.sidebar_flap.get_reveal_duration()
            self.sidebar_flap.set_reveal_duration(0)

            self.sidebar_flap.set_reveal_flap(False)

            self.sidebar_flap.set_reveal_duration(dur)
        squeezer.connect("notify::visible-child", squeezer_changed)

        return squeezer

    def _create_sidebar(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=150)
        box.get_style_context().add_class("background")

        stack = Gtk.StackSidebar(stack=self.stack, vexpand=True)
        box.pack_start(stack, True, True, 0)

        box.pack_end(Gtk.Separator(), False, False, 0)

        self.sidebar_server_menu_button = IconButton("list-add-symbolic", label="Servers")
        self.sidebar_server_menu_button.connect("clicked", lambda *_: self.show_providers_window())
        box.pack_end(self.sidebar_server_menu_button, False, False, 0)

        settings = IconButton("emblem-system-symbolic", label="Settings")
        settings.connect("clicked", lambda *_: self._show_transient_window(self._settings_window))
        box.pack_end(settings, False, False, 0)

        downloads = IconButton("folder-download-symbolic", label="Downloads")
        downloads.connect('clicked', lambda *_: self._show_transient_window(self._downloads_window))
        box.pack_end(downloads, False, False, 0)

        return box

    def _create_label(
        self, text: str, *args, halign: Gtk.Align = Gtk.Align.START, **kwargs
    ) -> Gtk.Label:
        label = Gtk.Label(
            use_markup=True,
            halign=halign,
            ellipsize=Pango.EllipsizeMode.END,
            *args,
            **kwargs,
        )
        label.set_markup(text)
        label.get_style_context().add_class("search-result-row")
        return label

    def _create_toggle_menu_button(
        self, label: str, settings_name: str
    ) -> Tuple[Gtk.Box, Gtk.Switch]:
        def on_active_change(toggle: Gtk.Switch, _):
            self._emit_settings_change({settings_name: toggle.get_active()})

        box = Gtk.Box()
        box.add(gtk_label := Gtk.Label(label=label))
        gtk_label.get_style_context().add_class("menu-label")
        switch = Gtk.Switch(active=True)
        switch.connect("notify::active", on_active_change)
        box.pack_end(switch, False, False, 0)
        box.get_style_context().add_class("menu-button")
        return box, switch

    def _create_model_button(
        self,
        text: str,
        clicked_fn: Callable = None,
        action_name: str = None,
        action_value: GLib.Variant = None,
        **kwargs,
    ) -> Gtk.ModelButton:
        model_button = Gtk.ModelButton(text=text, **kwargs)
        model_button.get_style_context().add_class("menu-button")
        if clicked_fn:
            model_button.connect("clicked", clicked_fn)
        if action_name:
            model_button.set_action_name(f"app.{action_name}")
        if action_value is not None:
            model_button.set_action_target_value(action_value)
        return model_button

    def _create_switch_provider_button(
        self, provider: ProviderConfiguration
    ) -> Gtk.Box:
        box = Gtk.Box()
        provider_name_button = self._create_model_button(
            provider.name,
            action_name="switch-music-provider",
            action_value=GLib.Variant("s", provider.id),
        )
        provider_name_button.connect(
            "clicked", lambda *a: self.server_connection_popover.popdown()
        )
        box.pack_start(provider_name_button, True, True, 0)

        provider_delete_button = IconButton(
            icon_name="user-trash-symbolic",
            tooltip_text=f"Remove the {provider.name} music provider",
        )
        provider_delete_button.connect(
            "clicked", lambda *a: self.server_connection_popover.popdown()
        )
        provider_delete_button.set_action_name("app.remove-music-provider")
        provider_delete_button.set_action_target_value(GLib.Variant("s", provider.id))
        box.pack_end(provider_delete_button, False, False, 0)

        return box

    def _on_settings_change(self, setting, _, prop):
        if self._updating_settings:
            return

        self._emit_settings_change({setting: self.get_property(prop.name)})

    def _create_search_popup(self) -> Gtk.PopoverMenu:
        self.search_popup = Gtk.PopoverMenu(modal=False)

        results_scrollbox = Gtk.ScrolledWindow(
            min_content_width=500,
            min_content_height=700,
        )

        def make_search_result_header(text: str) -> Gtk.Label:
            label = self._create_label(text)
            label.get_style_context().add_class("search-result-header")
            return label

        search_results_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            name="search-results",
        )
        self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner")
        search_results_box.add(self.search_results_loading)

        search_results_box.add(make_search_result_header("Songs"))
        self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        search_results_box.add(self.song_results)

        search_results_box.add(make_search_result_header("Albums"))
        self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        search_results_box.add(self.album_results)

        search_results_box.add(make_search_result_header("Artists"))
        self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        search_results_box.add(self.artist_results)

        search_results_box.add(make_search_result_header("Playlists"))
        self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        search_results_box.add(self.playlist_results)

        results_scrollbox.add(search_results_box)
        self.search_popup.add(results_scrollbox)

        self.search_popup.set_relative_to(self.search_entry)
        rect = Gdk.Rectangle()
        rect.x = 22
        rect.y = 28
        rect.width = 1
        rect.height = 1
        self.search_popup.set_pointing_to(rect)
        self.search_popup.set_position(Gtk.PositionType.BOTTOM)

    # Event Listeners
    # =========================================================================
    def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
        if not self._event_in_widgets(event, self.search_entry, self.search_popup):
            self._hide_search()

        # if not self._event_in_widgets(
        #     event,
        #     self.player_controls.device_button,
        #     self.player_controls.device_popover,
        # ):
        #     self.player_controls.device_popover.popdown()

        # if not self._event_in_widgets(
        #     event,
        #     self.player_controls.play_queue_button,
        #     self.player_controls.play_queue_popover,
        # ):
        #     self.player_controls.play_queue_popover.popdown()

        return False

    def show_providers_window(self):
        if self.is_initialized:
            self._providers_window.open_status_page()
        else:
            self._providers_window.open_create_page()

        self._show_transient_window(self._providers_window)



    _transient_window = None

    def _show_transient_window(self, window):
        assert self._transient_window is None
        self._transient_window = window

        window.set_transient_for(self)

        if not self.is_desktop and self.is_maximized():
            window.maximize()

        window.show_all()

        def reset(*_):
            self._transient_window = None
        window.connect('hide', reset)

        run_action(self, 'app.refresh')

    def _on_search_entry_focus(self, *args):
        self._show_search()

    def _on_search_entry_button_press(self, *args):
        self._show_search()

    def _on_search_entry_loose_focus(self, *args):
        self._hide_search()

    search_idx = 0
    searches: Set[Result] = set()

    def _on_search_entry_changed(self, entry: Gtk.Entry):
        while len(self.searches) > 0:
            search = self.searches.pop()
            if search:
                search.cancel()

        if not self.search_popup.is_visible():
            self.search_popup.show_all()
            self.search_popup.popup()

        def search_result_calback(idx: int, result: API.SearchResult):
            # Ignore slow returned searches.
            if idx < self.search_idx:
                return

            GLib.idle_add(self._update_search_results, result)

        def search_result_done(r: Result):
            if r.result() is True:
                # The search was cancelled
                return

            # If all results are back, the stop the loading indicator.
            GLib.idle_add(self._set_search_loading, False)

        self.search_idx += 1
        search_result = AdapterManager.search(
            entry.get_text(),
            search_callback=partial(search_result_calback, self.search_idx),
            before_download=lambda: self._set_search_loading(True),
        )
        search_result.add_done_callback(search_result_done)
        self.searches.add(search_result)

    def _on_search_entry_stop_search(self, entry: Any):
        self.search_popup.popdown()

    # Helper Functions
    # =========================================================================
    def _emit_settings_change(self, changed_settings: Dict[str, Any]):
        if self._updating_settings:
            return
        self.emit("refresh-window", {"__settings__": changed_settings}, False)

    def _show_search(self):
        self.search_entry.set_size_request(300, -1)
        self.search_popup.show_all()
        self.search_results_loading.hide()
        self.search_popup.popup()

    def _hide_search(self):
        self.search_popup.popdown()
        self.search_entry.set_size_request(-1, -1)

    def _set_search_loading(self, loading_state: bool):
        if loading_state:
            self.search_results_loading.start()
            self.search_results_loading.show_all()
        else:
            self.search_results_loading.stop()
            self.search_results_loading.hide()

    def _remove_all_from_widget(self, widget: Gtk.Widget):
        for c in widget.get_children():
            widget.remove(c)

    def _create_search_result_row(
        self, text: str, action_name: str, id: str, cover_art_id: Optional[str]
    ) -> Gtk.Button:
        def on_search_row_button_press(*args):
            self.emit("go-to", action_name, id)
            self._hide_search()

        row = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
        row.connect("button-press-event", on_search_row_button_press)

        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        image = SpinnerImage(image_name="search-artwork", image_size=30)
        box.add(image)
        box.add(self._create_label(text))
        row.add(box)

        def image_callback(f: Result):
            image.set_loading(False)
            image.set_from_file(f.result())

        artwork_future = AdapterManager.get_cover_art_uri(cover_art_id, "file")
        artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))

        return row

    def _update_search_results(self, search_results: API.SearchResult):
        # Songs
        if search_results.songs is not None:
            self._remove_all_from_widget(self.song_results)
            for song in search_results.songs:
                label_text = util.dot_join(
                    f"<b>{song.title}</b>",
                    song.artist.name if song.artist else None,
                )
                assert song.album and song.album.id
                self.song_results.add(
                    self._create_search_result_row(
                        bleach.clean(label_text), "album", song.album.id, song.cover_art
                    )
                )

            self.song_results.show_all()

        # Albums
        if search_results.albums is not None:
            self._remove_all_from_widget(self.album_results)
            for album in search_results.albums:
                label_text = util.dot_join(
                    f"<b>{album.name}</b>",
                    album.artist.name if album.artist else None,
                )
                assert album.id
                self.album_results.add(
                    self._create_search_result_row(
                        bleach.clean(label_text), "album", album.id, album.cover_art
                    )
                )

            self.album_results.show_all()

        # Artists
        if search_results.artists is not None:
            self._remove_all_from_widget(self.artist_results)
            for artist in search_results.artists:
                assert artist.id
                self.artist_results.add(
                    self._create_search_result_row(
                        bleach.clean(artist.name),
                        "artist",
                        artist.id,
                        artist.artist_image_url,
                    )
                )

            self.artist_results.show_all()

        # Playlists
        if search_results.playlists:
            self._remove_all_from_widget(self.playlist_results)
            for playlist in search_results.playlists:
                self.playlist_results.add(
                    self._create_search_result_row(
                        bleach.clean(playlist.name),
                        "playlist",
                        playlist.id,
                        playlist.cover_art,
                    )
                )

            self.playlist_results.show_all()

    def _event_in_widgets(self, event: Gdk.EventButton, *widgets) -> bool:
        for widget in widgets:
            if not widget.is_visible():
                continue

            _, win_x, win_y = Gdk.Window.get_origin(self.get_window())
            widget_x, widget_y = widget.translate_coordinates(self, 0, 0)
            allocation = widget.get_allocation()

            bound_x = (win_x + widget_x, win_x + widget_x + allocation.width)
            bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)

            # If the event is in this widget, return True immediately.
            if (bound_x[0] <= event.x_root <= bound_x[1]) and (
                bound_y[0] <= event.y_root <= bound_y[1]
            ):
                return True

        return False


class ComboEntry(GObject.GObject):
    def  __init__(self, value):
        GObject.GObject.__init__(self)

        self.value = value


class SettingsWindow(Handy.PreferencesWindow):
    def __init__(self, main_window):
        Handy.PreferencesWindow.__init__(self)
        self.main_window = main_window

        # Don't die when closed
        def hide_not_destroy(*_):
            self.hide()
            return True
        self.connect('delete-event', hide_not_destroy)

        general = Handy.PreferencesPage(icon_name="emblem-system-symbolic", title="General")
        general_group = Handy.PreferencesGroup(title="General")

        self._settings_updates = []
        self._player_settings_updates = []

        def set_args(setting):
            name = setting.replace('_', '-')
            action = f'settings.set-{name}'
            return (lambda app_config: getattr(app_config, setting),
                    lambda value: run_action(self.main_window, action, value))

        # Notifications
        row = self._create_switch(*set_args("song_play_notification"), title="Enable Song Notifications")
        general_group.add(row)

        # Player settings
        self.player_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        # vbox.add(self.player_settings_box)

        general.add(general_group)
        self.add(general)

        # Downloads
        download = Handy.PreferencesPage(icon_name="folder-download-symbolic", title="Downloads")
        download_group = Handy.PreferencesGroup(title="Downloads", description="Settings for downloads")

        # Allow Song Downloads
        self.download_row = Handy.ExpanderRow(show_enable_switch=True, title="Allow Song Downloads", expanded=True)

        # Download on Stream
        row = self._create_switch(*set_args("download_on_stream"), title="When Streaming, Also Download Song")
        self.download_row.add(row)

        # Prefetch Songs
        row = self._create_spin_button(0, 10, 1, *set_args("prefetch_amount"), title="Number of Songs to Prefetch")
        self.download_row.add(row)

        # Max Concurrent Downloads
        row = self._create_spin_button(0, 10, 1, *set_args("concurrent_download_limit"), title="Maximum Concurrent Downloads")
        self.download_row.add(row)

        download_group.add(self.download_row)
        download.add(download_group)
        self.add(download)

        # Players
        players = Handy.PreferencesPage(icon_name="media-playback-start-symbolic", title="Players")
        self.players_group = Handy.PreferencesGroup(title="Players", description="Settings for music players")
        players.add(self.players_group)
        self.add(players)

    _cached_players = None
    _updating_settings = False

    def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
        self._updating_settings = True

        for update in self._settings_updates:
            update(app_config)

        self.download_row.set_enable_expansion(app_config.allow_song_downloads)

        # Update players
        config_options = player_manager.get_configuration_options()
        new_players = list(config_options.keys())
        if not self._cached_players or self._cached_players != new_players:
            # TODO: Diff instead of re-creating
            for c in self.players_group.get_children():
                self.players_group.remove(c)

            # Keep the update functions for the player options in a separate
            # list, as these are dynamic
            settings_updates = self._settings_updates
            self._settings_updates = []

            def set_args(player, option, type_name):
                return (lambda app_config: app_config.player_config[player][option],
                        lambda value: run_action(self.main_window, f'players.set-{type_name}-option', player, option, value))

            for player_name, options in player_manager.get_configuration_options().items():
                player_row = Handy.ExpanderRow(title=player_name)

                for option_name, descriptor in options.items():
                    if type(descriptor) == tuple:
                        row = self._create_option(
                            descriptor,
                            *set_args(player_name, option_name, 'str'),
                            title=option_name)

                    elif descriptor == bool:
                        row = self._create_switch(
                            *set_args(player_name, option_name, 'bool'),
                            title=option_name)

                    elif descriptor == int:
                        row = self._create_spin_button(
                            0, 9999, 1,
                            *set_args(player_name, option_name, 'int'),
                            title=option_name)
                    else:
                        assert False

                    player_row.add(row)

                self.players_group.add(player_row)

            self.players_group.show_all()

            self._player_settings_updates = self._settings_updates
            self._settings_updates = settings_updates

            self._cached_players = new_players

        for update in self._player_settings_updates:
            update(app_config)

        self._updating_settings = False

    def _connect_setting(self, widget, signal, get_value: Callable, set_value: Callable, get, set):
        def on_signal(*_):
            if self._updating_settings:
                return

            set_value(get(widget))
        widget.connect(signal, on_signal)

        def update(app_config: AppConfiguration):
            set(widget, get_value(app_config))
        self._settings_updates.append(update)

    def _create_switch(self, get_value: Callable, set_value: Callable, **kwargs):
        row = Handy.ActionRow(**kwargs)
        switch = Gtk.Switch(valign=Gtk.Align.CENTER)

        self._connect_setting(switch, 'notify::active', get_value, set_value, lambda s: s.get_active(), lambda s, v: s.set_active(v))

        row.add(switch)
        return row

    def _create_spin_button(self, low: int, high: int, step: int, get_value: Callable, set_value: Callable, **kwargs):
        row = Handy.ActionRow(**kwargs)
        button = Gtk.SpinButton.new_with_range(low, high, step)

        self._connect_setting(button, 'notify::value', get_value, set_value, lambda b: b.get_value(), lambda b, v: b.set_value(v))

        row.add(button)
        return row

    def _create_option(self, values: List[str], get_value: Callable, set_value: Callable, **kwargs):
        row = Handy.ComboRow(**kwargs)

        store = Gio.ListStore.new(ComboEntry)
        for option in values:
            store.append(ComboEntry(option))

        row.bind_name_model(store, lambda r: r.value)
        row.set_selected_index(0)

        self._connect_setting(row, 'notify::selected-index', get_value, set_value, lambda c: values[c.get_selected_index()], lambda c, v: c.set_selected_index(values.index(v)))

        return row


class DownloadsWindow(Handy.Window):
    _failed_store: Gtk.ListStore = None
    _current_store: Gtk.ListStore = None
    _pending_store: Gtk.ListStore = None
    _downloads: Dict[str, Tuple[Gtk.ListStore, Gtk.TreeIter]] = None

    def __init__(self, main_window):
        Handy.Window.__init__(self,
            modal=True,
            window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
            destroy_with_parent=True,
            type_hint=Gdk.WindowTypeHint.DIALOG,
            default_width=640,
            default_height=576)
        self.main_window = main_window

        # Don't die when closed
        def hide_not_destroy(*_):
            self.hide()
            return True
        self.connect('delete-event', hide_not_destroy)

        self._failed_store = Gtk.ListStore(str)
        self._current_store = Gtk.ListStore(
            str,  # song ID
            float,  # progress
        )
        self._pending_store = Gtk.ListStore(str)
        self._downloads = {}

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        header_bar = Handy.HeaderBar(show_close_button=True, title="Downloads")
        box.add(header_bar)

        clamp = Handy.Clamp(margin=12)

        inner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        current_downloads_header = Gtk.Box()
        current_downloads_header.add(
            current_downloads_label := Gtk.Label(
                label="Current Downloads",
                name="menu-header",
            )
        )
        current_downloads_label.get_style_context().add_class("menu-label")

        self.cancel_all_button = IconButton("process-stop-symbolic", "Cancel All", sensitive=False)
        self.cancel_all_button.connect("clicked", self._on_cancel_all_clicked)
        current_downloads_header.pack_end(self.cancel_all_button, False, False, 0)

        self.retry_all_button = IconButton('view-refresh-symbolic', 'Retry All', sensitive=False)
        self.retry_all_button.connect("clicked", self._on_retry_all_clicked)
        current_downloads_header.pack_end(self.retry_all_button, False, False, 0)

        inner_box.add(current_downloads_header)

        self.stack = Gtk.Stack()

        self.scrolled_window = Gtk.ScrolledWindow()

        scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        scrolled_box.add(self._create_list(self._failed_store))
        scrolled_box.add(self._create_list(self._current_store))
        scrolled_box.add(self._create_list(self._pending_store))

        self.scrolled_window.add(scrolled_box)

        self.stack.add(self.scrolled_window)

        self.placeholder = Gtk.Label(
            label="<i>No current downloads</i>",
            use_markup=True,
            name="current-downloads-list-placeholder",
        )
        self.stack.add(self.placeholder)

        inner_box.pack_start(self.stack, True, True, 5)

        button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)

        button = Gtk.Button(label="Clear Song Cache")
        button.get_style_context().add_class('destructive-action')
        button.connect("clicked", self._clear_song_file_cache)
        button_box.pack_end(button, False, False, 5)

        button = Gtk.Button(label="Clear Full Cache")
        button.get_style_context().add_class('destructive-action')
        button.connect("clicked", self._clear_entire_cache)
        button_box.pack_end(button, False, False, 5)

        inner_box.add(button_box)

        clamp.add(inner_box)

        box.pack_start(clamp, True, True, 0)

        self.add(box)

    def _prompt_confirm_clear_cache(
        self, title: str, detail_text: str
    ) -> Gtk.ResponseType:
        confirm_dialog = Gtk.MessageDialog(
            transient_for=self.get_toplevel(),
            message_type=Gtk.MessageType.WARNING,
            text=title,
        )
        confirm_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
        confirm_dialog.add_button(Gtk.STOCK_DELETE, Gtk.ResponseType.YES)
        confirm_dialog.format_secondary_markup(detail_text)
        result = confirm_dialog.run()
        confirm_dialog.destroy()
        return result

    def _on_cancel_all_clicked(self):
        AdapterManager.cancel_download_songs(list(self._downloads.keys()))

    def _on_retry_all_clicked(self):
        AdapterManager.batch_download_songs(
            list(self._downloads.keys()),
            lambda _: None,
            lambda _: None,
        )

    def _clear_song_file_cache(self, _):
        title = "Confirm Delete Song Files"
        detail_text = "Are you sure you want to delete all cached song files? Your song metadata will be preserved."  # noqa: 512
        if self._prompt_confirm_clear_cache(title, detail_text) == Gtk.ResponseType.YES:
            AdapterManager.clear_song_cache()
            run_action(self.main_window, 'app.refresh')

    def _clear_entire_cache(self, _):
        title = "Confirm Delete Song Files and Metadata"
        detail_text = "Are you sure you want to delete all cached song files and corresponding metadata?"  # noqa: 512
        if self._prompt_confirm_clear_cache(title, detail_text) == Gtk.ResponseType.YES:
            AdapterManager.clear_entire_cache()
            run_action(self.main_window, 'app.refresh')

    def _create_list(self, store: Gtk.ListStore):
        view = Gtk.TreeView(
            model=store,
            headers_visible=False)

        renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
        column = Gtk.TreeViewColumn("", renderer)
        column.set_expand(True)

        def song_name(column: Any,
                      cell: Gtk.CellRendererPixbuf,
                      model: Gtk.ListStore,
                      tree_iter: Gtk.TreeIter,
                      flags: Any):
            song_id = model.get_value(tree_iter, 0)
            song = AdapterManager.get_song_details(song_id).result()

            artist = song.artist.name if song.artist else None
            label = f"{bleach.clean(artist)} - <b>{bleach.clean(song.title)}</b>"
            cell.set_property("markup", label)
        column.set_cell_data_func(renderer, song_name)

        view.append_column(column)

        if store == self._current_store:
            renderer = Gtk.CellRendererProgress()
            renderer.set_fixed_size(60, 35)
            column = Gtk.TreeViewColumn("", renderer)

            def progress(column: Any,
                         cell: Gtk.CellRendererPixbuf,
                         model: Gtk.ListStore,
                         tree_iter: Gtk.TreeIter,
                         flags: Any):
                value = model.get_value(tree_iter, 1)
                cell.set_property("value", value * 100)
            column.set_cell_data_func(renderer, progress)

            view.append_column(column)

        if store == self._failed_store:
            icon_name = 'view-refresh-symbolic'
        else:
            icon_name = 'window-close-symbolic'
        renderer = Gtk.CellRendererPixbuf(icon_name=icon_name)
        renderer.set_fixed_size(35, 35)
        button_column = Gtk.TreeViewColumn("", renderer)
        button_column.set_resizable(True)
        view.append_column(button_column)

        def on_button_press(tree: Any, event: Gdk.EventButton) -> bool:
            if event.button == 1:
                path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
                iter = store.get_iter(path)
                song_id = store[iter][0]

                if column == button_column:
                    if store == self._failed_store:
                        AdapterManager.batch_download_songs(
                            [song_id], lambda _: None, lambda _: None)
                    else:
                        AdapterManager.cancel_download_songs([song_id])
                    return True
            return False
        view.connect("button-press-event", on_button_press)
        return view

    def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
        pass

    def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
        def remove():
            if song_id in self._downloads:
                store, iter = self._downloads[song_id]
                store.remove(iter)
                del self._downloads[song_id]

        if progress.type == DownloadProgress.Type.QUEUED:
            remove()

            iter = self._pending_store.append()
            self._pending_store[iter] = (song_id,)
            self._downloads[song_id] = (self._pending_store, iter)
        elif progress.type in (
            DownloadProgress.Type.DONE,
            DownloadProgress.Type.CANCELLED,
        ):
            remove()
        elif progress.type == DownloadProgress.Type.ERROR:
            remove()

            iter = self._failed_store.append()
            self._failed_store[iter] = (song_id,)
            self._downloads[song_id] = (self._failed_store, iter)
        elif progress.type == DownloadProgress.Type.PROGRESS:
            if song_id not in self._downloads or self._downloads[song_id][0] != self._current_store:
                remove()

            if song_id in self._downloads:
                iter = self._downloads[song_id][1]
                self._current_store[iter][1] = progress.progress_fraction
            else:
                iter = self._current_store.prepend((song_id, progress.progress_fraction))
                self._downloads[song_id] = (self._current_store, iter)
        else:
            assert False

        self.cancel_all_button.set_sensitive(len(self._downloads) > 0)
        self.retry_all_button.set_sensitive(len(self._failed_store) > 0)

        if len(self._downloads) > 0:
            self.stack.set_visible_child(self.scrolled_window)
        else:
            self.stack.set_visible_child(self.placeholder)
