from __future__ import annotations

import os
import json
import logging
import threading
from typing import Any, List, Protocol, cast

import httpx
import pytest
from respx import MockRouter

from anthropic import (
    Omit,
    Anthropic,
    AnthropicError,
    AsyncAnthropic,
    BetaFallbackState,
    BetaRefusalFallbackMiddleware,
    omit,
)
from anthropic.types.beta import BetaMessage, BetaFallbackParam, BetaOutputConfigParam
from anthropic.lib.middleware._fallbacks import _fallback_state
from anthropic.types.anthropic_beta_param import AnthropicBetaParam

base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
api_key = "my-anthropic-api-key"

LOGGER_NAME = "anthropic.lib.middleware"


def make_sync_client(**kwargs: Any) -> Anthropic:
    return Anthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=0, **kwargs)


def make_async_client(**kwargs: Any) -> AsyncAnthropic:
    return AsyncAnthropic(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=0, **kwargs)


def message(model: str, **overrides: Any) -> httpx.Response:
    return httpx.Response(
        200,
        json={
            "id": "msg_1",
            "type": "message",
            "role": "assistant",
            "model": model,
            "content": [],
            "stop_reason": "end_turn",
            "stop_sequence": None,
            "usage": {"input_tokens": 1, "output_tokens": 1},
            **overrides,
        },
    )


def refusal(model: str, fallback_credit_token: str | None = None) -> httpx.Response:
    return message(
        model,
        stop_reason="refusal",
        stop_details={
            "type": "refusal",
            "category": None,
            "explanation": None,
            "fallback_credit_token": fallback_credit_token,
        },
    )


class MockRequestCall(Protocol):
    request: httpx.Request


def create_message(
    client: Anthropic,
    *,
    betas: List[AnthropicBetaParam] | Omit = omit,
    fallbacks: List[BetaFallbackParam] | Omit = omit,
    output_config: BetaOutputConfigParam | Omit = omit,
) -> BetaMessage:
    return client.beta.messages.create(
        model="primary-model",
        max_tokens=1024,
        messages=[{"role": "user", "content": "hi"}],
        betas=betas,
        fallbacks=fallbacks,
        output_config=output_config,
    )


async def create_message_async(client: AsyncAnthropic) -> BetaMessage:
    return await client.beta.messages.create(
        model="primary-model",
        max_tokens=1024,
        messages=[{"role": "user", "content": "hi"}],
    )


def request_bodies(respx_mock: MockRouter) -> list[dict[str, Any]]:
    calls = cast("list[MockRequestCall]", respx_mock.calls)
    return [cast("dict[str, Any]", json.loads(call.request.content)) for call in calls]


def beta_headers(respx_mock: MockRouter) -> list[str | None]:
    calls = cast("list[MockRequestCall]", respx_mock.calls)
    return [call.request.headers.get("anthropic-beta") for call in calls]


