# tests/test_streaming_limits.py
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest


class TestStreamingLimits:
    def test_constants_defined(self):
        from second_opinion_mcp.server import MAX_STREAMING_CONTENT_BYTES, MAX_STREAMING_REASONING_BYTES
        assert MAX_STREAMING_CONTENT_BYTES == 10 * 1024 * 1024
        assert MAX_STREAMING_REASONING_BYTES == 5 * 1024 * 1024


def _make_chat_chunk(content=None, reasoning=None, usage=None):
    """Build a fake streaming chunk matching OpenAI's chat completion format."""
    delta = SimpleNamespace(content=content, reasoning_content=reasoning)
    choice = SimpleNamespace(delta=delta)
    chunk = SimpleNamespace(choices=[choice], usage=usage)
    return chunk


class _AsyncStreamCtx:
    """Async context manager that yields chunks from an async iterator."""

    def __init__(self, chunks):
        self._chunks = chunks

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        pass

    def __aiter__(self):
        return self

    async def __anext__(self):
        try:
            return self._chunks.pop(0)
        except IndexError:
            raise StopAsyncIteration


class TestStreamingContentTruncation:
    """Tests that content accumulation stops once the byte limit is exceeded."""

    @pytest.fixture(autouse=True)
    def _small_limits(self, monkeypatch):
        """Set streaming limits to 100 bytes for testing."""
        import second_opinion_mcp.server as srv
        monkeypatch.setattr(srv, "MAX_STREAMING_CONTENT_BYTES", 100)
        monkeypatch.setattr(srv, "MAX_STREAMING_REASONING_BYTES", 100)

    @pytest.fixture
    def mock_deepseek_client(self, monkeypatch):
        """Provide a mock AsyncOpenAI client wired to deepseek."""
        import second_opinion_mcp.server as srv
        client = MagicMock()
        monkeypatch.setattr(srv, "get_client", lambda provider: client)
        return client

    async def test_content_under_limit_accumulates(self, mock_deepseek_client):
        """Content smaller than limit is accumulated fully."""
        from second_opinion_mcp.server import _call_model_streaming

        small = "x" * 50  # 50 bytes, well under 100
        chunks = [_make_chat_chunk(content=small)]
        mock_deepseek_client.chat.completions.create = AsyncMock(
            return_value=_AsyncStreamCtx(chunks)
        )

        content, reasoning = await _call_model_streaming("deepseek", "test")
        assert content == small
        assert "[truncated" not in content.lower()

    async def test_content_truncated_when_over_limit(self, mock_deepseek_client):
        """Content stops accumulating once the byte limit is exceeded."""
        from second_opinion_mcp.server import _call_model_streaming

        chunk1 = "a" * 60   # 60 bytes
        chunk2 = "b" * 60   # 60 bytes - cumulative 120 > 100
        chunks = [
            _make_chat_chunk(content=chunk1),
            _make_chat_chunk(content=chunk2),
        ]
        mock_deepseek_client.chat.completions.create = AsyncMock(
            return_value=_AsyncStreamCtx(chunks)
        )

        content, reasoning = await _call_model_streaming("deepseek", "test")
        # Only the first chunk should be in the content
        assert chunk1 in content
        assert chunk2 not in content
        assert "[Response truncated due to size limits]" in content

    async def test_reasoning_truncated_independently(self, mock_deepseek_client):
        """Reasoning truncation is independent of content truncation."""
        from second_opinion_mcp.server import _call_model_streaming

        small_content = "content"  # well under limit
        reason1 = "r" * 60
        reason2 = "s" * 60  # cumulative 120 > 100
        chunks = [
            _make_chat_chunk(content=small_content, reasoning=reason1),
            _make_chat_chunk(reasoning=reason2),
        ]
        mock_deepseek_client.chat.completions.create = AsyncMock(
            return_value=_AsyncStreamCtx(chunks)
        )

        content, reasoning = await _call_model_streaming("deepseek", "test")
        # Content should be intact (under limit)
        assert content == small_content
        assert "[truncated" not in content.lower()
        # Reasoning should be truncated
        assert reason1 in reasoning
        assert reason2 not in reasoning
        assert "[Reasoning truncated due to size limits]" in reasoning

    async def test_truncation_logs_warning(self, mock_deepseek_client, caplog):
        """Truncation emits a warning-level log message."""
        from second_opinion_mcp.server import _call_model_streaming

        chunk1 = "a" * 60
        chunk2 = "b" * 60
        chunks = [
            _make_chat_chunk(content=chunk1),
            _make_chat_chunk(content=chunk2),
        ]
        mock_deepseek_client.chat.completions.create = AsyncMock(
            return_value=_AsyncStreamCtx(chunks)
        )

        with caplog.at_level(logging.WARNING, logger="second-opinion"):
            await _call_model_streaming("deepseek", "test")

        assert any("Content size limit reached" in rec.message for rec in caplog.records)

    async def test_truncation_notice_appended_once(self, mock_deepseek_client):
        """Truncation notice only appears once even with many excess chunks."""
        from second_opinion_mcp.server import _call_model_streaming

        chunks = [_make_chat_chunk(content="x" * 30) for _ in range(10)]  # 300 bytes total
        mock_deepseek_client.chat.completions.create = AsyncMock(
            return_value=_AsyncStreamCtx(chunks)
        )

        content, _ = await _call_model_streaming("deepseek", "test")
        assert content.count("[Response truncated due to size limits]") == 1
