import glob
import hashlib
import itertools
import json
import logging
import os
import re
import shutil
import threading
from collections import defaultdict
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from enum import Enum, EnumMeta
from functools import lru_cache
from pathlib import Path
from time import sleep
from typing import (
    Any,
    Callable,
    DefaultDict,
    Dict,
    Generic,
    Iterable,
    List,
    Optional,
    Set,
    Tuple,
    TypeVar,
    Union,
)

import requests
from fuzzywuzzy import fuzz

try:
    import gi

    gi.require_version("NM", "1.0")
    from gi.repository import NM

    networkmanager_imported = 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 NM from GLib. Detection of SSID will be disabled."
    )
    networkmanager_imported = False

from .config import AppConfiguration
from .server import Server
from .server.api_object import APIObject
from .server.api_objects import (
    AlbumID3,
    AlbumWithSongsID3,
    Artist,
    ArtistID3,
    ArtistInfo2,
    ArtistWithAlbumsID3,
    Child,
    Directory,
    Genre,
    Playlist,
    PlaylistWithSongs,
)


class Singleton(type):
    """
    Metaclass for :class:`CacheManager` so that it can be used like a
    singleton.
    """

    def __getattr__(cls, name: str) -> Any:
        if not CacheManager._instance:
            return None
        # If the cache has a function to do the thing we want, use it. If
        # not, then go directly to the server (this is useful for things
        # that just send data  to the server.)
        if hasattr(CacheManager._instance, name):
            return getattr(CacheManager._instance, name)
        else:
            return getattr(CacheManager._instance.server, name)

        return None


class SongCacheStatus(Enum):
    NOT_CACHED = 0
    CACHED = 1
    PERMANENTLY_CACHED = 2
    DOWNLOADING = 3


@lru_cache(maxsize=8192)
def similarity_ratio(query: str, string: str) -> int:
    """
    Return the :class:`fuzzywuzzy.fuzz.partial_ratio` between the ``query`` and
    the given ``string``.

    This ends up being called quite a lot, so the result is cached in an LRU
    cache using :class:`functools.lru_cache`.

    :param query: the query string
    :param string: the string to compare to the query string
    """
    return fuzz.partial_ratio(query.lower(), string.lower())


S = TypeVar("S")


class SearchResult:
    """
    An object representing the aggregate results of a search which can include
    both server and local results.
    """

    _artist: Set[ArtistID3] = set()
    _album: Set[AlbumID3] = set()
    _song: Set[Child] = set()
    _playlist: Set[Playlist] = set()

    def __init__(self, query: str):
        self.query = query

    def add_results(self, result_type: str, results: Iterable):
        """Adds the ``results`` to the ``_result_type`` set."""
        if results is None:
            return

        member = f"_{result_type}"
        if getattr(self, member) is None:
            setattr(self, member, set())

        setattr(
            self, member, getattr(getattr(self, member, set()), "union")(set(results)),
        )

    def _to_result(self, it: Iterable[S], transform: Callable[[S], str],) -> List[S]:
        all_results = sorted(
            ((similarity_ratio(self.query, transform(x)), x) for x in it),
            key=lambda rx: rx[0],
            reverse=True,
        )
        result: List[S] = []
        for ratio, x in all_results:
            if ratio > 60 and len(result) < 20:
                result.append(x)
            else:
                # No use going on, all the rest are less.
                break
        return result

    @property
    def artist(self) -> Optional[List[ArtistID3]]:
        if self._artist is None:
            return None
        return self._to_result(self._artist, lambda a: a.name)

    @property
    def album(self) -> Optional[List[AlbumID3]]:
        if self._album is None:
            return None

        return self._to_result(self._album, lambda a: f"{a.name} - {a.artist}")

    @property
    def song(self) -> Optional[List[Child]]:
        if self._song is None:
            return None
        return self._to_result(self._song, lambda s: f"{s.title} - {s.artist}")

    @property
    def playlist(self) -> Optional[List[Playlist]]:
        if self._playlist is None:
            return None
        return self._to_result(self._playlist, lambda p: p.name)


T = TypeVar("T")


