"""Agent 最终模型请求预算测试。"""

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

from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, SummarizationMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool

from app.agent import MoviePilotAgent
from app.agent.middleware.tool_selection import ToolSelectorMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.chain.message import MessageChain


class _ToolBindingFakeModel(FakeMessagesListChatModel):
    """记录最终绑定工具，同时保留固定响应行为。"""

    bound_tool_names: list[str] = []

    def bind_tools(self, tools, **kwargs):
        """记录 LangChain 最终交给模型的工具集合。"""
        self.bound_tool_names = [
            item.get("function", {}).get("name") or item.get("name")
            if isinstance(item, dict)
            else item.name
            for item in tools
        ]
        return self


class _DynamicSystemMiddleware(AgentMiddleware):
    """模拟 MoviePilot 运行时动态追加系统上下文。"""

    async def awrap_model_call(self, request, handler):
        current = request.system_message.content if request.system_message else ""
        return await handler(
            request.override(
                system_message=SystemMessage(
                    content=f"{current}\n{'动态系统上下文 ' * 200}"
                )
            )
        )


def _request(
        *,
        messages=None,
        system_message=None,
        tools=None,
        max_input_tokens=4096,
        max_output_tokens=512,
        model_settings=None,
) -> ModelRequest:
    """构造带模型窗口和最终请求组成的测试请求。"""
    return ModelRequest(
        model=SimpleNamespace(
            model="small-model",
            profile={
                "max_input_tokens": max_input_tokens,
                "max_output_tokens": max_output_tokens,
            },
        ),
        messages=list(messages or []),
        system_message=system_message,
        tools=list(tools or []),
        state={},
        runtime=None,
        model_settings=model_settings,
    )


def test_final_request_can_exceed_window_before_message_fraction_triggers():
    """动态系统提示词和工具定义可能在消息摘要阈值前耗尽输入窗口。"""
    messages = [HumanMessage(content="用户上下文 " * 1000)]
    model = SimpleNamespace(
        _llm_type="test-chat",
        profile={"max_input_tokens": 4096},
    )
    model.with_retry = lambda: model
    summarizer = SummarizationMiddleware(
        model=model,
        trigger=("fraction", 0.85),
    )
    message_tokens = summarizer.token_counter(messages)
    assert message_tokens < 4096 * 0.85
    assert not summarizer._should_summarize(messages, message_tokens)

    request = _request(
        messages=messages,
        system_message=SystemMessage(content="动态系统上下文 " * 1000),
        tools=[
            {
                "type": "function",
                "function": {
                    "name": "large_schema_tool",
                    "description": "工具业务说明 " * 800,
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "value": {
                                "type": "string",
                                "description": "参数约束 " * 400,
                            }
                        },
                    },
                },
            }
        ],
    )

    snapshot = UsageMiddleware.estimate_request(request)

    assert snapshot["has_estimate"]
    assert snapshot["message_tokens"] < 4096 * 0.85
    assert snapshot["system_tokens"] > 0
    assert snapshot["tool_tokens"] > 0
    assert snapshot["estimated_input_tokens"] > 4096
    assert snapshot["estimated_over_input_limit"] is True
    assert snapshot["model_max_output_tokens"] == 512
    assert snapshot["configured_output_limit_tokens"] is None
    assert "output_headroom_tokens" not in snapshot


def test_request_budget_marks_same_request_over_limit_after_switch_to_small_model():
    """相同请求切换到小窗口模型后，应仅改变预算判断而不改变估算输入。"""
    messages = [HumanMessage(content="x" * 12000)]

    large_snapshot = UsageMiddleware.estimate_request(
        _request(messages=messages, max_input_tokens=128000)
    )
    small_snapshot = UsageMiddleware.estimate_request(
        _request(messages=messages, max_input_tokens=2048)
    )

    assert large_snapshot["estimated_input_tokens"] == small_snapshot["estimated_input_tokens"]
    assert large_snapshot["estimated_over_input_limit"] is False
    assert small_snapshot["estimated_over_input_limit"] is True


