#!/usr/bin/env python3
"""RAMSES RF - Decode/process a message (payload into JSON/DTO)."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from datetime import datetime as dt, timedelta as td
from functools import lru_cache
from typing import TYPE_CHECKING, Any, TypeAlias

from . import exceptions as exc
from .address import Address
from .command import Command
from .const import DEV_TYPE_MAP, SZ_DHW_IDX, SZ_DOMAIN_ID, SZ_UFH_IDX, SZ_ZONE_IDX
from .packet import Packet
from .parsers import parse_payload
from .ramses import CODE_IDX_ARE_COMPLEX, CODES_SCHEMA, RQ_IDX_COMPLEX

# TODO:
# long-format msg.__str__ - alias columns don't line up


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

if TYPE_CHECKING:
    from ramses_rf.gateway import Gateway

    from .const import IndexT, VerbT  # noqa: F401, pylint: disable=unused-import


__all__ = ["Message"]


CODE_NAMES: dict[Code | str, str] = {k: v["name"] for k, v in CODES_SCHEMA.items()}

MSG_FORMAT_10: str = "|| {:10s} | {:10s} | {:2s} | {:16s} | {:^4s} || {}"

_TD_SECS_003: td = td(seconds=3)


_LOGGER = logging.getLogger(__name__)


@dataclass
class PayloadBase:
    """Base Data Transfer Object for parsed payloads.

    Acts as the foundation for the strict DTO migration, replacing raw dicts.
    """

    pass


# Transition alias for typing until full payload migration is complete
PayloadT: TypeAlias = Any  # PayloadBase | list[PayloadBase] | dict | list[dict]


class MessageBase:
    """The Message class; will trap/log invalid msgs."""

    def __init__(self, pkt: Packet) -> None:
        """Create a message from a valid packet.

        :param pkt: The packet to process into a message
        :type pkt: Packet
        :raises PacketInvalid: If the packet payload cannot be parsed.
        """

        self._pkt: Packet = pkt

        self.src: Address = pkt.src
        self.dst: Address = pkt.dst
        self._addrs: tuple[Address, Address, Address] = pkt._addrs

        self.dtm: dt = pkt.dtm
        self.rssi: str = pkt.rssi

        self.verb: VerbT = pkt.verb
        self.seqn: str = (
            pkt.seqn
        )  # the msg is part of a set for a Code, received in order
        self.code: Code = pkt.code
        self.len: int = pkt._len

        # Initialize attributes before parsing to prevent AttributeError
        # if an exception is raised and __repr__ is called during handling.
        self._str: str | None = None
        self._payload: PayloadT = {}

        self._payload = self._validate(self._pkt.payload)  # ? may raise PacketInvalid

    def __str__(self) -> str:
        """Return a human-readable string representation of this object.

        :return: A human-readable string representation of this object.
        :rtype: str
        """

        def ctx(pkt: Packet) -> str:
            """Extract the context string from the packet safely."""
            val: str
            if pkt._ctx is True:
                val = "[..]"
            elif pkt._ctx is False:
                val = ""
            elif pkt._ctx is None:
                val = "??"  # type: ignore[unreachable]
            else:
                val = str(pkt._ctx)

            if (
                not val
                and isinstance(pkt.payload, str)
                and pkt.payload[:2] not in ("00", "FF")
            ):
                return f"({pkt.payload[:2]})"
            return val

        if self._str is not None:
            return self._str

        if self.src.id == self._addrs[0].id:
            name_0 = self._name(self.src)
            name_1 = (
                "" if self.dst is self.src else self._name(self.dst)
            )  # use 'is', issue_cc 318
        else:
            name_0 = ""
            name_1 = self._name(self.src)

        code_name = CODE_NAMES.get(self.code, f"unknown_{self.code}")
        self._str = MSG_FORMAT_10.format(
            name_0, name_1, self.verb, code_name, ctx(self._pkt), self.payload
        )
        return self._str

    def __repr__(self) -> str:
        """Return an unambiguous string representation of this object.

        :return: An unambiguous string representation of this object.
        :rtype: str
        """
        return str(self._pkt)

    def __eq__(self, other: object) -> bool:
        """Check equality against another Message."""
        if not isinstance(other, MessageBase):
            return NotImplemented
        return (self.src, self.dst, self.verb, self.code, self._pkt.payload) == (
            other.src,
            other.dst,
            other.verb,
            other.code,
            other._pkt.payload,
        )

    def __lt__(self, other: object) -> bool:
        """Compare timestamps for ordering."""
        if not isinstance(other, MessageBase):
            return NotImplemented
        return self.dtm < other.dtm

    def _name(self, addr: Address) -> str:
        """Return a friendly name for an Address, or a Device.

        :param addr: The address to identify.
        :type addr: Address
        :return: A friendly name for an Address, or a Device.
        :rtype: str
        """
        return f" {addr.id}"  # can't do 'CTL:123456' instead of ' 01:123456'

    @property
    def payload(self) -> PayloadT:
        """Return the parsed payload, preferably as a strongly-typed DTO.

        :return: The payload.
        :rtype: PayloadT
        """
        return self._payload

    @property
    def _has_payload(self) -> bool:
        """Return False if there is no payload (may falsely return True).

        The message (i.e. the raw payload) may still have an idx.

        :return: False if there is no payload (may falsely return True).
        :rtype: bool
        """
        return self._pkt._has_payload

    @property
    def _has_array(self) -> bool:
        """Return True if the message's raw payload is an array.

        :return: True if the message's raw payload is an array.
        :rtype: bool
        """
        return bool(self._pkt._has_array)

    @property
    def _idx(self) -> dict[str, str]:
        """Get the domain_id/zone_idx/other_idx of a message payload, if any.
        Used to identify the zone/domain that a message applies to.

        :return: an empty dict if there is none such, or None if undetermined.
        :rtype: dict[str, str]
        """

        # .I --- 01:145038 --:------ 01:145038 3B00 002 FCC8

        IDX_NAMES = {
            Code._0002: "other_idx",  # non-evohome: hometronics
            Code._10A0: SZ_DHW_IDX,  # can be 2 DHW zones per system, albeit unusual
            Code._1260: SZ_DHW_IDX,  # can be 2 DHW zones per system, albeit unusual
            Code._1F41: SZ_DHW_IDX,  # can be 2 DHW zones per system, albeit unusual
            Code._22C9: SZ_UFH_IDX,  # UFH circuit
            Code._2389: "other_idx",  # anachronistic
            Code._2D49: "other_idx",  # non-evohome: hometronics
            Code._31D9: "hvac_id",
            Code._31DA: "hvac_id",
            Code._3220: "msg_id",
        }  # ALSO: SZ_DOMAIN_ID, SZ_ZONE_IDX

        if self.code in (Code._31D9, Code._31DA):  # shouldn't be needed?
            assert isinstance(self._pkt._idx, str)  # mypy hint
            return {"hvac_id": self._pkt._idx}

        if self._pkt._idx in (True, False) or self.code in CODE_IDX_ARE_COMPLEX:
            return {}  # above was: CODE_IDX_COMPLEX + (Code._3150):

        if self.code in (Code._3220,):  # FIXME: should be _SIMPLE
            return {}

        # .I 068 03:201498 --:------ 03:201498 30C9 003 0106D6 # rare

        # .I --- 00:034798 --:------ 12:126457 2309 003 0201F4
        if not {self.src.type, self.dst.type} & {
            DEV_TYPE_MAP.CTL,
            DEV_TYPE_MAP.UFC,
            DEV_TYPE_MAP.HCW,  # ?remove (see above, rare)
            DEV_TYPE_MAP.DTS,
            DEV_TYPE_MAP.HGI,
            DEV_TYPE_MAP.DT2,
            DEV_TYPE_MAP.PRG,
        }:  # FIXME: DEX should be deprecated to use device type rather than class
            assert self._pkt._idx == "00", "What!! (AA)"
            return {}

        # .I 035 --:------ --:------ 12:126457 30C9 003 017FFF
        if self.src.type == self.dst.type and self.src.type not in (
            DEV_TYPE_MAP.CTL,
            DEV_TYPE_MAP.UFC,
            DEV_TYPE_MAP.HCW,  # ?remove (see above, rare)
            DEV_TYPE_MAP.HGI,
            DEV_TYPE_MAP.PRG,
        ):  # DEX
            assert self._pkt._idx == "00", "What!! (AB)"
            return {}

        # .I --- 04:029362 --:------ 12:126457 3150 002 0162
        # if not getattr(self.src, "_is_controller", True) and not getattr(
        #     self.dst, "_is_controller", True
        # ):
        #     assert self._pkt._idx == "00", "What!! (BA)"
        #     return {}

        # .I --- 04:029362 --:------ 12:126457 3150 002 0162
        # if not (
        #     getattr(self.src, "_is_controller", True)
        #     or getattr(self.dst, "_is_controller", True)
        # ):
        #     assert self._pkt._idx == "00", "What!! (BB)"
        #     return {}

        if self.src.type == self.dst.type and not getattr(
            self.src, "_is_controller", True
        ):  # DEX
            assert self._pkt._idx == "00", "What!! (BC)"
            return {}

        # TODO: also 000C (but is a complex idx)
        # TODO: also 3150 (when not domain; will be array in that case)
        if self.code in (Code._000A, Code._2309) and self.src.type == DEV_TYPE_MAP.UFC:
            assert isinstance(self._pkt._idx, str)  # mypy hint
            return {IDX_NAMES[Code._22C9]: self._pkt._idx}

        assert isinstance(self._pkt._idx, str)  # mypy hint
        idx_name = SZ_DOMAIN_ID if self._pkt._idx[:1] == "F" else SZ_ZONE_IDX
        index_name = IDX_NAMES.get(self.code, idx_name)

        return {index_name: self._pkt._idx}

    # TODO: needs work...
    def _validate(self, raw_payload: str) -> PayloadT:
        """Validate a message packet payload, and parse it if valid.

        :param raw_payload: The raw payload string.
        :type raw_payload: str
        :return: A dict containing key: value pairs, or a list/DTO created from the payload
        :rtype: PayloadT
        :raises PacketInvalid: If it is not valid or parsable.
        """

        try:  # parse the payload
            # TODO: only accept invalid packets to/from HGI when flag raised
            _check_msg_payload(self, self._pkt.payload)  # ? InvalidPayloadError

            if not self._has_payload and (
                self.verb == RQ and self.code not in RQ_IDX_COMPLEX
            ):
                return {}

            result = parse_payload(self)  # invoke the code parsers

            if isinstance(result, list):
                return result
            if isinstance(result, dict):
                return {**self._idx, **result}

            # Return the strongly-typed PayloadBase DTO object
            return result  # type: ignore[unreachable]

        except exc.PacketInvalid as err:
            _LOGGER.warning("%s < %s", self._pkt, err)
            raise err

        except AssertionError as err:
            # beware: HGI80 can send 'odd' but parseable packets +/- get invalid reply
            _LOGGER.exception("%s < %s", self._pkt, f"{err.__class__.__name__}({err})")
            raise exc.PacketInvalid("Bad packet") from err

        except (AttributeError, LookupError, TypeError, ValueError) as err:  # TODO: dev
            _LOGGER.exception(
                "%s < Coding error: %s", self._pkt, f"{err.__class__.__name__}({err})"
            )
            raise exc.PacketInvalid from err

        except NotImplementedError as err:  # parser_unknown (unknown packet code)
            _LOGGER.warning("%s < Unknown packet code (cannot parse)", self._pkt)
            raise exc.PacketInvalid from err


class Message(MessageBase):
    """Extend the Message class, so it is useful to a stateful Gateway.

    Adds _expired attr to the Message class.
    """

    CANT_EXPIRE: float = -1.0  # sentinel value for fraction_expired

    HAS_EXPIRED: float = 2.0  # fraction_expired >= HAS_EXPIRED
    # .HAS_DIED = 1.0  # fraction_expired >= 1.0 (is expected lifespan)
    IS_EXPIRING: float = 0.8  # fraction_expired >= 0.8 (and < HAS_EXPIRED)

    _gwy: Gateway | None = None
    _fraction_expired: float | None = None

    @classmethod
    def _from_cmd(cls, cmd: Command, dtm: dt | None = None) -> Message:
        """Create a Message from a Command.

        :param cmd: The command.
        :type cmd: Command
        :param dtm: Datetime overrides.
        :type dtm: dt | None
        :return: The generated message.
        :rtype: Message
        """
        return cls(Packet._from_cmd(cmd, dtm=dtm))

    @classmethod
    def _from_pkt(cls, pkt: Packet) -> Message:
        """Create a Message from a Packet.

        :param pkt: The packet.
        :type pkt: Packet
        :return: The generated message.
        :rtype: Message
        """
        return cls(pkt)

    @property
    def _expired(self) -> bool:
        """Return True if the message is dated, False otherwise.

        :return: True if the message is dated, False otherwise
        :rtype: bool
        """
        # fraction_expired = (dt_now - self.dtm - _TD_SECONDS_003) / self._pkt._lifespan
        # TODO: keep none >7d, even 10E0, etc.

        def fraction_expired(lifespan: td) -> float:
            """
            :return: the packet's age as fraction of its 'normal' life span.
            """
            if self._gwy:  # self._gwy is set in ramses_tx.gateway.Engine._msg_handler
                return float(
                    (self._gwy._engine._dt_now() - self.dtm - _TD_SECS_003) / lifespan
                )
            return (dt.now() - self.dtm - _TD_SECS_003) / lifespan

        # 1. Look for easy win...
        if self._fraction_expired is not None:
            if self._fraction_expired == self.CANT_EXPIRE:
                return False
            if self._fraction_expired >= self.HAS_EXPIRED:
                return True

        # 2. Need to update the fraction_expired...
        if self.code == Code._1F09 and self.verb != RQ:  # sync_cycle is a special case
            # RQs won't have remaining_seconds, RP/Ws have only partial cycle times
            rem_secs = getattr(self.payload, "remaining_seconds", None)
            if rem_secs is None and isinstance(self.payload, dict):
                rem_secs = self.payload.get("remaining_seconds", 0)

            self._fraction_expired = fraction_expired(
                td(seconds=float(rem_secs or 0)),
            )

        elif self._pkt._lifespan is False:  # Can't expire
            self._fraction_expired = self.CANT_EXPIRE

        elif self._pkt._lifespan is True:  # Can't expire
            raise NotImplementedError("Lifespan True not implemented")

        else:
            assert isinstance(self._pkt._lifespan, td)
            self._fraction_expired = fraction_expired(self._pkt._lifespan)

        return self._fraction_expired >= self.HAS_EXPIRED


@lru_cache(maxsize=256)
def re_compile_re_match(regex: str, string: str) -> bool:  # Optional[Match[Any]]
    """Check if the provided string matches the regex pattern.

    :param regex: The regex pattern string
    :type regex: str
    :param string: The text payload to test
    :type string: str
    :return: True if matched, False otherwise
    :rtype: bool
    """
    # TODO: confirm this does speed things up
    # Python has its own caching of re.compile, _MAXCACHE = 512
    # https://github.com/python/cpython/blob/3.10/Lib/re.py
    return bool(re.compile(regex).match(string))


def _check_msg_payload(msg: MessageBase, payload: str) -> None:
    """Validate a packet's payload against its verb/code pair.

    :param msg: The message object being validated
    :type msg: MessageBase
    :param payload: The raw hex payload string
    :type payload: str
    :raises PacketInvalid: If the code is unknown or verb/code pair is invalid.
    :raises PacketPayloadInvalid: If the payload does not match the expected regex.
    """

    _ = repr(msg._pkt)  # HACK: ? raise InvalidPayloadError

    if msg.code not in CODES_SCHEMA:
        raise exc.PacketInvalid(f"Unknown code: {msg.code}")

    # Guard the TypedDict access by verifying the key against literals
    # We use a tuple of strings that match the CodeSchemaEntry keys
    if msg.verb not in ("RQ", "RP", " I", " W"):
        raise exc.PacketInvalid(f"Unknown verb/code pair: {msg.verb}/{msg.code}")

    # Now that we've checked the literal membership, Mypy allows the access
    # We use .get() to return the regex (str) and safely handle missing verbs
    regex = CODES_SCHEMA[msg.code].get(msg.verb)

    if not regex:
        raise exc.PacketInvalid(f"Unknown verb/code pair: {msg.verb}/{msg.code}")

    if not re_compile_re_match(str(regex), payload):
        raise exc.PacketPayloadInvalid(
            f"Payload doesn't match {msg.verb}/{msg.code}: {payload} != {regex}"
        )
