"""Chatto realtime WebSocket client.

Chatto exposes a binary-protobuf realtime channel at ``/api/realtime`` (see
``proto/chatto/realtime/v1/realtime.proto``). This module speaks that
protocol: it opens the WebSocket, exchanges ``hello`` frames, subscribes to
the caller's authorized live-event stream, and yields decoded events.

Usage::

    async with await ChattoClient.login(...) as client:
        async for event in stream_events(client):
            print(event.kind, event.payload)

Requires the ``chattolib[realtime]`` extra (which pulls in ``websockets`` and
``protobuf``).
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal, overload

from chattolib import _pb  # noqa: F401 — installs pb import path
from chattolib.exceptions import ChattoError
from chattolib.realtime_types import RealtimePayload, parse_payload
from chattolib.types import parse_datetime

if TYPE_CHECKING:
    from chattolib.client import ChattoClient
    from chattolib.realtime_types import (
        AssetDeletedPayload,
        AssetProcessingPayload,
        CallEventPayload,
        MentionNotificationPayload,
        MessageEditedPayload,
        MessagePostedPayload,
        MessageRetractedPayload,
        NewDirectMessageNotificationPayload,
        NotificationCreatedPayload,
        NotificationDismissedPayload,
        NotificationLevelChangedPayload,
        PresenceChangedPayload,
        ReactionPayload,
        RoomEventPayload,
        RoomGroupsUpdatedPayload,
        RoomMarkedAsReadPayload,
        RoomUniversalChangedPayload,
        ServerMemberDeletedPayload,
        ServerUpdatedPayload,
        ServerUserPreferencesUpdatedPayload,
        SessionTerminatedPayload,
        ThreadCreatedPayload,
        ThreadFollowChangedPayload,
        TypingPayload,
        UserCustomStatusClearedPayload,
        UserCustomStatusSetPayload,
        UserProfileUpdatedPayload,
    )

REALTIME_PATH = "/api/realtime"
REALTIME_PROTOCOL_VERSION = 1


class ChattoRealtimeError(ChattoError):
    """Server returned a protocol error over the realtime WebSocket."""

    def __init__(self, code: str, message: str, *, fatal: bool = False) -> None:
        self.code = code
        self.message = message
        self.fatal = fatal
        super().__init__(f"{code}: {message}")


class ChattoRealtimeCloseError(ChattoError):
    """Server sent a close frame."""

    def __init__(
        self,
        code: str,
        message: str,
        *,
        reconnect: bool = False,
        retry_after_ms: int = 0,
    ) -> None:
        self.code = code
        self.message = message
        self.reconnect = reconnect
        self.retry_after_ms = retry_after_ms
        super().__init__(f"{code}: {message}")


def realtime_url(base_url: str) -> str:
    """Convert an HTTP(S) base URL to the realtime WebSocket URL."""
    base = base_url.rstrip("/")
    if base.startswith("https://"):
        return "wss://" + base[len("https://") :] + REALTIME_PATH
    if base.startswith("http://"):
        return "ws://" + base[len("http://") :] + REALTIME_PATH
    return "wss://" + base + REALTIME_PATH


@dataclass
class ServerHello:
    """Server's response to the initial hello frame."""

    protocol_version: int
    server_version: str
    heartbeat_interval_seconds: int
    capabilities: list[str]


