"""Tests for the OpenAI UAT agent."""

from __future__ import annotations

import importlib.util
import subprocess
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

AGENT_SCRIPT = Path(__file__).resolve().parent / "openai_agent.py"

# Import the module directly for unit testing internal functions
spec = importlib.util.spec_from_file_location("openai_agent", str(AGENT_SCRIPT))
openai_agent = importlib.util.module_from_spec(spec)
spec.loader.exec_module(openai_agent)


class TestArgParsing:
    """Test CLI argument parsing."""

    def test_missing_required_args_exits(self):
        """Script exits with error when required args are missing."""
        result = subprocess.run(
            [sys.executable, str(AGENT_SCRIPT)],
            capture_output=True,
            text=True,
        )
        assert result.returncode != 0

    def test_help_flag(self):
        """Script shows help text."""
        result = subprocess.run(
            [sys.executable, str(AGENT_SCRIPT), "--help"],
            capture_output=True,
            text=True,
        )
        assert result.returncode == 0
        assert "--prompt" in result.stdout
        assert "--base-url" in result.stdout
        assert "--mcp-config" in result.stdout


class TestToolConversion:
    """Test MCP tool schema to OpenAI function format conversion."""

    def test_basic_tool_conversion(self):
        """MCP tool schema converts to OpenAI function format."""
        mcp_tool = MagicMock()
        mcp_tool.name = "search_entities"
        mcp_tool.description = "Search for entities"
        mcp_tool.inputSchema = {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search term"},
            },
            "required": ["query"],
        }

        result = openai_agent.mcp_tool_to_openai(mcp_tool)

        assert result == {
            "type": "function",
            "function": {
                "name": "search_entities",
                "description": "Search for entities",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {"type": "string", "description": "Search term"},
                    },
                    "required": ["query"],
                },
            },
        }

    def test_tool_with_no_parameters(self):
        """MCP tool with no inputSchema gets empty parameters."""
        mcp_tool = MagicMock()
        mcp_tool.name = "get_version"
        mcp_tool.description = "Get HA version"
        mcp_tool.inputSchema = None

        result = openai_agent.mcp_tool_to_openai(mcp_tool)

        assert result["function"]["parameters"] == {
            "type": "object",
            "properties": {},
        }


