"""Tests for the context-halving bugfix.

Background
----------
When the API returns "max_tokens too large given prompt" (input is fine,
but input_tokens + requested max_tokens > context_window), the old code
incorrectly halved context_length via get_next_probe_tier().

The fix introduces:
  * parse_available_output_tokens_from_error() — detects this specific
    error class and returns the available output token budget.
  * _ephemeral_max_output_tokens on AIAgent — a one-shot override that
    caps the output for one retry without touching context_length.

Naming note
-----------
  max_tokens     = OUTPUT token cap (a single response).
  context_length = TOTAL context window (input + output combined).
These are different and the old code conflated them; the fix keeps them
separate.
"""

import sys
import os
from unittest.mock import MagicMock, patch, PropertyMock

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pytest


# ---------------------------------------------------------------------------
# parse_available_output_tokens_from_error — unit tests
# ---------------------------------------------------------------------------

class TestParseAvailableOutputTokens:
    """Pure-function tests; no I/O required."""

    def _parse(self, msg):
        from agent.model_metadata import parse_available_output_tokens_from_error
        return parse_available_output_tokens_from_error(msg)

    # ── Should detect and extract ────────────────────────────────────────

    def test_anthropic_canonical_format(self):
        """Canonical Anthropic error: max_tokens: X > context_window: Y - input_tokens: Z = available_tokens: W"""
        msg = (
            "max_tokens: 32768 > context_window: 200000 "
            "- input_tokens: 190000 = available_tokens: 10000"
        )
        assert self._parse(msg) == 10000

    def test_anthropic_format_large_numbers(self):
        msg = (
            "max_tokens: 128000 > context_window: 200000 "
            "- input_tokens: 180000 = available_tokens: 20000"
        )
        assert self._parse(msg) == 20000

    def test_available_tokens_variant_spacing(self):
        """Handles extra spaces around the colon."""
        msg = "max_tokens: 32768 > 200000 available_tokens : 5000"
        assert self._parse(msg) == 5000

    def test_available_tokens_natural_language(self):
        """'available tokens: N' wording (no underscore)."""
        msg = "max_tokens must be at most 10000 given your prompt (available tokens: 10000)"
        assert self._parse(msg) == 10000

    def test_single_token_available(self):
        """Edge case: only 1 token left."""
        msg = "max_tokens: 9999 > context_window: 10000 - input_tokens: 9999 = available_tokens: 1"
        assert self._parse(msg) == 1

    # ── Should NOT detect (returns None) ─────────────────────────────────

    def test_prompt_too_long_is_not_output_cap_error(self):
        """'prompt is too long' errors must NOT be caught — they need context halving."""
        msg = "prompt is too long: 205000 tokens > 200000 maximum"
        assert self._parse(msg) is None

    def test_generic_context_window_exceeded(self):
        """Generic context window errors without available_tokens should not match."""
        msg = "context window exceeded: maximum is 32768 tokens"
        assert self._parse(msg) is None

    def test_context_length_exceeded(self):
        msg = "context_length_exceeded: prompt has 131073 tokens, limit is 131072"
        assert self._parse(msg) is None

    def test_no_max_tokens_keyword(self):
        """Error not related to max_tokens at all."""
        msg = "invalid_api_key: the API key is invalid"
        assert self._parse(msg) is None

    def test_empty_string(self):
        assert self._parse("") is None

    def test_rate_limit_error(self):
        msg = "rate_limit_error: too many requests per minute"
        assert self._parse(msg) is None


# ---------------------------------------------------------------------------
# build_anthropic_kwargs — output cap clamping
# ---------------------------------------------------------------------------

