# coding: utf-8

import json
import shlex
import shutil

from base64 import b64decode
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import timezone
from hashlib import sha1
from locale import getdefaultlocale
from multiprocessing import Queue
from platform import system
from requests import session
from requests.exceptions import HTTPError, ConnectionError
from sys import platform as sys_platform
from uuid import uuid4
from urllib.parse import urlencode, parse_qsl, urlparse

from legendary import __version__
from legendary.api.egs import EPCAPI
from legendary.api.lgd import LGDAPI
from legendary.downloader.mp.manager import DLManager
from legendary.lfs.egl import EPCLFS
from legendary.lfs.lgndry import LGDLFS
from legendary.lfs.utils import clean_filename, delete_folder, delete_filelist, get_dir_size
from legendary.models.downloading import AnalysisResult, ConditionCheckResult
from legendary.models.egl import EGLManifest
from legendary.models.exceptions import *
from legendary.models.game import *
from legendary.models.json_manifest import JSONManifest
from legendary.models.manifest import Manifest, ManifestMeta
from legendary.models.chunk import Chunk
from legendary.lfs.crossover import *
from legendary.utils.egl_crypt import decrypt_epic_data
from legendary.utils.env import is_windows_mac_or_pyi
from legendary.lfs.eos import EOSOverlayApp, query_registry_entries
from legendary.utils.game_workarounds import is_opt_enabled, update_workarounds, get_exe_override
from legendary.utils.savegame_helper import SaveGameHelper
from legendary.utils.selective_dl import games as sdl_games
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_path_search


# ToDo: instead of true/false return values for success/failure actually raise an exception that the CLI/GUI
#  can handle to give the user more details. (Not required yet since there's no GUI so log output is fine)


