"""
Tests for DSPy integration.

Covers:
    - Module invocation creates span
    - Disabled tracer passthrough
    - LM call creates child span
    - Error recording via on_module_end(exception=...)
    - Module type capture
    - Provider suppression during LM calls
    - Tool call creates TOOL_CALL span
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest


class TestDSPyIntegration:
    """Tests for RisicareDSPyCallback."""

    @pytest.fixture
    def mock_tracer(self):
        tracer = MagicMock()
        tracer.is_enabled = True
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        tracer.start_span_no_context.return_value = mock_span
        return tracer

    @pytest.fixture
    def callback(self):
        from risicare.integrations.dspy._callback import RisicareDSPyCallback
        return RisicareDSPyCallback()

    def test_module_call_creates_span(self, mock_tracer, callback):
        """Module invocation should create a module span."""
        mock_instance = MagicMock()
        mock_instance.__class__.__name__ = "ChainOfThought"
        mock_instance.signature = "question -> answer"

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_module_start(
                call_id="call-001",
                instance=mock_instance,
                inputs={"question": "What is 2+2?"},
            )

            callback.on_module_end(
                call_id="call-001",
                outputs={"answer": "4"},
            )

        mock_tracer.start_span_no_context.assert_called_once()
        call_kwargs = mock_tracer.start_span_no_context.call_args
        assert "dspy.module/" in str(call_kwargs)

        mock_span = mock_tracer.start_span_no_context.return_value
        mock_span.end.assert_called_once()

    def test_disabled_tracer_passthrough(self, callback):
        """When tracing is disabled, no span should be created."""
        with patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=False,
        ):
            callback.on_module_start(
                call_id="call-002",
                instance=MagicMock(),
                inputs={},
            )

        assert "call-002" not in callback._open_spans

    def test_lm_call_creates_child_span(self, mock_tracer, callback):
        """LM call should create an LLM_CALL span."""
        mock_lm = MagicMock()
        mock_lm.model = "openai/gpt-4o"

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_lm_start(
                call_id="lm-001",
                instance=mock_lm,
                inputs={"prompt": "Answer: "},
            )

            callback.on_lm_end(
                call_id="lm-001",
                outputs=MagicMock(),
            )

        mock_tracer.start_span_no_context.assert_called_once()
        call_kwargs = mock_tracer.start_span_no_context.call_args
        assert "dspy.lm/openai/gpt-4o" in str(call_kwargs)

        attrs = call_kwargs.kwargs.get("attributes", call_kwargs[1].get("attributes", {}))
        assert attrs.get("gen_ai.request.model") == "openai/gpt-4o"
        assert attrs.get("gen_ai.system") == "openai"

    def test_records_error_on_exception(self, mock_tracer, callback):
        """Exceptions should be recorded on the span."""
        mock_instance = MagicMock()
        error = RuntimeError("LM call failed")

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_module_start(
                call_id="call-err",
                instance=mock_instance,
                inputs={},
            )

            callback.on_module_end(
                call_id="call-err",
                outputs=None,
                exception=error,
            )

        mock_span = mock_tracer.start_span_no_context.return_value
        mock_span.record_exception.assert_called_once_with(error)
        mock_span.set_attribute.assert_any_call("error", True)

    def test_captures_module_type(self, mock_tracer, callback):
        """Module class name should be captured in attributes."""

        class ChainOfThought:
            signature = "question -> answer"

        mock_instance = ChainOfThought()

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_module_start(
                call_id="call-type",
                instance=mock_instance,
                inputs={},
            )

        call_kwargs = mock_tracer.start_span_no_context.call_args
        attrs = call_kwargs.kwargs.get("attributes", call_kwargs[1].get("attributes", {}))
        assert attrs.get("framework.dspy.module_type") == "ChainOfThought"
        assert attrs.get("framework.dspy.signature") == "question -> answer"

        # Clean up
        callback.on_module_end(call_id="call-type", outputs=None)

    def test_provider_suppression_during_lm(self, mock_tracer, callback):
        """Provider spans should be suppressed during LM calls."""
        from risicare.integrations._dedup import is_provider_suppressed

        mock_lm = MagicMock()
        mock_lm.model = "openai/gpt-4o"

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_lm_start(
                call_id="lm-sup",
                instance=mock_lm,
                inputs={},
            )

            # Suppression should be active
            assert is_provider_suppressed()

            callback.on_lm_end(
                call_id="lm-sup",
                outputs=MagicMock(),
            )

            # Suppression should be released
            assert not is_provider_suppressed()

    def test_tool_call_creates_span(self, mock_tracer, callback):
        """Tool invocation should create a TOOL_CALL span."""
        mock_tool = MagicMock()
        mock_tool.name = "calculator"

        with patch(
            "risicare.integrations.dspy._callback.get_tracer",
            return_value=mock_tracer,
        ), patch(
            "risicare.integrations.dspy._callback.is_tracing_enabled",
            return_value=True,
        ):
            callback.on_tool_start(
                call_id="tool-001",
                instance=mock_tool,
                inputs={"expression": "2+2"},
            )

            callback.on_tool_end(
                call_id="tool-001",
                outputs="4",
            )

        call_kwargs = mock_tracer.start_span_no_context.call_args
        assert "dspy.tool/calculator" in str(call_kwargs)
        mock_tracer.start_span_no_context.return_value.end.assert_called_once()
