"""Smoke tests for the Navi Code TUI."""

from __future__ import annotations

from pathlib import Path

import pytest

from clients.terminal.tui.events import WsEvent
from clients.terminal.tui.tui_app import NaviCodeTui


@pytest.fixture(autouse=True)
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Override the state dir so tests never touch ~/.navi_code."""
    from clients.terminal import config

    original = config.settings.state_dir
    config.settings.state_dir = tmp_path
    import clients.terminal.tui.settings as settings_module

    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:
    """Mock the backend REST api so TUI tests never hit a live server.

    Every test mounts NaviCodeTui, whose _startup worker calls api.create_session /
    api.get_session and whose _refresh_sessions worker calls api.list_sessions.
    Without a server these raise and _ctx.session_id stays None, which breaks any
    test that asserts on the session. Tests that assert on stop_session still
    monkeypatch it per-test with their own call-tracking list.
    """
    import clients.terminal.api as api_module

    def fake_create_session(profile_id: str | None = None) -> dict:
        return {"session_id": "test-session", "profile_id": profile_id or "navi_code"}

    def fake_get_session(session_id: str) -> dict:
        return {"session_id": session_id, "profile_id": "navi_code"}

    def fake_list_sessions() -> list[dict]:
        return []

    def fake_get_profile_model(profile_id: str) -> str | None:
        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)


@pytest.mark.anyio
async def test_tui_mounts_widgets() -> None:
    """The TUI app mounts chat, status, and input widgets."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        assert pilot.app.query_one("ChatPanel") is not None
        assert pilot.app.query_one("StatusPanel") is not None
        assert pilot.app.query_one("SessionsPanel") is not None
        assert pilot.app.query_one("InputBox") is not None


@pytest.mark.anyio
async def test_user_message_appears_in_chat() -> None:
    """Typing a message and submitting adds it to the chat panel."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        input_box = pilot.app.query_one("InputBox")
        input_box._input.text = "hello"
        await pilot.press("enter")
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        assert any(
            item.kind == "user_message" and item.content == "hello" for item in chat._model.items
        )


@pytest.mark.anyio
async def test_input_text_is_rendered_with_visible_color() -> None:
    """Typed text in the input field is rendered with a visible foreground color."""
    async with NaviCodeTui(new_session=True).run_test(size=(80, 24)) as pilot:
        await pilot.pause()
        input_box = pilot.app.query_one("InputBox")
        input_box._input.focus()
        await pilot.press("h", "i", " ", "n", "a", "v", "i")
        await pilot.pause()
        assert input_box._input.text == "hi navi"
        strip = input_box._input.render_line(0)
        segments = list(strip)
        rendered_text = "".join(seg.text for seg in segments)
        assert "hi navi" in rendered_text
        # Ensure foreground and background are not identical so text is visible.
        for seg in segments:
            if "hi navi" in seg.text and seg.style:
                assert seg.style.color != seg.style.bgcolor


@pytest.mark.anyio
async def test_enter_submits_multiline_text() -> None:
    """Enter submits the full multi-line buffer (newlines preserved)."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        input_box = pilot.app.query_one("InputBox")
        input_box._input.text = "line one\nline two"
        input_box._input.focus()
        await pilot.press("enter")
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        submitted = [
            item.content
            for item in chat._model.items
            if item.kind == "user_message"
        ]
        assert submitted == ["line one\nline two"]
        # Input is cleared after submit.
        assert input_box._input.text == ""