def test_request_budget_reads_only_explicit_per_call_output_limit():
    """只有最终请求显式配置的输出上限才可视为单次调用限制。"""
    request = _request(
        messages=[HumanMessage(content="hello")],
        max_output_tokens=8192,
        model_settings={"max_completion_tokens": 1024},
    )

    snapshot = UsageMiddleware.estimate_request(request)

    assert snapshot["model_max_output_tokens"] == 8192
    assert snapshot["configured_output_limit_tokens"] == 1024
    assert snapshot["estimated_input_tokens"] < snapshot["context_window_tokens"]


def test_request_budget_rejects_non_integer_token_limits():
    """近似观察不得把 bool、浮点或字符串误报为有效 token 上限。"""
    for invalid_value in (True, False, 1.5, 0, -1, "1024"):
        request = _request(
            messages=[HumanMessage(content="hello")],
            max_input_tokens=invalid_value,
            max_output_tokens=invalid_value,
            model_settings={"max_completion_tokens": invalid_value},
        )

        snapshot = UsageMiddleware.estimate_request(request)

        assert snapshot["context_window_tokens"] is None
        assert snapshot["model_max_output_tokens"] is None
        assert snapshot["configured_output_limit_tokens"] is None
        assert snapshot["estimated_input_ratio"] is None
        assert snapshot["estimated_over_input_limit"] is None


def test_request_budget_uses_next_valid_output_limit_alias():
    """高优先字段无效时，应继续读取同一请求中的有效兼容字段。"""
    request = _request(
        messages=[HumanMessage(content="hello")],
        model_settings={"max_completion_tokens": True, "max_tokens": 1024},
    )

    snapshot = UsageMiddleware.estimate_request(request)

    assert snapshot["configured_output_limit_tokens"] == 1024


def test_request_budget_counts_multimodal_input_without_storing_content():
    """图片按固定成本计入估算，快照不得保留请求正文或工具定义。"""
    secret_marker = "REQUEST_BUDGET_SECRET_MARKER"
    request = _request(
        messages=[
            HumanMessage(
                content=[
                    {"type": "text", "text": secret_marker},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,hidden"},
                    },
                    {"type": "file", "file_id": secret_marker},
                ]
            )
        ],
        system_message=SystemMessage(content=secret_marker),
        tools=[
            {
                "type": "function",
                "function": {
                    "name": "secret_tool",
                    "description": secret_marker,
                    "parameters": {"type": "object", "properties": {}},
                },
            }
        ],
    )

    snapshot = UsageMiddleware.estimate_request(request)

    assert snapshot["image_count"] == 1
    assert snapshot["unknown_multimodal_count"] == 1
    assert snapshot["multimodal_tokens"] == 85
    assert snapshot["estimated_input_tokens"] == (
        snapshot["message_tokens"]
        + snapshot["system_tokens"]
        + snapshot["tool_tokens"]
    )
    assert snapshot["model"] == "small-model"
    assert secret_marker not in repr(snapshot)
    assert all(
        value is None or isinstance(value, (bool, int, float))
        for key, value in snapshot.items()
        if key != "model"
    )


def test_request_budget_counts_each_image_cost_exactly_once():
    """LangChain 的消息估算已包含图片固定成本，汇总预算不得重复相加。"""
    text_only = UsageMiddleware.estimate_request(
        _request(
            messages=[HumanMessage(content=[{"type": "text", "text": "hello"}])]
        )
    )
    with_image = UsageMiddleware.estimate_request(
        _request(
            messages=[
                HumanMessage(
                    content=[
                        {"type": "text", "text": "hello"},
                        {
                            "type": "image_url",
                            "image_url": {"url": "data:image/png;base64,hidden"},
                        },
                    ]
                )
            ]
        )
    )

    assert with_image["multimodal_tokens"] == 85
    assert (
        with_image["estimated_input_tokens"] - text_only["estimated_input_tokens"]
        == 85
    )


