import json
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock, patch

import pytest

import ccgram.handlers.commands.menu_sync as cmd_orch_mod
from ccgram.handlers.text.text_handler import text_handler
from ccgram.handlers.recovery.recovery_banner import (
    _recovery_help_text,
    build_recovery_keyboard,
)
from ccgram.handlers.recovery.recovery_callbacks import handle_recovery_callback
from ccgram.handlers.recovery.resume_picker import _SessionEntry, scan_sessions_for_cwd
from ccgram.handlers.callback_data import (
    CB_RECOVERY_BACK,
    CB_RECOVERY_BROWSE,
    CB_RECOVERY_CANCEL,
    CB_RECOVERY_CONTINUE,
    CB_RECOVERY_FRESH,
    CB_RECOVERY_PICK,
    CB_RECOVERY_RESUME,
)
from ccgram.handlers.user_state import (
    PENDING_THREAD_ID,
    PENDING_THREAD_TEXT,
    RECOVERY_SESSIONS,
    RECOVERY_WINDOW_ID,
)

# After Round 5 split: _handle_fresh/continue/resume/back/cancel/browse,
# _create_and_bind_window, build_recovery_keyboard, _recovery_help_text,
# scan_sessions_for_cwd (re-imported here), session_manager, thread_router,
# tmux_manager, etc. all live in recovery_banner. Keeping the constant name
# minimises diff against pre-split tests.
_RC = "ccgram.handlers.recovery.recovery_banner"
_RP = "ccgram.handlers.recovery.resume_picker"


@pytest.fixture(autouse=True)
def _patch_recovery_session_manager():
    """Mock session_manager writes (set_window_*) so tests don't hit real state."""
    with patch(f"{_RC}.session_manager"):
        yield


def _make_update(
    *,
    chat_id: int = -100999,
    user_id: int = 100,
    thread_id: int = 42,
    text: str = "hello",
) -> MagicMock:
    update = MagicMock()
    update.effective_user = MagicMock(id=user_id)
    update.effective_chat = MagicMock(id=chat_id)
    msg = MagicMock()
    msg.text = text
    msg.message_thread_id = thread_id
    msg.chat.type = "supergroup"
    msg.chat.is_forum = True
    msg.is_topic_message = True
    update.message = msg
    update.callback_query = None
    return update


def _make_callback_update(
    *,
    chat_id: int = -100999,
    user_id: int = 100,
    thread_id: int = 42,
    data: str = "",
) -> MagicMock:
    update = MagicMock()
    update.effective_user = MagicMock(id=user_id)
    update.effective_chat = MagicMock(id=chat_id)
    query = AsyncMock()
    query.data = data
    query.message = MagicMock()
    query.message.chat.type = "supergroup"
    query.message.chat.id = chat_id
    query.message.message_thread_id = thread_id
    query.message.chat.is_forum = True
    query.message.is_topic_message = True
    update.callback_query = query
    update.message = None
    return update


def _make_context(user_data: dict | None = None) -> MagicMock:
    ctx = MagicMock()
    ctx.user_data = user_data if user_data is not None else {}
    ctx.bot = AsyncMock()
    return ctx


def _recovery_user_data(
    thread_id: int = 42,
    text: str = "hello",
    window_id: str = "@0",
) -> dict:
    return {
        PENDING_THREAD_ID: thread_id,
        PENDING_THREAD_TEXT: text,
        RECOVERY_WINDOW_ID: window_id,
    }