@pytest.mark.anyio
async def test_input_grows_with_multiline_content() -> None:
    """The input field height grows with content (multi-line), up to a cap."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        input_box = pilot.app.query_one("InputBox")
        single = input_box._input.region.height
        input_box._input.text = "a\nb\nc\nd\ne\nf"
        await pilot.pause()
        grown = input_box._input.region.height
        assert grown > single


@pytest.mark.anyio
async def test_ws_event_renders_in_chat() -> None:
    """A synthetic WebSocket stream_delta is added to the assistant response."""
    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": "hi"})
        await pilot.pause()
        assert chat._model._current_assistant is not None
        assert chat._model._current_assistant.content == "hi"


@pytest.mark.anyio
async def test_stream_start_does_not_create_empty_assistant_item() -> None:
    """stream_start must not eagerly add an empty assistant bubble.

    The answer bubble is created lazily on the first stream_delta so it lands at
    the position where text arrives, not stranded at the top of the turn.
    """
    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"})
        await pilot.pause()
        assert chat._model._current_assistant is None
        assert not any(item.kind == "assistant_message" for item in chat._model.items)


@pytest.mark.anyio
async def test_final_answer_appears_after_tools() -> None:
    """The final assistant answer must render BELOW the tool cards, not above them.

    Regression: stream_start used to create the assistant bubble at the top, so
    the final answer (accumulated there) was scrolled out of view by scroll_end
    while tools/thinking below stayed visible. Now each text segment opens its
    own bubble at the point it arrives, so the final answer lands at the bottom.
    """
    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": "thinking_delta", "delta": "hmm"})
        chat.handle_ws_event({"type": "thinking_end"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "I'll list X. "})
        chat.handle_ws_event({"type": "tool_started", "tool": "filesystem", "args": {}})
        chat.handle_ws_event(
            {"type": "tool_call", "tool": "filesystem", "args": {}, "result": "a\nb", "success": True}
        )
        chat.handle_ws_event({"type": "thinking_delta", "delta": "now answer"})
        chat.handle_ws_event({"type": "thinking_end"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "X contains a and b."})
        chat.handle_ws_event({"type": "stream_end", "content": "X contains a and b."})
        await pilot.pause()

        items = chat._model.items
        # The final answer is the last assistant_message (a turn_meta duration
        # line may follow it, which is expected and fine).
        last_answer = max(i for i, it in enumerate(items) if it.kind == "assistant_message")
        assert items[last_answer].content == "X contains a and b."
        # It must come after the last tool_call.
        last_tool = max(i for i, it in enumerate(items) if it.kind == "tool_call")
        assert last_answer > last_tool
        # No empty assistant bubbles.
        assert all(it.content for it in items if it.kind == "assistant_message")


@pytest.mark.anyio
async def test_empty_assistant_item_purged_on_stream_end() -> None:
    """stream_end drops assistant/thinking bubbles that never received any text."""
    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"})
        # An empty delta creates a bubble with no content.
        chat.handle_ws_event({"type": "stream_delta", "delta": ""})
        chat.handle_ws_event({"type": "stream_end", "content": ""})
        await pilot.pause()
        assert not any(
            item.kind in ("assistant_message", "thinking_block") and not item.content
            for item in chat._model.items
        )


@pytest.mark.anyio
async def test_planning_status_rolls_in_place() -> None:
    """Successive non-subagent planning_status events update one line, not stack."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False})
        chat.handle_ws_event({"type": "planning_status", "phase": 2, "label": "Execution plan", "is_subagent": False})
        chat.handle_ws_event({"type": "planning_status", "phase": 3, "label": "Plan review", "is_subagent": False})
        await pilot.pause()
        planning = [it for it in chat._model.items if it.kind == "planning_status"]
        assert len(planning) == 1
        assert planning[0].content == "Plan review"
        assert planning[0].meta["phase"] == 3


@pytest.mark.anyio
async def test_plan_ready_consumes_planning_indicator_and_adds_card() -> None:
    """plan_ready removes the pending planning line and appends a plan card."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False})
        chat.handle_ws_event(
            {"type": "plan_ready", "plan": "## Plan\n**Steps:**\n1. Do thing\n", "is_subagent": False}
        )
        await pilot.pause()
        items = chat._model.items
        assert not any(it.kind == "planning_status" for it in items)
        plans = [it for it in items if it.kind == "plan_ready"]
        assert len(plans) == 1
        assert "## Plan" in plans[0].content
        assert items[-1].kind == "plan_ready"


@pytest.mark.anyio
async def test_subagent_planning_appended_separately() -> None:
    """Subagent planning does not merge into the parent's rolling indicator."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False})
        chat.handle_ws_event({"type": "planning_status", "phase": 1, "label": "sub plan", "is_subagent": True})
        await pilot.pause()
        planning = [it for it in chat._model.items if it.kind == "planning_status"]
        assert len(planning) == 2
        assert planning[0].content == "Analysis"
        assert planning[0].meta["is_subagent"] is False
        assert planning[1].content == "sub plan"
        assert planning[1].meta["is_subagent"] is True