def test_request_budget_callback_failure_does_not_block_model_call():
    """预算观察失败不得改变模型请求和响应。"""
    request = _request(messages=[HumanMessage(content="hello")])
    response = ModelResponse(result=[AIMessage(content="ok")])

    def _raise_callback(_snapshot):
        raise RuntimeError("observer unavailable")

    middleware = UsageMiddleware(on_request_budget=_raise_callback)
    handled = []

    async def _handler(received: ModelRequest):
        handled.append(received)
        return response

    result = asyncio.run(middleware.awrap_model_call(request, _handler))

    assert result is response
    assert handled == [request]


def test_request_budget_callback_failure_clears_previous_estimate_state():
    """预算回调失败时，本轮 usage 不得与上一轮估算拼接。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    callback_count = 0

    def _record_then_fail(snapshot):
        nonlocal callback_count
        callback_count += 1
        if callback_count == 2:
            raise RuntimeError("observer unavailable")
        agent._record_request_budget(snapshot)

    middleware = UsageMiddleware(
        on_request_budget=_record_then_fail,
        on_usage=agent._record_usage,
    )
    responses = iter(
        [
            ModelResponse(
                result=[
                    AIMessage(
                        content="first",
                        usage_metadata={
                            "input_tokens": 10,
                            "output_tokens": 1,
                            "total_tokens": 11,
                        },
                    )
                ]
            ),
            ModelResponse(
                result=[
                    AIMessage(
                        content="second",
                        usage_metadata={
                            "input_tokens": 50,
                            "output_tokens": 2,
                            "total_tokens": 52,
                        },
                    )
                ]
            ),
        ]
    )

    async def _handler(_request):
        return next(responses)

    request = _request(messages=[HumanMessage(content="hello")])
    asyncio.run(middleware.awrap_model_call(request, _handler))
    assert agent.get_session_status()["last_request_estimate_available"] is True

    asyncio.run(middleware.awrap_model_call(request, _handler))
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["last_request_estimate_available"] is False
    assert status["last_estimated_input_tokens"] is None
    assert status["last_actual_input_tokens"] is None
    assert status["last_estimate_error_tokens"] is None
    assert status["model"] == "small-model"
    assert status["context_window_tokens"] == 4096
    assert status["last_input_tokens"] == 50


def test_request_budget_estimator_failure_reports_empty_snapshot_and_calls_model():
    """估算器异常应清除旧观测状态，并继续原模型调用。"""
    budgets = []
    middleware = UsageMiddleware(on_request_budget=budgets.append)
    request = _request(messages=[HumanMessage(content="hello")])
    response = ModelResponse(result=[AIMessage(content="ok")])

    async def _handler(_request):
        return response

    with patch.object(
        UsageMiddleware,
        "estimate_request",
        side_effect=RuntimeError("request content must not reach the snapshot"),
    ):
        result = asyncio.run(middleware.awrap_model_call(request, _handler))

    assert result is response
    assert budgets == [
        {
            "request_sequence": 1,
            "has_estimate": False,
            "model": "small-model",
            "context_window_tokens": 4096,
        }
    ]


def test_request_budget_metadata_failure_still_calls_model():
    """模型元数据属性异常不得让预算观察器阻断真实模型调用。"""

    class _BrokenMetadataModel:
        @property
        def model(self):
            raise RuntimeError("model metadata unavailable")

        @property
        def model_name(self):
            raise RuntimeError("model metadata unavailable")

        @property
        def model_id(self):
            raise RuntimeError("model metadata unavailable")

        @property
        def profile(self):
            raise RuntimeError("profile metadata unavailable")

    budgets = []
    middleware = UsageMiddleware(on_request_budget=budgets.append)
    request = ModelRequest(
        model=_BrokenMetadataModel(),
        messages=[HumanMessage(content="hello")],
        tools=[],
        state={},
        runtime=None,
    )
    response = ModelResponse(result=[AIMessage(content="ok")])
    handled = []

    async def _handler(received):
        handled.append(received)
        return response

    with patch.object(
        UsageMiddleware,
        "estimate_request",
        side_effect=RuntimeError("estimate unavailable"),
    ):
        result = asyncio.run(middleware.awrap_model_call(request, _handler))

    assert result is response
    assert handled == [request]
    assert budgets == [
        {
            "request_sequence": 1,
            "has_estimate": False,
            "model": None,
            "context_window_tokens": None,
        }
    ]


def test_request_sequence_callback_failure_still_calls_model():
    """会话序号分配异常时应放弃最近快照竞争，并继续真实模型调用。"""
    budgets = []

    def _raise_sequence():
        raise RuntimeError("sequence unavailable")

    middleware = UsageMiddleware(
        on_request_budget=budgets.append,
        next_request_sequence=_raise_sequence,
    )
    request = _request(messages=[HumanMessage(content="hello")])
    response = ModelResponse(result=[AIMessage(content="ok")])
    handled = []

    async def _handler(received):
        handled.append(received)
        return response

    result = asyncio.run(middleware.awrap_model_call(request, _handler))

    assert result is response
    assert handled == [request]
    assert budgets[0]["request_sequence"] is None


def test_request_budget_and_actual_usage_share_one_request_sequence():
    """真实 usage 只能校准同一次成功模型调用产生的估算。"""
    budgets = []
    usages = []
    middleware = UsageMiddleware(
        on_request_budget=budgets.append,
        on_usage=usages.append,
    )
    request = _request(messages=[HumanMessage(content="hello")])
    response = ModelResponse(
        result=[
            AIMessage(
                content="ok",
                usage_metadata={
                    "input_tokens": 23,
                    "output_tokens": 4,
                    "total_tokens": 27,
                },
            )
        ]
    )

    async def _handler(_request):
        return response

    asyncio.run(middleware.awrap_model_call(request, _handler))

    assert budgets[0]["request_sequence"] == 1
    assert usages[0]["request_sequence"] == 1
    assert usages[0]["request_budget_recorded"] is True
    assert usages[0]["input_usage_available"] is True
    assert usages[0]["estimated_input_tokens"] == budgets[0]["estimated_input_tokens"]


def test_partial_usage_without_input_does_not_calibrate_request_estimate():
    """仅有输出 usage 时，不得把缺失的真实输入误报为零。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    middleware = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
    )
    request = _request(messages=[HumanMessage(content="hello")])
    response = ModelResponse(
        result=[
            AIMessage(
                content="ok",
                response_metadata={
                    "token_usage": {
                        "completion_tokens": 7,
                        "total_tokens": 7,
                    }
                },
            )
        ]
    )

    async def _handler(_request):
        return response

    asyncio.run(middleware.awrap_model_call(request, _handler))
    status = agent.get_session_status()

    assert status["last_request_estimate_available"] is True
    assert status["last_input_usage_available"] is False
    assert status["last_input_tokens"] is None
    assert status["last_output_tokens"] == 7
    assert status["last_actual_input_tokens"] is None
    assert status["last_estimate_error_tokens"] is None
    assert status["last_estimate_error_ratio"] is None
    assert status["last_context_usage_ratio"] is None
    status_text = MessageChain._format_session_status_text(status)
    assert "最近一次上下文占用: 未知 / 4,096" in status_text
    assert "最近一次 tokens: 输入 未知 / 输出 7 / 总计 7" in status_text


