#!/usr/bin/env python3
"""A CLI for the ramses_rf library."""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import sys
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Literal

import asyncclick as click
from asyncclick.exceptions import ClickException, Exit
from colorama import Fore, Style, init as colorama_init

from ramses_rf import Gateway, GracefulExit, Message, exceptions as exc
from ramses_rf.const import DONT_CREATE_MESSAGES, SZ_ZONE_IDX
from ramses_rf.gateway import GatewayConfig
from ramses_rf.helpers import deep_merge
from ramses_rf.schemas import (
    SCH_GLOBAL_CONFIG,
    SZ_CONFIG,
    SZ_DISABLE_DISCOVERY,
    SZ_ENABLE_EAVESDROP,
    SZ_REDUCE_PROCESSING,
)
from ramses_tx import is_valid_dev_id
from ramses_tx.logger import CONSOLE_COLS, DEFAULT_DATEFMT, DEFAULT_FMT
from ramses_tx.schemas import (
    SZ_DISABLE_QOS,
    SZ_DISABLE_SENDING,
    SZ_ENFORCE_KNOWN_LIST,
    SZ_EVOFW_FLAG,
    SZ_FILE_NAME,
    SZ_KNOWN_LIST,
    SZ_PACKET_LOG,
    SZ_SERIAL_PORT,
)

from .debug import SZ_DBG_MODE, start_debugging
from .discovery import GET_FAULTS, GET_SCHED, SET_SCHED, spawn_scripts

from ramses_rf.const import (  # noqa: F401, isort: skip, pylint: disable=unused-import
    I_,
    RP,
    RQ,
    W_,
    DEV_TYPE_MAP,
    Code,
)

if TYPE_CHECKING:
    from _typeshed import SupportsRead


_PROFILE_LIBRARY = False  # NOTE: for profiling of library

if _PROFILE_LIBRARY:
    import cProfile
    import pstats


SZ_INPUT_FILE: Final = "input_file"

# DEFAULT_SUMMARY can be: True, False, or None
SHOW_SCHEMA = False
SHOW_PARAMS = False
SHOW_STATUS = False
SHOW_KNOWNS = False
SHOW_TRAITS = False
SHOW_CRAZYS = False

PRINT_STATE = False  # print engine state
# GET_STATE = False  # get engine state
# SET_STATE = False  # set engine state

# this is called after import colorlog to ensure its handlers wrap the correct streams
logging.basicConfig(level=logging.WARNING, format=DEFAULT_FMT, datefmt=DEFAULT_DATEFMT)


EXECUTE: Final = "execute"
LISTEN: Final = "listen"
MONITOR: Final = "monitor"
PARSE: Final = "parse"


COLORS = {
    I_: Fore.GREEN,
    RP: Fore.CYAN,
    RQ: Fore.CYAN,
    W_: Style.BRIGHT + Fore.MAGENTA,
}

CONTEXT_SETTINGS: dict[str, Any] = dict(help_option_names=["-h", "--help"])

LIB_KEYS = tuple(SCH_GLOBAL_CONFIG({}).keys()) + (SZ_SERIAL_PORT,)
LIB_CFG_KEYS = tuple(SCH_GLOBAL_CONFIG({})[SZ_CONFIG].keys()) + (SZ_EVOFW_FLAG,)


def normalise_config(
    lib_config: dict[str, dict[str, str | bool | None]],
) -> tuple[str | None, dict[str, Any] | None]:
    """Convert a HA config dict into the client library's own format.

    :param lib_config: The configuration dictionary from Home Assistant.
    :return: A tuple containing the serial port (if any) and the normalized configuration dictionary.
    """

    serial_port = lib_config.pop(SZ_SERIAL_PORT, None)

    # fix for: https://github.com/ramses-rf/ramses_rf/issues/96
    packet_log: str | Mapping[str, str | bool | None] | None = lib_config.get(
        SZ_PACKET_LOG
    )
    if packet_log is None:
        packet_log = {}
    elif isinstance(packet_log, str):
        packet_log = {SZ_FILE_NAME: packet_log}
    assert isinstance(packet_log, dict)
    lib_config[SZ_PACKET_LOG] = packet_log

    return serial_port, lib_config  # type: ignore[return-value]