@pytest.mark.anyio
async def test_turn_thinking_creates_subagent_thinking_block() -> None:
    """turn_thinking events become thinking_block items flagged is_subagent."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.handle_ws_event(
            {"type": "turn_thinking", "thinking": "let me consider…", "is_subagent": True}
        )
        await pilot.pause()
        blocks = [it for it in chat._model.items if it.kind == "thinking_block"]
        assert len(blocks) == 1
        assert blocks[0].content == "let me consider…"
        assert blocks[0].meta["is_subagent"] is True


@pytest.mark.anyio
async def test_unknown_slash_command_shows_error() -> None:
    """Unknown slash command produces an error item in chat."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        input_box = pilot.app.query_one("InputBox")
        input_box._input.text = "/notacommand"
        await pilot.press("enter")
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        assert any(item.kind == "error" for item in chat._model.items)


@pytest.mark.anyio
async def test_chat_panel_clear_resets_conversation() -> None:
    """ChatPanel.clear() removes all items and current assistant state."""
    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": "hi"})
        await pilot.pause()
        assert chat._model.items
        chat.clear()
        assert not chat._model.items
        assert chat._model._current_assistant is None


@pytest.mark.anyio
async def test_status_panel_shows_backend_and_theme() -> None:
    """Status panel displays backend URL and current theme."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        status = pilot.app.query_one("StatusPanel")
        backend_text = str(status._backend.render())
        theme_text = str(status._theme.render())
        assert "Backend:" in backend_text
        assert "Theme: gnexus-dark" in theme_text


@pytest.mark.anyio
async def test_attach_shows_configured_model() -> None:
    """On attach the status panel shows the profile's configured model (pre-request)."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        status = pilot.app.query_one("StatusPanel")
        model_text = str(status._model.render())
        assert "configured-model" in model_text


@pytest.mark.anyio
async def test_model_info_updates_status_panel() -> None:
    """A model_info WS event updates the status panel to the resolved model."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        app = pilot.app
        app.on_ws_event(WsEvent({"type": "model_info", "model": "resolved-by-backend"}))
        await pilot.pause()
        status = app.query_one("StatusPanel")
        model_text = str(status._model.render())
        assert "resolved-by-backend" in model_text


@pytest.mark.anyio
async def test_model_info_not_forwarded_to_chat() -> None:
    """model_info is a status update, not a chat item."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        app = pilot.app
        app.on_ws_event(WsEvent({"type": "model_info", "model": "some-model"}))
        await pilot.pause()
        chat = app.query_one("ChatPanel")
        assert not any(
            getattr(item, "kind", None) == "model_info" for item in chat._model.items
        )


