"""A2A event streaming — local queue, SSE formatting, and remote stub.

The :class:`A2aEventQueue` provides a working in-memory event queue for
local mode and a stub for remote subscriptions that raises
:class:`A2aNotAvailableError`.

:class:`SseEventFormatter` converts :class:`A2aEvent` instances into
``text/event-stream`` formatted strings per the Server-Sent Events spec.
The data payload follows JSON-RPC 2.0 notification format as required by
the A2A protocol specification.

:class:`EventBusBridge` subscribes to the internal ``EventBus`` and
publishes translated :class:`A2aEvent` instances to an event queue.
"""

from __future__ import annotations

import json
from collections.abc import Callable
from typing import Any, ClassVar

import structlog
from ulid import ULID

from cleveragents.a2a.errors import A2aNotAvailableError
from cleveragents.a2a.models import A2aEvent

# ---------------------------------------------------------------------------
# SSE event type constants (A2A protocol)
# ---------------------------------------------------------------------------

TASK_STATUS_UPDATE: str = "TaskStatusUpdateEvent"
TASK_ARTIFACT_UPDATE: str = "TaskArtifactUpdateEvent"

logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)

_REMOTE_MSG = (
    "Remote event subscriptions are not available in local mode"
    " - server mode will be implemented as a separate project"
)


class A2aEventQueue:
    """In-memory event queue with local pub/sub and remote stub."""

    def __init__(self) -> None:
        self._events: list[A2aEvent] = []
        self._subscriptions: dict[str, Callable[[A2aEvent], Any]] = {}
        self._is_closed: bool = False

    # ------------------------------------------------------------------
    # Local-mode operations (working)
    # ------------------------------------------------------------------

    @property
    def is_closed(self) -> bool:
        """Whether this queue has been closed."""
        return self._is_closed

    def publish(self, event: A2aEvent) -> None:
        """Append *event* to the local queue and notify subscribers."""
        if self._is_closed:
            raise RuntimeError("Cannot publish to a closed event queue")
        if not isinstance(event, A2aEvent):
            raise TypeError("event must be an A2aEvent instance")
        self._events.append(event)
        logger.debug(
            "a2a.event.published",
            event_id=event.event_id,
            event_type=event.event_type,
        )
        for sub_id, callback in self._subscriptions.items():
            try:
                callback(event)
            except Exception:
                logger.exception(
                    "a2a.event.callback_error",
                    subscription_id=sub_id,
                )

    def subscribe_local(self, callback: Callable[[A2aEvent], Any]) -> str:
        """Register a local callback and return a subscription ID."""
        if not callable(callback):
            raise TypeError("callback must be callable")
        sub_id = str(ULID())
        self._subscriptions[sub_id] = callback
        logger.debug("a2a.event.subscribed", subscription_id=sub_id)
        return sub_id

    def unsubscribe(self, subscription_id: str) -> bool:
        """Remove a subscription.  Returns ``True`` if it existed."""
        if not subscription_id or not isinstance(subscription_id, str):
            raise ValueError("subscription_id must be a non-empty string")
        removed = self._subscriptions.pop(subscription_id, None) is not None
        if removed:
            logger.debug("a2a.event.unsubscribed", subscription_id=subscription_id)
        return removed

    def get_events(self, limit: int = 100) -> list[A2aEvent]:
        """Return the most recent *limit* events from the queue."""
        if not isinstance(limit, int) or limit < 1:
            raise ValueError("limit must be a positive integer")
        return list(self._events[-limit:])

    def close(self) -> None:
        """Remove all subscriptions and clear the event queue.

        Logs the number of subscriptions that were active at the time of
        closing.  This method is safe to call multiple times.
        """
        self._is_closed = True
        count = len(self._subscriptions)
        self._subscriptions.clear()
        self._events.clear()
        if count:
            logger.info("a2a.event_queue.closed", subscription_count=count)

    # ------------------------------------------------------------------
    # Remote stub (raises)
    # ------------------------------------------------------------------

    def subscribe_remote(self, endpoint: str) -> None:
        """Subscribe to a remote event stream.

        Always raises :class:`A2aNotAvailableError` in local mode.
        """
        if not endpoint or not isinstance(endpoint, str):
            raise ValueError("endpoint must be a non-empty string")
        logger.warning("%s (endpoint=%s)", _REMOTE_MSG, endpoint)
        raise A2aNotAvailableError(
            _REMOTE_MSG,
            details={"endpoint": endpoint},
        )


# ---------------------------------------------------------------------------
# SSE event formatter
# ---------------------------------------------------------------------------


