"""Unit tests for MCALiteLLMCallback.

Tests acceptance criteria from Story 1-10 (token extraction, metric naming, cost, traces)
and Story 2-9 (unified token metric structure with token_type attribute dimension).
"""

import pytest
from datetime import datetime, timedelta
from unittest.mock import Mock, MagicMock, patch, PropertyMock

from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.resources import Resource

from mca_sdk.integrations.litellm_callback import MCALiteLLMCallback


@pytest.fixture
def mock_mca_client():
    """Create a mock MCAClient with real OTel components for testing."""
    client = Mock()

    # Create real metric reader and provider for testing
    metric_reader = InMemoryMetricReader()
    resource = Resource.create({"service.name": "test-genai-service"})
    meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
    client.meter = meter_provider.get_meter("test-meter")

    # Create real tracer provider for testing
    span_exporter = InMemorySpanExporter()
    tracer_provider = TracerProvider(resource=resource)
    tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
    client.tracer = tracer_provider.get_tracer("test-tracer")

    # Mock logger
    client.logger = Mock()
    client.logger.info = Mock()
    client.logger.error = Mock()

    # Store references for assertions
    client._metric_reader = metric_reader
    client._span_exporter = span_exporter
    client._meter_provider = meter_provider
    client._tracer_provider = tracer_provider

    yield client

    # Cleanup
    meter_provider.shutdown()
    tracer_provider.shutdown()


