import asyncio
import ssl
import json
import aiohttp_jinja2
import jinja2
import base64
from datetime import timedelta
from os.path import abspath, join
from typing import Any, Dict

from aiohttp import BasicAuth, hdrs, web, ClientSession, ClientResponseError
from aiohttp.web import HTTPException, Request, HTTPSeeOther, HTTPNotFound
from injector import ClassAssistedBuilder, ProviderOf, inject, singleton

from backup.config import Config, Setting, CreateOptions, BoolValidator, Startable, Version, VERSION
from backup.const import SOURCE_GOOGLE_DRIVE, SOURCE_HA, GITHUB_BUG_TEMPLATE
from backup.model import Coordinator, Backup, AbstractBackup
from backup.exceptions import KnownError, GoogleCredGenerateError, ensureKey
from backup.util import GlobalInfo, Estimator, DataCache, UpgradeFlags
from backup.file import File
from backup.ha import HaSource, PendingBackup, BACKUP_NAME_KEYS, HaRequests, HaUpdater
from backup.ha import Password
from backup.time import Time
from backup.worker import Trigger
from backup.logger import getLogger, getHistory, TraceLogger
from backup.creds import Exchanger, Creds
from backup.debugworker import DebugWorker
from backup.drive import FolderFinder, AuthCodeQuery
from backup.const import FOLDERS
from .debug import Debug
from yarl import URL

logger = getLogger(__name__)

# Used to Google's oauth verification
SCOPE: str = 'https://www.googleapis.com/auth/drive.file'

MIME_TEXT_HTML = "text/html"
MIME_JSON = "application/json"
VERSION_CREATION_TRACKING = Version(0, 104, 0)


