#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2024 by the Linux Foundation
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'

import json
import pathlib
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple

from rich.markup import escape
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Footer, Label, ListItem, ListView, ProgressBar, Static
from textual.worker import Worker, WorkerState

import b4
import b4.review
import b4.review.tracking
import liblore
from b4.review_tui._common import (
    QUIT_BINDINGS,
    LoreNodeShutdownMixin,
    SeparatedFooter,
    _fix_ansi_theme,
    ci_styles,
    logger,
    notify_quit_hint,
    pad_display,
    resolve_styles,
    run_lore_worker,
)
from b4.review_tui._modals import (
    PW_HELP_LINES,
    ApplyStateModal,
    CIChecksScreen,
    HelpScreen,
    LimitScreen,
    SetStateScreen,
)


class PwFetchProgress(Message):
    """Posted from the fetch worker as pages of patches arrive.

    Carries the number of patches fetched so far and the total expected, so the
    main thread can drive the loading progress bar.
    """

    def __init__(self, fetched: int, total: int) -> None:
        self.fetched = fetched
        self.total = total
        super().__init__()


class _TrackData(NamedTuple):
    """Series metadata gathered off the UI thread, ready to record in the db."""

    change_id: str
    revision: int
    subject: Optional[str]
    sender_name: Optional[str]
    sender_email: Optional[str]
    sent_at: Optional[str]
    message_id: str
    num_patches: int


def _format_series_label(
    series: Dict[str, Any],
    tracked: bool,
    ts: Optional[Dict[str, str]] = None,
    show_delegate: bool = True,
    selected: bool = False,
) -> Text:
    """Build a Text label for a series row.

    *ts* is a resolved theme styles dict from :func:`resolve_styles`.
    When *show_delegate* is ``False`` the delegate column is omitted.
    *selected* marks the row for a bulk action with a leading ``*``.
    """
    track_mark = 'T' if tracked else ' '
    ci_state = series.get('check') or 'pending'
    ci_map = (
        ci_styles(ts)
        if ts
        else {
            'pending': 'dim',
            'success': 'green',
            'warning': 'red',
            'fail': 'bold red',
        }
    )
    ci_style = ci_map.get(ci_state, ci_map['pending'])
    date = (series.get('date') or '')[:10]
    state = f'{(series.get("state") or "new"):<15s}'
    submitter = pad_display(series.get('submitter') or 'Unknown', 30)
    name = series.get('name') or '(no subject)'
    text = Text()
    sel_style = (f'bold {ts["warning"]}' if ts else 'bold') if selected else ''
    text.append('*' if selected else ' ', style=sel_style)
    text.append(track_mark)
    text.append('\u25cf', style=ci_style)
    if show_delegate:
        delegate = pad_display(series.get('delegate') or '', 15)
        text.append(f' {date}  {state} {submitter} {delegate} {name}')
    else:
        text.append(f' {date}  {state} {submitter} {name}')
    return text


class PwSeriesItem(ListItem):
    """A single Patchwork series entry in the listing."""

    ACTION_REQUIRED_STATES = ('new', 'under-review')

    def __init__(
        self,
        series: Dict[str, Any],
        tracked: bool = False,
        show_delegate: bool = True,
        selected: bool = False,
    ) -> None:
        super().__init__()
        self.series = series
        self.tracked = tracked
        self.show_delegate = show_delegate
        self.selected = selected
        state = series.get('state', 'new')
        if state not in self.ACTION_REQUIRED_STATES:
            self.add_class('--dimmed')
        if tracked:
            self.add_class('--tracked')

    def compose(self) -> ComposeResult:
        ts = resolve_styles(self.app)
        yield Label(
            _format_series_label(
                self.series,
                self.tracked,
                ts,
                show_delegate=self.show_delegate,
                selected=self.selected,
            ),
            markup=False,
        )

    def refresh_label(self) -> None:
        """Re-render this row's label after a state/tracked/selected change."""
        ts = resolve_styles(self.app)
        self.query_one(Label).update(
            _format_series_label(
                self.series,
                self.tracked,
                ts,
                show_delegate=self.show_delegate,
                selected=self.selected,
            )
        )