class CacheManager(metaclass=Singleton):
    """
    Handles everything related to caching metadata and song files.
    """

    executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=50)
    should_exit: bool = False

    class Result(Generic[T]):
        # This needs to accept some way of:
        # 1. getting data from the server to fulfill the request
        # 2. coercing the data to the schema of the cachedb
        # 3. queries for retriving the data from the cachedb
        # All results should be retrieved using select statements from the DB
        """
        A result from a CacheManager function. This is effectively a wrapper
        around a Future, but it can also resolve immediately if the data
        already exists.
        """
        data: Optional[T] = None
        future: Optional[Future] = None
        on_cancel: Optional[Callable[[], None]] = None

        @staticmethod
        def from_data(data: T) -> "CacheManager.Result[T]":
            result: "CacheManager.Result[T]" = CacheManager.Result()
            result.data = data
            return result

        @staticmethod
        def from_server(
            download_fn: Callable[[], T],
            before_download: Callable[[], Any] = None,
            after_download: Callable[[T], Any] = None,
            on_cancel: Callable[[], Any] = None,
        ) -> "CacheManager.Result[T]":
            result: "CacheManager.Result[T]" = CacheManager.Result()

            def future_fn() -> T:
                if before_download:
                    before_download()
                return download_fn()

            result.future = CacheManager.executor.submit(future_fn)
            result.on_cancel = on_cancel

            if after_download is not None:
                result.future.add_done_callback(
                    lambda f: after_download and after_download(f.result())
                )

            return result

        def result(self) -> T:
            if self.data is not None:
                return self.data
            if self.future is not None:
                return self.future.result()

            raise Exception(
                "CacheManager.Result did not have either a data or future " "member."
            )

        def add_done_callback(self, fn: Callable, *args):
            if self.future is not None:
                self.future.add_done_callback(fn, *args)
            else:
                # Run the function immediately if it's not a future.
                fn(self, *args)

        def cancel(self) -> bool:
            if self.on_cancel is not None:
                self.on_cancel()

            if self.future is not None:
                return self.future.cancel()
            return True

        @property
        def is_future(self) -> bool:
            return self.future is not None

    @staticmethod
    def ready() -> bool:
        return CacheManager._instance is not None

    @staticmethod
    def shutdown():
        logging.info("CacheManager shutdown start")
        CacheManager.should_exit = True
        CacheManager.executor.shutdown()
        CacheManager._instance.save_cache_info()
        logging.info("CacheManager shutdown complete")

    class CacheEncoder(json.JSONEncoder):
        def default(self, obj: Any) -> Optional[Union[int, List, Dict]]:
            """
            Encodes Python objects to JSON.

            - ``datetime`` objects are converted to UNIX timestamps (``int``)
            - ``set`` objects are converted to ``list`` objects
            - ``APIObject`` objects are recursively encoded
            - ``EnumMeta`` objects are ignored
            - everything else is encoded using the default encoder
            """
            if type(obj) == datetime:
                return int(obj.timestamp() * 1000)
            elif type(obj) == set:
                return list(obj)
            elif isinstance(obj, APIObject):
                return {k: v for k, v in obj.__dict__.items() if v is not None}
            elif isinstance(obj, EnumMeta):
                return None

            return json.JSONEncoder.default(self, obj)

    class __CacheManagerInternal:
        # Thread lock for preventing threads from overriding the state while
        # it's being saved.
        cache_lock = threading.Lock()

        cache: DefaultDict[str, Any] = defaultdict(dict)
        permanently_cached_paths: Set[str] = set()

        # The server instance.
        server: Server

        # TODO (#56): need to split out the song downloads and make them higher
        # priority I think. Maybe even need to just make this a priority queue.
        download_set_lock = threading.Lock()
        current_downloads: Set[str] = set()

        def __init__(self, app_config: AppConfiguration):
            self.app_config = app_config
            assert self.app_config.server is not None
            self.app_config.server

            # If connected to the "Local Network SSID", use the "Local Network
            # Address" instead of the "Server Address".
            hostname = self.app_config.server.server_address
            if self.app_config.server.local_network_ssid in self.current_ssids:
                hostname = self.app_config.server.local_network_address

            self.server = Server(
                name=self.app_config.server.name,
                hostname=hostname,
                username=self.app_config.server.username,
                password=self.app_config.server.password,
                disable_cert_verify=self.app_config.server.disable_cert_verify,
            )
            self.download_limiter_semaphore = threading.Semaphore(
                self.app_config.concurrent_download_limit
            )

            self.load_cache_info()

        @property
        def current_ssids(self) -> Set[str]:
            if not networkmanager_imported:
                return set()

            self.networkmanager_client = NM.Client.new()
            self.nmclient_initialized = False
            self._current_ssids: Set[str] = set()
            if not self.nmclient_initialized:
                # Only look at the active WiFi connections.
                for ac in self.networkmanager_client.get_active_connections():
                    if ac.get_connection_type() != "802-11-wireless":
                        continue
                    devs = ac.get_devices()
                    if len(devs) != 1:
                        continue
                    if devs[0].get_device_type() != NM.DeviceType.WIFI:
                        continue

                    self._current_ssids.add(ac.get_id())

            return self._current_ssids

        def load_cache_info(self):
            cache_meta_file = self.calculate_abs_path(".cache_meta")

            meta_json = {}
            if cache_meta_file.exists():
                with open(cache_meta_file, "r") as f:
                    try:
                        meta_json = json.load(f)
                    except json.decoder.JSONDecodeError:
                        # Just continue with the default meta_json.
                        logging.warning("Unable to load cache", stack_info=True)

            cache_version = meta_json.get("version", 0)

            if cache_version < 1:
                logging.info("Migrating cache to version 1.")
                cover_art_re = re.compile(r"(\d+)_(\d+)")
                abs_path = self.calculate_abs_path("cover_art/")
                abs_path.mkdir(parents=True, exist_ok=True)
                for cover_art_file in abs_path.iterdir():
                    match = cover_art_re.match(cover_art_file.name)
                    if match:
                        art_id, dimensions = map(int, match.groups())
                        if dimensions == 1000:
                            no_dimens = cover_art_file.parent.joinpath("{art_id}")
                            logging.debug(f"Moving {cover_art_file} to {no_dimens}")
                            shutil.move(cover_art_file, no_dimens)
                        else:
                            logging.debug(f"Deleting {cover_art_file}")
                            cover_art_file.unlink()

            self.cache["version"] = 1

            cache_configs = [
                # Playlists
                ("playlists", Playlist, list),
                ("playlist_details", PlaylistWithSongs, dict),
                ("genres", Genre, list),
                ("song_details", Child, dict),
                # Non-ID3 caches
                ("music_directories", Directory, dict),
                ("indexes", Artist, list),
                # ID3 caches
                ("albums", AlbumWithSongsID3, "dict-list"),
                ("album_details", AlbumWithSongsID3, dict),
                ("artists", ArtistID3, list),
                ("artist_details", ArtistWithAlbumsID3, dict),
                ("artist_infos", ArtistInfo2, dict),
            ]
            for name, type_name, default in cache_configs:
                if default == list:
                    self.cache[name] = [
                        type_name.from_json(x) for x in meta_json.get(name) or []
                    ]
                elif default == dict:
                    self.cache[name] = {
                        id: type_name.from_json(x)
                        for id, x in (meta_json.get(name) or {}).items()
                    }
                elif default == "dict-list":
                    self.cache[name] = {
                        n: [type_name.from_json(x) for x in xs]
                        for n, xs in (meta_json.get(name) or {}).items()
                    }

        def save_cache_info(self):
            os.makedirs(self.app_config.cache_location, exist_ok=True)

            cache_meta_file = self.calculate_abs_path(".cache_meta")
            os.makedirs(os.path.dirname(cache_meta_file), exist_ok=True)
            with open(cache_meta_file, "w+") as f, self.cache_lock:
                f.write(json.dumps(self.cache, indent=2, cls=CacheManager.CacheEncoder))

        def save_file(self, absolute_path: Path, data: bytes):
            # Make the necessary directories and write to file.
            os.makedirs(absolute_path.parent, exist_ok=True)
            with open(absolute_path, "wb+") as f:
                f.write(data)

        def calculate_abs_path(self, *relative_paths) -> Path:
            assert self.app_config.server is not None
            return Path(self.app_config.cache_location).joinpath(
                self.app_config.server.strhash(), *relative_paths
            )

        def calculate_download_path(self, *relative_paths) -> Path:
            """
            Determine where to temporarily put the file as it is downloading.
            """
            assert self.app_config.server is not None
            xdg_cache_home = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser(
                "~/.cache"
            )
            return Path(xdg_cache_home).joinpath(
                "sublime-music", self.app_config.server.strhash(), *relative_paths,
            )

        def return_cached_or_download(
            self,
            relative_path: Union[Path, str],
            download_fn: Callable[[], bytes],
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
            allow_download: bool = True,
        ) -> "CacheManager.Result[str]":
            abs_path = self.calculate_abs_path(relative_path)
            abs_path_str = str(abs_path)
            download_path = self.calculate_download_path(relative_path)

            if abs_path.exists() and not force:
                return CacheManager.Result.from_data(abs_path_str)

            if not allow_download:
                return CacheManager.Result.from_data("")

            def do_download() -> str:
                resource_downloading = False
                with self.download_set_lock:
                    if abs_path_str in self.current_downloads:
                        resource_downloading = True

                    self.current_downloads.add(abs_path_str)

                if resource_downloading:
                    logging.info(f"{abs_path} already being downloaded.")
                    # The resource is already being downloaded. Busy loop until
                    # it has completed. Then, just return the path to the
                    # resource.
                    while abs_path_str in self.current_downloads:
                        sleep(0.2)
                else:
                    logging.info(f"{abs_path} not found. Downloading...")

                    os.makedirs(download_path.parent, exist_ok=True)
                    try:
                        self.save_file(download_path, download_fn())
                    except requests.exceptions.ConnectionError:
                        with self.download_set_lock:
                            self.current_downloads.discard(abs_path_str)

                    # Move the file to its cache download location.
                    os.makedirs(abs_path.parent, exist_ok=True)
                    if download_path.exists():
                        shutil.move(str(download_path), abs_path)

                logging.info(f"{abs_path} downloaded. Returning.")
                return abs_path_str

            def after_download(path: str):
                with self.download_set_lock:
                    self.current_downloads.discard(path)

            return CacheManager.Result.from_server(
                do_download,
                before_download=before_download,
                after_download=after_download,
            )

        @staticmethod
        def create_future(fn: Callable, *args) -> Future:
            """Creates a future on the CacheManager's executor."""
            return CacheManager.executor.submit(fn, *args)

        def delete_cached_cover_art(self, id: int):
            relative_path = f"cover_art/*{id}*"

            abs_path = self.calculate_abs_path(relative_path)

            for path in glob.glob(str(abs_path)):
                Path(path).unlink()

        def get_playlists(
            self,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[List[Playlist]]":
            if self.cache.get("playlists") and not force:
                return CacheManager.Result.from_data(self.cache["playlists"] or [])

            def after_download(playlists: List[Playlist]):
                with self.cache_lock:
                    self.cache["playlists"] = playlists
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_playlists().playlist or [],
                before_download=before_download,
                after_download=after_download,
            )

        def invalidate_playlists_cache(self):
            if not self.cache.get("playlists"):
                return

            with self.cache_lock:
                self.cache["playlists"] = []
            self.save_cache_info()

        def get_playlist(
            self,
            playlist_id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[PlaylistWithSongs]":
            playlist_details = self.cache.get("playlist_details", {})
            if playlist_id in playlist_details and not force:
                return CacheManager.Result.from_data(playlist_details[playlist_id])

            def after_download(playlist: PlaylistWithSongs):
                with self.cache_lock:
                    self.cache["playlist_details"][playlist_id] = playlist

                    # Playlists have the song details, so save those too.
                    for song in playlist.entry or []:
                        self.cache["song_details"][song.id] = song

                self.save_cache_info()

            # Invalidate the cached photo if we are forcing a retrieval
            # from the server.
            if force:
                self.delete_cached_cover_art(playlist_id)

            return CacheManager.Result.from_server(
                lambda: self.server.get_playlist(playlist_id),
                before_download=before_download,
                after_download=after_download,
            )

        def create_playlist(self, name: str) -> Future:
            def do_create_playlist():
                self.server.create_playlist(name=name)

            return CacheManager.create_future(do_create_playlist)

        def update_playlist(self, playlist_id: int, *args, **kwargs) -> Future:
            def do_update_playlist():
                self.server.update_playlist(playlist_id, *args, **kwargs)
                with self.cache_lock:
                    del self.cache["playlist_details"][playlist_id]

            return CacheManager.create_future(do_update_playlist)

        def get_artists(
            self,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[List[ArtistID3]]":
            cache_name = "artists"

            if self.cache.get(cache_name) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name])

            def download_fn() -> List[ArtistID3]:
                artists: List[ArtistID3] = []
                for index in self.server.get_artists().index:
                    artists.extend(index.artist)
                return artists

            def after_download(artists: List[ArtistID3]):
                with self.cache_lock:
                    self.cache[cache_name] = artists
                self.save_cache_info()

            return CacheManager.Result.from_server(
                download_fn,
                before_download=before_download,
                after_download=after_download,
            )

        def get_artist(
            self,
            artist_id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[ArtistWithAlbumsID3]":
            cache_name = "artist_details"

            if artist_id in self.cache.get(cache_name, {}) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][artist_id])

            def after_download(artist: ArtistWithAlbumsID3):
                with self.cache_lock:
                    self.cache[cache_name][artist_id] = artist
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_artist(artist_id),
                before_download=before_download,
                after_download=after_download,
            )

        def get_indexes(
            self,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[List[Artist]]":
            cache_name = "indexes"

            if self.cache.get(cache_name) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name])

            def download_fn() -> List[Artist]:
                artists: List[Artist] = []
                for index in self.server.get_indexes().index:
                    artists.extend(index.artist)
                return artists

            def after_download(artists: List[Artist]):
                with self.cache_lock:
                    self.cache[cache_name] = artists
                self.save_cache_info()

            return CacheManager.Result.from_server(
                download_fn,
                before_download=before_download,
                after_download=after_download,
            )

        def get_music_directory(
            self,
            id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[Directory]":
            cache_name = "music_directories"

            if id in self.cache.get(cache_name, {}) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][id])

            def after_download(directory: Directory):
                with self.cache_lock:
                    self.cache[cache_name][id] = directory
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_music_directory(id),
                before_download=before_download,
                after_download=after_download,
            )

        def get_artist_info(
            self,
            artist_id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[ArtistInfo2]":
            cache_name = "artist_infos"

            if artist_id in self.cache.get(cache_name, {}) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][artist_id])

            def after_download(artist_info: ArtistInfo2):
                if not artist_info:
                    return

                with self.cache_lock:
                    self.cache[cache_name][artist_id] = artist_info
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: (self.server.get_artist_info2(id=artist_id) or ArtistInfo2()),
                before_download=before_download,
                after_download=after_download,
            )

        def get_artist_artwork(
            self,
            artist: Union[Artist, ArtistID3],
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[str]":
            def do_get_artist_artwork(
                artist_info: ArtistInfo2,
            ) -> "CacheManager.Result[str]":
                lastfm_url = "".join(artist_info.largeImageUrl or [])

                is_placeholder = lastfm_url == ""
                is_placeholder |= lastfm_url.endswith(
                    "2a96cbd8b46e442fc41c2b86b821562f.png"
                )
                is_placeholder |= lastfm_url.endswith(
                    "1024px-No_image_available.svg.png"
                )

                # If it is the placeholder LastFM image, try and use the cover
                # art filename given by the server.
                if is_placeholder:
                    if isinstance(artist, (ArtistWithAlbumsID3, ArtistID3)):
                        if artist.coverArt:
                            return CacheManager.get_cover_art_filename(artist.coverArt)
                        elif (
                            isinstance(artist, ArtistWithAlbumsID3)
                            and artist.album
                            and len(artist.album) > 0
                        ):
                            return CacheManager.get_cover_art_filename(
                                artist.album[0].coverArt
                            )

                    elif isinstance(artist, Directory) and len(artist.child) > 0:
                        # Retrieve the first album's cover art
                        return CacheManager.get_cover_art_filename(
                            artist.child[0].coverArt
                        )

                if lastfm_url == "":
                    return CacheManager.Result.from_data("")

                url_hash = hashlib.md5(lastfm_url.encode("utf-8")).hexdigest()
                return self.return_cached_or_download(
                    f"cover_art/artist.{url_hash}",
                    lambda: requests.get(lastfm_url).content,
                    before_download=before_download,
                    force=force,
                )

            def download_fn(artist_info: CacheManager.Result[ArtistInfo2]) -> str:
                # In this case, artist_info is a future, so we have to wait for
                # its result before calculating. Then, immediately unwrap the
                # result() because we are already within a future.
                return do_get_artist_artwork(artist_info.result()).result()

            artist_info = CacheManager.get_artist_info(artist.id)
            if artist_info.is_future:
                return CacheManager.Result.from_server(
                    lambda: download_fn(artist_info), before_download=before_download,
                )
            else:
                return do_get_artist_artwork(artist_info.result())

        def get_album_list(
            self,
            type_: str,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
            # Look at documentation for get_album_list in server.py:
            **params,
        ) -> "CacheManager.Result[List[AlbumID3]]":
            cache_name = "albums"

            if len(self.cache.get(cache_name, {}).get(type_, [])) > 0 and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][type_])

            def do_get_album_list() -> List[AlbumID3]:
                def get_page(offset: int, page_size: int = 500,) -> List[AlbumID3]:
                    return (
                        self.server.get_album_list2(
                            type_, size=page_size, offset=offset, **params,
                        ).album
                        or []
                    )

                page_size = 40 if type_ == "random" else 500
                offset = 0

                next_page = get_page(offset, page_size=page_size)
                albums = next_page

                # If it returns 500 things, then there's more leftover.
                while len(next_page) == 500:
                    next_page = get_page(offset)
                    albums.extend(next_page)
                    offset += 500

                return albums

            def after_download(albums: List[AlbumID3]):
                with self.cache_lock:
                    if not self.cache[cache_name].get(type_):
                        self.cache[cache_name][type_] = []
                    self.cache[cache_name][type_] = albums
                self.save_cache_info()

            return CacheManager.Result.from_server(
                do_get_album_list,
                before_download=before_download,
                after_download=after_download,
            )

        def get_album(
            self,
            album_id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[AlbumWithSongsID3]":
            cache_name = "album_details"

            if album_id in self.cache.get(cache_name, {}) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][album_id])

            def after_download(album: AlbumWithSongsID3):
                with self.cache_lock:
                    self.cache[cache_name][album_id] = album

                    # Albums have the song details as well, so save those too.
                    for song in album.get("song", []):
                        self.cache["song_details"][song.id] = song
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_album(album_id),
                before_download=before_download,
                after_download=after_download,
            )

        def batch_delete_cached_songs(
            self, song_ids: List[int], on_song_delete: Callable[[], None],
        ) -> Future:
            def do_delete_cached_songs():
                # Do the actual download.
                for f in map(CacheManager.get_song_details, song_ids):
                    song: Child = f.result()
                    relative_path = song.path
                    abs_path = self.calculate_abs_path(relative_path)
                    if abs_path.exists():
                        abs_path.unlink()
                    on_song_delete()

            return CacheManager.create_future(do_delete_cached_songs)

        def batch_download_songs(
            self,
            song_ids: List[int],
            before_download: Callable[[], None],
            on_song_download_complete: Callable[[], None],
        ) -> Future:
            def do_download_song(song_id: int):
                try:
                    # If a song download is already in the queue and then the
                    # app is exited, this prevents the download.
                    if CacheManager.should_exit:
                        return

                    # Do the actual download. Call .result() because we are
                    # already inside of a future.
                    song = CacheManager.get_song_details(song_id).result()
                    self.return_cached_or_download(
                        song.path,
                        lambda: self.server.download(song.id),
                        before_download=before_download,
                    ).result()
                    on_song_download_complete()
                finally:
                    # Release the semaphore lock. This will allow the next song
                    # in the queue to be downloaded. I'm doing this in the
                    # finally block so that it always runs, regardless of
                    # whether an exception is thrown or the function returns.
                    self.download_limiter_semaphore.release()

            def do_batch_download_songs():
                for song_id in song_ids:
                    # Only allow a certain number of songs ot be downloaded
                    # simultaneously.
                    self.download_limiter_semaphore.acquire()

                    # Prevents further songs from being downloaded.
                    if CacheManager.should_exit:
                        break
                    CacheManager.create_future(do_download_song, song_id)

            return CacheManager.create_future(do_batch_download_songs)

        def get_cover_art_filename(
            self,
            id: str,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
            allow_download: bool = True,
        ) -> "CacheManager.Result[str]":
            if id is None:
                default_art_path = "ui/images/default-album-art.png"
                return CacheManager.Result.from_data(
                    str(Path(__file__).parent.joinpath(default_art_path))
                )
            return self.return_cached_or_download(
                f"cover_art/{id}",
                lambda: self.server.get_cover_art(id),
                before_download=before_download,
                force=force,
                allow_download=allow_download,
            )

        def get_song_details(
            self,
            song_id: int,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[Child]":
            cache_name = "song_details"
            if self.cache[cache_name].get(song_id) and not force:
                return CacheManager.Result.from_data(self.cache[cache_name][song_id])

            def after_download(song_details: Child):
                with self.cache_lock:
                    self.cache[cache_name][song_id] = song_details
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_song(song_id),
                before_download=before_download,
                after_download=after_download,
            )

        def get_play_queue(self) -> Future:
            return CacheManager.create_future(self.server.get_play_queue)

        def save_play_queue(
            self, play_queue: List[str], current: str, position: float,
        ):
            CacheManager.create_future(
                self.server.save_play_queue, play_queue, current, position
            )

        def scrobble(self, song_id: int) -> Future:
            def do_scrobble():
                self.server.scrobble(song_id)

            return CacheManager.create_future(do_scrobble)

        def get_song_filename_or_stream(
            self, song: Child, format: str = None, force_stream: bool = False,
        ) -> Tuple[str, bool]:
            abs_path = self.calculate_abs_path(song.path)
            if abs_path.exists() and not force_stream:
                return (str(abs_path), False)
            return (self.server.get_stream_url(song.id, format=format), True)

        def get_genres(
            self,
            before_download: Callable[[], None] = lambda: None,
            force: bool = False,
        ) -> "CacheManager.Result[List[Genre]]":
            if self.cache.get("genres") and not force:
                return CacheManager.Result.from_data(self.cache["genres"])

            def after_download(genres: List[Genre]):
                with self.cache_lock:
                    self.cache["genres"] = genres
                self.save_cache_info()

            return CacheManager.Result.from_server(
                lambda: self.server.get_genres().genre,
                before_download=before_download,
                after_download=after_download,
            )

        def search(
            self,
            query: str,
            search_callback: Callable[[SearchResult, bool], None],
            before_download: Callable[[], None] = lambda: None,
        ) -> "CacheManager.Result":
            if query == "":
                search_callback(SearchResult(""), True)
                return CacheManager.Result.from_data(None)

            before_download()

            # Keep track of if the result is cancelled and if it is, then don't
            # do anything with any results.
            cancelled = False

            # This future actually does the search and calls the
            # search_callback when each of the futures completes.
            def do_search():
                # Sleep for a little while before returning the local results.
                # They are less expensive to retrieve (but they still incur
                # some overhead due to the GTK UI main loop queue).
                sleep(0.2)
                if cancelled:
                    return

                # Local Results
                search_result = SearchResult(query)
                search_result.add_results(
                    "album", itertools.chain(*self.cache["albums"].values())
                )
                search_result.add_results("artist", self.cache["artists"])
                search_result.add_results("song", self.cache["song_details"].values())
                search_result.add_results("playlist", self.cache["playlists"])
                search_callback(search_result, False)

                # Wait longer to see if the user types anything else so we
                # don't peg the server with tons of requests.
                sleep(0.2)
                if cancelled:
                    return

                # Server Results
                search_fn = self.server.search3
                try:
                    # Attempt to add the server search results to the
                    # SearchResult. If it fails, that's fine, we will use the
                    # finally to always return a final SearchResult to the UI.
                    server_result = search_fn(query)
                    search_result.add_results("album", server_result.album)
                    search_result.add_results("artist", server_result.artist)
                    search_result.add_results("song", server_result.song)
                except Exception:
                    # We really don't care about what the exception was (could
                    # be connection error, could be invalid JSON, etc.) because
                    # we will always have returned local results.
                    return
                finally:
                    search_callback(search_result, True)

            # When the future is cancelled (this will happen if a new search is
            # created).
            def on_cancel():
                nonlocal cancelled
                cancelled = True

            return CacheManager.Result.from_server(do_search, on_cancel=on_cancel)

        def get_cached_status(self, song: Child) -> SongCacheStatus:
            cache_path = self.calculate_abs_path(song.path)
            if cache_path.exists():
                if cache_path in self.permanently_cached_paths:
                    return SongCacheStatus.PERMANENTLY_CACHED
                else:
                    return SongCacheStatus.CACHED
            elif str(cache_path) in self.current_downloads:
                return SongCacheStatus.DOWNLOADING
            else:
                return SongCacheStatus.NOT_CACHED

    _instance: Optional[__CacheManagerInternal] = None

    def __init__(self):
        raise Exception("Do not instantiate the CacheManager.")

    @staticmethod
    def reset(app_config: AppConfiguration):
        CacheManager._instance = CacheManager.__CacheManagerInternal(app_config)
        similarity_ratio.cache_clear()
