# Released under the MIT License. See LICENSE for details.
#
# pylint: disable=too-many-lines
"""Functionality related to the high level state of the app."""

import io
import os
import time
import asyncio
from enum import Enum
from functools import partial
from typing import TYPE_CHECKING, override
from threading import RLock

from efro.threadpool import ThreadPoolExecutorEx
from efro.util import strip_exception_tracebacks

import _babase
from babase._language import LanguageSubsystem
from babase._locale import LocaleSubsystem
from babase._plugin import PluginSubsystem
from babase._meta import MetadataSubsystem
from babase._net import NetworkSubsystem
from babase._workspace import WorkspaceSubsystem
from babase._assetsubsystem import AssetSubsystem
from babase._appcomponent import AppComponentSubsystem
from babase._appmodeselector import AppModeSelector
from babase._appintent import AppIntentDefault, AppIntentExec
from babase._stringedit import StringEditSubsystem
from babase._devconsole import DevConsoleSubsystem
from babase._analytics import AnalyticsSubsystem
from babase._appconfig import AppConfig
from babase._logging import lifecyclelog, balog, applog
from babase._gc import GarbageCollectionSubsystem

if TYPE_CHECKING:
    from typing import Any, Callable, Coroutine, Generator, Awaitable
    from concurrent.futures import Future

    import babase
    from babase import AppIntent, AppMode, AppSubsystem
    from babase._apputils import AppHealthSubsystem

    # __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
    # This section generated by batools.appmodule; do not edit.

    from baclassic import ClassicAppSubsystem
    from baplus import PlusAppSubsystem
    from bauiv1 import UIV1AppSubsystem

    # __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__