class SseEventFormatter:
    """Convert :class:`A2aEvent` to ``text/event-stream`` format.

    Each event is serialized as::

        event: <event_type>
        id: <event_id>
        data: <json payload>

    Two trailing newlines terminate the event per the EventSource spec.
    The data payload follows JSON-RPC 2.0 notification format per the A2A
    protocol specification (§Streaming Architecture).
    """

    # Mapping from A2A event type names to JSON-RPC 2.0 method strings.
    # Per spec §Streaming Architecture: TaskStatusUpdateEvent → task/statusUpdate,
    # TaskArtifactUpdateEvent → task/artifactUpdate.
    _EVENT_TYPE_TO_METHOD: ClassVar[dict[str, str]] = {
        "TaskStatusUpdateEvent": "task/statusUpdate",
        "TaskArtifactUpdateEvent": "task/artifactUpdate",
    }

    @staticmethod
    def format(event: A2aEvent) -> str:
        """Format an :class:`A2aEvent` as an SSE text block.

        The ``data:`` line contains a JSON-RPC 2.0 notification envelope::

            {
                "jsonrpc": "2.0",
                "method": "task/statusUpdate",
                "params": {
                    "taskId": "<plan_id>",
                    ...event data fields...
                }
            }

        Non-spec fields (``event_id``, ``event_type``, ``timestamp``) are
        excluded from the data payload; they are carried in the SSE envelope
        headers (``event:`` and ``id:`` lines) instead.
        """
        # Resolve JSON-RPC 2.0 method name from event type.
        method = SseEventFormatter._EVENT_TYPE_TO_METHOD.get(
            event.event_type,
            f"task/{event.event_type}",
        )

        # Build params from event data fields.
        params: dict[str, Any] = dict(event.data)

        # Include taskId in params when plan_id is present (per spec).
        if event.plan_id:
            params["taskId"] = event.plan_id

        data_payload = json.dumps(
            {
                "jsonrpc": "2.0",
                "method": method,
                "params": params,
            },
            default=str,
        )
        lines = [
            f"event: {event.event_type}",
            f"id: {event.event_id}",
            f"data: {data_payload}",
            "",
            "",
        ]
        return "\n".join(lines)

    @staticmethod
    def format_keepalive() -> str:
        """Format a keep-alive comment for SSE connections."""
        return ": keepalive\n\n"


# ---------------------------------------------------------------------------
# EventBus -> A2aEventQueue bridge
# ---------------------------------------------------------------------------


class EventBusBridge:
    """Subscribe to the internal :class:`EventBus` and forward events.

    Translates :class:`DomainEvent` instances from the reactive event
    bus into :class:`A2aEvent` instances and publishes them to an
    :class:`A2aEventQueue`.

    Usage::

        bridge = EventBusBridge(event_bus, event_queue)
        bridge.start()  # subscribes to bus
        bridge.stop()   # unsubscribes
    """

    # Domain event types that map to SSE TaskStatusUpdateEvent
    _STATUS_EVENT_TYPES: frozenset[str] = frozenset(
        {
            "PLAN_CREATED",
            "PLAN_PHASE_CHANGED",
            "PLAN_STATE_CHANGED",
            "PLAN_APPLIED",
            "PLAN_CANCELLED",
            "PLAN_ERRORED",
        }
    )

    # Domain event types that map to SSE TaskArtifactUpdateEvent
    _ARTIFACT_EVENT_TYPES: frozenset[str] = frozenset(
        {
            "CHECKPOINT_RESTORED",
        }
    )

    def __init__(
        self,
        event_bus: Any,
        event_queue: A2aEventQueue,
    ) -> None:
        self._event_bus = event_bus
        self._event_queue = event_queue
        self._subscription: Any | None = None

    def start(self) -> None:
        """Subscribe to the event bus and begin forwarding."""
        if hasattr(self._event_bus, "subscribe"):
            self._subscription = self._event_bus.subscribe(self._on_domain_event)
            logger.info("a2a.event_bridge.started")

    def stop(self) -> None:
        """Unsubscribe from the event bus."""
        if self._subscription is not None:
            if hasattr(self._subscription, "dispose"):
                self._subscription.dispose()
            self._subscription = None
            logger.info("a2a.event_bridge.stopped")

    def _on_domain_event(self, domain_event: Any) -> None:
        """Translate a domain event to an A2A event and publish."""
        event_type_name = getattr(domain_event, "event_type", None)
        if event_type_name is None:
            return

        # Normalize to string if it's an enum
        type_str = (
            event_type_name.value
            if hasattr(event_type_name, "value")
            else str(event_type_name)
        )

        if type_str in self._STATUS_EVENT_TYPES:
            sse_type = TASK_STATUS_UPDATE
        elif type_str in self._ARTIFACT_EVENT_TYPES:
            sse_type = TASK_ARTIFACT_UPDATE
        else:
            sse_type = type_str

        plan_id = getattr(domain_event, "plan_id", None)
        details = getattr(domain_event, "details", {})

        a2a_event = A2aEvent(
            event_type=sse_type,
            plan_id=plan_id,
            data=dict(details) if details else {},
        )

        import contextlib

        with contextlib.suppress(RuntimeError):
            self._event_queue.publish(a2a_event)


__all__ = [
    "TASK_ARTIFACT_UPDATE",
    "TASK_STATUS_UPDATE",
    "A2aEventQueue",
    "EventBusBridge",
    "SseEventFormatter",
]
