"""Tests for ChatPanel per-message widget rendering and the visible-window cap.

Each message is its own ``_ChatItemView`` widget. On a per-token stream only the
streaming bubble's widget re-renders (signature-gated); everything else is
untouched. The window cap bounds how many item widgets stay mounted regardless
of total history length, and a truncation hint sits at the top when older
messages are dropped.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from clients.terminal.tui.tui_app import NaviCodeTui


@pytest.fixture(autouse=True)
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    from clients.terminal import config
    import clients.terminal.tui.settings as settings_module

    original = config.settings.state_dir
    config.settings.state_dir = tmp_path
    settings_module._tui_settings = None
    yield tmp_path
    config.settings.state_dir = original
    settings_module._tui_settings = None


@pytest.fixture(autouse=True)
def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None:
    import clients.terminal.api as api_module

    async def fake_create_session(*a, **k):
        return {"session_id": "s", "profile_id": "navi_code"}

    async def fake_get_session(sid):
        return {"session_id": sid, "profile_id": "navi_code"}

    async def fake_list_sessions():
        return []

    async def fake_get_profile_model(pid):
        return "configured-model"

    monkeypatch.setattr(api_module, "create_session", fake_create_session)
    monkeypatch.setattr(api_module, "get_session", fake_get_session)
    monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions)
    monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model)


def _wrap_render(chat) -> list:
    """Wrap the panel's renderer registry.render to count calls."""
    calls: list[dict] = []
    original_render = chat._registry.render

    def _record(msg, *args, **kwargs):
        calls.append(msg)
        return original_render(msg, *args, **kwargs)

    chat._registry.render = _record  # type: ignore[method-assign]
    return calls


def _item_widgets(chat) -> list:
    """The panel's mounted item widgets, in display (insertion) order."""
    return list(chat._widgets.values())


@pytest.mark.anyio
async def test_per_token_refresh_renders_only_the_streaming_item() -> None:
    """A stream_delta changes only the assistant bubble's signature, so only the
    streaming widget is re-rendered. Rebuilds are throttled (~150 ms coalesce)
    for the Markdown assistant bubble — a rapid burst produces a couple of
    rebuilds, not one per token — but every rebuild is for the assistant item,
    never the untouched user bubble."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        calls = _wrap_render(chat)

        chat.add_user_message("hi")  # renders the user bubble once
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})  # creates + renders assistant
        await pilot.pause()  # let the mount flush

        baseline = len(calls)
        # A rapid burst of tokens — throttled, not one render per token.
        for ch in "bcdefghijk":
            chat.handle_ws_event({"type": "stream_delta", "delta": ch})
        await pilot.pause(0.2)  # let the throttle window fire

        after = len(calls)
        # The first mutating delta rebuilds immediately (snappy start); the rest
        # coalesce into one rebuild after the window — not ten rebuilds.
        assert after > baseline
        assert after - baseline <= 2
        # Every rebuild in the burst was for the streaming assistant item — the
        # user bubble widget was never touched.
        assert all(c.get("type") == "assistant_message" for c in calls[baseline:])


@pytest.mark.anyio
async def test_per_token_refresh_keeps_other_widgets_stable(monkeypatch) -> None:
    """Re-rendering the streaming widget must NOT touch the other item widgets:
    the same widget objects persist across tokens (no re-mount of older items)."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        monkeypatch.setattr(chat, "_max_visible_items", lambda: 50)
        chat.clear()

        chat.add_user_message("hi")
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})
        await pilot.pause()

        widgets_before = _item_widgets(chat)
        user_widget = widgets_before[0]
        assistant_widget = widgets_before[-1]
        assert user_widget._item.kind == "user_message"
        assert assistant_widget._item.kind == "assistant_message"

        # Two more tokens — the user widget must be the same object; only the
        # assistant widget re-renders.
        chat.handle_ws_event({"type": "stream_delta", "delta": "b"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "c"})
        await pilot.pause()

        widgets_after = _item_widgets(chat)
        assert widgets_after[0] is user_widget, "user bubble widget must not be re-mounted"
        assert widgets_after[-1] is assistant_widget, "assistant widget updated in place, not re-mounted"
        assert assistant_widget._item.content == "abc"


