# Released under the MIT License. See LICENSE for details.
#
"""Generates a dummy _ba.py based on the game's real internal _ba module.

This allows us to use code introspection tools such as pylint from outside
the game, and also allows external scripts to import game scripts successfully
(though with limited functionality)
"""

from __future__ import annotations

import os
import sys
import textwrap
import subprocess
from typing import TYPE_CHECKING

from efro.error import CleanError
from efro.terminal import Clr
from efrotools import get_files_hash

if TYPE_CHECKING:
    from types import ModuleType
    from typing import Sequence, Any, Tuple, List
    from batools.docs import AttributeInfo


def _get_varying_func_info(sig_in: str) -> Tuple[str, str]:
    """Return overloaded signatures and return statements for varying funcs."""
    returns = 'return None'
    if sig_in == ('getdelegate(self, type: Type,'
                  ' doraise: bool = False) -> <varies>'):
        sig = ('# Show that ur return type varies based on "doraise" value:\n'
               '@overload\n'
               'def getdelegate(self, type: Type[_T],'
               ' doraise: Literal[False] = False) -> Optional[_T]:\n'
               '    ...\n'
               '\n'
               '@overload\n'
               'def getdelegate(self, type: Type[_T],'
               ' doraise: Literal[True]) -> _T:\n'
               '    ...\n'
               '\n'
               'def getdelegate(self, type: Any,'
               ' doraise: bool = False) -> Any:\n')
    elif sig_in == ('getinputdevice(name: str, unique_id:'
                    ' str, doraise: bool = True)   -> <varies>'):
        sig = ('# Show that our return type varies based on "doraise" value:\n'
               '@overload\n'
               'def getinputdevice(name: str, unique_id: str,'
               ' doraise: Literal[True] = True) -> ba.InputDevice:\n'
               '    ...\n'
               '\n'
               '@overload\n'
               'def getinputdevice(name: str, unique_id: str,'
               ' doraise: Literal[False]) -> Optional[ba.InputDevice]:\n'
               '    ...\n'
               '\n'
               'def getinputdevice(name: str, unique_id: str,'
               ' doraise: bool=True) -> Any:')
    elif sig_in == ('time(timetype: ba.TimeType = TimeType.SIM,'
                    '   timeformat: ba.TimeFormat = TimeFormat.SECONDS)'
                    '   -> <varies>'):
        sig = (
            '# Overloads to return a type based on requested format.\n'
            '\n'
            '@overload\n'
            'def time(timetype: ba.TimeType = TimeType.SIM,\n'
            '        timeformat: Literal[TimeFormat.SECONDS]'
            ' = TimeFormat.SECONDS) -> float:\n'
            '    ...\n'
            '\n'
            '# This "*"'
            ' keyword-only hack lets us accept 1 arg'
            ' (timeformat=MILLISECS) forms.\n'
            '@overload\n'
            'def time(timetype: ba.TimeType = TimeType.SIM, *,\n'
            '         timeformat: Literal[TimeFormat.MILLISECONDS]) -> int:\n'
            '    ...\n'
            '\n'
            '@overload\n'
            'def time(timetype: ba.TimeType,\n'
            '         timeformat: Literal[TimeFormat.MILLISECONDS]) -> int:\n'
            '    ...\n'
            '\n'
            '\n'
            'def time(timetype: ba.TimeType = TimeType.SIM,\n'
            '         timeformat: ba.TimeFormat = TimeFormat.SECONDS)'
            ' -> Any:\n')
    elif sig_in == 'getactivity(doraise: bool = True) -> <varies>':
        sig = (
            '# Show that our return type varies based on "doraise" value:\n'
            '@overload\n'
            'def getactivity(doraise: Literal[True] = True) -> ba.Activity:\n'
            '    ...\n'
            '\n'
            '\n'
            '@overload\n'
            'def getactivity(doraise: Literal[False])'
            ' -> Optional[ba.Activity]:\n'
            '    ...\n'
            '\n'
            '\n'
            'def getactivity(doraise: bool = True) -> Optional[ba.Activity]:')
    elif sig_in == 'getsession(doraise: bool = True) -> <varies>':
        sig = ('# Show that our return type varies based on "doraise" value:\n'
               '@overload\n'
               'def getsession(doraise: Literal[True] = True) -> ba.Session:\n'
               '    ...\n'
               '\n'
               '\n'
               '@overload\n'
               'def getsession(doraise: Literal[False])'
               ' -> Optional[ba.Session]:\n'
               '    ...\n'
               '\n'
               '\n'
               'def getsession(doraise: bool = True) -> Optional[ba.Session]:')

    else:
        raise RuntimeError(
            f'Unimplemented varying func: {Clr.RED}{sig_in}{Clr.RST}')
    return sig, returns


