"""Tests for resilient message bus functionality."""

import asyncio
from dataclasses import dataclass
from datetime import datetime
from unittest.mock import Mock

import pytest
import pytest_asyncio

from llmgine.bus.resilience import (
    ResilientMessageBus,
    RetryConfig,
)
from llmgine.llm import SessionID
from llmgine.messages.commands import Command, CommandResult
from llmgine.messages.events import DeadLetterCommandEvent


@dataclass
class SimpleTestCommand(Command):
    """Simple test command."""

    __test__ = False
    test_data: str = "test"


@dataclass
class AlwaysFailingCommand(Command):
    """Command that always fails."""

    __test__ = False


@pytest_asyncio.fixture
async def resilient_bus():
    """Create a resilient message bus for testing."""
    # Force create a new instance by clearing the singleton
    if hasattr(ResilientMessageBus, "_instance"):
        ResilientMessageBus._instance = None

    bus = ResilientMessageBus(
        retry_config=RetryConfig(
            max_retries=2,
            initial_delay=0.01,  # Fast retries for testing
            max_delay=0.1,
            jitter=False,  # Deterministic for testing
        ),
        max_dead_letter_size=10,
    )
    await bus.start()
    yield bus
    await bus.stop()
    # Clear singleton after test
    if hasattr(ResilientMessageBus, "_instance"):
        ResilientMessageBus._instance = None


@pytest.fixture
def mock_observability():
    """Mock observability manager."""
    mock = Mock()
    mock.observe_event = Mock()
    return mock