def split_kwargs(
    obj: tuple[dict[str, Any], dict[str, Any]], kwargs: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Split kwargs into cli/library kwargs.

    :param obj: A tuple containing the current CLI and library configuration dictionaries.
    :param kwargs: The keyword arguments to split.
    :return: A tuple containing the updated CLI and library configuration dictionaries.
    """
    cli_kwargs, lib_kwargs = obj

    cli_kwargs.update(
        {k: v for k, v in kwargs.items() if k not in LIB_KEYS + LIB_CFG_KEYS}
    )
    lib_kwargs.update({k: v for k, v in kwargs.items() if k in LIB_KEYS})
    lib_kwargs[SZ_CONFIG].update({k: v for k, v in kwargs.items() if k in LIB_CFG_KEYS})

    return cli_kwargs, lib_kwargs


class DeviceIdParamType(click.ParamType):
    """A Click parameter type for Device IDs."""

    name = "device_id"

    def convert(self, value: str, param: Any, ctx: click.Context | None) -> str:
        """Convert the value to a Device ID.

        :param value: The value to convert.
        :param param: The parameter being converted.
        :param ctx: The Click context.
        :return: The converted Device ID.
        :raises click.BadParameter: If the value is not a valid Device ID.
        """
        if is_valid_dev_id(value):
            return value.upper()
        self.fail(f"{value!r} is not a valid device_id", param, ctx)
        assert False  # satisfy mypy, as self.fail raises BadParameter and never returns


# Args/Params for both RF and file
@click.group(context_settings=CONTEXT_SETTINGS)  # , invoke_without_command=True)
@click.option("-z", "--debug-mode", count=True, help="enable debugger")
@click.option("-c", "--config-file", type=click.File("r"))
@click.option("-rk", "--restore-schema", type=click.File("r"), help="from a HA store")
@click.option("-rs", "--restore-state", type=click.File("r"), help=" from a HA store")
@click.option("-r", "--reduce-processing", count=True, help="-rrr will give packets")
@click.option("-lf", "--long-format", is_flag=True, help="dont truncate STDOUT")
@click.option("-e/-ne", "--eavesdrop/--no-eavesdrop", default=None)
@click.option("-g", "--print-state", count=True, help="print state (g=schema, gg=all)")
# @click.option("--get-state/--no-get-state", default=GET_STATE, help="get the engine state")
# @click.option("--set-state/--no-set-state", default=SET_STATE, help="set the engine state")
@click.option(  # show_schema
    "-k/-nk",
    "--show-schema/--no-show-schema",
    default=SHOW_SCHEMA,
    help="display system schema",
)
@click.option(  # show_params
    "-p/-np",
    "--show-params/--no-show-params",
    default=SHOW_PARAMS,
    help="display system params",
)
@click.option(  # show_status
    "-s/-ns",
    "--show-status/--no-show-status",
    default=SHOW_STATUS,
    help="display system state",
)
@click.option(  # show_knowns
    "-n/-nn",
    "--show-knowns/--no-show-knowns",
    default=SHOW_KNOWNS,
    help="display known_list (of devices)",
)
@click.option(  # show_traits
    "-t/-nt",
    "--show-traits/--no-show-traits",
    default=SHOW_TRAITS,
    help="display device traits",
)
@click.option(  # show_crazys
    "-x/-nx",
    "--show-crazys/--no-show-crazys",
    default=SHOW_CRAZYS,
    help="display crazy things",
)
@click.pass_context
async def cli(
    ctx: click.Context,
    /,
    config_file: SupportsRead[str | bytes] | None = None,
    eavesdrop: None | bool = None,
    **kwargs: Any,
) -> None:
    """A CLI for the ramses_rf library.

    :param ctx: The Click context.
    :param config_file: An optional configuration file to load.
    :param eavesdrop: Whether to enable eavesdropping mode.
    :param kwargs: Additional keyword arguments.
    """

    if kwargs[SZ_DBG_MODE] > 0:  # Do first
        start_debugging(kwargs[SZ_DBG_MODE] == 1)

    kwargs, lib_kwargs = split_kwargs(({}, {SZ_CONFIG: {}}), kwargs)

    if eavesdrop is not None:
        lib_kwargs[SZ_CONFIG][SZ_ENABLE_EAVESDROP] = eavesdrop

    if config_file:  # TODO: validate file with voluptuous, use YAML
        lib_kwargs = deep_merge(
            lib_kwargs, json.load(config_file)
        )  # CLI takes precedence

    ctx.obj = kwargs, lib_kwargs


# Args/Params for packet log only
class FileCommand(click.Command):  # client.py parse <file>
    """A Click Command class for file-based operations."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.params.insert(  # input_file name/path only
            0, click.Argument(("input-file",))
        )
        # self.params.insert(  # --packet-log  # NOTE: useful only for test/dev
        #     1,
        #     click.Option(
        #         ("-o", "--packet-log"),
        #         type=click.Path(),
        #         help="Log all packets to this file",
        #     ),
        # )