def test_missing_usage_clears_previous_last_call_values():
    """本轮没有 usage 时，最近一次状态不得保留上一轮实际值。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    agent._record_request_budget(
        {
            "request_sequence": 1,
            "has_estimate": True,
            "estimated_input_tokens": 10,
            "context_window_tokens": 1000,
        }
    )
    agent._record_usage(
        {
            "request_sequence": 1,
            "request_budget_recorded": True,
            "has_usage": True,
            "input_usage_available": True,
            "input_tokens": 12,
            "output_tokens": 3,
            "total_tokens": 15,
            "cache_usage_available": True,
            "cache_read_input_tokens": 4,
            "cache_write_input_tokens": 0,
            "uncached_input_tokens": 8,
        }
    )
    agent._record_request_budget(
        {
            "request_sequence": 2,
            "has_estimate": True,
            "estimated_input_tokens": 20,
            "context_window_tokens": 1000,
        }
    )
    agent._record_usage(
        {
            "request_sequence": 2,
            "request_budget_recorded": True,
            "has_usage": False,
            "input_usage_available": False,
        }
    )

    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["last_request_estimate_available"] is True
    assert status["last_input_usage_available"] is False
    assert status["last_input_tokens"] is None
    assert status["last_output_tokens"] is None
    assert status["last_total_tokens"] is None
    assert status["last_context_usage_ratio"] is None
    assert status["last_cache_usage_available"] is False
    assert status["total_input_tokens"] == 12
    assert status["total_output_tokens"] == 3
    assert status["total_tokens"] == 15
    status_text = MessageChain._format_session_status_text(status)
    assert "最近一次 tokens: 输入 未知 / 输出 未知 / 总计 未知" in status_text
    assert "最近一次缓存:" not in status_text
    assert "当前会话累计缓存: 命中 4 / 写入 0 / 未命中 8" in status_text


def test_out_of_order_responses_keep_latest_request_snapshot():
    """较早请求晚返回时，只累计 usage，不得覆盖较新请求的最近状态。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    middleware = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
    )
    first_request = _request(
        messages=[HumanMessage(content="first")],
        max_input_tokens=1000,
    )
    first_request.model.model = "first-model"
    second_request = _request(
        messages=[HumanMessage(content="second")],
        max_input_tokens=2000,
    )
    second_request.model.model = "second-model"
    first_started = asyncio.Event()
    release_first = asyncio.Event()

    async def _handler(request):
        if request.model.model == "first-model":
            first_started.set()
            await release_first.wait()
            return ModelResponse(
                result=[
                    AIMessage(
                        content="first",
                        usage_metadata={
                            "input_tokens": 10,
                            "output_tokens": 1,
                            "total_tokens": 11,
                        },
                    )
                ]
            )
        return ModelResponse(
            result=[
                AIMessage(
                    content="second",
                    usage_metadata={
                        "input_tokens": 50,
                        "output_tokens": 2,
                        "total_tokens": 52,
                    },
                )
            ]
        )

    async def _run_out_of_order():
        first_task = asyncio.create_task(
            middleware.awrap_model_call(first_request, _handler)
        )
        await first_started.wait()
        await middleware.awrap_model_call(second_request, _handler)
        release_first.set()
        await first_task

    asyncio.run(_run_out_of_order())
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["model"] == "second-model"
    assert status["context_window_tokens"] == 2000
    assert status["last_input_tokens"] == 50
    assert status["last_output_tokens"] == 2
    assert status["last_total_tokens"] == 52
    assert status["last_actual_input_tokens"] == 50
    assert status["total_input_tokens"] == 60
    assert status["total_output_tokens"] == 3
    assert status["total_tokens"] == 63
    assert status["model_call_count"] == 2


