"""Tests property-based pour BasePipeline."""

from __future__ import annotations

from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock, patch

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

from cezam_lib.cezam_shared.exceptions import MinIOError, NonRetryableError, RetryableError
from cezam_lib.pipeline_template.base_pipeline import BasePipeline
from cezam_lib.pipeline_template.extractor import DataExtractor
from cezam_lib.pipeline_template.messages import FusionMessage


class StubExtractedData(BaseModel):
    """Modèle de données extrait pour les tests."""

    field1: str
    field2: int
    field3: float


class StubExtractor(DataExtractor[StubExtractedData]):
    """Extracteur de test retournant des données factices."""

    def __init__(self, result: StubExtractedData | None = None):
        self._result = result or StubExtractedData(
            field1="test", field2=42, field3=3.14
        )

    def extract(self, ocr_data: dict) -> StubExtractedData:
        return self._result


class FailingExtractor(DataExtractor[StubExtractedData]):
    """Extracteur qui lève une exception configurable."""

    def __init__(self, exception: Exception):
        self._exception = exception

    def extract(self, ocr_data: dict) -> StubExtractedData:
        raise self._exception


simulation_ids = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")
public_ids = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")
doc_names = st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != "")
document_types = st.text(min_size=1, max_size=30).filter(lambda x: x.strip() != "")
ocr_paths = st.text(min_size=1, max_size=100).filter(lambda x: x.strip() != "")
confidences = st.floats(min_value=0.0, max_value=1.0, allow_nan=False)
pipeline_names = st.text(min_size=1, max_size=30).filter(lambda x: x.strip() != "")


pipeline_messages = st.builds(
    lambda sim_id, pub_id, doc, doc_type, conf, path: {
        "simulation_id": sim_id,
        "simulation_public_id": pub_id,
        "doc_name": doc,
        "document_type": doc_type,
        "confidence": conf,
        "ocr_json_path": path,
    },
    sim_id=simulation_ids,
    pub_id=public_ids,
    doc=doc_names,
    doc_type=document_types,
    conf=confidences,
    path=ocr_paths,
)


pipeline_messages_with_correlation = st.builds(
    lambda sim_id, pub_id, doc, doc_type, conf, path, corr_id: {
        "simulation_id": sim_id,
        "simulation_public_id": pub_id,
        "doc_name": doc,
        "document_type": doc_type,
        "confidence": conf,
        "ocr_json_path": path,
        "correlation_id": corr_id,
    },
    sim_id=simulation_ids,
    pub_id=public_ids,
    doc=doc_names,
    doc_type=document_types,
    conf=confidences,
    path=ocr_paths,
    corr_id=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
)


ocr_data_strategy = st.dictionaries(
    st.text(min_size=1, max_size=20),
    st.one_of(st.text(max_size=50), st.integers(), st.floats(allow_nan=False)),
    min_size=0,
    max_size=10,
)


error_messages = st.text(min_size=1, max_size=100)


@contextmanager
def mock_tracer():
    """Mock le tracer OTel pour éviter les problèmes avec NoOpTracer."""
    mock_span = MagicMock()
    mock_span.__enter__ = MagicMock(return_value=mock_span)
    mock_span.__exit__ = MagicMock(return_value=False)
    mock_span.set_attribute = MagicMock()
    mock_span.set_status = MagicMock()
    mock_span.record_exception = MagicMock()

    mock_tracer_instance = MagicMock()
    mock_tracer_instance.start_as_current_span.return_value = mock_span

    with patch("opentelemetry.trace.get_tracer", return_value=mock_tracer_instance):
        yield mock_tracer_instance


def create_pipeline_with_mocks(
    extractor: DataExtractor,
    pipeline_name: str = "test_pipeline",
    queue_name: str = "test_queue",
) -> tuple[BasePipeline, MagicMock, MagicMock, MagicMock]:
    """Crée un pipeline avec des mocks pour les dépendances."""
    mock_datalake = MagicMock()
    mock_publisher = MagicMock()
    mock_consumer = MagicMock()

    class ConcretePipeline(BasePipeline):
        pass

    with mock_tracer():
        pipeline = ConcretePipeline(
            datalake_client=mock_datalake,
            publisher=mock_publisher,
            consumer=mock_consumer,
            extractor=extractor,
            queue_name=queue_name,
            pipeline_name=pipeline_name,
        )

    return pipeline, mock_datalake, mock_publisher, mock_consumer


