import logging
import math
import os
import random
import sys
from concurrent.futures import Future
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple

try:
    import osxmmkeys
    tap_imported = True
except Exception:
    tap_imported = False

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
try:
    gi.require_version('Notify', '0.7')
    from gi.repository import Notify
    glib_notify_exists = True
except Exception:
    # I really don't care what kind of exception it is, all that matters is the
    # import failed for some reason.
    logging.warning(
        'Unable to import Notify from GLib. Notifications will be disabled.')
    glib_notify_exists = False

from .cache_manager import CacheManager
from .config import ReplayGainType
from .dbus_manager import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
from .server.api_objects import Child, Directory, Playlist
from .state_manager import ApplicationState, RepeatType
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
from .ui.settings import SettingsDialog


class SublimeMusicApp(Gtk.Application):
    def __init__(self, config_file: str):
        super().__init__(application_id="com.sumnerevans.sublimemusic")
        if glib_notify_exists:
            Notify.init('Sublime Music')

        self.window: Optional[Gtk.Window] = None
        self.state = ApplicationState()
        self.state.config_file = config_file
        self.dbus_manager: Optional[DBusManager] = None

        self.connect('shutdown', self.on_app_shutdown)

    def do_startup(self):
        Gtk.Application.do_startup(self)

        def add_action(name: str, fn: Callable, parameter_type: str = None):
            """Registers an action with the application."""
            if type(parameter_type) == str:
                parameter_type = GLib.VariantType(parameter_type)
            action = Gio.SimpleAction.new(name, parameter_type)
            action.connect('activate', fn)
            self.add_action(action)

        # Add action for menu items.
        add_action('configure-servers', self.on_configure_servers)
        add_action('settings', self.on_settings)

        # Add actions for player controls
        add_action('play-pause', self.on_play_pause)
        add_action('next-track', self.on_next_track)
        add_action('prev-track', self.on_prev_track)
        add_action('repeat-press', self.on_repeat_press)
        add_action('shuffle-press', self.on_shuffle_press)

        # Navigation actions.
        add_action('play-next', self.on_play_next, parameter_type='as')
        add_action('add-to-queue', self.on_add_to_queue, parameter_type='as')
        add_action('go-to-album', self.on_go_to_album, parameter_type='s')
        add_action('go-to-artist', self.on_go_to_artist, parameter_type='s')
        add_action('browse-to', self.browse_to, parameter_type='s')
        add_action(
            'go-to-playlist', self.on_go_to_playlist, parameter_type='s')

        add_action('mute-toggle', self.on_mute_toggle)
        add_action(
            'update-play-queue-from-server',
            lambda a, p: self.update_play_state_from_server(),
        )

        if tap_imported:
            self.tap = osxmmkeys.Tap()
            self.tap.on('play_pause', self.on_play_pause)
            self.tap.on('next_track', self.on_next_track)
            self.tap.on('prev_track', self.on_prev_track)
            self.tap.start()

    def do_activate(self):
        # We only allow a single window and raise any existing ones
        if self.window:
            self.window.present()
            return

        # Windows are associated with the application when the last one is
        # closed the application shuts down.
        self.window = MainWindow(application=self, title="Sublime Music")

        # Configure the CSS provider so that we can style elements on the
        # window.
        css_provider = Gtk.CssProvider()
        css_provider.load_from_path(
            os.path.join(os.path.dirname(__file__), 'ui/app_styles.css'))
        context = Gtk.StyleContext()
        screen = Gdk.Screen.get_default()
        context.add_provider_for_screen(
            screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)

        self.window.stack.connect(
            'notify::visible-child',
            self.on_stack_change,
        )
        self.window.connect('song-clicked', self.on_song_clicked)
        self.window.connect('songs-removed', self.on_songs_removed)
        self.window.connect('refresh-window', self.on_refresh_window)
        self.window.connect('go-to', self.on_window_go_to)
        self.window.connect('key-press-event', self.on_window_key_press)
        self.window.player_controls.connect('song-scrub', self.on_song_scrub)
        self.window.player_controls.connect(
            'device-update', self.on_device_update)
        self.window.player_controls.connect(
            'volume-change', self.on_volume_change)

        self.window.show_all()
        self.window.present()

        # Load the configuration and update the UI with the curent server, if
        # it exists.
        self.state.load()

        # If there is no current server, show the dialog to select a server.
        if self.state.config.server is None:
            self.show_configure_servers_dialog()

            # If they didn't add one with the dialog, close the window.
            if self.state.config.server is None:
                self.window.close()
                return

        self.update_window()

        # Configure the players
        self.last_play_queue_update = 0
        self.loading_state = False
        self.should_scrobble_song = False

        def time_observer(value: Optional[float]):
            if (self.loading_state or not self.window
                    or not self.state.current_song):
                return

            if value is None:
                self.last_play_queue_update = 0
                return

            self.state.song_progress = value
            GLib.idle_add(
                self.window.player_controls.update_scrubber,
                self.state.song_progress,
                self.state.current_song.duration,
            )

            if self.last_play_queue_update + 15 <= value:
                self.save_play_queue()

            if value > 5 and self.should_scrobble_song:
                CacheManager.scrobble(self.state.current_song.id)
                self.should_scrobble_song = False

        def on_track_end():
            if (self.state.current_song_index == len(self.state.play_queue) - 1
                    and self.state.repeat_type == RepeatType.NO_REPEAT):
                self.state.playing = False
                self.state.current_song_index = -1
                GLib.idle_add(self.update_window)
                return

            GLib.idle_add(self.on_next_track)

        @dbus_propagate(self)
        def on_player_event(event: PlayerEvent):
            if event.name == 'play_state_change':
                self.state.playing = event.value
            elif event.name == 'volume_change':
                self.state.volume = event.value

            GLib.idle_add(self.update_window)

        self.mpv_player = MPVPlayer(
            time_observer,
            on_track_end,
            on_player_event,
            self.state.config,
        )
        self.chromecast_player = ChromecastPlayer(
            time_observer,
            on_track_end,
            on_player_event,
            self.state.config,
        )
        self.player = self.mpv_player

        if self.state.current_device != 'this device':
            # TODO (#120)
            pass

        self.state.current_device = 'this device'

        # Need to do this after we set the current device.
        self.player.volume = self.state.volume

        # Prompt to load the play queue from the server.
        if self.state.config.server.sync_enabled:
            self.update_play_state_from_server(prompt_confirm=True)

        # Send out to the bus that we exist.
        if self.dbus_manager:
            self.dbus_manager.property_diff()

    # ########## DBUS MANAGMENT ########## #
    def do_dbus_register(
            self, connection: Gio.DBusConnection, path: str) -> bool:
        self.dbus_manager = DBusManager(
            connection,
            self.on_dbus_method_call,
            self.on_dbus_set_property,
            lambda: (self.state, getattr(self, 'player', None)),
        )
        return True

    def on_dbus_method_call(
        self,
        connection: Gio.DBusConnection,
        sender: str,
        path: str,
        interface: str,
        method: str,
        params: GLib.Variant,
        invocation: Gio.DBusMethodInvocation,
    ):
        second_microsecond_conversion = 1000000

        def seek_fn(offset: float):
            if not self.state.current_song:
                return
            offset_seconds = offset / second_microsecond_conversion
            new_seconds = self.state.song_progress + offset_seconds
            self.on_song_scrub(
                None, new_seconds / self.state.current_song.duration * 100)

        def set_pos_fn(track_id: str, position: float = 0):
            if self.state.playing:
                self.on_play_pause()
            pos_seconds = position / second_microsecond_conversion
            self.state.song_progress = pos_seconds
            track_id, occurrence = track_id.split('/')[-2:]

            # Find the (N-1)th time that the track id shows up in the list. (N
            # is the -*** suffix on the track id.)
            song_index = [
                i for i, x in enumerate(self.state.play_queue) if x == track_id
            ][int(occurrence) or 0]

            self.play_song(song_index)

        def get_tracks_metadata(track_ids: List[str]) -> GLib.Variant:
            if not self.dbus_manager:
                return

            if len(track_ids):
                # We are lucky, just return an empty list.
                return GLib.Variant('(aa{sv})', ([], ))

            # Have to calculate all of the metadatas so that we can deal with
            # repeat song IDs.
            metadatas: Iterable[Any] = [
                self.dbus_manager.get_mpris_metadata(i, self.state.play_queue)
                for i in range(len(self.state.play_queue))
            ]

            # Get rid of all of the tracks that were not requested.
            metadatas = filter(
                lambda m: m['mpris:trackid'] in track_ids, metadatas)

            # Sort them so they get returned in the same order as they were
            # requested.
            metadatas = sorted(
                metadatas, key=lambda m: track_ids.index(m['mpris:trackid']))

            # Turn them into dictionaries that can actually be serialized into
            # a GLib.Variant.
            metadatas = map(
                lambda m: {k: DBusManager.to_variant(v)
                           for k, v in m.items()},
                metadatas,
            )

            return GLib.Variant('(aa{sv})', (list(metadatas), ))

        def activate_playlist(playlist_id: str):
            playlist_id = playlist_id.split('/')[-1]
            playlist = CacheManager.get_playlist(playlist_id).result()

            # Calculate the song id to play.
            song_idx = 0
            if self.state.shuffle_on:
                song_idx = random.randint(0, len(playlist.entry) - 1)

            self.on_song_clicked(
                None,
                song_idx,
                [s.id for s in playlist.entry],
                {'active_playlist_id': playlist_id},
            )

        def get_playlists(
                index: int,
                max_count: int,
                order: str,
                reverse_order: bool,
        ) -> GLib.Variant:
            playlists_result = CacheManager.get_playlists()
            if playlists_result.is_future:
                # We don't want to wait for the response in this case, so just
                # return an empty array.
                return GLib.Variant('(a(oss))', ([], ))

            playlists = playlists_result.result()

            sorters = {
                'Alphabetical': lambda p: p.name,
                'Created': lambda p: p.created,
                'Modified': lambda p: p.changed,
            }
            playlists.sort(
                key=sorters.get(order, lambda p: p),
                reverse=reverse_order,
            )

            def make_playlist_tuple(p: Playlist) -> GLib.Variant:
                cover_art_filename = CacheManager.get_cover_art_filename(
                    p.coverArt,
                    allow_download=False,
                ).result()
                return (f'/playlist/{p.id}', p.name, cover_art_filename or '')

            return GLib.Variant(
                '(a(oss))', (
                    [
                        make_playlist_tuple(p)
                        for p in playlists[index:(index + max_count)]
                    ], ))

        method_call_map: Dict[str, Dict[str, Any]] = {
            'org.mpris.MediaPlayer2': {
                'Raise': self.window and self.window.present,
                'Quit': self.window and self.window.destroy,
            },
            'org.mpris.MediaPlayer2.Player': {
                'Next': self.on_next_track,
                'Previous': self.on_prev_track,
                'Pause': self.state.playing and self.on_play_pause,
                'PlayPause': self.on_play_pause,
                'Stop': self.state.playing and self.on_play_pause,
                'Play': not self.state.playing and self.on_play_pause,
                'Seek': seek_fn,
                'SetPosition': set_pos_fn,
            },
            'org.mpris.MediaPlayer2.TrackList': {
                'GoTo': set_pos_fn,
                'GetTracksMetadata': get_tracks_metadata,
            },
            'org.mpris.MediaPlayer2.Playlists': {
                'ActivatePlaylist': activate_playlist,
                'GetPlaylists': get_playlists,
            },
        }
        method_fn = method_call_map.get(interface, {}).get(method)
        if method_fn is None:
            logging.warning(
                f'Unknown/unimplemented method: {interface}.{method}.')
        invocation.return_value(
            method_fn(*params) if callable(method_fn) else None)

    def on_dbus_set_property(
        self,
        connection: Gio.DBusConnection,
        sender: str,
        path: str,
        interface: str,
        property_name: str,
        value: GLib.Variant,
    ):
        def change_loop(new_loop_status: GLib.Variant):
            self.state.repeat_type = RepeatType.from_mpris_loop_status(
                new_loop_status.get_string())
            self.update_window()

        def set_shuffle(new_val: GLib.Variant):
            if new_val.get_boolean() != self.state.shuffle_on:
                self.on_shuffle_press(None, None)

        def set_volume(new_val: GLib.Variant):
            self.on_volume_change(None, new_val.get_double() * 100)

        setter_map: Dict[str, Dict[str, Any]] = {
            'org.mpris.MediaPlayer2.Player': {
                'LoopStatus': change_loop,
                'Rate': lambda _: None,
                'Shuffle': set_shuffle,
                'Volume': set_volume,
            }
        }

        setter = setter_map.get(interface, {}).get(property_name)
        if setter is None:
            logging.warning('Set: Unknown property: {property_name}.')
            return
        if callable(setter):
            setter(value)

    # ########## ACTION HANDLERS ########## #
    @dbus_propagate()
    def on_refresh_window(
        self,
        _: Any,
        state_updates: Dict[str, Any],
        force: bool = False,
    ):
        for k, v in state_updates.items():
            setattr(self.state, k, v)
        self.update_window(force=force)

    def on_configure_servers(self, *args):
        self.show_configure_servers_dialog()

    def on_settings(self, *args):
        """Show the Settings dialog."""
        dialog = SettingsDialog(self.window, self.state.config)
        result = dialog.run()
        if result == Gtk.ResponseType.OK:
            self.state.config.port_number = int(
                dialog.data['port_number'].get_text())
            self.state.config.always_stream = dialog.data[
                'always_stream'].get_active()
            self.state.config.download_on_stream = dialog.data[
                'download_on_stream'].get_active()
            self.state.config.song_play_notification = dialog.data[
                'song_play_notification'].get_active()
            self.state.config.serve_over_lan = dialog.data[
                'serve_over_lan'].get_active()
            self.state.config.prefetch_amount = dialog.data[
                'prefetch_amount'].get_value_as_int()
            self.state.config.concurrent_download_limit = dialog.data[
                'concurrent_download_limit'].get_value_as_int()
            self.state.config.replay_gain = ReplayGainType.from_string(
                dialog.data['replay_gain'].get_active_id())
            self.state.save_config()
            self.reset_state()
        dialog.destroy()

    def on_window_go_to(self, win: Any, action: str, value: str):
        {
            'album': self.on_go_to_album,
            'artist': self.on_go_to_artist,
            'playlist': self.on_go_to_playlist,
        }[action](None, GLib.Variant('s', value))

    @dbus_propagate()
    def on_play_pause(self, *args):
        if self.state.current_song_index < 0:
            return

        if self.player.song_loaded:
            self.player.toggle_play()
            self.save_play_queue()
        else:
            # This is from a restart, start playing the file.
            self.play_song(self.state.current_song_index)

        self.state.playing = not self.state.playing
        self.update_window()

    def on_next_track(self, *args):
        # Handle song repeating
        if self.state.repeat_type == RepeatType.REPEAT_SONG:
            song_index_to_play = self.state.current_song_index
        # Wrap around the play queue if at the end.
        elif self.state.current_song_index == len(self.state.play_queue) - 1:
            # This may happen due to D-Bus.
            if self.state.repeat_type == RepeatType.NO_REPEAT:
                return
            song_index_to_play = 0
        else:
            song_index_to_play = self.state.current_song_index + 1

        self.play_song(song_index_to_play, reset=True)

    def on_prev_track(self, *args):
        # Go back to the beginning of the song if we are past 5 seconds.
        # Otherwise, go to the previous song.
        if self.state.repeat_type == RepeatType.REPEAT_SONG:
            song_index_to_play = self.state.current_song_index
        elif self.state.song_progress < 5:
            if (self.state.current_song_index == 0
                    and self.state.repeat_type == RepeatType.NO_REPEAT):
                song_index_to_play = 0
            else:
                song_index_to_play = (self.state.current_song_index - 1) % len(
                    self.state.play_queue)
        else:
            song_index_to_play = self.state.current_song_index

        self.play_song(song_index_to_play, reset=True)

    @dbus_propagate()
    def on_repeat_press(self, *args):
        # Cycle through the repeat types.
        new_repeat_type = RepeatType((self.state.repeat_type.value + 1) % 3)
        self.state.repeat_type = new_repeat_type
        self.update_window()

    @dbus_propagate()
    def on_shuffle_press(self, *args):
        if self.state.shuffle_on:
            # Revert to the old play queue.
            self.state.current_song_index = self.state.old_play_queue.index(
                self.state.current_song.id)
            self.state.play_queue = self.state.old_play_queue.copy()
        else:
            self.state.old_play_queue = self.state.play_queue.copy()

            # Remove the current song, then shuffle and put the song back.
            song_id = self.state.current_song.id
            del self.state.play_queue[self.state.current_song_index]
            random.shuffle(self.state.play_queue)
            self.state.play_queue = [song_id] + self.state.play_queue
            self.state.current_song_index = 0

        self.state.shuffle_on = not self.state.shuffle_on
        self.update_window()

    @dbus_propagate()
    def on_play_next(self, action: Any, song_ids: List[str]):
        if self.state.current_song is None:
            insert_at = 0
        else:
            insert_at = self.state.current_song_index + 1

        self.state.play_queue = (
            self.state.play_queue[:insert_at] + list(song_ids)
            + self.state.play_queue[insert_at:])
        self.state.old_play_queue.extend(song_ids)
        self.update_window()

    @dbus_propagate()
    def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
        self.state.play_queue.extend(song_ids)
        self.state.old_play_queue.extend(song_ids)
        self.update_window()

    def on_go_to_album(self, action: Any, album_id: GLib.Variant):
        # Switch to the By Year view (or genre, if year is not available) to
        # guarantee that the album is there.
        album = CacheManager.get_album(album_id.get_string()).result()
        if isinstance(album, Directory):
            if len(album.child) > 0:
                album = album.child[0]

        if album.get('year'):
            self.state.current_album_sort = 'byYear'
            self.state.current_album_from_year = album.year
            self.state.current_album_to_year = album.year
        elif album.get('genre'):
            self.state.current_album_sort = 'byGenre'
            self.state.current_album_genre = album.genre
        else:
            dialog = Gtk.MessageDialog(
                transient_for=self.window,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text='Could not go to album',
            )
            dialog.format_secondary_markup(
                'Could not go to the album because it does not have a year or '
                'genre.')
            dialog.run()
            dialog.destroy()
            return

        self.state.current_tab = 'albums'
        self.state.selected_album_id = album_id.get_string()
        self.update_window(force=True)

    def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
        self.state.current_tab = 'artists'
        self.state.selected_artist_id = artist_id.get_string()
        self.update_window()

    def browse_to(self, action: Any, item_id: GLib.Variant):
        self.state.current_tab = 'browse'
        self.state.selected_browse_element_id = item_id.get_string()
        self.update_window()

    def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
        self.state.current_tab = 'playlists'
        self.state.selected_playlist_id = playlist_id.get_string()
        self.update_window()

    def on_server_list_changed(self, action: Any, servers: GLib.Variant):
        self.state.config.servers = servers
        self.state.save_config()

    def on_connected_server_changed(
        self,
        action: Any,
        current_server: GLib.Variant,
    ):
        if self.state.config.server:
            self.state.save()
        self.state.config.current_server = current_server
        self.state.save_config()

        self.reset_state()

    def reset_state(self):
        if self.state.playing:
            self.on_play_pause()
        self.loading_state = True
        self.state.load()
        self.player.reset()
        self.loading_state = False

        # Update the window according to the new server configuration.
        self.update_window()

    def on_stack_change(self, stack: Gtk.Stack, _: Any):
        self.state.current_tab = stack.get_visible_child_name()
        self.update_window()

    def on_song_clicked(
        self,
        win: Any,
        song_index: int,
        song_queue: List[str],
        metadata: Dict[str, Any],
    ):
        # Reset the play queue so that we don't ever revert back to the
        # previous one.
        old_play_queue = song_queue.copy()

        if metadata.get('force_shuffle_state') is not None:
            self.state.shuffle_on = metadata['force_shuffle_state']

        if metadata.get('active_playlist_id') is not None:
            self.state.active_playlist_id = metadata.get('active_playlist_id')
        else:
            self.state.active_playlist_id = None

        # If shuffle is enabled, then shuffle the playlist.
        if self.state.shuffle_on and not metadata.get('no_reshuffle'):
            song_id = song_queue[song_index]

            del song_queue[song_index]
            random.shuffle(song_queue)
            song_queue = [song_id] + song_queue
            song_index = 0

        self.play_song(
            song_index,
            reset=True,
            old_play_queue=old_play_queue,
            play_queue=song_queue,
        )

    def on_songs_removed(self, win: Any, song_indexes_to_remove: List[int]):
        self.state.play_queue = [
            song_id for i, song_id in enumerate(self.state.play_queue)
            if i not in song_indexes_to_remove
        ]

        # Determine how many songs before the currently playing one were also
        # deleted.
        before_current = [
            i for i in song_indexes_to_remove
            if i < self.state.current_song_index
        ]

        if self.state.current_song_index in song_indexes_to_remove:
            if len(self.state.play_queue) == 0:
                self.on_play_pause()
                self.state.current_song_index = -1
                self.update_window()
                return

            self.state.current_song_index -= len(before_current)
            self.play_song(self.state.current_song_index, reset=True)
        else:
            self.state.current_song_index -= len(before_current)
            self.update_window()
            self.save_play_queue()

    @dbus_propagate()
    def on_song_scrub(self, win: Any, scrub_value: float):
        if not self.state.current_song or not self.window:
            return

        new_time = self.state.current_song.duration * (scrub_value / 100)

        self.state.song_progress = new_time
        self.window.player_controls.update_scrubber(
            self.state.song_progress, self.state.current_song.duration)

        # If already playing, then make the player itself seek.
        if self.player.song_loaded:
            self.player.seek(new_time)

        self.save_play_queue()

    def on_device_update(self, win: Any, device_uuid: str):
        if device_uuid == self.state.current_device:
            return
        self.state.current_device = device_uuid

        was_playing = self.state.playing
        self.player.pause()
        self.player._song_loaded = False
        self.state.playing = False

        if self.dbus_manager:
            self.dbus_manager.property_diff()

        self.update_window()

        if device_uuid == 'this device':
            self.player = self.mpv_player
        else:
            self.chromecast_player.set_playing_chromecast(device_uuid)
            self.player = self.chromecast_player

        if was_playing:
            self.on_play_pause()
            if self.dbus_manager:
                self.dbus_manager.property_diff()

    @dbus_propagate()
    def on_mute_toggle(self, *args):
        self.state.is_muted = not self.state.is_muted
        self.player.is_muted = self.state.is_muted
        self.update_window()

    @dbus_propagate()
    def on_volume_change(self, _: Any, value: float):
        self.state.volume = value
        self.player.volume = self.state.volume
        self.update_window()

    def on_window_key_press(
            self,
            window: Gtk.Window,
            event: Gdk.EventKey,
    ) -> bool:
        # Need to use bitwise & here to see if CTRL is pressed.
        if (event.keyval == 102
                and event.state & Gdk.ModifierType.CONTROL_MASK):
            # Ctrl + F
            window.search_entry.grab_focus()
            return False

        if window.search_entry.has_focus():
            return False

        keymap = {
            32: self.on_play_pause,
            65360: self.on_prev_track,
            65367: self.on_next_track,
        }

        action = keymap.get(event.keyval)
        if action:
            action()
            return True

        return False

    def on_app_shutdown(self, app: 'SublimeMusicApp'):
        if glib_notify_exists:
            Notify.uninit()

        if tap_imported and self.tap:
            self.tap.stop()

        if self.state.config.server is None:
            return

        self.player.pause()
        self.chromecast_player.shutdown()
        self.mpv_player.shutdown()

        self.state.save()
        self.save_play_queue()
        if self.dbus_manager:
            self.dbus_manager.shutdown()
        CacheManager.shutdown()

    # ########## HELPER METHODS ########## #
    def show_configure_servers_dialog(self):
        """Show the Connect to Server dialog."""
        dialog = ConfigureServersDialog(self.window, self.state.config)
        dialog.connect('server-list-changed', self.on_server_list_changed)
        dialog.connect(
            'connected-server-changed', self.on_connected_server_changed)
        dialog.run()
        dialog.destroy()

    def update_window(self, force: bool = False):
        if not self.window:
            return
        GLib.idle_add(lambda: self.window.update(self.state, force=force))

    def update_play_state_from_server(self, prompt_confirm: bool = False):
        # TODO (#129): need to make the up next list loading for the duration
        # here if prompt_confirm is False.
        was_playing = self.state.playing
        self.player.pause()
        self.state.playing = False
        self.update_window()

        def do_update(f: Future):
            play_queue = f.result()
            new_play_queue = [s.id for s in play_queue.entry]
            new_current_song_id = str(play_queue.current)
            new_song_progress = play_queue.position / 1000

            if prompt_confirm:
                # If there's not a significant enough difference, don't prompt.
                progress_diff = 15
                if self.state.song_progress:
                    progress_diff = abs(
                        self.state.song_progress - new_song_progress)

                if (self.state.play_queue == new_play_queue
                        and self.state.current_song
                        and self.state.current_song.id == new_current_song_id
                        and progress_diff < 15):
                    return

                dialog = Gtk.MessageDialog(
                    transient_for=self.window,
                    message_type=Gtk.MessageType.INFO,
                    buttons=Gtk.ButtonsType.YES_NO,
                    text='Resume Playback?',
                )

                dialog.format_secondary_markup(
                    'Do you want to resume the play queue saved by '
                    + str(play_queue.changedBy) + ' at '
                    + play_queue.changed.astimezone(
                        tz=None).strftime('%H:%M on %Y-%m-%d') + '?')
                result = dialog.run()
                dialog.destroy()
                if result != Gtk.ResponseType.YES:
                    return

            self.state.play_queue = new_play_queue
            self.state.song_progress = play_queue.position / 1000

            self.state.current_song_index = self.state.play_queue.index(
                new_current_song_id)

            self.player.reset()
            self.update_window()

            if was_playing:
                self.on_play_pause()

        play_queue_future = CacheManager.get_play_queue()
        play_queue_future.add_done_callback(
            lambda f: GLib.idle_add(do_update, f))

    song_playing_order_token = 0

    def play_song(
        self,
        song_index: int,
        reset: bool = False,
        old_play_queue: List[str] = None,
        play_queue: List[str] = None,
    ):
        # Do this the old fashioned way so that we can have access to ``reset``
        # in the callback.
        @dbus_propagate(self)
        def do_play_song(song: Child):
            uri, stream = CacheManager.get_song_filename_or_stream(
                song,
                force_stream=self.state.config.always_stream,
            )
            # Prevent it from doing the thing where it continually loads
            # songs when it has to download.
            if reset:
                self.player.reset()
                self.state.song_progress = 0
                self.should_scrobble_song = True

            # Show a song play notification.
            if self.state.config.song_play_notification:
                try:
                    if glib_notify_exists:
                        notification_lines = []
                        if song.album:
                            notification_lines.append(f'<i>{song.album}</i>')
                        if song.artist:
                            notification_lines.append(song.artist)
                        song_notification = Notify.Notification.new(
                            song.title,
                            '\n'.join(notification_lines),
                        )
                        song_notification.add_action(
                            'clicked',
                            'Open Sublime Music',
                            lambda *a: self.window.present()
                            if self.window else None,
                        )
                        song_notification.show()

                        def on_cover_art_download_complete(
                            cover_art_filename: str,
                            order_token: int,
                        ):
                            if order_token != self.song_playing_order_token:
                                return

                            # Add the image to the notification, and re-show
                            # the notification.
                            song_notification.set_image_from_pixbuf(
                                GdkPixbuf.Pixbuf.new_from_file_at_scale(
                                    cover_art_filename, 70, 70, True))
                            song_notification.show()

                        def get_cover_art_filename(
                                order_token: int) -> Tuple[str, int]:
                            return (
                                CacheManager.get_cover_art_filename(
                                    song.coverArt).result(),
                                order_token,
                            )

                        self.song_playing_order_token += 1
                        cover_art_future = CacheManager.create_future(
                            get_cover_art_filename,
                            self.song_playing_order_token,
                        )
                        cover_art_future.add_done_callback(
                            lambda f: on_cover_art_download_complete(
                                *f.result()))
                    if sys.platform == 'darwin':
                        notification_lines = []
                        if song.album:
                            notification_lines.append(song.album)
                        if song.artist:
                            notification_lines.append(song.artist)
                        notification_text = '\n'.join(notification_lines)
                        osascript_command = [
                            'display',
                            'notification',
                            f'"{notification_text}"',
                            'with',
                            'title',
                            f'"{song.title}"',
                        ]

                        os.system(
                            f"osascript -e '{' '.join(osascript_command)}'")
                except Exception:
                    logging.warning(
                        'Unable to display notification. Is a notification '
                        'daemon running?')

            def on_song_download_complete(song_id: int):
                if (self.state.current_song
                        and self.state.current_song.id != song.id):
                    return
                if not self.state.playing:
                    return

                # Switch to the local media if the player can hotswap (MPV can,
                # Chromecast cannot hotswap without lag).
                if self.player.can_hotswap_source:
                    self.player.play_media(
                        CacheManager.get_song_filename_or_stream(song)[0],
                        self.state.song_progress,
                        song,
                    )
                GLib.idle_add(self.update_window)

            # If streaming, also download the song, unless configured not to,
            # or configured to always stream.
            if (stream and self.state.config.download_on_stream
                    and not self.state.config.always_stream):
                CacheManager.batch_download_songs(
                    [song.id],
                    before_download=lambda: self.update_window(),
                    on_song_download_complete=on_song_download_complete,
                )

            self.player.play_media(
                uri,
                0 if reset else self.state.song_progress,
                song,
            )
            self.state.playing = True
            self.update_window()

            # Prefetch songs
            if self.state.repeat_type != RepeatType.REPEAT_SONG:
                song_idx = self.state.play_queue.index(song.id)
                prefetch_idxs = []
                for i in range(self.state.config.prefetch_amount):
                    prefetch_idx: int = song_idx + 1 + i
                    play_queue_len: int = len(self.state.play_queue)
                    if (self.state.repeat_type == RepeatType.REPEAT_QUEUE
                            or prefetch_idx < play_queue_len):
                        prefetch_idxs.append(
                            prefetch_idx % play_queue_len)  # noqa: S001
                CacheManager.batch_download_songs(
                    [self.state.play_queue[i] for i in prefetch_idxs],
                    before_download=lambda: GLib.idle_add(self.update_window),
                    on_song_download_complete=lambda _: GLib.idle_add(
                        self.update_window),
                )

        if old_play_queue:
            self.state.old_play_queue = old_play_queue

        if play_queue:
            self.state.play_queue = play_queue

        self.state.current_song_index = song_index

        if play_queue:
            self.save_play_queue()

        song_details_future = CacheManager.get_song_details(
            self.state.play_queue[self.state.current_song_index])
        song_details_future.add_done_callback(
            lambda f: GLib.idle_add(do_play_song, f.result()), )

    def save_play_queue(self):
        if len(self.state.play_queue) == 0:
            return

        position = self.state.song_progress
        self.last_play_queue_update = position or 0

        if self.state.config.server.sync_enabled and self.state.current_song:
            CacheManager.save_play_queue(
                play_queue=self.state.play_queue,
                current=self.state.current_song.id,
                position=math.floor(position * 1000) if position else None,
            )