class TestRefusalFallback:
    @pytest.mark.respx(base_url=base_url)
    def test_retries_a_refusal_with_the_fallback_params_and_credit_token(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        result = create_message(client)

        assert result.model == "fallback-model"
        assert result.stop_reason == "end_turn"
        # a `fallback` seam block is prepended at the model boundary — the same
        # block shape the streaming splice emits
        assert [block.to_dict() for block in result.content] == [
            {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}, "trigger": {"type": "refusal", "category": None}}
        ]
        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "fallback-model"]
        assert bodies[1]["fallback_credit_token"] == {"token": "credit-token", "mode": "best_effort"}

    @pytest.mark.respx(base_url=base_url)
    def test_pins_the_conversation_to_the_accepted_fallback_via_state(
        self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture
    ) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), message("fallback-model"), message("fallback-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        state = BetaFallbackState()
        with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
            with state:
                create_message(client)
            assert state.index == 0

            # the follow-up goes straight to the pinned fallback in a single request
            with state:
                create_message(client)

        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "fallback-model", "fallback-model"]
        assert not [record for record in caplog.records if record.name == LOGGER_NAME]

    @pytest.mark.respx(base_url=base_url, assert_all_called=False)
    def test_raises_if_state_index_is_out_of_bounds_for_the_chain(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("fallback-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        state = BetaFallbackState()
        state.index = 1
        with state:
            with pytest.raises(
                AnthropicError, match=r"BetaFallbackState\.index 1 is out of bounds for a chain of 1 fallback\(s\)"
            ):
                create_message(client)
        assert len(respx_mock.calls) == 0

    @pytest.mark.respx(base_url=base_url)
    def test_warns_once_when_falling_back_without_a_state(
        self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture
    ) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[
                refusal("primary-model"),
                message("fallback-model"),
                refusal("primary-model"),
                message("fallback-model"),
            ]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        with caplog.at_level(logging.WARNING, logger=LOGGER_NAME):
            create_message(client)
            create_message(client)

        warnings = [record for record in caplog.records if record.name == LOGGER_NAME]
        assert len(warnings) == 1
        assert "BetaFallbackState" in warnings[0].getMessage()

    @pytest.mark.respx(base_url=base_url)
    def test_a_separate_conversation_is_unaffected_by_another_state(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), message("fallback-model"), message("primary-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        with BetaFallbackState():
            create_message(client)
        with BetaFallbackState():
            create_message(client)

        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "fallback-model", "primary-model"]

    @pytest.mark.respx(base_url=base_url)
    def test_leaves_accepted_requests_and_the_response_untouched(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        state = BetaFallbackState()
        with state:
            result = create_message(client)

        assert result.model == "primary-model"
        bodies = request_bodies(respx_mock)
        assert len(bodies) == 1
        assert "fallback_credit_token" not in bodies[0]
        assert state.index is None

    @pytest.mark.respx(base_url=base_url)
    def test_walks_each_hop_through_the_chain_until_a_model_accepts(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[
                refusal("primary-model"),
                refusal("mid-model"),
                message("last-model", content=[{"type": "text", "text": "ok"}]),
            ]
        )
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "mid-model"}, {"model": "last-model"}])]
        )

        state = BetaFallbackState()
        with state:
            result = create_message(client)

        assert result.model == "last-model"
        assert state.index == 1
        # one seam per model boundary, in hop order, ahead of the served content
        assert [block.to_dict() for block in result.content] == [
            {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "mid-model"}, "trigger": {"type": "refusal", "category": None}},
            {"type": "fallback", "from": {"model": "mid-model"}, "to": {"model": "last-model"}, "trigger": {"type": "refusal", "category": None}},
            {"type": "text", "text": "ok"},
        ]
        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "mid-model", "last-model"]

    @pytest.mark.respx(base_url=base_url)
    def test_a_pinned_continuation_seams_from_the_pinned_model(self, respx_mock: MockRouter) -> None:
        # the pinned entry refuses and the chain advances — the first seam's
        # `from.model` must be the pinned entry (the model actually queried),
        # not the caller's original body model
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("mid-model"), message("last-model", content=[{"type": "text", "text": "ok"}])]
        )
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "mid-model"}, {"model": "last-model"}])]
        )

        state = BetaFallbackState()
        state.index = 0
        with state:
            result = create_message(client)

        assert result.model == "last-model"
        assert [block.to_dict() for block in result.content] == [
            {"type": "fallback", "from": {"model": "mid-model"}, "to": {"model": "last-model"}, "trigger": {"type": "refusal", "category": None}},
            {"type": "text", "text": "ok"},
        ]
        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["mid-model", "last-model"]

    @pytest.mark.respx(base_url=base_url)
    def test_returns_the_final_refusal_once_the_chain_is_exhausted(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), refusal("fallback-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        result = create_message(client)

        assert result.model == "fallback-model"
        assert result.stop_reason == "refusal"
        # terminal refusal is surfaced verbatim — no seam blocks prepended
        assert result.content == []
        assert len(respx_mock.calls) == 2

    @pytest.mark.respx(base_url=base_url)
    def test_entry_overrides_are_merged_whole_over_the_body(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model", "max_tokens": 32}])]
        )

        create_message(client)

        bodies = request_bodies(respx_mock)
        assert bodies[0]["max_tokens"] == 1024
        assert bodies[1]["max_tokens"] == 32

    @pytest.mark.respx(base_url=base_url)
    def test_an_explicit_none_entry_field_unsets_it_on_the_retry(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model", "max_tokens": None}])]
        )

        create_message(client)

        bodies = request_bodies(respx_mock)
        assert bodies[0]["max_tokens"] == 1024
        # explicit None unsets: absent from the retried request, not sent as null
        assert "max_tokens" not in bodies[1]

    @pytest.mark.respx(base_url=base_url)
    def test_each_hop_patches_the_original_params_not_the_previous_hop(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), refusal("mid-model"), message("last-model")]
        )
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware([{"model": "mid-model", "max_tokens": 32}, {"model": "last-model"}])
            ]
        )

        create_message(client)

        bodies = request_bodies(respx_mock)
        assert bodies[0]["max_tokens"] == 1024
        assert bodies[1]["max_tokens"] == 32
        # hop 2 patches the ORIGINAL request — hop 1's override does not leak,
        # and its absent field keeps the original value
        assert bodies[2]["max_tokens"] == 1024
        assert bodies[2]["model"] == "last-model"

    @pytest.mark.respx(base_url=base_url)
    def test_output_config_subfields_patch_one_level_deep(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware(
                    [{"model": "fallback-model", "output_config": {"effort": "high", "format": None}}]
                )
            ]
        )

        create_message(
            client,
            output_config={
                "effort": "low",
                "format": {"type": "json_schema", "schema": {"type": "object"}},
                "task_budget": {"type": "tokens", "total": 500},
            },
        )

        bodies = request_bodies(respx_mock)
        # `effort` set overrides, `format: None` unsets only `format`, the
        # absent `task_budget` keeps its original value
        assert bodies[1]["output_config"] == {"effort": "high", "task_budget": {"type": "tokens", "total": 500}}

    @pytest.mark.respx(base_url=base_url)
    def test_an_explicit_none_output_config_unsets_it_whole(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model", "output_config": None}])]
        )

        create_message(client, output_config={"effort": "low"})

        bodies = request_bodies(respx_mock)
        assert bodies[0]["output_config"] == {"effort": "low"}
        assert "output_config" not in bodies[1]

    @pytest.mark.respx(base_url=base_url)
    def test_an_absent_output_config_keeps_the_original(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        create_message(client, output_config={"effort": "low"})

        assert request_bodies(respx_mock)[1]["output_config"] == {"effort": "low"}

    @pytest.mark.respx(base_url=base_url)
    def test_output_config_subfields_seed_a_missing_original(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware(
                    [{"model": "fallback-model", "output_config": {"effort": "high", "format": None}}]
                )
            ]
        )

        create_message(client)

        bodies = request_bodies(respx_mock)
        assert "output_config" not in bodies[0]
        # the subfields the hop sets seed a new object; None entries are dropped
        assert bodies[1]["output_config"] == {"effort": "high"}

    @pytest.mark.respx(base_url=base_url)
    def test_unsetting_every_output_config_subfield_drops_the_key(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware(
                    [{"model": "fallback-model", "output_config": {"effort": None, "format": None}}]
                )
            ]
        )

        create_message(
            client,
            output_config={"effort": "low", "format": {"type": "json_schema", "schema": {"type": "object"}}},
        )

        bodies = request_bodies(respx_mock)
        assert bodies[0]["output_config"]
        # nothing left after the unsets — the key is dropped, never sent as `{}`
        assert "output_config" not in bodies[1]

    @pytest.mark.respx(base_url=base_url)
    def test_all_none_output_config_subfields_on_a_missing_original_add_nothing(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model"), message("fallback-model")])
        client = make_sync_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model", "output_config": {"effort": None}}])]
        )

        create_message(client)

        bodies = request_bodies(respx_mock)
        assert "output_config" not in bodies[0]
        # an empty result is never added as `{}`
        assert "output_config" not in bodies[1]

    @pytest.mark.respx(base_url=base_url)
    def test_output_config_subfields_do_not_leak_into_the_next_hop(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), refusal("mid-model"), message("last-model")]
        )
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware(
                    [{"model": "mid-model", "output_config": {"effort": "high"}}, {"model": "last-model"}]
                )
            ]
        )

        create_message(client, output_config={"effort": "low"})

        bodies = request_bodies(respx_mock)
        assert bodies[1]["output_config"] == {"effort": "high"}
        # hop 2 patches the ORIGINAL output_config — hop 1's subfield does not leak
        assert bodies[2]["output_config"] == {"effort": "low"}

    @pytest.mark.respx(base_url=base_url)
    def test_a_hop_http_error_surfaces_to_the_app(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[
                refusal("primary-model", "credit-token"),
                httpx.Response(
                    400, json={"type": "error", "error": {"type": "invalid_request_error", "message": "nope"}}
                ),
            ]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        from anthropic import BadRequestError

        with pytest.raises(BadRequestError):
            create_message(client)
        assert len(respx_mock.calls) == 2

    @pytest.mark.respx(base_url=base_url, assert_all_called=False)
    def test_server_side_fallbacks_raise_an_error(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model", "credit-token")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        with pytest.raises(
            AnthropicError,
            match=r"Sending the `fallbacks:` request param is not supported when using the `BetaRefusalFallbackMiddleware`\. You should either remove the middleware and send `fallbacks:` with the `server-side-fallback-2026-07-01` beta header to let the API handle refusal fallbacks, or omit the `fallbacks:` param if you'd like `BetaRefusalFallbackMiddleware` to handle fallbacks on the client side\.",
        ):
            create_message(client, fallbacks=[{"model": "server-fallback"}])
        # the error is raised before any request is sent
        assert len(respx_mock.calls) == 0

    @pytest.mark.respx(base_url=base_url)
    def test_an_empty_chain_disables_the_middleware(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[refusal("primary-model", "credit-token")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([])])

        result = create_message(client)

        assert result.stop_reason == "refusal"
        assert len(respx_mock.calls) == 1
        assert beta_headers(respx_mock) == [None]

    @pytest.mark.respx(base_url=base_url)
    def test_the_non_beta_messages_surface_passes_through(self, respx_mock: MockRouter) -> None:
        # only `client.beta.messages` requests are handled; the first-party
        # surface mints no credit tokens, so its refusals surface as-is
        respx_mock.post("/v1/messages").mock(
            side_effect=[
                message(
                    "primary-model",
                    stop_reason="refusal",
                    stop_details={"type": "refusal", "category": None, "explanation": None},
                )
            ]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        result = client.messages.create(
            model="primary-model",
            max_tokens=1024,
            messages=[{"role": "user", "content": "hi"}],
        )

        assert result.stop_reason == "refusal"
        assert len(respx_mock.calls) == 1
        assert beta_headers(respx_mock) == [None]


class TestBetaHeader:
    @pytest.mark.respx(base_url=base_url)
    def test_sends_the_fallback_credit_beta_on_the_original_and_fallback_requests(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        create_message(client)

        assert beta_headers(respx_mock) == ["fallback-credit-2026-07-01", "fallback-credit-2026-07-01"]

    @pytest.mark.respx(base_url=base_url)
    def test_the_betas_option_replaces_the_default(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(
            middleware=[
                BetaRefusalFallbackMiddleware(
                    [{"model": "fallback-model"}],
                    betas=["fallback-credit-2027-01-01", "interleaved-thinking-2025-05-14"],
                )
            ]
        )

        create_message(client)

        assert beta_headers(respx_mock) == ["fallback-credit-2027-01-01, interleaved-thinking-2025-05-14"]

    @pytest.mark.respx(base_url=base_url)
    def test_empty_betas_sends_no_beta_header(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}], betas=[])])

        create_message(client)

        assert beta_headers(respx_mock) == [None]

    @pytest.mark.respx(base_url=base_url)
    def test_does_not_duplicate_a_beta_already_on_the_request(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        create_message(client, betas=["fallback-credit-2026-07-01"])

        assert beta_headers(respx_mock) == ["fallback-credit-2026-07-01"]

    @pytest.mark.respx(base_url=base_url)
    def test_appends_to_betas_already_on_the_request(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        create_message(client, betas=["interleaved-thinking-2025-05-14"])

        assert beta_headers(respx_mock) == ["interleaved-thinking-2025-05-14, fallback-credit-2026-07-01"]


def helper_headers(respx_mock: MockRouter) -> list[list[str]]:
    calls = cast("list[MockRequestCall]", respx_mock.calls)
    return [call.request.headers.get_list("x-stainless-helper") for call in calls]


class TestHelperTelemetry:
    @pytest.mark.respx(base_url=base_url)
    def test_tags_the_original_and_fallback_requests(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])
        create_message(client)
        assert helper_headers(respx_mock) == [
            ["fallback-refusal-middleware"],
            ["fallback-refusal-middleware"],
        ]

    @pytest.mark.respx(base_url=base_url)
    def test_appends_to_a_helper_tag_already_on_the_request(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])
        client.beta.messages.create(
            model="primary-model",
            max_tokens=1024,
            messages=[{"role": "user", "content": "hi"}],
            extra_headers={"X-Stainless-Helper": "BetaToolRunner"},
        )
        assert helper_headers(respx_mock) == [["BetaToolRunner, fallback-refusal-middleware"]]

    @pytest.mark.respx(base_url=base_url)
    def test_does_not_tag_requests_it_passes_through(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(side_effect=[message("primary-model")])
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])
        # the GA surface is not applicable to this middleware
        client.messages.create(
            model="primary-model",
            max_tokens=1024,
            messages=[{"role": "user", "content": "hi"}],
        )
        assert helper_headers(respx_mock) == [[]]

    @pytest.mark.respx(base_url=base_url)
    async def test_async_tags_the_original_and_fallback_requests(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_async_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])
        await create_message_async(client)
        assert helper_headers(respx_mock) == [
            ["fallback-refusal-middleware"],
            ["fallback-refusal-middleware"],
        ]


