import asyncio
import logging
from types import SimpleNamespace
from typing import Optional

import pytest

from codex_autorunner.integrations.telegram.adapter import TelegramMessage
from codex_autorunner.integrations.telegram.constants import (
    PLACEHOLDER_TEXT,
    QUEUED_PLACEHOLDER_TEXT,
)
from codex_autorunner.integrations.telegram.handlers.commands_runtime import (
    TelegramCommandHandlers,
    _RuntimeStub,
)
from codex_autorunner.integrations.telegram.helpers import _format_turn_metrics
from codex_autorunner.integrations.telegram.state import TelegramTopicRecord


class _TurnResult:
    def __init__(self) -> None:
        self.agent_messages = ["ok"]
        self.errors: list[str] = []
        self.status = "completed"
        self.token_usage = None


class _TurnHandle:
    def __init__(self, turn_id: str, wait_event: asyncio.Event) -> None:
        self.turn_id = turn_id
        self._wait_event = wait_event

    async def wait(self, *_args: object, **_kwargs: object) -> _TurnResult:
        await self._wait_event.wait()
        return _TurnResult()


class _ClientStub:
    def __init__(
        self,
        *,
        turn_wait_events: Optional[list[asyncio.Event]] = None,
        turn_start_events: Optional[list[asyncio.Event]] = None,
        review_wait_events: Optional[list[asyncio.Event]] = None,
        review_start_events: Optional[list[asyncio.Event]] = None,
    ) -> None:
        self.turn_start_calls: list[tuple[str, str]] = []
        self.review_start_calls: list[tuple[str, str]] = []
        self._turn_wait_events = turn_wait_events or []
        self._turn_start_events = turn_start_events or []
        self._review_wait_events = review_wait_events or []
        self._review_start_events = review_start_events or []

    async def turn_start(
        self, thread_id: str, prompt_text: str, **_kwargs: object
    ) -> _TurnHandle:
        idx = len(self.turn_start_calls)
        self.turn_start_calls.append((thread_id, prompt_text))
        if idx < len(self._turn_start_events):
            self._turn_start_events[idx].set()
        wait_event = self._turn_wait_events[idx]
        return _TurnHandle(f"turn-{idx}", wait_event)

    async def review_start(self, thread_id: str, **_kwargs: object) -> _TurnHandle:
        idx = len(self.review_start_calls)
        self.review_start_calls.append((thread_id, "review"))
        if idx < len(self._review_start_events):
            self._review_start_events[idx].set()
        wait_event = self._review_wait_events[idx]
        return _TurnHandle(f"review-{idx}", wait_event)


class _RouterStub:
    def __init__(self, records: dict[str, TelegramTopicRecord]) -> None:
        self._records = records

    async def get_topic(self, key: str) -> Optional[TelegramTopicRecord]:
        return self._records.get(key)

    async def set_active_thread(
        self, chat_id: int, thread_id: Optional[int], active_thread_id: Optional[str]
    ) -> Optional[TelegramTopicRecord]:
        record = self._records.get(f"{chat_id}:{thread_id}")
        if record is not None and active_thread_id is not None:
            record.active_thread_id = active_thread_id
        return record

    async def update_topic(
        self, chat_id: int, thread_id: Optional[int], apply: object
    ) -> None:
        record = self._records.get(f"{chat_id}:{thread_id}")
        if record is None:
            return
        if callable(apply):
            apply(record)