class TestMCALiteLLMCallbackInitialization:
    """Test callback initialization."""

    def test_initialization_creates_metric_instruments(self, mock_mca_client):
        """Test that all required metrics are created on init."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._requests_counter is not None
        assert callback._requests_failed_counter is not None
        assert callback._tokens_counter is not None
        assert not hasattr(callback, "_prompt_tokens_counter")
        assert not hasattr(callback, "_completion_tokens_counter")
        assert not hasattr(callback, "_total_tokens_counter")
        assert callback._cost_histogram is not None
        assert callback._latency_histogram is not None

    def test_initialization_with_phi_flags(self, mock_mca_client):
        """Test initialization with PHI recording flags."""
        callback = MCALiteLLMCallback(mock_mca_client, record_prompts=True, record_completions=True)

        assert callback._record_prompts is True
        assert callback._record_completions is True

    def test_initialization_default_phi_flags_true(self, mock_mca_client):
        """Test that PHI recording is enabled by default."""
        mock_mca_client.config.capture_input_data = True
        mock_mca_client.config.capture_output_data = True
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._record_prompts is True
        assert callback._record_completions is True


class TestTokenExtraction:
    """Test token usage extraction (AC1, AC2)."""

    def test_extracts_token_usage_from_model_response(self, mock_mca_client):
        """AC1: Verify token extraction from ModelResponse object."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Mock response with usage attribute
        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 100
        mock_response.usage.completion_tokens = 50
        mock_response.usage.total_tokens = 150

        token_usage = callback._extract_token_usage(mock_response)

        assert token_usage["prompt_tokens"] == 100
        assert token_usage["completion_tokens"] == 50
        assert token_usage["total_tokens"] == 150

    def test_extracts_token_usage_from_dict_response(self, mock_mca_client):
        """AC1: Verify token extraction from dict response."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Dict response format
        dict_response = {
            "usage": {
                "prompt_tokens": 200,
                "completion_tokens": 100,
                "total_tokens": 300,
            }
        }

        token_usage = callback._extract_token_usage(dict_response)

        assert token_usage["prompt_tokens"] == 200
        assert token_usage["completion_tokens"] == 100
        assert token_usage["total_tokens"] == 300

    def test_handles_missing_usage_data(self, mock_mca_client):
        """AC1: Verify graceful handling of missing usage data."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Response without usage
        mock_response = Mock()
        mock_response.usage = None

        token_usage = callback._extract_token_usage(mock_response)

        assert token_usage["prompt_tokens"] == 0
        assert token_usage["completion_tokens"] == 0
        assert token_usage["total_tokens"] == 0

    def test_handles_partial_usage_data(self, mock_mca_client):
        """AC1: Verify handling of partial usage data."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Response with partial usage
        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 50
        mock_response.usage.completion_tokens = None
        mock_response.usage.total_tokens = None

        token_usage = callback._extract_token_usage(mock_response)

        assert token_usage["prompt_tokens"] == 50
        assert token_usage["completion_tokens"] == 0
        assert token_usage["total_tokens"] == 0


class TestSuccessCallback:
    """Test log_success_event (AC1, AC2, AC3)."""

    def test_records_token_metrics(self, mock_mca_client):
        """AC2: Verify metrics recorded with correct names."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Create mock response
        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 100
        mock_response.usage.completion_tokens = 50
        mock_response.usage.total_tokens = 150
        mock_response._hidden_params = {}

        kwargs = {"model": "gpt-4", "litellm_call_id": "test-123"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1.5)

        with patch("litellm.completion_cost", return_value=0.005):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        # Verify logger was called (metrics are recorded)
        mock_mca_client.logger.info.assert_called_once()
        call_args = mock_mca_client.logger.info.call_args
        extra = call_args[1]["extra"]

        assert extra["prompt_tokens"] == 100
        assert extra["completion_tokens"] == 50
        assert extra["total_tokens"] == 150
        assert extra["status"] == "success"

    @patch("litellm.completion_cost")
    def test_calculates_cost_using_litellm(self, mock_completion_cost, mock_mca_client):
        """AC3: Verify cost calculation using litellm.completion_cost()."""
        mock_completion_cost.return_value = 0.0123

        callback = MCALiteLLMCallback(mock_mca_client)

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 100
        mock_response.usage.completion_tokens = 50
        mock_response.usage.total_tokens = 150

        cost = callback._calculate_cost(mock_response, {})

        assert cost == 0.0123
        mock_completion_cost.assert_called_once()

    def test_handles_cost_calculation_error(self, mock_mca_client):
        """AC3: Verify graceful handling when cost unavailable."""
        callback = MCALiteLLMCallback(mock_mca_client)

        mock_response = Mock()

        with patch("litellm.completion_cost", side_effect=Exception("No pricing")):
            cost = callback._calculate_cost(mock_response, {})

        assert cost is None

    def test_calculates_latency_correctly(self, mock_mca_client):
        """Verify latency is calculated from start/end times."""
        callback = MCALiteLLMCallback(mock_mca_client)

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15
        mock_response._hidden_params = {}

        kwargs = {"model": "gpt-4", "litellm_call_id": "test-latency"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=2.5)

        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        # Verify latency in log
        call_args = mock_mca_client.logger.info.call_args
        extra = call_args[1]["extra"]
        assert extra["latency_seconds"] == pytest.approx(2.5, rel=0.01)


class TestTraceSpans:
    """Test trace span creation (AC4)."""

    def test_creates_span_on_pre_api_call(self, mock_mca_client):
        """AC4: Verify span created in log_pre_api_call."""
        callback = MCALiteLLMCallback(mock_mca_client)

        kwargs = {
            "model": "gpt-4",
            "temperature": 0.7,
            "max_tokens": 1000,
            "litellm_call_id": "test-span-456",
        }

        callback.log_pre_api_call("gpt-4", [], kwargs)

        # Verify span stored in active_spans
        assert "test-span-456" in callback._active_spans
        span_data = callback._active_spans["test-span-456"]
        assert span_data["model"] == "gpt-4"
        assert "span" in span_data
        assert "start_time" in span_data

    def test_span_completed_on_success(self, mock_mca_client):
        """AC4: Verify span is ended on success."""
        callback = MCALiteLLMCallback(mock_mca_client)

        call_id = "test-complete-span"
        kwargs = {"model": "gpt-4", "litellm_call_id": call_id}

        # Start span
        callback.log_pre_api_call("gpt-4", [], kwargs)
        assert call_id in callback._active_spans

        # Complete with success
        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15
        mock_response._hidden_params = {}

        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        # Verify span removed from active_spans (completed)
        assert call_id not in callback._active_spans

    def test_extracts_span_attributes_correctly(self, mock_mca_client):
        """AC4: Verify span attributes include model, provider, temperature, max_tokens."""
        callback = MCALiteLLMCallback(mock_mca_client)

        kwargs = {
            "temperature": 0.7,
            "max_tokens": 1000,
            "top_p": 0.9,
            "stream": False,
        }

        attributes = callback._extract_span_attributes("gpt-4", kwargs)

        assert attributes["llm.model"] == "gpt-4"
        assert attributes["llm.provider"] == "openai"
        assert attributes["llm.temperature"] == 0.7
        assert attributes["llm.max_tokens"] == 1000
        assert attributes["llm.top_p"] == 0.9
        assert attributes["llm.stream"] is False


