#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2024 by the Linux Foundation
#
"""Headless Textual tests for review-TUI modal screens.

Uses Textual's built-in ``App.run_test()`` / ``Pilot`` harness so the
tests run without a real terminal.  Only lightweight, self-contained
modals are exercised here — no database, network, or git needed.
"""

import importlib
from typing import Any, Dict, Generator, List, Optional, Tuple
from unittest import mock

import pytest

pytest.importorskip('textual')

from textual.app import App, ComposeResult
from textual.widgets import Checkbox, Input, Label, ListView, Select, Static

import b4
import liblore
from b4.review_tui._modals import (
    TRACKING_HELP_LINES,
    ActionScreen,
    ConfirmScreen,
    HelpScreen,
    LimitScreen,
    LinkRevisionConfirmScreen,
    LinkRevisionScreen,
    NoteScreen,
    PriorReviewScreen,
    RevisionChoiceScreen,
    SetStateScreen,
    SnoozeScreen,
    TakeScreen,
    TrailerScreen,
    UpdateAllScreen,
    UpdateRevisionScreen,
    WorkerScreen,
    _FetchViewerScreen,
)

# ---------------------------------------------------------------------------
# Compat helper — Textual ≥ 1.0 (pip) uses Static.content,
# older builds (e.g. Fedora 43 package) still use Static.renderable.
# ---------------------------------------------------------------------------


def _static_text(widget: Any) -> str:
    """Return the text content of a Static widget across Textual versions."""
    if hasattr(widget, 'content'):
        return str(widget.content)
    return str(widget.renderable)


# ---------------------------------------------------------------------------
# Minimal host app — just enough to push modal screens onto
# ---------------------------------------------------------------------------


class ModalTestApp(App[None]):
    """Bare app that serves as a host for pushing modal screens."""

    def compose(self) -> ComposeResult:
        yield Label('host')


# ---------------------------------------------------------------------------
# HelpScreen
# ---------------------------------------------------------------------------


class TestHelpScreen:
    """Tests for the HelpScreen modal."""

    @staticmethod
    def _lines() -> List[str]:
        return [
            '[bold]Test Help[/bold]\n',
            '\n',
            '  [bold]a[/bold]  Do alpha\n',
            '  [bold]b[/bold]  Do bravo\n',
        ]

    @pytest.mark.asyncio
    async def test_escape_dismisses(self) -> None:
        app = ModalTestApp()
        dismissed: List[Any] = []

        async with app.run_test() as pilot:
            app.push_screen(HelpScreen(self._lines()), dismissed.append)
            await pilot.pause()
            # The help screen should now be on top
            assert isinstance(app.screen, HelpScreen)

            await pilot.press('escape')
            await pilot.pause()
            # Should be back on the host screen
            assert not isinstance(app.screen, HelpScreen)
            # https://github.com/python/mypy/issues/9457:
            # app.screen is stale-narrowed across await.
            assert dismissed == [None]  # type: ignore[unreachable]

    @pytest.mark.asyncio
    async def test_question_mark_dismisses(self) -> None:
        app = ModalTestApp()
        dismissed: List[Any] = []

        async with app.run_test() as pilot:
            app.push_screen(HelpScreen(self._lines()), dismissed.append)
            await pilot.pause()

            await pilot.press('question_mark')
            await pilot.pause()
            assert not isinstance(app.screen, HelpScreen)
            assert dismissed == [None]

    @pytest.mark.asyncio
    async def test_q_dismisses(self) -> None:
        app = ModalTestApp()
        dismissed: List[Any] = []

        async with app.run_test() as pilot:
            app.push_screen(HelpScreen(self._lines()), dismissed.append)
            await pilot.pause()

            await pilot.press('q')
            await pilot.pause()
            assert not isinstance(app.screen, HelpScreen)
            assert dismissed == [None]

    @pytest.mark.asyncio
    async def test_scroll_bindings_do_not_dismiss(self) -> None:
        """j, k, space, etc. should scroll but keep the modal open."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(HelpScreen(TRACKING_HELP_LINES))
            await pilot.pause()
            assert isinstance(app.screen, HelpScreen)

            for key in (
                'j',
                'k',
                'down',
                'up',
                'space',
                'backspace',
                'pagedown',
                'pageup',
            ):
                await pilot.press(key)
                await pilot.pause()
                assert isinstance(app.screen, HelpScreen), (
                    f'{key!r} unexpectedly closed the help screen'
                )

    @pytest.mark.asyncio
    async def test_content_rendered(self) -> None:
        """The static content inside the modal should contain our text."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            lines = self._lines()
            app.push_screen(HelpScreen(lines))
            await pilot.pause()

            dialog = app.screen.query_one('#help-dialog')
            assert dialog is not None