class _HandlerStub(TelegramCommandHandlers):
    def __init__(
        self,
        *,
        client: _ClientStub,
        max_parallel_turns: int,
        records: dict[str, TelegramTopicRecord],
        placeholder_events: Optional[dict[int, asyncio.Event]] = None,
        deliver_result: bool = True,
    ) -> None:
        self._logger = logging.getLogger("test")
        self._config = SimpleNamespace(
            concurrency=SimpleNamespace(
                max_parallel_turns=max_parallel_turns,
                per_topic_queue=False,
            ),
            app_server_turn_timeout_seconds=None,
            agent_turn_timeout_seconds={"codex": None, "opencode": None},
            message_overflow="document",
        )
        self._router = _RouterStub(records)
        self._turn_semaphore = asyncio.Semaphore(max_parallel_turns)
        self._turn_contexts: dict[tuple[str, str], object] = {}
        self._turn_preview_text: dict[tuple[str, str], str] = {}
        self._turn_preview_updated_at: dict[tuple[str, str], float] = {}
        self._token_usage_by_thread: dict[str, dict[str, object]] = {}
        self._token_usage_by_turn: dict[str, dict[str, object]] = {}
        self._voice_config = None
        self._client = client
        self._placeholder_calls: list[dict[str, object]] = []
        self._placeholder_ids: dict[int, int] = {}
        self._edit_calls: list[tuple[int, str]] = []
        self._deliver_calls: list[dict[str, object]] = []
        self._outbox_calls: list[dict[str, object]] = []
        self._delete_calls: list[tuple[object, object]] = []
        self._placeholder_events = placeholder_events or {}
        self._deliver_result = deliver_result

    async def _resolve_topic_key(self, chat_id: int, thread_id: Optional[int]) -> str:
        return f"{chat_id}:{thread_id}"

    def _ensure_turn_semaphore(self) -> asyncio.Semaphore:
        return self._turn_semaphore

    async def _client_for_workspace(self, _workspace_path: str) -> _ClientStub:
        return self._client

    async def _find_thread_conflict(
        self, _thread_id: str, *, key: str
    ) -> Optional[str]:
        return None

    async def _refresh_workspace_id(
        self, _key: str, _record: TelegramTopicRecord
    ) -> Optional[str]:
        return None

    async def _handle_thread_conflict(self, *_args: object, **_kwargs: object) -> None:
        return None

    async def _verify_active_thread(
        self, _message: TelegramMessage, record: TelegramTopicRecord
    ) -> TelegramTopicRecord:
        return record

    def _maybe_append_whisper_disclaimer(
        self, prompt_text: str, *, transcript_text: Optional[str]
    ) -> str:
        return prompt_text

    async def _maybe_inject_github_context(
        self,
        prompt_text: str,
        _record: object,
        *,
        link_source_text: Optional[str] = None,
        allow_cross_repo: bool = False,
    ) -> tuple[str, bool]:
        _ = link_source_text, allow_cross_repo
        return prompt_text, False

    def _maybe_inject_car_context(self, prompt_text: str) -> tuple[str, bool]:
        return prompt_text, False

    def _maybe_inject_prompt_context(
        self,
        prompt_text: str,
        *,
        trigger_text: Optional[str] = None,
    ) -> tuple[str, bool]:
        _ = trigger_text
        return prompt_text, False

    def _maybe_inject_outbox_context(
        self,
        prompt_text: str,
        *,
        record: object,
        topic_key: str,
        has_file_context: bool = False,
        user_input_text: Optional[str] = None,
    ) -> tuple[str, bool]:
        _ = record, topic_key, has_file_context, user_input_text
        return prompt_text, False

    def _effective_policies(self, _record: TelegramTopicRecord) -> tuple[None, None]:
        return None, None

    async def _send_placeholder(
        self,
        chat_id: int,
        *,
        thread_id: Optional[int],
        reply_to: Optional[int],
        text: str = PLACEHOLDER_TEXT,
        reply_markup: Optional[dict[str, object]] = None,
    ) -> int:
        call = {
            "chat_id": chat_id,
            "thread_id": thread_id,
            "reply_to": reply_to,
            "text": text,
            "reply_markup": reply_markup,
        }
        self._placeholder_calls.append(call)
        if reply_to is not None:
            placeholder_id = 100 + len(self._placeholder_calls)
            self._placeholder_ids[reply_to] = placeholder_id
            event = self._placeholder_events.get(reply_to)
            if event is not None:
                event.set()
            return placeholder_id
        return 0

    async def _edit_message_text(
        self, _chat_id: int, message_id: int, text: str
    ) -> bool:
        self._edit_calls.append((message_id, text))
        return True

    def _format_turn_metrics_text(
        self,
        token_usage: Optional[dict[str, object]],
        elapsed_seconds: Optional[float],
    ) -> Optional[str]:
        return _format_turn_metrics(token_usage, elapsed_seconds)

    def _metrics_mode(self) -> str:
        return "append_to_response"

    async def _send_turn_metrics(self, *_args: object, **_kwargs: object) -> bool:
        return True

    async def _append_metrics_to_placeholder(
        self, *_args: object, **_kwargs: object
    ) -> bool:
        return True

    def _turn_key(
        self, thread_id: Optional[str], turn_id: Optional[str]
    ) -> Optional[tuple[str, str]]:
        if not thread_id or not turn_id:
            return None
        return (thread_id, turn_id)

    def _register_turn_context(
        self, turn_key: tuple[str, str], _turn_id: str, ctx: object
    ) -> bool:
        existing = self._turn_contexts.get(turn_key)
        if existing and existing is not ctx:
            return False
        self._turn_contexts[turn_key] = ctx
        return True

    def _clear_thinking_preview(self, turn_key: tuple[str, str]) -> None:
        self._turn_preview_text.pop(turn_key, None)
        self._turn_preview_updated_at.pop(turn_key, None)

    async def _start_turn_progress(
        self,
        _turn_key: tuple[str, str],
        *,
        ctx: object,
        agent: str,
        model: Optional[str],
        label: str = "working",
    ) -> None:
        return None

    def _clear_turn_progress(self, _turn_key: tuple[str, str]) -> None:
        return None

    async def _send_message(self, *_args: object, **_kwargs: object) -> None:
        return None

    async def _deliver_turn_response(
        self,
        *,
        chat_id: int,
        thread_id: Optional[int],
        reply_to: Optional[int],
        placeholder_id: Optional[int],
        response: str,
        intermediate_response: Optional[str] = None,
        delete_placeholder_on_delivery: bool = True,
        overflow_mode_override: Optional[str] = None,
    ) -> bool:
        self._deliver_calls.append(
            {
                "chat_id": chat_id,
                "thread_id": thread_id,
                "reply_to": reply_to,
                "placeholder_id": placeholder_id,
                "response": response,
                "intermediate_response": intermediate_response,
                "delete_placeholder_on_delivery": delete_placeholder_on_delivery,
                "overflow_mode_override": overflow_mode_override,
            }
        )
        return self._deliver_result

    async def _send_message_with_outbox(
        self,
        chat_id: int,
        text: str,
        *,
        thread_id: Optional[int],
        reply_to: Optional[int],
        placeholder_id: Optional[int] = None,
        delete_placeholder_on_delivery: bool = True,
    ) -> bool:
        self._outbox_calls.append(
            {
                "chat_id": chat_id,
                "text": text,
                "thread_id": thread_id,
                "reply_to": reply_to,
                "placeholder_id": placeholder_id,
                "delete_placeholder_on_delivery": delete_placeholder_on_delivery,
            }
        )
        return True

    async def _send_turn_metrics(self, *_args: object, **_kwargs: object) -> bool:
        return True

    async def _delete_message(
        self, chat_id: object, message_id: object, **_kwargs: object
    ) -> bool:
        self._delete_calls.append((chat_id, message_id))
        return True

    async def _flush_outbox_files(self, *_args: object, **_kwargs: object) -> None:
        return None

    async def _finalize_voice_transcript(
        self, *_args: object, **_kwargs: object
    ) -> None:
        return None