class TestBuildAnthropicKwargsClamping:
    """The context_length clamp only fires when output ceiling > window.
    For standard Anthropic models (output ceiling < window) it must not fire.
    """

    def _build(self, model, max_tokens=None, context_length=None):
        from agent.anthropic_adapter import build_anthropic_kwargs
        return build_anthropic_kwargs(
            model=model,
            messages=[{"role": "user", "content": "hi"}],
            tools=None,
            max_tokens=max_tokens,
            reasoning_config=None,
            context_length=context_length,
        )

    def test_no_clamping_when_output_ceiling_fits_in_window(self):
        """Opus 4.6 native output (128K) < context window (200K) — no clamping."""
        kwargs = self._build("claude-opus-4-6", context_length=200_000)
        assert kwargs["max_tokens"] == 128_000

    def test_clamping_fires_for_tiny_custom_window(self):
        """When context_length is 8K (local model), output cap is clamped to 7999."""
        kwargs = self._build("claude-opus-4-6", context_length=8_000)
        assert kwargs["max_tokens"] == 7_999

    def test_explicit_max_tokens_respected_when_within_window(self):
        """Explicit max_tokens smaller than window passes through unchanged."""
        kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000)
        assert kwargs["max_tokens"] == 4096

    def test_explicit_max_tokens_clamped_when_exceeds_window(self):
        """Explicit max_tokens larger than a small window is clamped."""
        kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000)
        assert kwargs["max_tokens"] == 15_999

    def test_no_context_length_uses_native_ceiling(self):
        """Without context_length the native output ceiling is used directly."""
        kwargs = self._build("claude-sonnet-4-6")
        assert kwargs["max_tokens"] == 64_000


# ---------------------------------------------------------------------------
# Ephemeral max_tokens mechanism — _build_api_kwargs
# ---------------------------------------------------------------------------

class TestEphemeralMaxOutputTokens:
    """_build_api_kwargs consumes _ephemeral_max_output_tokens exactly once
    and falls back to self.max_tokens on subsequent calls.
    """

    def _make_agent(self):
        """Return a minimal AIAgent with api_mode='anthropic_messages' and
        a stubbed context_compressor, bypassing full __init__ cost."""
        from run_agent import AIAgent
        agent = object.__new__(AIAgent)
        # Minimal attributes used by _build_api_kwargs
        agent.api_mode = "anthropic_messages"
        agent.model = "claude-opus-4-6"
        agent.tools = []
        agent.max_tokens = None
        agent.reasoning_config = None
        agent._is_anthropic_oauth = False
        agent._ephemeral_max_output_tokens = None

        compressor = MagicMock()
        compressor.context_length = 200_000
        agent.context_compressor = compressor

        # Stub out the internal message-preparation helper
        agent._prepare_anthropic_messages_for_api = MagicMock(
            return_value=[{"role": "user", "content": "hi"}]
        )
        agent._anthropic_preserve_dots = MagicMock(return_value=False)
        agent.request_overrides = {}
        return agent

    def test_ephemeral_override_is_used_on_first_call(self):
        """When _ephemeral_max_output_tokens is set, it overrides self.max_tokens."""
        agent = self._make_agent()
        agent._ephemeral_max_output_tokens = 5_000

        kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
        assert kwargs["max_tokens"] == 5_000

    def test_ephemeral_override_is_consumed_after_one_call(self):
        """After one call the ephemeral override is cleared to None."""
        agent = self._make_agent()
        agent._ephemeral_max_output_tokens = 5_000

        agent._build_api_kwargs([{"role": "user", "content": "hi"}])
        assert agent._ephemeral_max_output_tokens is None

    def test_subsequent_call_uses_self_max_tokens(self):
        """A second _build_api_kwargs call uses the normal max_tokens path."""
        agent = self._make_agent()
        agent._ephemeral_max_output_tokens = 5_000
        agent.max_tokens = None  # will resolve to native ceiling (128K for Opus 4.6)

        agent._build_api_kwargs([{"role": "user", "content": "hi"}])
        # Second call — ephemeral is gone
        kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
        assert kwargs2["max_tokens"] == 128_000  # Opus 4.6 native ceiling

    def test_no_ephemeral_uses_self_max_tokens_directly(self):
        """Without an ephemeral override, self.max_tokens is used normally."""
        agent = self._make_agent()
        agent.max_tokens = 8_192

        kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
        assert kwargs["max_tokens"] == 8_192