class App:
    """High level Ballistica app functionality and state.

    Access the single shared instance of this class via the ``app`` attr
    available on various high level modules such as :mod:`babase`,
    :mod:`bauiv1`, and :mod:`bascenev1`.
    """

    # pylint: disable=too-many-public-methods

    # A few things defined as non-optional values but not actually
    # available until the app starts (so we need to predeclare them
    # here).

    #: Subsystem for keeping tabs on app health.
    health: AppHealthSubsystem

    #: Subsystem for network functionality.
    net: NetworkSubsystem

    #: How long we allow shutdown tasks to run before killing them.
    #: Currently the entire app hard-exits if shutdown takes 15 seconds,
    #: so we need to keep it under that. Staying above 10 should allow
    #: 10 second network timeouts to happen though.
    SHUTDOWN_TASK_TIMEOUT_SECONDS = 12

    #: Hard deadline for shutdown. The C++ side arms a suicide timer at
    #: this many seconds from the start of shutdown; if shutdown hasn't
    #: completed by then we're considered officially hung. On platforms
    #: where the Python faulthandler can write to fd 2, a traceback dump
    #: is also armed to fire at this same deadline (so we get a dump of
    #: every thread before we die) and the suicide timer is extended by
    #: :attr:`SHUTDOWN_FAULTHANDLER_RUNWAY_SECONDS` to give the dump
    #: time to finish writing.
    SHUTDOWN_SUICIDE_TIMEOUT_SECONDS: float = 15.0

    #: Extra time added to the suicide timer on faulthandler-capable
    #: platforms so the dump has room to complete before we die.
    SHUTDOWN_FAULTHANDLER_RUNWAY_SECONDS: float = 2.0

    def __init__(self) -> None:
        """(internal)

        Do not instantiate this class. You can access the single shared
        instance of it through various high level packages: 'babase.app',
        'bascenev1.app', 'bauiv1.app', etc.
        """

        # Hack for docs-generation: we can be imported with dummy modules
        # instead of our actual binary ones, but we don't function.
        if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
            return

        self._subsystems: list[AppSubsystem] = []
        self._subsystem_registration_ended = False

        #: Config values for the app.
        self.config: AppConfig = AppConfig(_babase.get_initial_app_config())
        _babase.set_app_config(self.config)

        #: Static environment values for the app.
        self.env: babase.Env = _babase.Env()

        #: Current app state.
        self.state: AppState = AppState.NOT_STARTED

        #: Default executor which can be used for misc background
        #: processing. It should also be passed to any additional asyncio
        #: loops we create so that everything shares the same single set
        #: of worker threads.
        self.threadpool: ThreadPoolExecutorEx = ThreadPoolExecutorEx(
            thread_name_prefix='baworker',
            initializer=self._thread_pool_thread_init,
        )

        #: Garbage collection related functionality.
        self.gc: GarbageCollectionSubsystem = self.register_subsystem(
            GarbageCollectionSubsystem()
        )

        #: Locale related functionality.
        self.locale: LocaleSubsystem = self.register_subsystem(
            LocaleSubsystem()
        )

        #: Language related functionality.
        self.lang: LanguageSubsystem = self.register_subsystem(
            LanguageSubsystem()
        )

        #: Subsystem for wrangling plugins.
        self.plugins: PluginSubsystem = self.register_subsystem(
            PluginSubsystem()
        )

        #: Subsystem for wrangling metadata.
        self.meta: MetadataSubsystem = MetadataSubsystem()

        #: Subsystem for acquiring + tracking downloadable asset packages.
        self.assets: AssetSubsystem = self.register_subsystem(AssetSubsystem())

        #: Subsystem for wrangling workspaces.
        self.workspaces: WorkspaceSubsystem = WorkspaceSubsystem()

        #: :meta private:
        self.components: AppComponentSubsystem = AppComponentSubsystem()

        #: Subsystem for wrangling text input from various sources.
        self.stringedit: StringEditSubsystem = StringEditSubsystem()

        #: Subsystem for wrangling the dev-console UI.
        self.devconsole: DevConsoleSubsystem = DevConsoleSubsystem()

        #: Subsystem for wrangling analytics.
        self.analytics: AnalyticsSubsystem = AnalyticsSubsystem()

        #: Incremented each time the app leaves the
        #: :attr:`~babase.AppState.SUSPENDED` state. This can be a simple
        #: way to determine if network data should be refreshed/etc.
        self.fg_state: int = 0

        self._native_bootstrapping_completed = False
        self._init_completed = False
        self._meta_scan_completed = False
        self._native_start_called = False
        self._native_suspended = False
        self._native_shutdown_called = False
        self._native_shutdown_complete_called = False
        self._fault_handler_armed = False
        self._initial_sign_in_completed = False
        self._called_on_initing = False
        self._called_on_loading = False
        self._called_on_running = False
        self._pending_apply_app_config = False
        self._asyncio_loop: asyncio.AbstractEventLoop | None = None
        self._asyncio_tasks: set[asyncio.Task] = set()
        self._pending_intent: AppIntent | None = None
        self._intent: AppIntent | None = None
        self._mode_selector: babase.AppModeSelector | None = None
        self._mode_instances: dict[type[AppMode], AppMode] = {}
        self._mode: AppMode | None = None
        self._shutdown_task: asyncio.Task[None] | None = None
        self._shutdown_tasks: list[Coroutine[None, None, None]] = [
            self._wait_for_shutdown_suppressions(),
            self._fade_and_shutdown_graphics(),
            self._fade_and_shutdown_audio(),
        ]
        self._pool_thread_count = 0

        # We hold a lock while lazy-loading our subsystem properties so
        # we don't spin up any subsystem more than once, but the lock is
        # recursive so that the subsystems can instantiate other
        # subsystems.
        self._subsystem_property_lock = RLock()
        self._subsystem_property_data: dict[str, AppSubsystem | bool] = {}

    @property
    def active(self) -> bool:
        """Whether the app is currently front and center.

        This will be False when the app is hidden, other activities
        are covering it, etc. (depending on the platform).
        """
        return _babase.app_is_active()

    @property
    def mode(self) -> AppMode:
        """The app's current mode.

        Raises :class:`ValueError` if no mode is set.
        """
        assert _babase.in_logic_thread()
        mode = self._mode
        if mode is None:
            raise ValueError('No app-mode set.')
        return mode

    @property
    def asyncio_loop(self) -> asyncio.AbstractEventLoop:
        """The logic thread's :mod:`asyncio` event-loop.

        This allows :mod:`asyncio` tasks to be run in the logic thread.

        Generally you should call
        :meth:`~babase.App.create_async_task()` to schedule async code
        to run instead of using this directly. That will handle
        retaining the task and logging errors automatically. Only
        schedule tasks onto ``asyncio_loop`` yourself when you intend to
        hold on to the returned task and await its results. Releasing
        the task reference can lead to subtle bugs such as unreported
        errors and garbage-collected tasks disappearing before their
        work is done.

        This loop is integrated directly into the logic thread's event
        loop, so :meth:`asyncio.get_running_loop()` returns it from
        anywhere on the logic thread, and work posted from other threads
        (such as ``run_in_executor`` completions) wakes the logic thread
        immediately.
        """
        assert self._asyncio_loop is not None
        return self._asyncio_loop

    def create_async_task[T](
        self, coro: Coroutine[Any, Any, T], *, name: str | None = None
    ) -> None:
        """Create a fully managed :mod:`asyncio` task.

        This will automatically retain and release a reference to the task
        and log any exceptions that occur in it. If you need to await a task
        or otherwise need more control, schedule a task directly using
        :attr:`asyncio_loop`.
        """
        assert _babase.in_logic_thread()

        # We hold a strong reference to the task until it is done.
        # Otherwise it is possible for it to be garbage collected and
        # disappear midway if the caller does not hold on to the
        # returned task, which seems like a great way to introduce
        # hard-to-track bugs.
        task = self.asyncio_loop.create_task(coro, name=name)
        self._asyncio_tasks.add(task)
        task.add_done_callback(self._on_task_done)

    @property
    def mode_selector(self) -> babase.AppModeSelector:
        """Controls which app-modes are used for handling given intents.

        Plugins can override this to change high level app behavior and
        spinoff projects can change the default implementation for the
        same effect.
        """
        if self._mode_selector is None:
            raise RuntimeError(
                'mode_selector cannot be used until the app reaches'
                ' the running state.'
            )
        return self._mode_selector

    @mode_selector.setter
    def mode_selector(self, selector: babase.AppModeSelector) -> None:
        self._mode_selector = selector

    def _on_task_done(self, task: Any) -> None:
        # Report any errors that occurred.
        assert isinstance(task, asyncio.Task)
        try:
            exc = task.exception()
            if exc is not None:
                balog.error(
                    "Error in async task '%s'.", task.get_name(), exc_info=exc
                )
                # We're done with the exception, so let's rip out its
                # tracebacks to try and avoid the need for cyclic
                # garbage collection.
                strip_exception_tracebacks(exc)

        except Exception:
            balog.exception('Error reporting async task error.')

        self._asyncio_tasks.remove(task)

    def _get_subsystem_property(
        self, ssname: str, create_call: Callable[[], AppSubsystem | None]
    ) -> AppSubsystem | None:

        # Quick-out: if a subsystem is present, just return it; no
        # locking necessary.
        val = self._subsystem_property_data.get(ssname)
        if val is not None:
            if val is False:
                # False means subsystem is confirmed as unavailable.
                return None
            if val is not True:
                # A subsystem has been set. Return it.
                return val

        # Anything else (no val present or val True) requires locking.
        with self._subsystem_property_lock:
            val = self._subsystem_property_data.get(ssname)
            if val is not None:
                if val is False:
                    # False means confirmed as not present.
                    return None
                if val is True:
                    # True means this property is already being loaded,
                    # and the fact that we're holding the lock means
                    # we're doing the loading, so this is a dependency
                    # loop. Not good.
                    raise RuntimeError(
                        f'Subsystem dependency loop detected for {ssname}'
                    )
                # Must be an instantiated subsystem. Noice.
                return val

            # Ok, there's nothing here for it. Instantiate and set it
            # while we hold the lock. Set a placeholder value of True
            # while we load so we can error if something we're loading
            # tries to recursively load us.
            self._subsystem_property_data[ssname] = True

            # Do our one attempt to create the singleton.
            val = create_call()
            self._subsystem_property_data[ssname] = (
                False if val is None else self.register_subsystem(val)
            )

        return val

    # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__
    # This section generated by batools.appmodule; do not edit.

    @property
    def classic(self) -> ClassicAppSubsystem | None:
        """Our classic subsystem (if available)."""
        return self._get_subsystem_property(
            'classic', self._create_classic_subsystem
        )  # type: ignore

    @staticmethod
    def _create_classic_subsystem() -> ClassicAppSubsystem | None:
        # pylint: disable=cyclic-import
        try:
            from baclassic import ClassicAppSubsystem

            return ClassicAppSubsystem()
        except ImportError:
            return None
        except Exception:
            balog.exception('Error importing baclassic.')
            return None

    @property
    def plus(self) -> PlusAppSubsystem | None:
        """Our plus subsystem (if available)."""
        return self._get_subsystem_property(
            'plus', self._create_plus_subsystem
        )  # type: ignore

    @staticmethod
    def _create_plus_subsystem() -> PlusAppSubsystem | None:
        # pylint: disable=cyclic-import
        try:
            from baplus import PlusAppSubsystem

            return PlusAppSubsystem()
        except ImportError:
            return None
        except Exception:
            balog.exception('Error importing baplus.')
            return None

    @property
    def ui_v1(self) -> UIV1AppSubsystem:
        """Our ui_v1 subsystem (always available)."""
        return self._get_subsystem_property(
            'ui_v1', self._create_ui_v1_subsystem
        )  # type: ignore

    @staticmethod
    def _create_ui_v1_subsystem() -> UIV1AppSubsystem:
        # pylint: disable=cyclic-import

        from bauiv1 import UIV1AppSubsystem

        return UIV1AppSubsystem()

    # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__

    @property
    def shutting_down(self) -> bool:
        """Whether the app has begun (or completed) shutting down.

        Becomes True once the app reaches
        :attr:`~AppState.SHUTTING_DOWN` and remains True through
        :attr:`~AppState.SHUTDOWN_COMPLETE`. Useful for long-running
        async work that should bow out quietly instead of erroring when
        app-level facilities (the threadpool, network, etc.) start
        getting torn down out from under it.
        """
        return self.state in (
            AppState.SHUTTING_DOWN,
            AppState.SHUTDOWN_COMPLETE,
        )

    def register_subsystem[T: AppSubsystem](self, subsystem: T) -> T:
        """Register an :class:`~babase.AppSubsystem` instance with the app.

        Facilitates the subsystem receiving state callbacks, etc.

        Note that subsystems can only be registered up until the app
        dispatches its launch intent -- which is to say, any time before
        or during a plugin's :meth:`~babase.Plugin.on_app_running()`
        call, but not after.

        Returns the passed object for convenience in assigning it to an
        attr/etc.
        """

        # Registration closes once plugins have been brought up (see
        # App._run_plugin_phase). This ensures that all subsystems
        # receive a consistent set of callbacks starting with
        # on_app_running().

        if self._subsystem_registration_ended:
            raise RuntimeError(
                'Subsystems can no longer be registered at this point.'
            )
        assert not any(s is subsystem for s in self._subsystems)
        self._subsystems.append(subsystem)
        return subsystem

    def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None:
        """Add a task to be run on app shutdown.

        All shutdown tasks will be run concurrently alongside a fade-out,
        so it is ok for them to take a moment or two to do their thing.

        If a shutdown task is still running after
        :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS`, however, it will be
        canceled.

        Code needing more exact control over its place in app shutdown
        can look into :func:`babase.atexit()`, (though this comes with
        some limitations as well).
        """
        if (
            self.state is AppState.SHUTTING_DOWN
            or self.state is AppState.SHUTDOWN_COMPLETE
        ):
            stname = self.state.name
            raise RuntimeError(
                f'Cannot add shutdown tasks with current state {stname}.'
            )
        self._shutdown_tasks.append(coro)

    def shutdown_fault_handler_arm(self) -> float:
        """Arm a Python traceback dump for shutdown diagnostics.

        Called from the C++ shutdown path, colocated with the
        suicide-timer arm. Returns the number of seconds C++ should use
        for its suicide timer: :attr:`SHUTDOWN_SUICIDE_TIMEOUT_SECONDS`
        if the faulthandler dump can't be armed (e.g. ``fd 2`` is not
        available), or that value plus
        :attr:`SHUTDOWN_FAULTHANDLER_RUNWAY_SECONDS` if it was armed
        successfully — the extra time gives the dump room to finish
        writing before the process is killed.

        Pairs with :meth:`shutdown_fault_handler_disarm`, which is
        called at the end of ``_pre_interpreter_shutdown``.
        """
        import faulthandler

        # Output goes directly to fd 2 rather than sys.stderr because
        # sys.stderr is replaced by a log-forwarding wrapper (no
        # fileno()) in headless builds; fd 2 is the raw stderr, which
        # for headless under basn lands in the task output stream. Only
        # arm if fd 2 is actually open; on environments without a
        # stderr attached (Windows GUI with no console, services run
        # with 2>/dev/null, some embedded setups) the dump would
        # silently write to a closed fd, so skip the diagnostic in
        # those cases and let the C++ suicide timer handle the exit on
        # its own.
        try:
            os.fstat(2)
        except OSError:
            return self.SHUTDOWN_SUICIDE_TIMEOUT_SECONDS
        # Flip the armed flag before arming so that if something goes
        # wrong mid-arm a subsequent disarm still cancels the timer.
        self._fault_handler_armed = True
        try:
            faulthandler.dump_traceback_later(
                self.SHUTDOWN_SUICIDE_TIMEOUT_SECONDS, exit=False, file=2
            )
        except Exception:
            self._fault_handler_armed = False
            return self.SHUTDOWN_SUICIDE_TIMEOUT_SECONDS
        # Announce the arm so later diagnostics can tell "shutdown
        # legitimately took ~15s" from "shutdown got stuck" — if the
        # disarm INFO doesn't appear before the faulthandler dump, the
        # hang is somewhere between here and ``_pre_interpreter_shutdown``
        # finishing.
        lifecyclelog.info(
            'suicide watchdog armed; dump in %.0fs',
            self.SHUTDOWN_SUICIDE_TIMEOUT_SECONDS,
        )
        return (
            self.SHUTDOWN_SUICIDE_TIMEOUT_SECONDS
            + self.SHUTDOWN_FAULTHANDLER_RUNWAY_SECONDS
        )

    def shutdown_fault_handler_disarm(self) -> None:
        """Cancel the shutdown faulthandler dump armed earlier.

        Safe to call even if :meth:`shutdown_fault_handler_arm` was
        never called or didn't arm anything — in that case it's a
        no-op.
        """
        if not self._fault_handler_armed:
            return
        self._fault_handler_armed = False
        import faulthandler

        faulthandler.cancel_dump_traceback_later()

    def _pre_interpreter_shutdown(self) -> None:
        """Called just before interpreter is finalized."""
        import gc
        from babase._env import interpreter_shutdown_sanity_checks

        # Bracket with timing logs so a slow tail-end shutdown is
        # distinguishable from a true hang: if the faulthandler dump
        # beats the "end" line, we're stuck inside this function.
        start_time = time.monotonic()
        lifecyclelog.info('pre-interpreter-shutdown begin')
        try:
            # Spin down connection pools or whatever else used for
            # networking.
            self.net.pre_interpreter_shutdown()

            # Run a last round of cyclic garbage collection - mostly so
            # we keep ourselves aware of reference cycles that need
            # cleaning up. This has been observed eating the entire
            # 15s watchdog budget in the wild, so time it explicitly.
            #
            # When running under basn, add two extra diagnostics around
            # this collect:
            #
            #   1. ``gc.DEBUG_STATS`` for the duration of this single
            #      collect. The CPython GC prints per-generation
            #      progress to stderr ("gc: collecting generation N..."
            #      / "gc: done, M unreachable, ..."), so a hang in
            #      gen-2 leaves us with the gen-0/gen-1 trace and a
            #      missing gen-2 "done" line — pinpoint signal for the
            #      stuck phase. Output is minimal in a healthy run.
            #
            #   2. ``len(gc.get_objects())`` snapshot just before the
            #      collect. Tells us how many tracked objects the
            #      collect was about to walk — if a hang fires later,
            #      a large number here is itself signal that the heap
            #      is bigger than expected.
            #
            # Order matters: enable DEBUG_STATS *before* the snapshot
            # so that even if anything between here and the collect
            # were to stall, the gen-N progress lines still fire when
            # the collect eventually runs. An earlier version of this
            # diagnostic also built a top-10 type histogram via
            # ``Counter`` — we observed that itself eat the entire
            # post-shutdown-begin tail-end on a real prod node (heap
            # large enough that walking it took >4s), so the histogram
            # is dropped pending a cheaper way to capture it. The
            # count alone tells us "the heap is huge" which is the
            # main thing we care about for now.
            #
            # Both gated to ``BASNLOG`` so interactive client runs
            # (including dev sessions) stay quiet and per-run cost is
            # zero outside basn-hosted games.
            basnlog = os.environ.get('BASNLOG') == '1'
            if basnlog:
                prev_debug = gc.get_debug()
                gc.set_debug(prev_debug | gc.DEBUG_STATS)
                lifecyclelog.info(
                    'pre-interpreter-shutdown pre-final-gc:'
                    ' %d tracked objects.',
                    len(gc.get_objects()),
                )

            gc_start = time.monotonic()
            try:
                self.gc.collect(force=True)
            finally:
                if basnlog:
                    gc.set_debug(prev_debug)
            lifecyclelog.info(
                'pre-interpreter-shutdown final gc.collect took %.2fs',
                time.monotonic() - gc_start,
            )

            # Turn off any garbage-collector debugging or we'll get a
            # huge dump of stuff as Python is tearing itself down, which
            # we don't care about.
            if gc.get_debug() != 0:
                gc.set_debug(0)
                # Clear garbage or else we get warnings about
                # uncollectable objects if we've been running with
                # gc.DEBUG_SAVEALL.
                gc.garbage.clear()

            # Finish up anything the threadpool is working on and kill
            # its threads.
            self.threadpool.shutdown()

            # General sanity checks for lingering threads/etc.
            interpreter_shutdown_sanity_checks()
        finally:
            lifecyclelog.info(
                'pre-interpreter-shutdown end (took %.2fs)',
                time.monotonic() - start_time,
            )
            # Cancel the shutdown faulthandler dump armed by the C++
            # side alongside the suicide timer. If anything above
            # raises, we still want to cancel so a slow interpreter
            # finalize doesn't get a spurious dump.
            self.shutdown_fault_handler_disarm()

    def run(self) -> None:
        """Run the app to completion.

        Note that this only works on builds/runs where Ballistica is
        managing its own event loop.
        """
        _babase.run_app()

    def set_intent(self, intent: AppIntent) -> None:
        """Set the intent for the app.

        Intent defines what the app is trying to do at a given time.
        This call is asynchronous; the intent switch will happen in the
        logic thread in the near future. If this is called repeatedly
        before the change takes place, the final intent to be set will
        be used.
        """

        # Mark this one as pending. We do this synchronously so that the
        # last one marked actually takes effect if there is overlap
        # (doing this in the bg thread could result in race conditions).
        self._pending_intent = intent

        # Do the actual work of calcing our app-mode/etc. in a bg thread
        # since it may block for a moment to load modules/etc.
        self.threadpool.submit_no_wait(self._set_intent, intent)

    def push_apply_app_config(self) -> None:
        """Internal. Use :meth:`babase.AppConfig.apply()`.

        :meta private:
        """
        # To be safe, let's run this by itself in the event loop. This
        # avoids potential trouble if this gets called mid-draw or
        # something like that.
        self._pending_apply_app_config = True
        _babase.pushcall(self._apply_app_config, raw=True)

    def on_native_start(self) -> None:
        """Called by the native layer when the app is being started.

        :meta private:
        """
        assert _babase.in_logic_thread()
        assert not self._native_start_called
        self._native_start_called = True
        self._update_state()

    def on_native_bootstrapping_complete(self) -> None:
        """Called by the native layer once its ready to rock.

        :meta private:
        """
        assert _babase.in_logic_thread()
        assert not self._native_bootstrapping_completed
        self._native_bootstrapping_completed = True

        self._update_state()

    def on_native_suspend(self) -> None:
        """Called by the native layer when the app is suspended.

        :meta private:
        """
        assert _babase.in_logic_thread()
        assert not self._native_suspended  # Should avoid redundant calls.
        self._native_suspended = True
        self._update_state()

    def on_native_unsuspend(self) -> None:
        """Called by the native layer when the app suspension ends.

        :meta private:
        """
        assert _babase.in_logic_thread()
        assert self._native_suspended  # Should avoid redundant calls.
        self._native_suspended = False
        self._update_state()

    def on_native_shutdown(self) -> None:
        """Called by the native layer when the app starts shutting down.

        :meta private:
        """
        assert _babase.in_logic_thread()
        self._native_shutdown_called = True
        self._update_state()

    def on_native_shutdown_complete(self) -> None:
        """Called by the native layer when the app is done shutting down.

        :meta private:
        """
        assert _babase.in_logic_thread()
        self._native_shutdown_complete_called = True
        self._update_state()

    def on_native_active_changed(self) -> None:
        """Called by the native layer when the app active state changes.

        :meta private:
        """
        assert _babase.in_logic_thread()
        if self._mode is not None:
            self._mode.on_app_active_changed()

    def handle_deep_link(self, url: str) -> None:
        """Handle a deep link URL."""
        from babase import builtinassets

        assert _babase.in_logic_thread()

        appname = _babase.appname()
        if url.startswith(f'{appname}://code/'):
            code = url.replace(f'{appname}://code/', '')
            if self.classic is not None:
                self.classic.accounts.add_pending_promo_code(code)
        else:
            try:
                _babase.screenmessage(
                    builtinassets.strings.ui.error, color=(1, 0, 0)
                )
                builtinassets.audio.error.get().play()
            except ImportError:
                pass

    def on_initial_sign_in_complete(self) -> None:
        """Called when initial sign-in (or lack thereof) completes.

        This normally gets called by the plus subsystem. The
        initial-sign-in process may include tasks such as syncing
        account workspaces or other data so it may take a substantial
        amount of time.

        :meta private:
        """
        assert _babase.in_logic_thread()
        assert not self._initial_sign_in_completed

        lifecyclelog.info('initial-sign-in complete')

        # Tell meta it can start scanning extra stuff that just showed
        # up (namely account workspaces).
        self.meta.start_extra_scan()

        self._initial_sign_in_completed = True
        self._update_state()

    def set_ui_scale(self, scale: babase.UIScale) -> None:
        """Change ui-scale on the fly.

        Currently this is mainly for testing/debugging and will not be
        called as part of normal app operation, though this may change
        in the future.

        :meta private:
        """
        assert _babase.in_logic_thread()

        # Apply to the native layer.
        _babase.set_ui_scale(scale.name.lower())

        # Inform all subsystems that something screen-related has
        # changed. Operate on a copy of the list here because this can
        # be called while subsystems are still being added (a ui-scale
        # change during construct-mode bring-up, for instance).
        for subsystem in self._subsystems.copy():
            try:
                subsystem.on_ui_scale_change()
            except Exception:
                balog.exception(
                    'Error in on_ui_scale_change() for subsystem %s.', subsystem
                )

    def on_screen_size_change(self) -> None:
        """Screen size has changed.

        :meta private:
        """

        # Inform all app subsystems in the same order they were inited.
        # Operate on a copy of the list here because this can be called
        # while subsystems are still being added.
        for subsystem in self._subsystems.copy():
            try:
                subsystem.on_screen_size_change()
            except Exception:
                balog.exception(
                    'Error in on_screen_size_change() for subsystem %s.',
                    subsystem,
                )

    def _set_intent(self, intent: AppIntent) -> None:
        from babase._appmode import AppMode

        # This should be happening in a bg thread.
        assert not _babase.in_logic_thread()
        try:
            # Ask the selector what app-mode to use for this intent.
            if self.mode_selector is None:
                raise RuntimeError('No AppModeSelector set.')

            modetype: type[AppMode] | None

            # Special case - for testing we may force a specific
            # app-mode to handle this intent instead of going through our
            # usual selector.
            forced_mode_type = getattr(intent, '_force_app_mode_handler', None)
            if isinstance(forced_mode_type, type) and issubclass(
                forced_mode_type, AppMode
            ):
                modetype = forced_mode_type
            else:
                modetype = self.mode_selector.app_mode_for_intent(intent)

            # NOTE: Since intents are somewhat high level things,
            # perhaps we should do some universal thing like a
            # screenmessage saying 'The app cannot handle the request'
            # on failure.

            if modetype is None:
                raise RuntimeError(
                    f'No app-mode found to handle app-intent'
                    f' type {type(intent)}.'
                )

            # Make sure the app-mode the selector gave us *actually*
            # supports the intent.
            if not modetype.can_handle_intent(intent):
                raise RuntimeError(
                    f'Intent {intent} cannot be handled by AppMode type'
                    f' {modetype} (selector {self.mode_selector}'
                    f' incorrectly thinks that it can be).'
                )

            # Ok; seems legit. Now instantiate the mode if necessary and
            # kick back to the logic thread to apply.
            mode = self._mode_instances.get(modetype)
            if mode is None:
                self._mode_instances[modetype] = mode = modetype()
            _babase.pushcall(
                partial(self._apply_intent, intent, mode),
                from_other_thread=True,
            )
        except Exception:
            balog.exception('Error setting app intent to %s.', intent)
            _babase.pushcall(
                partial(self._display_set_intent_error, intent),
                from_other_thread=True,
            )

    def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None:
        assert _babase.in_logic_thread()

        # ONLY apply this intent if it is still the most recent one
        # submitted.
        if intent is not self._pending_intent:
            return

        # If the app-mode for this intent is different than the active
        # one, switch modes.
        if type(mode) is not type(self._mode):
            if self._mode is None:
                is_initial_mode = True
            else:
                is_initial_mode = False
                try:
                    self._mode.on_deactivate()
                except Exception:
                    balog.exception(
                        'Error deactivating app-mode %s.', self._mode
                    )

            # Reset all subsystems. Operate on a copy; registration is
            # normally closed by now, but an intent dispatched while
            # construct-mode is still sitting on a failed bring-up can
            # get here with it open.
            for subsystem in self._subsystems.copy():
                try:
                    subsystem.reset()
                except Exception:
                    balog.exception(
                        'Error in reset() for subsystem %s.', subsystem
                    )

            self._mode = mode
            try:
                mode.on_activate()
            except Exception:
                # Hmm; what should we do in this case?...
                balog.exception('Error activating app-mode %s.', mode)

            self._on_app_mode_activated()

            # Let the world know when we first have an app-mode; certain
            # app stuff such as input processing can proceed at that
            # point.
            if is_initial_mode:
                _babase.on_initial_app_mode_set()

        try:
            mode.handle_intent(intent)
        except Exception:
            balog.exception(
                'Error handling intent %s in app-mode %s.', intent, mode
            )

    def _on_app_mode_activated(self) -> None:
        """Tell subsystems an app-mode just became active."""
        # Operate on a copy here; construct-mode activates before
        # subsystem registration is closed off.
        for subsystem in self._subsystems.copy():
            try:
                subsystem.on_app_mode_activated()
            except Exception:
                balog.exception(
                    'Error in on_app_mode_activated() for subsystem %s.',
                    subsystem,
                )

    def _enter_construct_mode(self, intent: AppIntent) -> None:
        """Enter construct-mode directly as the boot-time bring-up gate.

        Construct-mode is not an intent handler and is never returned by
        the mode-selector, so we activate it here by hand — mirroring the
        mode-swap half of :meth:`_apply_intent`, minus intent handling. It
        readies the required asset-packages and then hands ``intent`` back
        to us via :meth:`on_construct_complete` once bring-up completes.
        """
        # pylint: disable=cyclic-import
        from babase._constructmode import ConstructAppMode

        assert _babase.in_logic_thread()

        # If a mode is somehow already active, don't disrupt it; just
        # dispatch normally and skip the construct-mode gate. Plugins
        # still need bringing up in that case (nothing else will do it),
        # even though they won't get the asset guarantees this gate
        # normally gives them -- hence the warning.
        if self._mode is not None:
            lifecyclelog.warning(
                'App-mode %s already active at construct-mode entry;'
                ' skipping the asset bring-up gate.',
                self._mode,
            )
            self._run_plugin_phase()
            self.set_intent(intent)
            return

        mode = ConstructAppMode(deferred_intent=intent)

        # Reset all subsystems, matching what _apply_intent does when a
        # mode becomes active. Operate on a copy; registration is still
        # open at this point (plugins have not loaded yet).
        for subsystem in self._subsystems.copy():
            try:
                subsystem.reset()
            except Exception:
                balog.exception('Error in reset() for subsystem %s.', subsystem)

        self._mode = mode
        try:
            mode.on_activate()
        except Exception:
            balog.exception('Error activating construct-mode %s.', mode)

        self._on_app_mode_activated()

        # First app-mode is now set; start the consoles / enable input.
        # (Under the server manager, the stdin reader itself holds off on
        # processing commands until a real intent-handling app-mode is
        # active — construct-mode's C++ app_mode stays EmptyAppMode — so
        # the wrapper's commands wait safely; see StdioConsole. Interactive
        # sessions can use stdin/dev-console during construct-mode.)
        _babase.on_initial_app_mode_set()

    def _display_set_intent_error(self, intent: AppIntent) -> None:
        """Show the *user* something went wrong setting an intent."""
        from babase import builtinassets

        del intent
        _babase.screenmessage(builtinassets.strings.ui.error, color=(1, 0, 0))
        builtinassets.audio.error.get().play()

    def _on_initing(self) -> None:
        """Called when the app enters the initing state.

        Here we can put together subsystems and other pieces for the
        app, but most things should not be doing any work yet.
        """
        # pylint: disable=cyclic-import
        from babase import _asyncio
        from babase import _appconfig
        from babase._apputils import AppHealthSubsystem
        from babase import _env

        assert _babase.in_logic_thread()

        # Since we're officially spinning up an app, add some sanity
        # checks to help make sure we do a clean exit at the end of it
        # (at least on monolithic builds). We add this before we make
        # any other on-initing calls that could result in thread
        # spinups/etc. so that any of their atexits will have fired
        # before this one.
        if self.env.monolithic_build:
            _babase.atexit(self._pre_interpreter_shutdown)

        _env.on_app_state_initing()

        self.net = NetworkSubsystem()
        self._asyncio_loop = _asyncio.setup_asyncio()
        self.health = self.register_subsystem(AppHealthSubsystem())

        # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
        # This section generated by batools.appmodule; do not edit.

        # Poke these attrs to create all our subsystems.
        _ = self.plus
        _ = self.classic
        _ = self.ui_v1

        # __FEATURESET_APP_SUBSYSTEM_CREATE_END__

        # We're a pretty short-lived state. This should flip us to
        # 'loading'.
        self._init_completed = True
        self._update_state()

    def _on_loading(self) -> None:
        """Called when we enter the loading state.

        At this point, all built-in pieces of the app should be in place
        and can start talking to each other and doing work. Though at a
        high level, the goal of the app at this point is only to sign in
        to initial accounts, download workspaces, and otherwise prepare
        itself to really 'run'.
        """
        assert _babase.in_logic_thread()
        lifecyclelog.info('on-loading begin')

        # Get meta-system scanning built-in stuff in the bg.
        self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete)

        # Inform all app subsystems in the same order they were inited.
        # Operate on a copy of the list here because subsystems can
        # still be added at this point.
        for subsystem in self._subsystems.copy():
            try:
                subsystem.on_app_loading()
            except Exception:
                balog.exception(
                    'Error in on_app_loading() for subsystem %s.', subsystem
                )

        # Normally plus tells us when initial sign-in is done. If plus
        # is not present, however, we just do it ourself so we can
        # proceed on to the running state.
        if self.plus is None:
            _babase.pushcall(self.on_initial_sign_in_complete)

        lifecyclelog.info('on-loading end')

    def _on_meta_scan_complete(self) -> None:
        """Called when meta-scan is done doing its thing."""
        assert _babase.in_logic_thread()
        lifecyclelog.info('meta-scan complete')

        # Now that we know what's out there, build our final plugin set.
        self.plugins.on_meta_scan_complete()

        assert not self._meta_scan_completed
        self._meta_scan_completed = True
        self._update_state()

    def _on_running(self) -> None:
        """Called when we enter the running state.

        At this point, all workspaces, initial accounts, etc. are in place
        and we can actually get started doing whatever we're gonna do.
        """
        assert _babase.in_logic_thread()
        lifecyclelog.info('on-running begin')

        # Let our native layer know.
        _babase.on_app_running()

        # Set a default app-mode-selector. Plugins wanting their own get
        # to replace this in their on_app_running call, which still lands
        # before any intent is dispatched (see on_construct_complete).
        if self._mode_selector is None:
            self._mode_selector = DefaultAppModeSelector()

        # Inform all app subsystems in the same order they were
        # registered. Operate on a copy here because subsystems can
        # still be added at this point; any that are get their
        # on_app_running call in on_construct_complete, which is also
        # where registration gets cut off.
        for subsystem in self._subsystems.copy():
            try:
                subsystem.on_app_running()
            except Exception:
                balog.exception(
                    'Error in on_app_running() for subsystem %s.', subsystem
                )

        # Determine the launch intent: an explicit 'exec' command if one
        # was provided, else our default thing — but only if nothing
        # has already driven an intent.
        exec_cmd = _babase.exec_arg()
        initial_intent: AppIntent | None
        if exec_cmd is not None:
            initial_intent = AppIntentExec(exec_cmd)
        elif self._pending_intent is None:
            initial_intent = AppIntentDefault()
        else:
            initial_intent = None

        # Bring required assets up in construct-mode first; it releases
        # this intent to the app-mode that actually handles it once
        # bring-up completes. (If something already drove an intent,
        # initial_intent is None and we leave that flow untouched.)
        if initial_intent is not None:
            self._enter_construct_mode(initial_intent)

        lifecyclelog.info('on-running end')

    def on_construct_complete(self, intent: AppIntent | None) -> None:
        """Called by construct-mode once required assets are in place.

        Loads plugins, closes off subsystem registration, and finally
        releases *intent* (the launch intent construct-mode has been
        holding) to the app-mode that handles it.

        Plugins load *here* -- rather than in the usual on_app_running
        subsystem pass -- because this is the only spot where both of
        the things they need are true at once:

        * Every asset-package the meta-scan found a ``# ba_meta require
          asset-package`` line for is resolved and registered, so a
          plugin can load whatever it likes without tripping the
          too-early-load gate (see
          :func:`babase._asset_packages.check_asset_package_load`).
          That covers packages declared by plugins themselves, since the
          user scripts dir is scanned regardless of which plugins wind
          up enabled.
        * No app-mode has been activated and no intent has been
          dispatched yet, so a plugin still gets to influence what
          happens next: replacing :attr:`mode_selector`, registering app
          subsystems, or driving its own intent via :meth:`set_intent`.

        A happy side effect of deferring the *load* and not merely the
        callback: a plugin's module is not imported until now either, so
        no plugin-authored code at all -- module top-level included --
        runs before assets are ready.

        :meta private:
        """
        assert _babase.in_logic_thread()

        self._run_plugin_phase()

        if intent is None:
            # Nothing to release; something drove an intent before
            # construct-mode even got going.
            return

        # If a plugin just drove its own intent, that one wins -- ours
        # would only stomp it. (_pending_intent stays None throughout
        # bring-up, since construct-mode holds our intent rather than
        # setting it, so a non-None value here can only be plugin-made.)
        if self._pending_intent is not None:
            lifecyclelog.info('Deferring to plugin-driven launch intent.')
            return

        self.set_intent(intent)

    def _run_plugin_phase(self) -> None:
        """Load plugins, then close off subsystem registration.

        Idempotent; only the first call does anything.
        """
        assert _babase.in_logic_thread()

        if self._subsystem_registration_ended:
            return

        # Registration only ever appends, so anything past this index
        # once plugins are done is a subsystem some plugin added. Those
        # missed the on_app_running pass in _on_running, so give them
        # theirs below.
        preexisting_count = len(self._subsystems)

        self.plugins.load_and_notify()

        for subsystem in self._subsystems[preexisting_count:]:
            try:
                subsystem.on_app_running()
            except Exception:
                balog.exception(
                    'Error in on_app_running() for subsystem %s.', subsystem
                )

        # Cut off new subsystem additions at this point. From here on
        # out everything sees a fixed set whose members have all had a
        # consistent run of callbacks starting with on_app_running().
        self._subsystem_registration_ended = True

    def _apply_app_config(self) -> None:
        assert _babase.in_logic_thread()

        lifecyclelog.info('apply-app-config')

        # If multiple apply calls have been made, only actually apply
        # once.
        if not self._pending_apply_app_config:
            return

        _pending_apply_app_config = False

        # Inform all app subsystems in the same order they were inited.
        # Operate on a copy here because subsystems may still be able to
        # be added at this point.
        for subsystem in self._subsystems.copy():
            try:
                subsystem.apply_app_config()
            except Exception:
                balog.exception(
                    'Error in apply_app_config() for subsystem %s.',
                    subsystem,
                )

        # Let the native layer do its thing.
        _babase.apply_app_config()

    def _update_state(self) -> None:
        # pylint: disable=too-many-branches
        assert _babase.in_logic_thread()

        # Shutdown-complete trumps absolutely all.
        if self._native_shutdown_complete_called:
            if self.state is not AppState.SHUTDOWN_COMPLETE:
                self.state = AppState.SHUTDOWN_COMPLETE
                lifecyclelog.info('app-state is now %s', self.state.name)
                self._on_shutdown_complete()

        # Shutdown trumps all. Though we can't start shutting down until
        # init is completed since we need our asyncio stuff to exist for
        # the shutdown process.
        elif self._native_shutdown_called and self._init_completed:
            # Entering shutdown state:
            if self.state is not AppState.SHUTTING_DOWN:
                self.state = AppState.SHUTTING_DOWN
                applog.info('Shutting down...')
                lifecyclelog.info('app-state is now %s', self.state.name)
                self._on_shutting_down()

        elif self._native_suspended:
            # Entering suspended state:
            if self.state is not AppState.SUSPENDED:
                self.state = AppState.SUSPENDED
                lifecyclelog.info('app-state is now %s', self.state.name)
                self._on_suspend()
        else:
            # Leaving suspended state:
            if self.state is AppState.SUSPENDED:
                self._on_unsuspend()

            # Entering or returning to running state
            if self._initial_sign_in_completed and self._meta_scan_completed:
                if self.state != AppState.RUNNING:
                    self.state = AppState.RUNNING
                    lifecyclelog.info('app-state is now %s', self.state.name)
                    if not self._called_on_running:
                        self._called_on_running = True
                        self._on_running()

            # Entering or returning to loading state:
            elif self._init_completed:
                if self.state is not AppState.LOADING:
                    self.state = AppState.LOADING
                    lifecyclelog.info('app-state is now %s', self.state.name)
                    if not self._called_on_loading:
                        self._called_on_loading = True
                        self._on_loading()

            # Entering or returning to initing state:
            elif self._native_bootstrapping_completed:
                if self.state is not AppState.INITING:
                    self.state = AppState.INITING
                    lifecyclelog.info('app-state is now %s', self.state.name)
                    if not self._called_on_initing:
                        self._called_on_initing = True
                        self._on_initing()

            # Entering or returning to native bootstrapping:
            elif self._native_start_called:
                if self.state is not AppState.NATIVE_BOOTSTRAPPING:
                    self.state = AppState.NATIVE_BOOTSTRAPPING
                    lifecyclelog.info('app-state is now %s', self.state.name)
            else:
                # Only logical possibility left is NOT_STARTED, in which
                # case we should not be getting called.
                balog.warning(
                    'App._update_state called while in %s state;'
                    ' should not happen.',
                    self.state.value,
                    stack_info=True,
                )

    async def _shutdown(self) -> None:

        _babase.lock_all_input()
        try:
            async with asyncio.TaskGroup() as task_group:
                for task_coro in self._shutdown_tasks:
                    # Note: Mypy currently complains if we don't take
                    # this return value, but we don't actually need to.
                    # https://github.com/python/mypy/issues/15036
                    _ = task_group.create_task(
                        self._run_shutdown_task(task_coro)
                    )
        except* Exception:
            balog.exception('Unexpected error(s) in shutdown.')

        # Note: ideally we should run this directly here, but currently
        # it does some legacy stuff which blocks, so running it here
        # gives us asyncio task-took-too-long warnings. If we can
        # convert those to nice graceful async tasks we should revert
        # this to a direct call.
        _babase.pushcall(_babase.complete_shutdown)

    async def _run_shutdown_task(
        self, coro: Coroutine[None, None, None]
    ) -> None:
        """Run a shutdown task; report errors and abort if taking too long."""

        task = asyncio.create_task(coro)

        # Use asyncio.wait (not wait_for) so that if we do time out we
        # still have an uncancelled Task we can call print_stack() on.
        # wait_for auto-cancels and awaits the task past the timeout,
        # which leaves print_stack returning 'No stack for <cancelled
        # Task ...>' by the time we'd try to use it.
        done, _pending = await asyncio.wait(
            [task], timeout=self.SHUTDOWN_TASK_TIMEOUT_SECONDS
        )
        if task not in done:
            # Task hung past our timeout. Snapshot its current
            # suspended stack so the log names a culprit even when
            # the faulthandler dump can't reach this phase (the
            # usual case: shutdown completes normally after the
            # cancel and _pre_interpreter_shutdown disarms the dump
            # before the 15s deadline).
            stack_buf = io.StringIO()
            try:
                task.print_stack(file=stack_buf)
            except Exception:  # pylint: disable=broad-exception-caught
                stack_buf.write('(failed to capture task stack)')
            stack_text = stack_buf.getvalue().rstrip() or '(empty)'
            balog.error(
                'Timed out waiting for shutdown task %s.'
                ' Current task stack:\n%s',
                coro,
                stack_text,
            )
            task.cancel()
            # Let the cancellation propagate briefly so the coroutine
            # unwinds cleanly; don't block shutdown indefinitely on a
            # task that may also be ignoring cancel.
            try:
                await asyncio.wait([task], timeout=1.0)
            except Exception:  # pylint: disable=broad-exception-caught
                pass
        elif (exc := task.exception()) is not None:
            # Go with full ugly stack trace for anything unexpected.
            balog.error('Error in shutdown task %s.', coro, exc_info=exc)

    def _on_suspend(self) -> None:
        """Called when the app goes to a suspended state."""
        assert _babase.in_logic_thread()

        # Suspend all app subsystems in the opposite order they were inited.
        for subsystem in reversed(self._subsystems):
            try:
                subsystem.on_app_suspend()
            except Exception:
                balog.exception(
                    'Error in on_app_suspend() for subsystem %s.', subsystem
                )

    def _on_unsuspend(self) -> None:
        """Called when unsuspending."""
        assert _babase.in_logic_thread()
        self.fg_state += 1

        # Unsuspend all app subsystems in the same order they were inited.
        for subsystem in self._subsystems:
            try:
                subsystem.on_app_unsuspend()
            except Exception:
                balog.exception(
                    'Error in on_app_unsuspend() for subsystem %s.', subsystem
                )

    def _on_shutting_down(self) -> None:
        """(internal)"""
        assert _babase.in_logic_thread()

        # Inform app subsystems that we're shutting down in the opposite
        # order they were inited.
        for subsystem in reversed(self._subsystems):
            try:
                subsystem.on_app_shutdown()
            except Exception:
                balog.exception(
                    'Error in on_app_shutdown() for subsystem %s.', subsystem
                )

        # Now kick off any async shutdown task(s).
        assert self._asyncio_loop is not None
        self._shutdown_task = self._asyncio_loop.create_task(self._shutdown())

    def _on_shutdown_complete(self) -> None:
        """(internal)"""
        assert _babase.in_logic_thread()

        # Deactivate any active app-mode. This allows things like saving
        # state to happen naturally without needing to handle
        # app-shutdown as a special case.
        if self._mode is not None:
            try:
                self._mode.on_deactivate()
            except Exception:
                balog.exception(
                    'Error deactivating app-mode %s at app shutdown.',
                    self._mode,
                )
            self._mode = None

        # Inform app subsystems that we're done shutting down in the opposite
        # order they were inited.
        for subsystem in reversed(self._subsystems):
            try:
                subsystem.on_app_shutdown_complete()
            except Exception:
                balog.exception(
                    'Error in on_app_shutdown_complete() for subsystem %s.',
                    subsystem,
                )

    async def _wait_for_shutdown_suppressions(self) -> None:

        # Spin and wait for anything blocking shutdown to complete.
        starttime = _babase.apptime()
        lifecyclelog.info('shutdown-suppress-wait begin')
        while _babase.shutdown_suppress_count() > 0:
            await asyncio.sleep(0.001)
        lifecyclelog.info('shutdown-suppress-wait end')
        duration = _babase.apptime() - starttime
        if duration > 1.0:
            balog.warning(
                'Shutdown-suppressions lasted longer than ideal '
                '(%.2f seconds).',
                duration,
            )

    async def _fade_and_shutdown_graphics(self) -> None:

        # Kick off a short fade and give it time to complete.
        lifecyclelog.info('fade-and-shutdown-graphics begin')
        fade_done = False

        starttime = time.monotonic()

        def _set_fade_done() -> None:
            nonlocal fade_done
            fade_done = True

        if _babase.app.env.gui:
            _babase.fade_screen(False, time=0.25, endcall=_set_fade_done)
        else:
            fade_done = True

        # Note: originally was just sleeping once for the fade duration,
        # but due to timing mismatches that could resulted in the game
        # freezing visually mid-fade to finish quitting. So now waiting
        # until the fade confirms that it is done.
        while not fade_done:
            await asyncio.sleep(0.03)
            # Fallback trigger in case fade never calls back.
            if time.monotonic() - starttime > 2.0:
                lifecyclelog.warning('fade_screen took too long; cutting off.')
                fade_done = True

        # Now tell the graphics system to go down and wait until it has
        # done so.
        _babase.graphics_shutdown_begin()
        while not _babase.graphics_shutdown_is_complete():
            await asyncio.sleep(0.01)
        lifecyclelog.info('fade-and-shutdown-graphics end')

    async def _fade_and_shutdown_audio(self) -> None:

        # Tell the audio system to go down and give it a bit of
        # time to do so gracefully.
        lifecyclelog.info('fade-and-shutdown-audio begin')
        _babase.audio_shutdown_begin()
        await asyncio.sleep(0.15)
        while not _babase.audio_shutdown_is_complete():
            await asyncio.sleep(0.01)
        lifecyclelog.info('fade-and-shutdown-audio end')

    def get_convenience_imports(self) -> dict[str, str | None]:
        """Return active convenience imports.

        This consists of a dict mapping module names to optional
        aliases. These are the modules that will be auto-imported into
        the REPL environment.

        This can be set in the config as 'Convenience Imports'. If it is
        not present there, defaults for the app are given.
        """
        cfg = self.config.get('Convenience Imports')
        if cfg is not None:
            if isinstance(cfg, dict) and all(
                isinstance(k, str) and isinstance(v, str | None)
                for k, v in cfg.items()
            ):
                return cfg
            balog.warning(
                'Ignoring invalid \'Convenience Imports\' app config value;'
                ' expected a dict of str -> str | None.'
            )

        # __GET_DEFAULT_CONVENIENCE_IMPORTS_BEGIN__
        # This section generated by batools.appmodule; do not edit.

        return {
            'bascenev1': 'bs',
            'bascenev1lib': 'bsl',
            'bauiv1': 'bui',
            'bauiv1lib': 'buil',
        }

        # __GET_DEFAULT_CONVENIENCE_IMPORTS_END__

    def _thread_pool_thread_init(self) -> None:
        # Help keep things clear in profiling tools/etc.
        self._pool_thread_count += 1
        _babase.set_thread_name(f'ballistica worker-{self._pool_thread_count}')