def _message(*, message_id: int, thread_id: int) -> TelegramMessage:
    return TelegramMessage(
        update_id=1,
        message_id=message_id,
        chat_id=10,
        thread_id=thread_id,
        from_user_id=2,
        text="hello",
        date=None,
        is_topic_message=True,
    )


def _record(thread_id: str) -> TelegramTopicRecord:
    return TelegramTopicRecord(
        workspace_path="/tmp",
        active_thread_id=thread_id,
        thread_ids=[thread_id],
    )


@pytest.mark.anyio
async def test_turn_placeholder_sent_while_queued() -> None:
    first_wait = asyncio.Event()
    second_wait = asyncio.Event()
    first_started = asyncio.Event()
    second_started = asyncio.Event()
    second_placeholder = asyncio.Event()

    client = _ClientStub(
        turn_wait_events=[first_wait, second_wait],
        turn_start_events=[first_started, second_started],
    )
    records = {
        "10:11": _record("thread-1"),
        "10:12": _record("thread-2"),
    }
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
        placeholder_events={2: second_placeholder},
    )

    runtime_one = _RuntimeStub()
    runtime_two = _RuntimeStub()
    task_one = asyncio.create_task(
        handler._run_turn_and_collect_result(
            _message(message_id=1, thread_id=11),
            runtime_one,
            record=records["10:11"],
        )
    )
    await asyncio.wait_for(first_started.wait(), timeout=1.0)

    task_two = asyncio.create_task(
        handler._run_turn_and_collect_result(
            _message(message_id=2, thread_id=12),
            runtime_two,
            record=records["10:12"],
        )
    )
    await asyncio.wait_for(second_placeholder.wait(), timeout=1.0)
    assert len(client.turn_start_calls) == 1

    second_call = next(
        call for call in handler._placeholder_calls if call["reply_to"] == 2
    )
    assert second_call["text"] == QUEUED_PLACEHOLDER_TEXT

    first_wait.set()
    await asyncio.wait_for(second_started.wait(), timeout=1.0)
    second_wait.set()
    await asyncio.gather(task_one, task_two)

    placeholder_id = handler._placeholder_ids[2]
    assert (placeholder_id, PLACEHOLDER_TEXT) in handler._edit_calls