@dataclass
class RealtimeEvent:
    """One live event delivered over the realtime WebSocket.

    ``kind`` names the ``oneof event`` case set on the envelope
    (``message_posted``, ``reaction_added``, ``presence_changed``, …).
    ``payload`` is the typed payload dataclass for that kind (see
    :mod:`chattolib.realtime_types`), or ``None`` for unknown/future kinds.
    ``raw`` exposes the original protobuf envelope as an escape hatch.
    """

    id: str
    created_at: datetime | None
    actor_id: str | None
    kind: str
    payload: RealtimePayload | None
    raw: Any  # the full RealtimeEventEnvelope

    @overload
    def get(self, kind: Literal["message_posted"]) -> MessagePostedPayload | None: ...
    @overload
    def get(self, kind: Literal["message_edited"]) -> MessageEditedPayload | None: ...
    @overload
    def get(self, kind: Literal["message_retracted"]) -> MessageRetractedPayload | None: ...
    @overload
    def get(
        self, kind: Literal["reaction_added", "reaction_removed"]
    ) -> ReactionPayload | None: ...
    @overload
    def get(self, kind: Literal["user_typing"]) -> TypingPayload | None: ...
    @overload
    def get(self, kind: Literal["presence_changed"]) -> PresenceChangedPayload | None: ...
    @overload
    def get(
        self,
        kind: Literal[
            "room_created",
            "room_updated",
            "room_deleted",
            "room_archived",
            "room_unarchived",
            "user_joined_room",
            "user_left_room",
        ],
    ) -> RoomEventPayload | None: ...
    @overload
    def get(
        self, kind: Literal["room_universal_changed"]
    ) -> RoomUniversalChangedPayload | None: ...
    @overload
    def get(self, kind: Literal["notification_created"]) -> NotificationCreatedPayload | None: ...
    @overload
    def get(
        self, kind: Literal["notification_dismissed"]
    ) -> NotificationDismissedPayload | None: ...
    @overload
    def get(
        self, kind: Literal["notification_level_changed"]
    ) -> NotificationLevelChangedPayload | None: ...
    @overload
    def get(self, kind: Literal["thread_follow_changed"]) -> ThreadFollowChangedPayload | None: ...
    @overload
    def get(self, kind: Literal["thread_created"]) -> ThreadCreatedPayload | None: ...
    @overload
    def get(self, kind: Literal["room_marked_as_read"]) -> RoomMarkedAsReadPayload | None: ...
    @overload
    def get(self, kind: Literal["server_updated"]) -> ServerUpdatedPayload | None: ...
    @overload
    def get(self, kind: Literal["user_profile_updated"]) -> UserProfileUpdatedPayload | None: ...
    @overload
    def get(self, kind: Literal["user_custom_status_set"]) -> UserCustomStatusSetPayload | None: ...
    @overload
    def get(
        self, kind: Literal["user_custom_status_cleared"]
    ) -> UserCustomStatusClearedPayload | None: ...
    @overload
    def get(
        self, kind: Literal["server_user_preferences_updated"]
    ) -> ServerUserPreferencesUpdatedPayload | None: ...
    @overload
    def get(self, kind: Literal["room_groups_updated"]) -> RoomGroupsUpdatedPayload | None: ...
    @overload
    def get(self, kind: Literal["server_member_deleted"]) -> ServerMemberDeletedPayload | None: ...
    @overload
    def get(
        self,
        kind: Literal[
            "asset_processing_started", "asset_processing_succeeded", "asset_processing_failed"
        ],
    ) -> AssetProcessingPayload | None: ...
    @overload
    def get(self, kind: Literal["asset_deleted"]) -> AssetDeletedPayload | None: ...
    @overload
    def get(
        self,
        kind: Literal[
            "call_started", "call_participant_joined", "call_participant_left", "call_ended"
        ],
    ) -> CallEventPayload | None: ...
    @overload
    def get(self, kind: Literal["mention_notification"]) -> MentionNotificationPayload | None: ...
    @overload
    def get(
        self, kind: Literal["new_direct_message_notification"]
    ) -> NewDirectMessageNotificationPayload | None: ...
    @overload
    def get(self, kind: Literal["session_terminated"]) -> SessionTerminatedPayload | None: ...
    @overload
    def get(self, kind: str) -> RealtimePayload | None: ...

    def get(self, kind: str) -> RealtimePayload | None:
        """Return ``payload`` if this event is of ``kind``, else ``None``."""
        return self.payload if self.kind == kind else None