@singleton
class UiServer(Trigger, Startable):
    @inject
    def __init__(self, debug: Debug, coord: Coordinator, ha_source: HaSource, harequests: HaRequests,
                 time: Time, config: Config, global_info: GlobalInfo, estimator: Estimator,
                 session: ClientSession, exchanger_builder: ClassAssistedBuilder[Exchanger],
                 debug_worker: DebugWorker, folder_finder: FolderFinder, data_cache: DataCache,
                 haupdater: HaUpdater, custom_auth_provider: ProviderOf[AuthCodeQuery]):
        super().__init__()
        # Currently running server tasks
        self.runners = []
        self.exchanger_builder = exchanger_builder
        self._coord = coord
        self._time = time
        self.custom_auth_provider = custom_auth_provider
        self.manual_exchanger: Exchanger = None
        self.config: Config = config
        self.auth_cache: Dict[str, Any] = {}
        self.last_log_index = 0
        self.host_server = None
        self.ingress_server = None
        self.running = False
        self._harequests = harequests
        self._global_info = global_info
        self._ha_source = ha_source
        self._starts = 0
        self._estimator = estimator
        self._debug = debug
        self.session = session
        self.debug_worker = debug_worker
        self.folder_finder = folder_finder
        self.ignore_other_turned_on = False
        self._data_cache = data_cache
        self._haupdater = haupdater
        self._check_creds_loop: asyncio.Task = None
        self._check_creds_error: Exception = None
        self._device_code_authorizer: AuthCodeQuery = None
        self._upload_event = asyncio.Event()

    def name(self):
        return "UI Server"

    def base_context(self):
        return {
            'version': VERSION,
            'backgroundColor': self.config.get(Setting.BACKGROUND_COLOR),
            'accentColor': self.config.get(Setting.ACCENT_COLOR),
            'coordEnabled': self._coord.enabled(),
            'save_drive_creds_path': self.config.get(Setting.SAVE_DRIVE_CREDS_PATH),
            'bmc_logo_path': "static/" + VERSION + "/images/bmc.svg"
        }

    async def getstatus(self, request) -> Dict[Any, Any]:
        return web.json_response(await self.buildStatusInfo())

    async def buildStatusInfo(self):
        status: Dict[Any, Any] = {}
        status['folder_id'] = self.folder_finder.getCachedFolder()
        status['backups'] = []
        backups = self._coord.backups()
        for backup in backups:
            status['backups'].append(self.getBackupDetails(backup))
        status['ha_url_base'] = self._ha_source.getHomeAssistantUrl()
        status['restore_backup_path'] = "hassio/backups"
        status['ask_error_reports'] = not self.config.isExplicit(
            Setting.SEND_ERROR_REPORTS)
        status['warn_ingress_upgrade'] = False
        status['cred_version'] = self._global_info.credVersion
        next = self._coord.nextBackupTime()
        if next is None:
            status['next_backup_text'] = "Disabled"
            status['next_backup_machine'] = ""
            status['next_backup_detail'] = "Disabled"
        elif (next < self._time.now()):
            status['next_backup_text'] = self._time.formatDelta(
                self._time.now())
            status['next_backup_machine'] = self._time.asRfc3339String(
                self._time.now())
            status['next_backup_detail'] = self._time.toLocal(
                self._time.now()).strftime("%c")
        else:
            status['next_backup_text'] = self._time.formatDelta(next)
            status['next_backup_machine'] = self._time.asRfc3339String(next)
            status['next_backup_detail'] = self._time.toLocal(
                next).strftime("%c")
        not_ignored = list(filter(lambda s: not s.ignore(), self._coord.backups()))
        if len(not_ignored) > 0:
            latest = not_ignored[len(not_ignored) - 1].date()
            status['last_backup_text'] = self._time.formatDelta(latest)
            status['last_backup_machine'] = self._time.asRfc3339String(
                latest)
            status['last_backup_detail'] = self._time.toLocal(
                latest).strftime("%c")
        else:
            status['last_backup_text'] = "Never"
            status['last_backup_machine'] = ""
            status['last_backup_detail'] = "Never"

        status['last_error'] = None
        if self._global_info._last_error is not None and self._global_info.isErrorSuppressed():
            status['last_error'] = self.processError(
                self._global_info._last_error)
        status["last_error_count"] = self._global_info.failureCount()
        status["ignore_errors_for_now"] = self._global_info.ignoreErrorsForNow()
        status["syncing"] = self._coord.isSyncing()
        status["ignore_sync_error"] = self._coord.isWorkingThroughUpload()
        status["firstSync"] = self._global_info._first_sync
        status["backup_name_template"] = self.config.get(
            Setting.BACKUP_NAME)
        status['sources'] = self._coord.buildBackupMetrics()
        status['authenticate_url'] = str(URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path("/drive/authorize"))
        choose_url = str(URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path('/drive/picker').with_query({
            "bg": self.config.get(Setting.BACKGROUND_COLOR),
            "ac": self.config.get(Setting.ACCENT_COLOR),
            "version": VERSION
        }))
        status['choose_folder_url'] = str(choose_url)
        status['dns_info'] = self._global_info.getDnsInfo()
        status['enable_drive_upload'] = self.config.get(
            Setting.ENABLE_DRIVE_UPLOAD)
        status['is_custom_creds'] = self._coord._model.dest.isCustomCreds()
        status['is_specify_folder'] = self.config.get(
            Setting.SPECIFY_BACKUP_FOLDER)
        status['backup_cooldown_active'] = self._coord.isWaitingForStartup()
        name_keys = {}
        for key in BACKUP_NAME_KEYS:
            name_keys[key] = BACKUP_NAME_KEYS[key](
                "Full", self._time.now(), self._ha_source.getHostInfo())
        status['backup_name_keys'] = name_keys

        # Indicate the user should be notified for a specific situation where:
        #  - They recently turned on "IGNORE_OTHER_BACKUPS"
        #  - They have ignored backups created before upgrading to v0.104.0 or higher.
        upgrade_date = self._data_cache.getUpgradeTime(VERSION_CREATION_TRACKING)
        ignored = len(list(filter(lambda s: s.date() < upgrade_date, filter(Backup.ignore, self._coord.backups()))))
        status["notify_check_ignored"] = ignored > 0 and self.ignore_other_turned_on
        status["warn_backup_upgrade"] = self.config.get(Setting.CALL_BACKUP_SNAPSHOT) and not self._data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
        status["warn_upgrade_backups"] = self._data_cache.notifyForIgnoreUpgrades
        status["warn_oob_oauth"] = not self._data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW) and self._coord._model.dest.might_be_oob_creds
        return status

    async def bootstrap(self, request) -> Dict[Any, Any]:
        return web.Response(body="bootstrap_update_data = {0};".format(json.dumps(await self.buildStatusInfo(), indent=4)), content_type="text/javascript")

    def getBackupDetails(self, backup: Backup):
        ha = backup.getSource(SOURCE_HA)
        sources = []
        for source_key in backup.sources:
            source: AbstractBackup = backup.sources[source_key]
            sources.append({
                'name': source.name(),
                'key': source_key,
                'size': source.size(),
                'retained': source.retained(),
                'delete_next': backup.getPurges().get(source_key) or False,
                'slug': backup.slug(),
                'ignored': source.ignore(),
            })

        return {
            'name': backup.name(),
            'slug': backup.slug(),
            'size': backup.sizeString(),
            'status': backup.status(),
            'date': self._time.toLocal(backup.date()).strftime("%c"),
            'createdAt': self._time.formatDelta(backup.date()),
            'isPending': ha is not None and type(ha) is PendingBackup,
            'protected': backup.protected(),
            'type': backup.backupType(),
            'folders': backup.details().get("folders", []),
            'addons': self.formatAddons(backup.details()),
            'sources': sources,
            'haVersion': False if backup.version() == None else backup.version(),
            'uploadable': backup.getSource(SOURCE_HA) is None and len(backup.sources) > 0,
            'restorable': backup.getSource(SOURCE_HA) is not None,
            'status_detail': backup.getStatusDetail(),
            'upload_info': backup.getUploadInfo(self._time),
            'ignored': backup.ignore(),
            'timestamp': backup.date().timestamp(),
            'note': backup.note()
        }

    def formatAddons(self, backup_data):
        addons = []
        for addon in backup_data.get("addons", []):
            addons.append({
                'name': addon.get('name', "Unknown"),
                'slug': addon.get("slug", "unknown"),
                'version': addon.get("version", ""),
                # The supervisor stores backup size in MB
                'size': self._estimator.asSizeString(float(addon.get("size", 0)) * 1024 * 1024),
            })
        return addons

    async def manualCredCheckLoop(self, auth: AuthCodeQuery):
        try:
            creds = await auth.waitForPermission()
            self._coord.saveCreds(creds)
            self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW)
        except asyncio.CancelledError:
            # Cancelled, thats fine
            pass
        except Exception as e:
            self._check_creds_error = e

    async def checkManualAuth(self, request: Request):
        if self._check_creds_error is not None:
            raise self._check_creds_error
        elif self._device_code_authorizer is not None:
            return web.json_response({
                'message': "Waiting for you to authorize the add-on.",
                'auth_url': self._device_code_authorizer.verification_url,
                'code': self._device_code_authorizer.user_code,
                'expires': self._time.formatDelta(self._device_code_authorizer.expiration)
            })
        else:
            return web.json_response({
                'message': "No request for authorization is in progress."
            })

    async def manualauth(self, request: Request) -> None:
        client_id = request.query.get("client_id", "")
        client_secret = request.query.get("client_secret", "")
        if client_id == "" or client_secret == "":
            raise GoogleCredGenerateError("Invalid information provided")

        if self._check_creds_loop is not None and not self._check_creds_loop.done():
            self._check_creds_loop.cancel()
            await self._check_creds_loop
        self._device_code_authorizer = self.custom_auth_provider.get()
        await self._device_code_authorizer.requestCredentials(client_id, client_secret)
        self._check_creds_error = None
        self._check_creds_loop = asyncio.create_task(self.manualCredCheckLoop(self._device_code_authorizer))
        return web.json_response({
            'auth_url': self._device_code_authorizer.verification_url,
            'code': self._device_code_authorizer.user_code,
            'expires': self._time.formatDelta(self._device_code_authorizer.expiration),
        })

    async def backup(self, request: Request) -> Any:
        custom_name = request.query.get("custom_name", None)
        retain_drive = BoolValidator.strToBool(
            request.query.get("retain_drive", False))
        retain_ha = BoolValidator.strToBool(
            request.query.get("retain_ha", False))
        note = request.query.get("note", None)
        options = CreateOptions(self._time.now(), custom_name, {
            SOURCE_GOOGLE_DRIVE: retain_drive,
            SOURCE_HA: retain_ha
        }, note=note)
        backup = await self._coord.startBackup(options)
        return web.json_response({"message": "Requested backup '{0}'".format(backup.name())})

    async def deleteSnapshot(self, request: Request):
        data = await request.json()
        # Check to make sure the slug is valid.
        self._coord.getBackup(data['slug'])
        await self._coord.delete(data['sources'], data['slug'])
        return web.json_response({"message": "Deleted from {0} place(s)".format(len(data['sources']))})

    async def ignore(self, request: Request):
        data = await request.json()
        # Check to make sure the slug is valid.
        backup = self._coord.getBackup(data['slug'])
        await self._coord.ignore(data['slug'], data['ignore'])
        await self.startSync(request)
        if data['ignore']:
            return web.json_response({"message": "'{0}' will be ignored.".format(backup.name())})
        else:
            return web.json_response({"message": "'{0}' will be included.".format(backup.name())})

    async def retain(self, request: Request):
        data = await request.json()
        slug = data['slug']

        self._coord.getBackup(slug)
        await self._coord.retain(data['sources'], slug)
        return web.json_response({'message': "Updated the backup's settings"})

    async def note(self, request: Request):
        data = await request.json()
        slug = data['slug']

        self._coord.getBackup(slug)
        await self._coord.note(data.get("note", None), slug)
        return web.json_response({'message': "Updated the backup's settings"})

    async def resolvefolder(self, request: Request):
        use_existing = BoolValidator.strToBool(
            request.query.get("use_existing", False))
        self.folder_finder.resolveExisting(use_existing)
        self._global_info.suppressError()
        self._global_info.setIngoreErrorsForNow(True)
        await self.sync()
        return web.json_response({'message': 'Done'})

    async def skipspacecheck(self, request: Request):
        self._global_info.setSkipSpaceCheckOnce(True)
        self._global_info.setIngoreErrorsForNow(True)
        await self.startSync(request)
        return web.json_response({'message': 'Done'})

    async def confirmdelete(self, request: Request):
        always = BoolValidator.strToBool(request.query.get("always", False))
        self._global_info.allowMultipleDeletes()
        self._global_info.setIngoreErrorsForNow(True)
        if always:
            validated = self.config.validateUpdate(
                {"confirm_multiple_deletes": False})
            await self._updateConfiguration(validated)
            await self.sync()
            return web.json_response({'message': 'Configuration updated, I\'ll never ask again'})
        else:
            await self.sync()
            return web.json_response({'message': 'Backups deleted this one time'})

    async def log(self, request: Request) -> Any:
        format = request.query.get("format", "download")
        catchup = BoolValidator.strToBool(
            request.query.get("catchup", "False"))
        if not catchup:
            self.last_log_index = 0
        if format == "view":
            context = self.base_context()
            return aiohttp_jinja2.render_template("logs.jinja2",
                                                  request,
                                                  context)
        resp = web.StreamResponse()
        if format == "html":
            resp.content_type = 'text/html'
        else:
            resp.content_type = 'text/plain'
            resp.headers['Content-Disposition'] = 'attachment; filename="home-assistant-google-drive-backup.log"'

        await resp.prepare(request)

        def content():
            html = format == "colored"
            if format == "html":
                yield "<html><head><title>Home Assistant Google Drive Backup Log</title></head><body><pre>\n"
            for line in getHistory(self.last_log_index, html):
                self.last_log_index = line[0]
                if line:
                    yield line[1].replace("\n", "   \n") + "\n"
            if format == "html":
                yield "</pre></body>\n"

        for line in content():
            await resp.write(line.encode())
        await resp.write_eof()

    async def token(self, request: Request) -> None:
        self._global_info.setIngoreErrorsForNow(True)
        creds_deserialized = json.loads(str(base64.b64decode(request.query.get('creds').strip().encode("utf-8")), 'utf-8'))
        creds = Creds.load(self._time, creds_deserialized)
        self._coord.saveCreds(creds)

        # Build the redirect url
        if 'host' in request.query:
            redirect = request.query.get('host')
        else:
            redirect = self._ha_source.getAddonUrl()
        if MIME_JSON in request.headers[hdrs.ACCEPT]:
            return web.json_response({'redirect': str(redirect)})
        else:
            raise HTTPSeeOther(redirect)

    async def changefolder(self, request: Request) -> None:
        # update config to specify backup folder
        await self._updateConfiguration(self.config.validateUpdate({Setting.SPECIFY_BACKUP_FOLDER: True}))

        id = request.query.get("id", None)
        await self.folder_finder.save(id)
        self._global_info.setIngoreErrorsForNow(True)
        self.trigger()
        return web.json_response({})

    async def sync(self, request: Request = None) -> Any:
        self._coord.clearCaches()
        await self._coord.sync()
        return await self.getstatus(request)

    async def startSync(self, request) -> Any:
        self._coord.clearCaches()
        asyncio.create_task(self._coord.sync(), name="Sync from web request")
        await self._coord._sync_start.wait()
        return await self.getstatus(request)

    async def cancelSync(self, request: Request):
        await self._coord.cancel()
        return await self.getstatus(request)

    async def getconfig(self, request: Request):
        await self._ha_source.refresh()
        current_config = {}
        for setting in Setting:
            current_config[setting.key()] = self.config.getForUi(setting)
        default_config = {}
        for setting in Setting:
            default_config[setting.key()] = setting.default()
        return web.json_response({
            'config': current_config,
            'addons': self._global_info.addons,
            'folders': FOLDERS,
            'defaults': default_config,
            'backup_folder': self.folder_finder.getCachedFolder(),
            'is_custom_creds': self._coord._model.dest.isCustomCreds()
        })

    async def errorreports(self, request: Request):
        send = BoolValidator.strToBool(request.query.get("send", False))

        update = {
            "send_error_reports": send
        }
        validated = self.config.validateUpdate(update)
        await self._updateConfiguration(validated)
        return web.json_response({'message': 'Configuration updated'})

    async def callbackupsnapshot(self, request: Request):
        switch = BoolValidator.strToBool(request.query.get("switch", False))

        if switch:
            validated = self.config.validateUpdate({Setting.CALL_BACKUP_SNAPSHOT: False})
            await self._updateConfiguration(validated)
            self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
        self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
        self._data_cache.saveIfDirty()
        return web.json_response({'message': 'Configuration updated'})

    async def ignoredbackupswitch(self, request: Request):
        switch = BoolValidator.strToBool(request.query.get("switch", False))

        validated = self.config.validateUpdate({Setting.IGNORE_UPGRADE_BACKUPS: switch})
        await self._updateConfiguration(validated)
        self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_IGNORED_BACKUPS)
        self._data_cache.saveIfDirty()
        if switch:
            return web.json_response({'message': 'Configuration updated'})
        else:
            return web.json_response({'message': 'Acknowledged'})

    async def ignorestartupcooldown(self, request: Request):
        self._coord.ignoreStartupDelay()
        return await self.sync(request)

    async def exposeserver(self, request: Request):
        expose = BoolValidator.strToBool(request.query.get("expose", False))
        if expose:
            update = {
                Setting.EXPOSE_EXTRA_SERVER: True
            }
        else:
            update = {
                Setting.EXPOSE_EXTRA_SERVER: False,
                Setting.USE_SSL: False,
                Setting.REQUIRE_LOGIN: False
            }
        validated = self.config.validateUpdate(update)
        await self._updateConfiguration(validated)

        File.touch(self.config.get(Setting.INGRESS_TOKEN_FILE_PATH))
        await self._ha_source.init()

        redirect = ""
        try:
            if request.url.port != self.config.get(Setting.INGRESS_PORT):
                redirect = self._ha_source.getFullAddonUrl()
        except:  # noqa: E722
            # eat the error
            pass
        return web.json_response({
            'message': 'Configuration updated',
            'redirect': redirect
        })

    async def makeanissue(self, request: Request):
        if self._global_info._last_error is not None:
            error = logger.formatException(self._global_info._last_error)
        else:
            error = "No error could be identified automatically."
        data = await self.debug_worker.buildBugReportData(error)
        body = GITHUB_BUG_TEMPLATE
        for key in data:
            if isinstance(data[key], dict):
                body = body.replace(
                    "{" + key + "}", json.dumps(data[key], indent=4))
            else:
                body = body.replace("{" + key + "}", str(data[key]))
        return web.json_response({'markdown': body})

    async def saveconfig(self, request: Request) -> Any:
        data = await request.json()
        update = ensureKey("config", data, "the configuration update request")

        # validate the backup password
        Password(self.config.getConfigFor(update)).resolve()

        validated, needUpdate = self.config.validate(update)
        message = await self._updateConfiguration(validated, ensureKey("backup_folder", data, "the configuration update request"), trigger=False)
        try:
            await self.cancelSync(request)
            await self.startSync(request)
        except:  # noqa: E722
            # eat the error, just cancel optimistically
            pass
        return web.json_response(message)

    async def ackignorecheck(self, request: Request):
        self.ignore_other_turned_on = False
        return web.json_response({'message': "Acknowledged."})

    async def aknowledgeooboauth(self, request: Request):
        self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW)
        self._data_cache.saveIfDirty()
        return web.json_response({'message': "Acknowledged"})

    async def _updateConfiguration(self, new_config, backup_folder_id=None, trigger=True):
        update = {}
        for key in new_config:
            update[key.key()] = new_config[key]
        old_drive_option = self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
        old_ignore_others_option = self.config.get(Setting.IGNORE_OTHER_BACKUPS)
        await self._harequests.updateConfig(update)

        self.config.update(new_config)

        if not old_ignore_others_option and self.config.get(Setting.IGNORE_OTHER_BACKUPS):
            self.ignore_other_turned_on = True
        self._haupdater.triggerRefresh()
        if self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and backup_folder_id is not None and len(backup_folder_id):
            await self.folder_finder.save(backup_folder_id)
        if trigger:
            self.trigger()
        return {
            'message': 'Settings saved',
            'reload_page': self.config.get(Setting.ENABLE_DRIVE_UPLOAD) != old_drive_option
        }

    async def waitForUpload(self):
        await self._upload_event.wait()

    async def _doUpload(self, slug):
        await self._coord.uploadBackups(slug)
        self._upload_event.set()

    async def upload(self, request: Request):
        slug = request.query.get("slug", "")
        asyncio.create_task(self._doUpload(slug))
        return web.json_response({'message': "Uploading backup in the background"})

    async def redirect(self, request, url):
        context = {
            **self.base_context(),
            'url': url
        }
        return aiohttp_jinja2.render_template("redirect.jinja2",
                                              request,
                                              context)

    async def addonLogo(self, request: Request):
        slug = request.match_info.get('slug')
        if not self._ha_source.addonHasLogo(slug):
            raise HTTPNotFound()
        try:
            (content_type, data) = await self._harequests.getAddonLogo(slug)
            return web.Response(headers={hdrs.CONTENT_TYPE: content_type}, body=data)
        except ClientResponseError as e:
            return web.Response(status=e.status)

    async def download(self, request: Request):
        slug = request.query.get("slug", "")
        backup = self._coord.getBackup(slug)
        stream = await self._coord.download(slug)
        await stream.setup()
        resp = web.StreamResponse()
        resp.content_type = 'application/tar'
        resp.headers['Content-Disposition'] = 'attachment; filename="{}.tar"'.format(
            backup.name())
        resp.headers['Content-Length'] = str(stream.size())

        await resp.prepare(request)

        # SOMEDAY: consider re-streaming a decrypted tar file for the sake of convenience

        async for chunk in stream.generator(self.config.get(Setting.DEFAULT_CHUNK_SIZE)):
            await resp.write(chunk)

        await resp.write_eof()

    async def run(self) -> None:
        await self.stop()

        # Create the ingress server
        app = web.Application(middlewares=[self.error_middleware])
        aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.filePath()))
        self._addRoutes(app)

        # The ingress port is considered secured by Home Assistant, so it doesn't get SSL or basic HTTP auth
        logger.info("Starting server on port {}".format(
            self.config.get(Setting.INGRESS_PORT)))
        await self._start_site(app, self.config.get(Setting.INGRESS_PORT))

        try:
            if self.config.get(Setting.EXPOSE_EXTRA_SERVER):
                ssl_context = None
                if self.config.get(Setting.USE_SSL):
                    ssl_context = ssl.create_default_context(
                        ssl.Purpose.CLIENT_AUTH)
                    ssl_context.load_cert_chain(self.config.get(
                        Setting.CERTFILE), self.config.get(Setting.KEYFILE))
                middleware = [self.error_middleware]
                if self.config.get(Setting.REQUIRE_LOGIN):
                    middleware.append(HomeAssistantLoginAuth(
                        self._time, self._harequests))

                extra_app = web.Application(middlewares=middleware)
                aiohttp_jinja2.setup(extra_app, loader=jinja2.FileSystemLoader(self.filePath()))
                self._addRoutes(extra_app)
                logger.info("Starting server on port {}".format(
                    self.config.get(Setting.PORT)))
                await self._start_site(extra_app, self.config.get(Setting.PORT), ssl_context=ssl_context)
        except FileNotFoundError:
            logger.error("The configured SSL key or certificate files couldn't be found and so \nan SSL server couldn't be started, please check your settings. \nThe addon web-ui is still available through ingress.")
        except ssl.SSLError:
            logger.error("Your SSL certificate or key couldn't be loaded and so an SSL server couldn't be started.  Please verify that your SSL settings are correctly configured.  The addon web-ui is still available through ingress.")
        logger.info("Server started")
        self.running = True
        self._starts += 1

    def _addRoutes(self, app):
        app.add_routes(
            [web.static('/static/' + VERSION, abspath(join(__file__, "..", "..", "static")), append_version=True)])
        app.add_routes([web.get('/', self.index)])
        app.add_routes([web.get('/index.html', self.index)])
        app.add_routes([web.get('/index', self.index)])
        app.add_routes([web.get('/favicon.ico', self.favicon)])
        app.add_routes([web.get('/logo/{slug}', self.addonLogo)])
        self._addRoute(app, self.reauthenticate)
        self._addRoute(app, self.bootstrap)
        self._addRoute(app, self.tos)
        self._addRoute(app, self.pp)

        self._addRoute(app, self.getstatus)
        self._addRoute(app, self.backup)
        self._addRoute(app, self.manualauth)
        self._addRoute(app, self.token)

        self._addRoute(app, self.log)

        self._addRoute(app, self.sync)
        self._addRoute(app, self.startSync)
        self._addRoute(app, self.cancelSync)

        self._addRoute(app, self.getconfig)
        self._addRoute(app, self.errorreports)
        self._addRoute(app, self.exposeserver)
        self._addRoute(app, self.saveconfig)
        self._addRoute(app, self.changefolder)
        self._addRoute(app, self.confirmdelete)
        self._addRoute(app, self.resolvefolder)
        self._addRoute(app, self.skipspacecheck)

        self._addRoute(app, self.upload)
        self._addRoute(app, self.download)
        self._addRoute(app, self.deleteSnapshot)
        self._addRoute(app, self.retain)
        self._addRoute(app, self.note)

        self._addRoute(app, self._debug.simerror)
        self._addRoute(app, self._debug.getTasks)
        self._addRoute(app, self._debug.timeoffset)
        self._addRoute(app, self.makeanissue)
        self._addRoute(app, self.ignorestartupcooldown)
        self._addRoute(app, self.callbackupsnapshot)
        self._addRoute(app, self.ignoredbackupswitch)
        self._addRoute(app, self.ignore)
        self._addRoute(app, self.ackignorecheck)
        self._addRoute(app, self.aknowledgeooboauth)
        self._addRoute(app, self.checkManualAuth)

    def _addRoute(self, app, method):
        app.add_routes([
            web.get("/" + method.__name__, method),
            web.post("/" + method.__name__, method)
        ])

    async def start(self):
        await self.run()

    async def _start_site(self, app, port, ssl_context=None):
        aiohttp_logger = TraceLogger("aiohttp.access")
        if self.config.get(Setting.TRACE_REQUESTS):
            runner = web.AppRunner(app, logger=aiohttp_logger, access_log=aiohttp_logger,
                                access_log_format='%a %t "%r" %s %b "%{Referer}i" "%{User-Agent}i (%Tfs)"')
        else:
            runner = web.AppRunner(app)
        self.runners.append(runner)
        await runner.setup()
        # maybe host should be 0.0.0.0
        site = web.TCPSite(runner, "0.0.0.0", port, ssl_context=ssl_context)
        await site.start()

    async def stop(self):
        # Stop pending requests for all available servers
        for runner in self.runners:
            try:
                await runner.shutdown()
            except Exception as e:
                logger.error(
                    "Error while trying to shut down server: " + str(e))
            try:
                await runner.cleanup()
            except Exception as e:
                logger.error(
                    "Error while trying to shut down server: " + str(e))
        self.runners = []

    async def shutdown(self):
        await self.stop()

    @web.middleware
    async def error_middleware(self, request: Request, handler):
        try:
            log_trace = self.config.get(Setting.TRACE_REQUESTS)
            if log_trace:
                logger.trace("Serving %s %s to %s", request.method,
                            request.url, request.remote)
            handled = await handler(request)
            if log_trace:
                logger.trace("Completed %s %s", request.method, request.url)
            return handled
        except Exception as ex:
            if isinstance(ex, HTTPException):
                raise
            logger.error("Error serving %s %s", request.method, request.url)
            logger.error(logger.formatException(ex))
            data = self.processError(ex)
            return web.json_response(data, status=data['http_status'])

    def processError(self, e):
        if isinstance(e, KnownError):
            known: KnownError = e
            return {
                'http_status': known.httpStatus(),
                'error_type': known.code(),
                'message': known.message(),
                'details': logger.formatException(e),
                'data': known.data()
            }
        else:
            return {
                'http_status': 500,
                'error_type': "generic_error",
                'message': "An unexpected error occurred: " + str(e),
                'details': logger.formatException(e)
            }

    def filePath(self, name=None):
        if name is None:
            return abspath(join(__file__, "..", "..", "static"))
        else:
            return abspath(join(__file__, "..", "..", "static", name))

    def cssElement(self, selector, keys):
        ret = selector
        ret += " {\n"
        for key in keys:
            ret += "\t" + key + ": " + keys[key] + ";\n"
        ret += "}\n\n"
        return ret

    async def index(self, request: Request):
        if not self._coord.enabled():
            template = "index.jinja2"
            context = {
                **self.base_context(),
                'showOpenDriveLink': True
            }
        else:
            template = "working.jinja2"
            context = {
                **self.base_context(),
                'showOpenDriveLink': True,
                'navBarTitle': 'Backups'
            }
        response = aiohttp_jinja2.render_template(template,
                                                  request,
                                                  context)
        response.headers['cache-control'] = 'no-store'
        return response

    @aiohttp_jinja2.template('privacy_policy.jinja2')
    async def pp(self, request: Request):
        return self.base_context()

    @aiohttp_jinja2.template('terms_of_service.jinja2')
    async def tos(self, request: Request):
        return self.base_context()

    async def favicon(self, request: Request):
        return web.FileResponse(abspath(join(__file__, "..", "..", "static", "images", "favicon.png")))

    @aiohttp_jinja2.template('index.jinja2')
    async def reauthenticate(self, request: Request) -> Any:
        return {
            **self.base_context(),
            'showOpenDriveLink': True
        }


