"""Registry walk for the injected-parameter strip-and-retry safety class.

Bug class (#90257, #89897, #91164, #89503): a parameter that Hermes (or the
provider's own gateway) injects into a request — ``prompt_cache_retention``,
reasoning translations, ``temperature``/``max_tokens``/``response_format``
adjustments — draws an HTTP 400 from the provider and the turn DIES instead
of stripping the parameter (or retrying the identical request when the field
was never ours) and completing.

Two request paths are pinned, each driving the REAL recovery code with only
the HTTP boundary faked (a client that 400s once, then succeeds):

* Classifier path (main conversation loop): ``classify_api_error`` +
  ``_SERVER_INJECTED_PARAM_SENDERS`` (fixed in PR #91643). The registry is
  walked at collection time, so adding a new injected param automatically
  extends coverage. The retry contract mirrors conversation_loop: retryable
  → resend identical request; non-retryable → abort.
* Auxiliary path: ``agent.auxiliary_client.call_llm``'s reactive strip-and-
  retry rungs for every parameter that path knows how to strip.

Guard: a 400 naming a param NOT in the registry must still fail — we never
blindly strip arbitrary params, because that would convert our own request
bug into a friendly lie (error-classifier policy, AGENTS.md).
"""

import copy
from types import SimpleNamespace
from unittest.mock import patch

import pytest

from agent.error_classifier import (
    FailoverReason,
    _SERVER_INJECTED_PARAM_SENDERS,
    classify_api_error,
)
from agent.auxiliary_client import call_llm


class MockAPIError(Exception):
    """Simulates an OpenAI SDK APIStatusError (status_code + body)."""

    def __init__(self, message, status_code=None, body=None):
        super().__init__(message)
        self.status_code = status_code
        self.body = body or {}


# ---------------------------------------------------------------------------
# Path 1 — classifier path (main conversation loop contract)
# ---------------------------------------------------------------------------

REGISTRY_PARAMS = sorted(_SERVER_INJECTED_PARAM_SENDERS)


def _error_shapes(param):
    """Real provider 400 shapes naming *param* (see #90257 live capture)."""
    openai_body = {
        "error": {
            "message": f"Unsupported parameter: '{param}'",
            "type": "invalid_request_error",
            "param": param,
            "code": "unsupported_parameter",
        }
    }
    return [
        pytest.param(
            f"Error code: 400 - {openai_body!r}", openai_body,
            id=f"{param}-openai-structured",
        ),
        pytest.param(
            f"{param} is not supported on this model", {},
            id=f"{param}-message-only",
        ),
        pytest.param(
            f"Unknown parameter: {param}",
            {"detail": f"Unknown parameter: {param}"},
            id=f"{param}-terse-detail",
        ),
    ]


def _drive_conversation_retry(transport, *, provider, model, max_attempts=3):
    """Minimal loop honoring the conversation_loop classifier contract.

    retryable → resend the identical request; non-retryable → abort the turn.
    The classifier under test is the REAL one; only the transport is fake.
    """
    for _ in range(max_attempts):
        try:
            return transport()
        except Exception as exc:  # noqa: BLE001 — contract mirror
            verdict = classify_api_error(
                exc, provider=provider, model=model,
                approx_tokens=50, num_messages=3,
            )
            if not verdict.retryable:
                raise
    raise AssertionError("retries exhausted")


class _FlakyTransport:
    """400s exactly once, then succeeds — the HTTP boundary and nothing else."""

    def __init__(self, error):
        self.error = error
        self.calls = 0

    def __call__(self):
        self.calls += 1
        if self.calls == 1:
            raise self.error
        return {"ok": True}


class TestClassifierPathRegistryWalk:
    """Every registered injected param × every real 400 shape must retry."""

    @pytest.mark.parametrize("param", REGISTRY_PARAMS)
    def test_classified_retryable_without_compression(self, param):
        """#90257/#91164: injected-param 400 → retryable server_error,
        never routed into the compression loop (request shape was fine)."""
        for msg_param in _error_shapes(param):
            message, body = msg_param.values
            err = MockAPIError(message, status_code=400, body=copy.deepcopy(body))
            verdict = classify_api_error(
                err, provider="openai-codex", model="gpt-5.6-sol",
                approx_tokens=546912, context_length=272000, num_messages=576,
            )
            assert verdict.reason == FailoverReason.server_error, message
            assert verdict.retryable is True, message
            assert verdict.should_compress is False, message


    @pytest.mark.parametrize("param", REGISTRY_PARAMS)
    def test_sender_route_still_fails_fast(self, param):
        """When the current provider IS a deliberate sender of the param, the
        400 is a real request bug — it must stay a non-retryable format_error."""
        sender = _SERVER_INJECTED_PARAM_SENDERS[param][0]
        err = MockAPIError(
            f"Unsupported parameter: '{param}'", status_code=400,
            body={"error": {"message": f"Unsupported parameter: '{param}'",
                            "code": "unsupported_parameter", "param": param}},
        )
        verdict = classify_api_error(err, provider=sender, model="any-model")
        assert verdict.reason == FailoverReason.format_error
        assert verdict.retryable is False

    def test_guard_unregistered_param_still_fails(self):
        """Policy guard: a 400 naming a param NOT in the registry must abort.
        Blindly stripping arbitrary params would mask real request bugs
        (never convert our own request bug into a friendly lie)."""
        err = MockAPIError(
            "Unsupported parameter: 'frobnication_level'", status_code=400,
            body={"error": {"message": "Unsupported parameter: 'frobnication_level'",
                            "code": "unsupported_parameter",
                            "param": "frobnication_level"}},
        )
        verdict = classify_api_error(
            err, provider="openai-codex", model="gpt-5.6-sol",
        )
        assert verdict.reason == FailoverReason.format_error
        assert verdict.retryable is False
        transport = _FlakyTransport(err)
        with pytest.raises(MockAPIError):
            _drive_conversation_retry(
                transport, provider="openai-codex", model="gpt-5.6-sol",
            )
        assert transport.calls == 1  # aborted on first attempt, no blind retry