class TestFusionMessageStructureValidity:
    """Tests pour la validité de la structure des messages fusion."""

    @given(message=pipeline_messages, pipeline_name=pipeline_names, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_contains_all_required_fields(
        self, message: dict[str, Any], pipeline_name: str, ocr_data: dict
    ) -> None:
        """Le message fusion doit contenir tous les champs requis."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(
            extractor, pipeline_name=pipeline_name
        )

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert captured_message is not None
        assert isinstance(captured_message, FusionMessage)

        assert captured_message.simulation_id == message["simulation_id"]
        assert captured_message.simulation_public_id == message["simulation_public_id"]
        assert captured_message.document_type == message["document_type"]
        assert captured_message.pipeline_output_path is not None
        assert len(captured_message.pipeline_output_path) > 0
        assert captured_message.status in ("success", "partial", "error")
        assert isinstance(captured_message.source_documents, list)
        assert len(captured_message.source_documents) > 0

    @given(message=pipeline_messages, pipeline_name=pipeline_names, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_output_path_follows_convention(
        self, message: dict[str, Any], pipeline_name: str, ocr_data: dict
    ) -> None:
        """Le chemin de sortie doit suivre la convention {sim_id}/{pipeline_name}/{doc_name}_result.json."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(
            extractor, pipeline_name=pipeline_name
        )

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        sim_id = message["simulation_id"]
        doc_name = message["doc_name"]
        expected_path = f"{sim_id}/{pipeline_name}/{doc_name}_result.json"
        assert captured_message.pipeline_output_path == expected_path

    @given(message=pipeline_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_source_documents_contains_doc_name(
        self, message: dict[str, Any], ocr_data: dict
    ) -> None:
        """source_documents doit contenir le nom du document traité."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert message["doc_name"] in captured_message.source_documents

    @given(message=pipeline_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_fields_extracted_is_non_negative(
        self, message: dict[str, Any], ocr_data: dict
    ) -> None:
        """fields_extracted doit être >= 0."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert captured_message.fields_extracted >= 0

    @given(message=pipeline_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_duration_is_non_negative(
        self, message: dict[str, Any], ocr_data: dict
    ) -> None:
        """duration_ms doit être >= 0."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert captured_message.duration_ms >= 0

    @given(message=pipeline_messages_with_correlation, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_preserves_correlation_id(
        self, message: dict[str, Any], ocr_data: dict
    ) -> None:
        """Le correlation_id doit être préservé dans le message fusion."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert captured_message.correlation_id == message.get("correlation_id")

    @given(message=pipeline_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_fusion_message_confidence_in_valid_range(
        self, message: dict[str, Any], ocr_data: dict
    ) -> None:
        """confidence_avg doit être entre 0.0 et 1.0."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        captured_message = None

        def capture_publish(exchange, routing_key, message):
            nonlocal captured_message
            captured_message = message

        mock_publisher.publish.side_effect = capture_publish

        with mock_tracer():
            pipeline.process_message(message)

        assert 0.0 <= captured_message.confidence_avg <= 1.0


class TestRetryBehaviorConsistency:
    """Tests pour la cohérence du comportement de retry."""

    @given(message=pipeline_messages, error_msg=error_messages)
    @settings(max_examples=100)
    def test_retryable_error_from_minio_read_propagates(
        self, message: dict[str, Any], error_msg: str
    ) -> None:
        """Une erreur MinIO temporaire doit lever RetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.side_effect = MinIOError(f"Connection timeout: {error_msg}")

        with mock_tracer():
            with pytest.raises(RetryableError):
                pipeline.process_message(message)

    @given(message=pipeline_messages)
    @settings(max_examples=100)
    def test_non_retryable_error_for_missing_file(
        self, message: dict[str, Any]
    ) -> None:
        """Un fichier OCR inexistant doit lever NonRetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.side_effect = MinIOError("Fichier inexistant: path/to/file")

        with mock_tracer():
            with pytest.raises(NonRetryableError):
                pipeline.process_message(message)

    @given(message=pipeline_messages)
    @settings(max_examples=100)
    def test_non_retryable_error_for_nosuchkey(
        self, message: dict[str, Any]
    ) -> None:
        """Une erreur NoSuchKey doit lever NonRetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.side_effect = MinIOError("NoSuchKey: object not found")

        with mock_tracer():
            with pytest.raises(NonRetryableError):
                pipeline.process_message(message)

    @given(message=pipeline_messages, error_msg=error_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_retryable_error_from_extractor_propagates(
        self, message: dict[str, Any], error_msg: str, ocr_data: dict
    ) -> None:
        """Une RetryableError de l'extracteur doit être propagée."""
        extractor = FailingExtractor(RetryableError(error_msg))
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        with mock_tracer():
            with pytest.raises(RetryableError) as exc_info:
                pipeline.process_message(message)

        assert error_msg in str(exc_info.value)

    @given(message=pipeline_messages, error_msg=error_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_non_retryable_error_from_extractor_propagates(
        self, message: dict[str, Any], error_msg: str, ocr_data: dict
    ) -> None:
        """Une NonRetryableError de l'extracteur doit être propagée."""
        extractor = FailingExtractor(NonRetryableError(error_msg))
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        with mock_tracer():
            with pytest.raises(NonRetryableError) as exc_info:
                pipeline.process_message(message)

        assert error_msg in str(exc_info.value)

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

    @given(
        message=pipeline_messages,
        exception_type=generic_exceptions,
        error_msg=error_messages,
        ocr_data=ocr_data_strategy,
    )
    @settings(max_examples=100)
    def test_generic_exception_from_extractor_becomes_non_retryable(
        self, message: dict[str, Any], exception_type: type, error_msg: str, ocr_data: dict
    ) -> None:
        """Une exception générique de l'extracteur doit devenir NonRetryableError."""
        extractor = FailingExtractor(exception_type(error_msg))
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        with mock_tracer():
            with pytest.raises(NonRetryableError):
                pipeline.process_message(message)

    @given(message=pipeline_messages, error_msg=error_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_retryable_error_from_minio_write_propagates(
        self, message: dict[str, Any], error_msg: str, ocr_data: dict
    ) -> None:
        """Une erreur MinIO lors de l'écriture doit lever RetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data
        mock_minio.put_json.side_effect = MinIOError(f"Write failed: {error_msg}")

        with mock_tracer():
            with pytest.raises(RetryableError):
                pipeline.process_message(message)

    @given(message=pipeline_messages, error_msg=error_messages, ocr_data=ocr_data_strategy)
    @settings(max_examples=100)
    def test_retryable_error_from_publisher_propagates(
        self, message: dict[str, Any], error_msg: str, ocr_data: dict
    ) -> None:
        """Une erreur de publication vers fusion doit lever RetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data
        mock_publisher.publish.side_effect = Exception(f"Publish failed: {error_msg}")

        with mock_tracer():
            with pytest.raises(RetryableError):
                pipeline.process_message(message)

    @given(ocr_data=ocr_data_strategy)
    @settings(max_examples=50)
    def test_invalid_message_raises_non_retryable_error(
        self, ocr_data: dict
    ) -> None:
        """Un message invalide doit lever NonRetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        invalid_message = {"simulation_id": "test"}

        with mock_tracer():
            with pytest.raises(NonRetryableError):
                pipeline.process_message(invalid_message)

    @given(ocr_data=ocr_data_strategy)
    @settings(max_examples=50)
    def test_empty_simulation_id_raises_non_retryable_error(
        self, ocr_data: dict
    ) -> None:
        """Un simulation_id vide doit lever NonRetryableError."""
        extractor = StubExtractor()
        pipeline, mock_minio, mock_publisher, _ = create_pipeline_with_mocks(extractor)

        mock_minio.get_json.return_value = ocr_data

        invalid_message = {
            "simulation_id": "",
            "simulation_public_id": "pub123",
            "doc_name": "doc.pdf",
            "document_type": "cni",
            "confidence": 0.9,
            "ocr_json_path": "ocr/test.json",
        }

        with mock_tracer():
            with pytest.raises(NonRetryableError):
                pipeline.process_message(invalid_message)
