"""Tests for Dead Letter Queue (DLQ) handler."""

import json
from unittest.mock import AsyncMock, patch

import pytest

from libs.events.base import EventPayload
from libs.events.dlq import DLQHandler, DLQMetrics


@pytest.fixture
def event_payload():
    """Create a test event payload."""
    return EventPayload(
        data={"test": "data", "value": 123},
        actor="test-user",
        tenant_id="test-tenant",
        trace_id="test-trace-123",
        schema_version="1.0",
    )


@pytest.fixture
def mock_js():
    """Create a mock JetStream context."""
    js = AsyncMock()
    js.stream_info = AsyncMock()
    js.add_stream = AsyncMock()
    js.publish = AsyncMock()
    return js


class TestDLQHandler:
    """Test cases for DLQ handler."""

    @pytest.mark.asyncio
    async def test_initialization(self, mock_js):
        """Test DLQ handler initialization."""
        handler = DLQHandler(
            js=mock_js,
            dlq_stream_name="TEST_DLQ",
            max_retries=5,
            backoff_base_ms=500,
        )

        assert handler.js == mock_js
        assert handler.dlq_stream_name == "TEST_DLQ"
        assert handler.max_retries == 5
        assert handler.backoff_base_ms == 500

    @pytest.mark.asyncio
    async def test_ensure_dlq_stream_exists_already_exists(self, mock_js):
        """Test ensuring DLQ stream when it already exists."""
        mock_js.stream_info.return_value = {"name": "TEST_DLQ"}

        handler = DLQHandler(js=mock_js, dlq_stream_name="TEST_DLQ")
        await handler.ensure_dlq_stream_exists()

        mock_js.stream_info.assert_called_once_with("TEST_DLQ")
        mock_js.add_stream.assert_not_called()

    @pytest.mark.asyncio
    async def test_ensure_dlq_stream_creates_stream(self, mock_js):
        """Test ensuring DLQ stream when it doesn't exist."""
        from nats.js.errors import NotFoundError

        mock_js.stream_info.side_effect = NotFoundError
        mock_js.add_stream = AsyncMock()

        handler = DLQHandler(js=mock_js, dlq_stream_name="TEST_DLQ")
        await handler.ensure_dlq_stream_exists()

        mock_js.add_stream.assert_called_once()
        call_kwargs = mock_js.add_stream.call_args[1]
        assert call_kwargs["name"] == "TEST_DLQ"
        assert call_kwargs["subjects"] == ["TEST_DLQ.*"]

    @pytest.mark.asyncio
    async def test_send_to_dlq(self, mock_js, event_payload):
        """Test sending event to DLQ."""
        handler = DLQHandler(js=mock_js)

        error = ValueError("Test error message")
        await handler.send_to_dlq(
            topic="test-topic",
            payload=event_payload,
            error=error,
            retry_count=3,
        )

        mock_js.publish.assert_called_once()
        call_kwargs = mock_js.publish.call_args[1]

        # Verify subject
        assert call_kwargs["subject"] == "TAX_AGENT_DLQ.test-topic"

        # Verify payload content
        payload_data = json.loads(call_kwargs["payload"].decode())
        assert payload_data["original_topic"] == "test-topic"
        assert payload_data["retry_count"] == 3
        assert payload_data["error"]["type"] == "ValueError"
        assert payload_data["error"]["message"] == "Test error message"

        # Verify headers
        headers = call_kwargs["headers"]
        assert headers["original_topic"] == "test-topic"
        assert headers["event_id"] == event_payload.event_id
        assert headers["error_type"] == "ValueError"

    @pytest.mark.asyncio
    async def test_send_to_dlq_with_original_message(self, mock_js, event_payload):
        """Test sending event to DLQ with original message data."""
        handler = DLQHandler(js=mock_js)

        original_message = b'{"test": "original"}'
        error = RuntimeError("Processing failed")

        await handler.send_to_dlq(
            topic="test-topic",
            payload=event_payload,
            error=error,
            retry_count=2,
            original_message_data=original_message,
        )

        call_kwargs = mock_js.publish.call_args[1]
        payload_data = json.loads(call_kwargs["payload"].decode())

        assert "original_message_data" in payload_data
        assert payload_data["original_message_data"] == '{"test": "original"}'

    @pytest.mark.asyncio
    async def test_send_to_dlq_handles_publish_failure(self, mock_js, event_payload):
        """Test DLQ handler when DLQ publish fails."""
        mock_js.publish.side_effect = Exception("DLQ publish failed")

        handler = DLQHandler(js=mock_js)

        # Should not raise, but log critical error
        await handler.send_to_dlq(
            topic="test-topic",
            payload=event_payload,
            error=ValueError("Original error"),
            retry_count=1,
        )

        # Verify publish was attempted
        mock_js.publish.assert_called_once()

    def test_calculate_backoff(self, mock_js):
        """Test exponential backoff calculation."""
        handler = DLQHandler(
            js=mock_js,
            backoff_base_ms=1000,
            backoff_multiplier=2.0,
            backoff_max_ms=10000,
        )

        # First retry: 1000ms * 2^0 = 1000ms = 1s
        assert handler.calculate_backoff(0) == 1.0

        # Second retry: 1000ms * 2^1 = 2000ms = 2s
        assert handler.calculate_backoff(1) == 2.0

        # Third retry: 1000ms * 2^2 = 4000ms = 4s
        assert handler.calculate_backoff(2) == 4.0

        # Fourth retry: 1000ms * 2^3 = 8000ms = 8s
        assert handler.calculate_backoff(3) == 8.0

        # Fifth retry: would be 16000ms but capped at 10000ms = 10s
        assert handler.calculate_backoff(4) == 10.0

    @pytest.mark.asyncio
    async def test_retry_with_backoff_success_first_attempt(self, mock_js):
        """Test successful operation on first attempt."""
        handler = DLQHandler(js=mock_js, max_retries=3)

        async def successful_func():
            return "success"

        success, error = await handler.retry_with_backoff(successful_func)

        assert success is True
        assert error is None

    @pytest.mark.asyncio
    async def test_retry_with_backoff_success_after_retries(self, mock_js):
        """Test successful operation after retries."""
        handler = DLQHandler(
            js=mock_js,
            max_retries=3,
            backoff_base_ms=100,  # Short backoff for testing
        )

        attempt_count = 0

        async def flaky_func():
            nonlocal attempt_count
            attempt_count += 1
            if attempt_count < 3:
                raise ValueError(f"Fail attempt {attempt_count}")
            return "success"

        with patch("asyncio.sleep", new=AsyncMock()):  # Speed up test
            success, error = await handler.retry_with_backoff(flaky_func)

        assert success is True
        assert error is None
        assert attempt_count == 3

    @pytest.mark.asyncio
    async def test_retry_with_backoff_all_attempts_fail(self, mock_js):
        """Test operation that fails all retry attempts."""
        handler = DLQHandler(
            js=mock_js,
            max_retries=2,
            backoff_base_ms=100,
        )

        async def always_fails():
            raise ValueError("Always fails")

        with patch("asyncio.sleep", new=AsyncMock()):  # Speed up test
            success, error = await handler.retry_with_backoff(always_fails)

        assert success is False
        assert isinstance(error, ValueError)
        assert str(error) == "Always fails"

    @pytest.mark.asyncio
    async def test_retry_with_backoff_applies_delay(self, mock_js):
        """Test that retry applies backoff delay."""
        handler = DLQHandler(
            js=mock_js,
            max_retries=2,
            backoff_base_ms=1000,
            backoff_multiplier=2.0,
        )

        attempt_count = 0

        async def failing_func():
            nonlocal attempt_count
            attempt_count += 1
            raise ValueError("Fail")

        with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep:
            await handler.retry_with_backoff(failing_func)

        # Should have called sleep twice (after 1st and 2nd failures)
        assert mock_sleep.call_count == 2

        # Verify backoff delays
        calls = mock_sleep.call_args_list
        assert calls[0][0][0] == 1.0  # First retry: 1s
        assert calls[1][0][0] == 2.0  # Second retry: 2s


