"""Behavioral tests for provider-confirmed compression-budget rearming.

``compression_attempts`` is a shared per-turn backstop (pre-API gate,
overflow/413 handlers, post-tool gate). Before the refund fix, *successful*
pre-API compactions consumed it permanently: a marathon tool turn burned all
attempts on compactions that worked, the pre-API gate went dark for the rest
of the turn, and the context grew unchecked until the provider rejected the
request terminally ("max compression attempts (N) reached").

The budget is rearmed only when a completed compaction is followed by a real
provider prompt count below the configured threshold. Rough estimates and
usage-less responses cannot reopen the anti-thrash cap.

These tests drive ``run_conversation()`` through real tool iterations — no
source inspection, only observable compaction counts.
"""

from __future__ import annotations

import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from agent.conversation_loop import _should_rearm_compression_budget
from run_agent import AIAgent


# ---------------------------------------------------------------------------
# Unit: refund decision
# ---------------------------------------------------------------------------


class TestRearmDecision:
    def test_provider_confirmed_recovery_rearms(self):
        assert _should_rearm_compression_budget(
            2,
            completed_compaction_pending=True,
            prompt_tokens=7_999,
            threshold_tokens=10_000,
        )

    @pytest.mark.parametrize(
        ("attempts", "pending", "prompt_tokens", "threshold_tokens"),
        [
            (0, True, 7_999, 10_000),
            (2, False, 7_999, 10_000),
            (2, True, 0, 10_000),
            (2, True, 10_000, 10_000),
            (2, True, 10_001, 10_000),
            (2, True, 7_999, 0),
        ],
    )
    def test_unverified_or_pressured_response_keeps_budget_burned(
        self, attempts, pending, prompt_tokens, threshold_tokens
    ):
        assert not _should_rearm_compression_budget(
            attempts,
            completed_compaction_pending=pending,
            prompt_tokens=prompt_tokens,
            threshold_tokens=threshold_tokens,
        )


# ---------------------------------------------------------------------------
# Behavioral: marathon tool turn keeps compacting past the old cap
# ---------------------------------------------------------------------------


def _tool_call(i: int):
    return SimpleNamespace(
        id=f"call_{i}",
        type="function",
        # Vary the query per call: a real marathon turn issues distinct
        # lookups, and identical (args, result) pairs are now legitimately
        # deduped into reference stubs by the stall-guard subsystem —
        # zero-variance args here would deflate the very context pressure
        # this test exists to exercise.
        function=SimpleNamespace(name="web_search", arguments=f'{{"query": "x{i}"}}'),
    )


def _usage(prompt_tokens: int | None):
    if prompt_tokens is None:
        return None
    return SimpleNamespace(
        prompt_tokens=prompt_tokens,
        completion_tokens=1,
        total_tokens=prompt_tokens + 1,
    )


def _tool_response(i: int, prompt_tokens: int | None):
    msg = SimpleNamespace(
        content=None,
        reasoning_content=None,
        reasoning=None,
        tool_calls=[_tool_call(i)],
    )
    choice = SimpleNamespace(message=msg, finish_reason="tool_calls")
    return SimpleNamespace(
        choices=[choice], model="test/model", usage=_usage(prompt_tokens)
    )


def _stop_response(prompt_tokens: int | None):
    msg = SimpleNamespace(
        content="done",
        reasoning_content=None,
        reasoning=None,
        tool_calls=None,
    )
    choice = SimpleNamespace(message=msg, finish_reason="stop")
    return SimpleNamespace(
        choices=[choice], model="test/model", usage=_usage(prompt_tokens)
    )


def _make_tool_defs(*names: str) -> list:
    return [
        {
            "type": "function",
            "function": {
                "name": n,
                "description": f"{n} tool",
                "parameters": {"type": "object", "properties": {}},
            },
        }
        for n in names
    ]


THRESHOLD = 10_000

# Each tool result is large enough that the assembled request crosses
# THRESHOLD every iteration (estimator is ~chars/4), forcing one pre-API
# compaction per iteration — but stays below the 100K-char per-result
# persistence threshold (tools/budget_config.py) so it reaches the context
# untruncated.
BIG_TOOL_RESULT = "x" * 60_000