# Args/Params for RF packets only
class PortCommand(
    click.Command
):  # client.py <command> <port> --packet-log xxx --evofw3-flag xxx
    """A Click Command class for serial port operations."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.params.insert(0, click.Argument(("serial-port",)))
        """ # self.params.insert(  # --no-discover
        #     1,
        #     click.Option(
        #         ("-d/-nd", "--discover/--no-discover"),
        #         is_flag=True,
        #         default=False,
        #         help="Log all packets to this file",
        #     ),
        # )
        # """
        self.params.insert(  # --packet-log
            2,
            click.Option(
                ("-o", "--packet-log"),
                type=click.Path(),
                help="Log all packets to this file",
            ),
        )
        self.params.insert(  # --evofw-flag
            3,
            click.Option(
                ("-T", "--evofw-flag"),
                type=click.STRING,
                help="Pass this traceflag to evofw",
            ),
        )


#
# 1/4: PARSE (a file, +/- eavesdrop)
@click.command(cls=FileCommand)  # parse a packet log file, then stop
@click.pass_obj
async def parse(
    obj: Any, /, **kwargs: Any
) -> tuple[Literal["parse"], dict[str, str], dict[str, str]]:
    """Command to parse a log file containing messages/packets.

    :param obj: The context object containing configuration.
    :param kwargs: Additional keyword arguments.
    :return: A tuple containing the command name, library configuration, and CLI configuration.
    """
    config, lib_config = split_kwargs(obj, kwargs)

    lib_config[SZ_INPUT_FILE] = config.pop(SZ_INPUT_FILE)  # just the file path

    return PARSE, lib_config, config


#
# 2/4: MONITOR (listen to RF, +/- discovery, +/- eavesdrop)
@click.command(cls=PortCommand)  # (optionally) execute a command/script, then monitor
@click.option("-d/-nd", "--discover/--no-discover", default=None)  # --no-discover
@click.option(  # --exec-cmd 'RQ 01:123456 1F09 00'
    "-x", "--exec-cmd", type=click.STRING, help="e.g. 'RQ 01:123456 1F09 00'"
)
@click.option(  # --execute-scr script device_id
    "-X",
    "--exec-scr",
    type=(str, DeviceIdParamType()),
    help="scan_disc|scan_full|scan_hard|bind device_id",
)
@click.option(  # --poll-devices device_id, device_id,...
    "--poll-devices", type=click.STRING, help="e.g. 'device_id, device_id, ...'"
)
@click.pass_obj
async def monitor(
    obj: Any, /, discover: None | bool = None, **kwargs: Any
) -> tuple[Literal["monitor"], dict[str, str], dict[str, str]]:
    """Monitor (eavesdrop and/or probe) a serial port for messages/packets.

    :param obj: The context object containing configuration.
    :param discover: Whether to enable discovery. If None, inferred from other arguments.
    :param kwargs: Additional keyword arguments.
    :return: A tuple containing the command name, library configuration, and CLI configuration.
    """
    config, lib_config = split_kwargs(obj, kwargs)

    if discover is None:
        if kwargs["exec_scr"] is None and kwargs["poll_devices"] is None:
            print(" - discovery is enabled")
            lib_config[SZ_CONFIG][SZ_DISABLE_DISCOVERY] = False
        else:
            print(" - discovery is disabled")
            lib_config[SZ_CONFIG][SZ_DISABLE_DISCOVERY] = True

    return MONITOR, lib_config, config


