# Released under the MIT License. See LICENSE for details.
#
"""Functionality for sending and responding to messages.
Supports static typing for message types and possible return types.
"""

from typing import TYPE_CHECKING, Annotated
from dataclasses import dataclass
from enum import Enum

from efro.dataclassio import ioprepped, IOAttrs

if TYPE_CHECKING:
    pass


class UnregisteredMessageIDError(Exception):
    """A message or response id is not covered by our protocol."""


class Message:
    """Base class for messages."""

    class RetryPolicy(Enum):
        """Defines if/when/how retries are attempted for a message."""

        #: The default retry policy - disallow any retries since we
        #: assume it could lead to unintended effects on the server side
        #: if repeat messages come in.
        DISALLOW = 'disallow'

        #: Allow reasonable retry attempts for this message. By returning
        #: this value, a message acknowledges that there will be no bad
        #: effects if the server were to receive this message multiple
        #: times.
        ALLOW = 'allow'

        #: Like the :attr:`ALLOW` option, but retries may be attempted
        #: for a longer period of time. Using this too much may gum up
        #: servers, so limit its use to special cases on important
        #: messages and use regular :attr:`ALLOW` for all others.
        ALLOW_EXTRA = 'allow_extra'

    @classmethod
    def get_response_types(cls) -> list[type[Response] | None]:
        """Return all Response types this Message can return when sent.

        The default implementation specifies a None return type.
        """
        return [None]

    def get_retry_policy(self) -> RetryPolicy:
        """Define how retries should be handled for this message.

        This returns :attr:`~RetryPolicy.DISALLOW` by default, but message
        classes can override it depending on the behavior they desire.
        Note that the implementation (or lack thereof) for these
        policies is up to the particular messaging system; for example
        something built on reliable transport probably has no need for
        the concept of retries.
        """
        return self.RetryPolicy.DISALLOW

    def get_timeout(self) -> float | None:
        """Per-message outbound-call timeout, in seconds.

        Returns ``None`` by default, meaning "use whatever the sender
        implementation considers standard." Message classes on hot
        paths — e.g. transport-agent launches — can override this to
        return a tighter value so a saturated or unreachable peer
        can't compound into a long synchronous hold on the caller.

        Like :meth:`get_retry_policy`, concrete enforcement is up to
        the messaging system; message classes just express intent.
        """
        return None


class Response:
    """Base class for responses to messages."""


class SysResponse:
    """Base class for system-responses to messages.

    These are only sent/handled by the messaging system itself;
    users of the api never see them.
    """


# Some standard response types:


@ioprepped
@dataclass
class ErrorSysResponse(SysResponse):
    """SysResponse saying some error has occurred for the send.

    This generally results in an Exception being raised for the caller.
    """

    def set_local_exception(self, exc: Exception) -> None:
        """Attach a local exception to facilitate better logging/handling.

        Be aware that this data does not get serialized and only
        exists on the local object.
        """
        setattr(self, '_sr_local_exception', exc)

    def get_local_exception(self) -> Exception | None:
        """Fetch a local attached exception."""
        value = getattr(self, '_sr_local_exception', None)
        assert isinstance(value, Exception | None)
        return value

    def clear_local_exception(self) -> None:
        """Drop any attached local exception.

        The messaging system calls this once it has consumed the
        attached exception. An attached exception's traceback
        generally references the frame that created us, and that
        frame's reference to us in turn completes a reference cycle
        (frame -> response -> exception -> traceback -> frame), so
        holding on to the exception longer than needed would leave
        cleanup to the cyclic garbage collector.
        """
        setattr(self, '_sr_local_exception', None)

    class ErrorType(Enum):
        """Type of error that occurred while sending a message."""

        REMOTE = 0
        REMOTE_CLEAN = 1
        LOCAL = 2
        COMMUNICATION = 3
        REMOTE_COMMUNICATION = 4

    error_message: Annotated[str, IOAttrs('m')]
    error_type: Annotated[ErrorType, IOAttrs('e')] = ErrorType.REMOTE


@ioprepped
@dataclass
class EmptySysResponse(SysResponse):
    """The response equivalent of None."""


# TODO: could allow handlers to deal in raw values for these
# types similar to how we allow None in place of EmptySysResponse.
# Though not sure if they are widely used enough to warrant the
# extra code complexity.
@ioprepped
@dataclass
class BoolResponse(Response):
    """A simple bool value response."""

    value: Annotated[bool, IOAttrs('v')]


@ioprepped
@dataclass
class StringResponse(Response):
    """A simple string value response."""

    value: Annotated[str, IOAttrs('v')]