class TestToolCallLoop:
    """Test the agent tool-call loop logic."""

    @pytest.mark.asyncio
    async def test_direct_text_response(self):
        """LLM responds with text immediately (no tool calls)."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Here are the results."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].finish_reason = "stop"
        mock_response.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="test-model",
            messages=[{"role": "user", "content": "Hello"}],
            tools=[],
            mcp_client=AsyncMock(),
        )

        assert result["result"] == "Here are the results."
        assert result["num_turns"] == 1
        assert result["tool_stats"]["totalCalls"] == 0
        assert result["tokens_input"] == 100
        assert result["tokens_output"] == 50
        assert result["cost_usd"] == 0

    @pytest.mark.asyncio
    async def test_no_think_sends_enable_thinking_false(self):
        """no_think=True sends the enable_thinking=false chat-template kwarg.

        Qwen3.5/3.6 ignore the in-band ``/no_think`` token, so disabling
        reasoning relies on this extra_body kwarg reaching the server.
        """
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Done."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = None
        mock_response.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=5,
            completion_tokens_details=MagicMock(reasoning_tokens=0),
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        await openai_agent.tool_call_loop(
            client=mock_client,
            model="qwen3.6",
            messages=[{"role": "user", "content": "Hello"}],
            tools=[],
            mcp_client=AsyncMock(),
            no_think=True,
        )

        _, kwargs = mock_client.chat.completions.create.call_args
        assert kwargs["extra_body"] == {
            "chat_template_kwargs": {"enable_thinking": False}
        }

    @pytest.mark.asyncio
    async def test_no_think_default_omits_extra_body(self):
        """Default (no_think=False) sends no extra_body, leaving other models untouched."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Done."
        mock_response.choices[0].message.tool_calls = None
        mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5)
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        await openai_agent.tool_call_loop(
            client=mock_client,
            model="some-model",
            messages=[{"role": "user", "content": "Hello"}],
            tools=[],
            mcp_client=AsyncMock(),
        )

        _, kwargs = mock_client.chat.completions.create.call_args
        assert "extra_body" not in kwargs

    @pytest.mark.asyncio
    async def test_no_think_warns_when_backend_still_reasons(self, caplog):
        """Warn when --no-think is set but the model still returns reasoning tokens."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Done."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = None
        mock_response.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=235,
            completion_tokens_details=MagicMock(reasoning_tokens=220),
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with caplog.at_level("WARNING"):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "Hello"}],
                tools=[],
                mcp_client=AsyncMock(),
                no_think=True,
            )

        assert any("still produced reasoning" in r.message for r in caplog.records)

    @pytest.mark.asyncio
    async def test_no_think_silent_when_backend_honors_it(self, caplog):
        """No warning when reasoning is actually suppressed (zero reasoning tokens)."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Done."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = None
        mock_response.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=5,
            completion_tokens_details=MagicMock(reasoning_tokens=0),
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with caplog.at_level("WARNING"):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "Hello"}],
                tools=[],
                mcp_client=AsyncMock(),
                no_think=True,
            )

        assert not any("still produced reasoning" in r.message for r in caplog.records)

    @pytest.mark.asyncio
    async def test_reasoning_tokens_captured_in_tokens_thoughts(self):
        """reasoning_tokens from completion_tokens_details flows into tokens_thoughts."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "391."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = None
        mock_response.usage = MagicMock(
            prompt_tokens=23,
            completion_tokens=235,
            completion_tokens_details=MagicMock(reasoning_tokens=220),
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="qwen3.6",
            messages=[{"role": "user", "content": "17*23?"}],
            tools=[],
            mcp_client=AsyncMock(),
        )

        assert result["tokens_thoughts"] == 220
        assert result["tokens_output"] == 235

    @pytest.mark.asyncio
    async def test_tokens_thoughts_zero_when_backend_omits_details(self):
        """Backends without completion_tokens_details report zero thoughts, not a crash."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "ok"
        mock_response.choices[0].message.tool_calls = None
        mock_response.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=5,
            completion_tokens_details=None,
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="some-model",
            messages=[{"role": "user", "content": "hi"}],
            tools=[],
            mcp_client=AsyncMock(),
        )

        assert result["tokens_thoughts"] == 0

    @pytest.mark.asyncio
    async def test_no_think_warns_on_reasoning_content_without_token_detail(
        self, caplog
    ):
        """Warn via reasoning_content when the backend reports no reasoning token count."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "391."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = "let me think..."
        mock_response.usage = MagicMock(
            prompt_tokens=10, completion_tokens=5, completion_tokens_details=None
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with caplog.at_level("WARNING"):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "Hi"}],
                tools=[],
                mcp_client=AsyncMock(),
                no_think=True,
            )

        msgs = [
            r.message for r in caplog.records if "still produced reasoning" in r.message
        ]
        assert len(msgs) == 1
        assert "token count unavailable" in msgs[0]

    @pytest.mark.asyncio
    async def test_no_think_warns_on_inline_think_block(self, caplog):
        """Warn when reasoning is inlined as a <think> block with no structured signal.

        Uses uppercase tags to also pin the case-insensitive detection.
        """
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "<THINK>17*23</THINK>391."
        mock_response.choices[0].message.tool_calls = None
        mock_response.choices[0].message.reasoning_content = None
        mock_response.usage = MagicMock(
            prompt_tokens=10, completion_tokens=5, completion_tokens_details=None
        )
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with caplog.at_level("WARNING"):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "Hi"}],
                tools=[],
                mcp_client=AsyncMock(),
                no_think=True,
            )

        assert any("still produced reasoning" in r.message for r in caplog.records)

    @pytest.mark.asyncio
    async def test_no_think_warns_only_once_across_turns(self, caplog):
        """The warning fires at most once even when every turn keeps reasoning."""

        def _reasoning_resp(content, tool_calls):
            resp = MagicMock()
            resp.choices = [MagicMock()]
            resp.choices[0].message.content = content
            resp.choices[0].message.tool_calls = tool_calls
            resp.choices[0].message.reasoning_content = None
            resp.usage = MagicMock(
                prompt_tokens=10,
                completion_tokens=60,
                completion_tokens_details=MagicMock(reasoning_tokens=50),
            )
            return resp

        tc = MagicMock()
        tc.id = "call_1"
        tc.function.name = "some_tool"
        tc.function.arguments = "{}"
        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(
            side_effect=[_reasoning_resp(None, [tc]), _reasoning_resp("done", None)]
        )
        mock_mcp = AsyncMock()
        mock_mcp.call_tool.return_value = MagicMock(content=[MagicMock(text="ok")])

        with caplog.at_level("WARNING"):
            result = await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "Hi"}],
                tools=[],
                mcp_client=mock_mcp,
                no_think=True,
            )

        warnings = [
            r for r in caplog.records if "still produced reasoning" in r.message
        ]
        assert len(warnings) == 1
        assert result["tokens_thoughts"] == 100  # 50 per turn, accumulated

    @pytest.mark.asyncio
    async def test_single_tool_call(self):
        """LLM calls one tool, then responds with text."""
        mock_client = MagicMock()

        # First response: tool call
        tool_call = MagicMock()
        tool_call.id = "call_1"
        tool_call.function.name = "search_entities"
        tool_call.function.arguments = '{"query": "light"}'

        resp1 = MagicMock()
        resp1.choices = [MagicMock()]
        resp1.choices[0].message.content = None
        resp1.choices[0].message.tool_calls = [tool_call]
        resp1.choices[0].finish_reason = "tool_calls"
        resp1.usage = MagicMock(prompt_tokens=200, completion_tokens=30)

        # Second response: text
        resp2 = MagicMock()
        resp2.choices = [MagicMock()]
        resp2.choices[0].message.content = "Found 3 lights."
        resp2.choices[0].message.tool_calls = None
        resp2.choices[0].finish_reason = "stop"
        resp2.usage = MagicMock(prompt_tokens=300, completion_tokens=20)

        mock_client.chat.completions.create = AsyncMock(side_effect=[resp1, resp2])

        # Mock MCP client
        mock_mcp = AsyncMock()
        mock_mcp.call_tool.return_value = MagicMock(
            content=[MagicMock(text="light.bed_light, light.ceiling, light.kitchen")]
        )

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="test-model",
            messages=[{"role": "user", "content": "Find lights"}],
            tools=[{"type": "function", "function": {"name": "search_entities"}}],
            mcp_client=mock_mcp,
        )

        assert result["result"] == "Found 3 lights."
        assert result["num_turns"] == 2
        assert result["tool_stats"]["totalCalls"] == 1
        assert result["tool_stats"]["totalSuccess"] == 1
        assert result["tool_stats"]["totalFail"] == 0
        assert result["tokens_input"] == 500
        assert result["tokens_output"] == 50

    @pytest.mark.asyncio
    async def test_tool_call_failure_counted(self):
        """Failed MCP tool calls are counted in stats."""
        mock_client = MagicMock()

        # First response: tool call
        tool_call = MagicMock()
        tool_call.id = "call_1"
        tool_call.function.name = "bad_tool"
        tool_call.function.arguments = "{}"

        resp1 = MagicMock()
        resp1.choices = [MagicMock()]
        resp1.choices[0].message.content = None
        resp1.choices[0].message.tool_calls = [tool_call]
        resp1.usage = MagicMock(prompt_tokens=100, completion_tokens=10)

        # Second response: text
        resp2 = MagicMock()
        resp2.choices = [MagicMock()]
        resp2.choices[0].message.content = "Tool failed."
        resp2.choices[0].message.tool_calls = None
        resp2.usage = MagicMock(prompt_tokens=200, completion_tokens=10)

        mock_client.chat.completions.create = AsyncMock(side_effect=[resp1, resp2])

        mock_mcp = AsyncMock()
        mock_mcp.call_tool.side_effect = RuntimeError("Tool not found")

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="test-model",
            messages=[{"role": "user", "content": "Try it"}],
            tools=[],
            mcp_client=mock_mcp,
        )

        assert result["tool_stats"]["totalCalls"] == 1
        assert result["tool_stats"]["totalFail"] == 1
        assert result["tool_stats"]["totalSuccess"] == 0

    @pytest.mark.asyncio
    async def test_malformed_tool_arguments_reported_as_error(self):
        """Malformed JSON arguments are reported as errors, not silently ignored."""
        mock_client = MagicMock()

        tool_call = MagicMock()
        tool_call.id = "call_bad"
        tool_call.function.name = "some_tool"
        tool_call.function.arguments = "not valid json{{"

        resp1 = MagicMock()
        resp1.choices = [MagicMock()]
        resp1.choices[0].message.content = None
        resp1.choices[0].message.tool_calls = [tool_call]
        resp1.usage = MagicMock(prompt_tokens=10, completion_tokens=5)

        resp2 = MagicMock()
        resp2.choices = [MagicMock()]
        resp2.choices[0].message.content = "Done."
        resp2.choices[0].message.tool_calls = None
        resp2.usage = MagicMock(prompt_tokens=20, completion_tokens=5)

        mock_client.chat.completions.create = AsyncMock(side_effect=[resp1, resp2])

        mock_mcp = AsyncMock()

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="test-model",
            messages=[{"role": "user", "content": "test"}],
            tools=[],
            mcp_client=mock_mcp,
        )

        assert result["result"] == "Done."
        assert result["tool_stats"]["totalCalls"] == 1
        assert result["tool_stats"]["totalFail"] == 1
        assert result["tool_stats"]["totalSuccess"] == 0
        # MCP tool should NOT have been called with bad args
        mock_mcp.call_tool.assert_not_called()

    @pytest.mark.asyncio
    async def test_max_iterations_reached(self):
        """Loop terminates after MAX_TOOL_LOOP_ITERATIONS with tool calls."""
        mock_client = MagicMock()

        tool_call = MagicMock()
        tool_call.id = "call_loop"
        tool_call.function.name = "some_tool"
        tool_call.function.arguments = "{}"

        resp = MagicMock()
        resp.choices = [MagicMock()]
        resp.choices[0].message.content = None
        resp.choices[0].message.tool_calls = [tool_call]
        resp.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=15,
            completion_tokens_details=MagicMock(reasoning_tokens=5),
        )

        mock_client.chat.completions.create = AsyncMock(return_value=resp)

        mock_mcp = AsyncMock()
        mock_mcp.call_tool.return_value = MagicMock(content=[MagicMock(text="ok")])

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="test-model",
            messages=[{"role": "user", "content": "loop forever"}],
            tools=[],
            mcp_client=mock_mcp,
        )

        assert result["result"] == "Max tool-call iterations reached"
        assert result["num_turns"] == openai_agent.MAX_TOOL_LOOP_ITERATIONS
        assert (
            result["tool_stats"]["totalCalls"] == openai_agent.MAX_TOOL_LOOP_ITERATIONS
        )
        # tokens_thoughts accumulates across every turn and is reported at the
        # max-iterations exit too (not just the final-response exit).
        assert result["tokens_thoughts"] == 5 * openai_agent.MAX_TOOL_LOOP_ITERATIONS

    @pytest.mark.asyncio
    async def test_null_usage_handled(self):
        """Response with usage=None doesn't crash token counting."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = "Result."
        mock_response.choices[0].message.tool_calls = None
        mock_response.usage = None
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        result = await openai_agent.tool_call_loop(
            client=mock_client,
            model="m",
            messages=[{"role": "user", "content": "test"}],
            tools=[],
            mcp_client=AsyncMock(),
        )

        assert result["tokens_input"] == 0
        assert result["tokens_output"] == 0
        assert result["result"] == "Result."

    @pytest.mark.asyncio
    async def test_empty_choices_raises(self):
        """API returning empty choices produces a clear error."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices = []
        mock_response.usage = None
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with pytest.raises(RuntimeError, match="empty choices"):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="test-model",
                messages=[{"role": "user", "content": "test"}],
                tools=[],
                mcp_client=AsyncMock(),
            )


class TestRunScenarioInline:
    """Test the scenario wrapper that owns the /no_think prepend and threading."""

    @staticmethod
    def _mock_client():
        client = MagicMock()
        resp = MagicMock()
        resp.choices = [MagicMock()]
        resp.choices[0].message.content = "Done."
        resp.choices[0].message.tool_calls = None
        resp.choices[0].message.reasoning_content = None
        resp.usage = MagicMock(
            prompt_tokens=10,
            completion_tokens=5,
            completion_tokens_details=MagicMock(reasoning_tokens=0),
        )
        client.chat.completions.create = AsyncMock(return_value=resp)
        return client

    @pytest.mark.asyncio
    async def test_no_think_prepends_token_and_sends_kwarg(self):
        """no_think=True both prepends /no_think and sends the enable_thinking kwarg."""
        client = self._mock_client()
        await openai_agent.run_scenario_inline(
            client,
            AsyncMock(),
            "qwen3.6",
            "Find lights",
            no_think=True,
            openai_tools=[],
        )
        _, kwargs = client.chat.completions.create.call_args
        assert kwargs["messages"][0]["content"] == "/no_think\n\nFind lights"
        assert kwargs["extra_body"] == {
            "chat_template_kwargs": {"enable_thinking": False}
        }

    @pytest.mark.asyncio
    async def test_default_omits_prepend_and_kwarg(self):
        """Default leaves the prompt verbatim and sends no extra_body."""
        client = self._mock_client()
        await openai_agent.run_scenario_inline(
            client,
            AsyncMock(),
            "some-model",
            "Find lights",
            openai_tools=[],
        )
        _, kwargs = client.chat.completions.create.call_args
        assert kwargs["messages"][0]["content"] == "Find lights"
        assert "extra_body" not in kwargs


class TestExtractToolResultText:
    """Test MCP tool result text extraction."""

    def test_text_content_blocks(self):
        """Multiple text blocks are joined with newlines."""
        result = MagicMock(content=[MagicMock(text="line1"), MagicMock(text="line2")])
        assert openai_agent.extract_tool_result_text(result) == "line1\nline2"

    def test_non_text_content_falls_back_to_str(self):
        """Non-text content blocks fall back to str()."""

        class ImageBlock:
            def __str__(self):
                return "raw-block"

        result = MagicMock(content=[ImageBlock()])
        assert openai_agent.extract_tool_result_text(result) == "raw-block"

    def test_no_content_falls_back_to_str(self):
        """Result with no content attribute falls back to str()."""

        class BareResult:
            def __str__(self):
                return "fallback"

        assert openai_agent.extract_tool_result_text(BareResult()) == "fallback"


class TestDetectModel:
    """Test model auto-detection."""

    @pytest.mark.asyncio
    async def test_returns_first_model_id(self):
        """Returns the first model ID from the API."""
        client = MagicMock()
        model = MagicMock()
        model.id = "llama-3.1-8b"
        client.models.list = AsyncMock(return_value=MagicMock(data=[model]))
        assert await openai_agent.detect_model(client) == "llama-3.1-8b"

    @pytest.mark.asyncio
    async def test_raises_when_no_models(self):
        """Raises RuntimeError when no models are available."""
        client = MagicMock()
        client.models.list = AsyncMock(return_value=MagicMock(data=[]))
        with pytest.raises(RuntimeError, match="No models available"):
            await openai_agent.detect_model(client)


class TestDetectQuantization:
    """Test best-effort LM Studio quantization detection."""

    @staticmethod
    def _patch_client(monkeypatch, *, payload=None, get_exc=None, raise_status=False):
        """Swap httpx.AsyncClient for a fake; returns a dict capturing the GET url."""
        import httpx

        captured: dict = {}

        class _Resp:
            def raise_for_status(self):
                if raise_status:
                    raise httpx.HTTPError("500 Server Error")

            def json(self):
                return payload or {}

        class _Client:
            def __init__(self, **kwargs):
                pass

            async def __aenter__(self):
                return self

            async def __aexit__(self, *exc):
                return False

            async def get(self, url):
                captured["url"] = url
                if get_exc:
                    raise get_exc
                return _Resp()

        monkeypatch.setattr(httpx, "AsyncClient", _Client)
        return captured

    @pytest.mark.asyncio
    async def test_returns_quant_and_strips_v1_suffix(self, monkeypatch):
        """Happy path: returns the quant and queries /api/v0/models with /v1 stripped."""
        captured = self._patch_client(
            monkeypatch,
            payload={"data": [{"id": "qwen3.6-27b", "quantization": "IQ2_M"}]},
        )
        result = await openai_agent.detect_quantization(
            "http://172.19.0.1:1234/v1", "qwen3.6-27b"
        )
        assert result == "IQ2_M"
        assert captured["url"] == "http://172.19.0.1:1234/api/v0/models"

    @pytest.mark.asyncio
    async def test_none_when_model_not_listed(self, monkeypatch):
        """A model id absent from the listing yields None."""
        self._patch_client(
            monkeypatch, payload={"data": [{"id": "other", "quantization": "Q4_K_M"}]}
        )
        result = await openai_agent.detect_quantization("http://h/v1", "qwen3.6-27b")
        assert result is None

    @pytest.mark.asyncio
    async def test_none_when_entry_has_no_quant(self, monkeypatch):
        """A matching entry without a quantization key yields None."""
        self._patch_client(monkeypatch, payload={"data": [{"id": "qwen3.6-27b"}]})
        result = await openai_agent.detect_quantization("http://h/v1", "qwen3.6-27b")
        assert result is None

    @pytest.mark.asyncio
    async def test_none_on_transport_error(self, monkeypatch):
        """A transport error is swallowed (never fatal); returns None."""
        import httpx

        self._patch_client(monkeypatch, get_exc=httpx.ConnectError("boom"))
        result = await openai_agent.detect_quantization("http://h/v1", "m")
        assert result is None

    @pytest.mark.asyncio
    async def test_none_on_http_error_status(self, monkeypatch):
        """A 5xx (raise_for_status) is swallowed; returns None."""
        self._patch_client(monkeypatch, raise_status=True)
        result = await openai_agent.detect_quantization("http://h/v1", "m")
        assert result is None


def _bad_request(message: str) -> openai.BadRequestError:  # noqa: F821
    """Build a real ``openai.BadRequestError`` carrying ``message``."""
    import httpx
    import openai

    request = httpx.Request("POST", "http://test/v1/chat/completions")
    response = httpx.Response(400, request=request)
    return openai.BadRequestError(
        message,
        response=response,
        body={"error": {"type": "exceed_context_size_error"}},
    )


class TestParseContextOverflow:
    """Test detection/parsing of llama-server context-overflow 400s."""

    def test_extracts_requested_and_context_tokens(self):
        err = _bad_request(
            "request (27220 tokens) exceeds the available context size (24576 tokens)"
        )
        assert openai_agent._parse_context_overflow(err) == (27220, 24576)

    def test_none_for_unrelated_bad_request(self):
        err = _bad_request("invalid 'temperature': must be <= 2")
        assert openai_agent._parse_context_overflow(err) is None

    def test_marker_without_numbers_returns_none_pair(self):
        """Marker present but no parseable counts → (None, None), still an overflow."""
        err = _bad_request("exceed_context_size_error: too long")
        assert openai_agent._parse_context_overflow(err) == (None, None)

    def test_extracts_from_sdk_wire_format(self):
        """The SDK stringifies as ``Error code: 400 - {body-dict}``; parse survives it.

        Exercises the real on-the-wire shape (regex embedded in a dict repr,
        plus the ``exceed_context_size_error`` type marker) rather than a clean
        pre-extracted message.
        """
        err = _bad_request(
            "Error code: 400 - {'error': {'message': 'request (27220 tokens) "
            "exceeds the available context size (24576 tokens)', "
            "'type': 'exceed_context_size_error'}}"
        )
        assert openai_agent._parse_context_overflow(err) == (27220, 24576)


class TestContextOverflowInLoop:
    """Test that the tool-call loop surfaces context overflow distinctly."""

    @pytest.mark.asyncio
    async def test_overflow_raises_context_window_exceeded(self):
        """A context-overflow 400 becomes ContextWindowExceededError with counts."""
        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(
            side_effect=_bad_request(
                "request (27220 tokens) exceeds the available context size "
                "(24576 tokens)"
            )
        )

        with pytest.raises(openai_agent.ContextWindowExceededError) as exc_info:
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "hi"}],
                tools=[],
                mcp_client=AsyncMock(),
            )

        assert exc_info.value.requested_tokens == 27220
        assert exc_info.value.context_size == 24576

    @pytest.mark.asyncio
    async def test_unrelated_bad_request_propagates(self):
        """A non-overflow 400 is re-raised unchanged, not mislabeled as overflow."""
        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(
            side_effect=_bad_request("invalid 'temperature': must be <= 2")
        )

        import openai

        with pytest.raises(openai.BadRequestError):
            await openai_agent.tool_call_loop(
                client=mock_client,
                model="qwen3.6",
                messages=[{"role": "user", "content": "hi"}],
                tools=[],
                mcp_client=AsyncMock(),
            )