@pytest.mark.anyio
async def test_window_cap_mounts_only_last_n_with_hint(monkeypatch) -> None:
    """Beyond the cap, only the last N item widgets stay mounted and a hint is shown."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        monkeypatch.setattr(chat, "_max_visible_items", lambda: 3)
        chat.clear()  # drop the startup cwd/connected status items

        for i in range(5):
            chat.add_user_message(f"msg {i}")
        await pilot.pause()

        widgets = _item_widgets(chat)
        # Only the last 3 items are mounted.
        assert len(widgets) == 3
        assert [w._item.content for w in widgets] == ["msg 2", "msg 3", "msg 4"]
        # Truncation hint is visible and reports the 2 dropped oldest items.
        assert chat._hidden_count == 2
        assert chat._hint_view.styles.display != "none"


@pytest.mark.anyio
async def test_no_hint_when_within_limit(monkeypatch) -> None:
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        monkeypatch.setattr(chat, "_max_visible_items", lambda: 10)
        chat.clear()  # drop the startup cwd/connected status items

        for i in range(3):
            chat.add_user_message(f"msg {i}")
        await pilot.pause()

        assert len(_item_widgets(chat)) == 3
        assert chat._hidden_count == 0
        assert chat._hint_view.styles.display == "none"


@pytest.mark.anyio
async def test_clear_unmounts_all_item_widgets() -> None:
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.add_user_message("hi")
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})
        await pilot.pause()
        assert chat._widgets  # item widgets are mounted

        chat.clear()
        await pilot.pause()
        assert chat._widgets == {}
        assert chat._model.items == []


@pytest.mark.anyio
async def test_removed_item_is_unmounted() -> None:
    """stream_end purges empty assistant bubbles; their widget must be removed
    so only live items stay mounted."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")

        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": ""})  # empty bubble
        await pilot.pause()
        empty_ids = {id(it) for it in chat._model.items if it.kind == "assistant_message"}
        assert empty_ids  # the empty bubble exists before stream_end

        chat.handle_ws_event({"type": "stream_end", "content": ""})
        await pilot.pause()
        mounted_ids = set(chat._widgets.keys())
        assert not (mounted_ids & empty_ids)
        # And the empty bubble is no longer in the model.
        assert not any(it.kind == "assistant_message" for it in chat._model.items)

# ─── load_history (session resume) ─────────────────────────────────────────


def _hist_msg(**kw) -> dict:
    """A persisted Message dict as GET /sessions/{id} returns it."""
    base = {"is_display": True}
    base.update(kw)
    return base


# ─── turn_meta (request duration) ───────────────────────────────────────────


def test_chat_model_stream_end_appends_turn_meta() -> None:
    """stream_end records the whole-turn duration as a metadata item."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    model.handle_ws_event({"type": "stream_delta", "delta": "done"})
    model.handle_ws_event({"type": "stream_end", "content": "done", "elapsed_seconds": 136})
    meta = [it for it in model.items if it.kind == "turn_meta"]
    assert len(meta) == 1
    assert meta[0].meta["elapsed_seconds"] == 136
    # It sits below the assistant answer.
    assert model.items[-1].kind == "turn_meta"


def test_chat_model_stream_end_without_elapsed_still_appends() -> None:
    """If the backend omits elapsed_seconds, the metadata item still lands (renders —)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    model.handle_ws_event({"type": "stream_end", "content": ""})
    meta = [it for it in model.items if it.kind == "turn_meta"]
    assert len(meta) == 1
    assert meta[0].meta["elapsed_seconds"] is None


