"""Unit tests for distributed event bus.

Tests cover:
- FileEvent dataclass and serialization
- FileEventType enum
- Path pattern matching
- RedisEventBus (mocked Redis)
- Factory functions

Related: Issue #1106 Block 2
"""

import json
from unittest.mock import AsyncMock, MagicMock

import pytest

from nexus.services.event_bus.base import EventBusBase
from nexus.services.event_bus.factory import create_event_bus
from nexus.services.event_bus.protocol import AckableEvent, EventBusProtocol
from nexus.services.event_bus.redis import RedisEventBus
from nexus.services.event_bus.types import FileEvent, FileEventType

# =============================================================================
# FileEventType Tests
# =============================================================================


class TestFileEventType:
    """Tests for FileEventType enum."""

    def test_event_types_defined(self):
        """Test that all expected event types are defined."""
        assert FileEventType.FILE_WRITE == "file_write"
        assert FileEventType.FILE_DELETE == "file_delete"
        assert FileEventType.FILE_RENAME == "file_rename"
        assert FileEventType.DIR_CREATE == "dir_create"
        assert FileEventType.DIR_DELETE == "dir_delete"

    def test_event_type_string_value(self):
        """Test that event types are strings."""
        assert isinstance(FileEventType.FILE_WRITE.value, str)
        assert FileEventType.FILE_WRITE.value == "file_write"


# =============================================================================
# FileEvent Tests
# =============================================================================