@pytest.mark.anyio
async def test_turns_start_without_queue_when_parallelism_allows() -> None:
    first_wait = asyncio.Event()
    second_wait = asyncio.Event()
    first_started = asyncio.Event()
    second_started = asyncio.Event()

    client = _ClientStub(
        turn_wait_events=[first_wait, second_wait],
        turn_start_events=[first_started, second_started],
    )
    records = {
        "10:11": _record("thread-1"),
        "10:12": _record("thread-2"),
    }
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=2,
        records=records,
    )

    runtime_one = _RuntimeStub()
    runtime_two = _RuntimeStub()
    task_one = asyncio.create_task(
        handler._run_turn_and_collect_result(
            _message(message_id=1, thread_id=11),
            runtime_one,
            record=records["10:11"],
        )
    )
    task_two = asyncio.create_task(
        handler._run_turn_and_collect_result(
            _message(message_id=2, thread_id=12),
            runtime_two,
            record=records["10:12"],
        )
    )
    await asyncio.wait_for(first_started.wait(), timeout=1.0)
    await asyncio.wait_for(second_started.wait(), timeout=1.0)

    first_wait.set()
    second_wait.set()
    await asyncio.gather(task_one, task_two)

    texts = [call["text"] for call in handler._placeholder_calls]
    assert texts == [PLACEHOLDER_TEXT, PLACEHOLDER_TEXT]
    assert handler._edit_calls == []


@pytest.mark.anyio
async def test_normal_turn_deletes_progress_placeholder_on_success() -> None:
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    records = {"10:11": _record("thread-1")}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(
        message, _RuntimeStub(), record=records["10:11"]
    )

    assert handler._deliver_calls
    assert handler._deliver_calls[-1]["delete_placeholder_on_delivery"] is True
    assert handler._delete_calls == []


@pytest.mark.anyio
async def test_normal_turn_append_to_progress_does_not_emit_separate_metrics() -> None:
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    records = {"10:11": _record("thread-1")}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )
    captured: dict[str, object] = {}

    async def _append_metrics(
        chat_id: int,
        message_id: Optional[int],
        metrics: str,
        *,
        base_text: Optional[str] = None,
    ) -> bool:
        captured["chat_id"] = chat_id
        captured["message_id"] = message_id
        captured["metrics"] = metrics
        captured["base_text"] = base_text
        return True

    handler._metrics_mode = lambda: "append_to_progress"
    handler._format_turn_metrics_text = lambda *_args, **_kwargs: "metrics block"
    handler._append_metrics_to_placeholder = _append_metrics

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(
        message, _RuntimeStub(), record=records["10:11"]
    )

    assert captured == {
        "chat_id": 10,
        "message_id": 101,
        "metrics": "metrics block",
        "base_text": "",
    }
    assert handler._deliver_calls[-1]["delete_placeholder_on_delivery"] is True
    assert "metrics block" not in handler._deliver_calls[-1]["response"]
    assert handler._outbox_calls == []


@pytest.mark.anyio
async def test_normal_turn_appends_metrics_footer_to_response_by_default() -> None:
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = _record("thread-1")
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )
    metrics_calls: list[dict[str, object]] = []

    async def _send_turn_metrics(**kwargs: object) -> bool:
        metrics_calls.append(dict(kwargs))
        return True

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="final output",
            placeholder_id=456,
            elapsed_seconds=12.34,
            token_usage={
                "last": {
                    "totalTokens": 80,
                    "inputTokens": 60,
                    "outputTokens": 20,
                },
                "modelContextWindow": 100,
            },
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="done · agent codex · gpt-4.1-mini · 12s · step 3",
        )

    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]
    handler._send_turn_metrics = _send_turn_metrics  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, _RuntimeStub(), record=record)

    delivered = handler._deliver_calls[-1]["response"]
    assert "final output" in delivered
    assert "agent codex · gpt-4.1-mini · 12s · step 3 · ctx 20%" in delivered
    assert "Token usage: total 80 input 60 output 20" in delivered
    assert "Turn time:" not in delivered
    assert handler._deliver_calls[-1]["overflow_mode_override"] == "split"
    assert metrics_calls == []