def test_chat_model_stream_stopped_does_not_append_turn_meta() -> None:
    """A user-interrupted turn records a status line, not a duration metadata line."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    model.handle_ws_event({"type": "stream_stopped"})
    assert not any(it.kind == "turn_meta" for it in model.items)
    assert any(it.kind == "status" for it in model.items)


def test_chat_model_load_history_maps_roles() -> None:
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="user", content="hello"),
        _hist_msg(role="assistant", thinking="hmm", content=""),
        _hist_msg(
            role="assistant",
            content="",
            tool_calls=[{"id": "1", "name": "filesystem", "arguments": {"path": "/x"}}],
        ),
        _hist_msg(role="tool", name="filesystem", content="listed: a, b"),
        _hist_msg(role="assistant", content="X has a and b."),
    ]
    model = ChatModel()
    model.load_history(messages)

    kinds = [it.kind for it in model.items]
    assert kinds == [
        "user_message",
        "thinking_block",
        "tool_started",
        "tool_call",
        "assistant_message",
    ]
    assert model.items[0].content == "hello"
    assert model.items[1].content == "hmm"
    assert model.items[2].meta["tool"] == "filesystem"
    assert model.items[2].meta["args"] == {"path": "/x"}
    assert model.items[3].meta["result"] == "listed: a, b"
    assert model.items[4].content == "X has a and b."


def test_chat_model_load_history_recovers_tool_args_and_metadata() -> None:
    """Resume must reproduce the live tool_call payload: the tool message in
    storage carries only the result, so load_history recovers the call args via
    the assistant message's tool_call_id and forwards the stored metadata.
    Without this the filesystem renderer sees args={} → action=None and falls
    back to a plain (un-highlighted) result — the resume "no syntax highlighting"
    symptom."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(
            role="assistant",
            content="",
            tool_calls=[{"id": "call-7", "name": "filesystem", "arguments": {"action": "edit", "path": "/a.py"}}],
        ),
        _hist_msg(
            role="tool",
            name="filesystem",
            tool_call_id="call-7",
            content="replaced",
            metadata={"diff": "- 2│ old\n+ 2│ new"},
        ),
    ]
    model = ChatModel()
    model.load_history(messages)

    tool_call = [it for it in model.items if it.kind == "tool_call"][0]
    assert tool_call.meta["tool"] == "filesystem"
    assert tool_call.meta["args"] == {"action": "edit", "path": "/a.py"}
    assert tool_call.meta["result"] == "replaced"
    assert tool_call.meta["metadata"] == {"diff": "- 2│ old\n+ 2│ new"}


def test_chat_model_load_history_tool_args_fallback_when_id_missing() -> None:
    """If the tool message has no tool_call_id (or it doesn't match a stored
    call), args fall back to {} rather than raising — the renderer then degrades
    to the plain result, which is acceptable for legacy/foreign sessions."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="tool", name="filesystem", content="listed"),
    ]
    model = ChatModel()
    model.load_history(messages)
    tool_call = [it for it in model.items if it.kind == "tool_call"][0]
    assert tool_call.meta["args"] == {}
    assert tool_call.meta["metadata"] == {}


def test_chat_model_load_history_emits_assistant_text_before_tool_started() -> None:
    """When an assistant message carries BOTH text and tool_calls, the text
    bubble must land ABOVE the tool cards — matching the live stream where
    stream_delta precedes tool_started. The previous code emitted tool_started
    first, stranding the answer under its own tool calls on resume."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(
            role="assistant",
            content="Let me check the file.",
            tool_calls=[{"id": "1", "name": "filesystem", "arguments": {"action": "read"}}],
        ),
        _hist_msg(role="tool", name="filesystem", tool_call_id="1", content="ok"),
    ]
    model = ChatModel()
    model.load_history(messages)

    kinds = [it.kind for it in model.items]
    assert kinds == ["assistant_message", "tool_started", "tool_call"]
    assert model.items[0].content == "Let me check the file."


def test_chat_model_load_history_appends_turn_meta_for_elapsed() -> None:
    """The whole-turn duration is stamped on the assistant message that closed
    the run; load_history reproduces the single turn_meta line stream_end
    appends. Intermediate tool-tour assistant messages (no elapsed_seconds) do
    not produce extra turn_meta items."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(
            role="assistant",
            content="",
            tool_calls=[{"id": "1", "name": "filesystem", "arguments": {}}],
        ),
        _hist_msg(role="tool", name="filesystem", tool_call_id="1", content="ok"),
        _hist_msg(role="assistant", content="done", elapsed_seconds=42.5),
    ]
    model = ChatModel()
    model.load_history(messages)

    metas = [it for it in model.items if it.kind == "turn_meta"]
    assert len(metas) == 1
    assert metas[0].meta["elapsed_seconds"] == 42.5
    # turn_meta sits below the final answer, as in the live stream.
    assert model.items[-1].kind == "turn_meta"


def test_chat_model_load_history_skips_non_display() -> None:
    """Context-only user message, summaries, compression events are not shown."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="user", content="display me", is_display=True),
        _hist_msg(role="user", content="context only", is_display=False),
        _hist_msg(role="assistant", content="summary", is_display=False, is_summary=True),
        _hist_msg(role="assistant", content="real answer"),
    ]
    model = ChatModel()
    model.load_history(messages)
    contents = [(it.kind, it.content) for it in model.items]
    assert contents == [("user_message", "display me"), ("assistant_message", "real answer")]