@web.middleware
class HomeAssistantLoginAuth():
    def __init__(self, time, harequests):
        self._time = time
        self._harequests = harequests
        self.auth_cache: Dict[str, Any] = {}
        self.realm = "Home Assistant Login"

    def parse_auth_header(self, request):
        auth_header = request.headers.get(hdrs.AUTHORIZATION)
        if not auth_header:
            return None
        try:
            auth = BasicAuth.decode(auth_header=auth_header)
        except ValueError:
            auth = None
        return auth

    async def authenticate(self, request):
        auth = self.parse_auth_header(request)
        return (auth is not None and await self.check_credentials(auth.login, auth.password))

    async def check_credentials(self, username, password):
        if username is None:
            raise ValueError('username is None')  # pragma: no cover

        if password is None:
            raise ValueError('password is None')  # pragma: no cover

        if username in self.auth_cache and self.auth_cache[username]['password'] == password and self.auth_cache[username]['timeout'] > self._time.now():
            return True
        try:
            await self._harequests.auth(username, password)
            self.auth_cache[username] = {'password': password, 'timeout': (
                self._time.now() + timedelta(minutes=10))}
            return True
        except Exception as e:
            logger.printException(e)
            return False

    def challenge(self):
        return web.Response(
            body=b'', status=401, reason='UNAUTHORIZED',
            headers={
                hdrs.WWW_AUTHENTICATE: 'Basic realm="%s"' % self.realm,
                hdrs.CONTENT_TYPE: 'text/html; charset=utf-8',
                hdrs.CONNECTION: 'keep-alive'
            }
        )

    async def __call__(self, request, handler):
        if await self.authenticate(request):
            return await handler(request)
        else:
            return self.challenge()