# ---------------------------------------------------------------------------
# Integration: error handler does NOT halve context_length for output-cap errors
# ---------------------------------------------------------------------------

class TestContextNotHalvedOnOutputCapError:
    """When the API returns 'max_tokens too large given prompt', the handler
    must set _ephemeral_max_output_tokens and NOT modify context_length.
    """

    def _make_agent_with_compressor(self, context_length=200_000):
        from run_agent import AIAgent
        from agent.context_compressor import ContextCompressor

        agent = object.__new__(AIAgent)
        agent.api_mode = "anthropic_messages"
        agent.model = "claude-opus-4-6"
        agent.base_url = "https://api.anthropic.com"
        agent.tools = []
        agent.max_tokens = None
        agent.reasoning_config = None
        agent._is_anthropic_oauth = False
        agent._ephemeral_max_output_tokens = None
        agent.log_prefix = ""
        agent.quiet_mode = True
        agent.verbose_logging = False

        compressor = MagicMock(spec=ContextCompressor)
        compressor.context_length = context_length
        compressor.threshold_percent = 0.75
        agent.context_compressor = compressor

        agent._prepare_anthropic_messages_for_api = MagicMock(
            return_value=[{"role": "user", "content": "hi"}]
        )
        agent._anthropic_preserve_dots = MagicMock(return_value=False)
        agent._vprint = MagicMock()
        agent.request_overrides = {}
        return agent

    def test_output_cap_error_sets_ephemeral_not_context_length(self):
        """On 'max_tokens too large' error, _ephemeral_max_output_tokens is set
        and compressor.context_length is left unchanged."""
        from agent.model_metadata import parse_available_output_tokens_from_error
        from agent.model_metadata import get_next_probe_tier

        error_msg = (
            "max_tokens: 128000 > context_window: 200000 "
            "- input_tokens: 180000 = available_tokens: 20000"
        )

        # Simulate the handler logic from run_agent.py
        agent = self._make_agent_with_compressor(context_length=200_000)
        old_ctx = agent.context_compressor.context_length

        available_out = parse_available_output_tokens_from_error(error_msg)
        assert available_out == 20_000, "parser must detect the error"

        # The fix: set ephemeral, skip context_length modification
        agent._ephemeral_max_output_tokens = max(1, available_out - 64)

        # context_length must be untouched
        assert agent.context_compressor.context_length == old_ctx
        assert agent._ephemeral_max_output_tokens == 19_936

    def test_prompt_too_long_still_triggers_probe_tier(self):
        """Genuine prompt-too-long errors must still use get_next_probe_tier."""
        from agent.model_metadata import parse_available_output_tokens_from_error
        from agent.model_metadata import get_next_probe_tier

        error_msg = "prompt is too long: 205000 tokens > 200000 maximum"

        available_out = parse_available_output_tokens_from_error(error_msg)
        assert available_out is None, "prompt-too-long must not be caught by output-cap parser"

        # The old halving path is still used for this class of error
        new_ctx = get_next_probe_tier(200_000)
        assert new_ctx == 128_000

    def test_output_cap_error_safety_margin(self):
        """The ephemeral value includes a 64-token safety margin below available_out."""
        from agent.model_metadata import parse_available_output_tokens_from_error

        error_msg = (
            "max_tokens: 32768 > context_window: 200000 "
            "- input_tokens: 190000 = available_tokens: 10000"
        )
        available_out = parse_available_output_tokens_from_error(error_msg)
        safe_out = max(1, available_out - 64)
        assert safe_out == 9_936

    def test_safety_margin_never_goes_below_one(self):
        """When available_out is very small, safe_out must be at least 1."""
        from agent.model_metadata import parse_available_output_tokens_from_error

        error_msg = (
            "max_tokens: 10 > context_window: 200000 "
            "- input_tokens: 199990 = available_tokens: 1"
        )
        available_out = parse_available_output_tokens_from_error(error_msg)
        safe_out = max(1, available_out - 64)
        assert safe_out == 1