class PwApp(LoreNodeShutdownMixin, App[None]):
    """Textual app for browsing Patchwork series."""

    TITLE = 'b4 review pw'

    DEFAULT_CSS = """
    PwApp {
        layout: vertical;
    }
    #pw-title {
        dock: top;
        width: 100%;
        height: 1;
        background: $primary-darken-2;
        color: $text;
        text-style: bold;
        content-align: left middle;
    }
    #pw-header {
        width: 100%;
        height: 1;
        background: $surface;
        color: $text-muted;
    }
    #pw-list {
        height: 1fr;
    }
    PwSeriesItem.--dimmed Label {
        color: $text-disabled;
    }
    PwSeriesItem.--hidden Label {
        color: $text-disabled;
        text-style: dim italic;
    }
    PwSeriesItem.--tracked Label {
        color: $success-lighten-2;
        text-style: bold;
    }
    PwSeriesItem.--tracked.--dimmed Label {
        color: $success;
    }
    #pw-loading {
        height: 1fr;
        align: center middle;
    }
    #pw-loading-dialog {
        width: 70;
        height: auto;
        border: solid $accent;
        background: $surface;
        padding: 1 2;
    }
    #pw-loading-title {
        text-style: bold;
        margin-bottom: 1;
    }
    #pw-loading-status {
        margin-bottom: 1;
    }
    #pw-loading-bar {
        width: 100%;
        height: 1;
    }
    PwApp:ansi #pw-title {
        background: ansi_bright_black;
        color: ansi_default;
        text-style: bold;
    }
    PwApp:ansi #pw-header {
        background: ansi_default;
        color: ansi_default;
        text-style: dim;
    }
    PwApp:ansi PwSeriesItem.--dimmed Label {
        color: ansi_default;
        text-style: dim;
    }
    PwApp:ansi PwSeriesItem.--hidden Label {
        color: ansi_default;
        text-style: dim italic;
    }
    PwApp:ansi PwSeriesItem.--tracked Label {
        color: ansi_default;
        text-style: bold;
    }
    PwApp:ansi PwSeriesItem.--tracked.--dimmed Label {
        color: ansi_default;
    }
    """

    BINDING_GROUPS = {
        'view': 'Series',
        'ci_checks': 'Series',
        'track_series': 'Series',
        'toggle_mark': 'Series',
        'set_state': 'Series',
        'hide_series': 'Series',
        'refresh': 'App',
        'limit': 'App',
        'toggle_show_hidden': 'App',
        'quit': 'App',
        'help': 'App',
    }

    BINDINGS = [
        # Hidden navigation bindings
        Binding('j', 'cursor_down', 'Down', show=False),
        Binding('k', 'cursor_up', 'Up', show=False),
        Binding('u', 'unhide_series', 'unhide', show=False),
        Binding('a', 'mark_all', 'mark all', show=False),
        Binding('escape', 'clear_marks', 'clear marks', show=False),
        # Series-specific actions
        Binding('c', 'ci_checks', 'ci checks'),
        Binding('t', 'track_series', 'track'),
        Binding('space', 'toggle_mark', 'mark', key_display='space'),
        Binding('s', 'set_state', 'set state'),
        Binding('h', 'hide_series', 'hide'),
        # App-global actions
        Binding('r', 'refresh', 'refresh'),
        Binding('l', 'limit', 'limit'),
        Binding('H', 'toggle_show_hidden', 'show hidden', key_display='H'),
        *QUIT_BINDINGS,
        Binding('question_mark', 'help', 'help', key_display='?'),
    ]

    def __init__(
        self,
        pwkey: str,
        pwurl: str,
        pwproj: str,
        email_dryrun: bool = False,
        patatt_sign: bool = True,
    ) -> None:
        super().__init__()
        self._pwkey = pwkey
        self._pwurl = pwurl
        self._pwproj = pwproj
        self._email_dryrun = email_dryrun
        self._patatt_sign = patatt_sign
        self._states: List[Dict[str, Any]] = []
        self._hidden_ids: Set[int] = set()
        self._tracked_ids: Set[int] = set()
        # Series ids marked for a bulk action (set state). Persists across
        # limit/hidden toggles; pruned to still-existing series on (re)fetch.
        self._selected_ids: Set[int] = set()
        self._show_hidden: bool = False
        self._limit_pattern: str = ''
        self._all_series: List[Dict[str, Any]] = []
        # Title without the trailing "· N selected" suffix, set on each refresh.
        self._base_title: str = ''
        self._tracking_identifier: Optional[str] = None
        self._tracking_enabled: bool = False
        self._track_ctx: Optional[Dict[str, Any]] = None
        # Day window the listing was limited to (set when the backlog is large),
        # or None when the full backlog was fetched.
        self._window_days: Optional[int] = None
        # Total outstanding patches probed, and whether we've already shown the
        # large-backlog notice this session (it pops up once, not every reload).
        self._backlog_count: int = 0
        self._backlog_notice_shown: bool = False
        self._load_local_data()
        self._load_tracking_data()

    def _get_local_data_path(self) -> pathlib.Path:
        datadir = b4.get_data_dir('patchwork')
        return pathlib.Path(datadir) / f'{self._pwproj}.json'

    def _load_local_data(self) -> None:
        path = self._get_local_data_path()
        if not path.exists():
            return
        try:
            with open(path, 'r', encoding='utf-8') as fp:
                data = json.load(fp)
            for sid, sdata in data.get('series', {}).items():
                if sdata.get('hidden'):
                    self._hidden_ids.add(int(sid))
        except (json.JSONDecodeError, OSError):
            pass

    def _load_tracking_data(self) -> None:
        # Try to resolve tracking identifier from current repo or use pwproj
        topdir = b4.git_get_toplevel()
        if topdir:
            self._tracking_identifier = b4.review.tracking.get_repo_identifier(topdir)
        if not self._tracking_identifier:
            # Fall back to patchwork project name
            self._tracking_identifier = self._pwproj
        if self._tracking_identifier and b4.review.tracking.db_exists(
            self._tracking_identifier
        ):
            self._tracking_enabled = True
            self._tracked_ids = b4.review.tracking.get_tracked_pw_series_ids(
                self._tracking_identifier
            )

    def _save_local_data(self) -> None:
        path = self._get_local_data_path()
        data: Dict[str, Any] = {'series': {}}
        for sid in self._hidden_ids:
            data['series'][str(sid)] = {'hidden': True}
        try:
            with open(path, 'w', encoding='utf-8') as fp:
                json.dump(data, fp, indent=2)
        except OSError:
            pass

    def _make_loading(self) -> Vertical:
        """Build the centered loading dialog (title + status + progress bar).

        Mirrors the "update all" modal in the tracking app so the loading
        state looks like a proper panel rather than a bar floating in space.
        The bar starts with no total, so it pulses (indeterminate) until the
        fetch worker probes the backlog and posts the first PwFetchProgress.
        """
        return Vertical(
            Vertical(
                Label('Loading series from Patchwork', id='pw-loading-title'),
                Label('Connecting…', id='pw-loading-status'),
                ProgressBar(id='pw-loading-bar', show_eta=False),
                id='pw-loading-dialog',
            ),
            id='pw-loading',
        )

    def compose(self) -> ComposeResult:
        yield Static(' Patchwork — loading\u2026', id='pw-title', markup=False)
        yield self._make_loading()
        yield SeparatedFooter()

    def on_mount(self) -> None:
        _fix_ansi_theme(self)
        self.run_worker(self._fetch_initial, name='_fetch_initial', thread=True)

    def _fetch_initial(
        self,
    ) -> Tuple['b4.review.PwFetchResult', List[Dict[str, Any]]]:
        import b4.review
        from b4.review_tui._common import _quiet_worker

        def _progress(fetched: int, total: int) -> None:
            self.post_message(PwFetchProgress(fetched, total))

        with _quiet_worker():
            result = b4.review.pw_fetch_series(
                self._pwkey, self._pwurl, self._pwproj, progress_cb=_progress
            )
            states = b4.review.pw_fetch_states(self._pwkey, self._pwurl, self._pwproj)
            return result, states

    def on_pw_fetch_progress(self, event: PwFetchProgress) -> None:
        """Drive the loading progress bar and status from the fetch worker.

        Patches arrive a page at a time, so a determinate bar can only advance
        once per page.  When the whole result fits in a single page there are
        no intermediate steps -- the bar would just sit at 0 and jump to full
        -- so keep it pulsing (indeterminate) and let the status line carry the
        count.  Multi-page fetches get a determinate, per-page-advancing bar.
        """
        single_page = event.total <= b4.review.PW_PER_PAGE
        for bar in self.query('#pw-loading-bar').results(ProgressBar):
            if not single_page:
                bar.update(total=event.total, progress=event.fetched)
        for status in self.query('#pw-loading-status').results(Label):
            if single_page:
                status.update(f'Loading {event.total:,} patches…')
            else:
                status.update(f'{event.fetched:,} of {event.total:,} patches')

    async def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        if event.worker.name == '_fetch_initial':
            if event.state == WorkerState.SUCCESS:
                if event.worker.result is None:
                    return
                result, states = event.worker.result
                self._states = states
                self._window_days = result.window_days
                self._backlog_count = result.outstanding
                await self._populate(result.series)
                self._maybe_notify_backlog()
            elif event.state == WorkerState.ERROR:
                for widget in self.query('#pw-loading'):
                    await widget.remove()
                self.query_one('#pw-title', Static).update(
                    ' Patchwork — error fetching series'
                )
                self.notify(str(event.worker.error), severity='error')
        elif event.worker.name == '_track_series':
            if event.state == WorkerState.SUCCESS:
                data = event.worker.result
                if data is None:
                    return
                self._finish_tracking(data)
            elif event.state == WorkerState.ERROR:
                self._track_ctx = None
                err = event.worker.error
                # A sticky-cancel left by a sibling app dismisses quietly.
                if isinstance(err, liblore.OperationCancelledError):
                    return
                self.notify(f'Error retrieving series: {err}', severity='error')

    async def _populate(self, series_list: List[Dict[str, Any]]) -> None:
        loading = self.query('#pw-loading')
        for widget in loading:
            await widget.remove()
        self._all_series = series_list
        await self._refresh_list()

    def _maybe_notify_backlog(self) -> None:
        """Toast the large-backlog notice once, if the listing was windowed."""
        if self._backlog_notice_shown or not self._window_days:
            return
        self._backlog_notice_shown = True
        # notify() gained a markup= kwarg only in newer Textual (absent in the
        # 2.x that Debian ships), so escape the one user-controlled field
        # instead of disabling markup -- works on every supported version.
        proj = escape(self._pwproj or 'This project')
        self.notify(
            f'{proj} has {self._backlog_count:,} outstanding patches — '
            f'showing only the last {self._window_days} days.',
            title='Large Patchwork backlog',
            severity='warning',
            timeout=10,
        )

    async def _refresh_list(self) -> None:
        widgets = list(self.query('#pw-header, #pw-list'))
        for widget in widgets:
            await widget.remove()
        # Drop marks for series that no longer exist (e.g. after a re-fetch).
        valid_ids = {s.get('id') for s in self._all_series if s.get('id') is not None}
        self._selected_ids &= valid_ids
        visible = []
        hidden_count = 0
        for s in self._all_series:
            sid = s.get('id')
            is_hidden = sid in self._hidden_ids
            if is_hidden:
                hidden_count += 1
                if self._show_hidden:
                    visible.append((s, True))
            else:
                visible.append((s, False))
        if self._limit_pattern:
            visible = [
                (s, h)
                for s, h in visible
                if self._matches_limit(s, self._limit_pattern)
            ]
        limit_suffix = f', limit: {self._limit_pattern}' if self._limit_pattern else ''
        if hidden_count and not self._show_hidden:
            base = f' Patchwork — {len(visible)} series ({hidden_count} hidden{limit_suffix})'
        elif hidden_count and self._show_hidden:
            base = (
                f' Patchwork — {len(visible)} series '
                f'(showing {hidden_count} hidden{limit_suffix})'
            )
        elif self._limit_pattern:
            base = f' Patchwork — {len(visible)} action-required series{limit_suffix}'
        else:
            base = f' Patchwork — {len(visible)} action-required series'
        # When the backlog forced a recent-only fetch, say so: older series are
        # deliberately not loaded, which would otherwise look like data loss.
        if self._window_days:
            base += f' · last {self._window_days} days'
        self._base_title = base
        self._update_title_selection()
        if not visible:
            return
        # Hide the delegate column when all values are empty or identical
        delegates = {s.get('delegate', '') or '' for s, _h in visible}
        show_delegate = len(delegates) > 1
        if show_delegate:
            header_text = f'    {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Delegate":<15s} {"Series"}'
        else:
            header_text = (
                f'    {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Series"}'
            )
        header = Static(header_text, id='pw-header')
        items = []
        for s, is_hidden in visible:
            sid = s.get('id')
            is_tracked = sid in self._tracked_ids if sid else False
            is_selected = sid in self._selected_ids if sid else False
            item = PwSeriesItem(
                s,
                tracked=is_tracked,
                show_delegate=show_delegate,
                selected=is_selected,
            )
            if is_hidden:
                item.add_class('--hidden')
            items.append(item)
        lv = ListView(*items, id='pw-list')
        await self.mount(header, before=self.query_one(Footer))
        await self.mount(lv, before=self.query_one(Footer))
        lv.focus()

    async def action_refresh(self) -> None:
        """Re-fetch series from Patchwork and rebuild the list."""
        for widget in self.query('#pw-header, #pw-list'):
            await widget.remove()
        self.query_one('#pw-title', Static).update(' Patchwork — refreshing\u2026')
        await self.mount(self._make_loading(), before=self.query_one(Footer))
        self.run_worker(self._fetch_initial, name='_fetch_initial', thread=True)

    @staticmethod
    def _matches_limit(series: Dict[str, Any], pattern: str) -> bool:
        """Check whether *series* matches the limit *pattern*.

        Tokens are split on whitespace and combined with AND logic:
        - ``s:<substring>`` matches against the state field
        - ``d:<substring>`` matches against the delegate field
        - bare text matches against name or submitter
        """
        for token in pattern.lower().split():
            if token.startswith('s:'):
                needle = token[2:]
                if needle not in (series.get('state', '') or '').lower():
                    return False
            elif token.startswith('d:'):
                needle = token[2:]
                if needle not in (series.get('delegate', '') or '').lower():
                    return False
            else:
                if (
                    token not in (series.get('name', '') or '').lower()
                    and token not in (series.get('submitter', '') or '').lower()
                ):
                    return False
        return True

    def action_limit(self) -> None:
        delegates = {s.get('delegate', '') or '' for s in self._all_series}
        if len(delegates) > 1:
            hint = 'Prefixes: s:<state>  d:<delegate>'
        else:
            hint = 'Prefixes: s:<state>'
        self.push_screen(
            LimitScreen(self._limit_pattern, hint=hint), callback=self._on_limit
        )

    async def _on_limit(self, result: Optional[str]) -> None:
        if result is None:
            return
        self._limit_pattern = result
        await self._refresh_list()

    def action_cursor_down(self) -> None:
        try:
            self.query_one('#pw-list', ListView).action_cursor_down()
        except Exception:
            pass

    def action_cursor_up(self) -> None:
        try:
            self.query_one('#pw-list', ListView).action_cursor_up()
        except Exception:
            pass

    def _visible_items(self) -> List['PwSeriesItem']:
        """All series rows currently mounted in the list view."""
        try:
            lv = self.query_one('#pw-list', ListView)
        except Exception:
            return []
        return [c for c in lv.children if isinstance(c, PwSeriesItem)]

    def _update_title_selection(self) -> None:
        """Append a "· N selected" suffix to the title when marks are active."""
        try:
            title = self.query_one('#pw-title', Static)
        except Exception:
            return
        base = self._base_title
        count = len(self._selected_ids)
        if count:
            base = f'{base} · {count} selected'
        title.update(base)

    def action_toggle_mark(self) -> None:
        """Mark/unmark the highlighted series, then advance the cursor."""
        item = self._get_highlighted_item()
        if item is None:
            return
        sid = item.series.get('id')
        if sid is None:
            return
        if sid in self._selected_ids:
            self._selected_ids.discard(sid)
            item.selected = False
        else:
            self._selected_ids.add(sid)
            item.selected = True
        item.refresh_label()
        self._update_title_selection()
        self.action_cursor_down()

    def action_mark_all(self) -> None:
        """Mark every visible series, or unmark them all if already marked."""
        items = self._visible_items()
        if not items:
            return
        all_marked = all(item.series.get('id') in self._selected_ids for item in items)
        for item in items:
            sid = item.series.get('id')
            if sid is None:
                continue
            if all_marked:
                self._selected_ids.discard(sid)
                item.selected = False
            else:
                self._selected_ids.add(sid)
                item.selected = True
            item.refresh_label()
        self._update_title_selection()

    def action_clear_marks(self) -> None:
        """Drop all marks (Esc)."""
        if not self._selected_ids:
            return
        self._selected_ids.clear()
        for item in self._visible_items():
            if item.selected:
                item.selected = False
                item.refresh_label()
        self._update_title_selection()

    def action_view(self) -> None:
        """View a Patchwork series thread in the lite thread viewer."""
        item = self._get_highlighted_item()
        if item is None:
            return
        msgid = item.series.get('msgid', '')
        if not msgid:
            self.notify('No message-id available for this series', severity='error')
            return
        from b4.review_tui._lite_app import LiteThreadScreen

        self.push_screen(
            LiteThreadScreen(
                msgid, email_dryrun=self._email_dryrun, patatt_sign=self._patatt_sign
            )
        )

    def action_ci_checks(self) -> None:
        """View CI check details for the highlighted series."""
        item = self._get_highlighted_item()
        if item is None:
            return
        check = item.series.get('check') or 'pending'
        if check == 'pending':
            self.notify(
                'No CI checks available for this series', severity='information'
            )
            return
        self.push_screen(CIChecksScreen(self._pwkey, self._pwurl, item.series))

    def _get_highlighted_item(self) -> Optional['PwSeriesItem']:
        try:
            lv = self.query_one('#pw-list', ListView)
        except Exception:
            return None
        child = lv.highlighted_child
        if isinstance(child, PwSeriesItem):
            return child
        return None

    def _state_targets(self) -> List[Dict[str, Any]]:
        """Series the set-state action applies to.

        All marked series when any are marked, otherwise the highlighted row.
        Marked series are resolved from the full listing so the action still
        covers rows hidden by the current limit filter.
        """
        if self._selected_ids:
            return [s for s in self._all_series if s.get('id') in self._selected_ids]
        item = self._get_highlighted_item()
        return [item.series] if item is not None else []

    def action_set_state(self) -> None:
        if not self._states:
            self.notify('States not loaded yet', severity='warning')
            return
        targets = self._state_targets()
        if not targets:
            return
        item = self._get_highlighted_item()
        current_state = item.series.get('state', 'new') if item is not None else 'new'
        self.push_screen(
            SetStateScreen(self._states, current_state),
            callback=lambda result: self._on_set_state(result, targets),
        )

    def _on_set_state(
        self, result: Optional[Tuple[str, bool]], targets: List[Dict[str, Any]]
    ) -> None:
        if result is None:
            return
        new_state, archived = result
        patch_ids: List[int] = []
        for s in targets:
            patch_ids.extend(s.get('patch_ids', []))
        if not patch_ids:
            self.notify('No patch IDs for selected series', severity='warning')
            return

        if len(targets) == 1:
            label = targets[0].get('name') or '(no subject)'
        else:
            label = f'{len(targets)} series ({len(patch_ids)} patches)'
        self.push_screen(
            ApplyStateModal(
                self._pwkey, self._pwurl, patch_ids, new_state, archived, label
            ),
            callback=lambda res: self._on_apply_complete(res, targets),
        )

    def _on_apply_complete(
        self, result: Optional[Tuple[int, int, str]], targets: List[Dict[str, Any]]
    ) -> None:
        assert result is not None
        ok, fail, new_state = result
        if fail:
            self.notify(f'{ok} updated, {fail} failed', severity='warning')
        elif len(targets) == 1:
            self.notify(f'{ok} patch(es) set to {new_state}', severity='information')
        else:
            self.notify(
                f'{len(targets)} series set to {new_state} ({ok} patches)',
                severity='information',
            )
        mounted = {item.series.get('id'): item for item in self._visible_items()}
        for s in targets:
            s['state'] = new_state
            item = mounted.get(s.get('id'))
            if item is None:
                continue
            item.selected = False
            if new_state in PwSeriesItem.ACTION_REQUIRED_STATES:
                item.remove_class('--dimmed')
            else:
                item.add_class('--dimmed')
            item.refresh_label()
        self._selected_ids.clear()
        self._update_title_selection()

    def action_track_series(self) -> None:
        if not self._tracking_enabled:
            self.notify(
                'Repository not enrolled. Enroll with: b4 review enroll',
                severity='warning',
            )
            return
        item = self._get_highlighted_item()
        if item is None:
            return
        sid = item.series.get('id')
        if sid is None:
            return
        if sid in self._tracked_ids:
            self.notify('Series already tracked', severity='information')
            return

        series_name = item.series.get('name') or '(no subject)'
        msgid = item.series.get('msgid', '')
        if not msgid:
            self.notify('No message-id available for this series', severity='error')
            return

        pw_series_id: int = sid

        # Retrieve from lore in a worker thread rather than suspending the UI.
        # The fetch produces logging output, which is why this used to drop out
        # of the TUI (and flicker); _track_fetch silences it with _quiet_worker
        # instead, so the whole thing stays on-screen.
        self._track_ctx = {
            'item': item,
            'series_name': series_name,
            'pw_series_id': pw_series_id,
        }
        self.notify(f'Retrieving series: {series_name}…', timeout=3)
        run_lore_worker(self, lambda: self._track_fetch(msgid), name='_track_series')

    def _track_fetch(self, msgid: str) -> _TrackData:
        """Fetch and parse a series for tracking, off the UI thread.

        Runs under _quiet_worker() so the lore-retrieve logging doesn't trample
        the screen -- that's what lets us avoid suspending the TUI.  Anything
        raised here surfaces through on_worker_state_changed's ERROR branch.
        """
        from b4.review_tui._common import _quiet_worker

        with _quiet_worker():
            logger.info('Retrieving series: %s', msgid)
            msgs = b4.review._retrieve_messages(msgid)
            lser = b4.review._get_lore_series(msgs)

            try:
                ref_msg = b4.review.get_reference_message(lser)
            except LookupError:
                raise LookupError(
                    'Could not find cover letter or first patch'
                ) from None

            if lser.change_id:
                change_id = lser.change_id
            else:
                date_prefix = ref_msg.date.strftime('%Y%m%d')
                slug = ref_msg.lsubject.get_slug(sep='-', with_counter=False)[:60]
                change_id = f'{date_prefix}-{slug}-{lser.fingerprint[:12]}'
                logger.info('No change-id found, generated: %s', change_id)

            sent_at: Optional[str] = None
            if ref_msg.date:
                sent_at = ref_msg.date.isoformat()

            return _TrackData(
                change_id=change_id,
                revision=lser.revision,
                subject=lser.subject,
                sender_name=lser.fromname,
                sender_email=lser.fromemail,
                sent_at=sent_at,
                message_id=ref_msg.msgid,
                num_patches=lser.expected,
            )

    def _finish_tracking(self, data: _TrackData) -> None:
        """Record a fetched series in the db and update the row (UI thread)."""
        ctx = self._track_ctx
        self._track_ctx = None
        if not ctx:
            return
        item = ctx['item']
        series_name = ctx['series_name']
        pw_series_id = ctx['pw_series_id']

        assert self._tracking_identifier is not None
        conn = b4.review.tracking.get_db(self._tracking_identifier)
        b4.review.tracking.add_series_to_db(
            conn,
            data.change_id,
            data.revision,
            data.subject,
            data.sender_name,
            data.sender_email,
            data.sent_at,
            data.message_id,
            data.num_patches,
            pw_series_id,
        )
        conn.close()

        self._tracked_ids.add(pw_series_id)
        item.tracked = True
        item.add_class('--tracked')
        item.refresh_label()
        self.notify(
            f'Started tracking: {series_name}', severity='information', timeout=3
        )

    async def action_hide_series(self) -> None:
        item = self._get_highlighted_item()
        if item is None:
            return
        sid = item.series.get('id')
        if sid is None:
            return
        if sid in self._hidden_ids:
            self.notify('Series already hidden', severity='information')
            return
        self._hidden_ids.add(sid)
        self._save_local_data()
        title = item.series.get('name') or '(no subject)'
        self.notify(f'Hidden: {title}', timeout=3)
        await self._refresh_list()

    async def action_toggle_show_hidden(self) -> None:
        if not self._hidden_ids:
            self.notify('No hidden series', severity='information')
            return
        self._show_hidden = not self._show_hidden
        await self._refresh_list()

    async def action_unhide_series(self) -> None:
        item = self._get_highlighted_item()
        if item is None:
            return
        sid = item.series.get('id')
        if sid is None:
            return
        if sid not in self._hidden_ids:
            self.notify('Series not hidden', severity='information')
            return
        self._hidden_ids.discard(sid)
        self._save_local_data()
        title = item.series.get('name') or '(no subject)'
        self.notify(f'Restored: {title}', timeout=3)
        await self._refresh_list()

    def check_action(self, action: str, parameters: Tuple[Any, ...]) -> Optional[bool]:
        if action == 'ci_checks':
            item = self._get_highlighted_item()
            if item is None:
                return False
            return (item.series.get('check') or 'pending') != 'pending'
        if action == 'track_series':
            if not self._tracking_enabled:
                return True  # Allow action so user sees the helpful error message
            item = self._get_highlighted_item()
            if item is None:
                return False
            sid = item.series.get('id')
            return sid not in self._tracked_ids
        if action == 'toggle_show_hidden':
            return bool(self._hidden_ids)
        if action == 'unhide_series':
            item = self._get_highlighted_item()
            if item is None:
                return False
            sid = item.series.get('id')
            return sid in self._hidden_ids
        if action == 'hide_series':
            item = self._get_highlighted_item()
            if item is None:
                return False
            sid = item.series.get('id')
            return sid not in self._hidden_ids
        return True

    def on_list_view_selected(self, event: ListView.Selected) -> None:
        self.action_view()

    def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
        self.refresh_bindings()

    def action_help(self) -> None:
        """Show keybinding help."""
        self.push_screen(HelpScreen(PW_HELP_LINES))

    async def action_quit(self) -> None:
        self.exit()

    def action_quit_hint(self) -> None:
        """Warn that quitting takes a capital Q."""
        notify_quit_hint(self)