class TestResilientMessageBus:
    """Test resilient message bus functionality."""

    @pytest.mark.asyncio
    async def test_successful_command_execution(self, resilient_bus):
        """Test that successful commands execute without retry."""
        handler_called = 0

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            nonlocal handler_called
            handler_called += 1
            return CommandResult(
                success=True, command_id=cmd.command_id, result={"message": "Success"}
            )

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        cmd = SimpleTestCommand(session_id=SessionID("test-session"))
        result = await resilient_bus.execute(cmd)

        assert result.success is True
        assert handler_called == 1  # Should not retry on success
        assert result.result["message"] == "Success"

    @pytest.mark.asyncio
    async def test_command_retry_on_failure(self, resilient_bus):
        """Test that failing commands are retried."""
        handler_calls = []

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            handler_calls.append(datetime.now())
            if len(handler_calls) < 3:
                raise Exception(f"Failure {len(handler_calls)}")
            return CommandResult(
                success=True,
                command_id=cmd.command_id,
                result={"attempts": len(handler_calls)},
            )

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        cmd = SimpleTestCommand(session_id=SessionID("test-session"))
        result = await resilient_bus.execute(cmd)

        assert result.success is True
        assert len(handler_calls) == 3  # Initial + 2 retries
        assert result.result["attempts"] == 3

    @pytest.mark.asyncio
    async def test_command_added_to_dead_letter_after_max_retries(self, resilient_bus):
        """Test that commands exceeding retry limit go to dead letter queue."""
        handler_calls = 0

        async def handler(cmd: AlwaysFailingCommand) -> CommandResult:
            nonlocal handler_calls
            handler_calls += 1
            raise Exception("Always fails")

        resilient_bus.register_command_handler(AlwaysFailingCommand, handler)

        cmd = AlwaysFailingCommand(session_id=SessionID("test-session"))
        result = await resilient_bus.execute(cmd)

        assert result.success is False
        assert handler_calls == 3  # Initial + 2 retries (max_retries=2)
        assert "failed after 3 attempts" in result.error
        assert result.metadata["dead_letter"] is True

        # Check dead letter queue
        assert resilient_bus.dead_letter_queue_size == 1
        entries = await resilient_bus.get_dead_letter_entries()
        assert len(entries) == 1
        assert entries[0].command.command_id == cmd.command_id
        assert entries[0].attempts == 3

    @pytest.mark.asyncio
    async def test_exponential_backoff(self, resilient_bus):
        """Test exponential backoff between retries."""
        retry_times = []

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            retry_times.append(datetime.now())
            raise Exception("Fail to test backoff")

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        cmd = SimpleTestCommand(session_id=SessionID("test-session"))
        await resilient_bus.execute(cmd)

        assert len(retry_times) == 3  # Initial + 2 retries

        # Check delays between attempts (should increase)
        delay1 = (retry_times[1] - retry_times[0]).total_seconds()
        delay2 = (retry_times[2] - retry_times[1]).total_seconds()

        # With exponential base 2: first delay ~0.01s, second ~0.02s
        assert 0.005 < delay1 < 0.015  # Allow some variance
        assert 0.015 < delay2 < 0.025
        assert delay2 > delay1  # Exponential increase

    @pytest.mark.asyncio
    async def test_error_tracking(self, resilient_bus):
        """Test that error statistics are tracked correctly."""
        attempt_count = 0
        command_count = 0

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            nonlocal attempt_count, command_count
            attempt_count += 1

            # Track which command we're on
            if attempt_count == 1:
                command_count = 1
            elif attempt_count == 4:  # After first command succeeds
                command_count = 2

            # First command: fail twice, then succeed
            if command_count == 1 and attempt_count <= 2:
                raise Exception("Tracked failure")
            # Second command: always fail
            elif command_count == 2:
                raise Exception("Always fails")

            return CommandResult(success=True, command_id=cmd.command_id)

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        # First command fails twice then succeeds
        cmd1 = SimpleTestCommand(session_id=SessionID("test-session"))
        result1 = await resilient_bus.execute(cmd1)
        assert result1.success  # Should succeed on 3rd attempt

        # Second command fails all attempts
        cmd2 = SimpleTestCommand(session_id=SessionID("test-session"))
        result2 = await resilient_bus.execute(cmd2)
        assert not result2.success  # Should fail after all retries

        stats = resilient_bus.get_handler_error_stats(SessionID("test-session"))
        handler_stats = stats["test-session"]["SimpleTestCommand"]

        assert handler_stats["total_executions"] == 2  # Two commands
        assert handler_stats["failure_count"] == 5  # 2 failures for cmd1, 3 for cmd2
        assert handler_stats["failure_rate"] == 5 / 2
        assert handler_stats["last_failure"] is not None

    @pytest.mark.asyncio
    async def test_retry_from_dead_letter_queue(self, resilient_bus):
        """Test retrying a command from dead letter queue."""
        attempts = 0

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            nonlocal attempts
            attempts += 1
            if attempts <= 3:
                raise Exception("Initial failures")
            return CommandResult(
                success=True,
                command_id=cmd.command_id,
                result={"final_attempts": attempts},
            )

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        # First execution fails
        cmd = SimpleTestCommand(session_id=SessionID("test-session"))
        result = await resilient_bus.execute(cmd)
        assert result.success is False
        assert resilient_bus.dead_letter_queue_size == 1

        # Retry from dead letter
        retry_result = await resilient_bus.retry_dead_letter_entry(cmd.command_id)
        assert retry_result is not None
        assert retry_result.success is True
        assert retry_result.result["final_attempts"] == 4
        assert resilient_bus.dead_letter_queue_size == 0

    @pytest.mark.asyncio
    @pytest.mark.skip(reason="Test takes too long with retries")
    async def test_dead_letter_queue_limit(self, resilient_bus):
        """Test that dead letter queue respects size limit."""

        async def handler(cmd: AlwaysFailingCommand) -> CommandResult:
            raise Exception("Always fails")

        resilient_bus.register_command_handler(AlwaysFailingCommand, handler)

        # Use smaller number to reduce test time - each command does 3 retries
        # With 5 commands, that's 15 failures total
        for i in range(5):  # Reduced from 12
            cmd = AlwaysFailingCommand(session_id=SessionID("test-session"))
            await resilient_bus.execute(cmd)

        # Dead letter queue should have entries
        assert resilient_bus.dead_letter_queue_size >= 5

    @pytest.mark.asyncio
    async def test_concurrent_retries(self, resilient_bus):
        """Test that multiple commands can retry concurrently."""
        handler_calls = {1: [], 2: []}

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            cmd_num = int(cmd.test_data)
            handler_calls[cmd_num].append(datetime.now())

            if len(handler_calls[cmd_num]) < 2:
                await asyncio.sleep(0.01)  # Simulate work
                raise Exception(f"Failure for command {cmd_num}")

            return CommandResult(
                success=True, command_id=cmd.command_id, result={"cmd_num": cmd_num}
            )

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        # Execute two commands concurrently
        cmd1 = SimpleTestCommand(session_id=SessionID("test"), test_data="1")
        cmd2 = SimpleTestCommand(session_id=SessionID("test"), test_data="2")

        results = await asyncio.gather(
            resilient_bus.execute(cmd1), resilient_bus.execute(cmd2)
        )

        assert all(r.success for r in results)
        assert len(handler_calls[1]) == 2
        assert len(handler_calls[2]) == 2

    @pytest.mark.asyncio
    async def test_retry_with_jitter(self):
        """Test retry with jitter enabled."""
        bus = ResilientMessageBus(
            retry_config=RetryConfig(max_retries=3, initial_delay=0.1, jitter=True)
        )

        # Test multiple delay calculations to ensure jitter
        delays = []
        for _ in range(10):
            delay = bus._calculate_retry_delay(1)
            delays.append(delay)

        # With jitter, delays should vary
        assert len(set(delays)) > 1
        assert all(0 <= d <= 0.1 for d in delays)  # Full jitter: 0 to initial_delay

    @pytest.mark.asyncio
    async def test_handler_returning_failure(self, resilient_bus):
        """Test retry behavior when handler returns failure (not exception)."""
        attempts = 0

        async def handler(cmd: SimpleTestCommand) -> CommandResult:
            nonlocal attempts
            attempts += 1
            if attempts < 3:
                return CommandResult(
                    success=False,
                    command_id=cmd.command_id,
                    error="Handler returned failure",
                )
            return CommandResult(success=True, command_id=cmd.command_id)

        resilient_bus.register_command_handler(SimpleTestCommand, handler)

        cmd = SimpleTestCommand(session_id=SessionID("test"))
        result = await resilient_bus.execute(cmd)

        assert result.success is True
        assert attempts == 3  # Should retry on handler-returned failures too

    @pytest.mark.asyncio
    async def test_dead_letter_event_is_typed(self, resilient_bus):
        """Verify we publish a typed dead-letter event."""
        seen: list[DeadLetterCommandEvent] = []

        async def on_dead_letter(evt: DeadLetterCommandEvent):
            seen.append(evt)

        async def failing(cmd: AlwaysFailingCommand) -> CommandResult:
            raise Exception("Always fails")

        resilient_bus.register_event_handler(DeadLetterCommandEvent, on_dead_letter)
        resilient_bus.register_command_handler(AlwaysFailingCommand, failing)

        result = await resilient_bus.execute(AlwaysFailingCommand(session_id=SessionID("t")))
        assert result.success is False
        # Give the bus a moment to process the event
        await asyncio.sleep(0.05)
        assert len(seen) == 1
        assert seen[0].attempts >= 1