class AppState(Enum):
    """High level state the app can be in."""

    #: The app has not yet begun starting and should not be used in
    #: any way.
    NOT_STARTED = 0

    #: The native layer is spinning up its machinery (screens,
    #: renderers, etc.). Nothing should happen in the Python layer
    #: until this completes.
    NATIVE_BOOTSTRAPPING = 1

    #: Python app subsystems are being inited but should not yet
    #: interact or do any work.
    INITING = 2

    #: Python app subsystems are inited and interacting, but the app
    #: has not yet embarked on a high level course of action. It is
    #: doing initial account logins, workspace & asset downloads,
    #: etc.
    LOADING = 3

    #: All pieces are in place and the app is now doing its thing.
    RUNNING = 4

    #: Used on platforms such as mobile where the app basically needs
    #: to shut down while backgrounded. In this state, all event
    #: loops are suspended and all graphics and audio must cease
    #: completely. Be aware that the suspended state can be entered
    #: from any other state including :attr:`NATIVE_BOOTSTRAPPING` and
    #: :attr:`SHUTTING_DOWN`.
    SUSPENDED = 5

    #: The app is shutting down. This process may involve sending
    #: network messages or other things that can take up to a few
    #: seconds, so ideally graphics and audio should remain
    #: functional (with fades or spinners or whatever to show
    #: something is happening).
    SHUTTING_DOWN = 6

    #: The app has completed shutdown. Any code running here should
    #: be basically immediate.
    SHUTDOWN_COMPLETE = 7


class DefaultAppModeSelector(AppModeSelector):
    """Selects an :class:`AppMode` to handle an :class:`AppIntent`.

    This default version is generated by the project updater based
    on the 'default_app_modes' value in the projectconfig.

    It is also possible to modify app mode selection behavior by
    setting the app's :attr:`~babase.App.mode_selector` to an
    instance of a custom :class:`~babase.AppModeSelector` subclass.
    This is a good way to go if you are modifying app behavior
    dynamically via a :class:`~babase.Plugin` instead of statically
    in a spinoff project.
    """

    @override
    def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode] | None:
        # pylint: disable=cyclic-import

        # __DEFAULT_APP_MODE_SELECTION_BEGIN__
        # This section generated by batools.appmodule; do not edit.

        # Ask our default app modes to handle it.
        # (generated from 'default_app_modes' in projectconfig).
        import baclassic
        import babase

        for appmode in [
            baclassic.ClassicAppMode,
            babase.EmptyAppMode,
        ]:
            if appmode.can_handle_intent(intent):
                return appmode

        return None

        # __DEFAULT_APP_MODE_SELECTION_END__
