"""Tests property-based pour RabbitMQ Publisher et Consumer."""

from __future__ import annotations

import json
from typing import Any
from unittest.mock import MagicMock, patch

from hypothesis import given, settings
from hypothesis import strategies as st
from pydantic import BaseModel

from cezam_lib.cezam_shared.rabbitmq import RabbitMQPublisher, RabbitMQConsumer
from cezam_lib.cezam_shared.exceptions import RetryableError, NonRetryableError


json_primitives = st.one_of(
    st.none(),
    st.booleans(),
    st.integers(min_value=-(2**53), max_value=2**53),
    st.floats(allow_nan=False, allow_infinity=False),
    st.text(max_size=100),
)


def json_values() -> st.SearchStrategy[Any]:
    """Génère des valeurs JSON valides (récursif)."""
    return st.recursive(
        json_primitives,
        lambda children: st.one_of(
            st.lists(children, max_size=10),
            st.dictionaries(st.text(min_size=1, max_size=20), children, max_size=10),
        ),
        max_leaves=50,
    )


json_objects = st.dictionaries(
    st.text(min_size=1, max_size=20),
    json_values(),
    min_size=1,
    max_size=10,
)


class DynamicModel(BaseModel):
    """Modèle Pydantic dynamique pour les tests."""

    name: str
    value: int
    tags: list[str] = []
    metadata: dict[str, Any] = {}


pydantic_models = st.builds(
    DynamicModel,
    name=st.text(min_size=1, max_size=50),
    value=st.integers(min_value=-1000000, max_value=1000000),
    tags=st.lists(st.text(min_size=1, max_size=20), max_size=5),
    metadata=st.dictionaries(
        st.text(min_size=1, max_size=20),
        st.one_of(st.text(max_size=50), st.integers(), st.booleans()),
        max_size=5,
    ),
)


exchange_names = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")
routing_keys = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")
queue_names = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")


class TestOtelTraceContextPropagation:
    """Tests property-based pour la propagation du contexte OTel."""

    @given(message=json_objects, exchange=exchange_names, routing_key=routing_keys)
    @settings(max_examples=100)
    def test_publish_injects_otel_headers_when_span_active(
        self, message: dict[str, Any], exchange: str, routing_key: str
    ) -> None:
        """Quand un span OTel est actif, les headers doivent contenir traceparent."""
        captured_properties = None

        def capture_basic_publish(**kwargs):
            nonlocal captured_properties
            captured_properties = kwargs.get("properties")

        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            mock_channel = MagicMock()
            mock_connection.return_value.channel.return_value = mock_channel
            mock_connection.return_value.is_closed = False
            mock_channel.is_closed = False
            mock_channel.basic_publish.side_effect = capture_basic_publish

            with patch("cezam_lib.cezam_shared.rabbitmq.inject") as mock_inject:
                def inject_trace(headers):
                    headers["traceparent"] = "00-abcd1234-efgh5678-01"

                mock_inject.side_effect = inject_trace

                publisher = RabbitMQPublisher(
                    host="localhost",
                    port=5672,
                    user="guest",
                    password="guest",
                )

                publisher.publish(
                    exchange=exchange,
                    routing_key=routing_key,
                    message=message,
                )

                mock_inject.assert_called_once()
                assert captured_properties is not None
                assert captured_properties.headers is not None
                assert "traceparent" in captured_properties.headers

    @given(model=pydantic_models, exchange=exchange_names, routing_key=routing_keys)
    @settings(max_examples=100)
    def test_publish_pydantic_model_injects_otel_headers(
        self, model: DynamicModel, exchange: str, routing_key: str
    ) -> None:
        """La propagation OTel fonctionne aussi avec les modèles Pydantic."""
        captured_properties = None

        def capture_basic_publish(**kwargs):
            nonlocal captured_properties
            captured_properties = kwargs.get("properties")

        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            mock_channel = MagicMock()
            mock_connection.return_value.channel.return_value = mock_channel
            mock_connection.return_value.is_closed = False
            mock_channel.is_closed = False
            mock_channel.basic_publish.side_effect = capture_basic_publish

            with patch("cezam_lib.cezam_shared.rabbitmq.inject") as mock_inject:
                def inject_trace(headers):
                    headers["traceparent"] = "00-trace-span-01"
                    headers["tracestate"] = "vendor=value"

                mock_inject.side_effect = inject_trace

                publisher = RabbitMQPublisher(
                    host="localhost",
                    port=5672,
                    user="guest",
                    password="guest",
                )

                publisher.publish(
                    exchange=exchange,
                    routing_key=routing_key,
                    message=model,
                )

                assert captured_properties is not None
                assert captured_properties.headers is not None
                assert "traceparent" in captured_properties.headers

    @given(message=json_objects, exchange=exchange_names, routing_key=routing_keys)
    @settings(max_examples=50)
    def test_publish_without_active_span_has_empty_or_no_headers(
        self, message: dict[str, Any], exchange: str, routing_key: str
    ) -> None:
        """Sans span actif, inject() ne modifie pas les headers."""
        captured_properties = None

        def capture_basic_publish(**kwargs):
            nonlocal captured_properties
            captured_properties = kwargs.get("properties")

        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            mock_channel = MagicMock()
            mock_connection.return_value.channel.return_value = mock_channel
            mock_connection.return_value.is_closed = False
            mock_channel.is_closed = False
            mock_channel.basic_publish.side_effect = capture_basic_publish

            with patch("cezam_lib.cezam_shared.rabbitmq.inject") as mock_inject:
                mock_inject.side_effect = lambda headers: None

                publisher = RabbitMQPublisher(
                    host="localhost",
                    port=5672,
                    user="guest",
                    password="guest",
                )

                publisher.publish(
                    exchange=exchange,
                    routing_key=routing_key,
                    message=message,
                )

                mock_inject.assert_called_once()
                assert captured_properties is not None
                assert captured_properties.headers is None or captured_properties.headers == {}