def _writefuncs(parent: Any, funcnames: Sequence[str], indent: int,
                spacing: int, as_method: bool) -> str:
    # pylint: disable=too-many-branches
    # pylint: disable=too-many-statements
    # pylint: disable=too-many-locals
    out = ''
    spcstr = '\n' * spacing
    indstr = ' ' * indent
    for funcname in funcnames:

        # Skip some that are not in public builds.
        if funcname in {'master_hash_dump'}:
            continue

        func = getattr(parent, funcname)
        docstr = func.__doc__

        # We expect an empty line and take everything before that to be
        # the function signature.
        if '\n\n' not in docstr:
            raise Exception(f'docstr missing empty line: {func}')
        sig = docstr.split('\n\n')[0].replace('\n', ' ').strip()

        # Sanity check - make sure name is in the sig.
        if funcname + '(' not in sig:
            raise Exception(f'func name not found in sig for {funcname}')

        # If these are methods, add self.
        if as_method:
            if funcname + '()' in sig:
                sig = sig.replace(funcname + '()', funcname + '(self)')
            else:
                sig = sig.replace(funcname + '(', funcname + '(self, ')

        # We expect sig to have a -> denoting return type.
        if ' -> ' not in sig:
            raise Exception(f'no "->" found in docstr for {funcname}')
        returns = sig.split('->')[-1].strip()

        # Some functions don't have simple signatures; we need to hard-code
        # those here with overloads and whatnot.
        if '<varies>' in sig:
            overloadsigs, returnstr = _get_varying_func_info(sig)
            defsline = textwrap.indent(overloadsigs, indstr)
        else:
            defsline = f'{indstr}def {sig}:\n'

            # Types can be strings for forward-declaration cases.
            if ((returns[0] == "'" and returns[-1] == "'")
                    or (returns[0] == '"' and returns[-1] == '"')):
                returns = returns[1:-1]
            if returns == 'None':
                returnstr = 'return None'
            elif returns == 'ba.Lstr':
                returnstr = ('import ba  # pylint: disable=cyclic-import\n'
                             "return ba.Lstr(value='')")
            elif returns in ('ba.Activity', 'Optional[ba.Activity]'):
                returnstr = (
                    'import ba  # pylint: disable=cyclic-import\nreturn ' +
                    'ba.Activity(settings={})')
            elif returns in ('ba.Session', 'Optional[ba.Session]'):
                returnstr = (
                    'import ba  # pylint: disable=cyclic-import\nreturn ' +
                    'ba.Session([])')
            elif returns == 'Optional[ba.SessionPlayer]':
                returnstr = ('import ba  # pylint: disable=cyclic-import\n'
                             'return ba.SessionPlayer()')
            elif returns == 'Optional[ba.Player]':
                returnstr = ('import ba  # pylint: disable=cyclic-import\n'
                             'return ba.Player()')
            elif returns.startswith('ba.'):

                # We cant import ba at module level so let's
                # do it within funcs as needed.
                returnstr = (
                    'import ba  # pylint: disable=cyclic-import\nreturn ' +
                    returns + '()')

                # we'll also have to go without a sig
                # (could use the equivalent _ba class perhaps)
                # sig = sig.split('->')[0]

            elif returns in ['object', 'Any']:

                # We use 'object' when we mean "can vary"
                # don't want pylint making assumptions in this case.
                returnstr = 'return _uninferrable()'
            elif returns == 'Tuple[float, float]':
                returnstr = 'return (0.0, 0.0)'
            elif returns == 'Optional[str]':
                returnstr = "return ''"
            elif returns == 'Tuple[float, float, float, float]':
                returnstr = 'return (0.0, 0.0, 0.0, 0.0)'
            elif returns == 'Optional[ba.Widget]':
                returnstr = 'return Widget()'
            elif returns == 'Optional[ba.InputDevice]':
                returnstr = 'return InputDevice()'
            elif returns == 'List[ba.Widget]':
                returnstr = 'return [Widget()]'
            elif returns == 'Tuple[float, ...]':
                returnstr = 'return (0.0, 0.0, 0.0)'
            elif returns == 'List[str]':
                returnstr = "return ['blah', 'blah2']"
            elif returns == 'Union[float, int]':
                returnstr = 'return 0.0'
            elif returns == 'Dict[str, Any]':
                returnstr = "return {'foo': 'bar'}"
            elif returns in ('Optional[Tuple[int, int]]', 'Tuple[int, int]'):
                returnstr = 'return (0, 0)'
            elif returns == 'List[Dict[str, Any]]':
                returnstr = "return [{'foo': 'bar'}]"
            elif returns in [
                    'session.Session', 'team.Team', '_app.App',
                    'appconfig.AppConfig'
            ]:
                returnstr = ('from ba import ' + returns.split('.')[0] +
                             '; return ' + returns + '()')
            elif returns in [
                    'bool', 'str', 'int', 'list', 'dict', 'tuple', 'float',
                    'SessionData', 'ActivityData', 'Player', 'SessionPlayer',
                    'InputDevice', 'Sound', 'Texture', 'Model', 'CollideModel',
                    'team.Team', 'Vec3', 'Widget', 'Node'
            ]:
                returnstr = 'return ' + returns + '()'
            else:
                raise Exception(
                    f'unknown returns value: {returns} for {funcname}')
        returnspc = indstr + '    '
        returnstr = ('\n' + returnspc).join(returnstr.strip().splitlines())
        docstr_out = _formatdoc(docstr, indent + 4)
        out += spcstr + defsline + docstr_out + f'{returnspc}{returnstr}\n'
    return out