@pytest.mark.anyio
async def test_streaming_state_tracks_ws_events() -> None:
    """stream_start enters streaming mode; stream_end/stream_stopped/error leave it."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        app = pilot.app
        input_box = app.query_one("InputBox")

        app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert app._streaming is True
        assert "Esc to stop" in input_box._input.placeholder

        app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "hi"}))
        await pilot.pause()
        assert app._streaming is True

        app.on_ws_event(WsEvent({"type": "stream_stopped"}))
        await pilot.pause()
        assert app._streaming is False
        assert input_box._input.placeholder == "Ask anything..."


@pytest.mark.anyio
async def test_escape_stops_active_stream(monkeypatch: pytest.MonkeyPatch) -> None:
    """Pressing Esc while streaming calls api.stop_session for the current session."""
    stopped: list[str] = []

    async def fake_stop_session(session_id: str) -> dict:
        stopped.append(session_id)
        return {"ok": True}

    import clients.terminal.tui.tui_app as tui_app_module

    monkeypatch.setattr(tui_app_module.api, "stop_session", fake_stop_session)

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        app = pilot.app
        # Simulate an active run for the resolved session.
        app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert app._streaming is True
        current_session = app._ctx.session_id
        assert current_session

        await pilot.press("escape")
        await pilot.pause(0.1)
        assert stopped == [current_session]


@pytest.mark.anyio
async def test_stream_stopped_renders_status() -> None:
    """stream_stopped resets the assistant buffer and shows a status message."""
    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": "partial"})
        chat.handle_ws_event({"type": "stream_stopped"})
        await pilot.pause()
        assert chat._model._current_assistant is None
        assert any(
            item.kind == "status" and "stopped" in item.content.lower()
            for item in chat._model.items
        )


@pytest.mark.anyio
async def test_escape_does_nothing_when_not_streaming(monkeypatch: pytest.MonkeyPatch) -> None:
    """Esc without an active stream does not call stop_session."""
    calls: list[str] = []

    async def fake_stop_session(session_id: str) -> dict:
        calls.append(session_id)
        return {"ok": True}

    import clients.terminal.tui.tui_app as tui_app_module

    monkeypatch.setattr(tui_app_module.api, "stop_session", fake_stop_session)

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        await pilot.press("escape")
        await pilot.pause(0.1)
        assert calls == []


@pytest.mark.anyio
async def test_resolve_session_with_explicit_id_resumes_it(monkeypatch: pytest.MonkeyPatch) -> None:
    """An explicit session_id (--resume) resumes that exact session."""
    import clients.terminal.api as api_module

    seen: list[str] = []
    monkeypatch.setattr(api_module, "get_session", lambda sid: seen.append(sid) or {
        "session_id": sid,
        "profile_id": "navi_code",
    })

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        # _startup already attached a session (calling get_session once), so
        # snapshot the recorder baseline before our explicit resume call.
        baseline = len(seen)
        result = await pilot.app._resolve_session("sess-explicit", None, False)
        assert result == "sess-explicit"
        assert seen[baseline:] == ["sess-explicit"]


@pytest.mark.anyio
async def test_resolve_session_strict_on_bad_explicit_id(monkeypatch: pytest.MonkeyPatch) -> None:
    """A bad --resume id surfaces an error and does NOT silently create a new session."""
    import clients.terminal.api as api_module

    def boom(_sid: str) -> dict:
        raise RuntimeError("session not found")

    created: list[str] = []
    monkeypatch.setattr(api_module, "get_session", boom)
    monkeypatch.setattr(api_module, "create_session", lambda *_a, **_k: created.append("x") or {
        "session_id": "NEW",
        "profile_id": "navi_code",
    })

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        # _startup (force_new=True) already created a session; snapshot so we
        # only assert about what our bad-resume call does.
        created_before = len(created)
        chat = pilot.app.query_one("ChatPanel")
        before_errors = sum(1 for it in chat._model.items if it.kind == "error")
        result = await pilot.app._resolve_session("sess-bad", None, False)
        assert result is None
        # No new session was created behind the user's back.
        assert len(created) == created_before
        # An error was surfaced to the chat panel.
        after_errors = sum(1 for it in chat._model.items if it.kind == "error")
        assert after_errors == before_errors + 1


@pytest.mark.anyio
async def test_activity_indicator_runs_during_turn() -> None:
    """stream_start starts the spinner; stream_end stops it."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        status_bar = pilot.app.query_one("StatusBar")
        activity = status_bar._activity

        assert activity._active is False

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert activity._active is True
        assert activity._label == "thinking"

        pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": ""}))
        await pilot.pause()
        assert activity._active is False
        assert activity._label == ""