class TestFileEvent:
    """Tests for FileEvent dataclass."""

    def test_create_basic_event(self):
        """Test creating a basic FileEvent."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        assert event.type == FileEventType.FILE_WRITE
        assert event.path == "/inbox/test.txt"
        assert event.zone_id == "zone1"
        assert event.event_id is not None
        assert event.timestamp is not None

    def test_create_event_with_optional_fields(self):
        """Test creating event with all optional fields."""
        event = FileEvent(
            type=FileEventType.FILE_RENAME,
            path="/inbox/new.txt",
            zone_id="zone1",
            old_path="/inbox/old.txt",
            size=1024,
            content_id="abc123",
            agent_id="agent-1",
        )

        assert event.old_path == "/inbox/old.txt"
        assert event.size == 1024
        assert event.content_id == "abc123"
        assert event.agent_id == "agent-1"

    def test_to_dict_basic(self):
        """Test serializing event to dictionary."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
            event_id="event-123",
        )

        data = event.to_dict()

        assert data["type"] == "file_write"
        assert data["path"] == "/inbox/test.txt"
        assert data["zone_id"] == "zone1"
        assert data["event_id"] == "event-123"
        assert "timestamp" in data

    def test_to_dict_with_optional_fields(self):
        """Test serializing event with optional fields."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
            old_path="/inbox/old.txt",
            size=1024,
        )

        data = event.to_dict()

        assert data["old_path"] == "/inbox/old.txt"
        assert data["size"] == 1024
        assert "content_id" not in data  # None values not included
        assert "agent_id" not in data

    def test_to_json(self):
        """Test serializing event to JSON string."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        json_str = event.to_json()

        assert isinstance(json_str, str)
        parsed = json.loads(json_str)
        assert parsed["type"] == "file_write"
        assert parsed["path"] == "/inbox/test.txt"

    def test_from_dict(self):
        """Test creating event from dictionary."""
        data = {
            "type": "file_write",
            "path": "/inbox/test.txt",
            "zone_id": "zone1",
            "timestamp": "2024-01-01T00:00:00Z",
            "event_id": "event-123",
        }

        event = FileEvent.from_dict(data)

        assert event.type == "file_write"
        assert event.path == "/inbox/test.txt"
        assert event.zone_id == "zone1"
        assert event.event_id == "event-123"

    def test_from_json_string(self):
        """Test creating event from JSON string."""
        json_str = '{"type": "file_delete", "path": "/inbox/test.txt", "zone_id": "zone1"}'

        event = FileEvent.from_json(json_str)

        assert event.type == "file_delete"
        assert event.path == "/inbox/test.txt"

    def test_from_json_bytes(self):
        """Test creating event from JSON bytes."""
        json_bytes = b'{"type": "file_delete", "path": "/inbox/test.txt", "zone_id": "zone1"}'

        event = FileEvent.from_json(json_bytes)

        assert event.type == "file_delete"
        assert event.path == "/inbox/test.txt"

    def test_roundtrip_serialization(self):
        """Test event can be serialized and deserialized."""
        original = FileEvent(
            type=FileEventType.FILE_RENAME,
            path="/inbox/new.txt",
            zone_id="zone1",
            old_path="/inbox/old.txt",
            size=2048,
            content_id="hash123",
        )

        json_str = original.to_json()
        restored = FileEvent.from_json(json_str)

        assert restored.type == "file_rename"  # Note: Enum becomes string
        assert restored.path == original.path
        assert restored.zone_id == original.zone_id
        assert restored.old_path == original.old_path
        assert restored.size == original.size
        assert restored.content_id == original.content_id

    def test_event_with_none_zone_id(self):
        """Test that zone_id can be None (Layer 1 local events)."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id=None,  # Layer 1 doesn't have zone
        )

        assert event.zone_id is None

        # to_dict should not include zone_id when None
        data = event.to_dict()
        assert "zone_id" not in data

    def test_from_dict_without_zone_id(self):
        """Test that from_dict handles missing zone_id."""
        data = {
            "type": "file_write",
            "path": "/inbox/test.txt",
            # No zone_id
        }

        event = FileEvent.from_dict(data)

        assert event.zone_id is None
        assert event.type == "file_write"
        assert event.path == "/inbox/test.txt"


class TestFileEventPathMatching:
    """Tests for FileEvent path pattern matching."""

    def test_exact_match(self):
        """Test exact path matching."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/inbox/test.txt") is True
        assert event.matches_path_pattern("/inbox/other.txt") is False

    def test_directory_match(self):
        """Test directory pattern matching."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/subdir/test.txt",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/inbox/") is True
        assert event.matches_path_pattern("/inbox/subdir/") is True
        assert event.matches_path_pattern("/other/") is False

    def test_directory_exact_match(self):
        """Test matching directory itself."""
        event = FileEvent(
            type=FileEventType.DIR_CREATE,
            path="/inbox",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/inbox/") is True
        assert event.matches_path_pattern("/inbox") is True

    def test_glob_star_pattern(self):
        """Test glob * pattern matching."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/inbox/*.txt") is True
        assert event.matches_path_pattern("/inbox/*.pdf") is False
        # * does NOT cross / boundaries (proper glob semantics, not fnmatch)
        assert event.matches_path_pattern("/*.txt") is False  # * stops at /

    def test_glob_question_pattern(self):
        """Test glob ? pattern matching."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/inbox/tes?.txt") is True
        assert event.matches_path_pattern("/inbox/test.tx?") is True
        assert event.matches_path_pattern("/inbox/t?st.txt") is True

    def test_glob_double_star_pattern(self):
        """Test glob ** pattern matching."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/deep/nested/test.txt",
            zone_id="zone1",
        )

        # fnmatch uses shell patterns, ** matches any characters
        assert event.matches_path_pattern("/inbox/**") is True
        assert event.matches_path_pattern("**/*.txt") is True

    def test_rename_matches_old_path(self):
        """Test that rename events match old_path."""
        event = FileEvent(
            type=FileEventType.FILE_RENAME,
            path="/inbox/new.txt",
            zone_id="zone1",
            old_path="/inbox/old.txt",
        )

        # Matches new path
        assert event.matches_path_pattern("/inbox/new.txt") is True

        # Also matches old path
        assert event.matches_path_pattern("/inbox/old.txt") is True

        # Matches directory containing both
        assert event.matches_path_pattern("/inbox/") is True

    def test_rename_old_path_glob(self):
        """Test rename old_path with glob patterns."""
        event = FileEvent(
            type=FileEventType.FILE_RENAME,
            path="/archive/file.txt",
            zone_id="zone1",
            old_path="/inbox/file.txt",
        )

        assert event.matches_path_pattern("/inbox/*.txt") is True
        assert event.matches_path_pattern("/archive/*.txt") is True
        assert event.matches_path_pattern("/other/*.txt") is False

    def test_no_match(self):
        """Test pattern that doesn't match."""
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        assert event.matches_path_pattern("/other/test.txt") is False
        assert event.matches_path_pattern("/inbox/other.txt") is False