def _special_class_cases(classname: str) -> str:
    out = ''

    # Special case: define a fallback attr getter with a random
    # return type in cases where our class handles attrs itself.
    if classname in ['Vec3']:
        out += ('\n'
                '    # pylint: disable=function-redefined\n'
                '\n'
                '    @overload\n'
                '    def __init__(self) -> None:\n'
                '        pass\n'
                '\n'
                '    @overload\n'
                '    def __init__(self, value: float):\n'
                '        pass\n'
                '\n'
                '    @overload\n'
                '    def __init__(self, values: Sequence[float]):\n'
                '        pass\n'
                '\n'
                '    @overload\n'
                '    def __init__(self, x: float, y: float, z: float):\n'
                '        pass\n'
                '\n'
                '    def __init__(self, *args: Any, **kwds: Any):\n'
                '        pass\n'
                '\n'
                '    def __add__(self, other: Vec3) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    def __sub__(self, other: Vec3) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    @overload\n'
                '    def __mul__(self, other: float) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    @overload\n'
                '    def __mul__(self, other: Sequence[float]) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    def __mul__(self, other: Any) -> Any:\n'
                '        return self\n'
                '\n'
                '    @overload\n'
                '    def __rmul__(self, other: float) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    @overload\n'
                '    def __rmul__(self, other: Sequence[float]) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    def __rmul__(self, other: Any) -> Any:\n'
                '        return self\n'
                '\n'
                '    # (for index access)\n'
                '    def __getitem__(self, typeargs: Any) -> Any:\n'
                '        return 0.0\n'
                '\n'
                '    def __len__(self) -> int:\n'
                '        return 3\n'
                '\n'
                '    # (for iterator access)\n'
                '    def __iter__(self) -> Any:\n'
                '        return self\n'
                '\n'
                '    def __next__(self) -> float:\n'
                '        return 0.0\n'
                '\n'
                '    def __neg__(self) -> Vec3:\n'
                '        return self\n'
                '\n'
                '    def __setitem__(self, index: int, val: float) -> None:\n'
                '        pass\n')
    if classname in ['Node']:
        out += (
            '\n'
            '    # Note attributes:\n'
            '    # NOTE: I\'m just adding *all* possible node attrs here\n'
            '    # now now since we have a single ba.Node type; in the\n'
            '    # future I hope to create proper individual classes\n'
            '    # corresponding to different node types with correct\n'
            '    # attributes per node-type.\n'
            '    color: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    size: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    position: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    position_center: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    position_forward: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    punch_position: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    punch_velocity: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    velocity: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    name_color: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    tint_color: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    tint2_color: Sequence[float] = (0.0, 0.0, 0.0)\n'
            "    text: Union[ba.Lstr, str] = ''\n"
            '    texture: Optional[ba.Texture] = None\n'
            '    tint_texture: Optional[ba.Texture] = None\n'
            '    times: Sequence[int] = (1,2,3,4,5)\n'
            '    values: Sequence[float] = (1.0, 2.0, 3.0, 4.0)\n'
            '    offset: float = 0.0\n'
            '    input0: float = 0.0\n'
            '    input1: float = 0.0\n'
            '    input2: float = 0.0\n'
            '    input3: float = 0.0\n'
            '    flashing: bool = False\n'
            '    scale: Union[float, Sequence[float]] = 0.0\n'  # FIXME
            '    opacity: float = 0.0\n'
            '    loop: bool = False\n'
            '    time1: int = 0\n'
            '    time2: int = 0\n'
            '    timemax: int = 0\n'
            '    client_only: bool = False\n'
            '    materials: Sequence[Material] = ()\n'
            '    roller_materials: Sequence[Material] = ()\n'
            "    name: str = ''\n"
            '    punch_materials: Sequence[ba.Material] = ()\n'
            '    pickup_materials: Sequence[ba.Material] = ()\n'
            '    extras_material: Sequence[ba.Material] = ()\n'
            '    rotate: float = 0.0\n'
            '    hold_node: Optional[ba.Node] = None\n'
            '    hold_body: int = 0\n'
            '    host_only: bool = False\n'
            '    premultiplied: bool = False\n'
            '    source_player: Optional[ba.Player] = None\n'
            '    model_opaque: Optional[ba.Model] = None\n'
            '    model_transparent: Optional[ba.Model] = None\n'
            '    damage_smoothed: float = 0.0\n'
            '    gravity_scale: float = 1.0\n'
            '    punch_power: float = 0.0\n'
            '    punch_momentum_linear: Sequence[float] = '
            '(0.0, 0.0, 0.0)\n'
            '    punch_momentum_angular: float = 0.0\n'
            '    rate: int = 0\n'
            '    vr_depth: float = 0.0\n'
            '    is_area_of_interest: bool = False\n'
            '    jump_pressed: bool = False\n'
            '    pickup_pressed: bool = False\n'
            '    punch_pressed: bool = False\n'
            '    bomb_pressed: bool = False\n'
            '    fly_pressed: bool = False\n'
            '    hold_position_pressed: bool = False\n'
            '    knockout: float = 0.0\n'
            '    invincible: bool = False\n'
            '    stick_to_owner: bool = False\n'
            '    damage: int = 0\n'
            '    run: float = 0.0\n'
            '    move_up_down: float = 0.0\n'
            '    move_left_right: float = 0.0\n'
            '    curse_death_time: int = 0\n'
            '    boxing_gloves: bool = False\n'
            '    use_fixed_vr_overlay: bool = False\n'
            '    allow_kick_idle_players: bool = False\n'
            '    music_continuous: bool = False\n'
            '    music_count: int = 0\n'
            '    hurt: float = 0.0\n'
            '    always_show_health_bar: bool = False\n'
            '    mini_billboard_1_texture: Optional[ba.Texture] = None\n'
            '    mini_billboard_1_start_time: int = 0\n'
            '    mini_billboard_1_end_time: int = 0\n'
            '    mini_billboard_2_texture: Optional[ba.Texture] = None\n'
            '    mini_billboard_2_start_time: int = 0\n'
            '    mini_billboard_2_end_time: int = 0\n'
            '    mini_billboard_3_texture: Optional[ba.Texture] = None\n'
            '    mini_billboard_3_start_time: int = 0\n'
            '    mini_billboard_3_end_time: int = 0\n'
            '    boxing_gloves_flashing: bool = False\n'
            '    dead: bool = False\n'
            '    floor_reflection: bool = False\n'
            '    debris_friction: float = 0.0\n'
            '    debris_kill_height: float = 0.0\n'
            '    vr_near_clip: float = 0.0\n'
            '    shadow_ortho: bool = False\n'
            '    happy_thoughts_mode: bool = False\n'
            '    shadow_offset: Sequence[float] = (0.0, 0.0)\n'
            '    paused: bool = False\n'
            '    time: int = 0\n'
            '    ambient_color: Sequence[float] = (1.0, 1.0, 1.0)\n'
            "    camera_mode: str = 'rotate'\n"
            '    frozen: bool = False\n'
            '    area_of_interest_bounds: Sequence[float]'
            ' = (-1, -1, -1, 1, 1, 1)\n'
            '    shadow_range: Sequence[float] = (0, 0, 0, 0)\n'
            "    counter_text: str = ''\n"
            '    counter_texture: Optional[ba.Texture] = None\n'
            '    shattered: int = 0\n'
            '    billboard_texture: Optional[ba.Texture] = None\n'
            '    billboard_cross_out: bool = False\n'
            '    billboard_opacity: float = 0.0\n'
            '    slow_motion: bool = False\n'
            "    music: str = ''\n"
            '    vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0)\n'
            '    vr_overlay_center_enabled: bool = False\n'
            '    vignette_outer: Sequence[float] = (0.0, 0.0)\n'
            '    vignette_inner: Sequence[float] = (0.0, 0.0)\n'
            '    tint: Sequence[float] = (1.0, 1.0, 1.0)\n')

    # Special case: need to be able to use the 'with' statement
    # on some classes.
    if classname in ['Context']:
        out += ('\n'
                '    def __enter__(self) -> None:\n'
                '        """Support for "with" statement."""\n'
                '        pass\n'
                '\n'
                '    def __exit__(self, exc_type: Any, exc_value: Any, '
                'traceback: Any) -> Any:\n'
                '        """Support for "with" statement."""\n'
                '        pass\n')
    return out