#
# 3/4: EXECUTE (send cmds to RF, +/- discovery, +/- eavesdrop)
@click.command(cls=PortCommand)  # execute a (complex) script, then stop
@click.option("-d/-nd", "--discover/--no-discover", default=None)  # --no-discover
@click.option(  # --exec-cmd 'RQ 01:123456 1F09 00'
    "-x", "--exec-cmd", type=click.STRING, help="e.g. 'RQ 01:123456 1F09 00'"
)
@click.option(  # --get-faults ctl_id
    "--get-faults", type=DeviceIdParamType(), help="controller_id"
)
@click.option(  # --get-schedule ctl_id zone_idx|HW
    "--get-schedule",
    default=[None, None],
    type=(DeviceIdParamType(), str),
    help="controller_id, zone_idx (e.g. '0A', 'HW')",
)
@click.option(  # --set-schedule ctl_id zone_idx|HW
    "--set-schedule",
    default=[None, None],
    type=(DeviceIdParamType(), click.File("r")),
    help="controller_id, filename.json",
)
@click.pass_obj
async def execute(
    obj: Any, /, **kwargs: Any
) -> tuple[Literal["execute"], dict[str | None, str | dict[str, Any]], dict[str, str]]:
    """Execute any specified scripts, return the results, then quit.

    Disables discovery, and enforces a strict allow_list.

    :param obj: A tuple containing the CLI and library configuration dictionaries.
    :param kwargs: Additional arguments passed to the command.
    :return: A tuple containing the command string, library config, and CLI config.
    """
    config, lib_config = split_kwargs(obj, kwargs)

    print(" - discovery is force-disabled")
    lib_config[SZ_CONFIG][SZ_DISABLE_DISCOVERY] = True
    lib_config[SZ_CONFIG][SZ_DISABLE_QOS] = False

    known_list: dict[str | None, dict[str, Any]] = {}
    if kwargs[GET_FAULTS]:
        known_list = {kwargs[GET_FAULTS]: {}}
    elif kwargs[GET_SCHED][0]:
        known_list = {kwargs[GET_SCHED][0]: {}}
    elif kwargs[SET_SCHED][0]:
        known_list = {kwargs[SET_SCHED][0]: {}}

    if known_list:
        print(" - known list is force-configured/enforced")
        lib_config[SZ_KNOWN_LIST] = known_list
        lib_config[SZ_CONFIG][SZ_ENFORCE_KNOWN_LIST] = True

    return EXECUTE, lib_config, config  # type: ignore[return-value]


#
# 4/4: LISTEN (to RF, +/- eavesdrop - NO sending/discovery)
@click.command(cls=PortCommand)  # (optionally) execute a command, then listen
@click.pass_obj
async def listen(
    obj: Any, /, **kwargs: Any
) -> tuple[
    Literal["listen"], dict[str, str | dict[str, str | None] | None], dict[str, Any]
]:
    """Listen to (eavesdrop only) a serial port for messages/packets.

    :param obj: The context object containing configuration.
    :param kwargs: Additional keyword arguments.
    :return: A tuple containing the command name, library configuration, and CLI configuration.
    """
    config, lib_config = split_kwargs(obj, kwargs)

    print(" - sending is force-disabled")
    lib_config[SZ_CONFIG][SZ_DISABLE_SENDING] = True

    return LISTEN, lib_config, config