class TestAsyncRefusalFallback:
    @pytest.mark.respx(base_url=base_url)
    async def test_retries_a_refusal_with_the_fallback_params_and_credit_token(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_async_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        result = await create_message_async(client)

        assert result.model == "fallback-model"
        assert [block.to_dict() for block in result.content] == [
            {"type": "fallback", "from": {"model": "primary-model"}, "to": {"model": "fallback-model"}, "trigger": {"type": "refusal", "category": None}}
        ]
        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "fallback-model"]
        assert bodies[1]["fallback_credit_token"] == {"token": "credit-token", "mode": "best_effort"}

    @pytest.mark.respx(base_url=base_url)
    async def test_pins_the_conversation_to_the_accepted_fallback_via_state(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), message("fallback-model"), message("fallback-model")]
        )
        client = make_async_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        state = BetaFallbackState()
        with state:
            await create_message_async(client)
        assert state.index == 0

        with state:
            await create_message_async(client)

        bodies = request_bodies(respx_mock)
        assert [body["model"] for body in bodies] == ["primary-model", "fallback-model", "fallback-model"]

    @pytest.mark.respx(base_url=base_url)
    async def test_walks_each_hop_through_the_chain_until_a_model_accepts(self, respx_mock: MockRouter) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model"), refusal("mid-model"), message("last-model")]
        )
        client = make_async_client(
            middleware=[BetaRefusalFallbackMiddleware([{"model": "mid-model"}, {"model": "last-model"}])]
        )

        state = BetaFallbackState()
        with state:
            result = await create_message_async(client)

        assert result.model == "last-model"
        assert state.index == 1