# ---------------------------------------------------------------------------
# ConfirmScreen
# ---------------------------------------------------------------------------


class TestConfirmScreen:
    """Tests for the ConfirmScreen modal."""

    @pytest.mark.asyncio
    async def test_y_confirms(self) -> None:
        app = ModalTestApp()
        results: List[Optional[bool]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                ConfirmScreen('Delete?', ['This is permanent.']),
                results.append,
            )
            await pilot.pause()
            assert isinstance(app.screen, ConfirmScreen)

            await pilot.press('y')
            await pilot.pause()
            assert not isinstance(app.screen, ConfirmScreen)
            # https://github.com/python/mypy/issues/9457:
            # app.screen is stale-narrowed across await.
            assert results == [True]  # type: ignore[unreachable]

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[bool]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                ConfirmScreen('Delete?', ['This is permanent.']),
                results.append,
            )
            await pilot.pause()

            await pilot.press('escape')
            await pilot.pause()
            assert not isinstance(app.screen, ConfirmScreen)
            assert results == [False]

    @pytest.mark.asyncio
    async def test_body_lines_rendered(self) -> None:
        """Each body string should appear as a Static widget."""
        app = ModalTestApp()
        body = ['Line one.', 'Line two.', 'Line three.']

        async with app.run_test() as pilot:
            app.push_screen(ConfirmScreen('Title', body))
            await pilot.pause()

            statics = app.screen.query('#confirm-dialog Static')
            # body lines + hint line + possibly title
            rendered = [_static_text(s) for s in statics]
            for line in body:
                assert any(line in r for r in rendered), (
                    f'{line!r} not found in rendered statics'
                )

    @pytest.mark.asyncio
    async def test_subject_shown(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                ConfirmScreen('Title', ['body'], subject='My Subject'),
            )
            await pilot.pause()

            title_widget = app.screen.query_one('#confirm-title')
            assert 'My Subject' in _static_text(title_widget)

    @pytest.mark.asyncio
    async def test_warning_border(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                ConfirmScreen('Oops', ['careful'], border='$warning'),
            )
            await pilot.pause()

            dialog = app.screen.query_one('#confirm-dialog')
            assert dialog.has_class('--border-warning')


# ---------------------------------------------------------------------------
# TrailerScreen
# ---------------------------------------------------------------------------


class TestTrailerScreen:
    """Tests for the TrailerScreen modal."""

    @pytest.mark.asyncio
    async def test_cancel_returns_none(self) -> None:
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(TrailerScreen([]), results.append)
            await pilot.pause()
            assert isinstance(app.screen, TrailerScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_confirm_empty_returns_empty_list(self) -> None:
        """With nothing toggled, q/confirm returns an empty list."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(TrailerScreen([]), results.append)
            await pilot.pause()

            await pilot.press('q')
            await pilot.pause()
            assert len(results) == 1
            assert results[0] == []

    @pytest.mark.asyncio
    async def test_existing_trailers_pretoggled(self) -> None:
        """Trailers already present should be pre-selected."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                TrailerScreen(['Reviewed-by: Alice <a@b.com>']),
                results.append,
            )
            await pilot.pause()

            # Confirm immediately — should have Reviewed-by selected
            await pilot.press('q')
            await pilot.pause()
            assert results[0] == ['Reviewed-by']

    @pytest.mark.asyncio
    async def test_toggle_and_confirm(self) -> None:
        """Space toggles the highlighted item; q confirms."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(TrailerScreen([]), results.append)
            await pilot.pause()

            # First item is Acked-by (index 0), toggle it
            await pilot.press('space')
            await pilot.pause()
            # Move down to Reviewed-by, toggle it
            await pilot.press('j')
            await pilot.press('space')
            await pilot.pause()
            # Confirm
            await pilot.press('q')
            await pilot.pause()

            assert len(results) == 1
            assert results[0] is not None
            assert 'Acked-by' in results[0]
            assert 'Reviewed-by' in results[0]
            assert 'Tested-by' not in results[0]
            assert 'NACKed-by' not in results[0]

    @pytest.mark.asyncio
    async def test_toggle_twice_deselects(self) -> None:
        """Toggling the same item twice should deselect it."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(TrailerScreen([]), results.append)
            await pilot.pause()

            await pilot.press('space')  # select Acked-by
            await pilot.press('space')  # deselect Acked-by
            await pilot.pause()

            await pilot.press('q')
            await pilot.pause()
            assert results[0] == []

    @pytest.mark.asyncio
    async def test_jk_navigation(self) -> None:
        """j/k should move the highlight without toggling."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(TrailerScreen([]), results.append)
            await pilot.pause()

            # Navigate down to Tested-by (index 2) and toggle only it
            await pilot.press('j')
            await pilot.press('j')
            await pilot.press('space')
            await pilot.pause()

            await pilot.press('q')
            await pilot.pause()
            assert results[0] == ['Tested-by']

    @pytest.mark.asyncio
    async def test_enter_confirms(self) -> None:
        """Enter on the ListView should also confirm (via on_list_view_selected)."""
        app = ModalTestApp()
        results: List[Optional[List[str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                TrailerScreen(['Acked-by: Bob <b@c.com>']),
                results.append,
            )
            await pilot.pause()

            await pilot.press('enter')
            await pilot.pause()
            assert len(results) == 1
            assert results[0] == ['Acked-by']


# ---------------------------------------------------------------------------
# NoteScreen
# ---------------------------------------------------------------------------


class TestNoteScreen:
    """Tests for the NoteScreen modal."""

    @staticmethod
    def _entries() -> List[Tuple[str, str, str]]:
        return [
            ('Alice <alice@example.com>', 'green', 'Looks good to me.'),
            ('Bob <bob@example.com>', 'blue', 'Needs a rebase.'),
        ]

    @pytest.mark.asyncio
    async def test_escape_returns_none(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(NoteScreen(self._entries()), results.append)
            await pilot.pause()
            assert isinstance(app.screen, NoteScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_e_returns_edit(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(NoteScreen(self._entries()), results.append)
            await pilot.pause()

            await pilot.press('e')
            await pilot.pause()
            assert results == ['__EDIT__']

    @pytest.mark.asyncio
    async def test_d_returns_delete(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(NoteScreen(self._entries()), results.append)
            await pilot.pause()

            await pilot.press('d')
            await pilot.pause()
            assert results == ['__DELETE__']

    @pytest.mark.asyncio
    async def test_empty_notes(self) -> None:
        """Modal should still render with no entries."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(NoteScreen([]))
            await pilot.pause()
            assert isinstance(app.screen, NoteScreen)
            assert app.screen.query_one('#note-viewer') is not None


