"""Pluggable pub/sub broker seam for live-push fan-out.

`Broker` is the port; `RedisBroker` (gated behind the `[redis]` extra)
is the production adapter and `MockBroker` is the in-process test
double. A consumer that wants Redis Streams / NATS / Kafka writes a new
adapter — `SSEStreamService` never sees the broker's internals.
"""

from __future__ import annotations

import asyncio
import contextlib
from typing import Any, Protocol, runtime_checkable

__all__ = ["Broker", "MockBroker", "RedisBroker", "Subscription"]


@runtime_checkable
class Subscription(Protocol):
    """A live subscription to one channel."""

    async def get_message(self, *, timeout: float) -> bytes | None:
        """Return the next message payload, or ``None`` on timeout."""
        ...

    async def close(self) -> None:
        """Release the subscription (unsubscribe + free resources)."""
        ...


@runtime_checkable
class Broker(Protocol):
    """A pub/sub backend behind a two-method port."""

    async def publish(self, channel: str, payload: str | bytes) -> int:
        """Publish ``payload`` to ``channel``; return the delivery count."""
        ...

    async def subscribe(self, channel: str) -> Subscription:
        """Open a :class:`Subscription` to ``channel``."""
        ...


# ---------------------------------------------------------------------------
# Redis adapter (requires the `[redis]` extra)
# ---------------------------------------------------------------------------


class _RedisSubscription:
    def __init__(self, pubsub: Any, channel: str) -> None:
        self._pubsub = pubsub
        self._channel = channel

    async def get_message(self, *, timeout: float) -> bytes | None:
        msg = await self._pubsub.get_message(ignore_subscribe_messages=True, timeout=timeout)
        if msg is None:
            return None
        data = msg.get("data")
        if isinstance(data, bytes):
            return data
        if isinstance(data, str):
            return data.encode("utf-8")
        return None

    async def close(self) -> None:
        with contextlib.suppress(Exception):
            await self._pubsub.unsubscribe(self._channel)
            await self._pubsub.aclose()


class RedisBroker:
    """Redis pub/sub adapter. Pass a ``redis.asyncio.Redis`` client."""

    def __init__(self, redis: Any) -> None:
        self._redis = redis

    async def publish(self, channel: str, payload: str | bytes) -> int:
        return int(await self._redis.publish(channel, payload))

    async def subscribe(self, channel: str) -> Subscription:
        pubsub = self._redis.pubsub()
        await pubsub.subscribe(channel)
        return _RedisSubscription(pubsub, channel)


# ---------------------------------------------------------------------------
# In-process mock adapter (for consumers' tests)
# ---------------------------------------------------------------------------


class _MockSubscription:
    def __init__(self) -> None:
        self._queue: asyncio.Queue[bytes] = asyncio.Queue()
        self._closed = False

    async def get_message(self, *, timeout: float) -> bytes | None:
        try:
            return await asyncio.wait_for(self._queue.get(), timeout)
        except TimeoutError:
            return None

    async def close(self) -> None:
        self._closed = True

    def deliver(self, data: bytes) -> None:
        if not self._closed:
            self._queue.put_nowait(data)


class MockBroker:
    """Deterministic in-process broker. Only delivers to subscribers that
    were subscribed BEFORE the publish (matching real pub/sub semantics)."""

    def __init__(self) -> None:
        self._subs: dict[str, list[_MockSubscription]] = {}

    async def publish(self, channel: str, payload: str | bytes) -> int:
        data = payload.encode("utf-8") if isinstance(payload, str) else payload
        subs = self._subs.get(channel, [])
        for sub in subs:
            sub.deliver(data)
        return len(subs)

    async def subscribe(self, channel: str) -> Subscription:
        sub = _MockSubscription()
        self._subs.setdefault(channel, []).append(sub)
        return sub