def print_results(gwy: Gateway, **kwargs: Any) -> None:
    """Print the results of execution commands (faults, schedules).

    :param gwy: The gateway instance.
    :param kwargs: The command arguments.
    """
    if kwargs[GET_FAULTS]:
        fault_log = gwy.device_registry.system_by_id[
            kwargs[GET_FAULTS]
        ]._faultlog.faultlog

        if fault_log:
            [print(f"{k:02X}", v) for k, v in fault_log.items()]
        else:
            print("No fault log, or failed to get the fault log.")

    if kwargs[GET_SCHED][0]:
        system_id, zone_idx = kwargs[GET_SCHED]
        if zone_idx == "HW":
            dhw = gwy.device_registry.system_by_id[system_id].dhw
            zone: Any = dhw
        else:
            zone = gwy.device_registry.system_by_id[system_id].zone_by_idx[zone_idx]
        assert zone
        schedule = zone.schedule

        if schedule is None:
            print("Failed to get the schedule.")
        else:
            result = {SZ_ZONE_IDX: zone_idx, "schedule": schedule}
            print(">>> Schedule JSON begins <<<")
            print(json.dumps(result, indent=4))
            print(">>> Schedule JSON ended <<<")

    if kwargs[SET_SCHED][0]:
        system_id, _ = kwargs[GET_SCHED]


def _write_state(schema: dict[str, Any], msgs: dict[str, str]) -> None:
    """Write the state to the file system (blocking)."""
    with open("state_msgs.log", "w") as f:
        [f.write(f"{dtm} {pkt}\r\n") for dtm, pkt in msgs.items()]  # if not m._expired

    with open("state_schema.json", "w") as f:
        f.write(json.dumps(schema, indent=4))


async def _save_state(gwy: Gateway) -> None:
    """Save the gateway state to files.

    :param gwy: The gateway instance.
    """
    schema, msgs = await gwy.get_state()
    await asyncio.to_thread(_write_state, schema, msgs)


async def _print_engine_state(gwy: Gateway, **kwargs: Any) -> None:
    """Print the current engine state (schema and packets).

    :param gwy: The gateway instance.
    :param kwargs: Command arguments to determine verbosity.
    """
    (schema, packets) = await gwy.get_state(include_expired=True)

    if kwargs["print_state"] > 0:
        print(f"schema: {json.dumps(schema, indent=4)}\r\n")
    if kwargs["print_state"] > 1:
        print(f"packets: {json.dumps(packets, indent=4)}\r\n")


async def print_summary(gwy: Gateway, **kwargs: Any) -> None:
    """Print a summary of the system state, schema, params, and status.

    :param gwy: The gateway instance.
    :param kwargs: Command arguments to determine what to display.
    """
    entity = gwy.tcs or gwy

    if kwargs.get("show_schema"):
        print(f"Schema[{entity}] = {json.dumps(await entity.schema(), indent=4)}\r\n")

        # schema = {d.id: await d.schema() for d in sorted(gwy.device_registry.devices)}
        # print(f"Schema[devices] = {json.dumps({'schema': schema}, indent=4)}\r\n")

    if kwargs.get("show_params"):
        print(f"Params[{entity}] = {json.dumps(await entity.params(), indent=4)}\r\n")

        params = {d.id: await d.params() for d in sorted(gwy.device_registry.devices)}
        print(f"Params[devices] = {json.dumps({'params': params}, indent=4)}\r\n")

    if kwargs.get("show_status"):
        print(f"Status[{entity}] = {json.dumps(await entity.status(), indent=4)}\r\n")

        status = {d.id: await d.status() for d in sorted(gwy.device_registry.devices)}
        print(f"Status[devices] = {json.dumps({'status': status}, indent=4)}\r\n")

    if kwargs.get("show_knowns"):  # show device hints (show-knowns)
        print(f"allow_list (hints) = {json.dumps(gwy._engine._include, indent=4)}\r\n")

    if kwargs.get("show_traits"):  # show device traits
        result = {
            d.id: await d.traits()  # {k: v for k, v in (await d.traits()).items() if k[:1] == "_"}
            for d in sorted(gwy.device_registry.devices)
        }
        print(json.dumps(result, indent=4), "\r\n")

    if kwargs.get("show_crazys"):
        for device in [
            d for d in gwy.device_registry.devices if d.type == DEV_TYPE_MAP.CTL
        ]:
            if gwy.msg_db:
                for msg in await gwy.msg_db.get(device=device.id, code=Code._0005):
                    print(f"{msg._pkt}")
                for msg in await gwy.msg_db.get(device=device.id, code=Code._000C):
                    print(f"{msg._pkt}")
            else:  # TODO(eb): replace next block by
                #  raise NotImplementedError
                for msg_code, verbs in (await device.state_store._msgz()).items():
                    if msg_code in (Code._0005, Code._000C):
                        for verb in verbs.values():
                            for pkt in verb.values():
                                print(f"{pkt}")
            print()
        for device in [
            d for d in gwy.device_registry.devices if d.type == DEV_TYPE_MAP.UFC
        ]:
            if gwy.msg_db:
                for msg in await gwy.msg_db.get(device=device.id):
                    print(f"{msg._pkt}")
            else:  # TODO(eb): Q1 2026 replace next legacy block by
                #  raise NotImplementedError
                for cd in (await device.state_store._msgz()).values():
                    for verb in cd.values():
                        for pkt in verb.values():
                            print(f"{pkt}")
            print()