@pytest.mark.anyio
async def test_normal_turn_separate_metrics_stay_out_of_response_when_configured() -> (
    None
):
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = _record("thread-1")
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )
    metrics_calls: list[dict[str, object]] = []

    async def _send_turn_metrics(**kwargs: object) -> bool:
        metrics_calls.append(dict(kwargs))
        return True

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="final output",
            placeholder_id=456,
            elapsed_seconds=12.34,
            token_usage={
                "last": {
                    "totalTokens": 80,
                    "inputTokens": 60,
                    "outputTokens": 20,
                },
                "modelContextWindow": 100,
            },
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="done · agent codex · gpt-4.1-mini · 12s · step 3",
        )

    handler._metrics_mode = lambda: "separate"
    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]
    handler._send_turn_metrics = _send_turn_metrics  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, _RuntimeStub(), record=record)

    assert "Token usage:" not in handler._deliver_calls[-1]["response"]
    assert "Turn time:" not in handler._deliver_calls[-1]["response"]
    assert handler._deliver_calls[-1]["overflow_mode_override"] is None
    assert metrics_calls == [
        {
            "chat_id": 10,
            "thread_id": 11,
            "reply_to": 1,
            "elapsed_seconds": 12.34,
            "token_usage": {
                "last": {
                    "totalTokens": 80,
                    "inputTokens": 60,
                    "outputTokens": 20,
                },
                "modelContextWindow": 100,
            },
        }
    ]


@pytest.mark.anyio
async def test_normal_opencode_turn_appends_summary_footer_after_final_response() -> (
    None
):
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = TelegramTopicRecord(
        workspace_path="/tmp",
        active_thread_id="thread-1",
        thread_ids=["thread-1"],
        agent="opencode",
    )
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="final output",
            placeholder_id=456,
            elapsed_seconds=1.0,
            token_usage=None,
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="done · agent opencode · model-x · 1s · step 3",
        )

    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, _RuntimeStub(), record=record)

    assert handler._outbox_calls == []
    assert (
        handler._deliver_calls[-1]["response"]
        == "final output\n\nagent opencode · model-x · 1s · step 3"
    )
    assert handler._deliver_calls[-1]["intermediate_response"] is None


@pytest.mark.anyio
async def test_normal_opencode_turn_drops_no_response_sentinel_when_summary_present() -> (
    None
):
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = TelegramTopicRecord(
        workspace_path="/tmp",
        active_thread_id="thread-1",
        thread_ids=["thread-1"],
        agent="opencode",
    )
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="No response.",
            placeholder_id=456,
            elapsed_seconds=1.0,
            token_usage=None,
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="done · agent opencode · model-x · 1s · step 3",
        )

    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, _RuntimeStub(), record=record)

    assert (
        handler._deliver_calls[-1]["response"] == "(No response text returned.)\n\n"
        "agent opencode · model-x · 1s · step 3"
    )
    assert handler._deliver_calls[-1]["intermediate_response"] is None


@pytest.mark.anyio
async def test_normal_turn_clears_interrupt_status_after_successful_delivery() -> None:
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = _record("thread-1")
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
        deliver_result=True,
    )
    runtime = _RuntimeStub(interrupt_message_id=777, interrupt_turn_id="turn-1")

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="final output",
            placeholder_id=456,
            elapsed_seconds=1.0,
            token_usage=None,
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="",
            interrupt_status_turn_id="turn-1",
            interrupt_status_fallback_text="Interrupt requested; turn completed.",
        )

    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, runtime, record=record)

    assert handler._delete_calls == [(10, 777)]
    assert handler._edit_calls == []
    assert runtime.interrupt_message_id is None
    assert runtime.interrupt_turn_id is None