def test_sequence_failure_cannot_overwrite_next_request_snapshot():
    """无序号请求晚返回时只能累计 usage，不能覆盖后续有序请求。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    allocation_count = 0

    def _next_sequence():
        nonlocal allocation_count
        allocation_count += 1
        if allocation_count == 1:
            raise RuntimeError("sequence unavailable")
        return agent._next_request_sequence()

    middleware = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
        next_request_sequence=_next_sequence,
    )
    first_request = _request(
        messages=[HumanMessage(content="first")],
        max_input_tokens=1000,
    )
    first_request.model.model = "first-model"
    second_request = _request(
        messages=[HumanMessage(content="second")],
        max_input_tokens=2000,
    )
    second_request.model.model = "second-model"
    first_started = asyncio.Event()
    release_first = asyncio.Event()

    async def _handler(request):
        if request.model.model == "first-model":
            first_started.set()
            await release_first.wait()
            input_tokens = 10
        else:
            input_tokens = 50
        return ModelResponse(
            result=[
                AIMessage(
                    content=request.model.model,
                    usage_metadata={
                        "input_tokens": input_tokens,
                        "output_tokens": 1,
                        "total_tokens": input_tokens + 1,
                    },
                )
            ]
        )

    async def _run_out_of_order():
        first_task = asyncio.create_task(
            middleware.awrap_model_call(first_request, _handler)
        )
        await first_started.wait()
        await middleware.awrap_model_call(second_request, _handler)
        release_first.set()
        await first_task

    asyncio.run(_run_out_of_order())
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 1
    assert status["model"] == "second-model"
    assert status["context_window_tokens"] == 2000
    assert status["last_input_tokens"] == 50
    assert status["last_actual_input_tokens"] == 50
    assert status["total_input_tokens"] == 60
    assert status["model_call_count"] == 2


def test_failed_model_switch_keeps_model_and_window_from_same_request():
    """新模型请求即使失败，状态也不得混合旧模型名称与新窗口。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    agent._sync_model_profile(
        SimpleNamespace(model="large-model", profile={"max_input_tokens": 128000})
    )
    middleware = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
    )
    request = _request(
        messages=[HumanMessage(content="small")],
        max_input_tokens=2048,
    )
    request.model.model = "small-model"

    async def _failing_handler(_request):
        raise RuntimeError("model unavailable")

    async def _run_failure():
        try:
            await middleware.awrap_model_call(request, _failing_handler)
        except RuntimeError:
            pass

    asyncio.run(_run_failure())
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 1
    assert status["model"] == "small-model"
    assert status["context_window_tokens"] == 2048