class TestDLQMetrics:
    """Test cases for DLQ metrics."""

    def test_initialization(self):
        """Test metrics initialization."""
        metrics = DLQMetrics()

        assert metrics.total_dlq_events == 0
        assert len(metrics.dlq_events_by_topic) == 0
        assert len(metrics.dlq_events_by_error_type) == 0

    def test_record_dlq_event(self):
        """Test recording DLQ events."""
        metrics = DLQMetrics()

        metrics.record_dlq_event("topic1", "ValueError")
        metrics.record_dlq_event("topic1", "ValueError")
        metrics.record_dlq_event("topic2", "RuntimeError")

        assert metrics.total_dlq_events == 3
        assert metrics.dlq_events_by_topic["topic1"] == 2
        assert metrics.dlq_events_by_topic["topic2"] == 1
        assert metrics.dlq_events_by_error_type["ValueError"] == 2
        assert metrics.dlq_events_by_error_type["RuntimeError"] == 1

    def test_get_metrics(self):
        """Test getting metrics snapshot."""
        metrics = DLQMetrics()

        metrics.record_dlq_event("topic1", "ValueError")
        metrics.record_dlq_event("topic1", "RuntimeError")

        snapshot = metrics.get_metrics()

        assert snapshot["total_dlq_events"] == 2
        assert snapshot["by_topic"]["topic1"] == 2
        assert snapshot["by_error_type"]["ValueError"] == 1
        assert snapshot["by_error_type"]["RuntimeError"] == 1

        # Verify it's a copy, not a reference
        snapshot["total_dlq_events"] = 999
        assert metrics.total_dlq_events == 2

    def test_reset(self):
        """Test resetting metrics."""
        metrics = DLQMetrics()

        metrics.record_dlq_event("topic1", "ValueError")
        metrics.record_dlq_event("topic2", "RuntimeError")

        assert metrics.total_dlq_events == 2

        metrics.reset()

        assert metrics.total_dlq_events == 0
        assert len(metrics.dlq_events_by_topic) == 0
        assert len(metrics.dlq_events_by_error_type) == 0