def test_chat_model_load_history_plan_block() -> None:
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="assistant", content="1. step\n2. step", is_plan=True),
    ]
    model = ChatModel()
    model.load_history(messages)
    assert model.items[0].kind == "plan_ready"
    assert "step" in model.items[0].content


def test_chat_model_load_history_clears_existing() -> None:
    """Loading history replaces any items already in the model (session switch)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    model.add_user_message("old")
    assert len(model.items) == 1

    model.load_history([_hist_msg(role="user", content="new")])
    assert len(model.items) == 1
    assert model.items[0].content == "new"


def test_chat_model_stream_start_resets_dangling_thinking() -> None:
    """stream_start must reset the thinking pointer so a dropped previous turn
    (no thinking_end) does not glue the next turn's reasoning onto the old block."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    # Previous turn's thinking, never closed (socket dropped / stop without event).
    model.handle_ws_event({"type": "thinking_delta", "delta": "first reasoning"})
    assert model._current_thinking is not None
    # New turn starts before thinking_end arrived.
    model.handle_ws_event({"type": "stream_start"})
    assert model._current_thinking is None
    # The new turn's thinking lands in its own block, not appended to the old one.
    model.handle_ws_event({"type": "thinking_delta", "delta": "second reasoning"})
    blocks = [it for it in model.items if it.kind == "thinking_block"]
    assert [b.content for b in blocks] == ["first reasoning", "second reasoning"]


def test_chat_model_cap_trims_oldest_front() -> None:
    """items stays bounded: past the retention cap the oldest (front) are
    dropped so a long autonomous session does not grow without limit (3.P3)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel(cap=5)
    for i in range(10):
        model.add_user_message(f"m{i}")
    assert len(model.items) == 5
    # Front trimmed: the five kept are the most recent.
    assert [it.content for it in model.items] == ["m5", "m6", "m7", "m8", "m9"]


def test_chat_model_cap_none_keeps_everything() -> None:
    """cap=None disables trimming (used by tests / small sessions)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel(cap=None)
    for i in range(20):
        model.add_user_message(f"m{i}")
    assert len(model.items) == 20


def test_chat_model_stream_end_purge_keeps_nonempty_assistant() -> None:
    """A non-empty assistant answer survives stream_end; only the trailing
    empty bubble(s) are purged (tail-trim, 1.P1)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel(cap=None)
    model.handle_ws_event({"type": "stream_delta", "delta": "answer"})
    model.handle_ws_event({"type": "stream_end", "elapsed_seconds": 5})
    assert any(it.kind == "assistant_message" and it.content == "answer" for it in model.items)
    assert model.items[-1].kind == "turn_meta"
    assert model.items[-1].meta["elapsed_seconds"] == 5


def test_chat_model_stream_end_purges_trailing_empty_thinking_and_assistant() -> None:
    """Empty thinking/assistant bubbles at the tail are purged on stream_end
    (tail-trim) instead of a full-history scan (1.P1)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel(cap=None)
    model.handle_ws_event({"type": "thinking_delta", "delta": ""})  # empty thinking block
    model.handle_ws_event({"type": "thinking_end"})  # closes it but leaves empty block in items
    model.handle_ws_event({"type": "stream_end"})
    # The empty thinking block at the tail was purged.
    assert not any(it.kind == "thinking_block" and not it.content for it in model.items)
    assert model.items[-1].kind == "turn_meta"