def test_unknown_model_name_is_not_replaced_after_request_snapshot():
    """请求已开始后，未知模型名称不得与配置默认值拼成虚假快照。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    agent._record_request_budget(
        {
            "has_estimate": True,
            "request_sequence": 1,
            "model": None,
            "estimated_input_tokens": 100,
            "context_window_tokens": 2048,
        }
    )

    status = agent.get_session_status()

    assert status["model"] is None
    assert status["context_window_tokens"] == 2048


def test_request_sequence_remains_monotonic_after_agent_graph_rebuild():
    """重建 Agent 图后，新观察器也必须延续当前会话的请求顺序。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    first_graph = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
        next_request_sequence=agent._next_request_sequence,
    )
    rebuilt_graph = UsageMiddleware(
        on_request_budget=agent._record_request_budget,
        on_usage=agent._record_usage,
        next_request_sequence=agent._next_request_sequence,
    )

    async def _handler(request):
        return ModelResponse(
            result=[
                AIMessage(
                    content=request.model.model,
                    usage_metadata={
                        "input_tokens": request.model.profile["max_input_tokens"] // 10,
                        "output_tokens": 1,
                        "total_tokens": request.model.profile["max_input_tokens"] // 10 + 1,
                    },
                )
            ]
        )

    first = _request(
        messages=[HumanMessage(content="first")],
        max_input_tokens=1000,
    )
    first.model.model = "first-model"
    second = _request(
        messages=[HumanMessage(content="second")],
        max_input_tokens=2000,
    )
    second.model.model = "second-model"

    asyncio.run(first_graph.awrap_model_call(first, _handler))
    asyncio.run(rebuilt_graph.awrap_model_call(second, _handler))
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["model"] == "second-model"
    assert status["context_window_tokens"] == 2000
    assert status["last_input_tokens"] == 200


