"""A turn must never run against a session record whose agent is ``None``.

The deferred agent build can finish WITHOUT attaching an agent: when the record is replaced or
closed while the build runs, ``_build`` leaves early while its ``finally`` still sets
``agent_ready``.  Such a prompt used to reach the turn body, which dereferenced
``session["agent"]`` twice (``_invoke_agent`` and the turn's ``finally``) — the turn thread died
with ``running`` still True, the prompt vanished and the session stayed "busy" (#111531).
"""

from __future__ import annotations

import threading
import types

from tui_gateway import server
from tui_gateway.user_messages import AGENT_BUILD_ABANDONED


class _InlineThread:
    """Run the turn synchronously so tests observe its final state."""

    def __init__(self, target=None, daemon=None, args=(), kwargs=None, name=None):
        self._target, self._args, self._kwargs = target, args, kwargs or {}

    def start(self):
        if self._target is not None:
            self._target(*self._args, **self._kwargs)

    def is_alive(self):
        return False

    def join(self, timeout=None):
        return None


def _session(agent, **extra):
    return {
        "agent": agent, "agent_error": None, "session_key": "gw-session-key", "history": [],
        "history_lock": threading.RLock(), "history_version": 0, "running": True, "attached_images": [],
        "image_counter": 0, "cols": 80, "slash_worker": None, "show_reasoning": False,
        "tool_progress_mode": "all", "inflight_turn": None, **extra}


def _turn_env(monkeypatch, tmp_path) -> list:
    emitted: list[tuple] = []
    monkeypatch.setattr(server.threading, "Thread", _InlineThread)
    monkeypatch.setattr(server, "_emit", lambda event_type, sid, payload=None: emitted.append((event_type, sid, payload)))
    monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
    monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda sid, session: None)
    monkeypatch.setattr(server, "_session_cwd", lambda session: str(tmp_path))
    monkeypatch.setattr(server, "_register_session_cwd", lambda session: None)
    monkeypatch.setattr(server, "_tts_stream_begin", lambda: None)
    monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **k: None)
    monkeypatch.setattr(server, "_get_usage", lambda agent: {})
    return emitted


def test_turn_without_agent_is_refused_with_retryable_frame(monkeypatch, tmp_path):
    """The recorded build reason reaches the client as a retryable runtime frame; ``running`` is released."""
    emitted = _turn_env(monkeypatch, tmp_path)
    session = _session(None, agent_error=AGENT_BUILD_ABANDONED)

    assert server._run_prompt_submit("rid", "ui-sid", session, "继续") is False

    frames = [p for (t, _sid, p) in emitted if t == "message.complete"]
    assert len(frames) == 1
    assert frames[0]["status"] == "error" and frames[0]["recoverable"] is True
    assert frames[0]["error"] == AGENT_BUILD_ABANDONED
    assert frames[0]["error_surface"] == {"layer": "runtime", "code": "agent_init_failed", "retryable": True}
    assert session["running"] is False
    assert session["inflight_turn"]["status"] == "error"  # retained for session.resume

    # Control: a built agent still runs the turn to completion and clears the interim closure.
    agent = types.SimpleNamespace(
        session_id="agent-sid-1", run_conversation=lambda *a, **k: {"final_response": "done"},
        clear_interrupt=lambda: None)
    emitted.clear()
    server._run_prompt_submit("rid", "ui-sid", _session(agent), "go")
    assert [p["status"] for (t, _sid, p) in emitted if t == "message.complete"] == ["complete"]
    assert agent.interim_assistant_callback is None


def test_replaced_record_build_records_reason_and_leaves_agent_unset(monkeypatch, tmp_path):
    """A build whose record was swapped mid-flight sets ``agent_ready`` AND records why nothing attached."""
    monkeypatch.setattr(server.threading, "Thread", _InlineThread)
    monkeypatch.setattr(server, "_await_resume_history", lambda sid, current: False)

    sid = "replaced-record"
    session = _session(None, agent_ready=threading.Event(), cwd=str(tmp_path), profile_home=None)
    server._sessions[sid] = session
    try:
        server._start_agent_build(sid, session)
    finally:
        server._sessions.pop(sid, None)

    assert session["agent"] is None
    assert session["agent_ready"].is_set()
    assert session["agent_error"] == AGENT_BUILD_ABANDONED


def test_build_finishing_after_close_records_reason_and_closes_orphan(monkeypatch, tmp_path):
    """A build that finishes after ``session.close`` popped its record closes the late agent and
    records why nothing attached, exactly like the replaced-before-attach exit (#49852)."""
    import tui_gateway.entry as entry

    monkeypatch.setattr(server.threading, "Thread", _InlineThread)
    monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None)
    closed: list[str] = []
    sid = "closed-mid-build"

    def _make_agent_then_close(_sid, _key, **_kwargs):
        with server._sessions_lock:  # session.close lands while the agent is being constructed
            server._sessions.pop(sid, None)
        return types.SimpleNamespace(close=lambda: closed.append("closed"))

    monkeypatch.setattr(server, "_make_agent", _make_agent_then_close)
    session = _session(None, agent_ready=threading.Event(), cwd=str(tmp_path), profile_home=None)
    server._sessions[sid] = session
    try:
        server._start_agent_build(sid, session)
    finally:
        server._sessions.pop(sid, None)

    assert closed == ["closed"]
    assert session["agent"] is None
    assert session["agent_ready"].is_set()
    assert session["agent_error"] == AGENT_BUILD_ABANDONED