class TestBetaFallbackState:
    def test_reentering_the_same_state_nests(self) -> None:
        state = BetaFallbackState()
        with state:
            with state:
                assert _fallback_state.get() is state
            assert _fallback_state.get() is state
        assert _fallback_state.get() is None

    def test_nesting_different_states_restores_the_outer_pin(self) -> None:
        outer, inner = BetaFallbackState(), BetaFallbackState()
        with outer:
            with inner:
                assert _fallback_state.get() is inner
            assert _fallback_state.get() is outer
        assert _fallback_state.get() is None

    def test_one_state_shared_across_threads(self) -> None:
        # Each thread runs in its own context, so a state shared between them —
        # the documented usage — must enter and exit with that context's own
        # tokens; an instance-level token stack would interleave them and
        # `ContextVar.reset` raises on a foreign context's token.
        state = BetaFallbackState()
        a_entered, b_entered, a_exited = threading.Event(), threading.Event(), threading.Event()
        errors: list[Exception] = []

        def thread_a() -> None:
            try:
                with state:
                    a_entered.set()
                    assert b_entered.wait(timeout=5)
            except Exception as err:
                errors.append(err)
            finally:
                a_entered.set()
                a_exited.set()

        def thread_b() -> None:
            try:
                assert a_entered.wait(timeout=5)
                with state:
                    b_entered.set()
                    assert a_exited.wait(timeout=5)
                assert _fallback_state.get() is None
            except Exception as err:
                errors.append(err)
            finally:
                b_entered.set()

        threads = [threading.Thread(target=thread_a), threading.Thread(target=thread_b)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join(timeout=10)

        assert errors == []


class TestErrorLogging:
    @pytest.mark.respx(base_url=base_url)
    def test_nothing_is_logged_for_non_streaming_retries(
        self, respx_mock: MockRouter, caplog: pytest.LogCaptureFixture
    ) -> None:
        respx_mock.post("/v1/messages").mock(
            side_effect=[refusal("primary-model", "credit-token"), message("fallback-model")]
        )
        client = make_sync_client(middleware=[BetaRefusalFallbackMiddleware([{"model": "fallback-model"}])])

        create_message(client)

        assert not [
            record for record in caplog.records if record.name == LOGGER_NAME and record.levelno >= logging.ERROR
        ]