# =============================================================================
# RedisEventBus Tests (Mocked)
# =============================================================================


@pytest.fixture
def mock_redis_client():
    """Create a mock DragonflyClient."""
    client = MagicMock()
    client.client = MagicMock()
    client.health_check = AsyncMock(return_value=True)
    client.get_info = AsyncMock(return_value={"status": "ok"})
    return client


@pytest.fixture
def mock_pubsub():
    """Create a mock Redis PubSub."""
    pubsub = MagicMock()
    pubsub.subscribe = AsyncMock()
    pubsub.unsubscribe = AsyncMock()
    pubsub.close = AsyncMock()
    pubsub.aclose = AsyncMock()  # New async close method
    pubsub.get_message = AsyncMock(return_value=None)
    return pubsub


class TestRedisEventBus:
    """Tests for RedisEventBus with mocked Redis."""

    @pytest.mark.asyncio
    async def test_start_stop(self, mock_redis_client, mock_pubsub):
        """Test starting and stopping the event bus."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        bus = RedisEventBus(mock_redis_client)

        # Initially not started
        assert bus._started is False

        # Start
        await bus.start()
        assert bus._started is True
        mock_redis_client.client.pubsub.assert_called_once()

        # Double start is idempotent
        await bus.start()
        assert mock_redis_client.client.pubsub.call_count == 1

        # Stop
        await bus.stop()
        assert bus._started is False
        mock_pubsub.aclose.assert_called_once()

        # Double stop is idempotent
        await bus.stop()
        assert mock_pubsub.aclose.call_count == 1

    @pytest.mark.asyncio
    async def test_publish_requires_start(self, mock_redis_client):
        """Test that publish raises error if not started."""
        bus = RedisEventBus(mock_redis_client)
        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/test.txt",
            zone_id="zone1",
        )

        with pytest.raises(RuntimeError, match="not started"):
            await bus.publish(event)

    @pytest.mark.asyncio
    async def test_publish_event(self, mock_redis_client, mock_pubsub):
        """Test publishing an event."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub
        mock_redis_client.client.publish = AsyncMock(return_value=2)

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        )

        num_subscribers = await bus.publish(event)

        assert num_subscribers == 2
        mock_redis_client.client.publish.assert_called_once()
        call_args = mock_redis_client.client.publish.call_args
        assert call_args[0][0] == "nexus:events:zone1"  # Channel
        assert "file_write" in call_args[0][1]  # Message contains event type

    @pytest.mark.asyncio
    async def test_channel_name(self, mock_redis_client):
        """Test channel name generation."""
        bus = RedisEventBus(mock_redis_client)

        assert bus._channel_name("zone1") == "nexus:events:zone1"
        assert bus._channel_name("root") == "nexus:events:root"
        assert bus._channel_name("multi-zone-123") == "nexus:events:multi-zone-123"

    @pytest.mark.asyncio
    async def test_wait_for_event_requires_start(self, mock_redis_client):
        """Test that wait_for_event raises error if not started."""
        bus = RedisEventBus(mock_redis_client)

        with pytest.raises(RuntimeError, match="not started"):
            await bus.wait_for_event("zone1", "/inbox/")

    @pytest.mark.asyncio
    async def test_wait_for_event_timeout(self, mock_redis_client, mock_pubsub):
        """Test wait_for_event returns None on timeout."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub
        mock_pubsub.get_message = AsyncMock(return_value=None)

        # Create a separate pubsub for wait
        wait_pubsub = MagicMock()
        wait_pubsub.subscribe = AsyncMock()
        wait_pubsub.unsubscribe = AsyncMock()
        wait_pubsub.close = AsyncMock()
        wait_pubsub.aclose = AsyncMock()
        wait_pubsub.get_message = AsyncMock(return_value=None)

        # First call returns mock_pubsub for start(), second returns wait_pubsub
        call_count = [0]

        def create_pubsub():
            call_count[0] += 1
            if call_count[0] == 1:
                return mock_pubsub
            return wait_pubsub

        mock_redis_client.client.pubsub = create_pubsub

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        result = await bus.wait_for_event("zone1", "/inbox/", timeout=0.1)

        assert result is None

    @pytest.mark.asyncio
    async def test_wait_for_event_receives_matching_event(self, mock_redis_client, mock_pubsub):
        """Test wait_for_event returns matching event."""
        # Setup initial pubsub for start()
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        # Create wait pubsub that returns an event
        wait_pubsub = MagicMock()
        wait_pubsub.subscribe = AsyncMock()
        wait_pubsub.unsubscribe = AsyncMock()
        wait_pubsub.close = AsyncMock()
        wait_pubsub.aclose = AsyncMock()

        event_data = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        ).to_json()

        message = {
            "type": "message",
            "data": event_data.encode("utf-8"),
        }
        wait_pubsub.get_message = AsyncMock(return_value=message)

        # Track pubsub calls
        call_count = [0]

        def create_pubsub():
            call_count[0] += 1
            if call_count[0] == 1:
                return mock_pubsub
            return wait_pubsub

        mock_redis_client.client.pubsub = create_pubsub

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        result = await bus.wait_for_event("zone1", "/inbox/", timeout=5.0)

        assert result is not None
        assert result.type == "file_write"
        assert result.path == "/inbox/test.txt"

    @pytest.mark.asyncio
    async def test_wait_for_event_ignores_non_matching(self, mock_redis_client, mock_pubsub):
        """Test wait_for_event ignores non-matching events."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        # Create wait pubsub that returns non-matching then matching event
        wait_pubsub = MagicMock()
        wait_pubsub.subscribe = AsyncMock()
        wait_pubsub.unsubscribe = AsyncMock()
        wait_pubsub.close = AsyncMock()
        wait_pubsub.aclose = AsyncMock()

        non_matching_event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/other/test.txt",  # Not in /inbox/
            zone_id="zone1",
        ).to_json()

        matching_event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/inbox/test.txt",
            zone_id="zone1",
        ).to_json()

        messages = [
            {"type": "message", "data": non_matching_event.encode()},
            {"type": "message", "data": matching_event.encode()},
        ]
        message_iter = iter(messages)
        wait_pubsub.get_message = AsyncMock(side_effect=lambda **kw: next(message_iter, None))

        call_count = [0]

        def create_pubsub():
            call_count[0] += 1
            if call_count[0] == 1:
                return mock_pubsub
            return wait_pubsub

        mock_redis_client.client.pubsub = create_pubsub

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        result = await bus.wait_for_event("zone1", "/inbox/", timeout=5.0)

        assert result is not None
        assert result.path == "/inbox/test.txt"

    @pytest.mark.asyncio
    async def test_health_check_when_running(self, mock_redis_client, mock_pubsub):
        """Test health check when bus is running."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        result = await bus.health_check()

        assert result is True
        mock_redis_client.health_check.assert_called()

    @pytest.mark.asyncio
    async def test_health_check_when_not_started(self, mock_redis_client):
        """Test health check returns False when not started."""
        bus = RedisEventBus(mock_redis_client)

        result = await bus.health_check()

        assert result is False

    @pytest.mark.asyncio
    async def test_get_stats(self, mock_redis_client, mock_pubsub):
        """Test getting event bus stats."""
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        bus = RedisEventBus(mock_redis_client)
        await bus.start()

        stats = await bus.get_stats()

        assert stats["backend"] == "redis_pubsub"
        assert stats["status"] == "running"
        assert stats["channel_prefix"] == "nexus:events"


# =============================================================================
# Factory and Singleton Tests
# =============================================================================


class TestEventBusFactory:
    """Tests for self-resolving event bus factory."""

    def test_create_redis_event_bus_with_url(self):
        """Test creating Redis event bus via factory with explicit URL."""
        bus = create_event_bus(backend="redis", url="redis://localhost:6379")
        assert isinstance(bus, RedisEventBus)
        assert isinstance(bus, EventBusBase)

    def test_create_redis_no_url_raises(self):
        """Test that Redis backend raises when no URL available."""
        from unittest.mock import patch

        with (
            patch("nexus.lib.env.get_redis_url", return_value=None),
            patch("nexus.lib.env.get_dragonfly_url", return_value=None),
            pytest.raises(ValueError, match="No event bus backend available"),
        ):
            create_event_bus(backend="redis")

    def test_unsupported_backend(self):
        """Test error for unsupported backend."""
        with pytest.raises(ValueError, match="No event bus backend available"):
            create_event_bus(backend="unknown")


class TestEventBusProtocolCompliance:
    """Tests for EventBusProtocol compliance."""

    def test_redis_event_bus_implements_protocol(self, mock_redis_client):
        """Test that RedisEventBus implements EventBusProtocol."""
        bus = RedisEventBus(mock_redis_client)

        # Check protocol compliance via runtime_checkable
        assert isinstance(bus, EventBusProtocol)

        # Check all required methods exist
        assert hasattr(bus, "start")
        assert hasattr(bus, "stop")
        assert hasattr(bus, "publish")
        assert hasattr(bus, "wait_for_event")
        assert hasattr(bus, "health_check")
        assert hasattr(bus, "subscribe")  # New subscribe method


# =============================================================================
# Subscribe Method Tests
# =============================================================================


class TestSubscribeMethod:
    """Tests for the subscribe() async generator method."""

    @pytest.fixture
    def mock_redis_client(self):
        """Create a mock Redis client."""
        client = MagicMock()
        client.client = MagicMock()
        return client

    @pytest.mark.asyncio
    async def test_subscribe_yields_events(self, mock_redis_client):
        """Test that subscribe() yields FileEvent objects."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = True

        # Create mock pubsub
        mock_pubsub = AsyncMock()
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        # Prepare test events
        test_events = [
            FileEvent(
                type=FileEventType.FILE_WRITE,
                path="/test/file1.txt",
                zone_id="zone-1",
            ),
            FileEvent(
                type=FileEventType.FILE_DELETE,
                path="/test/file2.txt",
                zone_id="zone-1",
            ),
        ]

        # Mock get_message to return events then None
        call_count = [0]

        async def mock_get_message(*args, **kwargs):
            if call_count[0] < len(test_events):
                event = test_events[call_count[0]]
                call_count[0] += 1
                return {"type": "message", "data": event.to_json()}
            return None

        mock_pubsub.get_message = mock_get_message
        mock_pubsub.subscribe = AsyncMock()
        mock_pubsub.unsubscribe = AsyncMock()

        # Collect events from subscribe generator
        received = []
        count = 0
        async for event in bus.subscribe("zone-1"):
            received.append(event)
            count += 1
            if count >= 2:
                break

        assert len(received) == 2
        assert received[0].path == "/test/file1.txt"
        assert received[1].path == "/test/file2.txt"

    @pytest.mark.asyncio
    async def test_subscribe_requires_started(self, mock_redis_client):
        """Test that subscribe() raises if bus not started."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = False

        with pytest.raises(RuntimeError, match="not started"):
            async for _ in bus.subscribe("zone-1"):
                pass

    @pytest.mark.asyncio
    async def test_subscribe_filters_non_message_types(self, mock_redis_client):
        """Test that subscribe() ignores non-message types."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = True

        mock_pubsub = AsyncMock()
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        # Create one real event
        test_event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/test/file.txt",
            zone_id="zone-1",
        )

        # Mock get_message to return subscribe confirmation, then event, then None
        messages = [
            {"type": "subscribe", "data": 1},  # Should be ignored
            {"type": "message", "data": test_event.to_json()},  # Should be yielded
        ]
        call_count = [0]

        async def mock_get_message(*args, **kwargs):
            if call_count[0] < len(messages):
                msg = messages[call_count[0]]
                call_count[0] += 1
                return msg
            return None

        mock_pubsub.get_message = mock_get_message
        mock_pubsub.subscribe = AsyncMock()

        received = []
        count = 0
        async for event in bus.subscribe("zone-1"):
            received.append(event)
            count += 1
            if count >= 1:
                break

        # Should only receive the actual message, not the subscribe confirmation
        assert len(received) == 1
        assert received[0].path == "/test/file.txt"