def _coherent_compressor() -> MagicMock:
    """A compressor whose should_compress() reflects the passed estimate.

    Unlike the always-True stub in the attempt-cap tests, this models the
    real coupling: pressure at/over threshold → compress; pressure gone →
    healthy. That coupling is what makes the refund safe.
    """
    compressor = MagicMock()
    compressor.protect_first_n = 3
    compressor.protect_last_n = 20
    compressor.threshold_tokens = THRESHOLD
    compressor.context_length = 200_000
    compressor.last_prompt_tokens = 0
    compressor._verify_compaction_cleared_threshold = False
    compressor.awaiting_real_usage_after_compression = False
    compressor.should_compress.side_effect = lambda t=None: (t or 0) >= THRESHOLD
    compressor.should_defer_preflight_to_real_usage.return_value = False
    compressor.get_active_compression_failure_cooldown.return_value = None

    def _update_from_response(usage):
        compressor.last_prompt_tokens = int(usage.get("prompt_tokens", 0) or 0)
        compressor._verify_compaction_cleared_threshold = False
        compressor.awaiting_real_usage_after_compression = False

    compressor.update_from_response.side_effect = _update_from_response
    return compressor


@pytest.fixture()
def agent():
    with (
        patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
        patch("run_agent.check_toolset_requirements", return_value={}),
        patch("run_agent.OpenAI"),
    ):
        a = AIAgent(
            api_key="test-key-1234567890",
            base_url="https://openrouter.ai/api/v1",
            quiet_mode=True,
            skip_context_files=True,
            skip_memory=True,
            max_iterations=20,
        )
    a.client = MagicMock()
    a._cached_system_prompt = "You are helpful."
    a._use_prompt_caching = False
    a._disable_streaming = True
    a.tool_delay = 0
    a.save_trajectories = False
    a.compression_enabled = True
    a.context_compressor = _coherent_compressor()
    return a


def _run_marathon_turn(
    agent, n_tool_iterations: int, *, provider_prompt_tokens: int | None
):
    """Drive one turn of ``n_tool_iterations`` oversized tool results."""
    responses = [
        _tool_response(i, provider_prompt_tokens) for i in range(n_tool_iterations)
    ]
    responses.append(_stop_response(provider_prompt_tokens))
    agent.client.chat.completions.create.side_effect = responses

    compress_calls = []

    def _fake_compress(messages, system_message, **_kwargs):
        # Model a compaction that works: blank out every oversized payload,
        # keeping roles and tool-call pairing intact so sanitization is
        # unaffected. Arm the same provider-verification boundary as the real
        # compression path.
        compress_calls.append(len(messages))
        agent.context_compressor._verify_compaction_cleared_threshold = True
        agent.context_compressor.awaiting_real_usage_after_compression = True
        compacted = [
            dict(m, content="[summarized]")
            if isinstance(m, dict) and len(str(m.get("content") or "")) > 5_000
            else m
            for m in messages
        ]
        return compacted, "compressed prompt"

    with (
        patch.object(agent, "_compress_context", side_effect=_fake_compress),
        patch.object(agent, "_persist_session"),
        patch.object(agent, "_save_trajectory"),
        patch.object(agent, "_cleanup_task_resources"),
        patch(
            "run_agent.handle_function_call",
            lambda name, args, task_id=None, **kwargs: json.dumps(
                {"ok": True, "payload": BIG_TOOL_RESULT}
            ),
        ),
    ):
        result = agent.run_conversation("do a lot of tool work")

    return result, compress_calls


class TestCompressionBudgetRefund:
    def test_marathon_turn_compacts_past_the_per_turn_cap(self, agent):
        """8 oversized tool iterations → more compactions than the old cap.

        Pre-refund, the 4th+ pressure spike found the budget exhausted, the
        pre-API gate stayed dark, and the request grew unchecked. With the
        refund, every genuine pressure spike is compacted and the turn
        completes.
        """
        assert agent.max_compression_attempts == 3  # config default
        result, compress_calls = _run_marathon_turn(
            agent,
            n_tool_iterations=8,
            provider_prompt_tokens=THRESHOLD - 1,
        )

        assert result["completed"] is True
        assert len(compress_calls) > 3, (
            "successful compactions must refund the per-turn budget; "
            f"got only {len(compress_calls)} compactions for 8 pressure spikes"
        )

    @pytest.mark.parametrize("provider_prompt_tokens", [None, THRESHOLD])
    def test_unverified_or_pressured_compaction_stays_capped(
        self, agent, provider_prompt_tokens
    ):
        """Missing usage or real usage at threshold cannot recycle the cap."""
        result, compress_calls = _run_marathon_turn(
            agent,
            n_tool_iterations=8,
            provider_prompt_tokens=provider_prompt_tokens,
        )

        assert result["completed"] is True
        assert len(compress_calls) <= agent.max_compression_attempts, (
            "without provider-confirmed headroom the per-turn cap must hold; "
            f"got {len(compress_calls)} compactions"
        )