class TestFailureCallback:
    """Test log_failure_event (AC4)."""

    def test_records_failure_metrics(self, mock_mca_client):
        """AC4: Verify genai.requests_failed_total recorded."""
        callback = MCALiteLLMCallback(mock_mca_client)

        error = Exception("API rate limit exceeded")
        kwargs = {"model": "gpt-4", "litellm_call_id": "test-fail-789"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        # Verify error was logged
        mock_mca_client.logger.error.assert_called_once()
        call_args = mock_mca_client.logger.error.call_args
        assert "LLM request failed" in call_args[0][0]
        extra = call_args[1]["extra"]
        assert extra["status"] == "error"
        assert extra["error_type"] == "Exception"

    def test_extracts_error_info_from_exception(self, mock_mca_client):
        """AC4: Verify error type and message extracted correctly."""
        callback = MCALiteLLMCallback(mock_mca_client)

        error = ValueError("Invalid model parameter")
        error_type, error_message = callback._extract_error_info(error)

        assert error_type == "ValueError"
        assert "Invalid model parameter" in error_message

    def test_extracts_error_info_from_response_object(self, mock_mca_client):
        """AC4: Verify error extraction from response with error attribute."""
        callback = MCALiteLLMCallback(mock_mca_client)

        mock_response = Mock()
        mock_response.error = {"type": "RateLimitError", "message": "Too many requests"}

        error_type, error_message = callback._extract_error_info(mock_response)

        assert error_type == "RateLimitError"
        assert "Too many requests" in error_message

    def test_span_completed_with_error_status(self, mock_mca_client):
        """AC4: Verify span status set to ERROR on failure."""
        callback = MCALiteLLMCallback(mock_mca_client)

        call_id = "test-error-span"
        kwargs = {"model": "gpt-4", "litellm_call_id": call_id}

        # Start span
        callback.log_pre_api_call("gpt-4", [], kwargs)
        assert call_id in callback._active_spans

        # Complete with failure
        error = Exception("API Error")
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.3)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        # Verify span removed (completed)
        assert call_id not in callback._active_spans