@pytest.mark.anyio
async def test_activity_indicator_phase_label_reflects_events() -> None:
    """The spinner label tracks the current stage as WS events arrive."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        status_bar = pilot.app.query_one("StatusBar")
        activity = status_bar._activity

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert activity._label == "thinking"

        pilot.app.on_ws_event(WsEvent({"type": "planning_status", "phase": 1, "label": "Analysis", "is_subagent": False}))
        await pilot.pause()
        assert activity._label == "planning"

        pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "hi"}))
        await pilot.pause()
        assert activity._label == "responding"

        pilot.app.on_ws_event(WsEvent({"type": "tool_started", "tool": "filesystem", "args": {}}))
        await pilot.pause()
        assert activity._label == "running filesystem"


@pytest.mark.anyio
async def test_activity_indicator_stops_on_disconnect() -> None:
    """A dropped connection stops the spinner (no stream_end will arrive)."""
    from clients.terminal.tui.events import ConnectionStatusChanged

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        status_bar = pilot.app.query_one("StatusBar")
        activity = status_bar._activity

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert activity._active is True

        pilot.app.post_message(ConnectionStatusChanged(connected=False, detail=""))
        await pilot.pause()
        assert activity._active is False


@pytest.mark.anyio
async def test_attach_session_loads_history(monkeypatch: pytest.MonkeyPatch) -> None:
    """Resuming/attaching a session replays its stored messages into the chat."""
    import clients.terminal.api as api_module
    import clients.terminal.tui.tui_app as tui_app_module

    class FakeBridge:
        def __init__(self, app, session_id, cwd=None) -> None:
            self.client = None

        async def start(self) -> None:
            pass

        async def stop(self) -> None:
            pass

    monkeypatch.setattr(tui_app_module, "WsBridge", FakeBridge)

    messages = [
        {"role": "user", "content": "earlier question", "is_display": True},
        {"role": "assistant", "content": "earlier answer", "is_display": True},
        {"role": "user", "content": "context-only", "is_display": False},
    ]
    monkeypatch.setattr(api_module, "get_session", lambda sid: {
        "session_id": sid,
        "profile_id": "navi_code",
        "messages": messages,
    })

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        await pilot.app.attach_session("sess-resume")
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        contents = [it.content for it in chat._model.items]
        assert "earlier question" in contents
        assert "earlier answer" in contents
        # The context-only message (is_display=False) was skipped.
        assert "context-only" not in contents


@pytest.mark.anyio
async def test_stream_end_updates_context_fill() -> None:
    """A stream_end event carrying context_tokens/max_context_tokens seeds the gauge."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        # No data yet → placeholder.
        assert ctx._used is None
        pilot.app.on_ws_event(WsEvent({
            "type": "stream_end", "content": "",
            "context_tokens": 12000,
            "max_context_tokens": 32000,
        }))
        await pilot.pause()
        assert ctx._used == 12000
        assert ctx._max == 32000
        # 12000/32000 = 37.5%, which rounds to 38% (banker's rounding to even).
        assert "38%" in str(ctx.render())


@pytest.mark.anyio
async def test_context_compressed_updates_fill() -> None:
    """context_compressed reports the post-compression token count."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        pilot.app.on_ws_event(WsEvent({
            "type": "stream_end", "content": "",
            "context_tokens": 30000,
            "max_context_tokens": 32000,
        }))
        await pilot.pause()
        assert ctx._used == 30000

        pilot.app.on_ws_event(WsEvent({
            "type": "context_compressed",
            "context_tokens": 8000,
            "max_context_tokens": 32000,
        }))
        await pilot.pause()
        assert ctx._used == 8000


@pytest.mark.anyio
async def test_stream_stopped_does_not_touch_context() -> None:
    """stream_stopped carries no context fields; the gauge keeps its last value."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        pilot.app.on_ws_event(WsEvent({
            "type": "stream_end", "content": "",
            "context_tokens": 5000,
            "max_context_tokens": 32000,
        }))
        await pilot.pause()
        assert ctx._used == 5000

        pilot.app.on_ws_event(WsEvent({"type": "stream_stopped"}))
        await pilot.pause()
        # Unchanged — stream_stopped has no token fields.
        assert ctx._used == 5000


@pytest.mark.anyio
async def test_context_fill_keeps_last_on_none() -> None:
    """A context report missing token fields keeps the last known value."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        pilot.app.on_ws_event(WsEvent({
            "type": "stream_end", "content": "",
            "context_tokens": 10000,
            "max_context_tokens": 32000,
        }))
        await pilot.pause()
        pilot.app.on_ws_event(WsEvent({
            "type": "stream_end", "content": "",
            "context_tokens": None,
            "max_context_tokens": None,
        }))
        await pilot.pause()
        assert ctx._used == 10000
        assert ctx._max == 32000


@pytest.mark.anyio
async def test_context_fill_color_thresholds() -> None:
    """The gauge color escalates as the window fills (dim → warning → error)."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        from clients.terminal.tui.themes import get_active_theme
        t = get_active_theme()

        # Under 70% → dim.
        assert ctx._color_for(50) == t.text_dim.hex
        # 70–89% → warning.
        assert ctx._color_for(75) == t.warning.hex
        # 90%+ → error.
        assert ctx._color_for(95) == t.error.hex


@pytest.mark.anyio
async def test_context_fill_k_notation() -> None:
    """Token counts render in compact k/M notation."""
    from clients.terminal.tui.widgets.status_bar import _humanize
    assert _humanize(500) == "500"
    assert _humanize(12000) == "12.0k"
    assert _humanize(1_500_000) == "1.50M"