class TestBuildRecoveryKeyboard:
    def test_has_three_action_buttons(self) -> None:
        kb = build_recovery_keyboard("@0")
        action_row = kb.inline_keyboard[0]
        assert len(action_row) == 3

    def test_has_cancel_button(self) -> None:
        kb = build_recovery_keyboard("@0")
        cancel_row = kb.inline_keyboard[1]
        assert len(cancel_row) == 1
        assert cancel_row[0].callback_data == CB_RECOVERY_CANCEL

    def test_fresh_callback_data(self) -> None:
        kb = build_recovery_keyboard("@5")
        data = kb.inline_keyboard[0][0].callback_data
        assert data == f"{CB_RECOVERY_FRESH}@5"

    def test_continue_callback_data(self) -> None:
        kb = build_recovery_keyboard("@5")
        data = kb.inline_keyboard[0][1].callback_data
        assert data == f"{CB_RECOVERY_CONTINUE}@5"

    def test_resume_callback_data(self) -> None:
        kb = build_recovery_keyboard("@5")
        data = kb.inline_keyboard[0][2].callback_data
        assert data == f"{CB_RECOVERY_RESUME}@5"

    def test_callback_data_truncated_to_64_bytes(self) -> None:
        long_id = "@" + "x" * 60
        kb = build_recovery_keyboard(long_id)
        for row in kb.inline_keyboard:
            for btn in row:
                assert isinstance(btn.callback_data, str)
                assert len(btn.callback_data) <= 64

    def test_hides_continue_when_unsupported(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = False
            caps.supports_resume = True
            kb = build_recovery_keyboard("@0")

        action_row = kb.inline_keyboard[0]
        datas: list[str] = [
            b.callback_data
            for b in action_row
            if isinstance(b.callback_data, str)  # type: ignore[misc]
        ]
        assert any(d.startswith(CB_RECOVERY_FRESH) for d in datas)
        assert not any(d.startswith(CB_RECOVERY_CONTINUE) for d in datas)
        assert any(d.startswith(CB_RECOVERY_RESUME) for d in datas)

    def test_hides_resume_when_unsupported(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = True
            caps.supports_resume = False
            kb = build_recovery_keyboard("@0")

        action_row = kb.inline_keyboard[0]
        datas: list[str] = [
            b.callback_data
            for b in action_row
            if isinstance(b.callback_data, str)  # type: ignore[misc]
        ]
        assert any(d.startswith(CB_RECOVERY_FRESH) for d in datas)
        assert any(d.startswith(CB_RECOVERY_CONTINUE) for d in datas)
        assert not any(d.startswith(CB_RECOVERY_RESUME) for d in datas)

    def test_fresh_only_when_no_continue_or_resume(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = False
            caps.supports_resume = False
            kb = build_recovery_keyboard("@0")

        action_row = kb.inline_keyboard[0]
        assert len(action_row) == 1
        cb = action_row[0].callback_data
        assert isinstance(cb, str)
        assert cb.startswith(CB_RECOVERY_FRESH)  # type: ignore[union-attr]

    def test_uses_per_window_provider(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = True
            caps.supports_resume = True
            build_recovery_keyboard("@7")

        mock_gpw.assert_called_once_with("@7", provider_name=None)


class TestRecoveryHelpText:
    def test_full_capabilities_lists_all_actions(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = True
            caps.supports_resume = True
            text = _recovery_help_text("@0")

        assert "Start fresh" in text
        assert "Continue last session" in text
        assert "Resume from list" in text
        assert " · " in text

    def test_omits_continue_when_unsupported(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = False
            caps.supports_resume = True
            text = _recovery_help_text("@0")

        assert "Continue" not in text
        assert "Resume from list" in text
        assert "Start fresh" in text

    def test_omits_resume_when_unsupported(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = True
            caps.supports_resume = False
            text = _recovery_help_text("@0")

        assert "Resume" not in text
        assert "Continue last session" in text
        assert "Start fresh" in text

    def test_only_fresh_when_provider_minimal(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = False
            caps.supports_resume = False
            text = _recovery_help_text("@0")

        assert text == "Start fresh"

    def test_uses_per_window_provider(self) -> None:
        with patch(f"{_RC}.get_provider_for_window") as mock_gpw:
            caps = mock_gpw.return_value.capabilities
            caps.supports_continue = True
            caps.supports_resume = True
            _recovery_help_text("@9")

        mock_gpw.assert_called_once_with("@9", provider_name=None)


@pytest.fixture(autouse=True)
def _allow_user():
    with patch(
        "ccgram.handlers.text.text_handler.config.is_user_allowed", return_value=True
    ):
        yield


@pytest.fixture()
def _no_group():
    with patch("ccgram.handlers.text.text_handler.config") as mock_config:
        mock_config.group_id = None
        mock_config.is_user_allowed = MagicMock(return_value=True)
        yield mock_config


_TH = "ccgram.handlers.text.text_handler"


class TestTextHandlerDeadWindow:
    @patch(f"{_TH}.thread_router")
    @patch(f"{_TH}.tmux_manager")
    @patch(f"{_TH}.window_query")
    @patch(f"{_TH}.safe_reply", new_callable=AsyncMock)
    async def test_dead_window_shows_recovery_ui(
        self,
        mock_safe_reply: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.get_window_for_thread.return_value = "@0"
        mock_tm.find_window_by_id = AsyncMock(return_value=None)
        ws = MagicMock()
        ws.cwd = "/tmp/project"
        mock_sm.view_window.return_value = ws
        mock_tr.get_display_name.return_value = "project"

        update = _make_update()
        ctx = _make_context()

        with patch(f"{_TH}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            mock_path.cwd.return_value = mock_path.return_value
            await text_handler(update, ctx)

        mock_safe_reply.assert_called_once()
        call_kwargs = mock_safe_reply.call_args
        msg_text = (
            call_kwargs.args[1]
            if len(call_kwargs.args) > 1
            else call_kwargs.kwargs.get("text", "")
        )
        assert "ended" in msg_text
        assert "recover" in msg_text.lower()

    @patch(f"{_TH}.thread_router")
    @patch(f"{_TH}.tmux_manager")
    @patch(f"{_TH}.window_query")
    @patch(f"{_TH}.safe_reply", new_callable=AsyncMock)
    async def test_dead_window_stores_pending_message(
        self,
        mock_safe_reply: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.get_window_for_thread.return_value = "@0"
        mock_tm.find_window_by_id = AsyncMock(return_value=None)
        ws = MagicMock()
        ws.cwd = "/tmp/project"
        mock_sm.view_window.return_value = ws
        mock_tr.get_display_name.return_value = "project"

        update = _make_update(text="my pending message")
        user_data: dict = {}
        ctx = _make_context(user_data)

        with patch(f"{_TH}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            mock_path.cwd.return_value = mock_path.return_value
            await text_handler(update, ctx)

        assert user_data[PENDING_THREAD_TEXT] == "my pending message"
        assert user_data[PENDING_THREAD_ID] == 42
        assert user_data[RECOVERY_WINDOW_ID] == "@0"

    @patch(f"{_TH}.thread_router")
    @patch(f"{_TH}.tmux_manager")
    @patch(f"{_TH}.window_query")
    @patch(f"{_TH}.safe_reply", new_callable=AsyncMock)
    @patch(f"{_TH}.build_directory_browser")
    async def test_dead_window_no_cwd_falls_back_to_browser(
        self,
        mock_browser: MagicMock,
        mock_safe_reply: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.get_window_for_thread.return_value = "@0"
        mock_tm.find_window_by_id = AsyncMock(return_value=None)
        ws = MagicMock()
        ws.cwd = ""
        mock_sm.view_window.return_value = ws
        mock_tr.get_display_name.return_value = "project"
        mock_browser.return_value = ("Browse:", MagicMock(), [])

        update = _make_update()
        ctx = _make_context()

        with patch(f"{_TH}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = False
            mock_path.cwd.return_value = mock_path.return_value
            str_mock = MagicMock(return_value="/cwd")
            mock_path.cwd.return_value.__str__ = str_mock
            await text_handler(update, ctx)

        mock_tr.unbind_thread.assert_called_once()
        mock_browser.assert_called_once()

    @patch(f"{_TH}.thread_router")
    @patch(f"{_TH}.tmux_manager")
    @patch(f"{_TH}.window_query")
    @patch(f"{_TH}.safe_reply", new_callable=AsyncMock)
    @patch(f"{_TH}.build_directory_browser")
    async def test_dead_window_invalid_cwd_falls_back_to_browser(
        self,
        mock_browser: MagicMock,
        mock_safe_reply: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.get_window_for_thread.return_value = "@0"
        mock_tm.find_window_by_id = AsyncMock(return_value=None)
        ws = MagicMock()
        ws.cwd = "/nonexistent/path"
        mock_sm.view_window.return_value = ws
        mock_tr.get_display_name.return_value = "project"
        mock_browser.return_value = ("Browse:", MagicMock(), [])

        update = _make_update()
        ctx = _make_context()

        with patch(f"{_TH}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = False
            mock_path.cwd.return_value = mock_path.return_value
            str_mock = MagicMock(return_value="/cwd")
            mock_path.cwd.return_value.__str__ = str_mock
            await text_handler(update, ctx)

        mock_tr.unbind_thread.assert_called_once()

    @patch(f"{_TH}.thread_router")
    @patch(f"{_TH}.tmux_manager")
    @patch(f"{_TH}.window_query")
    @patch(f"{_TH}.safe_reply", new_callable=AsyncMock)
    async def test_dead_window_does_not_unbind(
        self,
        mock_safe_reply: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.get_window_for_thread.return_value = "@0"
        mock_tm.find_window_by_id = AsyncMock(return_value=None)
        ws = MagicMock()
        ws.cwd = "/tmp/project"
        mock_sm.view_window.return_value = ws
        mock_tr.get_display_name.return_value = "project"

        update = _make_update()
        ctx = _make_context()

        with patch(f"{_TH}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            mock_path.cwd.return_value = mock_path.return_value
            await text_handler(update, ctx)

        mock_tr.unbind_thread.assert_not_called()


class TestBotTextHandlerScopedMenu:
    @patch(
        "ccgram.handlers.text.text_handler.handle_text_message", new_callable=AsyncMock
    )
    @patch(
        "ccgram.handlers.commands.menu_sync.sync_scoped_provider_menu",
        new_callable=AsyncMock,
    )
    @patch("ccgram.handlers.commands.menu_sync.get_provider_for_window")
    @patch("ccgram.handlers.commands.menu_sync.thread_router")
    async def test_syncs_scoped_menu_when_thread_is_bound(
        self,
        mock_tr: MagicMock,
        mock_get_provider: MagicMock,
        mock_sync_menu: AsyncMock,
        mock_handle_text: AsyncMock,
        _no_group: MagicMock,
    ) -> None:
        provider = SimpleNamespace(capabilities=SimpleNamespace(name="codex"))
        mock_get_provider.return_value = provider
        mock_tr.resolve_window_for_thread.return_value = "@1"

        update = _make_update()
        ctx = _make_context()

        await text_handler(update, ctx)

        mock_sync_menu.assert_called_once_with(update.message, 100, provider)
        mock_handle_text.assert_called_once_with(update, ctx)

    @patch(
        "ccgram.handlers.text.text_handler.handle_text_message", new_callable=AsyncMock
    )
    @patch(
        "ccgram.handlers.commands.menu_sync.sync_scoped_provider_menu",
        new_callable=AsyncMock,
    )
    @patch("ccgram.handlers.commands.menu_sync.thread_router")
    async def test_skips_scoped_menu_sync_when_thread_is_unbound(
        self,
        mock_tr: MagicMock,
        mock_sync_menu: AsyncMock,
        mock_handle_text: AsyncMock,
        _no_group: MagicMock,
    ) -> None:
        mock_tr.resolve_window_for_thread.return_value = None

        update = _make_update()
        ctx = _make_context()

        await text_handler(update, ctx)

        mock_sync_menu.assert_not_called()
        mock_handle_text.assert_called_once_with(update, ctx)

    @patch(
        "ccgram.handlers.text.text_handler.handle_text_message", new_callable=AsyncMock
    )
    @patch(
        "ccgram.handlers.commands.menu_sync.sync_scoped_provider_menu",
        new_callable=AsyncMock,
    )
    @patch("ccgram.handlers.commands.menu_sync.get_provider_for_window")
    @patch("ccgram.handlers.commands.menu_sync.thread_router")
    async def test_cached_chat_user_still_resolves_provider_context(
        self,
        mock_tr: MagicMock,
        mock_get_provider: MagicMock,
        mock_sync_menu: AsyncMock,
        mock_handle_text: AsyncMock,
        _no_group: MagicMock,
    ) -> None:
        cmd_orch_mod._scoped_provider_menu.clear()
        try:
            cmd_orch_mod._scoped_provider_menu[(-100999, 100)] = "codex"
            provider = SimpleNamespace(capabilities=SimpleNamespace(name="codex"))
            mock_get_provider.return_value = provider
            mock_tr.resolve_window_for_thread.return_value = "@1"
            update = _make_update()
            update.message.chat.id = -100999
            ctx = _make_context()

            await text_handler(update, ctx)

            mock_tr.resolve_window_for_thread.assert_called_once_with(100, 42)
            mock_sync_menu.assert_called_once_with(update.message, 100, provider)
            mock_handle_text.assert_called_once_with(update, ctx)
        finally:
            cmd_orch_mod._scoped_provider_menu.clear()


class TestRecoveryFreshCallback:
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_fresh_creates_window_and_rebinds(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(
            cwd="/tmp/project", provider_name=""
        )
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_tr.unbind_thread.assert_called_once_with(100, 42)
        mock_tm.create_window.assert_called_once_with(
            "/tmp/project", agent_args="", launch_command="claude"
        )
        mock_tr.bind_thread.assert_called_once_with(
            100, 42, "@5", window_name="project"
        )
        mock_tr.set_group_chat_id.assert_called_once_with(100, 42, -100999)

    @patch(f"{_RC}.send_to_window", new_callable=AsyncMock)
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    @patch(f"{_RC}.safe_send", new_callable=AsyncMock)
    async def test_fresh_forwards_pending_message(
        self,
        _mock_safe_send: AsyncMock,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_send_to_window: AsyncMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_send_to_window.return_value = (True, "ok")
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_send_to_window.assert_called_once_with("@5", "hello")
        assert PENDING_THREAD_TEXT not in user_data
        assert PENDING_THREAD_ID not in user_data
        assert RECOVERY_WINDOW_ID not in user_data

    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_fresh_fails_when_cwd_gone(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        _mock_tm: MagicMock,
        mock_tr: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/gone")
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = False
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        assert "no longer exists" in mock_safe_edit.call_args.args[1].lower()

    async def test_fresh_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@0", thread_id=99)
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()

    async def test_fresh_no_pending_state_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@0")
        ctx = _make_context({})
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()

    async def test_fresh_window_id_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_FRESH}@999")
        user_data = {
            PENDING_THREAD_ID: 42,
            RECOVERY_WINDOW_ID: "@0",
        }
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestRecoveryContinueCallback:
    @patch(f"{_RC}.scan_sessions_for_cwd", return_value=[_SessionEntry("s1", "x")])
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_continue_creates_window_with_continue_flag(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        _mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(
            cwd="/tmp/project", provider_name=""
        )
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_tm.create_window.assert_called_once_with(
            "/tmp/project", agent_args="--continue", launch_command="claude"
        )
        mock_tr.bind_thread.assert_called_once_with(
            100, 42, "@5", window_name="project"
        )

    @patch(f"{_RC}.scan_sessions_for_cwd", return_value=[_SessionEntry("s1", "x")])
    @patch(f"{_RC}.send_to_window", new_callable=AsyncMock)
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    @patch(f"{_RC}.safe_send", new_callable=AsyncMock)
    async def test_continue_forwards_pending_message(
        self,
        _mock_safe_send: AsyncMock,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_send_to_window: AsyncMock,
        _mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_send_to_window.return_value = (True, "ok")
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0")
        user_data = _recovery_user_data(text="my message")
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_send_to_window.assert_called_once_with("@5", "my message")
        assert PENDING_THREAD_TEXT not in user_data

    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_continue_fails_when_cwd_gone(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        _mock_tm: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/gone")

        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = False
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        assert "no longer exists" in mock_safe_edit.call_args.args[1].lower()

    async def test_continue_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0", thread_id=99)
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestRecoveryResumeCallback:
    @patch(f"{_RC}.scan_sessions_for_cwd")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_resume_shows_session_picker(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_scan.return_value = [
            _SessionEntry("sess-1", "Fix login bug"),
            _SessionEntry("sess-2", "Add tests"),
        ]

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        assert "Select a session" in mock_safe_edit.call_args.args[1]
        assert RECOVERY_SESSIONS in user_data
        assert len(user_data[RECOVERY_SESSIONS]) == 2
        assert user_data[RECOVERY_SESSIONS][0]["session_id"] == "sess-1"

    @patch(f"{_RC}.scan_sessions_for_cwd")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_resume_no_sessions_shows_empty_state(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_scan.return_value = []

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        body = mock_safe_edit.call_args.args[1]
        assert "No sessions" in body
        kb = mock_safe_edit.call_args.kwargs["reply_markup"]
        button_texts = [b.text for row in kb.inline_keyboard for b in row]
        assert any("Browse" in t for t in button_texts)
        assert any("fresh" in t.lower() for t in button_texts)

    async def test_resume_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0", thread_id=99)
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestRecoveryResumePickCallback:
    @patch(f"{_RP}.window_query")
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_pick_creates_window_with_resume_flag(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_picker_wq: MagicMock,
    ) -> None:
        view = MagicMock(cwd="/tmp/project", provider_name="")
        mock_sm.view_window.return_value = view
        mock_picker_wq.view_window.return_value = view
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}0")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {
                "session_id": "a1b2c3d4-0000-0000-0000-000000000001",
                "summary": "Fix login bug",
            },
            {
                "session_id": "a1b2c3d4-0000-0000-0000-000000000002",
                "summary": "Add tests",
            },
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        with (
            patch(f"{_RC}.Path") as mock_path,
            patch(f"{_RP}.Path") as mock_p_path,
        ):
            mock_path.return_value.is_dir.return_value = True
            mock_p_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_tm.create_window.assert_called_once_with(
            "/tmp/project",
            agent_args="--resume a1b2c3d4-0000-0000-0000-000000000001",
            launch_command="claude",
        )
        mock_tr.bind_thread.assert_called_once()

    @patch(f"{_RP}.window_query")
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_pick_second_session(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_picker_wq: MagicMock,
    ) -> None:
        view = MagicMock(cwd="/tmp/project", provider_name="")
        mock_sm.view_window.return_value = view
        mock_picker_wq.view_window.return_value = view
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999

        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}1")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {
                "session_id": "a1b2c3d4-0000-0000-0000-000000000001",
                "summary": "Fix login bug",
            },
            {
                "session_id": "a1b2c3d4-0000-0000-0000-000000000002",
                "summary": "Add tests",
            },
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        with (
            patch(f"{_RC}.Path") as mock_path,
            patch(f"{_RP}.Path") as mock_p_path,
        ):
            mock_path.return_value.is_dir.return_value = True
            mock_p_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_tm.create_window.assert_called_once_with(
            "/tmp/project",
            agent_args="--resume a1b2c3d4-0000-0000-0000-000000000002",
            launch_command="claude",
        )

    async def test_pick_invalid_index_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}99")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {"session_id": "a1b2c3d4-0000-0000-0000-000000000001", "summary": "test"},
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "no longer" in query.answer.call_args.args[0].lower()

    async def test_pick_no_sessions_stored_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "no longer" in query.answer.call_args.args[0].lower()

    async def test_pick_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}0", thread_id=99)
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {"session_id": "a1b2c3d4-0000-0000-0000-000000000001", "summary": "test"},
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestRecoveryBackCallback:
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_back_shows_recovery_menu(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        update = _make_callback_update(data=f"{CB_RECOVERY_BACK}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        assert "Choose how to continue" in mock_safe_edit.call_args.args[1]
        query.answer.assert_called_once()

    @patch(f"{_RC}.get_provider_for_window")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_back_includes_help_text(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_gpw: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        caps = mock_gpw.return_value.capabilities
        caps.supports_continue = True
        caps.supports_resume = True

        update = _make_callback_update(data=f"{CB_RECOVERY_BACK}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        body = mock_safe_edit.call_args.args[1]
        assert "Start fresh" in body
        assert "Continue last session" in body
        assert "Resume from list" in body

    async def test_back_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_BACK}@0", thread_id=99)
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()

    async def test_back_no_pending_state_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_BACK}@0")
        ctx = _make_context({})
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()

    async def test_back_window_id_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_BACK}@999")
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestRecoveryCancelCallback:
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_cancel_clears_state(
        self,
        mock_safe_edit: AsyncMock,
    ) -> None:
        update = _make_callback_update(data=CB_RECOVERY_CANCEL)
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        assert PENDING_THREAD_TEXT not in user_data
        assert PENDING_THREAD_ID not in user_data
        assert RECOVERY_WINDOW_ID not in user_data
        mock_safe_edit.assert_called_once()

    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_cancel_also_clears_recovery_sessions(
        self,
        mock_safe_edit: AsyncMock,
    ) -> None:
        update = _make_callback_update(data=CB_RECOVERY_CANCEL)
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [{"session_id": "x", "summary": "y"}]
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        assert RECOVERY_SESSIONS not in user_data


class TestScanSessionsForCwd:
    def test_returns_sessions_matching_cwd(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-1.jsonl"
        session_file.write_text('{"type":"summary"}\n')

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-1",
                    "fullPath": str(session_file),
                    "projectPath": resolved,
                    "summary": "Fix the bug",
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].session_id == "sess-1"
        assert result[0].summary == "Fix the bug"

    def test_returns_empty_for_no_match(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        other_dir = tmp_path / "other"
        other_dir.mkdir()

        proj_dir = projects_path / "-tmp-other"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-1.jsonl"
        session_file.write_text('{"type":"summary"}\n')

        index = {
            "originalPath": str(other_dir.resolve()),
            "entries": [
                {
                    "sessionId": "sess-1",
                    "fullPath": str(session_file),
                    "projectPath": str(other_dir.resolve()),
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert result == []

    def test_returns_empty_when_projects_path_missing(self, tmp_path) -> None:
        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = tmp_path / "nonexistent"
            result = scan_sessions_for_cwd("/some/path")

        assert result == []

    def test_sorted_by_mtime_descending(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        import time

        old_file = proj_dir / "sess-old.jsonl"
        old_file.write_text('{"type":"summary"}\n')
        time.sleep(0.05)

        new_file = proj_dir / "sess-new.jsonl"
        new_file.write_text('{"type":"summary"}\n')

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-old",
                    "fullPath": str(old_file),
                    "projectPath": resolved,
                    "summary": "Old session",
                },
                {
                    "sessionId": "sess-new",
                    "fullPath": str(new_file),
                    "projectPath": resolved,
                    "summary": "New session",
                },
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 2
        assert result[0].session_id == "sess-new"
        assert result[1].session_id == "sess-old"

    def test_skips_missing_session_files(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-gone",
                    "fullPath": str(proj_dir / "nonexistent.jsonl"),
                    "projectPath": resolved,
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert result == []

    def test_uses_session_id_as_summary_fallback(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-abc123.jsonl"
        session_file.write_text('{"type":"summary"}\n')

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "a1b2c3d4-0000-0000-0000-abc123000000",
                    "fullPath": str(session_file),
                    "projectPath": resolved,
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].summary == "a1b2c3d4-000"

    def test_bare_jsonl_without_index(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        jsonl = proj_dir / "abc-123.jsonl"
        jsonl.write_text(
            f'{{"type":"user","cwd":"{resolved}","message":{{"content":[{{"type":"text","text":"Fix bug"}}]}}}}\n'
        )

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].session_id == "abc-123"
        assert result[0].summary == "Fix bug"

    def test_bare_jsonl_filters_by_cwd(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        other_dir = tmp_path / "other"
        other_dir.mkdir()

        proj_dir = projects_path / "-tmp-other"
        proj_dir.mkdir(parents=True)

        jsonl = proj_dir / "abc-123.jsonl"
        jsonl.write_text(
            f'{{"type":"user","cwd":"{other_dir.resolve()}","message":{{"content":"hi"}}}}\n'
        )

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert result == []

    def test_bare_jsonl_deduplicates_with_index(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-1.jsonl"
        session_file.write_text(
            f'{{"type":"user","cwd":"{resolved}","message":{{"content":"hi"}}}}\n'
        )

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-1",
                    "fullPath": str(session_file),
                    "projectPath": resolved,
                    "summary": "From index",
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].summary == "From index"

    def test_uses_first_prompt_as_summary_fallback(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-fp.jsonl"
        session_file.write_text('{"type":"summary"}\n')

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-fp",
                    "fullPath": str(session_file),
                    "projectPath": resolved,
                    "firstPrompt": "Implement auth",
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].summary == "Implement auth"

    def test_session_entries_carry_mtime(self, tmp_path) -> None:
        projects_path = tmp_path / "projects"
        work_dir = tmp_path / "myproj"
        work_dir.mkdir()
        resolved = str(work_dir.resolve())

        proj_dir = projects_path / "-tmp-myproj"
        proj_dir.mkdir(parents=True)

        session_file = proj_dir / "sess-1.jsonl"
        session_file.write_text('{"type":"summary"}\n')
        expected_mtime = session_file.stat().st_mtime

        index = {
            "originalPath": resolved,
            "entries": [
                {
                    "sessionId": "sess-1",
                    "fullPath": str(session_file),
                    "projectPath": resolved,
                    "summary": "Fix bug",
                }
            ],
        }
        (proj_dir / "sessions-index.json").write_text(json.dumps(index))

        with patch(f"{_RP}.config") as mock_config:
            mock_config.claude_projects_path = projects_path
            result = scan_sessions_for_cwd(str(work_dir))

        assert len(result) == 1
        assert result[0].mtime == expected_mtime


class TestRecoveryResumePickerLabels:
    @patch(f"{_RC}.scan_sessions_for_cwd")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_picker_labels_use_formatter(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan: MagicMock,
    ) -> None:
        import time as _time

        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        recent = _time.time() - 10
        mock_scan.return_value = [
            _SessionEntry("a1b2c3-0000-1111-2222-3333deadbeef", "Fix login bug", recent)
        ]

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        kb = _mock_safe_edit.call_args.kwargs["reply_markup"]
        button_text = kb.inline_keyboard[0][0].text
        assert "today" in button_text
        assert "Fix login bug" in button_text
        assert button_text.endswith(" · beef")

    @patch(f"{_RC}.scan_sessions_for_cwd")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_picker_labels_fall_back_to_never(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_scan.return_value = [
            _SessionEntry("a1b2c3-0000-1111-2222-3333deadbe99", "Old", 0.0)
        ]

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        kb = _mock_safe_edit.call_args.kwargs["reply_markup"]
        button_text = kb.inline_keyboard[0][0].text
        assert button_text.startswith("never · ")

    @patch(f"{_RC}.scan_sessions_for_cwd")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_picker_stores_mtime_for_pick_callback(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_scan.return_value = [_SessionEntry("sess-1", "X", 12345.0)]

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        stored = user_data[RECOVERY_SESSIONS]
        assert stored[0]["mtime"] == 12345.0


class TestRecoveryPerWindowProvider:
    @patch(f"{_RC}.scan_sessions_for_cwd", return_value=[_SessionEntry("s1", "x")])
    @patch(f"{_RC}.get_provider_for_window")
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_continue_uses_per_window_provider(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_gpw: MagicMock,
        _mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999
        mock_gpw.return_value.make_launch_args.return_value = "--continue"

        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_gpw.assert_called_with("@0", provider_name=ANY)
        mock_gpw.return_value.make_launch_args.assert_called_once_with(
            use_continue=True
        )

    @patch(f"{_RP}.get_provider_for_window")
    @patch(f"{_RP}.window_query")
    @patch(f"{_RC}.get_provider_for_window")
    @patch(f"{_RC}.thread_router")
    @patch(f"{_RC}.tmux_manager")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_resume_pick_uses_per_window_provider(
        self,
        _mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_tm: MagicMock,
        mock_tr: MagicMock,
        mock_gpw: MagicMock,
        mock_picker_wq: MagicMock,
        mock_picker_gpw: MagicMock,
    ) -> None:
        view = MagicMock(cwd="/tmp/project", provider_name="")
        mock_sm.view_window.return_value = view
        mock_picker_wq.view_window.return_value = view
        mock_tm.create_window = AsyncMock(
            return_value=(True, "Window created", "project", "@5")
        )
        mock_sm.wait_for_session_map_entry = AsyncMock()
        mock_sm.send_to_window = AsyncMock(return_value=(True, "ok"))
        mock_tr.resolve_chat_id.return_value = -100999
        mock_picker_gpw.return_value.make_launch_args.return_value = "--resume sess-1"

        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}0")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {"session_id": "sess-1", "summary": "Fix login bug"},
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        with (
            patch(f"{_RC}.Path") as mock_path,
            patch(f"{_RP}.Path") as mock_p_path,
        ):
            mock_path.return_value.is_dir.return_value = True
            mock_p_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_picker_gpw.assert_called_with("@0", provider_name=ANY)
        mock_picker_gpw.return_value.make_launch_args.assert_called_once_with(
            resume_id="sess-1"
        )


class TestRecoveryEmptyStateAndBrowseFallback:
    @patch(f"{_RC}.scan_sessions_for_cwd", return_value=[])
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_continue_with_no_sessions_shows_empty_state(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        _mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")

        update = _make_callback_update(data=f"{CB_RECOVERY_CONTINUE}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        body = mock_safe_edit.call_args.args[1]
        assert "No sessions" in body
        kb = mock_safe_edit.call_args.kwargs["reply_markup"]
        button_texts = [b.text for row in kb.inline_keyboard for b in row]
        assert any("Browse" in t for t in button_texts)
        assert any("fresh" in t.lower() for t in button_texts)

    @patch(f"{_RC}.scan_sessions_for_cwd", return_value=[])
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_empty_state_buttons_target_fresh_and_browse(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        _mock_scan: MagicMock,
    ) -> None:
        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")

        update = _make_callback_update(data=f"{CB_RECOVERY_RESUME}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        with patch(f"{_RC}.Path") as mock_path:
            mock_path.return_value.is_dir.return_value = True
            await handle_recovery_callback(query, 100, query.data, update, ctx)

        kb = mock_safe_edit.call_args.kwargs["reply_markup"]
        datas = [
            b.callback_data
            for row in kb.inline_keyboard
            for b in row
            if isinstance(b.callback_data, str)
        ]
        assert any(d.startswith(CB_RECOVERY_BROWSE) for d in datas)
        assert any(d.startswith(CB_RECOVERY_FRESH) for d in datas)
        assert CB_RECOVERY_CANCEL in datas

    @patch("ccgram.handlers.recovery.resume_command.scan_all_sessions")
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_browse_loads_cross_project_picker(
        self,
        mock_safe_edit: AsyncMock,
        mock_sm: MagicMock,
        mock_scan_all: MagicMock,
    ) -> None:
        from ccgram.handlers.recovery.resume_command import ResumeEntry

        mock_sm.view_window.return_value = MagicMock(cwd="/tmp/project")
        mock_scan_all.return_value = [
            ResumeEntry("sess-x", "From other project", "/other", 12345.0),
        ]

        update = _make_callback_update(data=f"{CB_RECOVERY_BROWSE}@0")
        user_data = _recovery_user_data(text="pending text")
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        from ccgram.handlers.user_state import RESUME_SESSIONS as _RS

        assert _RS in user_data
        assert user_data[_RS][0]["session_id"] == "sess-x"
        assert PENDING_THREAD_TEXT not in user_data
        body = mock_safe_edit.call_args.args[1]
        assert "Select a session" in body

    @patch("ccgram.handlers.recovery.resume_command.scan_all_sessions", return_value=[])
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_browse_with_no_sessions_anywhere(
        self,
        mock_safe_edit: AsyncMock,
        _mock_scan_all: MagicMock,
    ) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_BROWSE}@0")
        user_data = _recovery_user_data()
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        mock_safe_edit.assert_called_once()
        body = mock_safe_edit.call_args.args[1]
        assert "No past sessions" in body

    async def test_browse_topic_mismatch_rejected(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_BROWSE}@0", thread_id=99)
        user_data = {PENDING_THREAD_ID: 42, RECOVERY_WINDOW_ID: "@0"}
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        query.answer.assert_called_once()
        assert "mismatch" in query.answer.call_args.args[0].lower()


class TestEmptyStateKeyboardBuilder:
    def test_empty_keyboard_has_browse_fresh_and_cancel(self) -> None:
        from ccgram.handlers.recovery.resume_picker import (
            _build_empty_resume_keyboard,
        )

        kb = _build_empty_resume_keyboard("@0")
        datas = [
            b.callback_data
            for row in kb.inline_keyboard
            for b in row
            if isinstance(b.callback_data, str)
        ]
        assert any(d.startswith(CB_RECOVERY_BROWSE) for d in datas)
        assert any(d.startswith(CB_RECOVERY_FRESH) for d in datas)
        assert CB_RECOVERY_CANCEL in datas

    def test_empty_keyboard_callback_data_within_64_bytes(self) -> None:
        from ccgram.handlers.recovery.resume_picker import (
            _build_empty_resume_keyboard,
        )

        long_id = "@" + "x" * 60
        kb = _build_empty_resume_keyboard(long_id)
        for row in kb.inline_keyboard:
            for btn in row:
                assert isinstance(btn.callback_data, str)
                assert len(btn.callback_data) <= 64


class TestUpdatedToastWordings:
    @patch(f"{_RC}.window_query")
    @patch(f"{_RC}.safe_edit", new_callable=AsyncMock)
    async def test_pick_says_no_longer_in_list(
        self,
        _mock_safe_edit: AsyncMock,
        _mock_sm: MagicMock,
    ) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}99")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {"session_id": "x", "summary": "x"},
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        text = query.answer.call_args.args[0]
        assert "no longer" in text.lower()

    async def test_pick_invalid_value_says_couldnt_read(self) -> None:
        update = _make_callback_update(data=f"{CB_RECOVERY_PICK}notanumber")
        user_data = _recovery_user_data()
        user_data[RECOVERY_SESSIONS] = [
            {"session_id": "x", "summary": "x"},
        ]
        ctx = _make_context(user_data)
        query = update.callback_query

        await handle_recovery_callback(query, 100, query.data, update, ctx)

        text = query.answer.call_args.args[0]
        assert "couldn" in text.lower()