# ---------------------------------------------------------------------------
# Path 2 — auxiliary call path (call_llm reactive strip-and-retry rungs)
# ---------------------------------------------------------------------------

class _FlakyClient:
    """Fake OpenAI-SDK client: chat.completions.create 400s once, then OK."""

    def __init__(self, error):
        self.base_url = "https://api.openai.com/v1"
        self.calls = []
        self._error = error
        self.chat = SimpleNamespace(
            completions=SimpleNamespace(create=self._create)
        )

    def _create(self, **kwargs):
        self.calls.append(copy.deepcopy(kwargs))
        if len(self.calls) == 1:
            raise self._error
        return {"ok": True}


def _aux_patches(client):
    # gpt-4.1: a model the aux path still SENDS temperature to. gpt-5.x omits it up front
    # (#51083), which would leave the reactive strip rung nothing to strip.
    return (
        patch("agent.auxiliary_client._resolve_task_provider_model",
              return_value=("openai-codex", "gpt-4.1", None, None, None)),
        patch("agent.auxiliary_client._get_cached_client",
              return_value=(client, "gpt-4.1")),
        patch("agent.auxiliary_client._validate_llm_response",
              side_effect=lambda resp, _task, **_kw: resp),
    )


# Every parameter the auxiliary path knows how to strip, with a real provider
# phrasing and the kwargs that inject it. If a new strip rung is added to
# call_llm, extend this walk (the guard test below keeps the boundary honest).
AUX_STRIPPABLE = [
    pytest.param(
        {"temperature": 0.3},
        "HTTP 400: Unsupported parameter: temperature",
        lambda kw: "temperature" not in kw,
        id="temperature",
    ),
    pytest.param(
        {"max_tokens": 128},
        "Error code: 400 - {'error': {'message': \"Unsupported parameter: "
        "'max_tokens' is not supported with this model.\", "
        "'code': 'unsupported_parameter', 'param': 'max_tokens'}}",
        lambda kw: "max_tokens" not in kw and "max_completion_tokens" not in kw,
        id="max_tokens",
    ),
    pytest.param(
        {"extra_body": {"response_format": {"type": "json_object"}}},
        "HTTP 400: Unsupported parameter: response_format",
        lambda kw: "response_format"
        not in (kw.get("extra_body") or {}) and "response_format" not in kw,
        id="response_format",
    ),
]


class TestAuxiliaryPathStripRetryWalk:
    """call_llm must strip the rejected injected param and retry to success."""

    @pytest.mark.parametrize("inject_kwargs,error_msg,stripped_ok", AUX_STRIPPABLE)
    def test_strips_param_and_completes(self, inject_kwargs, error_msg, stripped_ok):
        """#89897/#90257 class: provider 400 names a param we injected — the
        aux call must retry once WITHOUT the param and return the response."""
        client = _FlakyClient(RuntimeError(error_msg))
        p1, p2, p3 = _aux_patches(client)
        with p1, p2, p3:
            result = call_llm(
                task="session_search",
                messages=[{"role": "user", "content": "hi"}],
                **inject_kwargs,
            )
        assert result == {"ok": True}
        assert len(client.calls) == 2, "expected exactly one strip-and-retry"
        assert stripped_ok(client.calls[1]), (
            f"retry still carried the rejected param: {client.calls[1].keys()}"
        )

    def test_guard_unregistered_param_still_raises(self):
        """A 400 naming a param the aux path has no strip rung for must
        surface — no blind stripping, no silent success (#91164 policy)."""
        client = _FlakyClient(
            RuntimeError("HTTP 400: Unsupported parameter: frobnication_level")
        )
        p1, p2, p3 = _aux_patches(client)
        with p1, p2, p3:
            with pytest.raises(RuntimeError, match="frobnication_level"):
                call_llm(
                    task="session_search",
                    messages=[{"role": "user", "content": "hi"}],
                )
        assert len(client.calls) == 1, "must not retry an unknown-param 400"