def test_chat_model_context_compressed_creates_summary_item() -> None:
    """A live context_compressed event surfaces the summary text as a distinct
    chat item (for both forced /compact and mid-turn auto-compress), carrying
    the before/after counts so the renderer can head the block with them."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    item = model.handle_ws_event(
        {
            "type": "context_compressed",
            "summary": "Earlier the user asked to build a TUI client.",
            "messages_before": 42,
            "messages_after": 8,
            "context_tokens": 1200,
            "max_context_tokens": 4096,
        }
    )
    assert item is not None
    assert item.kind == "context_summary"
    assert item.content == "Earlier the user asked to build a TUI client."
    assert item.meta["messages_before"] == 42
    assert item.meta["messages_after"] == 8
    assert model.items[-1] is item


@pytest.mark.anyio
async def test_chat_panel_load_history_renders(monkeypatch) -> None:
    from rich.console import Console

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()

        chat.load_history(
            [
                _hist_msg(role="user", content="hi there"),
                _hist_msg(role="assistant", content="hello back"),
            ]
        )
        await pilot.pause()

        widgets = _item_widgets(chat)
        # Two item widgets mounted, no truncation hint.
        assert len(widgets) == 2
        assert chat._hidden_count == 0
        # Items are mounted in order: user first, then assistant.
        assert [w._item.kind for w in widgets] == ["user_message", "assistant_message"]

        # Each widget carries the rendered bubble for its item; re-rendering the
        # item through the panel's own registry reproduces what's on screen.
        from clients.terminal.tui.widgets.chat_panel import _item_msg

        console = Console(record=True, width=80, force_terminal=True, color_system=None)
        for widget in widgets:
            console.print(chat._registry.render(_item_msg(widget._item)))
        rendered = console.export_text(clear=True)
        assert "hi there" in rendered
        assert "hello back" in rendered


@pytest.mark.anyio
async def test_sync_keeps_scroll_position_when_user_scrolled_up() -> None:
    """Stick-to-bottom: when the user has scrolled up to read earlier messages,
    incoming stream content must not yank the view back to the bottom on every
    sync. Auto-follow resumes only after the user scrolls back to the bottom.
    """
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()

        # Fill the chat so it overflows (vertical scroll range > 0).
        for i in range(30):
            chat.add_user_message(f"line {i} " + "x" * 40)
            chat.handle_ws_event({"type": "stream_start"})
            chat.handle_ws_event({"type": "stream_delta", "delta": "y" * 40})
            chat.handle_ws_event({"type": "stream_end"})
        await pilot.pause()
        assert chat.max_scroll_y > 0
        # Freshly filled: stuck to the bottom.
        assert chat.scroll_y == chat.max_scroll_y

        # User scrolls up to read earlier messages.
        chat.scroll_to(y=0, animate=False)
        await pilot.pause()
        scrolled_y = chat.scroll_y
        assert scrolled_y < chat.max_scroll_y

        # New content arrives while the user is scrolled up: position must be
        # preserved — _sync must not call scroll_end.
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "new " * 20})
        await pilot.pause()
        assert chat.scroll_y == scrolled_y

        # User scrolls back to the bottom: auto-follow resumes on the next sync.
        chat.scroll_end(animate=False)
        await pilot.pause()
        assert chat.scroll_y == chat.max_scroll_y
        chat.handle_ws_event({"type": "stream_delta", "delta": "more " * 20})
        await pilot.pause()
        assert chat.scroll_y == chat.max_scroll_y


# ─── text selection (copy without bubble chrome) ──────────────────────────────


def _rendered_lines_of(widget) -> list[str]:
    """The exact screen lines a _ChatItemView draws (the plain text of the
    selection-aware Content its render() builds, line layout identical to the
    screen), for asserting selection coordinates."""
    return widget._build_content().plain.split("\n")


@pytest.mark.anyio
async def test_get_selection_returns_clean_message_content() -> None:
    """A whole-message selection returns the bubble's text without the rounded
    border or the "You" title — the base Widget.get_selection returns None for
    Panel renderables, so without the override drag-select copies nothing."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("Hello world")
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        text, _ = widget.get_selection(Selection(None, None))
        assert text == "Hello world"
        # No panel chrome leaks into the copy.
        assert "╭" not in text and "│" not in text and "You" not in text