def _formatdoc(docstr: str, indent: int) -> str:
    out = ''
    indentstr = indent * ' '
    docslines = docstr.splitlines()
    if len(docslines) == 1:
        out += '\n' + indentstr + '"""' + docslines[0] + '"""\n'
    else:
        for i, line in enumerate(docslines):
            if i != 0 and line != '':
                docslines[i] = indentstr + line
        out += ('\n' + indentstr + '"""' + '\n'.join(docslines) + '\n' +
                indentstr + '"""\n')
    return out


def _writeclasses(module: ModuleType, classnames: Sequence[str]) -> str:
    # pylint: disable=too-many-branches
    import types
    from batools.docs import parse_docs_attrs
    out = ''
    for classname in classnames:
        cls = getattr(module, classname)
        if cls is None:
            raise Exception('unexpected')
        out += '\n' '\n'

        # Special case:
        if classname == 'Vec3':
            out += f'class {classname}(Sequence[float]):\n'
        else:
            out += f'class {classname}:\n'

        docstr = cls.__doc__
        out += _formatdoc(docstr, 4)

        # Create a public constructor if it has one.
        # If the first docs line appears to be a function signature
        # and not category or a usage statement ending with a period,
        # assume it has a public constructor.
        has_constructor = False
        if ('category:' not in docstr.splitlines()[0].lower()
                and not docstr.splitlines()[0].endswith('.')
                and docstr != '(internal)'):

            # Ok.. looks like the first line is a signature.
            # Make sure we've got a signature followed by a blank line.
            if '\n\n' not in docstr:
                raise Exception(
                    f'Constructor docstr missing empty line for {cls}.')
            sig = docstr.split('\n\n')[0].replace('\n', ' ').strip()

            # Sanity check - make sure name is in the sig.
            if classname + '(' not in sig:
                raise Exception(
                    f'Class name not found in constructor sig for {cls}.')
            sig = sig.replace(classname + '(', '__init__(self, ')
            out += '    def ' + sig + ':\n        pass\n'
            has_constructor = True

        # Scan its doc-string for attribute info; drop in typed
        # declarations for any that we find.
        attrs: List[AttributeInfo] = []
        parse_docs_attrs(attrs, docstr)
        if attrs:
            for attr in attrs:
                if attr.attr_type is not None:
                    out += f'    {attr.name}: {attr.attr_type}\n'
                else:
                    raise Exception(f'Found untyped attr in'
                                    f' {classname} docs: {attr.name}')

        # Special cases such as attributes we add.
        out += _special_class_cases(classname)

        # Print its methods.
        funcnames = []
        for entry in (e for e in dir(cls) if not e.startswith('__')):
            if isinstance(getattr(cls, entry), types.MethodDescriptorType):
                funcnames.append(entry)
            else:
                raise Exception(f'Unhandled obj {entry} in {cls}')
        funcnames.sort()
        functxt = _writefuncs(cls,
                              funcnames,
                              indent=4,
                              spacing=1,
                              as_method=True)
        if functxt == '' and not has_constructor:
            out += '    pass\n'
        else:
            out += functxt

    return out


