# Copyright (c) 2019 OpenCyphal
# This software is distributed under the terms of the MIT License.
# Author: Pavel Kirienko <pavel@opencyphal.org>

from __future__ import annotations
from typing import Callable, Optional, Any, List, Dict
import inspect
import logging
import pycyphal
from pycyphal.transport import Transport as Transport
import click
from yakut.paths import OUTPUT_TRANSFER_ID_MAP_DIR, OUTPUT_TRANSFER_ID_MAP_MAX_AGE

TransportFactory = Callable[[], Optional[Transport]]
"""
The result is None if no transport configuration was provided when invoking the command.
"""

_logger = logging.getLogger(__name__)


def transport_factory_option(f: Callable[..., Any]) -> Callable[..., Any]:
    def validate(ctx: click.Context, param: object, value: Optional[str]) -> TransportFactory:
        from yakut import Purser

        _ = param
        _logger.debug("Transport expression: %r", value)

        def factory() -> Transport | None:
            # Try constructing from the expression if provided:
            if value:
                try:
                    result: Transport | None = construct_transport(value)
                except Exception as ex:
                    raise click.BadParameter(f"Could not initialize transport {value!r}: {ex!r}") from ex
                _logger.info("Transport %r constructed from expression %r", result, value)
                return result
            # If no expression is given, construct from the registers passed via environment variables:
            from pycyphal.application import make_transport

            purser = ctx.find_object(Purser)
            assert isinstance(purser, Purser)
            result = make_transport(purser.get_registry())
            _logger.info("Transport constructed from registers: %r", result)
            return result

        return factory

    doc = f"""
Override the network interface configuration, including the local node-ID.
This option is only relevant for commands that access the network, like pub/sub/call/etc.; other commands ignore it.

By default, if this option is not given (neither via --transport nor YAKUT_TRANSPORT),
commands that access the network deduce the transport configuration from standard registers passed via
environment variables, such as UAVCAN__NODE__ID, UAVCAN__UDP__IFACE, UAVCAN__SERIAL__IFACE,
and so on.
The full list of registers that configure the transport is available in the definition of the standard
RPC-service "uavcan.register.Access", and in the documentation for PyCyphal:
https://pycyphal.readthedocs.io (see "make_transport()").

If this expression is given, the registers are ignored, and the transport instance is constructed by evaluating it.
Upon evaluation, the expression should yield either a single transport instance or a sequence thereof.
In the latter case, the multiple transports will be joined under the same redundant transport instance,
which may be heterogeneous (e.g., UDP+Serial).
To see supported transports and how they should be initialized, refer to https://pycyphal.readthedocs.io.

The expression does not need to explicitly reference the `pycyphal.transport` module
because its contents are wildcard-imported for convenience.
Further, when specifying a transport class, the suffix `Transport` may be omitted;
e.g., `UDPTransport` and `UDP` are equivalent.

Examples showcasing loopback, CAN, and heterogeneous UDP+Serial:

\b
    Loopback(None)
    CAN(can.media.socketcan.SocketCANMedia('vcan0',64),42)
    UDP('127.42.0.123',None),Serial("/dev/ttyUSB0",None)

To avoid issues caused by resetting the transfer-ID counters between invocations,
the tool stores the output transfer-ID map on disk keyed by the node-ID.
On this computer, the path is `{OUTPUT_TRANSFER_ID_MAP_DIR}`.
The map files can be removed to reset all transfer-ID counters to zero.
Files that are more than {OUTPUT_TRANSFER_ID_MAP_MAX_AGE} seconds old are not used.
"""
    f = click.option(
        "--transport",
        "-i",
        "transport_factory",
        envvar="YAKUT_TRANSPORT",
        type=str,
        metavar="EXPRESSION",
        callback=validate,
        help=doc,
    )(f)
    return f


def construct_transport(expression: str) -> Transport:
    context = _make_evaluation_context()
    trs = _evaluate_transport_expr(expression, context)
    _logger.debug("Transport expression evaluation result: %r", trs)
    if len(trs) == 1:
        return trs[0]  # Non-redundant transport
    if len(trs) > 1:
        from pycyphal.transport.redundant import RedundantTransport

        rt = RedundantTransport()
        for t in trs:
            rt.attach_inferior(t)
        assert rt.inferiors == trs
        return rt
    raise ValueError("No transports specified")


def _evaluate_transport_expr(expression: str, context: Dict[str, Any]) -> List[Transport]:
    out = eval(expression, context)
    if isinstance(out, Transport):
        return [out]
    if isinstance(out, (list, tuple)) and all(isinstance(x, Transport) for x in out):
        return list(out)
    raise ValueError(
        f"The expression {expression!r} yields an instance of {type(out).__name__!r}. "
        f"Expected an instance of pycyphal.transport.Transport or a list thereof."
    )


def _make_evaluation_context() -> Dict[str, Any]:
    import os

    def handle_import_error(parent_module_name: str, ex: ImportError) -> None:
        try:
            tr = parent_module_name.split(".")[2]
        except LookupError:
            tr = parent_module_name
        _logger.debug("Transport %r is not available due to the missing dependency %r", tr, ex.name)

    # This import is super slow, so we do it as late as possible.
    # Doing this when generating command-line arguments would be disastrous for performance.
    # noinspection PyTypeChecker
    pycyphal.util.import_submodules(pycyphal.transport, error_handler=handle_import_error)

    # Populate the context with all references that may be useful for the transport expression.
    context: Dict[str, Any] = {
        "pycyphal": pycyphal,
        "os": os,
    }

    # Expose pre-imported transport modules for convenience.
    for name, module in inspect.getmembers(pycyphal.transport, inspect.ismodule):
        if not name.startswith("_"):
            context[name] = module

    # Pre-import transport classes.
    for cls in pycyphal.util.iter_descendants(Transport):  # type: ignore
        if not cls.__name__.startswith("_") and cls is not Transport:
            name = cls.__name__.rpartition(Transport.__name__)[0]
            assert name
            context[name] = cls
            context[cls.__name__] = cls

    _logger.debug("Transport expression evaluation context (on the next line):\n%r", context)
    return context
