"""Tests for the in-process EventBus.

These exercise the drop-oldest overflow strategy, per-kind fan-out, and
the non-blocking publish contract. They are async because the bus
exposes an async iterator for subscribers.
"""

from __future__ import annotations

import asyncio
import time

import pytest

from cyclone.pubsub import EventBus, get_event_bus


@pytest.mark.asyncio
async def test_publish_no_subscribers_is_noop():
    bus = EventBus()
    # No subscribers at all — must not raise.
    await bus.publish("x", {"a": 1})


@pytest.mark.asyncio
async def test_publish_to_n_subscribers_delivers_to_all():
    bus = EventBus()
    queues = [bus.subscribe(["claim"]) for _ in range(3)]

    await bus.publish("claim", {"id": "C1"})

    received = [await asyncio.wait_for(q.__anext__(), timeout=0.5) for q in queues]
    for event in received:
        assert event == {"id": "C1", "_kind": "claim"}


@pytest.mark.asyncio
async def test_subscriber_only_receives_matching_kinds():
    bus = EventBus()
    queue = bus.subscribe(["a"])

    await bus.publish("b", {"n": 1})
    await bus.publish("a", {"n": 2})

    event = await asyncio.wait_for(queue.__anext__(), timeout=0.5)
    assert event == {"n": 2, "_kind": "a"}


@pytest.mark.asyncio
async def test_slow_subscriber_does_not_block_publish():
    bus = EventBus(max_queue_size=2)
    queue = bus.subscribe(["e"])

    async def emit():
        for i in range(3):
            await bus.publish("e", {"i": i})

    start = time.monotonic()
    await emit()
    elapsed = time.monotonic() - start

    # Publishing must not block even though the subscriber queue is full
    # after the second event; the drop-oldest policy keeps publishes non-blocking.
    assert elapsed < 0.05, f"publish blocked for {elapsed:.3f}s"

    # The slow subscriber retains only the last two events.
    seen = []
    for _ in range(2):
        seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5))
    assert [e["i"] for e in seen] == [1, 2]


@pytest.mark.asyncio
async def test_queue_overflow_drops_oldest():
    bus = EventBus(max_queue_size=2)
    queue = bus.subscribe(["e"])

    for i in range(3):
        await bus.publish("e", {"i": i})

    seen = []
    for _ in range(2):
        seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5))
    assert [e["i"] for e in seen] == [1, 2]


@pytest.mark.asyncio
async def test_multiple_concurrent_subscribers_each_get_all_events():
    bus = EventBus()
    queues = [bus.subscribe(["claim"]) for _ in range(2)]

    for i in range(5):
        await bus.publish("claim", {"i": i})

    for q in queues:
        seen = []
        for _ in range(5):
            seen.append(await asyncio.wait_for(q.__anext__(), timeout=0.5))
        assert [e["i"] for e in seen] == [0, 1, 2, 3, 4]


def test_get_event_bus_raises_when_app_state_uninitialized():
    """get_event_bus() late-imports cyclone.api; if the lifespan handler
    hasn't set ``app.state.event_bus`` (or it's been cleared), the
    AttributeError must surface so callers can fail loudly."""
    from cyclone.api import app

    # The autouse conftest fixture sets app.state.event_bus for every test
    # so that endpoint code can call request.app.state.event_bus. We pop
    # the key entirely here so __getattr__ raises AttributeError rather
    # than returning None for an explicitly-None slot.
    state_dict = app.state._state
    saved = state_dict.pop("event_bus", None)
    try:
        with pytest.raises((AttributeError, RuntimeError)):
            get_event_bus()
    finally:
        if saved is not None:
            state_dict["event_bus"] = saved


def test_get_event_bus_returns_app_state_bus():
    """When the lifespan handler has initialised the bus, get_event_bus()
    returns it. Validates the happy path under the same late-import."""
    from cyclone.api import app
    from cyclone.pubsub import EventBus

    saved = getattr(app.state, "event_bus", None)
    bus = EventBus()
    app.state.event_bus = bus
    try:
        assert get_event_bus() is bus
    finally:
        app.state.event_bus = saved


def test_unsubscribe_removes_queue_from_each_kind():
    bus = EventBus()
    q1, _ = bus.subscribe_raw(["a", "b"])
    q2, _ = bus.subscribe_raw(["a"])

    bus.unsubscribe(q1, ["a", "b"])

    assert q1 not in bus._subscribers.get("a", [])
    assert q1 not in bus._subscribers.get("b", [])
    assert q2 in bus._subscribers["a"]


def test_unsubscribe_is_idempotent():
    bus = EventBus()
    q, _ = bus.subscribe_raw(["a"])
    bus.unsubscribe(q, ["a"])
    # Second call must not raise.
    bus.unsubscribe(q, ["a"])
    bus.unsubscribe(q, ["b"])  # wrong kind — also fine
    assert "a" not in bus._subscribers
