"""TDD tests for the daemon event inbox (Redis Stream consumer groups).

Tests written BEFORE implementation — RED phase.
"""

from __future__ import annotations

import pytest

from loom.bus import channels


# --- DaemonEvent model ---


class TestDaemonEventModel:
    def test_create_from_fields(self):
        from loom.daemon.inbox import DaemonEvent

        evt = DaemonEvent(
            event_type="claims_expired",
            project_id="proj-1",
            task_id="task-abc",
            details={"count": 3},
        )
        assert evt.event_type == "claims_expired"
        assert evt.project_id == "proj-1"
        assert evt.task_id == "task-abc"
        assert evt.details == {"count": 3}
        assert evt.timestamp is not None

    def test_optional_task_id(self):
        from loom.daemon.inbox import DaemonEvent

        evt = DaemonEvent(
            event_type="project_complete",
            project_id="proj-1",
        )
        assert evt.task_id is None
        assert evt.details == {}

    def test_serialization_roundtrip(self):
        from loom.daemon.inbox import DaemonEvent

        evt = DaemonEvent(
            event_type="epic_closed",
            project_id="proj-1",
            task_id="epic-1",
            details={"children_done": 5},
        )
        data = evt.to_stream_dict()
        # All values must be strings for Redis XADD
        for v in data.values():
            assert isinstance(v, str), f"Expected str, got {type(v)}: {v}"

        restored = DaemonEvent.from_stream_dict(data)
        assert restored.event_type == evt.event_type
        assert restored.project_id == evt.project_id
        assert restored.task_id == evt.task_id
        assert restored.details == evt.details


# --- Producer: publish_daemon_event ---


class TestPublishDaemonEvent:
    @pytest.mark.asyncio
    async def test_publish_adds_to_stream(self, redis_conn):
        from loom.daemon.inbox import publish_daemon_event

        stream_id = await publish_daemon_event(
            redis_conn,
            project_id="proj-1",
            event_type="claims_expired",
            task_id="task-abc",
            details={"count": 2},
        )
        assert stream_id is not None

        # Verify the event is in the stream
        key = channels.daemon_inbox("proj-1")
        entries = await redis_conn.xrange(key)
        assert len(entries) == 1
        _id, data = entries[0]
        assert data["event_type"] == "claims_expired"
        assert data["task_id"] == "task-abc"

    @pytest.mark.asyncio
    async def test_publish_multiple_events(self, redis_conn):
        from loom.daemon.inbox import publish_daemon_event

        await publish_daemon_event(redis_conn, "proj-1", "claims_expired")
        await publish_daemon_event(redis_conn, "proj-1", "epic_closed", task_id="e-1")
        await publish_daemon_event(redis_conn, "proj-1", "retries_processed")

        key = channels.daemon_inbox("proj-1")
        entries = await redis_conn.xrange(key)
        assert len(entries) == 3

    @pytest.mark.asyncio
    async def test_publish_to_separate_projects(self, redis_conn):
        from loom.daemon.inbox import publish_daemon_event

        await publish_daemon_event(redis_conn, "proj-a", "claims_expired")
        await publish_daemon_event(redis_conn, "proj-b", "claims_expired")

        entries_a = await redis_conn.xrange(channels.daemon_inbox("proj-a"))
        entries_b = await redis_conn.xrange(channels.daemon_inbox("proj-b"))
        assert len(entries_a) == 1
        assert len(entries_b) == 1


# --- Consumer: create_consumer_group + drain_events ---


class TestConsumerGroup:
    @pytest.mark.asyncio
    async def test_create_consumer_group(self, redis_conn):
        from loom.daemon.inbox import create_consumer_group, publish_daemon_event

        # Publish first so the stream exists
        await publish_daemon_event(redis_conn, "proj-1", "test_event")

        # Should not raise
        await create_consumer_group(redis_conn, "proj-1", "delegator")

    @pytest.mark.asyncio
    async def test_create_group_idempotent(self, redis_conn):
        from loom.daemon.inbox import create_consumer_group, publish_daemon_event

        await publish_daemon_event(redis_conn, "proj-1", "test_event")
        await create_consumer_group(redis_conn, "proj-1", "delegator")
        # Second call should not raise
        await create_consumer_group(redis_conn, "proj-1", "delegator")

    @pytest.mark.asyncio
    async def test_create_group_on_empty_stream(self, redis_conn):
        """Consumer group can be created even if no events exist yet."""
        from loom.daemon.inbox import create_consumer_group

        # Should create the stream and group in one step
        await create_consumer_group(redis_conn, "proj-1", "delegator")