class LegendaryCore:
    """
    LegendaryCore handles most of the lower level interaction with
    the downloader, lfs, and api components to make writing CLI/GUI
    code easier and cleaner and avoid duplication.
    """
    _egl_version = '11.0.1-14907503+++Portal+Release-Live'

    def __init__(self, override_config=None, timeout=10.0):
        self.log = logging.getLogger('Core')
        self.egs = EPCAPI(timeout=timeout)
        self.lgd = LGDLFS(config_file=override_config)
        self.egl = EPCLFS()
        self.lgdapi = LGDAPI()

        # on non-Windows load the programdata path from config
        if os.name != 'nt':
            self.egl.programdata_path = self.lgd.config.get('Legendary', 'egl_programdata', fallback=None)
            if self.egl.programdata_path and not os.path.exists(self.egl.programdata_path):
                self.log.error(f'Config EGL path ("{self.egl.programdata_path}") is invalid! Disabling sync...')
                self.egl.programdata_path = None
                self.lgd.config.remove_option('Legendary', 'egl_programdata')
                self.lgd.config.remove_option('Legendary', 'egl_sync')
                self.lgd.save_config()

        self.local_timezone = datetime.now().astimezone().tzinfo
        self.language_code, self.country_code = ('en', 'US')

        if locale := self.lgd.config.get('Legendary', 'locale',
                                         fallback=getdefaultlocale(('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'))[0]):
            try:
                self.language_code, self.country_code = locale.split('-' if '-' in locale else '_')
                self.log.debug(f'Set locale to {self.language_code}-{self.country_code}')
                # adjust egs api language as well
                self.egs.language_code, self.egs.country_code = self.language_code, self.country_code
            except Exception as e:
                self.log.warning(f'Getting locale failed: {e!r}, falling back to using en-US.')
        elif system() != 'Darwin':  # macOS doesn't have a default locale we can query
            self.log.warning('Could not determine locale, falling back to en-US')

        self.update_available = False
        self.force_show_update = False
        self.webview_killswitch = False
        self.overlay_update_available = False
        self.logged_in = False

    def auth_sid(self, sid) -> str:
        """
        Handles getting an exchange code from a session id
        :param sid: session id
        :return: exchange code
        """
        s = session()
        s.headers.update({
            'X-Epic-Event-Action': 'login',
            'X-Epic-Event-Category': 'login',
            'X-Epic-Strategy-Flags': '',
            'X-Requested-With': 'XMLHttpRequest',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          f'EpicGamesLauncher/{self._egl_version} '
                          'UnrealEngine/4.23.0-14907503+++Portal+Release-Live '
                          'Chrome/84.0.4147.38 Safari/537.36'
        })
        s.cookies['EPIC_COUNTRY'] = self.country_code.upper()

        # get first set of cookies (EPIC_BEARER_TOKEN etc.)
        _ = s.get('https://www.epicgames.com/id/api/set-sid', params=dict(sid=sid))
        # get XSRF-TOKEN and EPIC_SESSION_AP cookie
        _ = s.get('https://www.epicgames.com/id/api/csrf')
        # finally, get the exchange code
        r = s.post('https://www.epicgames.com/id/api/exchange/generate',
                   headers={'X-XSRF-TOKEN': s.cookies['XSRF-TOKEN']})

        if r.status_code == 200:
            return r.json()['code']

        self.log.error(f'Getting exchange code failed: {r.json()}')
        return ''

    def auth_code(self, code) -> bool:
        """
        Handles authentication via authorization code (either retrieved manually or automatically)
        """
        try:
            with self.lgd.userdata_lock as lock:
                lock.data = self.egs.start_session(authorization_code=code)
            return True
        except Exception as e:
            self.log.error(f'Logging in failed with {e!r}, please try again.')
            return False

    def auth_ex_token(self, code) -> bool:
        """
        Handles authentication via exchange token (either retrieved manually or automatically)
        """
        try:
            with self.lgd.userdata_lock as lock:
                lock.data = self.egs.start_session(exchange_token=code)
            return True
        except Exception as e:
            self.log.error(f'Logging in failed with {e!r}, please try again.')
            return False

    def auth_import(self) -> bool:
        """Import refresh token from EGL installation and use it for logging in"""
        self.egl.read_config()
        remember_me_data = self.egl.config.get('RememberMe', 'Data')
        raw_data = b64decode(remember_me_data)
        # data is encrypted
        if raw_data[0] != '{':
            for data_key in self.egl.data_keys:
                try:
                    decrypted_data = decrypt_epic_data(data_key, raw_data)
                    re_data = json.loads(decrypted_data)[0]
                    break
                except Exception as e:
                    self.log.debug(f'Decryption with key {data_key} failed with {e!r}')
            else:
                raise ValueError('Decryption of EPIC launcher user information failed.')
        else:
            re_data = json.loads(raw_data)[0]

        if 'Token' not in re_data:
            raise ValueError('No login session in config')
        refresh_token = re_data['Token']
        try:
            with self.lgd.userdata_lock as lock:
                lock.data = self.egs.start_session(refresh_token=refresh_token)
            return True
        except Exception as e:
            self.log.error(f'Logging in failed with {e!r}, please try again.')
            return False

    def _login(self, lock, force_refresh=False) -> bool:
        """
        Attempts logging in with existing credentials.

        raises ValueError if no existing credentials or InvalidCredentialsError if the API return an error
        """
        if not lock.data:
            raise ValueError('No saved credentials')
        elif self.logged_in and lock.data['expires_at']:
            dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
            dt_now = datetime.utcnow()
            td = dt_now - dt_exp

            # if session still has at least 10 minutes left we can re-use it.
            if dt_exp > dt_now and abs(td.total_seconds()) > 600:
                return True
            else:
                self.logged_in = False

        # run update check
        if self.update_check_enabled():
            try:
                self.check_for_updates()
            except Exception as e:
                self.log.warning(f'Checking for Legendary updates failed: {e!r}')
        else:
            self.apply_lgd_config()

        # check for overlay updates
        if self.is_overlay_installed():
            try:
                self.check_for_overlay_updates()
            except Exception as e:
                self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}')

        if lock.data['expires_at'] and not force_refresh:
            dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
            dt_now = datetime.utcnow()
            td = dt_now - dt_exp

            # if session still has at least 10 minutes left we can re-use it.
            if dt_exp > dt_now and abs(td.total_seconds()) > 600:
                self.log.info('Trying to re-use existing login session...')
                try:
                    self.egs.resume_session(lock.data)
                    self.logged_in = True
                    return True
                except InvalidCredentialsError as e:
                    self.log.warning(f'Resuming failed due to invalid credentials: {e!r}')
                except Exception as e:
                    self.log.warning(f'Resuming failed for unknown reason: {e!r}')
                # If verify fails just continue the normal authentication process
                self.log.info('Falling back to using refresh token...')

        try:
            self.log.info('Logging in...')
            userdata = self.egs.start_session(lock.data['refresh_token'])
        except InvalidCredentialsError:
            self.log.error('Stored credentials are no longer valid! Please login again.')
            lock.clear()
            return False
        except (HTTPError, ConnectionError) as e:
            self.log.error(f'HTTP request for login failed: {e!r}, please try again later.')
            return False

        lock.data = userdata
        self.logged_in = True
        return True

    def login(self, force_refresh=False) -> bool:
        with self.lgd.userdata_lock as lock:
            return self._login(lock, force_refresh=force_refresh)

    def update_check_enabled(self):
        return not self.lgd.config.getboolean('Legendary', 'disable_update_check', fallback=False)

    def update_notice_enabled(self):
        if self.force_show_update:
            return True
        return not self.lgd.config.getboolean('Legendary', 'disable_update_notice',
                                              fallback=not is_windows_mac_or_pyi())

    def check_for_updates(self, force=False):
        def version_tuple(v):
            return tuple(map(int, (v.split('.'))))

        cached = self.lgd.get_cached_version()
        version_info = cached['data']
        if force or not version_info or (datetime.now().timestamp() - cached['last_update']) > 24*3600:
            version_info = self.lgdapi.get_version_information()
            self.lgd.set_cached_version(version_info)

        web_version = version_info['release_info']['version']
        self.update_available = version_tuple(web_version) > version_tuple(__version__)
        self.apply_lgd_config(version_info)

    def apply_lgd_config(self, version_info=None):
        """Applies configuration options returned by update API"""
        if not version_info:
            version_info = self.lgd.get_cached_version()['data']
        # if cached data is invalid
        if not version_info:
            self.log.debug('No cached legendary config to apply.')
            return

        if 'egl_config' in version_info:
            self.egs.update_egs_params(version_info['egl_config'])
            self._egl_version = version_info['egl_config'].get('version', self._egl_version)
            for data_key in version_info['egl_config'].get('data_keys', []):
                if data_key not in self.egl.data_keys:
                    self.egl.data_keys.append(data_key)
        if game_overrides := version_info.get('game_overrides'):
            update_workarounds(game_overrides)
            if sdl_config := game_overrides.get('sdl_config'):
                # add placeholder for games to fetch from API that aren't hardcoded
                for app_name in sdl_config.keys():
                    if app_name not in sdl_games:
                        sdl_games[app_name] = None
        if lgd_config := version_info.get('legendary_config'):
            self.webview_killswitch = lgd_config.get('webview_killswitch', False)

    def get_egl_version(self):
        return self._egl_version

    def get_update_info(self):
        return self.lgd.get_cached_version()['data'].get('release_info')

    def get_game_tip(self, app_name):
        update_info = self.lgd.get_cached_version()['data']
        if not update_info:
            return None

        return update_info.get('game_wiki', {}).get(app_name, {}).get(sys_platform)

    def get_sdl_data(self, app_name, platform='Windows'):
        if platform not in ('Win32', 'Windows'):
            app_name = f'{app_name}_{platform}'

        if app_name not in sdl_games:
            return None
        # load hardcoded data as fallback
        sdl_data = sdl_games[app_name]
        # get cached data
        cached = self.lgd.get_cached_sdl_data(app_name)
        # check if newer version is available and/or download if necessary
        version_info = self.lgd.get_cached_version()['data']
        latest = version_info.get('game_overrides', {}).get('sdl_config', {}).get(app_name)
        if (not cached and latest) or (cached and latest and latest > cached['version']):
            try:
                sdl_data = self.lgdapi.get_sdl_config(app_name)
                self.log.debug(f'Downloaded SDL data for "{app_name}", version: {latest}')
                self.lgd.set_cached_sdl_data(app_name, latest, sdl_data)
            except Exception as e:
                self.log.warning(f'Downloading SDL data failed with {e!r}')
        elif cached:
            sdl_data = cached['data']
        # return data if available
        return sdl_data

    def update_aliases(self, force=False):
        _aliases_enabled = not self.lgd.config.getboolean('Legendary', 'disable_auto_aliasing', fallback=False)
        if _aliases_enabled and (force or not self.lgd.aliases):
            self.lgd.generate_aliases()

    def get_assets(self, update_assets=False, platform='Windows') -> List[GameAsset]:
        # do not save and always fetch list when platform is overridden
        if not self.lgd.assets or update_assets or platform not in self.lgd.assets:
            # if not logged in, return empty list
            if not self.egs.user:
                return []

            assets = self.lgd.assets.copy() if self.lgd.assets else dict()

            assets.update({
                platform: [
                    GameAsset.from_egs_json(a) for a in
                    self.egs.get_game_assets(platform=platform)
                ]
            })

            # only save (and write to disk) if there were changes
            if self.lgd.assets != assets:
                self.lgd.assets = assets

        return self.lgd.assets[platform]

    def get_asset(self, app_name, platform='Windows', update=False) -> GameAsset:
        if update or platform not in self.lgd.assets:
            self.get_assets(update_assets=True, platform=platform)

        try:
            return next(i for i in self.lgd.assets[platform] if i.app_name == app_name)
        except StopIteration:
            raise ValueError

    def asset_valid(self, app_name) -> bool:
        # EGL sync is only supported for Windows titles so this is fine
        return any(i.app_name == app_name for i in self.lgd.assets['Windows'])

    def asset_available(self, game: Game, platform='Windows') -> bool:
        # Just say yes for Origin titles
        if game.third_party_store:
            return True

        try:
            asset = self.get_asset(game.app_name, platform=platform)
            return asset is not None
        except ValueError:
            return False

    def get_game(self, app_name, update_meta=False, platform='Windows') -> Game:
        if update_meta:
            self.get_game_list(True, platform=platform)
        return self.lgd.get_game_meta(app_name)

    def get_game_list(self, update_assets=True, platform='Windows') -> List[Game]:
        return self.get_game_and_dlc_list(update_assets=update_assets, platform=platform)[0]

    def get_game_and_dlc_list(self, update_assets=True, platform='Windows',
                              force_refresh=False, skip_ue=True) -> (List[Game], Dict[str, List[Game]]):
        _ret = []
        _dlc = defaultdict(list)
        meta_updated = False

        # fetch asset information for Windows, all installed platforms, and the specified one
        platforms = {'Windows'}
        platforms |= {platform}
        platforms |= self.get_installed_platforms()

        for _platform in platforms:
            self.get_assets(update_assets=update_assets, platform=_platform)

        if not self.lgd.assets:
            return _ret, _dlc

        assets = {}
        for _platform, _assets in self.lgd.assets.items():
            for _asset in _assets:
                if _asset.app_name in assets:
                    assets[_asset.app_name][_platform] = _asset
                else:
                    assets[_asset.app_name] = {_platform: _asset}

        fetch_list = []
        games = {}

        for app_name, app_assets in sorted(assets.items()):
            if skip_ue and any(v.namespace == 'ue' for v in app_assets.values()):
                continue

            game = self.lgd.get_game_meta(app_name)
            asset_updated = False
            if game:
                asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets.keys())
                games[app_name] = game

            if update_assets and (not game or force_refresh or (game and asset_updated)):
                self.log.debug(f'Scheduling metadata update for {app_name}')
                # namespace/catalog item are the same for all platforms, so we can just use the first one
                _ga = next(iter(app_assets.values()))
                fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id))
                meta_updated = True

        def fetch_game_meta(args):
            app_name, namespace, catalog_item_id = args
            eg_meta = self.egs.get_game_info(namespace, catalog_item_id, timeout=10.0)
            game = Game(app_name=app_name, app_title=eg_meta['title'], metadata=eg_meta, asset_infos=assets[app_name])
            self.lgd.set_game_meta(game.app_name, game)
            games[app_name] = game
            try:
                still_needs_update.remove(app_name)
            except KeyError:
                pass

        # setup and teardown of thread pool takes some time, so only do it when it makes sense.
        still_needs_update = {e[0] for e in fetch_list}
        use_threads = len(fetch_list) > 5
        if fetch_list:
            self.log.info(f'Fetching metadata for {len(fetch_list)} app(s).')
            if use_threads:
                with ThreadPoolExecutor(max_workers=16) as executor:
                    executor.map(fetch_game_meta, fetch_list, timeout=60.0)

        for app_name, app_assets in sorted(assets.items()):
            if skip_ue and any(v.namespace == 'ue' for v in app_assets.values()):
                continue

            game = games.get(app_name)
            # retry if metadata is still missing/threaded loading wasn't used
            if not game or app_name in still_needs_update:
                if use_threads:
                    self.log.warning(f'Fetching metadata for {app_name} failed, retrying')
                _ga = next(iter(app_assets.values()))
                fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id))
                game = games[app_name]

            if game.is_dlc and platform in app_assets:
                _dlc[game.metadata['mainGameItem']['id']].append(game)
            elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])) and platform in app_assets:
                _ret.append(game)

        self.update_aliases(force=meta_updated)
        if meta_updated:
            self._prune_metadata()

        return _ret, _dlc

    def _prune_metadata(self):
        # compile list of games without assets, then delete their metadata
        available_assets = set()
        for platform in self.get_installed_platforms() | {'Windows'}:
            available_assets |= {i.app_name for i in self.get_assets(platform=platform)}

        for app_name in self.lgd.get_game_app_names():
            if app_name in available_assets:
                continue
            # if metadata is still used by an install hold-off on deleting it
            if self.is_installed(app_name):
                continue
            game = self.get_game(app_name)
            # Origin games etc.
            if game.third_party_store:
                continue
            self.log.debug(f'Removing old/unused metadata for "{app_name}"')
            self.lgd.delete_game_meta(app_name)

    def get_non_asset_library_items(self, force_refresh=False,
                                    skip_ue=True) -> (List[Game], Dict[str, List[Game]]):
        """
        Gets a list of Games without assets for installation, for instance Games delivered via
        third-party stores that do not have assets for installation

        :param force_refresh: Force a metadata refresh
        :param skip_ue: Ingore Unreal Marketplace entries
        :return: List of Games and DLC that do not have assets
        """
        _ret = []
        _dlc = defaultdict(list)
        # get all the appnames we have to ignore
        ignore = set(i.app_name for i in self.get_assets())
        # broken old app name that we should always ignore
        ignore |= {'1'}

        for libitem in self.egs.get_library_items():
            if libitem['namespace'] == 'ue' and skip_ue:
                continue
            if libitem['appName'] in ignore:
                continue
            if libitem['sandboxType'] == 'PRIVATE':
                continue

            game = self.lgd.get_game_meta(libitem['appName'])
            if not game or force_refresh:
                eg_meta = self.egs.get_game_info(libitem['namespace'], libitem['catalogItemId'])
                game = Game(app_name=libitem['appName'], app_title=eg_meta['title'], metadata=eg_meta)
                self.lgd.set_game_meta(game.app_name, game)

            if game.is_dlc:
                _dlc[game.metadata['mainGameItem']['id']].append(game)
            elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])):
                _ret.append(game)

        # Force refresh to make sure these titles are included in aliasing
        self.update_aliases(force=True)
        return _ret, _dlc

    def get_dlc_for_game(self, app_name, platform='Windows'):
        game = self.get_game(app_name)
        if not game:
            self.log.warning(f'Metadata for {app_name} is missing!')
            return []

        if game.is_dlc:  # dlc shouldn't have DLC
            return []

        _, dlcs = self.get_game_and_dlc_list(update_assets=False, platform=platform)
        return dlcs[game.catalog_item_id]

    def get_installed_platforms(self):
        return {i.platform for i in self._get_installed_list(False)}

    def get_installed_list(self, include_dlc=False) -> List[InstalledGame]:
        if self.egl_sync_enabled:
            self.log.debug('Running EGL sync...')
            self.egl_sync()

        return self._get_installed_list(include_dlc)

    def _get_installed_list(self, include_dlc=False) -> List[InstalledGame]:
        if include_dlc:
            return self.lgd.get_installed_list()
        else:
            return [g for g in self.lgd.get_installed_list() if not g.is_dlc]

    def get_installed_dlc_list(self) -> List[InstalledGame]:
        return [g for g in self.lgd.get_installed_list() if g.is_dlc]

    def get_installed_game(self, app_name, skip_sync=False) -> InstalledGame:
        igame = self._get_installed_game(app_name)
        if not skip_sync and igame and self.egl_sync_enabled and igame.egl_guid and not igame.is_dlc:
            self.egl_sync(app_name)
            return self._get_installed_game(app_name)
        else:
            return igame

    def _get_installed_game(self, app_name) -> InstalledGame:
        return self.lgd.get_installed_game(app_name)

    def get_app_environment(self, app_name, wine_pfx=None, cx_bottle=None, disable_wine=False) -> dict:
        # get environment overrides from config
        env = dict()
        if 'default.env' in self.lgd.config:
            env |= {
                k: v
                for k, v in self.lgd.config['default.env'].items()
                if v and not k.startswith(';')
            }
        if f'{app_name}.env' in self.lgd.config:
            env |= {
                k: v
                for k, v in self.lgd.config[f'{app_name}.env'].items()
                if v and not k.startswith(';')
            }

        if disable_wine:
            return env

        # override wine prefix if necessary
        if sys_platform == 'darwin':
            if cx_bottle:
                env['CX_BOTTLE'] = cx_bottle
            elif 'CX_BOTTLE' not in os.environ:  # Only fall back to config is not already set in env
                cx_bottle = self.lgd.config.get('default', 'crossover_bottle', fallback='Legendary')
                cx_bottle = self.lgd.config.get(app_name, 'crossover_bottle', fallback=cx_bottle)
                if cx_bottle:
                    env['CX_BOTTLE'] = cx_bottle
            else:
                cx_bottle = os.environ['CX_BOTTLE']

            if cx_bottle:
                self.log.debug(f'Using CrossOver Bottle "{cx_bottle}"')

        if wine_pfx:
            env['WINEPREFIX'] = wine_pfx
        elif 'WINEPREFIX' not in os.environ:
            # only use config variable if not already set in environment
            if wine_pfx := self.lgd.config.get(app_name, 'wine_prefix', fallback=None):
                env['WINEPREFIX'] = wine_pfx

        return env

    def get_app_launch_command(self, app_name, wrapper=None, wine_binary=None,
                               crossover_app=None, disable_wine=False):
        _cmd = []
        if wrapper or (wrapper := self.lgd.config.get(app_name, 'wrapper',
                                                      fallback=self.lgd.config.get('default', 'wrapper',
                                                                                   fallback=None))):
            _cmd.extend(shlex.split(wrapper))

        if os.name != 'nt' and not disable_wine:
            if sys_platform == 'darwin' and not crossover_app and not wine_binary:
                crossover_app = self.lgd.config.get('default', 'crossover_app', fallback=None)
                crossover_app = self.lgd.config.get(app_name, 'crossover_app', fallback=crossover_app)
                # on Mac we use CrossOver by default if we can
                if not crossover_app and not self.lgd.config.getboolean('Legendary', 'disable_auto_crossover',
                                                                        fallback=False):
                    cx_apps = mac_find_crossover_apps()
                    cx_version, crossover_app = cx_apps[0]
                    self.log.debug(f'Default CrossOver found: {crossover_app} ({cx_version})')

            if sys_platform == 'darwin' and crossover_app:
                wine_path = os.path.join(crossover_app, 'Contents', 'SharedSupport', 'CrossOver', 'bin', 'wine')
                if os.path.exists(wine_path):
                    wine_binary = wine_path
                    cx_version = mac_get_crossover_version(crossover_app)
                    self.log.info(f'Using CrossOver {cx_version} ({crossover_app})')
                else:
                    self.log.warning(f'Specified CrossOver app not valid (no wine binary): {crossover_app}')

            if not wine_binary:
                # check if there's a default override
                wine_binary = self.lgd.config.get('default', 'wine_executable', fallback='wine')
                # check if there's a game specific override
                wine_binary = self.lgd.config.get(app_name, 'wine_executable', fallback=wine_binary)

            _cmd.append(wine_binary)

        return _cmd

    def get_pre_launch_command(self, app_name=''):
        if app_name:
            pre_launch_command = self.lgd.config.get(app_name, 'pre_launch_command', fallback=None)
            pre_launch_wait = self.lgd.config.getboolean(app_name, 'pre_launch_wait', fallback=False)

            if pre_launch_command:
                return pre_launch_command, pre_launch_wait

        # try default if no per-game override exists
        pre_launch_command = self.lgd.config.get('default', 'pre_launch_command', fallback=None)
        pre_launch_wait = self.lgd.config.getboolean('default', 'pre_launch_wait', fallback=False)
        return pre_launch_command, pre_launch_wait

    def get_launch_parameters(self, app_name: str, offline: bool = False,
                              user: str = None, extra_args: list = None,
                              wine_bin: str = None, wine_pfx: str = None,
                              language: str = None, wrapper: str = None,
                              disable_wine: bool = False,
                              executable_override: str = None,
                              crossover_app: str = None,
                              crossover_bottle: str = None) -> LaunchParameters:
        install = self.lgd.get_installed_game(app_name)
        game = self.lgd.get_game_meta(app_name)

        # Disable wine for non-Windows executables (e.g. native macOS)
        if not install.platform.startswith('Win'):
            disable_wine = True
            wine_pfx = wine_bin = None

        disable_wine = disable_wine or self.lgd.config.getboolean(
            app_name, 'no_wine', fallback=self.lgd.config.get('default', 'no_wine', fallback=False)
        )

        if executable_override or (executable_override := self.lgd.config.get(app_name, 'override_exe', fallback=None)):
            game_exe = executable_override.replace('\\', '/')
            exe_path = os.path.join(install.install_path, game_exe)
            if not os.path.exists(exe_path):
                raise ValueError(f'Executable path is invalid: {exe_path}')
        else:
            game_exe = install.executable.replace('\\', '/').lstrip('/')
            exe_path = os.path.join(install.install_path, game_exe)

        working_dir = os.path.split(exe_path)[0]

        params = LaunchParameters(
            game_executable=game_exe, game_directory=install.install_path, working_directory=working_dir,
            launch_command=self.get_app_launch_command(app_name=app_name, wrapper=wrapper, wine_binary=wine_bin,
                                                       crossover_app=crossover_app, disable_wine=disable_wine),
            environment=self.get_app_environment(app_name=app_name, wine_pfx=wine_pfx,
                                                 cx_bottle=crossover_bottle, disable_wine=disable_wine)
        )

        cmd, wait = self.get_pre_launch_command(app_name)
        if cmd:
            params.pre_launch_command = cmd
            params.pre_launch_wait = wait

        if install.launch_parameters:
            try:
                params.game_parameters.extend(shlex.split(install.launch_parameters, posix=False))
            except ValueError as e:
                self.log.warning(f'Parsing predefined launch parameters failed with: {e!r}, '
                                 f'input: {install.launch_parameters}')

        game_token = ''
        if not offline:
            self.log.info('Getting authentication token...')
            game_token = self.egs.get_game_token()['code']
        elif not install.can_run_offline:
            self.log.warning('Game is not approved for offline use and may not work correctly.')

        account_id = self.lgd.userdata['account_id']
        user_name = user or self.lgd.userdata['displayName']

        params.egl_parameters.extend([
            '-AUTH_LOGIN=unused',
            f'-AUTH_PASSWORD={game_token}',
            '-AUTH_TYPE=exchangecode',
            f'-epicapp={app_name}',
            '-epicenv=Prod'])

        if install.requires_ot and not offline:
            self.log.info('Getting ownership token.')
            ovt = self.egs.get_ownership_token(game.namespace, game.catalog_item_id)
            ovt_path = os.path.join(self.lgd.get_tmp_path(), f'{game.namespace}{game.catalog_item_id}.ovt')
            with open(ovt_path, 'wb') as f:
                f.write(ovt)
            params.egl_parameters.append(f'-epicovt={ovt_path}')

        language_code = self.lgd.config.get(app_name, 'language', fallback=language)
        if not language_code:  # fall back to system or config language
            language_code = self.language_code

        params.egl_parameters.extend([
            '-EpicPortal',
            f'-epicusername={user_name}',
            f'-epicuserid={account_id}',
            f'-epiclocale={language_code}',
            f'-epicsandboxid={game.namespace}'
        ])

        if extra_args:
            params.user_parameters.extend(extra_args)

        if config_args := self.lgd.config.get(app_name, 'start_params', fallback=None):
            params.user_parameters.extend(shlex.split(config_args.strip()))

        return params

    def get_origin_uri(self, app_name: str, offline: bool = False) -> str:
        token = '0' if offline else self.egs.get_game_token()['code']

        user_name = self.lgd.userdata['displayName']
        account_id = self.lgd.userdata['account_id']
        parameters = [
            ('AUTH_PASSWORD', token),
            ('AUTH_TYPE', 'exchangecode'),
            ('epicusername', user_name),
            ('epicuserid', account_id),
            ('epiclocale', self.language_code),
        ]

        game = self.get_game(app_name)
        extra_args = game.metadata.get('customAttributes', {}).get('AdditionalCommandline', {}).get('value')
        if extra_args:
            parameters.extend(parse_qsl(extra_args))

        return f'link2ea://launchgame/{app_name}?{urlencode(parameters)}'

    def get_save_games(self, app_name: str = ''):
        savegames = self.egs.get_user_cloud_saves(app_name, manifests=not not app_name)
        _saves = []
        for fname, f in savegames['files'].items():
            if '.manifest' not in fname:
                continue
            f_parts = fname.split('/')
            _saves.append(SaveGameFile(app_name=f_parts[2], filename=fname, manifest_name=f_parts[4],
                                       datetime=datetime.fromisoformat(f['lastModified'][:-1])))

        return _saves

    def get_save_path(self, app_name, platform='Windows'):
        game = self.lgd.get_game_meta(app_name)

        if platform == 'Mac':
            save_path = game.metadata['customAttributes'].get('CloudSaveFolder_MAC', {}).get('value')
        else:
            save_path = game.metadata['customAttributes'].get('CloudSaveFolder', {}).get('value')

        if not save_path:
            raise ValueError('Game does not support cloud saves')

        # replace backslashes
        save_path = save_path.replace('\\', '/')

        igame = self.lgd.get_installed_game(app_name)
        if not igame:
            raise ValueError('Game is not installed!')

        # the following variables are known:
        path_vars = {
            '{installdir}': igame.install_path,
            '{epicid}': self.lgd.userdata['account_id']
        }

        if sys_platform == 'win32':
            path_vars |= {
                '{appdata}': os.path.expandvars('%LOCALAPPDATA%'),
                '{userdir}': os.path.expandvars('%userprofile%/documents'),
                '{userprofile}': os.path.expandvars('%userprofile%'),
                '{usersavedgames}': os.path.expandvars('%userprofile%/Saved Games'),
            }
        elif sys_platform == 'darwin' and platform == 'Mac':
            path_vars |= {
                # Note: EGL actually resolves this to "~/Library/Application Support/Epic", but the only game
                # I could find using this (Loop Hero) expects it to be "~/Library/Application Support".
                '{appdata}': os.path.expanduser('~/Library/Application Support'),
                '{userdir}': os.path.expanduser('~/Documents'),
                '{userlibrary}': os.path.expanduser('~/Library'),
            }
        else:
            wine_pfx = None
            # on mac CrossOver takes precedence so check for a bottle first
            if sys_platform == 'darwin':
                cx_bottle = self.lgd.config.get(f'{app_name}.env', 'CX_BOTTLE', fallback=None)
                cx_bottle = self.lgd.config.get(app_name, 'crossover_bottle', fallback=cx_bottle)
                if cx_bottle and mac_is_valid_bottle(cx_bottle):
                    wine_pfx = mac_get_bottle_path(cx_bottle)

            # attempt to get WINE prefix from config
            if not wine_pfx:
                wine_pfx = self.lgd.config.get(f'{app_name}.env', 'WINEPREFIX', fallback=None)
                wine_pfx = self.lgd.config.get(app_name, 'wine_prefix', fallback=wine_pfx)
            # Proton is not officially supported, but people still use it, so look for it
            if not wine_pfx:
                proton_pfx = self.lgd.config.get(f'{app_name}.env', 'STEAM_COMPAT_DATA_PATH', fallback=None)
                if proton_pfx:
                    wine_pfx = f'{proton_pfx}/pfx'

            # fall back to defaults if app-specifics not found
            if not wine_pfx and sys_platform == 'darwin':
                cx_bottle = self.lgd.config.get('default.env', 'CX_BOTTLE', fallback=None)
                cx_bottle = self.lgd.config.get('default', 'crossover_bottle', fallback=cx_bottle)
                if cx_bottle and mac_is_valid_bottle(cx_bottle):
                    wine_pfx = mac_get_bottle_path(cx_bottle)

            if not wine_pfx:
                wine_pfx = self.lgd.config.get('default.env', 'WINEPREFIX', fallback=None)
                wine_pfx = self.lgd.config.get('default', 'wine_prefix', fallback=wine_pfx)

            # If we still didn't find anything, try to read the prefix from the environment variables of this process
            if not wine_pfx and sys_platform == 'darwin':
                cx_bottle = os.getenv('CX_BOTTLE')
                if cx_bottle and mac_is_valid_bottle(cx_bottle):
                    wine_pfx = mac_get_bottle_path(cx_bottle)

            if not wine_pfx:
                if proton_pfx := os.getenv('STEAM_COMPAT_DATA_PATH'):
                    wine_pfx = f'{proton_pfx}/pfx'
                else:
                    wine_pfx = os.getenv('WINEPREFIX', wine_pfx)

            # if all else fails, use the WINE default
            if not wine_pfx:
                wine_pfx = os.path.expanduser('~/.wine')

            # if we have a prefix, read the `user.reg` file and get the proper paths.
            if os.path.isdir(wine_pfx):
                wine_reg = read_registry(wine_pfx)
                wine_folders = get_shell_folders(wine_reg, wine_pfx)
                # path_vars['{userprofile}'] = user_path
                path_vars['{appdata}'] = wine_folders['Local AppData']
                # this maps to ~/Documents, but the name is locale-dependent so just resolve the symlink from WINE
                path_vars['{userdir}'] = os.path.realpath(wine_folders['Personal'])
                path_vars['{usersavedgames}'] = wine_folders['{4C5C32FF-BB9D-43B0-B5B4-2D72E54EAAA4}']

                # WINE prefixes using legacy appdata paths won't correctly resolve the relative paths from
                # %LOCALAPPDATA% that epic uses for some reason.
                # So instead we have to adjust the path and set the necessary variables to properly resolve it.
                if 'roaming' not in wine_folders['AppData'].lower():
                    path_vars['{locallow}'] = wine_folders['{A520A1A4-1780-4FF6-BD18-167343C5AF16}']
                    path_vars['{roaming}'] = wine_folders['AppData']

                    save_path_lower = save_path.lower()
                    if 'locallow' in save_path_lower:
                        save_path = '{locallow}/' + save_path[save_path_lower.index('locallow/') + 9:]
                    elif 'roaming' in save_path_lower:
                        save_path = '{roaming}/' + save_path[save_path_lower.index('roaming/') + 8:]

                    self.log.debug(f'Legacy AppData paths detected, adjusted save game folder to "{save_path}"')

        # these paths should always use a forward slash
        new_save_path = [path_vars.get(p.lower(), p) for p in save_path.split('/')]
        absolute_path = os.path.realpath(os.path.join(*new_save_path))
        # attempt to resolve as much as possible on case-sensitive file-systems
        if os.name != 'nt' and platform != 'Mac':
            absolute_path = case_insensitive_path_search(absolute_path)

        return absolute_path

    def check_savegame_state(self, path: str, save: SaveGameFile) -> (SaveGameStatus, (datetime, datetime)):
        latest = 0
        for _dir, _, _files in os.walk(path):
            for _file in _files:
                s = os.stat(os.path.join(_dir, _file))
                latest = max(latest, s.st_mtime)

        if not latest and not save:
            return SaveGameStatus.NO_SAVE, (None, None)

        # timezones are fun!
        dt_local = datetime.fromtimestamp(latest).replace(tzinfo=self.local_timezone).astimezone(timezone.utc)
        if not save:
            return SaveGameStatus.LOCAL_NEWER, (dt_local, None)

        dt_remote = datetime.strptime(save.manifest_name, '%Y.%m.%d-%H.%M.%S.manifest').replace(tzinfo=timezone.utc)
        if not latest:
            return SaveGameStatus.REMOTE_NEWER, (None, dt_remote)

        self.log.debug(f'Local save date: {str(dt_local)}, Remote save date: {str(dt_remote)}')

        # Ideally we check the files themselves based on manifest,
        # this is mostly a guess but should be accurate enough.
        if abs((dt_local - dt_remote).total_seconds()) < 60:
            return SaveGameStatus.SAME_AGE, (dt_local, dt_remote)
        elif dt_local > dt_remote:
            return SaveGameStatus.LOCAL_NEWER, (dt_local, dt_remote)
        else:
            return SaveGameStatus.REMOTE_NEWER, (dt_local, dt_remote)

    def upload_save(self, app_name, save_dir, local_dt: datetime = None,
                    disable_filtering: bool = False):
        game = self.lgd.get_game_meta(app_name)
        custom_attr = game.metadata['customAttributes']
        save_path = custom_attr.get('CloudSaveFolder', {}).get('value')
        save_path_mac = custom_attr.get('CloudSaveFolder_MAC', {}).get('value')

        include_f = exclude_f = None
        if not disable_filtering:
            # get file inclusion and exclusion filters if they exist
            if (_include := custom_attr.get('CloudIncludeList', {}).get('value', None)) is not None:
                include_f = _include.split(',')
            if (_exclude := custom_attr.get('CloudExcludeList', {}).get('value', None)) is not None:
                exclude_f = _exclude.split(',')

        if not save_path and not save_path_mac:
            raise ValueError('Game does not support cloud saves')

        sgh = SaveGameHelper()
        files = sgh.package_savegame(save_dir, app_name, self.egs.user.get('account_id'),
                                     save_path, save_path_mac, include_f, exclude_f, local_dt)

        if not files:
            self.log.info('No files to upload. If you believe this is incorrect run command with "--disable-filters"')
            return

        self.log.debug(f'Packed files: {str(files)}, creating cloud files...')
        resp = self.egs.create_game_cloud_saves(app_name, list(files.keys()))

        self.log.info('Starting upload...')
        for remote_path, file_info in resp['files'].items():
            self.log.debug(f'Uploading "{remote_path}"')
            f = files.get(remote_path)
            self.egs.unauth_session.put(file_info['writeLink'], data=f.read())

        self.log.info('Finished uploading savegame.')

    def download_saves(self, app_name='', manifest_name='', save_dir='', clean_dir=False):
        save_path = os.path.join(self.get_default_install_dir(), '.saves')
        if not os.path.exists(save_path):
            os.makedirs(save_path)

        _save_dir = save_dir
        savegames = self.egs.get_user_cloud_saves(app_name=app_name)
        files = savegames['files']
        for fname, f in files.items():
            if '.manifest' not in fname:
                continue
            f_parts = fname.split('/')

            if manifest_name and f_parts[4] != manifest_name:
                continue
            if not save_dir:
                _save_dir = os.path.join(save_path, f'{f_parts[2]}/{f_parts[4].rpartition(".")[0]}')
                if not os.path.exists(_save_dir):
                    os.makedirs(_save_dir)

            if app_name and clean_dir:
                game = self.lgd.get_game_meta(app_name)
                custom_attr = game.metadata['customAttributes']
                include_f = exclude_f = None

                # Make sure to only delete files that match the include/exclude filters.
                # This is particularly import for games that store save games in their install dir...
                if (_include := custom_attr.get('CloudIncludeList', {}).get('value', None)) is not None:
                    include_f = _include.split(',')
                if (_exclude := custom_attr.get('CloudExcludeList', {}).get('value', None)) is not None:
                    exclude_f = _exclude.split(',')

                sgh = SaveGameHelper()
                save_files = sgh.get_deletion_list(_save_dir, include_f, exclude_f)
                self.log.info('Deleting old save files...')
                delete_filelist(_save_dir, save_files, silent=True)

            self.log.info(f'Downloading "{fname.split("/", 2)[2]}"...')
            # download manifest
            r = self.egs.unauth_session.get(f['readLink'])
            if r.status_code != 200:
                self.log.error(f'Download failed, status code: {r.status_code}')
                continue

            if not r.content:
                self.log.error('Manifest is empty! Skipping...')
                continue

            m = self.load_manifest(r.content)

            # download chunks required for extraction
            chunks = dict()
            for chunk in m.chunk_data_list.elements:
                cpath_p = fname.split('/', 3)[:3]
                cpath_p.append(chunk.path)
                cpath = '/'.join(cpath_p)
                if cpath not in files:
                    self.log.warning(f'Chunk {cpath} not in file list, save data may be incomplete!')
                    continue

                self.log.debug(f'Downloading chunk "{cpath}"')
                r = self.egs.unauth_session.get(files[cpath]['readLink'])
                if r.status_code != 200:
                    self.log.error(f'Download failed, status code: {r.status_code}')
                    break
                c = Chunk.read_buffer(r.content)
                chunks[c.guid_num] = c.data

            if not chunks:
                if manifest_name:
                    self.log.fatal(f'No chunks were available, aborting. Try running '
                                   f'"legendary clean-saves {app_name}" and try again.')
                    return
                else:
                    self.log.error(f'No chunks were available, skipping. You can run "legendary clean-saves" '
                                   f'to remove this broken save from your account.')
                    continue

            for fm in m.file_manifest_list.elements:
                dirs, fname = os.path.split(fm.filename)
                fdir = os.path.join(_save_dir, dirs)
                fpath = os.path.join(fdir, fname)
                if not os.path.exists(fdir):
                    os.makedirs(fdir)

                self.log.debug(f'Writing "{fpath}"...')
                with open(fpath, 'wb') as fh:
                    for cp in fm.chunk_parts:
                        if cp.guid_num not in chunks:
                            self.log.error(f'Chunk part for {fname} is missing, file may be corrupted!')
                        else:
                            fh.write(chunks[cp.guid_num][cp.offset:cp.offset + cp.size])

                # set modified time to savegame creation timestamp
                m_date = datetime.strptime(f_parts[4], '%Y.%m.%d-%H.%M.%S.manifest')
                m_date = m_date.replace(tzinfo=timezone.utc).astimezone(self.local_timezone)
                os.utime(fpath, (m_date.timestamp(), m_date.timestamp()))

        self.log.info('Successfully completed savegame download.')

    def clean_saves(self, app_name='', delete_incomplete=False):
        savegames = self.egs.get_user_cloud_saves(app_name=app_name)
        files = savegames['files']
        deletion_list = []
        used_chunks = set()
        do_not_delete = set()

        # check if all chunks for manifests are there
        for fname, f in files.items():
            if '.manifest' not in fname:
                continue

            app_name = fname.split('/', 3)[2]

            self.log.info(f'Checking {app_name} "{fname.split("/", 2)[2]}"...')
            # download manifest
            r = self.egs.unauth_session.get(f['readLink'])

            if r.status_code == 404:
                self.log.error('Manifest is missing! Marking for deletion.')
                deletion_list.append(fname)
                continue
            elif r.status_code != 200:
                self.log.warning(f'Download failed, status code: {r.status_code}. Skipping...')
                do_not_delete.add(app_name)
                continue

            if not r.content:
                self.log.error('Manifest is empty! Marking for deletion.')
                deletion_list.append(fname)
                continue

            m = self.load_manifest(r.content)
            # check if all required chunks are present
            chunk_fnames = set()
            missing_chunks = 0
            total_chunks = m.chunk_data_list.count
            for chunk in m.chunk_data_list.elements:
                cpath_p = fname.split('/', 3)[:3]
                cpath_p.append(chunk.path)
                cpath = '/'.join(cpath_p)
                chunk_fnames.add(cpath)
                if cpath not in files:
                    missing_chunks += 1

            if (0 < missing_chunks < total_chunks and delete_incomplete) or missing_chunks == total_chunks:
                self.log.error('Chunk(s) missing, marking manifest for deletion.')
                deletion_list.append(fname)
                continue
            elif 0 < missing_chunks < total_chunks:
                self.log.error(f'Some chunk(s) missing, optionally run "legendary download-saves" to obtain a backup '
                               f'of the corrupted save, then re-run this command with "--delete-incomplete" to remove '
                               f'it from the cloud save service.')

            used_chunks |= chunk_fnames

        # check for orphaned chunks (not used in any manifests)
        for fname, f in files.items():
            if fname in used_chunks or '.manifest' in fname:
                continue
            # skip chunks where orphan status could not be reliably determined
            if fname.split('/', 3)[2] in do_not_delete:
                continue
            self.log.debug(f'Marking orphaned chunk {fname} for deletion.')
            deletion_list.append(fname)

        if deletion_list:
            self.log.info('Deleting unused/broken files...')
            for fname in deletion_list:
                self.log.debug(f'Deleting {fname}')
                self.egs.delete_game_cloud_save_file(fname)
            self.log.info(f'Deleted {len(deletion_list)} files.')
        else:
            self.log.info('Nothing to delete.')

        self.log.info('Successfully completed savegame cleanup.')

    def is_offline_game(self, app_name: str) -> bool:
        return self.lgd.config.getboolean(app_name, 'offline', fallback=False)

    def is_noupdate_game(self, app_name: str) -> bool:
        return self.lgd.config.getboolean(app_name, 'skip_update_check', fallback=False)

    def is_latest(self, app_name: str) -> bool:
        installed = self.lgd.get_installed_game(app_name)

        for ass in self.get_assets(True):
            if ass.app_name == app_name:
                return ass.build_version == installed.version
        # if we get here something is very wrong
        raise ValueError(f'Could not find {app_name} in asset list!')

    def is_installed(self, app_name: str) -> bool:
        return self.get_installed_game(app_name) is not None

    def _is_installed(self, app_name: str) -> bool:
        return self._get_installed_game(app_name) is not None

    def is_dlc(self, app_name: str) -> bool:
        if meta := self.lgd.get_game_meta(app_name):
            return meta.is_dlc
        else:
            raise ValueError('Game unknown!')

    @staticmethod
    def load_manifest(data: bytes) -> Manifest:
        if data[0:1] == b'{':
            return JSONManifest.read_all(data)
        else:
            return Manifest.read_all(data)

    def get_installed_manifest(self, app_name):
        igame = self._get_installed_game(app_name)
        old_bytes = self.lgd.load_manifest(app_name, igame.version, igame.platform)
        return old_bytes, igame.base_urls

    def get_cdn_urls(self, game, platform='Windows'):
        m_api_r = self.egs.get_game_manifest(game.namespace, game.catalog_item_id,
                                             game.app_name, platform)

        # never seen this outside the launcher itself, but if it happens: PANIC!
        if len(m_api_r['elements']) > 1:
            raise ValueError('Manifest response has more than one element!')

        manifest_hash = m_api_r['elements'][0]['hash']
        base_urls = []
        manifest_urls = []
        for manifest in m_api_r['elements'][0]['manifests']:
            base_url = manifest['uri'].rpartition('/')[0]
            if base_url not in base_urls:
                base_urls.append(base_url)

            if 'queryParams' in manifest:
                params = '&'.join(f'{p["name"]}={p["value"]}' for p in manifest['queryParams'])
                manifest_urls.append(f'{manifest["uri"]}?{params}')
            else:
                manifest_urls.append(manifest['uri'])

        return manifest_urls, base_urls, manifest_hash

    def get_cdn_manifest(self, game, platform='Windows', disable_https=False):
        manifest_urls, base_urls, manifest_hash = self.get_cdn_urls(game, platform)
        if not manifest_urls:
            raise ValueError('No manifest URLs returned by API')

        if disable_https:
            manifest_urls = [url.replace('https://', 'http://') for url in manifest_urls]

        for url in manifest_urls:
            self.log.debug(f'Trying to download manifest from "{url}"...')
            try:
                r = self.egs.unauth_session.get(url, timeout=10.0)
            except Exception as e:
                self.log.warning(f'Unable to download manifest from "{urlparse(url).netloc}" '
                                 f'(Exception: {e!r}), trying next URL...')
                continue

            if r.status_code == 200:
                manifest_bytes = r.content
                break
            else:
                self.log.warning(f'Unable to download manifest from "{urlparse(url).netloc}" '
                                 f'(status: {r.status_code}), trying next URL...')
        else:
            raise ValueError(f'Unable to get manifest from any CDN URL, last result: {r.status_code} ({r.reason})')

        if sha1(manifest_bytes).hexdigest() != manifest_hash:
            raise ValueError('Manifest sha hash mismatch!')

        return manifest_bytes, base_urls

    def get_uri_manifest(self, uri):
        if uri.startswith('http'):
            r = self.egs.unauth_session.get(uri)
            r.raise_for_status()
            new_manifest_data = r.content
            base_urls = [r.url.rpartition('/')[0]]
        else:
            base_urls = []
            with open(uri, 'rb') as f:
                new_manifest_data = f.read()

        return new_manifest_data, base_urls

    def get_delta_manifest(self, base_url, old_build_id, new_build_id):
        """Get optimized delta manifest (doesn't seem to exist for most games)"""
        if old_build_id == new_build_id:
            return None

        r = self.egs.unauth_session.get(f'{base_url}/Deltas/{new_build_id}/{old_build_id}.delta')
        return r.content if r.status_code == 200 else None

    def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '',
                         status_q: Queue = None, max_shm: int = 0, max_workers: int = 0,
                         force: bool = False, disable_patching: bool = False,
                         game_folder: str = '', override_manifest: str = '',
                         override_old_manifest: str = '', override_base_url: str = '',
                         platform: str = 'Windows', file_prefix_filter: list = None,
                         file_exclude_filter: list = None, file_install_tag: list = None,
                         dl_optimizations: bool = False, dl_timeout: int = 10,
                         repair: bool = False, repair_use_latest: bool = False,
                         disable_delta: bool = False, override_delta_manifest: str = '',
                         egl_guid: str = '', preferred_cdn: str = None,
                         disable_https: bool = False, bind_ip: str = None) -> (DLManager, AnalysisResult, ManifestMeta):
        # load old manifest
        old_manifest = None

        # load old manifest if we have one
        if override_old_manifest:
            self.log.info(f'Overriding old manifest with "{override_old_manifest}"')
            old_bytes, _ = self.get_uri_manifest(override_old_manifest)
            old_manifest = self.load_manifest(old_bytes)
        elif not disable_patching and not force and self.is_installed(game.app_name):
            old_bytes, _base_urls = self.get_installed_manifest(game.app_name)
            if _base_urls and not game.base_urls:
                game.base_urls = _base_urls

            if not old_bytes:
                self.log.error(f'Could not load old manifest, patching will not work!')
            else:
                old_manifest = self.load_manifest(old_bytes)

        base_urls = game.base_urls

        # The EGS client uses plaintext HTTP by default for the purposes of enabling simple DNS based
        # CDN redirection to a (local) cache. In Legendary this will be a config option.
        disable_https = disable_https or self.lgd.config.getboolean('Legendary', 'disable_https', fallback=False)

        if override_manifest:
            self.log.info(f'Overriding manifest with "{override_manifest}"')
            new_manifest_data, _base_urls = self.get_uri_manifest(override_manifest)
            # if override manifest has a base URL use that instead
            if _base_urls:
                base_urls = _base_urls
        else:
            new_manifest_data, base_urls = self.get_cdn_manifest(game, platform, disable_https=disable_https)
            # overwrite base urls in metadata with current ones to avoid using old/dead CDNs
            game.base_urls = base_urls
            # save base urls to game metadata
            self.lgd.set_game_meta(game.app_name, game)

        self.log.info('Parsing game manifest...')
        new_manifest = self.load_manifest(new_manifest_data)
        self.log.debug(f'Base urls: {base_urls}')
        # save manifest with version name as well for testing/downgrading/etc.
        self.lgd.save_manifest(game.app_name, new_manifest_data,
                               version=new_manifest.meta.build_version,
                               platform=platform)

        # check if we should use a delta manifest or not
        disable_delta = disable_delta or ((override_old_manifest or override_manifest) and not override_delta_manifest)
        if old_manifest and new_manifest:
            disable_delta = disable_delta or (old_manifest.meta.build_id == new_manifest.meta.build_id)
        if old_manifest and new_manifest and not disable_delta:
            if override_delta_manifest:
                self.log.info(f'Overriding delta manifest with "{override_delta_manifest}"')
                delta_manifest_data, _ = self.get_uri_manifest(override_delta_manifest)
            else:
                delta_manifest_data = self.get_delta_manifest(base_urls[0],
                                                              old_manifest.meta.build_id,
                                                              new_manifest.meta.build_id)
            if delta_manifest_data:
                delta_manifest = self.load_manifest(delta_manifest_data)
                self.log.info(f'Using optimized delta manifest to upgrade from build '
                              f'"{old_manifest.meta.build_id}" to '
                              f'"{new_manifest.meta.build_id}"...')
                new_manifest.apply_delta_manifest(delta_manifest)
            else:
                self.log.debug(f'No Delta manifest received from CDN.')

        # reuse existing installation's directory
        if igame := self.get_installed_game(base_game.app_name if base_game else game.app_name):
            install_path = igame.install_path
            # make sure to re-use the epic guid we assigned on first install
            if not game.is_dlc and igame.egl_guid:
                egl_guid = igame.egl_guid
        else:
            if not game_folder:
                if game.is_dlc:
                    game_folder = base_game.metadata.get('customAttributes', {}). \
                        get('FolderName', {}).get('value', base_game.app_name)
                else:
                    game_folder = game.metadata.get('customAttributes', {}). \
                        get('FolderName', {}).get('value', game.app_name)

            if not base_path:
                base_path = self.get_default_install_dir(platform=platform)

                if platform == 'Mac':
                    # if we're on mac and the path to the binary does not start with <something>.app,
                    # treat it as if it were a Windows game instead and install it to the default folder.

                    if all('.app' in fm.filename.partition('/')[0].lower()
                           for fm in new_manifest.file_manifest_list.elements):
                        # If it is a .app omit the game folder
                        game_folder = ''
                    else:
                        base_path = self.get_default_install_dir(platform='Windows')

            # make sure base directory actually exists (but do not create game dir)
            if not os.path.exists(base_path):
                self.log.info(f'"{base_path}" does not exist, creating...')
                os.makedirs(base_path)

            install_path = os.path.normpath(os.path.join(base_path, game_folder.strip()))

        # check for write access on the install path or its parent directory if it doesn't exist yet
        base_path = os.path.dirname(install_path)
        if os.path.exists(install_path) and not os.access(install_path, os.W_OK):
            raise PermissionError(f'No write access to "{install_path}"')
        elif not os.access(base_path, os.W_OK):
            raise PermissionError(f'No write access to "{base_path}"')

        self.log.info(f'Install path: {install_path}')

        if repair:
            if not repair_use_latest and old_manifest:
                # use installed manifest for repairs instead of updating
                new_manifest = old_manifest
                old_manifest = None

            filename = clean_filename(f'{game.app_name}.repair')
            resume_file = os.path.join(self.lgd.get_tmp_path(), filename)
            force = False
        elif not force:
            filename = clean_filename(f'{game.app_name}.resume')
            resume_file = os.path.join(self.lgd.get_tmp_path(), filename)
        else:
            resume_file = None

        # Use user-specified base URL or preferred CDN first, otherwise fall back to
        # EGS's behaviour of just selecting the first CDN in the list.
        base_url = None
        if override_base_url:
            self.log.info(f'Overriding base URL with "{override_base_url}"')
            base_url = override_base_url
        elif preferred_cdn or (preferred_cdn := self.lgd.config.get('Legendary', 'preferred_cdn', fallback=None)):
            for url in base_urls:
                if preferred_cdn in url:
                    base_url = url
                    break
            else:
                self.log.warning(f'Preferred CDN "{preferred_cdn}" unavailable, using default selection.')
        # Use first, fail if none known
        if not base_url:
            if not base_urls:
                raise ValueError('No base URLs found, please try again.')
            base_url = base_urls[0]

        if disable_https:
            base_url = base_url.replace('https://', 'http://')

        self.log.debug(f'Using base URL: {base_url}')
        scheme, cdn_host = base_url.split('/')[0:3:2]
        self.log.info(f'Selected CDN: {cdn_host} ({scheme.strip(":")})')

        if not max_shm:
            max_shm = self.lgd.config.getint('Legendary', 'max_memory', fallback=2048)

        if dl_optimizations or is_opt_enabled(game.app_name, new_manifest.meta.build_version):
            self.log.info('Download order optimizations are enabled.')
            process_opt = True
        else:
            process_opt = False

        if not max_workers:
            max_workers = self.lgd.config.getint('Legendary', 'max_workers', fallback=0)

        dlm = DLManager(install_path, base_url, resume_file=resume_file, status_q=status_q,
                        max_shared_memory=max_shm * 1024 * 1024, max_workers=max_workers,
                        dl_timeout=dl_timeout, bind_ip=bind_ip)
        anlres = dlm.run_analysis(manifest=new_manifest, old_manifest=old_manifest,
                                  patch=not disable_patching, resume=not force,
                                  file_prefix_filter=file_prefix_filter,
                                  file_exclude_filter=file_exclude_filter,
                                  file_install_tag=file_install_tag,
                                  processing_optimization=process_opt)

        prereq = None
        if new_manifest.meta.prereq_ids:
            prereq = dict(ids=new_manifest.meta.prereq_ids, name=new_manifest.meta.prereq_name,
                          path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args)

        uninstaller = None
        if new_manifest.meta.uninstall_action_path:
            uninstaller = dict(path=new_manifest.meta.uninstall_action_path,
                               args=new_manifest.meta.uninstall_action_args)

        offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
        ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')

        if file_install_tag is None:
            file_install_tag = []

        # Override exe at an install level to avoid breaking existing config overrides
        executable = new_manifest.meta.launch_exe
        if platform != 'Mac' and (exe_override := get_exe_override(app_name=game.app_name)):
            exe_override_l = exe_override.lower()
            # make sure that override exe even exists
            if any(fm.filename.lower() == exe_override_l for fm in new_manifest.file_manifest_list.elements):
                self.log.info(f'Launch exe will be changed from "{executable}" to "{exe_override}" for compatibility')
                executable = exe_override

        igame = InstalledGame(app_name=game.app_name, title=game.app_title,
                              version=new_manifest.meta.build_version, prereq_info=prereq,
                              manifest_path=override_manifest, base_urls=base_urls,
                              install_path=install_path, executable=executable,
                              launch_parameters=new_manifest.meta.launch_command,
                              can_run_offline=offline == 'true', requires_ot=ot == 'true',
                              is_dlc=base_game is not None, install_size=anlres.install_size,
                              egl_guid=egl_guid, install_tags=file_install_tag,
                              platform=platform, uninstaller=uninstaller)

        return dlm, anlres, igame

    @staticmethod
    def check_installation_conditions(analysis: AnalysisResult,
                                      install: InstalledGame,
                                      game: Game,
                                      updating: bool = False,
                                      ignore_space_req: bool = False) -> ConditionCheckResult:
        results = ConditionCheckResult(failures=set(), warnings=set())

        # if on linux, check for eac in the files
        if os.name != 'nt':
            for f in analysis.manifest_comparison.added:
                flower = f.lower()
                if 'easyanticheat' in flower:
                    results.warnings.add('(Linux) This game uses EasyAntiCheat and may not run on linux')
                elif 'beclient' in flower:
                    results.warnings.add('(Linux) This game uses BattlEye and may not run on linux')
                elif 'equ8.dll' in flower:
                    results.warnings.add('(Linux) This game is using EQU8 anticheat and may not run on linux')
                elif flower == 'fna.dll' or flower == 'xna.dll':
                    results.warnings.add('(Linux) This game is using XNA/FNA and may not run in WINE')

        if install.requires_ot:
            results.warnings.add('This game requires an ownership verification token and likely uses Denuvo DRM.')
        if not install.can_run_offline:
            results.warnings.add('This game is not marked for offline use (may still work).')

        base_path = os.path.split(install.install_path)[0]
        if os.path.exists(base_path):
            # Ensure that we have enough disk space for the installation process, as calculated by the analyser
            min_disk_space = analysis.disk_space_delta
            _, _, free = shutil.disk_usage(base_path)
            if free < min_disk_space:
                free_gib = free / 1024**3
                required_gib = min_disk_space / 1024**3
                if ignore_space_req:
                    results.warnings.add(f'Potentially not enough available disk space! '
                                         f'{free_gib:.02f} GiB < {required_gib:.02f} GiB')
                else:
                    results.failures.add(f'Not enough available disk space! '
                                         f'{free_gib:.02f} GiB < {required_gib:.02f} GiB')
        else:
            results.failures.add(f'Install path "{base_path}" does not exist, make sure all necessary mounts are '
                                 f'available. If you previously deleted the game folder without uninstalling, run '
                                 f'"legendary uninstall -y {game.app_name}" first.')

        # check if the game actually ships the files or just a uplay installer + packed game files
        uplay_required = False
        executables = [f for f in analysis.manifest_comparison.added if
                       f.lower().endswith('.exe') and not f.startswith('Installer/')]
        if not updating and not any('uplay' not in e.lower() for e in executables) and \
                any('uplay' in e.lower() for e in executables):
            uplay_required = True
            results.failures.add('This game requires installation via Uplay and does not ship executable game files.')

        if install.prereq_info:
            prereq_path = install.prereq_info['path'].lower()
            if 'uplay' in prereq_path or 'ubisoft' in prereq_path:
                uplay_required = True

        # check if the game launches via uplay
        if install.executable == 'UplayLaunch.exe':
            uplay_required = True

        # check if the game requires linking to an external account first
        if game.partner_link_type and game.partner_link_type != 'ubisoft':
            results.warnings.add(f'This game requires linking to "{game.partner_link_type}", '
                                 f'this is currently unsupported and the game may not work.')

        if uplay_required or game.partner_link_type == 'ubisoft':
            if os.name == 'nt':
                results.warnings.add('This game requires installation of Uplay/Ubisoft Connect, direct '
                                     'installation via Uplay is recommended. '
                                     'Use "legendary activate --uplay" and follow the instructions.')
            else:
                results.warnings.add('This game requires installation of Uplay/Ubisoft Connect, direct '
                                     'installation via Uplay running in WINE (e.g. using Lutris) is recommended. '
                                     'Use "legendary activate --uplay" and follow the instructions.')

        # Detect 2K launcher, warn about it
        if '2klauncher' in install.executable.lower():
            results.warnings.add('This game uses the 2K Launcher which is does not work with Legendary. '
                                 'Consult the Legendary or EpicLinux Wikis for workarounds (e.g. exe override).')
            # suggest alternative EXEs
            alts = []
            all_files = analysis.manifest_comparison.added \
                        | analysis.manifest_comparison.unchanged \
                        | analysis.manifest_comparison.changed
            for fname in sorted(all_files):
                if (fname_l := fname.lower()).endswith('.exe'):
                    if 'prereq' in fname_l or '2klauncher' in fname_l:
                        continue
                    alts.append(fname)
            # todo move this to "install" command as an interactive selection
            alt_str = '\n'.join(f'   + {alt}' for alt in alts)
            results.warnings.add('You may want to consider trying one of the following executables '
                                 f'(see README for launch parameter/config option usage):\n{alt_str}')

        # Detect EOS service
        eos_installer = next((f for f in analysis.manifest_comparison.added
                              if 'epiconlineservicesinstaller' in f.lower()), None)
        has_bootstrapper = any('eosbootstrapper' in f.lower() for f in analysis.manifest_comparison.added)

        if eos_installer:
            results.warnings.add('This game ships the Epic Online Services Windows service, '
                                 'it may have to be installed for the game to work properly. '
                                 f'To do so, run "{eos_installer}" inside the game directory '
                                 f'after the install has finished.')
        elif has_bootstrapper:
            results.warnings.add('This game ships the Epic Online Services bootstrapper. '
                                 'The Epic Online Services Windows service may have to be '
                                 'installed manually for the game to function properly.')

        return results

    def get_default_install_dir(self, platform='Windows'):
        if platform == 'Mac':
            return os.path.expanduser(self.lgd.config.get('Legendary', 'mac_install_dir', fallback='~/Applications'))
        else:
            return os.path.expanduser(self.lgd.config.get('Legendary', 'install_dir', fallback='~/Games'))

    def install_game(self, installed_game: InstalledGame) -> dict:
        if self.egl_sync_enabled and not installed_game.is_dlc and installed_game.platform.startswith('Win'):
            if not installed_game.egl_guid:
                installed_game.egl_guid = str(uuid4()).replace('-', '').upper()
            prereq = self._install_game(installed_game)
            self.egl_export(installed_game.app_name)
            return prereq
        else:
            return self._install_game(installed_game)

    def _install_game(self, installed_game: InstalledGame) -> dict:
        """Save game metadata and info to mark it "installed" and also show the user the prerequisites"""
        self.lgd.set_installed_game(installed_game.app_name, installed_game)
        if installed_game.prereq_info:
            if not installed_game.prereq_info.get('installed', False):
                return installed_game.prereq_info

        return dict()

    def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delete_root_directory=False):
        if installed_game.egl_guid:
            self.egl_uninstall(installed_game, delete_files=delete_files)

        if delete_files:
            # DO NOT attempt to delete /Applications or ~/Applications on macOS. That'd be a bad idea.
            if installed_game.platform == 'Mac' and installed_game.install_path.endswith('/Applications'):
                delete_root_directory = False

            try:
                manifest = self.load_manifest(self.get_installed_manifest(installed_game.app_name)[0])
                filelist = [
                    fm.filename for fm in manifest.file_manifest_list.elements if
                    not fm.install_tags or any(t in installed_game.install_tags for t in fm.install_tags)
                ]
                if not delete_filelist(installed_game.install_path, filelist, delete_root_directory):
                    self.log.error(f'Deleting "{installed_game.install_path}" failed, please remove manually.')
            except Exception as e:
                self.log.error(f'Deleting failed with {e!r}, please remove {installed_game.install_path} manually.')

        self.lgd.remove_installed_game(installed_game.app_name)

    def uninstall_tag(self, installed_game: InstalledGame):
        manifest = self.load_manifest(self.get_installed_manifest(installed_game.app_name)[0])
        tags = installed_game.install_tags
        if '' not in tags:
            tags.append('')

        # Create list of files that are now no longer needed *and* actually exist on disk
        filelist = [
            fm.filename for fm in manifest.file_manifest_list.elements if
            not any(((fit in fm.install_tags) or (not fit and not fm.install_tags)) for fit in tags)
            and os.path.exists(os.path.join(installed_game.install_path, fm.filename))
        ]

        if not delete_filelist(installed_game.install_path, filelist):
            self.log.warning(f'Deleting some deselected files failed, please check/remove manually.')

    def prereq_installed(self, app_name):
        igame = self.lgd.get_installed_game(app_name)
        igame.prereq_info['installed'] = True
        self.lgd.set_installed_game(app_name, igame)

    def import_game(self, game: Game, app_path: str, egl_guid='', platform='Windows') -> (Manifest, InstalledGame):
        needs_verify = True
        manifest_data = None

        # check if the game is from an EGL installation, load manifest if possible
        if not game.is_dlc and os.path.exists(os.path.join(app_path, '.egstore')):
            mf = None
            if not egl_guid:
                for f in os.listdir(os.path.join(app_path, '.egstore')):
                    if not f.endswith('.mancpn'):
                        continue

                    self.log.debug(f'Checking mancpn file "{f}"...')
                    mancpn = json.load(open(os.path.join(app_path, '.egstore', f), 'rb'))
                    if mancpn['AppName'] == game.app_name:
                        self.log.info('Found EGL install metadata, verifying...')
                        mf = f.replace('.mancpn', '.manifest')
                        break
            else:
                mf = f'{egl_guid}.manifest'

            if mf and os.path.exists(os.path.join(app_path, '.egstore', mf)):
                manifest_data = open(os.path.join(app_path, '.egstore', mf), 'rb').read()
            else:
                self.log.warning('.egstore folder exists but manifest file is missing, continuing as regular import...')

            # If there's no in-progress installation assume the game doesn't need to be verified
            if mf and not os.path.exists(os.path.join(app_path, '.egstore', 'bps')):
                needs_verify = False
                if os.path.exists(os.path.join(app_path, '.egstore', 'Pending')):
                    if os.listdir(os.path.join(app_path, '.egstore', 'Pending')):
                        needs_verify = True

                if not needs_verify:
                    self.log.debug(f'No in-progress installation found, assuming complete...')

        if not manifest_data:
            self.log.info(f'Downloading latest manifest for "{game.app_name}"')
            manifest_data, base_urls = self.get_cdn_manifest(game)
            if not game.base_urls:
                game.base_urls = base_urls
                self.lgd.set_game_meta(game.app_name, game)
        else:
            # base urls being empty isn't an issue, they'll be fetched when updating/repairing the game
            base_urls = game.base_urls

        # parse and save manifest to disk for verification step of import
        new_manifest = self.load_manifest(manifest_data)
        self.lgd.save_manifest(game.app_name, manifest_data,
                               version=new_manifest.meta.build_version, platform=platform)
        install_size = sum(fm.file_size for fm in new_manifest.file_manifest_list.elements)

        prereq = None
        if new_manifest.meta.prereq_ids:
            prereq = dict(ids=new_manifest.meta.prereq_ids, name=new_manifest.meta.prereq_name,
                          path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args)

        offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
        ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
        igame = InstalledGame(app_name=game.app_name, title=game.app_title, prereq_info=prereq, base_urls=base_urls,
                              install_path=app_path, version=new_manifest.meta.build_version, is_dlc=game.is_dlc,
                              executable=new_manifest.meta.launch_exe, can_run_offline=offline == 'true',
                              launch_parameters=new_manifest.meta.launch_command, requires_ot=ot == 'true',
                              needs_verification=needs_verify, install_size=install_size, egl_guid=egl_guid,
                              platform=platform)

        return new_manifest, igame

    def egl_get_importable(self):
        return [g for g in self.egl.get_manifests()
                if not self.is_installed(g.app_name) and
                g.main_game_appname == g.app_name and
                self.asset_valid(g.app_name)]

    def egl_get_exportable(self):
        if not self.egl.manifests:
            self.egl.read_manifests()
        return [g for g in self.get_installed_list() if
                g.app_name not in self.egl.manifests and g.platform.startswith('Win')]

    def egl_import(self, app_name):
        if not self.asset_valid(app_name):
            raise ValueError(f'To-be-imported game {app_name} not in game asset database!')
        if not self.lgd.lock_installed():
            self.log.warning('Could not acquire lock for EGL import')
            return

        self.log.debug(f'Importing "{app_name}" from EGL')
        # load egl json file
        try:
            egl_game = self.egl.get_manifest(app_name=app_name)
        except ValueError:
            self.log.fatal(f'EGL Manifest for {app_name} could not be loaded, not importing!')
            return
        # convert egl json file
        lgd_igame = egl_game.to_lgd_igame()

        # fix path on Linux if the game is installed to a Windows drive mapping
        if os.name != 'nt' and not lgd_igame.install_path.startswith('/'):
            drive_letter = lgd_igame.install_path[:2].lower()
            drive_c_path = self.egl.programdata_path.partition('ProgramData')[0]
            wine_pfx = os.path.realpath(os.path.join(drive_c_path, '..'))
            mapped_path = os.path.realpath(os.path.join(wine_pfx, 'dosdevices', drive_letter))
            if 'dosdevices' in mapped_path:
                self.log.error(f'Unable to resolve path for mapped drive "{drive_letter}" '
                               f'for WINE prefix at "{wine_pfx}"')
                return

            game_path = lgd_igame.install_path[2:].replace('\\', '/').lstrip('/')
            new_path = os.path.realpath(os.path.join(mapped_path, game_path))
            self.log.info(f'Adjusted game install path from "{lgd_igame.install_path}" to "{new_path}"')
            lgd_igame.install_path = new_path

        # check if manifest exists
        manifest_filename = os.path.join(lgd_igame.install_path, '.egstore', f'{lgd_igame.egl_guid}.manifest')
        if not os.path.exists(manifest_filename):
            self.log.warning(f'Game Manifest "{manifest_filename}" not found, cannot import!')
            return

        # load manifest file and copy it over
        with open(manifest_filename, 'rb') as f:
            manifest_data = f.read()
        new_manifest = self.load_manifest(manifest_data)
        self.lgd.save_manifest(lgd_igame.app_name, manifest_data,
                               version=new_manifest.meta.build_version,
                               platform='Windows')

        # transfer install tag choices to config
        if lgd_igame.install_tags:
            self.lgd.config.set(app_name, 'install_tags', ','.join(lgd_igame.install_tags))

        # mark game as installed
        _ = self._install_game(lgd_igame)

    def egl_export(self, app_name):
        if not self.lgd.lock_installed():
            self.log.warning('Could not acquire lock for EGL import')
            return

        self.log.debug(f'Exporting "{app_name}" to EGL')
        # load igame/game
        lgd_game = self.get_game(app_name)
        lgd_igame = self._get_installed_game(app_name)
        manifest_data, _ = self.get_installed_manifest(app_name)
        if not manifest_data:
            self.log.error(f'Game Manifest for "{app_name}" not found, cannot export!')
            return

        # create guid if it's not set already
        if not lgd_igame.egl_guid:
            lgd_igame.egl_guid = str(uuid4()).replace('-', '').upper()
            _ = self._install_game(lgd_igame)
        # convert to egl manifest
        egl_game = EGLManifest.from_lgd_game(lgd_game, lgd_igame)

        # make sure .egstore folder exists
        egstore_folder = os.path.join(lgd_igame.install_path, '.egstore')
        if not os.path.exists(egstore_folder):
            os.makedirs(egstore_folder)

        # copy manifest and create mancpn file in .egstore folder
        with open(os.path.join(egstore_folder, f'{egl_game.installation_guid}.manifest', ), 'wb') as mf:
            mf.write(manifest_data)

        mancpn = dict(FormatVersion=0, AppName=app_name,
                      CatalogItemId=lgd_game.catalog_item_id,
                      CatalogNamespace=lgd_game.namespace)
        with open(os.path.join(egstore_folder, f'{egl_game.installation_guid}.mancpn', ), 'w') as mcpnf:
            json.dump(mancpn, mcpnf, indent=4, sort_keys=True)

        # And finally, write the file for EGL
        self.egl.set_manifest(egl_game)

    def egl_uninstall(self, igame: InstalledGame, delete_files=True):
        try:
            self.egl.delete_manifest(igame.app_name)
        except ValueError as e:
            self.log.warning(f'Deleting EGL manifest failed: {e!r}')
        except FileNotFoundError:
            self.log.warning(f'EGL manifest was already deleted, in case you uninstalled the Epic Games Launcher'
                             f' please disable and unlink EGL Sync.')

        if delete_files:
            delete_folder(os.path.join(igame.install_path, '.egstore'))

    def egl_restore_or_uninstall(self, igame):
        # check if game binary is still present, if not; uninstall
        if not os.path.exists(os.path.join(igame.install_path,
                                           igame.executable.lstrip('/'))):
            self.log.warning('Synced game\'s files no longer exists, assuming it has been uninstalled.')
            igame.egl_guid = ''
            return self.uninstall_game(igame, delete_files=False)
        else:
            self.log.info('Game files exist, assuming game is still installed, re-exporting to EGL...')
            return self.egl_export(igame.app_name)

    def egl_sync(self, app_name=''):
        """
        Sync game installs between Legendary and the Epic Games Launcher
        """
        if not self.lgd.lock_installed():
            self.log.warning('Could not acquire lock for EGL sync')
            return

        # read egl json files
        if app_name:
            lgd_igame = self._get_installed_game(app_name)
            if not self.egl.manifests:
                self.egl.read_manifests()

            if app_name not in self.egl.manifests:
                self.log.info(f'Synced app "{app_name}" is no longer in the EGL manifest list.')
                return self.egl_restore_or_uninstall(lgd_igame)
            else:
                egl_igame = self.egl.get_manifest(app_name)
                if (egl_igame.app_version_string != lgd_igame.version) or \
                        (egl_igame.install_tags != lgd_igame.install_tags):
                    self.log.info(f'App "{egl_igame.app_name}" has been updated from EGL, syncing...')
                    return self.egl_import(egl_igame.app_name)
        else:
            # check EGL -> Legendary sync
            for egl_igame in self.egl.get_manifests():
                if egl_igame.main_game_appname != egl_igame.app_name:  # skip DLC
                    continue
                if not self.asset_valid(egl_igame.app_name):  # skip non-owned games
                    continue

                if not self._is_installed(egl_igame.app_name):
                    self.egl_import(egl_igame.app_name)
                else:
                    lgd_igame = self._get_installed_game(egl_igame.app_name)
                    if (egl_igame.app_version_string != lgd_igame.version) or \
                            (egl_igame.install_tags != lgd_igame.install_tags):
                        self.log.info(f'App "{egl_igame.app_name}" has been updated from EGL, syncing...')
                        self.egl_import(egl_igame.app_name)

            # Check for games that have been uninstalled
            for lgd_igame in self._get_installed_list():
                if not lgd_igame.egl_guid:  # skip non-exported
                    continue
                if lgd_igame.app_name in self.egl.manifests:
                    continue

                self.log.info(f'Synced app "{lgd_igame.app_name}" is no longer in the EGL manifest list.')
                self.egl_restore_or_uninstall(lgd_igame)

    @property
    def egl_sync_enabled(self):
        return self.lgd.config.getboolean('Legendary', 'egl_sync', fallback=False)

    def check_for_overlay_updates(self):
        cached = self.lgd.get_cached_overlay_version()
        version_info = cached['data']
        if not version_info or (datetime.now().timestamp() - cached['last_update']) > 24*3600:
            # start anoymous session for update check if we're not logged in yet
            if not self.logged_in:
                self.egs.start_session(client_credentials=True)

            manifest_response = self.egs.get_game_manifest(EOSOverlayApp.namespace,
                                                           EOSOverlayApp.catalog_item_id,
                                                           EOSOverlayApp.app_name)
            version_info = manifest_response['elements'][0]
            self.lgd.set_cached_overlay_version(version_info)

        installed = self.lgd.get_overlay_install_info()
        self.overlay_update_available = version_info['buildVersion'] != installed.version

    def is_overlay_installed(self):
        return self.lgd.get_overlay_install_info() is not None

    @staticmethod
    def is_overlay_install(path):
        return os.path.exists(os.path.join(path, 'EOSOVH-Win64-Shipping.dll'))

    def search_overlay_installs(self, prefix=None):
        locations = []
        install_info = self.lgd.get_overlay_install_info()
        if install_info:
            locations.append(install_info.install_path)

        if os.name == 'nt':
            # Launcher path
            locations.append(os.path.expandvars(r'%programfiles(x86)%\Epic Games\Launcher\Portal\Extras\Overlay'))
            # EOSH path
            locations.append(os.path.expandvars(f'%programfiles(x86)%\\Epic Games\\Epic Online Services'
                                                f'\\managedArtifacts\\{EOSOverlayApp.app_name}'))
        else:
            # Launcher path
            locations.append(os.path.join(prefix, 'drive_c', 'Program Files (x86)',
                                          'Epic Games/Launcher/Portal/Extras/Overlay'))
            # EOSH path
            locations.append(os.path.join(prefix, 'drive_c', 'Program Files (x86)',
                                          f'Epic Games/Epic Online Services/managedArtifacts/{EOSOverlayApp.app_name}'))

        # normalise all paths
        locations = [os.path.normpath(x) for x in locations]

        paths = query_registry_entries(prefix)
        if paths['overlay_path']:
            reg_path = os.path.normpath(paths['overlay_path'])
            if reg_path not in locations:
                locations.append(paths['overlay_path'])

        found = []
        for location in locations:
            if self.is_overlay_install(location):
                found.append(location)

        return found

    def prepare_overlay_install(self, path=None):
        # start anoymous session for update check if we're not logged in yet
        if not self.logged_in:
            self.egs.start_session(client_credentials=True)

        _manifest, base_urls = self.get_cdn_manifest(EOSOverlayApp)
        manifest = self.load_manifest(_manifest)

        if igame := self.lgd.get_overlay_install_info():
            path = igame.install_path
        else:
            path = path or os.path.join(self.get_default_install_dir(), '.overlay')

        dlm = DLManager(path, base_urls[0])
        analysis_result = dlm.run_analysis(manifest=manifest)

        install_size = analysis_result.install_size
        if os.path.exists(path):
            current_size = get_dir_size(path)
            install_size = min(0, install_size - current_size)

        parent_dir = path
        while not os.path.exists(parent_dir):
            parent_dir, _ = os.path.split(parent_dir)

        _, _, free = shutil.disk_usage(parent_dir)
        if free < install_size:
            raise ValueError(f'Not enough space to install overlay: {free / 1024 / 1024:.02f} '
                             f'MiB < {install_size / 1024 / 1024:.02f} MiB')

        igame = InstalledGame(app_name=EOSOverlayApp.app_name, title=EOSOverlayApp.app_title,
                              version=manifest.meta.build_version, base_urls=base_urls,
                              install_path=path, install_size=analysis_result.install_size)

        return dlm, analysis_result, igame

    def finish_overlay_install(self, igame):
        self.lgd.set_overlay_install_info(igame)

    def remove_overlay_install(self):
        igame = self.lgd.get_overlay_install_info()
        if os.path.exists(igame.install_path):
            delete_folder(igame.install_path, recursive=True)
        self.lgd.remove_overlay_install_info()

    def get_available_bottles(self):
        self.check_for_updates(force=True)
        lgd_version_data = self.lgd.get_cached_version()
        return lgd_version_data.get('data', {}).get('cx_bottles', [])

    def prepare_bottle_download(self, bottle_name, manifest_url, base_url=None):
        r = self.egs.unauth_session.get(manifest_url)
        r.raise_for_status()
        manifest = self.load_manifest(r.content)

        if not base_url:
            base_url = manifest_url.rpartition('/')[0]

        path = mac_get_bottle_path(bottle_name)
        if os.path.exists(path):
            raise FileExistsError(f'Bottle {bottle_name} already exists')

        dlm = DLManager(path, base_url)
        analysis_result = dlm.run_analysis(manifest=manifest)

        install_size = analysis_result.install_size

        parent_dir = path
        while not os.path.exists(parent_dir):
            parent_dir, _ = os.path.split(parent_dir)

        _, _, free = shutil.disk_usage(parent_dir)
        if free < install_size:
            raise ValueError(f'Not enough space to setup bottle: {free / 1024 / 1024:.02f} '
                             f'MiB < {install_size / 1024 / 1024:.02f} MiB')

        return dlm, analysis_result, path

    def finish_bottle_setup(self, bottle_name):
        path = mac_get_bottle_path(bottle_name)

        self.log.info('Creating bottle symlinks...')
        symlinks = [
            ('dosdevices/c:', '../drive_c'),
            ('dosdevices/y:', os.path.expanduser('~')),
            ('dosdevices/z:', '/'),
            ('drive_c/users/crossover/Desktop/My Mac Desktop', os.path.expanduser('~/Desktop')),
            ('drive_c/users/crossover/Downloads', os.path.expanduser('~/Downloads')),
            ('drive_c/users/crossover/My Documents', os.path.expanduser('~/Documents')),
            ('drive_c/users/crossover/My Music', os.path.expanduser('~/Music')),
            ('drive_c/users/crossover/My Pictures', os.path.expanduser('~/Pictures')),
            ('drive_c/users/crossover/My Videos', os.path.expanduser('~/Movies')),
            ('drive_c/users/crossover/Templates', os.path.join(path, 'dosdevices/c:/users/crossover/My Documents')),
        ]

        for link, target in symlinks:
            _link = os.path.join(path, link)
            _parent = os.path.split(_link)[0]
            if not os.path.exists(_parent):
                os.makedirs(_parent, exist_ok=True)

            try:
                os.symlink(target, _link)
            except Exception as e:
                self.log.error(f'Failed to create symlink {_link} -> {target}: {e!r}')

    @staticmethod
    def remove_bottle(bottle_name):
        path = mac_get_bottle_path(bottle_name)
        if os.path.exists(path):
            delete_folder(path, recursive=True)

    def exit(self):
        """
        Do cleanup, config saving, and exit.
        """
        self.lgd.save_config()
