# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

'''
Broker-daemon-actor "endpoint-hooks": the service task entry points for
``brokerd``.

'''
from __future__ import annotations
from contextlib import (
    asynccontextmanager as acm,
)
from types import ModuleType
from typing import (
    AsyncContextManager,
)

import tractor
import trio

from piker.log import (
    get_logger,
    get_console_log,
)
from . import _util
from . import get_brokermod

log = get_logger(name=__name__)

# `brokerd`-actor-always-enabled mods.
# NOTE: keeping this list as small as possible is part of our caps-sec
# model and should be treated with utmost care! In particular NO
# `piker.data.*` feed mods should be enabled in this (live,
# credentialed) trading actor; all data-feed serving is the
# domain of the `datad.<broker>` sibling daemon, see
# `piker.data._daemon._datad_service_mods`.
_brokerd_service_mods: list[str] = [
    'piker.brokers._daemon',
]


@tractor.context
async def _setup_persistent_brokerd(
    ctx: tractor.Context,
    brokername: str,
    loglevel: str|None = None,

) -> None:
    '''
    Trading-only daemon (lifetime) fixture: console logging
    setup and a pinned-open context for service mgmt.

    All data-feed-bus state now lives in the (data-feed-only)
    `datad.<brokername>` sibling daemon, see
    `piker.data._daemon._setup_persistent_datad()`; this
    actor hosts only the backend's `open_trade_dialog()`
    (live order-control) ep-task(s) which manage their own
    task trees per `tractor.Context`.

    '''
    # NOTE: we only need to setup logging once (and only) here
    # since all hosted daemon tasks will reference this same
    # log instance's (actor local) state and thus don't require
    # any further (level) configuration on their own B)
    actor: tractor.Actor = tractor.current_actor()
    tll: str = actor.loglevel
    log = get_console_log(
        level=loglevel or tll,
        name=f'{_util.subsys}.{brokername}',
        with_tractor_log=bool(tll),
    )
    assert log.name == _util.subsys

    # unblock caller
    await ctx.started()

    # we pin this task to keep the daemon active until the
    # parent actor decides to tear it down
    await trio.sleep_forever()


def broker_init(
    brokername: str,
    loglevel: str | None = None,

    **start_actor_kwargs,

) -> tuple[
    ModuleType,
    dict,
    AsyncContextManager,
]:
    '''
    Given an input broker name, load all named arguments
    which can be passed for daemon endpoint + context spawn
    as required in every `brokerd` (actor) service.

    This includes:
    - load the appropriate <brokername>.py pkg module,
    - reads any declared `_brokerd_mods: list[str]` (falling
      back to the full `__enable_modules__` set for
      not-yet-split backends) which will be passed to
      `tractor.ActorNursery.start_actor(enable_modules=)`
      at actor start time,
    - deliver a references to the daemon lifetime fixture, which
      for now is always the `_setup_persistent_brokerd()` context defined
      above.

    '''
    from ..brokers import get_brokermod
    brokermod = get_brokermod(brokername)
    modpath: str = brokermod.__name__

    start_actor_kwargs['name'] = f'brokerd.{brokername}'
    start_actor_kwargs.update(
        getattr(
            brokermod,
            '_spawn_kwargs',
            {},
        )
    )

    # XXX TODO: make this not so hacky/monkeypatched..
    # -> we need a sane way to configure the logging level for all
    # code running in brokerd.
    # if utilmod := getattr(brokermod, '_util', False):
    #     utilmod.log.setLevel(loglevel.upper())

    # lookup actor-enabled modules declared by the backend offering the
    # `brokerd` endpoint(s).
    enabled: list[str]
    enabled = start_actor_kwargs['enable_modules'] = [
        __name__,  # so that eps from THIS mod can be invoked
        modpath,
    ]
    for submodname in getattr(
        brokermod,
        '_brokerd_mods',
        # fallback for (flat, less mature) backends which
        # don't yet declare a daemon-kind mod split.
        getattr(
            brokermod,
            '__enable_modules__',
            [],
        ),
    ):
        subpath: str = f'{modpath}.{submodname}'
        enabled.append(subpath)

    return (
        brokermod,
        start_actor_kwargs,  # to `ActorNursery.start_actor()`

        # XXX see impl above; contains all (actor global)
        # setup/teardown expected in all `brokerd` actor instances.
        _setup_persistent_brokerd,
    )


async def spawn_brokerd(
    brokername: str,
    loglevel: str | None = None,

    **tractor_kwargs,

) -> bool:

    log.info(
        f'Spawning broker-daemon,\n'
        f'backend: {brokername!r}'
    )

    # fail fast on (data-only) backends which don't offer
    # ANY live order-control eps; the caller should instead
    # be using paper-mode (and thus never spawning us)!
    from ..data.validate import get_eps
    brokerd_eps: dict = get_eps(
        get_brokermod(brokername),
        'brokerd',
    )
    if not brokerd_eps:
        raise RuntimeError(
            f'Backend {brokername!r} offers NO `brokerd` '
            f'(live order-control) eps!?\n'
            f'It is likely a datad-only provider, use '
            f'paper-mode for clearing instead.\n'
        )

    (
        brokermode,
        tractor_kwargs,
        daemon_fixture_ep,
    ) = broker_init(
        brokername,
        loglevel,
        **tractor_kwargs,
    )

    brokermod = get_brokermod(brokername)
    extra_tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
    tractor_kwargs.update(extra_tractor_kwargs)

    # ask `pikerd` to spawn a new sub-actor and manage it under its
    # actor nursery
    from piker.service import Services

    dname: str = tractor_kwargs.pop('name')  # f'brokerd.{brokername}'
    portal = await Services.actor_n.start_actor(
        dname,
        enable_modules=list(dict.fromkeys(
            _brokerd_service_mods
            +
            tractor_kwargs.pop('enable_modules')
        )),
        debug_mode=Services.debug_mode,
        **tractor_kwargs
    )

    # NOTE: the service mngr expects an already spawned actor + its
    # portal ref in order to do non-blocking setup of brokerd
    # service nursery.
    await Services.start_service_task(
        dname,
        portal,

        # signature of target root-task endpoint
        daemon_fixture_ep,
        brokername=brokername,
        loglevel=loglevel,
    )
    return True


@acm
async def maybe_spawn_brokerd(

    brokername: str,
    loglevel: str|None = None,

    **pikerd_kwargs,

) -> tractor.Portal:
    '''
    Helper to spawn a brokerd service *from* a client who wishes to
    use the sub-actor-daemon but is fine with re-using any existing
    and contactable `brokerd`.

    Mas o menos, acts as a cached-actor-getter factory.

    '''
    from piker.service import maybe_spawn_daemon

    async with maybe_spawn_daemon(
        service_name=f'brokerd.{brokername}',
        service_task_target=spawn_brokerd,
        spawn_args={
            'brokername': brokername,
        },
        loglevel=loglevel,

        **pikerd_kwargs,

    ) as portal:
        yield portal