@pytest.mark.anyio
async def test_normal_turn_edits_interrupt_status_when_delivery_fails() -> None:
    wait = asyncio.Event()
    wait.set()
    client = _ClientStub(turn_wait_events=[wait])
    record = _record("thread-1")
    records = {"10:11": record}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
        deliver_result=False,
    )
    runtime = _RuntimeStub(interrupt_message_id=778, interrupt_turn_id="turn-1")

    async def _fake_run_turn_and_collect_result(
        _message: TelegramMessage,
        _runtime: _RuntimeStub,
        **_kwargs: object,
    ) -> SimpleNamespace:
        return SimpleNamespace(
            record=record,
            thread_id="thread-1",
            turn_id="turn-1",
            response="final output",
            placeholder_id=456,
            elapsed_seconds=1.0,
            token_usage=None,
            transcript_message_id=None,
            transcript_text=None,
            intermediate_response="",
            interrupt_status_turn_id="turn-1",
            interrupt_status_fallback_text="Interrupted.",
        )

    handler._run_turn_and_collect_result = _fake_run_turn_and_collect_result  # type: ignore[assignment]

    message = _message(message_id=1, thread_id=11)
    await handler._handle_normal_message(message, runtime, record=record)

    assert handler._delete_calls == []
    assert handler._edit_calls == [(778, "Interrupted.")]
    assert runtime.interrupt_message_id is None
    assert runtime.interrupt_turn_id is None


@pytest.mark.anyio
async def test_review_placeholder_sent_while_queued() -> None:
    first_wait = asyncio.Event()
    second_wait = asyncio.Event()
    first_started = asyncio.Event()
    second_started = asyncio.Event()
    second_placeholder = asyncio.Event()

    client = _ClientStub(
        review_wait_events=[first_wait, second_wait],
        review_start_events=[first_started, second_started],
    )
    records = {
        "10:11": _record("thread-1"),
        "10:12": _record("thread-2"),
    }
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
        placeholder_events={2: second_placeholder},
    )

    runtime_one = _RuntimeStub()
    runtime_two = _RuntimeStub()
    message_one = _message(message_id=1, thread_id=11)
    message_two = _message(message_id=2, thread_id=12)

    task_one = asyncio.create_task(
        handler._start_review(
            message_one,
            runtime_one,
            record=records["10:11"],
            thread_id="thread-1",
            target={"type": "diff"},
            delivery="inline",
        )
    )
    await asyncio.wait_for(first_started.wait(), timeout=1.0)

    task_two = asyncio.create_task(
        handler._start_review(
            message_two,
            runtime_two,
            record=records["10:12"],
            thread_id="thread-2",
            target={"type": "diff"},
            delivery="inline",
        )
    )
    await asyncio.wait_for(second_placeholder.wait(), timeout=1.0)
    assert len(client.review_start_calls) == 1

    second_call = next(
        call for call in handler._placeholder_calls if call["reply_to"] == 2
    )
    assert second_call["text"] == QUEUED_PLACEHOLDER_TEXT

    first_wait.set()
    await asyncio.wait_for(second_started.wait(), timeout=1.0)
    second_wait.set()
    await asyncio.gather(task_one, task_two)

    placeholder_id = handler._placeholder_ids[2]
    assert (placeholder_id, PLACEHOLDER_TEXT) in handler._edit_calls
    second_delivery = next(
        call for call in handler._deliver_calls if call["reply_to"] == 2
    )
    assert second_delivery["delete_placeholder_on_delivery"] is True
    assert handler._delete_calls == []


@pytest.mark.anyio
async def test_turn_cancel_releases_semaphore_on_race() -> None:
    client = _ClientStub(turn_wait_events=[asyncio.Event()])
    records = {"10:11": _record("thread-1")}
    handler = _HandlerStub(
        client=client,
        max_parallel_turns=1,
        records=records,
    )
    runtime = _RuntimeStub()
    runtime.queued_turn_cancel = None
    message = _message(message_id=1, thread_id=11)

    await handler._turn_semaphore.acquire()
    task = asyncio.create_task(
        handler._await_turn_slot(
            handler._turn_semaphore,
            runtime,
            message=message,
            placeholder_id=101,
            queued=True,
        )
    )
    for _ in range(100):
        if runtime.queued_turn_cancel is not None:
            break
        await asyncio.sleep(0)
    assert runtime.queued_turn_cancel is not None

    handler._turn_semaphore.release()
    runtime.queued_turn_cancel.set()
    result = await asyncio.wait_for(task, timeout=1.0)
    assert result is False

    await asyncio.wait_for(handler._turn_semaphore.acquire(), timeout=1.0)
    handler._turn_semaphore.release()