def generate(sources_hash: str, outfilename: str) -> None:
    """Run the actual generation from within the game."""
    import _ba as module
    from efrotools import get_public_license, PYVER
    import types
    funcnames = []
    classnames = []
    for entry in (e for e in dir(module) if not e.startswith('__')):
        if isinstance(getattr(module, entry), types.BuiltinFunctionType):
            funcnames.append(entry)
        elif isinstance(getattr(module, entry), type):
            classnames.append(entry)
        elif entry == 'app':
            # Ignore _ba.app.
            continue
        else:
            raise Exception(f'found unknown obj {entry}')
    funcnames.sort()
    classnames.sort()
    out = (get_public_license('python')
           + '\n'
           '#\n'
           '"""A dummy stub module for the real _ba.\n'
           '\n'
           'The real _ba is a compiled extension module and only available\n'
           'in the live game. This dummy module allows Pylint/Mypy/etc. to\n'
           'function reasonably well outside of the game.\n'
           '\n'
           'Make sure this file is never included in an actual game distro!\n'
           '\n'
           'Ideally this should be a stub (.pyi) file, but we\'d need\n'
           'to make sure that it still works with all our tools\n'
           '(mypy, pylint, pycharm).\n'
           '\n'
           'NOTE: This file was autogenerated by ' + __name__ + '; '
           'do not edit by hand.\n'
           '"""\n'
           '\n'
           # '# (hash we can use to see if this file is out of date)\n'
           # '# SOURCES_HASH='+sources_hash+'\n'
           # '\n'
           '# I\'m sorry Pylint. I know this file saddens you. Be strong.\n'
           '# pylint: disable=useless-suppression\n'
           '# pylint: disable=unnecessary-pass\n'
           '# pylint: disable=use-dict-literal\n'
           '# pylint: disable=use-list-literal\n'
           '# pylint: disable=unused-argument\n'
           '# pylint: disable=missing-docstring\n'
           '# pylint: disable=too-many-locals\n'
           '# pylint: disable=redefined-builtin\n'
           '# pylint: disable=too-many-lines\n'
           '# pylint: disable=redefined-outer-name\n'
           '# pylint: disable=invalid-name\n'
           '# pylint: disable=no-self-use\n'
           '# pylint: disable=no-value-for-parameter\n'
           '\n'
           'from __future__ import annotations\n'
           '\n'
           'from typing import TYPE_CHECKING, overload, Sequence, TypeVar\n'
           '\n'
           'from ba._generated.enums import TimeFormat, TimeType\n'
           '\n'
           'if TYPE_CHECKING:\n'
           '    from typing import (Any, Dict, Callable, Tuple, '
           '        List, Optional, Union, List, Type, Literal)\n'
           '    from ba._app import App\n'
           '    import ba\n'
           '\n'
           '\n'
           "_T = TypeVar('_T')\n"
           '\n'
           'app: App\n'
           '\n'
           'def _uninferrable() -> Any:\n'
           '    """Get an "Any" in mypy and "uninferrable" in Pylint."""\n'
           '    # pylint: disable=undefined-variable\n'
           '    return _not_a_real_variable  # type: ignore'
           '\n'
           '\n'
           )  # yapf: disable

    out += _writeclasses(module, classnames)
    out += _writefuncs(module, funcnames, indent=0, spacing=2, as_method=False)

    outhashpath = os.path.join(os.path.dirname(outfilename),
                               '._ba_sources_hash')

    with open(outfilename, 'w', encoding='utf-8') as outfile:
        outfile.write(out)

    with open(outhashpath, 'w', encoding='utf-8') as outfile:
        outfile.write(sources_hash)

    # Lastly, format it.
    subprocess.run([f'python{PYVER}', '-m', 'yapf', '--in-place', outfilename],
                   check=True)