# =============================================================================
# Error Recovery Tests
# =============================================================================


class TestErrorRecovery:
    """Tests for error handling and recovery scenarios."""

    @pytest.fixture
    def mock_redis_client(self):
        """Create a mock Redis client."""
        client = MagicMock()
        client.client = MagicMock()
        return client

    @pytest.mark.asyncio
    async def test_publish_handles_redis_error(self, mock_redis_client):
        """Test that publish() handles Redis errors gracefully."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = True

        # Make publish raise an exception
        mock_redis_client.client.publish = AsyncMock(side_effect=Exception("Redis connection lost"))

        event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/test/file.txt",
            zone_id="zone-1",
        )

        # Should raise the error (caller should handle)
        with pytest.raises(Exception, match="Redis connection lost"):
            await bus.publish(event)

    @pytest.mark.asyncio
    async def test_wait_for_event_handles_malformed_json(self, mock_redis_client):
        """Test that wait_for_event() handles malformed JSON gracefully."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = True

        mock_pubsub = AsyncMock()
        mock_redis_client.client.pubsub.return_value = mock_pubsub

        # Return malformed JSON, then valid event
        valid_event = FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/test/file.txt",
            zone_id="zone-1",
        )

        messages = [
            {"type": "message", "data": "not valid json{{{"},  # Malformed
            {"type": "message", "data": valid_event.to_json()},  # Valid
        ]
        call_count = [0]

        async def mock_get_message(*args, **kwargs):
            if call_count[0] < len(messages):
                msg = messages[call_count[0]]
                call_count[0] += 1
                return msg
            return None

        mock_pubsub.get_message = mock_get_message
        mock_pubsub.subscribe = AsyncMock()
        mock_pubsub.unsubscribe = AsyncMock()

        # Should skip malformed message and return valid one
        result = await bus.wait_for_event("zone-1", "/test/", timeout=1.0)

        assert result is not None
        assert result.path == "/test/file.txt"

    @pytest.mark.asyncio
    async def test_health_check_returns_false_on_error(self, mock_redis_client):
        """Test that health_check() returns False on Redis error."""
        bus = RedisEventBus(mock_redis_client)
        bus._started = True

        # Make ping raise an exception
        mock_redis_client.client.ping = AsyncMock(side_effect=Exception("Connection refused"))

        result = await bus.health_check()
        assert result is False