class TestDrainEvents:
    @pytest.mark.asyncio
    async def test_drain_returns_events(self, redis_conn):
        from loom.daemon.inbox import (
            DaemonEvent,
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        await publish_daemon_event(redis_conn, "proj-1", "claims_expired", details={"count": 2})
        await publish_daemon_event(redis_conn, "proj-1", "epic_closed", task_id="e-1")

        await create_consumer_group(redis_conn, "proj-1", "delegator")
        events = await drain_events(redis_conn, "proj-1", "delegator", "agent-1")

        assert len(events) == 2
        assert all(isinstance(e, DaemonEvent) for e in events)
        assert events[0].event_type == "claims_expired"
        assert events[1].event_type == "epic_closed"

    @pytest.mark.asyncio
    async def test_drain_empty_returns_empty_list(self, redis_conn):
        from loom.daemon.inbox import (
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        # Create stream + group but don't add events after group creation
        await publish_daemon_event(redis_conn, "proj-1", "setup")
        await create_consumer_group(redis_conn, "proj-1", "delegator")

        # Drain the setup event
        await drain_events(redis_conn, "proj-1", "delegator", "agent-1")
        # Now drain again — should be empty
        events = await drain_events(redis_conn, "proj-1", "delegator", "agent-1")
        assert events == []

    @pytest.mark.asyncio
    async def test_drain_with_count_limit(self, redis_conn):
        from loom.daemon.inbox import (
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        for i in range(5):
            await publish_daemon_event(redis_conn, "proj-1", f"event_{i}")

        await create_consumer_group(redis_conn, "proj-1", "delegator")
        events = await drain_events(redis_conn, "proj-1", "delegator", "agent-1", count=2)

        assert len(events) == 2

    @pytest.mark.asyncio
    async def test_independent_consumer_groups(self, redis_conn):
        """Two consumer groups each get their own copy of events."""
        from loom.daemon.inbox import (
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        await publish_daemon_event(redis_conn, "proj-1", "claims_expired")

        await create_consumer_group(redis_conn, "proj-1", "delegator")
        await create_consumer_group(redis_conn, "proj-1", "dashboard")

        events_a = await drain_events(redis_conn, "proj-1", "delegator", "agent-1")
        events_b = await drain_events(redis_conn, "proj-1", "dashboard", "dash-1")

        assert len(events_a) == 1
        assert len(events_b) == 1

    @pytest.mark.asyncio
    async def test_events_acknowledged_after_drain(self, redis_conn):
        """After draining, events are ACKed and won't be re-delivered."""
        from loom.daemon.inbox import (
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        await publish_daemon_event(redis_conn, "proj-1", "claims_expired")
        await create_consumer_group(redis_conn, "proj-1", "delegator")

        events = await drain_events(redis_conn, "proj-1", "delegator", "agent-1")
        assert len(events) == 1

        # Second drain should return nothing (already ACKed)
        events2 = await drain_events(redis_conn, "proj-1", "delegator", "agent-1")
        assert events2 == []

    @pytest.mark.asyncio
    async def test_group_reads_all_existing_events(self, redis_conn):
        """Consumer group created with id=0 reads all existing events."""
        from loom.daemon.inbox import (
            create_consumer_group,
            drain_events,
            publish_daemon_event,
        )

        # Publish before group exists
        await publish_daemon_event(redis_conn, "proj-1", "old_event")
        await create_consumer_group(redis_conn, "proj-1", "late_joiner")

        # Publish after group exists
        await publish_daemon_event(redis_conn, "proj-1", "new_event")

        events = await drain_events(redis_conn, "proj-1", "late_joiner", "agent-1")
        assert len(events) == 2
        assert events[0].event_type == "old_event"
        assert events[1].event_type == "new_event"