class TestProviderExtraction:
    """Test provider extraction from model strings."""

    def test_extracts_openai_provider(self, mock_mca_client):
        """Verify OpenAI provider extraction."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._extract_provider("gpt-4") == "openai"
        assert callback._extract_provider("gpt-3.5-turbo") == "openai"
        assert callback._extract_provider("gpt-4-turbo-preview") == "openai"

    def test_extracts_anthropic_provider(self, mock_mca_client):
        """Verify Anthropic provider extraction."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._extract_provider("claude-3-opus") == "anthropic"
        assert callback._extract_provider("claude-2") == "anthropic"
        assert callback._extract_provider("claude-3-5-sonnet") == "anthropic"

    def test_extracts_provider_from_prefix(self, mock_mca_client):
        """Verify provider extraction from model prefix."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._extract_provider("openai/gpt-4") == "openai"
        assert callback._extract_provider("anthropic/claude-3") == "anthropic"
        assert callback._extract_provider("ollama/llama2") == "ollama"

    def test_extracts_other_providers(self, mock_mca_client):
        """Verify other provider extractions."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._extract_provider("gemini-pro") == "google"
        assert callback._extract_provider("llama-2-70b") == "meta"
        assert callback._extract_provider("mistral-7b") == "mistral"

    def test_returns_unknown_for_unrecognized(self, mock_mca_client):
        """Verify unknown returned for unrecognized models."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._extract_provider("custom-model-xyz") == "unknown"


class TestAsyncCallbacks:
    """Test async callback methods."""

    @pytest.mark.asyncio
    async def test_async_success_delegates_to_sync(self, mock_mca_client):
        """Verify async_log_success_event delegates to sync implementation."""
        callback = MCALiteLLMCallback(mock_mca_client)

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15
        mock_response._hidden_params = {}

        kwargs = {"model": "gpt-4"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=None):
            await callback.async_log_success_event(kwargs, mock_response, start_time, end_time)

        # Verify logger was called (sync method was invoked)
        mock_mca_client.logger.info.assert_called_once()

    @pytest.mark.asyncio
    async def test_async_failure_delegates_to_sync(self, mock_mca_client):
        """Verify async_log_failure_event delegates to sync implementation."""
        callback = MCALiteLLMCallback(mock_mca_client)

        error = Exception("Test error")
        kwargs = {"model": "gpt-4"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        await callback.async_log_failure_event(kwargs, error, start_time, end_time)

        # Verify logger was called (sync method was invoked)
        mock_mca_client.logger.error.assert_called_once()

    @pytest.mark.asyncio
    async def test_async_success_exception_handler(self, mock_mca_client):
        """Verify async_log_success_event suppresses exceptions if sync delegate raises."""
        callback = MCALiteLLMCallback(mock_mca_client)
        with patch.object(callback, "log_success_event", side_effect=RuntimeError("boom")):
            # Must not raise — exception suppressed by async wrapper
            await callback.async_log_success_event(
                {"model": "gpt-4"},
                Mock(),
                datetime.now(),
                datetime.now() + timedelta(seconds=1),
            )

    @pytest.mark.asyncio
    async def test_async_failure_exception_handler(self, mock_mca_client):
        """Verify async_log_failure_event suppresses exceptions if sync delegate raises."""
        callback = MCALiteLLMCallback(mock_mca_client)
        with patch.object(callback, "log_failure_event", side_effect=RuntimeError("boom")):
            # Must not raise — exception suppressed by async wrapper
            await callback.async_log_failure_event(
                {"model": "gpt-4"},
                Mock(),
                datetime.now(),
                datetime.now() + timedelta(seconds=1),
            )


class TestPHIProtection:
    """Test PHI/HIPAA compliance features."""

    def test_no_prompts_logged_by_default(self, mock_mca_client):
        """Verify prompts are not logged when record_prompts=False."""
        callback = MCALiteLLMCallback(mock_mca_client, record_prompts=False)

        assert callback._record_prompts is False

    def test_no_error_messages_logged_by_default(self, mock_mca_client):
        """Verify error messages are not logged when record_error_messages=False."""
        callback = MCALiteLLMCallback(mock_mca_client, record_error_messages=False)

        assert callback._record_error_messages is False

    def test_error_messages_can_be_enabled_with_warning(self, mock_mca_client):
        """Verify error messages can be explicitly enabled."""
        callback = MCALiteLLMCallback(mock_mca_client, record_error_messages=True)

        assert callback._record_error_messages is True

    def test_error_messages_truncated(self, mock_mca_client):
        """Verify error messages are truncated to prevent PHI leakage."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Create a long error message
        long_error = "Error: " + "x" * 500
        error = Exception(long_error)

        error_type, error_message = callback._extract_error_info(error)

        # Should be truncated to 200 chars + truncation marker
        assert len(error_message) <= 220
        assert error_message.endswith("...[truncated]")

    def test_short_error_messages_not_truncated(self, mock_mca_client):
        """Verify short error messages are not truncated."""
        callback = MCALiteLLMCallback(mock_mca_client)

        short_error = "Short error message"
        error = Exception(short_error)

        error_type, error_message = callback._extract_error_info(error)

        assert error_message == short_error
        assert "[truncated]" not in error_message

    def test_failure_callback_omits_error_message_by_default(self, mock_mca_client):
        """Verify log_failure_event does NOT log error message unless enabled."""
        callback = MCALiteLLMCallback(mock_mca_client, record_error_messages=False)

        error = Exception("Sensitive error with PHI")
        kwargs = {"model": "gpt-4", "litellm_call_id": "test-phi"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        # Verify error message was NOT logged
        call_args = mock_mca_client.logger.error.call_args
        extra = call_args[1]["extra"]
        assert "error_message" not in extra
        assert extra["error_type"] == "Exception"

    def test_failure_callback_includes_error_message_when_enabled(self, mock_mca_client):
        """Verify log_failure_event DOES log error message when explicitly enabled."""
        callback = MCALiteLLMCallback(mock_mca_client, record_error_messages=True)

        error = Exception("Error message")
        kwargs = {"model": "gpt-4", "litellm_call_id": "test-phi-enabled"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        # Verify error message WAS logged
        call_args = mock_mca_client.logger.error.call_args
        extra = call_args[1]["extra"]
        assert "error_message" in extra
        assert extra["error_message"] == "Error message"


class TestIntegrationWithLiteLLM:
    """Test integration scenarios with LiteLLM patterns."""

    def test_handles_missing_call_id(self, mock_mca_client):
        """Verify callback handles missing litellm_call_id gracefully."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # No litellm_call_id in kwargs
        kwargs = {"model": "gpt-4"}

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15

        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        # Should not raise exception
        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        mock_mca_client.logger.info.assert_called_once()

    def test_handles_unknown_model(self, mock_mca_client):
        """Verify callback handles unknown model names."""
        callback = MCALiteLLMCallback(mock_mca_client)

        kwargs = {"model": "unknown-model-xyz", "litellm_call_id": "test-unknown"}

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15

        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        call_args = mock_mca_client.logger.info.call_args
        extra = call_args[1]["extra"]
        assert extra["model"] == "unknown-model-xyz"


class TestExceptionHandling:
    """Test that callbacks never crash the user's application."""

    def test_success_callback_handles_metric_recording_failure(self, mock_mca_client):
        """Verify log_success_event doesn't crash when metric recording fails."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Make meter fail
        mock_mca_client.meter.create_counter = Mock(
            side_effect=Exception("Telemetry backend unavailable")
        )

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15

        kwargs = {"model": "gpt-4"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        # Should NOT raise exception - error should be suppressed
        callback.log_success_event(kwargs, mock_response, start_time, end_time)

    def test_failure_callback_handles_span_completion_failure(self, mock_mca_client):
        """Verify log_failure_event doesn't crash when span operations fail."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Create a span that will fail on set_attribute
        call_id = "test-fail-span"
        mock_span = Mock()
        mock_span.set_attribute = Mock(side_effect=Exception("Span backend unavailable"))

        callback._active_spans[call_id] = {
            "span": mock_span,
            "start_time": datetime.now().timestamp(),
            "model": "gpt-4",
        }

        error = Exception("API Error")
        kwargs = {"model": "gpt-4", "litellm_call_id": call_id}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        # Should NOT raise exception - error should be suppressed
        callback.log_failure_event(kwargs, error, start_time, end_time)

    def test_pre_api_call_handles_tracer_failure(self, mock_mca_client):
        """Verify log_pre_api_call doesn't crash when tracer fails."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Make tracer fail
        mock_mca_client.tracer.start_span = Mock(side_effect=Exception("Tracer unavailable"))

        kwargs = {"model": "gpt-4", "litellm_call_id": "test-tracer-fail"}

        # Should NOT raise exception - error should be suppressed
        callback.log_pre_api_call("gpt-4", [], kwargs)


class TestMemoryLeakPrevention:
    """Test memory leak prevention for active spans."""

    def test_enforces_max_active_spans_limit(self, mock_mca_client):
        """Verify _active_spans dict enforces max size to prevent memory leaks."""
        # Use custom limit for faster test
        max_spans = 100
        callback = MCALiteLLMCallback(mock_mca_client, max_active_spans=max_spans)

        # Fill up to max capacity
        for i in range(max_spans):
            kwargs = {
                "model": "gpt-4",
                "litellm_call_id": f"call-{i}",
                "temperature": 0.7,
            }
            callback.log_pre_api_call("gpt-4", [], kwargs)

        # Verify at max capacity
        assert len(callback._active_spans) == max_spans

        # Add one more - should evict oldest
        kwargs = {
            "model": "gpt-4",
            "litellm_call_id": f"call-{max_spans}",
            "temperature": 0.7,
        }
        callback.log_pre_api_call("gpt-4", [], kwargs)

        # Should still be at max capacity (oldest evicted via FIFO)
        assert len(callback._active_spans) == max_spans
        # Oldest should be gone
        assert "call-0" not in callback._active_spans
        # Newest should exist
        assert f"call-{max_spans}" in callback._active_spans

    def test_evicted_spans_are_properly_ended(self, mock_mca_client):
        """Verify evicted spans are ended to prevent resource leaks."""
        # Use smaller limit for faster test
        max_spans = 50
        callback = MCALiteLLMCallback(mock_mca_client, max_active_spans=max_spans)

        # Track all created mock spans
        created_spans = []

        # Create custom mock that tracks span creation
        def create_mock_span(*args, **kwargs):
            mock_span = Mock()
            mock_span.set_attribute = Mock()
            mock_span.set_status = Mock()
            mock_span.end = Mock()
            created_spans.append(mock_span)
            return mock_span

        mock_mca_client.tracer.start_span = create_mock_span

        # Fill beyond max capacity (add 10 more than max)
        for i in range(max_spans + 10):
            kwargs = {
                "model": "gpt-4",
                "litellm_call_id": f"call-{i}",
                "temperature": 0.7,
            }
            callback.log_pre_api_call("gpt-4", [], kwargs)

        # Verify oldest spans were ended (first 10 evicted via FIFO)
        for i in range(10):
            span = created_spans[i]
            span.end.assert_called_once()


class TestMetricInstrumentNames:
    """Test that all metric instrument names use the genai.* prefix (AC #1 Story 2-8, AC #4 Story 2-9)."""

    def test_metric_instrument_names_use_genai_prefix(self, mock_mca_client):
        """Assert all 5 metric instruments are created with genai.* names."""
        callback = MCALiteLLMCallback(mock_mca_client)

        assert callback._requests_counter.name == "genai.requests_total"
        assert callback._requests_failed_counter.name == "genai.requests_failed_total"
        assert callback._tokens_counter.name == "genai.tokens_total"
        assert not hasattr(callback, "_prompt_tokens_counter")
        assert not hasattr(callback, "_completion_tokens_counter")
        assert callback._cost_histogram.name == "genai.cost_usd"
        assert callback._latency_histogram.name == "genai.latency_seconds"


class TestTokenAttributeDimension:
    """Test that log_success_event emits token_type attribute dimension (AC #5, Story 2-9)."""

    def test_token_counter_called_with_token_type_attributes(self, mock_mca_client):
        """Assert _tokens_counter.add is called three times with token_type=prompt/completion/total."""
        callback = MCALiteLLMCallback(mock_mca_client)
        callback._tokens_counter = Mock()
        callback._tokens_counter.name = "genai.tokens_total"

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 100
        mock_response.usage.completion_tokens = 50
        mock_response.usage.total_tokens = 150
        mock_response._hidden_params = {}

        kwargs = {"model": "gpt-4", "litellm_call_id": "token-dim-test"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=0.005):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        calls = callback._tokens_counter.add.call_args_list
        assert len(calls) == 3

        token_types_recorded = {c.kwargs["attributes"]["token_type"]: c.args[0] for c in calls}
        assert token_types_recorded["prompt"] == 100
        assert token_types_recorded["completion"] == 50
        assert token_types_recorded["total"] == 150

        for call in calls:
            attrs = call.kwargs["attributes"]
            assert "model" in attrs
            assert "provider" in attrs
            assert "status" in attrs

    def test_token_counter_handles_zero_tokens(self, mock_mca_client):
        """Verify zero-token calls (streaming/token-less variants) still emit correct token_type."""
        callback = MCALiteLLMCallback(mock_mca_client)
        callback._tokens_counter = Mock()
        callback._tokens_counter.name = "genai.tokens_total"

        mock_response = Mock()
        mock_response.usage = Mock(prompt_tokens=0, completion_tokens=0, total_tokens=0)
        mock_response._hidden_params = {}

        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.1)
        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(
                {"model": "gpt-4", "litellm_call_id": "zero-tok"},
                mock_response,
                start_time,
                end_time,
            )

        calls = callback._tokens_counter.add.call_args_list
        assert len(calls) == 3
        token_types_recorded = {c.kwargs["attributes"]["token_type"]: c.args[0] for c in calls}
        assert token_types_recorded["prompt"] == 0
        assert token_types_recorded["completion"] == 0
        assert token_types_recorded["total"] == 0


class TestCoverageGaps:
    """Tests targeting previously uncovered branches for 85%+ coverage."""

    def test_success_event_attribute_error_handler(self, mock_mca_client, capsys):
        """Cover AttributeError/KeyError exception handler in log_success_event."""
        callback = MCALiteLLMCallback(mock_mca_client)
        # Make the counter raise AttributeError
        callback._tokens_counter.add = Mock(side_effect=AttributeError("bad attr"))

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15

        kwargs = {"model": "gpt-4", "litellm_call_id": "attr-err"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=0.001):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        captured = capsys.readouterr()
        assert "data extraction error" in captured.err

    def test_success_event_connection_error_handler(self, mock_mca_client, capsys):
        """Cover ConnectionError/TimeoutError exception handler in log_success_event."""
        callback = MCALiteLLMCallback(mock_mca_client)
        callback._requests_counter.add = Mock(side_effect=ConnectionError("backend down"))

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5
        mock_response.usage.total_tokens = 15

        kwargs = {"model": "gpt-4", "litellm_call_id": "conn-err"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        with patch("litellm.completion_cost", return_value=None):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        captured = capsys.readouterr()
        assert "telemetry backend unavailable" in captured.err

    def test_failure_event_attribute_error_handler(self, mock_mca_client, capsys):
        """Cover AttributeError/KeyError exception handler in log_failure_event."""
        callback = MCALiteLLMCallback(mock_mca_client)
        callback._requests_failed_counter.add = Mock(side_effect=KeyError("missing key"))

        error = Exception("API Error")
        kwargs = {"model": "gpt-4", "litellm_call_id": "fail-attr"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        captured = capsys.readouterr()
        assert "data extraction error" in captured.err

    def test_failure_event_connection_error_handler(self, mock_mca_client, capsys):
        """Cover ConnectionError/TimeoutError exception handler in log_failure_event."""
        callback = MCALiteLLMCallback(mock_mca_client)
        callback._requests_failed_counter.add = Mock(side_effect=TimeoutError("timeout"))

        error = Exception("Timeout")
        kwargs = {"model": "gpt-4", "litellm_call_id": "fail-timeout"}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        callback.log_failure_event(kwargs, error, start_time, end_time)

        captured = capsys.readouterr()
        assert "telemetry backend unavailable" in captured.err

    def test_record_error_messages_flag(self, mock_mca_client):
        """Cover _record_error_messages=True path in log_failure_event."""
        callback = MCALiteLLMCallback(mock_mca_client, record_error_messages=True)

        error = Exception("Connection refused")
        call_id = "err-msg-test"
        kwargs = {"model": "gpt-4", "litellm_call_id": call_id}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=0.5)

        # Add a span so the span branch also executes with _record_error_messages
        mock_span = Mock()
        callback._active_spans[call_id] = {
            "span": mock_span,
            "start_time": start_time.timestamp(),
            "model": "gpt-4",
        }

        callback.log_failure_event(kwargs, error, start_time, end_time)

        # span.set_attribute("error.message", ...) should have been called
        calls = [str(c) for c in mock_span.set_attribute.call_args_list]
        assert any("error.message" in c for c in calls)

    def test_success_event_records_cost_on_span(self, mock_mca_client):
        """Cover span.set_attribute('genai.cost_usd', cost) branch."""
        callback = MCALiteLLMCallback(mock_mca_client)

        call_id = "cost-span-test"
        kwargs = {"model": "gpt-4", "litellm_call_id": call_id}
        start_time = datetime.now()
        end_time = start_time + timedelta(seconds=1)

        # Pre-populate active_spans to simulate a prior log_pre_api_call
        mock_span = Mock()
        callback._active_spans[call_id] = {
            "span": mock_span,
            "start_time": start_time.timestamp(),
            "model": "gpt-4",
        }

        mock_response = Mock()
        mock_response.usage = Mock()
        mock_response.usage.prompt_tokens = 50
        mock_response.usage.completion_tokens = 25
        mock_response.usage.total_tokens = 75

        with patch("litellm.completion_cost", return_value=0.0042):
            callback.log_success_event(kwargs, mock_response, start_time, end_time)

        # Verify cost attribute was set on span
        calls = {str(c) for c in mock_span.set_attribute.call_args_list}
        assert any("genai.cost_usd" in c for c in calls)

    def test_extract_token_usage_exception_handler(self, mock_mca_client):
        """Cover exception handler in _extract_token_usage.

        hasattr() catches all exceptions in Python 3, so a PropertyMock on usage
        causes hasattr to return False and the except branch is never reached.
        Instead, provide a truthy usage object whose prompt_tokens property raises
        ValueError — getattr(obj, name, default) only catches AttributeError, so
        ValueError propagates to the outer except block.
        """
        callback = MCALiteLLMCallback(mock_mca_client)

        class CorruptUsage:
            @property
            def prompt_tokens(self):
                raise ValueError("corrupt data")

        mock_response = Mock()
        mock_response.usage = CorruptUsage()  # hasattr returns True; instance is truthy

        result = callback._extract_token_usage(mock_response)
        assert result == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}

    def test_check_global_state_safety_multiple_callbacks(self, mock_mca_client):
        """Cover _check_global_state_safety when another callback already registered."""
        callback = MCALiteLLMCallback(mock_mca_client)

        # Simulate another MCALiteLLMCallback in litellm.callbacks
        with patch("litellm.callbacks", [callback]):
            with patch("mca_sdk.integrations.litellm_callback.logger") as mock_logger:
                callback._check_global_state_safety()
                mock_logger.warning.assert_called_once()
                assert "Multiple MCALiteLLMCallback" in mock_logger.warning.call_args[0][0]

    def test_check_global_state_safety_exception_suppressed(self, mock_mca_client):
        """Cover exception handler in _check_global_state_safety.

        The callbacks object must be truthy (to pass the `and litellm.callbacks` check)
        but raise during iteration so the list comprehension hits except Exception.
        """
        callback = MCALiteLLMCallback(mock_mca_client)

        callbacks_mock = MagicMock()
        callbacks_mock.__bool__ = Mock(return_value=True)
        callbacks_mock.__iter__ = Mock(side_effect=RuntimeError("forced iter error"))

        with patch("litellm.callbacks", callbacks_mock):
            with patch("mca_sdk.integrations.litellm_callback.logger") as mock_logger:
                # Should not raise — exception is suppressed
                callback._check_global_state_safety()
                mock_logger.debug.assert_called_once()
                assert "global state safety check" in mock_logger.debug.call_args[0][0]