# =============================================================================
# AckableEvent Tests (Issue #1331)
# =============================================================================


class TestAckableEvent:
    """Tests for AckableEvent acknowledgment semantics."""

    def _make_event(self) -> FileEvent:
        return FileEvent(
            type=FileEventType.FILE_WRITE,
            path="/test/file.txt",
            zone_id="zone1",
        )

    @pytest.mark.asyncio
    async def test_ack_calls_ack_fn(self):
        """Test that ack() invokes the callback."""
        ack_fn = AsyncMock()
        ackable = AckableEvent(event=self._make_event(), _ack_fn=ack_fn)

        await ackable.ack()

        ack_fn.assert_called_once()

    @pytest.mark.asyncio
    async def test_nack_calls_nack_fn_with_delay(self):
        """Test that nack(delay=5.0) invokes with delay."""
        nack_fn = AsyncMock()
        ackable = AckableEvent(event=self._make_event(), _nack_fn=nack_fn)

        await ackable.nack(delay=5.0)

        nack_fn.assert_called_once_with(5.0)

    @pytest.mark.asyncio
    async def test_nack_without_delay(self):
        """Test that nack() invokes with None delay."""
        nack_fn = AsyncMock()
        ackable = AckableEvent(event=self._make_event(), _nack_fn=nack_fn)

        await ackable.nack()

        nack_fn.assert_called_once_with(None)

    @pytest.mark.asyncio
    async def test_no_ack_fn_is_noop(self):
        """Test that ack() with None callback does nothing (Redis compat)."""
        ackable = AckableEvent(event=self._make_event())

        # Should not raise
        await ackable.ack()
        await ackable.nack()
        await ackable.in_progress()

    @pytest.mark.asyncio
    async def test_double_ack_is_idempotent(self):
        """Test that calling ack() twice doesn't error."""
        ack_fn = AsyncMock()
        ackable = AckableEvent(event=self._make_event(), _ack_fn=ack_fn)

        await ackable.ack()
        await ackable.ack()

        assert ack_fn.call_count == 2

    @pytest.mark.asyncio
    async def test_in_progress_extends_deadline(self):
        """Test that in_progress() invokes callback."""
        in_progress_fn = AsyncMock()
        ackable = AckableEvent(event=self._make_event(), _in_progress_fn=in_progress_fn)

        await ackable.in_progress()

        in_progress_fn.assert_called_once()

    def test_event_accessible(self):
        """Test that ackable.event returns the FileEvent."""
        event = self._make_event()
        ackable = AckableEvent(event=event)

        assert ackable.event is event
        assert ackable.event.path == "/test/file.txt"