def test_new_request_estimate_clears_stale_calibration_until_success():
    """新请求失败时不得沿用上一轮估算误差。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    first = {
        "has_estimate": True,
        "request_sequence": 1,
        "estimated_input_tokens": 100,
        "context_window_tokens": 1000,
    }
    second = {
        "has_estimate": True,
        "request_sequence": 2,
        "estimated_input_tokens": 200,
        "context_window_tokens": 1000,
    }

    agent._record_request_budget(first)
    agent._record_usage(
        {
            "request_sequence": 1,
            "request_budget_recorded": True,
            "input_usage_available": True,
            "estimated_input_tokens": 100,
            "has_usage": True,
            "input_tokens": 120,
            "output_tokens": 1,
            "total_tokens": 121,
        }
    )
    assert agent.get_session_status()["last_estimate_error_tokens"] == 20

    agent._record_request_budget(second)
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["last_actual_input_tokens"] is None
    assert status["last_estimate_error_tokens"] is None
    assert status["last_estimate_error_ratio"] is None


def test_failed_estimate_clears_previous_request_components():
    """估算失败后状态不得继续展示上一轮的请求组成。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    agent._record_request_budget(
        {
            "has_estimate": True,
            "request_sequence": 1,
            "estimated_input_tokens": 100,
            "message_tokens": 70,
            "system_tokens": 20,
            "tool_tokens": 10,
            "message_count": 3,
            "tool_count": 2,
        }
    )

    agent._record_request_budget(
        {"has_estimate": False, "request_sequence": 2}
    )
    status = agent.get_session_status()

    assert status["last_request_sequence"] == 2
    assert status["last_request_estimate_available"] is False
    assert status["last_estimated_input_tokens"] is None
    assert status["last_estimated_message_tokens"] is None
    assert status["last_estimated_system_tokens"] is None
    assert status["last_estimated_tool_tokens"] is None
    assert status["last_message_count"] == 0
    assert status["last_tool_count"] == 0


def test_new_request_with_unknown_window_clears_previous_model_window():
    """切换到窗口未知的模型后，不得继续展示上一模型的窗口。"""
    agent = MoviePilotAgent(session_id="request-budget", user_id="user-1")
    agent._sync_model_profile(
        SimpleNamespace(model="large-model", profile={"max_input_tokens": 128000})
    )
    agent._record_request_budget(
        {
            "has_estimate": True,
            "request_sequence": 1,
            "estimated_input_tokens": 100,
            "context_window_tokens": 128000,
        }
    )

    agent._sync_model_profile(
        SimpleNamespace(model="unknown-window-model", profile={"max_input_tokens": 0})
    )
    agent._record_request_budget(
        {
            "has_estimate": True,
            "request_sequence": 2,
            "estimated_input_tokens": 100,
            "context_window_tokens": None,
        }
    )
    status = agent.get_session_status()

    assert status["model"] == "unknown-window-model"
    assert status["context_window_tokens"] is None
    assert status["last_estimated_input_ratio"] is None
    assert status["last_estimated_over_input_limit"] is None


def test_real_agent_observer_sees_dynamic_system_and_selected_tools():
    """末尾观察器必须看到动态 system 和 ToolSelector 最终保留的工具。"""

    @tool
    def kept_tool(value: str) -> str:
        """应保留的测试工具。"""
        return value

    @tool
    def removed_tool(value: str) -> str:
        """应被筛除的测试工具。"""
        return value

    selection_model = _ToolBindingFakeModel(
        responses=[AIMessage(content='{"tools": []}')]
    )
    main_model = _ToolBindingFakeModel(responses=[AIMessage(content="done")])
    budgets = []
    graph = create_agent(
        model=main_model,
        tools=[kept_tool, removed_tool],
        system_prompt="base",
        middleware=[
            _DynamicSystemMiddleware(),
            ToolSelectorMiddleware(
                model=selection_model,
                selection_tools=[kept_tool, removed_tool],
                max_tools=1,
                always_include=["kept_tool"],
            ),
            UsageMiddleware(on_request_budget=budgets.append),
        ],
    )

    asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="hello")]}))

    assert len(budgets) == 1
    assert budgets[0]["tool_count"] == 1
    assert budgets[0]["system_tokens"] > 100
    assert main_model.bound_tool_names == ["kept_tool"]