def _dummy_module_dirty() -> Tuple[bool, str]:
    """Test hashes on the dummy module to see if it needs updates."""

    # Let's generate a hash from all sources under the python source dir.
    pysources = []
    exts = ['.cc', '.c', '.h']
    for root, _dirs, files in os.walk('src/ballistica/python'):
        for fname in files:
            if any(fname.endswith(ext) for ext in exts):
                pysources.append(os.path.join(root, fname))

    # Also lets add this script so we re-create when it changes.
    pysources.append(__file__)

    outpath = 'assets/src/ba_data/python/._ba_sources_hash'
    if not os.path.exists(outpath):
        existing_hash = ''
    else:
        with open(outpath, encoding='utf-8') as infile:
            existing_hash = infile.read()

    # Important to keep this deterministic...
    pysources.sort()

    # Note: going with plain integers instead of hex so linters
    # don't see words and whine about spelling errors.
    pysources_hash = get_files_hash(pysources, int_only=True)
    dirty = existing_hash != pysources_hash
    return dirty, pysources_hash


def update(projroot: str, check: bool, force: bool) -> None:
    """Update the dummy module as needed."""
    toolsdir = os.path.abspath(os.path.join(projroot, 'tools'))

    outfilename = os.path.abspath(
        os.path.join(projroot, 'assets/src/ba_data/python/_ba.py'))

    # Make sure we're running from the project root dir.
    os.chdir(projroot)

    # Force makes no sense in check mode.
    if force and check:
        raise Exception('cannot specify both force and check mode')

    dirty, sources_hash = _dummy_module_dirty()

    if dirty:
        if check:
            print(f'{Clr.RED}ERROR: dummy _ba module'
                  f' is out of date.{Clr.RST}')
            sys.exit(255)
    elif not force:
        # Dummy module is clean and force is off; we're done here.
        print('Dummy module _ba.py is up to date.')
        sys.exit(0)

    print(f'{Clr.MAG}Updating _ba.py Dummy Module...{Clr.RST}')

    # Let's build the cmake version; no sandboxing issues to contend with
    # there. Also going with the headless build; will need to revisit if
    # there's ever any functionality not available in that build.
    subprocess.run(['make', 'cmake-server-build'], check=True)

    # Launch ballistica and exec ourself from within it.
    print('Launching ballisticacore to generate dummy-module...')
    try:
        subprocess.run(
            [
                './ballisticacore',
                '-exec',
                f'try:\n'
                f'    import sys\n'
                f'    sys.path.append("{toolsdir}")\n'
                f'    from batools import dummymodule\n'
                f'    dummymodule.generate(sources_hash="{sources_hash}",\n'
                f'        outfilename="{outfilename}")\n'
                f'    ba.quit()\n'
                f'except Exception as exc:\n'
                f'    import sys\n'
                f'    import traceback\n'
                f'    print("ERROR GENERATING DUMMY MODULE")\n'
                f'    traceback.print_exc()\n'
                f'    sys.exit(255)\n',
            ],
            cwd='build/cmake/server-debug/dist',
            check=True,
        )
    except Exception as exc2:
        # Keep our error simple here; we want focus to be on what went
        # wrong withing BallisticaCore.
        raise CleanError(
            'BallisticaCore dummy-module generation failed.') from exc2

    print('Dummy-module generation complete.')