# =============================================================================
# Extended Factory Tests (Issue #1331)
# =============================================================================


class TestEventBusFactoryExtended:
    """Extended factory tests including NATS backend."""

    def test_create_nats_event_bus(self):
        """Test creating NATS event bus via factory."""
        from unittest.mock import patch

        with patch("nexus.services.event_bus.nats.NatsEventBus") as MockNats:
            MockNats.return_value = MagicMock()
            create_event_bus(backend="nats", nats_url="nats://test:4222")
            MockNats.assert_called_once_with(nats_url="nats://test:4222")

    def test_create_nats_uses_default_url(self):
        """Test that NATS backend uses DEFAULT_NATS_URL when no url override."""
        from unittest.mock import patch

        with patch("nexus.services.event_bus.nats.NatsEventBus") as MockNats:
            MockNats.return_value = MagicMock()
            create_event_bus(backend="nats")
            # Should use DEFAULT_NATS_URL from constants
            MockNats.assert_called_once()

    def test_create_redis_with_url(self):
        """Test that Redis backend works with explicit URL."""
        bus = create_event_bus(backend="redis", url="redis://localhost:6379")
        assert isinstance(bus, RedisEventBus)

    def test_unsupported_backend_still_raises(self):
        """Test error for unsupported backend raises ValueError."""
        from unittest.mock import patch

        with (
            patch("nexus.lib.env.get_redis_url", return_value=None),
            patch("nexus.lib.env.get_dragonfly_url", return_value=None),
            pytest.raises(ValueError, match="No event bus backend available"),
        ):
            create_event_bus(backend="unknown")