class RealtimeConnection:
    """Live realtime WebSocket session.

    Prefer :func:`stream_events` for the common case; use this class directly
    when you also need to send client pings, close cleanly, or inspect the
    negotiated :class:`ServerHello`.
    """

    def __init__(
        self,
        client: ChattoClient,
        *,
        protocol_version: int = REALTIME_PROTOCOL_VERSION,
    ) -> None:
        self._client = client
        self._protocol_version = protocol_version
        self._ws: Any = None
        self._server_hello: ServerHello | None = None

    @property
    def server_hello(self) -> ServerHello | None:
        return self._server_hello

    async def __aenter__(self) -> RealtimeConnection:
        await self.connect()
        return self

    async def __aexit__(self, *exc: Any) -> None:
        await self.close()

    async def connect(self) -> None:
        try:
            import websockets
        except ImportError as exc:  # pragma: no cover
            raise ChattoError(
                "The realtime channel requires the 'websockets' package. "
                "Install with `pip install chattolib[realtime]`."
            ) from exc

        from chattolib._pb.chatto.realtime.v1 import realtime_pb2

        url = realtime_url(self._client.base_url)
        headers: dict[str, str] = {}
        if self._client.session_cookie:
            headers["Cookie"] = f"chatto_session={self._client.session_cookie}"

        self._ws = await websockets.connect(url, additional_headers=headers)

        client_hello = realtime_pb2.RealtimeClientFrame()
        client_hello.hello.protocol_version = self._protocol_version
        if self._client.token:
            client_hello.hello.bearer_token = self._client.token
        await self._ws.send(client_hello.SerializeToString())

        first = await self._ws.recv()
        first_frame = realtime_pb2.RealtimeServerFrame()
        first_frame.ParseFromString(first)
        if first_frame.WhichOneof("frame") != "hello":
            _raise_for_control(first_frame)
            raise ChattoRealtimeError(
                "unexpected_frame",
                f"expected server hello, got {first_frame.WhichOneof('frame')!r}",
            )
        hello = first_frame.hello
        self._server_hello = ServerHello(
            protocol_version=hello.protocol_version,
            server_version=hello.server_version,
            heartbeat_interval_seconds=hello.heartbeat_interval_seconds,
            capabilities=list(hello.capabilities),
        )

        subscribe = realtime_pb2.RealtimeClientFrame()
        subscribe.subscribe_events.SetInParent()
        await self._ws.send(subscribe.SerializeToString())

    async def close(self) -> None:
        if self._ws is None:
            return
        try:
            await self._ws.close()
        finally:
            self._ws = None

    async def ping(self, nonce: str = "") -> None:
        """Send a client ping. The server replies with a matching pong."""
        if self._ws is None:
            raise ChattoError("realtime connection is closed")
        from chattolib._pb.chatto.realtime.v1 import realtime_pb2

        frame = realtime_pb2.RealtimeClientFrame()
        frame.ping.nonce = nonce
        await self._ws.send(frame.SerializeToString())

    async def events(self) -> AsyncIterator[RealtimeEvent]:
        """Yield decoded live events until the connection closes."""
        if self._ws is None:
            raise ChattoError("realtime connection is not open")
        from chattolib._pb.chatto.realtime.v1 import realtime_pb2

        async for raw in self._ws:
            if isinstance(raw, str):
                # The protocol is binary; a text frame indicates a protocol violation.
                raise ChattoRealtimeError(
                    "unexpected_text_frame",
                    "server sent a text frame; realtime protocol expects binary",
                )
            frame = realtime_pb2.RealtimeServerFrame()
            frame.ParseFromString(raw)
            case = frame.WhichOneof("frame")
            if case == "event":
                yield _wrap_event(frame.event)
            elif case in ("heartbeat", "pong", "subscribed"):
                continue
            elif case == "error":
                err = frame.error
                exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
                if err.fatal:
                    raise exc
                # Non-fatal errors are surfaced but the stream continues.
                # Callers can still receive them if desired; for now we log
                # by re-raising on fatal only.
                continue
            elif case == "close":
                close = frame.close
                raise ChattoRealtimeCloseError(
                    close.code,
                    close.message,
                    reconnect=close.reconnect,
                    retry_after_ms=close.retry_after_ms,
                )
            else:
                raise ChattoRealtimeError(
                    "unexpected_frame",
                    f"unknown server frame: {case!r}",
                )


def _raise_for_control(frame: Any) -> None:
    """If ``frame`` is an error or close frame, translate it and raise."""
    case = frame.WhichOneof("frame")
    if case == "error":
        err = frame.error
        raise ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
    if case == "close":
        close = frame.close
        raise ChattoRealtimeCloseError(
            close.code,
            close.message,
            reconnect=close.reconnect,
            retry_after_ms=close.retry_after_ms,
        )


def _wrap_event(envelope: Any) -> RealtimeEvent:
    kind = envelope.WhichOneof("event") or ""
    pb_payload = getattr(envelope, kind, None) if kind else None
    payload = parse_payload(kind, pb_payload) if kind else None
    created_at = None
    if envelope.HasField("created_at"):
        created_at = parse_datetime(envelope.created_at.ToJsonString())
    actor_id: str | None = None
    if envelope.HasField("actor_id"):
        actor_id = envelope.actor_id
    return RealtimeEvent(
        id=envelope.id,
        created_at=created_at,
        actor_id=actor_id,
        kind=kind,
        payload=payload,
        raw=envelope,
    )


async def stream_events(
    client: ChattoClient,
    *,
    protocol_version: int = REALTIME_PROTOCOL_VERSION,
) -> AsyncIterator[RealtimeEvent]:
    """Open a realtime connection and yield events until the server closes.

    Raises :class:`ChattoRealtimeCloseError` when the server sends a close frame,
    :class:`ChattoRealtimeError` on fatal protocol errors, or
    :class:`ChattoConnectError` if the initial HTTP handshake fails.
    """
    conn = RealtimeConnection(client, protocol_version=protocol_version)
    try:
        await conn.connect()
        async for event in conn.events():
            yield event
    finally:
        await conn.close()


__all__ = [
    "REALTIME_PATH",
    "REALTIME_PROTOCOL_VERSION",
    "ChattoRealtimeCloseError",
    "ChattoRealtimeError",
    "RealtimeConnection",
    "RealtimeEvent",
    "RealtimePayload",
    "ServerHello",
    "realtime_url",
    "stream_events",
]