class TestRetryBehaviorConsistency:
    """Tests property-based pour le comportement retry du consumer."""

    def _create_consumer_and_trigger_message(
        self, mock_connection, callback, message_body, headers=None
    ):
        """Helper pour créer un consumer et simuler un message."""
        mock_channel = MagicMock()
        mock_connection.return_value.channel.return_value = mock_channel
        mock_connection.return_value.is_closed = False
        mock_channel.is_closed = False

        captured_on_message = None

        def capture_callback(**kwargs):
            nonlocal captured_on_message
            captured_on_message = kwargs["on_message_callback"]
            return "consumer-tag"

        mock_channel.basic_consume.side_effect = capture_callback

        consumer = RabbitMQConsumer(
            host="localhost",
            port=5672,
            user="guest",
            password="guest",
        )

        consumer._should_stop = True
        consumer.consume(queue="test-queue", callback=callback)

        mock_method = MagicMock()
        mock_method.delivery_tag = 42

        mock_properties = MagicMock()
        mock_properties.headers = headers

        body = json.dumps(message_body).encode("utf-8")

        consumer._should_stop = False
        captured_on_message(mock_channel, mock_method, mock_properties, body)

        return mock_channel

    @given(message=json_objects, error_msg=st.text(min_size=1, max_size=100))
    @settings(max_examples=100)
    def test_retryable_error_nacks_with_requeue_true(
        self, message: dict[str, Any], error_msg: str
    ) -> None:
        """Une RetryableError doit toujours entraîner un nack avec requeue=True."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            callback = MagicMock(side_effect=RetryableError(error_msg))

            mock_channel = self._create_consumer_and_trigger_message(
                mock_connection, callback, message
            )

            mock_channel.basic_ack.assert_not_called()
            mock_channel.basic_nack.assert_called_once_with(
                delivery_tag=42, requeue=True
            )

    @given(message=json_objects, error_msg=st.text(min_size=1, max_size=100))
    @settings(max_examples=100)
    def test_non_retryable_error_nacks_with_requeue_false(
        self, message: dict[str, Any], error_msg: str
    ) -> None:
        """Une NonRetryableError doit toujours entraîner un nack avec requeue=False."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            callback = MagicMock(side_effect=NonRetryableError(error_msg))

            mock_channel = self._create_consumer_and_trigger_message(
                mock_connection, callback, message
            )

            mock_channel.basic_ack.assert_not_called()
            mock_channel.basic_nack.assert_called_once_with(
                delivery_tag=42, requeue=False
            )

    generic_exceptions = st.sampled_from([
        ValueError,
        TypeError,
        KeyError,
        RuntimeError,
        AttributeError,
        IndexError,
        ZeroDivisionError,
    ])

    @given(
        message=json_objects,
        exception_type=generic_exceptions,
        error_msg=st.text(min_size=1, max_size=100),
    )
    @settings(max_examples=100)
    def test_generic_exception_nacks_with_requeue_false(
        self, message: dict[str, Any], exception_type: type, error_msg: str
    ) -> None:
        """Toute exception générique doit entraîner un nack avec requeue=False."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            callback = MagicMock(side_effect=exception_type(error_msg))

            mock_channel = self._create_consumer_and_trigger_message(
                mock_connection, callback, message
            )

            mock_channel.basic_ack.assert_not_called()
            mock_channel.basic_nack.assert_called_once_with(
                delivery_tag=42, requeue=False
            )

    @given(message=json_objects)
    @settings(max_examples=100)
    def test_successful_callback_sends_ack(self, message: dict[str, Any]) -> None:
        """Un callback réussi doit toujours envoyer un ack."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            callback = MagicMock()

            mock_channel = self._create_consumer_and_trigger_message(
                mock_connection, callback, message
            )

            callback.assert_called_once_with(message)
            mock_channel.basic_ack.assert_called_once_with(delivery_tag=42)
            mock_channel.basic_nack.assert_not_called()

    @given(
        message=json_objects,
        traceparent=st.text(min_size=10, max_size=100),
    )
    @settings(max_examples=50)
    def test_otel_context_extracted_before_callback(
        self, message: dict[str, Any], traceparent: str
    ) -> None:
        """Le contexte OTel doit être extrait des headers avant l'appel du callback."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            with patch("cezam_lib.cezam_shared.rabbitmq.extract") as mock_extract:
                with patch("cezam_lib.cezam_shared.rabbitmq.otel_context") as mock_otel_context:
                    mock_ctx = MagicMock()
                    mock_extract.return_value = mock_ctx
                    mock_token = MagicMock()
                    mock_otel_context.attach.return_value = mock_token

                    callback = MagicMock()
                    headers = {"traceparent": traceparent}

                    self._create_consumer_and_trigger_message(
                        mock_connection, callback, message, headers=headers
                    )

                    mock_extract.assert_called_once_with(headers)
                    mock_otel_context.attach.assert_called_once_with(mock_ctx)
                    mock_otel_context.detach.assert_called_once_with(mock_token)

    @given(
        message=json_objects,
        error_msg=st.text(min_size=1, max_size=100),
        traceparent=st.text(min_size=10, max_size=100),
    )
    @settings(max_examples=50)
    def test_otel_context_detached_even_on_error(
        self, message: dict[str, Any], error_msg: str, traceparent: str
    ) -> None:
        """Le contexte OTel doit être détaché même si le callback lève une exception."""
        with patch("cezam_lib.cezam_shared.rabbitmq.pika.BlockingConnection") as mock_connection:
            with patch("cezam_lib.cezam_shared.rabbitmq.extract") as mock_extract:
                with patch("cezam_lib.cezam_shared.rabbitmq.otel_context") as mock_otel_context:
                    mock_ctx = MagicMock()
                    mock_extract.return_value = mock_ctx
                    mock_token = MagicMock()
                    mock_otel_context.attach.return_value = mock_token

                    callback = MagicMock(side_effect=RetryableError(error_msg))
                    headers = {"traceparent": traceparent}

                    self._create_consumer_and_trigger_message(
                        mock_connection, callback, message, headers=headers
                    )

                    mock_otel_context.detach.assert_called_once_with(mock_token)