async def async_main(command: str, lib_kwargs: dict[str, Any], **kwargs: Any) -> None:
    """Execute the main asynchronous logic for the CLI.

    :param command: The command to execute (e.g., "execute", "monitor", "listen", "parse").
    :param lib_kwargs: Configuration arguments for the library.
    :param kwargs: Additional CLI arguments.
    """

    async def handle_msg(_msg: Message) -> None:
        """Process the message as it arrives (a callback).

        In this case, the message is merely printed.
        """

        if kwargs["long_format"]:  # HACK for test/dev
            print(
                f"{_msg.dtm.isoformat(timespec='microseconds')} ... {_msg!r}"
                f"  # {_msg.payload}"  # or f'  # ("{msg.src!r}", "{msg.dst!r}")'
            )
            return

        dtm = f"{_msg.dtm:%H:%M:%S.%f}"[:-3]
        con_cols = CONSOLE_COLS

        if _msg.code == Code._PUZZ:
            print(f"{Style.BRIGHT}{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
        elif _msg.src and _msg.src.type == DEV_TYPE_MAP.HGI:
            print(f"{Style.BRIGHT}{COLORS.get(_msg.verb)}{dtm} {_msg}"[:con_cols])
        elif _msg.code == Code._1F09 and _msg.verb == I_:
            print(f"{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
        elif _msg.code in (Code._000A, Code._2309, Code._30C9) and _msg._has_array:
            print(f"{Fore.YELLOW}{dtm} {_msg}"[:con_cols])
        else:
            print(f"{COLORS.get(_msg.verb)}{dtm} {_msg}"[:con_cols])

    serial_port, lib_kwargs = normalise_config(lib_kwargs)  # type: ignore[assignment]

    if kwargs["restore_schema"]:
        print(" - restoring client schema from a HA cache...")
        state: dict[str, Any] = json.load(kwargs["restore_schema"])["data"][
            "client_state"
        ]
        lib_kwargs = lib_kwargs | state["schema"]

    # Explicitly extract input_file if present to ensure it's passed as a named arg
    input_file = lib_kwargs.pop(SZ_INPUT_FILE, None)

    # Extract the configuration variables targeting the GatewayConfig Dataclass
    config_dict = lib_kwargs.pop(SZ_CONFIG, {})

    gwy_config_kwargs: dict[str, Any] = {}

    # 1. Map inner SZ_CONFIG keys
    for key, val in config_dict.items():
        if key == SZ_REDUCE_PROCESSING and val is not None:
            gwy_config_kwargs[key] = int(val)
        else:
            gwy_config_kwargs[key] = val

    # 2. Extract known GatewayConfig keys from lib_kwargs
    known_config_keys = {
        "packet_log",
        "port_config",
        "block_list",
        "known_list",
        "hgi_id",
        "debug_mode",
        "disable_sending",
        "disable_qos",
        "enforce_known_list",
        "evofw_flag",
    }
    for key in list(lib_kwargs.keys()):
        if key in known_config_keys:
            gwy_config_kwargs[key] = lib_kwargs.pop(key)

    if input_file is not None:
        gwy_config_kwargs["input_file"] = input_file

    # 3. Whatever remains in lib_kwargs represents schema parameters
    if lib_kwargs:
        gwy_config_kwargs["schema"] = lib_kwargs

    gwy_config = GatewayConfig(**gwy_config_kwargs)

    # Instantiate Gateway
    gwy = Gateway(
        serial_port,
        config=gwy_config,
    )  # passes action to gateway

    if gwy_config.reduce_processing < DONT_CREATE_MESSAGES:
        # library will not send MSGs to STDOUT, so we'll send PKTs instead
        colorama_init(autoreset=True)  # WIP: remove strip=True
        gwy.add_msg_handler(handle_msg)

    if kwargs["restore_state"]:
        print(" - restoring packets from a HA cache...")
        state = json.load(kwargs["restore_state"])["data"]["client_state"]
        await gwy._restore_cached_packets(state["packets"])

    print("\r\nclient.py: Starting engine...")

    try:  # main code here
        await gwy.start()

        # TODO:
        # python client.py -rrr listen /dev/ttyUSB0
        # cat *.log | head | python client.py parse

        if command == EXECUTE:
            tasks = spawn_scripts(gwy, **kwargs)
            await asyncio.gather(*tasks)

        elif command == MONITOR:
            _ = spawn_scripts(gwy, **kwargs)
            await gwy._engine._protocol.wait_for_connection_lost(timeout=86400)
            # timeout set to timeout=86400, to stop type checker complaint if sent to None

        elif command == PARSE:
            # Wait indefinitely until EOF closes the transport
            await gwy._engine._protocol.wait_for_connection_lost(timeout=86400)
            # timeout set to timeout=86400, to stop type checker complaint if sent to None

        elif command == LISTEN:
            await gwy._engine._protocol.wait_for_connection_lost(timeout=86400)
            # timeout set to timeout=86400, to stop type checker complaint if sent to None

    except asyncio.CancelledError:
        msg = "ended via: CancelledError (e.g. SIGINT)"
    except GracefulExit:
        msg = "ended via: GracefulExit"
    except KeyboardInterrupt:  # FIXME: why isn't this captured here? see main
        msg = "ended via: KeyboardInterrupt"
    except exc.RamsesException as err:
        msg = f"ended via: RamsesException: {err}"
    else:  # if no Exceptions raised, e.g. EOF when parsing, or Ctrl-C?
        msg = "ended without error (e.g. EOF)"
    finally:
        with contextlib.suppress(asyncio.CancelledError):
            await (
                gwy.stop()
            )  # Task cancellation is expected during shutdown, especially after EOF

    print(f"\r\nclient.py: Engine stopped: {msg}")

    # if kwargs["save_state"]:
    #    await _save_state(gwy)

    if kwargs["print_state"]:
        await _print_engine_state(gwy, **kwargs)

    elif command == EXECUTE:
        print_results(gwy, **kwargs)

    await print_summary(gwy, **kwargs)


cli.add_command(parse)
cli.add_command(monitor)
cli.add_command(execute)
cli.add_command(listen)


def _run_cli() -> None:
    """Run the CLI via asyncclick and execute the core application engine."""
    try:
        result = cli(standalone_mode=False)
    except ClickException as err:
        err.show()
        sys.exit(err.exit_code)
    except Exit as err:
        sys.exit(err.exit_code)

    if isinstance(result, int):
        sys.exit(result)

    (command, lib_kwargs, kwargs) = result
    asyncio.run(async_main(command, lib_kwargs, **kwargs))


def main() -> None:  # pragma: no cover
    """Entry point for the CLI.

    Parses arguments, sets up the event loop (including Windows-specific policies),
    and runs the main asynchronous loop (optionally with profiling).
    """
    print("\r\nclient.py: Starting ramses_rf...")

    if sys.platform == "win32":
        print(" - event_loop_policy set for win32")  # do before asyncio.run()
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

    profile = None
    try:
        if _PROFILE_LIBRARY:
            profile = cProfile.Profile()
            profile.run("_run_cli()")
        else:
            _run_cli()
    except KeyboardInterrupt:  # , SystemExit):
        print("\r\nclient.py: Engine stopped: ended via: KeyboardInterrupt")

    if _PROFILE_LIBRARY:
        ps = pstats.Stats(profile)
        ps.sort_stats(pstats.SortKey.TIME).print_stats(20)

    print(" - finished ramses_rf.\r\n")


if __name__ == "__main__":  # pragma: no cover
    main()