@pytest.mark.anyio
async def test_get_selection_partial_line_aligns_to_content() -> None:
    """A partial-line selection maps the mouse column onto the content column
    (skipping the ``│ `` gutter), so the copied substring is exactly the
    highlighted characters — not offset by the border."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("Hello world")
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        lines = _rendered_lines_of(widget)
        # Layout sanity: top border, one content line, bottom border; content
        # starts two columns in (after ``│ ``).
        assert lines[0].lstrip().startswith("╭")
        assert lines[1].startswith("│ Hello world")
        assert lines[-1].lstrip().startswith("╰")

        # Select columns 2..7 on the content line → "Hello" (H at col 2).
        text, _ = widget.get_selection(Selection(Offset(x=2, y=1), Offset(x=7, y=1)))
        assert text == "Hello"


@pytest.mark.anyio
async def test_get_selection_across_two_content_lines() -> None:
    """A selection spanning two content rows returns both rows' content joined,
    without borders between them."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("alpha\nbeta")
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        lines = _rendered_lines_of(widget)
        content_rows = [ln for ln in lines if ln.startswith("│")]
        assert len(content_rows) >= 2

        text, _ = widget.get_selection(Selection(Offset(x=2, y=1), Offset(x=4, y=2)))
        assert text == "alpha\nbe"


@pytest.mark.anyio
async def test_get_selection_clamps_top_border_to_content_start() -> None:
    """Starting the drag on the title/border line does not pull chrome into the
    copy — it clamps to the start of the first content line."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("Hello world")
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        text, _ = widget.get_selection(Selection(Offset(x=0, y=0), Offset(x=7, y=1)))
        assert text == "Hello"
        assert "You" not in text


@pytest.mark.anyio
async def test_get_selection_plain_text_widget_has_no_chrome() -> None:
    """Non-panel items (status line, turn_meta) render plain Text — selection
    returns the text verbatim with no column shift."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.handle_ws_event({"type": "status", "content": "working"})
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        assert widget._item.kind == "status"
        lines = _rendered_lines_of(widget)
        assert lines == ["• working"]
        text, _ = widget.get_selection(Selection(None, None))
        assert text == "• working"


@pytest.mark.anyio
async def test_get_selection_filesystem_tool_output_is_selectable() -> None:
    """Filesystem tool output renders a plain Group/Text (no outer panel), so a
    selection over the result returns the content — the resume "can't copy tool
    output" symptom alongside the missing highlights."""
    from textual.geometry import Offset
    from textual.selection import Selection

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.handle_ws_event(
            {
                "type": "tool_call",
                "tool": "filesystem",
                "args": {"action": "read", "path": "/a.py"},
                "result": "1: line one\n2: line two",
                "success": True,
                "metadata": {},
            }
        )
        await pilot.pause()

        widget = list(chat._widgets.values())[0]
        assert widget._item.kind == "tool_call"
        text, _ = widget.get_selection(Selection(None, None))
        assert "line one" in text
        assert "line two" in text
        # Plain renderable — no panel borders anywhere.
        assert "│" not in text and "╭" not in text


@pytest.mark.anyio
async def test_screen_copy_text_strips_chrome_across_messages() -> None:
    """End-to-end: Screen.get_selected_text (what Ctrl+C copies) walks every
    selected widget's get_selection, so selecting across a user bubble and an
    assistant bubble yields both bodies joined — no borders, no titles."""
    from textual.geometry import Offset

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("question")
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "answer"})
        chat.handle_ws_event({"type": "stream_end", "content": "answer"})
        await pilot.pause()

        screen = pilot.app.screen
        screen.text_select_all()
        await pilot.pause()
        text = screen.get_selected_text()
        assert "question" in text
        assert "answer" in text
        assert "╭" not in text and "│" not in text


# ─── reading mode (borderless render for raw-terminal copy) ──────────────────


