"""
Tests for P0+P1 fixes in the LangChain integration.

Covers:
    P0-5: _run_spans memory guard (eviction at _MAX_RUN_SPANS)
    P1-5: Singleton callback handler (_get_callback_handler)
    P1-7: Message extraction guards (non-list first element)
"""

from __future__ import annotations

import importlib
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, patch
from uuid import UUID, uuid4

import pytest


# =============================================================================
# P0-5: _run_spans Memory Guard
# =============================================================================


class TestRunSpansMemoryGuard:
    """RisicareCallbackHandler._run_spans must not grow unboundedly."""

    @pytest.fixture
    def handler(self):
        """Create a handler with mocked tracer for isolated testing."""
        from risicare.integrations.langchain._callback import RisicareCallbackHandler
        return RisicareCallbackHandler()

    def test_max_run_spans_constant_exists(self):
        """_MAX_RUN_SPANS constant should be defined."""
        from risicare.integrations.langchain._callback import RisicareCallbackHandler
        assert hasattr(RisicareCallbackHandler, "_MAX_RUN_SPANS")
        assert RisicareCallbackHandler._MAX_RUN_SPANS == 10_000

    def test_eviction_triggers_at_limit(self, handler):
        """When _run_spans reaches _MAX_RUN_SPANS, eviction should kick in.
        With P2-9 TTL cleanup, stale entries (>TTL) are evicted first;
        if no stale entries exist, FIFO eviction removes oldest 10%."""
        import time
        from risicare.integrations.langchain._callback import _SpanEntry

        # Pre-fill _run_spans to the limit with RECENT start_times
        # so they don't trigger TTL eviction (P2-9).
        limit = handler._MAX_RUN_SPANS
        now = time.perf_counter()
        for i in range(limit):
            fake_uuid = UUID(int=i)
            handler._run_spans[fake_uuid] = _SpanEntry(
                span=MagicMock(),
                token=None,
                start_time=now,  # Recent — not stale
            )

        assert len(handler._run_spans) == limit

        # Create a mock tracer to enable _start_span
        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            new_run_id = uuid4()
            handler._start_span(
                run_id=new_run_id,
                parent_run_id=None,
                name="test_span",
                kind_str="INTERNAL",
            )

        # No stale entries → FIFO eviction removes oldest 10%
        # Evict count = max(1, limit // 10) = 1000
        # After eviction + new entry: limit - 1000 + 1 = 9001
        expected = limit - max(1, limit // 10) + 1
        assert len(handler._run_spans) == expected

        # The new span should be present
        assert new_run_id in handler._run_spans

    def test_no_eviction_below_limit(self, handler):
        """No eviction should happen when under the limit."""
        from risicare.integrations.langchain._callback import _SpanEntry

        # Add a few entries well below the limit
        for i in range(5):
            handler._run_spans[UUID(int=i)] = _SpanEntry(
                span=MagicMock(), token=None, start_time=float(i),
            )

        assert len(handler._run_spans) == 5

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            handler._start_span(
                run_id=uuid4(),
                parent_run_id=None,
                name="test_span",
                kind_str="INTERNAL",
            )

        # Should be 6 now (5 + 1), no eviction
        assert len(handler._run_spans) == 6

    def test_stale_entries_evicted_by_ttl(self, handler):
        """P2-9: Entries older than _SPAN_TTL_SECONDS should be evicted first."""
        import time
        from risicare.integrations.langchain._callback import _SpanEntry

        limit = handler._MAX_RUN_SPANS
        now = time.perf_counter()
        ttl = handler._SPAN_TTL_SECONDS

        # Fill half with stale entries, half with fresh
        half = limit // 2
        for i in range(half):
            fake_uuid = UUID(int=i)
            handler._run_spans[fake_uuid] = _SpanEntry(
                span=MagicMock(),
                token=None,
                start_time=now - ttl - 100,  # Stale: older than TTL
            )
        for i in range(half, limit):
            fake_uuid = UUID(int=i)
            handler._run_spans[fake_uuid] = _SpanEntry(
                span=MagicMock(),
                token=None,
                start_time=now,  # Fresh: recent
            )

        assert len(handler._run_spans) == limit

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            new_run_id = uuid4()
            handler._start_span(
                run_id=new_run_id,
                parent_run_id=None,
                name="test_span",
                kind_str="INTERNAL",
            )

        # TTL eviction removed all 5000 stale entries, then added 1 new
        expected = half + 1
        assert len(handler._run_spans) == expected
        assert new_run_id in handler._run_spans

    def test_all_stale_entries_evicted(self, handler):
        """P2-9: When all entries are stale, all should be evicted."""
        import time
        from risicare.integrations.langchain._callback import _SpanEntry

        limit = handler._MAX_RUN_SPANS
        now = time.perf_counter()
        ttl = handler._SPAN_TTL_SECONDS

        for i in range(limit):
            fake_uuid = UUID(int=i)
            handler._run_spans[fake_uuid] = _SpanEntry(
                span=MagicMock(),
                token=None,
                start_time=now - ttl - 100,  # All stale
            )

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            new_run_id = uuid4()
            handler._start_span(
                run_id=new_run_id,
                parent_run_id=None,
                name="test_span",
                kind_str="INTERNAL",
            )

        # All stale entries removed, only the new one remains
        assert len(handler._run_spans) == 1
        assert new_run_id in handler._run_spans


# =============================================================================
# P1-5: Singleton Callback Handler
# =============================================================================


class TestSingletonCallbackHandler:
    """_get_callback_handler() must return a module-level singleton."""

    def test_returns_same_instance(self):
        """Multiple calls should return the same handler instance."""
        from risicare.integrations.langchain._patches import _get_callback_handler

        handler1 = _get_callback_handler()
        handler2 = _get_callback_handler()
        assert handler1 is handler2

    def test_handler_type_is_correct(self):
        """Returned handler should be a RisicareCallbackHandler."""
        from risicare.integrations.langchain._patches import _get_callback_handler
        from risicare.integrations.langchain._callback import RisicareCallbackHandler

        handler = _get_callback_handler()
        assert isinstance(handler, RisicareCallbackHandler)

    def test_shared_run_spans_dict(self):
        """Singleton ensures all invocations share the same _run_spans dict."""
        from risicare.integrations.langchain._patches import _get_callback_handler

        handler = _get_callback_handler()
        handler._run_spans[uuid4()] = "test_entry"

        handler2 = _get_callback_handler()
        assert len(handler2._run_spans) > 0
        # Cleanup
        handler._run_spans.clear()


# =============================================================================
# P1-7: Message Extraction Guards
# =============================================================================


class TestMessageExtractionGuards:
    """on_chat_model_start must handle various message formats gracefully."""

    @pytest.fixture
    def handler(self):
        from risicare.integrations.langchain._callback import RisicareCallbackHandler
        return RisicareCallbackHandler()

    def _make_message(self, type_: str = "human", content: str = "hello"):
        """Create a mock LangChain message."""
        msg = MagicMock()
        msg.type = type_
        msg.content = content
        return msg

    def test_normal_list_of_lists(self, handler):
        """Standard LangChain format: List[List[BaseMessage]]."""
        msgs = [[self._make_message("human", "hello"), self._make_message("ai", "hi")]]

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            run_id = uuid4()
            handler.on_chat_model_start(
                serialized={"name": "ChatOpenAI"},
                messages=msgs,
                run_id=run_id,
                invocation_params={"model": "gpt-4o"},
            )

        assert run_id in handler._run_spans
        # Cleanup
        handler._run_spans.clear()

    def test_single_message_not_in_list(self, handler):
        """Some LangChain versions pass a single message not wrapped in a list."""
        # messages = [single_message] instead of [[single_message]]
        single_msg = self._make_message("human", "test")
        msgs = [single_msg]  # first element is a message, not a list

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            run_id = uuid4()
            # This should NOT crash due to the isinstance guard
            handler.on_chat_model_start(
                serialized={"name": "ChatOpenAI"},
                messages=msgs,
                run_id=run_id,
                invocation_params={"model": "gpt-4o"},
            )

        assert run_id in handler._run_spans
        handler._run_spans.clear()

    def test_empty_messages_list(self, handler):
        """Empty messages list should not crash."""
        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            run_id = uuid4()
            handler.on_chat_model_start(
                serialized={"name": "ChatOpenAI"},
                messages=[],
                run_id=run_id,
                invocation_params={"model": "gpt-4o"},
            )

        assert run_id in handler._run_spans
        handler._run_spans.clear()

    def test_none_first_element(self, handler):
        """messages = [None] should be handled gracefully."""
        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            run_id = uuid4()
            handler.on_chat_model_start(
                serialized={"name": "ChatOpenAI"},
                messages=[None],
                run_id=run_id,
                invocation_params={"model": "gpt-4o"},
            )

        assert run_id in handler._run_spans
        handler._run_spans.clear()

    def test_message_with_non_string_content(self, handler):
        """Messages with non-string content (e.g., list for multimodal) should not crash."""
        msg = MagicMock()
        msg.type = "human"
        msg.content = [{"type": "text", "text": "hello"}]  # Multimodal format
        msgs = [[msg]]

        mock_tracer = MagicMock()
        mock_span = MagicMock()
        mock_span.span_id = "abcdef0123456789"
        mock_span.trace_id = "a" * 32
        mock_tracer.is_enabled = True
        mock_tracer.start_span_no_context.return_value = mock_span

        with patch("risicare.integrations.langchain._callback.get_tracer", return_value=mock_tracer):
            run_id = uuid4()
            handler.on_chat_model_start(
                serialized={"name": "ChatOpenAI"},
                messages=msgs,
                run_id=run_id,
                invocation_params={"model": "gpt-4o"},
            )

        assert run_id in handler._run_spans
        handler._run_spans.clear()