@pytest.mark.anyio
async def test_attach_session_seeds_context_fill(monkeypatch: pytest.MonkeyPatch) -> None:
    """On resume the gauge is seeded from get_session so it isn't blank pre-turn."""
    import clients.terminal.api as api_module
    import clients.terminal.tui.tui_app as tui_app_module

    class FakeBridge:
        def __init__(self, app, session_id, cwd=None) -> None:
            self.client = None

        async def start(self) -> None:
            pass

        async def stop(self) -> None:
            pass

    monkeypatch.setattr(tui_app_module, "WsBridge", FakeBridge)
    monkeypatch.setattr(api_module, "get_session", lambda sid: {
        "session_id": sid,
        "profile_id": "navi_code",
        "messages": [],
        "context_token_count": 9000,
        "max_context_tokens": 32000,
    })

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        ctx = pilot.app.query_one("StatusBar")._ctx_fill
        # _startup already ran attach_session (against the monkeypatched
        # get_session), so the gauge is seeded — not blank — before any turn.
        assert ctx._used == 9000
        assert ctx._max == 32000
        assert "28%" in str(ctx.render())

        # An explicit re-attach re-seeds from the same session response.
        await pilot.app.attach_session("sess-resume")
        await pilot.pause()
        assert ctx._used == 9000
        assert "28%" in str(ctx.render())


# ─── elapsed timer + turn metadata ──────────────────────────────────────────


@pytest.mark.anyio
async def test_elapsed_timer_starts_on_stream_start() -> None:
    """stream_start starts the elapsed timer (it is active and showing 0s)."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        elapsed = pilot.app.query_one("StatusBar")._elapsed
        assert elapsed._active is False

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert elapsed._active is True
        assert elapsed._start_ts is not None
        assert "0s" in str(elapsed.render())


@pytest.mark.anyio
async def test_elapsed_timer_stops_on_stream_end_synced_to_backend() -> None:
    """stream_end freezes the timer at the backend's authoritative elapsed."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        elapsed = pilot.app.query_one("StatusBar")._elapsed

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": "", "elapsed_seconds": 136}))
        await pilot.pause()
        assert elapsed._active is False
        assert "2m 16s" in str(elapsed.render())


@pytest.mark.anyio
async def test_elapsed_timer_freezes_on_stream_stopped_without_backend() -> None:
    """stream_stopped has no elapsed_seconds; the timer freezes at its local value."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        elapsed = pilot.app.query_one("StatusBar")._elapsed

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert elapsed._active is True
        pilot.app.on_ws_event(WsEvent({"type": "stream_stopped"}))
        await pilot.pause()
        assert elapsed._active is False
        # Frozen at some duration (locally measured, near zero) — still a value.
        assert str(elapsed.render()) != ""


@pytest.mark.anyio
async def test_stream_end_writes_turn_meta_into_chat() -> None:
    """stream_end appends a dim ⏱ duration line to the chat for the turn."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "done"}))
        pilot.app.on_ws_event(WsEvent({"type": "stream_end", "content": "done", "elapsed_seconds": 136}))
        await pilot.pause()

        metas = [it for it in chat._model.items if it.kind == "turn_meta"]
        assert len(metas) == 1
        assert metas[0].meta["elapsed_seconds"] == 136
        # The metadata renders below the assistant answer.
        assert chat._model.items[-1].kind == "turn_meta"


@pytest.mark.anyio
async def test_stream_stopped_does_not_write_turn_meta_into_chat() -> None:
    """An interrupted turn does not get a duration metadata line."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        pilot.app.on_ws_event(WsEvent({"type": "stream_delta", "delta": "partial"}))
        pilot.app.on_ws_event(WsEvent({"type": "stream_stopped"}))
        await pilot.pause()

        assert not any(it.kind == "turn_meta" for it in chat._model.items)


@pytest.mark.anyio
async def test_disconnect_stops_elapsed_timer() -> None:
    """A dropped connection stops the elapsed timer (no stream_end will arrive)."""
    from clients.terminal.tui.events import ConnectionStatusChanged

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        elapsed = pilot.app.query_one("StatusBar")._elapsed

        pilot.app.on_ws_event(WsEvent({"type": "stream_start"}))
        await pilot.pause()
        assert elapsed._active is True

        pilot.app.post_message(ConnectionStatusChanged(connected=False, detail=""))
        await pilot.pause()
        assert elapsed._active is False