@pytest.mark.anyio
async def test_reading_mode_strips_bubble_borders_in_place() -> None:
    """set_reading_mode(True) re-renders every visible item without Panel chrome
    (and back with it on False), in place — so a raw Shift+drag + Ctrl+Shift+C in
    a terminal without OSC 52 copies clean text. Markdown answers keep raw
    fences (no code-block Panels)."""
    async with NaviCodeTui(new_session=True).run_test(size=(100, 24)) as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.add_user_message("Hello world")
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "Answer with ```py\nx=1\n``` block"})
        chat.handle_ws_event({"type": "stream_end", "content": "x"})
        await pilot.pause()

        widgets = list(chat._widgets.values())
        w_user = next(w for w in widgets if w._item.kind == "user_message")
        w_ans = next(w for w in widgets if w._item.kind == "assistant_message")

        # Normal mode: panels with borders.
        assert "╭" in w_user._build_content().plain
        assert "╭" in w_ans._build_content().plain

        # Enter reading mode: borders gone, content intact, scroll preserved.
        scroll_before = chat.scroll_y
        chat.set_reading_mode(True)
        await pilot.pause()
        user_plain = w_user._build_content().plain
        ans_plain = w_ans._build_content().plain
        assert "╭" not in user_plain and "│" not in user_plain
        assert "Hello world" in user_plain
        # Raw markdown fences preserved (no code-block Panel borders).
        assert "```py" in ans_plain and "╭" not in ans_plain
        assert chat.scroll_y == scroll_before

        # Exit reading mode: borders return.
        chat.set_reading_mode(False)
        await pilot.pause()
        assert "╭" in w_user._build_content().plain
        assert "╭" in w_ans._build_content().plain


@pytest.mark.anyio
async def test_new_items_in_reading_mode_are_borderless() -> None:
    """Items streamed in while reading mode is on mount without bubble borders."""
    async with NaviCodeTui(new_session=True).run_test(size=(100, 24)) as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.clear()
        chat.set_reading_mode(True)
        await pilot.pause()
        chat.add_user_message("late message")
        await pilot.pause()
        w = list(chat._widgets.values())[0]
        plain = w._build_content().plain
        assert "╭" not in plain and "late message" in plain


@pytest.mark.anyio
async def test_stream_delta_throttle_coalesces_rebuilds() -> None:
    """A rapid burst of stream deltas rebuilds the assistant bubble a couple of
    times (throttle coalesces), not once per token — the O(N²) markdown-per-delta
    cost is gone. The ChatItem content still accumulates every token."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        calls = _wrap_render(chat)

        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})  # mount
        await pilot.pause()  # mount flush
        baseline = len(calls)

        # 20 rapid deltas with no pause between — coalesced by the throttle.
        for _ in range(20):
            chat.handle_ws_event({"type": "stream_delta", "delta": "x"})
        mid = len(calls)
        await pilot.pause(0.2)  # let the throttle window fire
        after = len(calls)

        # At most the first mutating delta rebuilt immediately; the rest are
        # coalesced into one rebuild after the window — far fewer than 20.
        assert mid - baseline <= 1
        assert after - baseline <= 2

        # The ChatItem content got every token regardless of throttle.
        assistant = next(w for w in chat._widgets.values() if w._item.kind == "assistant_message")
        assert assistant._item.content == "a" + "x" * 20


@pytest.mark.anyio
async def test_stream_end_flushes_pending_throttled_rebuild() -> None:
    """stream_end flushes a pending throttled rebuild so the final chunk of
    streamed text appears immediately, not after the 150 ms window."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        calls = _wrap_render(chat)

        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})  # mount
        await pilot.pause()  # mount flush
        baseline = len(calls)

        # "b" rebuilds immediately (first mutating delta); "c" is throttled
        # (pending, window not elapsed). Then stream_end flushes — without a
        # pause for the throttle timer to fire.
        chat.handle_ws_event({"type": "stream_delta", "delta": "b"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "c"})
        chat.handle_ws_event({"type": "stream_end"})

        # The flush forced a rebuild of the final "abc" content despite the
        # throttle window not elapsing.
        assistant_renders = [
            c for c in calls[baseline:] if c.get("type") == "assistant_message"
        ]
        assert any(c.get("content") == "abc" for c in assistant_renders)
        assistant = next(w for w in chat._widgets.values() if w._item.kind == "assistant_message")
        assert assistant._item.content == "abc"