# ---------------------------------------------------------------------------
# PriorReviewScreen
# ---------------------------------------------------------------------------


class TestPriorReviewScreen:
    """Tests for the PriorReviewScreen modal."""

    @pytest.mark.asyncio
    async def test_escape_dismisses(self) -> None:
        app = ModalTestApp()
        results: List[Any] = []

        async with app.run_test() as pilot:
            app.push_screen(
                PriorReviewScreen('== Patch 1 ==\nLGTM'),
                results.append,
            )
            await pilot.pause()
            assert isinstance(app.screen, PriorReviewScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert not isinstance(app.screen, PriorReviewScreen)
            # https://github.com/python/mypy/issues/9457:
            # app.screen is stale-narrowed across await.
            assert results == [None]  # type: ignore[unreachable]

    @pytest.mark.asyncio
    async def test_content_rendered(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(PriorReviewScreen('== Header ==\nSome text'))
            await pilot.pause()
            assert app.screen.query_one('#prior-review-viewer') is not None


# ---------------------------------------------------------------------------
# RevisionChoiceScreen
# ---------------------------------------------------------------------------


class TestRevisionChoiceScreen:
    """Tests for the RevisionChoiceScreen modal."""

    @pytest.mark.asyncio
    async def test_n_returns_newest(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                RevisionChoiceScreen(current_rev=2, newest_rev=5),
                results.append,
            )
            await pilot.pause()
            assert isinstance(app.screen, RevisionChoiceScreen)

            await pilot.press('n')
            await pilot.pause()
            assert results == [5]

    @pytest.mark.asyncio
    async def test_o_returns_current(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                RevisionChoiceScreen(current_rev=2, newest_rev=5),
                results.append,
            )
            await pilot.pause()

            await pilot.press('o')
            await pilot.pause()
            assert results == [2]

    @pytest.mark.asyncio
    async def test_escape_returns_none(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                RevisionChoiceScreen(current_rev=1, newest_rev=3),
                results.append,
            )
            await pilot.pause()

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]


# ---------------------------------------------------------------------------
# SnoozeScreen
# ---------------------------------------------------------------------------


class TestSnoozeScreen:
    """Tests for the SnoozeScreen modal."""

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()
            assert isinstance(app.screen, SnoozeScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_duration_snooze(self) -> None:
        """Entering a duration and confirming should return a datetime."""
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()

            # The duration input should be focused by default
            dur_input = app.screen.query_one('#snooze-duration', Input)
            dur_input.value = '3d'

            await pilot.press('ctrl+y')
            await pilot.pause()

            assert len(results) == 1
            assert results[0] is not None
            assert results[0]['source'] == 'duration'
            assert results[0]['input'] == '3d'
            # until should be an ISO datetime
            assert 'T' in results[0]['until']

    @pytest.mark.asyncio
    async def test_tag_snooze(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()

            tag_input = app.screen.query_one('#snooze-tag', Input)
            tag_input.value = 'v6.15-rc3'

            await pilot.press('ctrl+y')
            await pilot.pause()

            assert len(results) == 1
            assert results[0] is not None
            assert results[0]['until'] == 'tag:v6.15-rc3'
            assert results[0]['source'] == 'tag'

    @pytest.mark.asyncio
    async def test_empty_fields_shows_error(self) -> None:
        """Confirming with no fields filled should show an error, not dismiss."""
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()

            await pilot.press('ctrl+y')
            await pilot.pause()
            # Should still be on the snooze screen (not dismissed)
            assert isinstance(app.screen, SnoozeScreen)
            assert len(results) == 0

            error = app.screen.query_one('#snooze-error')
            error_text = _static_text(error).lower()
            assert 'enter' in error_text or 'please' in error_text

    @pytest.mark.asyncio
    async def test_multiple_fields_shows_error(self) -> None:
        """Filling more than one field should show an error."""
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()

            app.screen.query_one('#snooze-duration', Input).value = '1d'
            app.screen.query_one('#snooze-tag', Input).value = 'v6.15'

            await pilot.press('ctrl+y')
            await pilot.pause()
            assert isinstance(app.screen, SnoozeScreen)
            assert len(results) == 0

            error = app.screen.query_one('#snooze-error')
            assert 'only one' in _static_text(error).lower()

    @pytest.mark.asyncio
    async def test_invalid_duration_shows_error(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Dict[str, str]]] = []

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(), results.append)
            await pilot.pause()

            app.screen.query_one('#snooze-duration', Input).value = 'banana'

            await pilot.press('ctrl+y')
            await pilot.pause()
            assert isinstance(app.screen, SnoozeScreen)
            assert len(results) == 0

    @pytest.mark.asyncio
    async def test_prepopulate_last_source(self) -> None:
        """Re-opening with last_source/last_input should pre-fill the field."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(SnoozeScreen(last_source='tag', last_input='v6.14'))
            await pilot.pause()

            tag_input = app.screen.query_one('#snooze-tag', Input)
            assert tag_input.value == 'v6.14'


# ---------------------------------------------------------------------------
# SetStateScreen
# ---------------------------------------------------------------------------


class TestSetStateScreen:
    """Tests for the SetStateScreen modal."""

    @staticmethod
    def _states() -> List[Dict[str, Any]]:
        return [
            {'slug': 'new', 'name': 'New'},
            {'slug': 'reviewing', 'name': 'Reviewing'},
            {'slug': 'replied', 'name': 'Replied'},
            {'slug': 'waiting', 'name': 'Waiting'},
        ]

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Tuple[str, bool]]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                SetStateScreen(self._states(), 'new'),
                results.append,
            )
            await pilot.pause()
            assert isinstance(app.screen, SetStateScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_select_state_with_enter(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Tuple[str, bool]]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                SetStateScreen(self._states(), 'new'),
                results.append,
            )
            await pilot.pause()

            # Navigate to 'reviewing' (one down from 'new')
            await pilot.press('j')
            await pilot.press('enter')
            await pilot.pause()

            assert len(results) == 1
            assert results[0] is not None
            assert results[0][0] == 'reviewing'
            assert results[0][1] is False  # not archived

    @pytest.mark.asyncio
    async def test_archive_toggle(self) -> None:
        app = ModalTestApp()
        results: List[Optional[Tuple[str, bool]]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                SetStateScreen(self._states(), 'new'),
                results.append,
            )
            await pilot.pause()

            # Toggle archive, then confirm
            await pilot.press('a')
            await pilot.press('enter')
            await pilot.pause()

            assert len(results) == 1
            assert results[0] is not None
            assert results[0][1] is True  # archived

    @pytest.mark.asyncio
    async def test_current_state_preselected(self) -> None:
        """The ListView index should start on the current state."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(SetStateScreen(self._states(), 'replied'))
            await pilot.pause()

            lv = app.screen.query_one('#state-list', ListView)
            assert lv.index == 2  # 'replied' is at index 2


# ---------------------------------------------------------------------------
# LimitScreen
# ---------------------------------------------------------------------------


class TestLimitScreen:
    """Tests for the LimitScreen modal."""

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(LimitScreen(), results.append)
            await pilot.pause()
            assert isinstance(app.screen, LimitScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_enter_submits_value(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(LimitScreen(), results.append)
            await pilot.pause()

            inp = app.screen.query_one('#limit-input', Input)
            inp.value = 'netfilter'

            await pilot.press('enter')
            await pilot.pause()
            assert results == ['netfilter']

    @pytest.mark.asyncio
    async def test_empty_enter_clears_filter(self) -> None:
        """Submitting empty input should return empty string (clear filter)."""
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(LimitScreen(), results.append)
            await pilot.pause()

            await pilot.press('enter')
            await pilot.pause()
            assert results == ['']

    @pytest.mark.asyncio
    async def test_current_pattern_prepopulated(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(LimitScreen(current_pattern='drm'))
            await pilot.pause()

            inp = app.screen.query_one('#limit-input', Input)
            assert inp.value == 'drm'


# ---------------------------------------------------------------------------
# ActionScreen
# ---------------------------------------------------------------------------


class TestActionScreen:
    """Tests for the ActionScreen modal."""

    _SHORTCUTS = {
        'review': 'r',
        'take': 'T',
        'snooze': 's',
        'archive': 'x',
    }

    @staticmethod
    def _actions() -> List[Tuple[str, str]]:
        return [
            ('review', 'Review this series'),
            ('take', 'Take (apply) this series'),
            ('snooze', 'Snooze this series'),
            ('archive', 'Archive this series'),
        ]

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(ActionScreen(self._actions()), results.append)
            await pilot.pause()
            assert isinstance(app.screen, ActionScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_enter_confirms_highlighted(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(ActionScreen(self._actions()), results.append)
            await pilot.pause()

            # First item is 'review'
            await pilot.press('enter')
            await pilot.pause()
            assert results == ['review']

    @pytest.mark.asyncio
    async def test_navigate_and_confirm(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(ActionScreen(self._actions()), results.append)
            await pilot.pause()

            await pilot.press('j')  # take
            await pilot.press('j')  # snooze
            await pilot.press('enter')
            await pilot.pause()
            assert results == ['snooze']

    @pytest.mark.asyncio
    async def test_shortcut_key_selects_directly(self) -> None:
        """Pressing the shortcut char should immediately dismiss."""
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
            )
            await pilot.pause()

            # 'T' is the shortcut for 'take'
            await pilot.press('T')
            await pilot.pause()
            assert results == ['take']

    @pytest.mark.asyncio
    async def test_shortcut_r_for_review(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
            )
            await pilot.pause()

            await pilot.press('r')
            await pilot.pause()
            assert results == ['review']

    @pytest.mark.asyncio
    async def test_shortcut_x_for_archive(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
            )
            await pilot.pause()

            await pilot.press('x')
            await pilot.pause()
            assert results == ['archive']


# ---------------------------------------------------------------------------
# UpdateRevisionScreen
# ---------------------------------------------------------------------------


class TestUpdateRevisionScreen:
    """Tests for the UpdateRevisionScreen modal."""

    @staticmethod
    def _revisions() -> List[Dict[str, Any]]:
        return [
            {'revision': 2, 'subject': '[PATCH v2] fix the thing'},
            {'revision': 3, 'subject': '[PATCH v3] fix the thing properly'},
            {'revision': 4, 'subject': '[PATCH v4] fix the thing for real'},
        ]

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                UpdateRevisionScreen(1, self._revisions()),
                results.append,
            )
            await pilot.pause()
            assert isinstance(app.screen, UpdateRevisionScreen)

            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_select_first_revision(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                UpdateRevisionScreen(1, self._revisions()),
                results.append,
            )
            await pilot.pause()

            await pilot.press('enter')
            await pilot.pause()
            assert results == [2]

    @pytest.mark.asyncio
    async def test_navigate_and_select(self) -> None:
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            app.push_screen(
                UpdateRevisionScreen(1, self._revisions()),
                results.append,
            )
            await pilot.pause()

            await pilot.press('j')  # v3
            await pilot.press('j')  # v4
            await pilot.press('enter')
            await pilot.pause()
            assert results == [4]

    @pytest.mark.asyncio
    async def test_filters_older_revisions(self) -> None:
        """Only revisions newer than current should appear."""
        app = ModalTestApp()
        results: List[Optional[int]] = []

        async with app.run_test() as pilot:
            # current_revision=3, so only v4 should appear
            app.push_screen(
                UpdateRevisionScreen(3, self._revisions()),
                results.append,
            )
            await pilot.pause()

            # Only one item in the list — entering should select v4
            await pilot.press('enter')
            await pilot.pause()
            assert results == [4]


# ---------------------------------------------------------------------------
# UpdateAllScreen — cancellation behaviour
# ---------------------------------------------------------------------------


class TestUpdateAllScreenCancellation:
    """UpdateAllScreen network-cancellation behaviour.

    These tests verify the two cancellation paths introduced when fixing the
    "goes off into space non-interruptably" bug:

    * action_cancel() — called by the Escape/q binding while the TUI is live
    * OperationCancelledError propagating up from update_series_tracking —
      tested without needing a real HTTP connection
    """

    @pytest.fixture(autouse=True)
    def _reset_lore_node(self) -> Generator[None, None, None]:
        """Reset the LORENODE singleton so tests don't share cancel state."""
        saved = b4.LORENODE
        b4.LORENODE = None
        yield
        b4.LORENODE = saved

    # ------------------------------------------------------------------
    # Sync test — no app context needed
    # ------------------------------------------------------------------

    def test_action_cancel_sets_flag_and_cancels_node(self) -> None:
        """action_cancel() flips _cancelled and cancels the lore node.

        Both actions must happen together: setting _cancelled guards the Python
        loop, while cancel() closes the session so a blocked socket read
        raises immediately in the worker thread.
        """
        screen = UpdateAllScreen(
            [{'change_id': 'c1', 'subject': '[PATCH] test', 'revision': 1}],
            'test-id',
            'https://example.com/%s',
        )
        node = b4.get_lore_node()

        screen.action_cancel()

        assert screen._cancelled, '_cancelled must be True after action_cancel'
        assert node._cancel_event.is_set(), (
            'cancel event must be set after action_cancel'
        )

    # ------------------------------------------------------------------
    # Async TUI test — exercises _do_updates loop-break path
    # ------------------------------------------------------------------

    @pytest.mark.asyncio
    async def test_operation_cancelled_error_breaks_loop(self) -> None:
        """OperationCancelledError breaks the update loop and preserves partial results.

        The first series succeeds; the second raises OperationCancelledError.
        The third series must never be attempted, and the modal must dismiss
        with series_checked == 1.
        """
        series_list = [
            {'change_id': 'c1', 'subject': '[PATCH] series 1', 'revision': 1},
            {'change_id': 'c2', 'subject': '[PATCH] series 2', 'revision': 1},
            {'change_id': 'c3', 'subject': '[PATCH] series 3', 'revision': 1},
        ]

        call_count = 0

        def _mock_update(
            series: Dict[str, Any],
            identifier: str,
            linkmask: str,
            topdir: Optional[str] = None,
        ) -> Dict[str, Any]:
            nonlocal call_count
            call_count += 1
            if call_count == 2:
                raise liblore.OperationCancelledError('network stalled')
            return {
                'new_revisions': 0,
                'new_trailers': 0,
                'error': None,
                'counts_updated': False,
            }

        dismissed: List[Optional[Dict[str, Any]]] = []

        app = ModalTestApp()
        with mock.patch('b4.review.update_series_tracking', side_effect=_mock_update):
            async with app.run_test(size=(120, 30)) as pilot:
                app.push_screen(
                    UpdateAllScreen(series_list, 'test-id', 'https://ex.com/%s'),
                    dismissed.append,
                )
                for _ in range(5):
                    await pilot.pause()

        assert dismissed, 'modal should have dismissed with a result'
        result = dismissed[0]
        assert result is not None
        assert result['series_checked'] == 1, 'only first series should be counted'
        assert call_count == 2, 'cancelled on second call; third never attempted'


class TestLinkRevisionScreen:
    """Tests for the LinkRevisionScreen input modal."""

    @pytest.mark.asyncio
    async def test_escape_cancels(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []
        async with app.run_test() as pilot:
            app.push_screen(LinkRevisionScreen(subject='A series'), results.append)
            await pilot.pause()
            assert isinstance(app.screen, LinkRevisionScreen)
            await pilot.press('escape')
            await pilot.pause()
            assert results == [None]

    @pytest.mark.asyncio
    async def test_enter_returns_value(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []
        async with app.run_test() as pilot:
            app.push_screen(LinkRevisionScreen(), results.append)
            await pilot.pause()
            app.screen.query_one(
                '#link-rev-input', Input
            ).value = '  20260320@example.com  '
            await pilot.press('enter')
            await pilot.pause()
            assert results == ['20260320@example.com']

    @pytest.mark.asyncio
    async def test_empty_input_does_not_dismiss(self) -> None:
        app = ModalTestApp()
        results: List[Optional[str]] = []
        async with app.run_test() as pilot:
            app.push_screen(LinkRevisionScreen(), results.append)
            await pilot.pause()
            await pilot.press('enter')
            await pilot.pause()
            assert results == []
            assert isinstance(app.screen, LinkRevisionScreen)


class TestLinkRevisionConfirmScreen:
    """Tests for the LinkRevisionConfirmScreen confirmation modal."""

    @pytest.mark.asyncio
    async def test_confirm_returns_true(self) -> None:
        app = ModalTestApp()
        results: List[Optional[bool]] = []
        async with app.run_test() as pilot:
            app.push_screen(
                LinkRevisionConfirmScreen('Sub', 4, 8, 'Srinivas'), results.append
            )
            await pilot.pause()
            await pilot.press('enter')
            await pilot.pause()
            assert results == [True]

    @pytest.mark.asyncio
    async def test_escape_returns_false(self) -> None:
        app = ModalTestApp()
        results: List[Optional[bool]] = []
        async with app.run_test() as pilot:
            app.push_screen(
                LinkRevisionConfirmScreen('Sub', 4, 8, 'Srinivas'), results.append
            )
            await pilot.pause()
            await pilot.press('escape')
            await pilot.pause()
            assert results == [False]

    @pytest.mark.asyncio
    async def test_warning_is_shown(self) -> None:
        app = ModalTestApp()
        async with app.run_test() as pilot:
            app.push_screen(
                LinkRevisionConfirmScreen(
                    'Sub', 4, 8, 'Srinivas', warning='will be absorbed'
                )
            )
            await pilot.pause()
            warning = app.screen.query_one('#link-confirm-warning', Static)
            assert 'absorbed' in _static_text(warning)


# ---------------------------------------------------------------------------
# WorkerScreen
# ---------------------------------------------------------------------------


class TestWorkerScreen:
    """Tests for the generic worker modal.

    Regression coverage for the crash reported when opening a series right
    after another network request was cancelled: the lore node's cancel flag
    stayed set, so the fresh fetch raised OperationCancelledError immediately
    (msgid 21fe13c1-5c9a-4a47-b97b-251da4748aea@sirena.org.uk).
    """

    @pytest.mark.asyncio
    async def test_on_mount_resets_cancel_flag(self) -> None:
        """A fetch must clear any stale cancel flag before it runs."""
        app = ModalTestApp()
        node = mock.Mock()
        dismissed: List[Any] = []

        def _work() -> str:
            return 'done'

        with mock.patch.object(b4, 'get_lore_node', return_value=node):
            async with app.run_test() as pilot:
                app.push_screen(WorkerScreen('Working…', _work), dismissed.append)
                await pilot.pause()
                await app.workers.wait_for_complete()
                await pilot.pause()

        node.reset_cancel.assert_called_once()
        assert dismissed == ['done']

    @pytest.mark.asyncio
    async def test_worker_error_notifies_without_crashing(self) -> None:
        """A fetch failure surfaces as a notification, not a TUI crash."""
        app = ModalTestApp()
        node = mock.Mock()
        dismissed: List[Any] = []

        def _work() -> str:
            raise ValueError('boom')

        with mock.patch.object(b4, 'get_lore_node', return_value=node):
            async with app.run_test() as pilot:
                with mock.patch.object(app, 'notify') as notify:
                    app.push_screen(WorkerScreen('Working…', _work), dismissed.append)
                    await pilot.pause()
                    await app.workers.wait_for_complete()
                    await pilot.pause()
                    # The app is still alive and the modal was dismissed.
                    assert dismissed == [None]
                    notify.assert_called_once()
                    assert notify.call_args.kwargs.get('severity') == 'error'

    @pytest.mark.asyncio
    async def test_cancellation_dismisses_silently(self) -> None:
        """A genuine cancellation dismisses without an error popup."""
        app = ModalTestApp()
        node = mock.Mock()
        dismissed: List[Any] = []

        def _work() -> str:
            raise liblore.OperationCancelledError('Request cancelled')

        with mock.patch.object(b4, 'get_lore_node', return_value=node):
            async with app.run_test() as pilot:
                with mock.patch.object(app, 'notify') as notify:
                    app.push_screen(WorkerScreen('Working…', _work), dismissed.append)
                    await pilot.pause()
                    await app.workers.wait_for_complete()
                    await pilot.pause()
                    assert dismissed == [None]
                    notify.assert_not_called()


# ---------------------------------------------------------------------------
# _FetchViewerScreen
# ---------------------------------------------------------------------------


class _RaisingViewer(_FetchViewerScreen):
    """A fetch-viewer whose fetch raises a caller-supplied exception."""

    def __init__(self, error: Exception) -> None:
        super().__init__()
        self._error = error

    def _fetch(self) -> Any:
        raise self._error

    def _show_result(self, result: Any) -> None:  # pragma: no cover - never hit
        pass


class TestFetchViewerScreen:
    """The series/CI fetch viewer shares the sticky-cancel-flag contract.

    It runs through run_lore_worker() (reset + exit_on_error=False), so a
    fetch failure surfaces in the viewer and a genuine cancellation dismisses
    quietly instead of printing "Request cancelled".
    """

    @pytest.mark.asyncio
    async def test_on_mount_resets_cancel_flag(self) -> None:
        app = ModalTestApp()
        node = mock.Mock()

        with mock.patch.object(b4, 'get_lore_node', return_value=node):
            async with app.run_test() as pilot:
                app.push_screen(_RaisingViewer(ValueError('boom')))
                await pilot.pause()
                await app.workers.wait_for_complete()
                await pilot.pause()
                # The screen stays up showing the error rather than crashing.
                node.reset_cancel.assert_called_once()
                assert isinstance(app.screen, _FetchViewerScreen)

    @pytest.mark.asyncio
    async def test_cancellation_dismisses_quietly(self) -> None:
        """A cancelled fetch dismisses the viewer without an error screen."""
        app = ModalTestApp()
        node = mock.Mock()
        dismissed: List[Any] = []
        err = liblore.OperationCancelledError('Request cancelled')

        with mock.patch.object(b4, 'get_lore_node', return_value=node):
            async with app.run_test() as pilot:
                app.push_screen(_RaisingViewer(err), dismissed.append)
                await pilot.pause()
                await app.workers.wait_for_complete()
                await pilot.pause()
                # Dismissed back to the host screen, no error left on display.
                assert dismissed == [None]
                assert not isinstance(app.screen, _FetchViewerScreen)


# ---------------------------------------------------------------------------
# TakeScreen
# ---------------------------------------------------------------------------


class TestTakeScreen:
    """Tests for the TakeScreen default take-method selection."""

    @pytest.mark.asyncio
    async def test_multipatch_with_cover_defaults_to_merge(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                TakeScreen('main', 'b4/review/test', num_patches=3, has_cover=True)
            )
            await pilot.pause()
            assert app.screen.query_one('#take-method', Select).value == 'merge'
            assert not app.screen.query('#take-nocover')

    @pytest.mark.asyncio
    async def test_multipatch_without_cover_defaults_to_linear(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                TakeScreen('main', 'b4/review/test', num_patches=3, has_cover=False)
            )
            await pilot.pause()
            assert app.screen.query_one('#take-method', Select).value == 'linear'
            hint = app.screen.query_one('#take-nocover', Static)
            assert 'No cover letter' in _static_text(hint)

    @pytest.mark.asyncio
    async def test_single_patch_defaults_to_linear(self) -> None:
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                TakeScreen('main', 'b4/review/test', num_patches=1, has_cover=True)
            )
            await pilot.pause()
            assert app.screen.query_one('#take-method', Select).value == 'linear'

    @pytest.mark.asyncio
    async def test_explicit_default_overrides_no_cover(self) -> None:
        """A configured default method wins over the no-cover heuristic."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                TakeScreen(
                    'main',
                    'b4/review/test',
                    num_patches=3,
                    default_method='merge',
                    has_cover=False,
                )
            )
            await pilot.pause()
            assert app.screen.query_one('#take-method', Select).value == 'merge'
            # The missing cover is still surfaced even when the default is
            # overridden.
            assert app.screen.query('#take-nocover')

    @pytest.mark.asyncio
    async def test_thank_and_archive_defaults_off(self) -> None:
        """The thank-and-archive checkbox is opt-in per take by default."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            app.push_screen(
                TakeScreen('main', 'b4/review/test', num_patches=3, has_cover=True)
            )
            await pilot.pause()
            assert not app.screen.query_one('#take-thank-archive', Checkbox).value

    @pytest.mark.asyncio
    async def test_thank_and_archive_config_default(self) -> None:
        """b4.review-thank-and-archive pre-checks the checkbox, and the
        result attribute follows the widget on confirm."""
        app = ModalTestApp()

        async with app.run_test() as pilot:
            screen = TakeScreen(
                'main',
                'b4/review/test',
                num_patches=3,
                has_cover=True,
                default_thank_archive=True,
            )
            app.push_screen(screen)
            await pilot.pause()
            assert app.screen.query_one('#take-thank-archive', Checkbox).value
            assert screen.thank_and_archive


class TestSendKeybindings:
    """Ctrl-y is the official send chord everywhere mail is sent; the
    old capital-S binding survives as an unlisted legacy alias."""

    @staticmethod
    def _send_bindings(screen_cls: Any) -> Dict[str, Any]:
        return {b.key: b for b in screen_cls.BINDINGS if b.action == 'send'}

    @pytest.mark.parametrize(
        'screen_path',
        [
            'b4.review_tui._modals.ThankScreen',
            'b4.review_tui._modals.FollowupReplyPreviewScreen',
            'b4.review_tui._review_app.ReviewApp',
            'b4.bugs._tui.ReplyPreviewScreen',
        ],
    )
    def test_ctrl_y_official_s_legacy(self, screen_path: str) -> None:
        modpath, clsname = screen_path.rsplit('.', 1)
        mod = importlib.import_module(modpath)
        bindings = self._send_bindings(getattr(mod, clsname))
        assert set(bindings) == {'ctrl+y', 'S'}
        assert bindings['ctrl+y'].show
        assert not bindings['S'].show
