#!/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 contextlib
import copy
import datetime
import email.message
import email.parser
import email.utils
import io
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from string import Template
from typing import (
    Any,
    Callable,
    Dict,
    Generator,
    List,
    Literal,
    Optional,
    Tuple,
    Union,
)

from rich.text import Text as RichText
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.widgets import Footer, Label, ListItem, ListView, Static
from textual.worker import Worker, WorkerState

import b4
import b4.mbox
import b4.review
import b4.review.tracking
import b4.ty
from b4.review._review import NO_COVER_NOTE
from b4.review_tui._common import (
    QUIT_BINDINGS,
    CheckRunnerMixin,
    LoreNodeShutdownMixin,
    ReplacementListView,
    SeparatedFooter,
    _fix_ansi_theme,
    _quiet_worker,
    _suspend_to_shell,
    _wait_for_enter,
    compute_range_diff,
    display_width,
    logger,
    mark_outgoing_seen,
    notify_quit_hint,
    pad_display,
    resolve_styles,
    run_lore_worker,
    suspend_and_edit,
)
from b4.review_tui._modals import (
    QUEUE_BUSY,
    TRACKING_HELP_LINES,
    AbandonConfirmScreen,
    ActionScreen,
    ArchiveConfirmScreen,
    BaseSelectionScreen,
    CheckLoadingScreen,
    CherryPickScreen,
    HelpScreen,
    LimitScreen,
    LinkRevisionConfirmScreen,
    LinkRevisionScreen,
    NewerRevisionWarningScreen,
    QueueDeliveryScreen,
    QueueScreen,
    RangeDiffScreen,
    RebaseScreen,
    RevisionChoiceScreen,
    SnoozeScreen,
    TakeConfirmScreen,
    TakeScreen,
    TargetBranchScreen,
    ThankScreen,
    UpdateAllScreen,
    UpdateRevisionScreen,
    WorkerScreen,
)

# Shortcut keys for the tracking-app action selector.
_ACTION_SHORTCUTS: Dict[str, str] = {
    'review': 'r',
    'take': 'T',
    'rebase': 'R',
    'waiting': 'w',
    'snooze': 's',
    'unsnooze': 'u',
    'upgrade': 'U',
    'link': 'l',
    'thank': 't',
    'abandon': 'A',
    'archive': 'x',
}


# Single-character Unicode symbols for each series status.
_STATUS_SYMBOLS: Dict[str, str] = {
    'new': '★',  # U+2605 black star
    'reviewing': '✎',  # U+270E lower right pencil (matches review app)
    'replied': '↩',  # U+21A9 leftwards arrow with hook
    'waiting': '↻',  # U+21BB clockwise open circle arrow
    'accepted': '∈',  # U+2208 element of
    'partial': '◐',  # U+25D0 circle with left half black (partially applied)
    'queued': '◷',  # U+25F7 white circle with upper right quadrant
    'snoozed': '⏸',  # U+23F8 double vertical bar
    'thanked': '✓',  # U+2713 check mark
    'gone': 'ø',  # U+00F8 latin small letter o with stroke
}

# Sort tier for each status.  Lower tier sorts higher in the list.
#   0 = active (needs review work)
#   1 = action required (awaiting maintainer decision)
#   2 = inactive (no action needed)
_STATUS_TIER: Dict[str, int] = {
    'new': 0,
    'reviewing': 0,
    'partial': 0,
    'replied': 1,
    'accepted': 1,
    'queued': 2,
    'snoozed': 2,
    'waiting': 2,
    'thanked': 2,
    'gone': 2,
}


def _effective_tier(series: Dict[str, Any]) -> int:
    """Sort/priority tier for *series*, accounting for pending actions.

    Normally just ``_STATUS_TIER[status]``, but a series parked as ``waiting``
    is pulled up into the actionable tier the moment a newer revision arrives:
    the upgrade it was waiting for is now available, so it belongs alongside
    the other actionable series instead of staying buried among the inactive
    ones at the bottom of the list.
    """
    status = 'queued' if series.get('queued') else series.get('status', 'new')
    if status == 'waiting' and series.get('has_newer'):
        return 0
    return _STATUS_TIER.get(status, 2)


# Statuses where the maintainer can take action right now.
_ACTIONABLE_STATUSES: frozenset[str] = frozenset(
    {
        'new',
        'reviewing',
        'replied',
        'partial',
        'accepted',
    }
)


def _shazam_merge_flags(config: Dict[str, Any], add_signoff: bool) -> List[str]:
    """``git merge`` flags for the take->merge path.

    ``b4.shazam-merge-flags`` is passed through verbatim, exactly like the
    ``b4 shazam`` CLI, so ``--log``/``--stat``/``--gpg-sign`` and any strategy
    (e.g. ``-s ours``) take effect unchanged -- the config is authoritative.

    Signed-off-by is then reconciled with the take dialog's checkbox, which is
    the per-take override: any ``--signoff``/``--no-signoff`` already in the
    config is dropped and a single, deduped flag is appended from
    *add_signoff*. The checkbox itself defaults from the config's signoff
    intent (see ``_show_take_screen``), so an unset config (whose default is
    ``--signoff``) still signs off, while ``--no-signoff`` in the config
    defaults the box off. Signoff rides on the git flag rather than being baked
    into the merge message body, so it can never double up (``git merge
    --signoff`` does not dedup against a trailer already in the message).
    """
    raw = str(config.get('shazam-merge-flags', '--signoff'))
    sp = shlex.shlex(raw, posix=True)
    sp.whitespace_split = True
    flags = [f for f in sp if f not in ('--signoff', '--no-signoff')]
    flags.append('--signoff' if add_signoff else '--no-signoff')
    return flags


def _worktree_for_branch(topdir: str, branch: str) -> Optional[str]:
    """Return the path of the worktree that has *branch* checked out, if any.

    The review TUI may be driven from a different worktree than the one the
    series is being applied to, so the merge has to run wherever the target
    branch lives rather than in the current checkout.
    """
    ecode, out = b4.git_run_command(
        topdir, ['for-each-ref', '--format=%(worktreepath)', f'refs/heads/{branch}']
    )
    if ecode != 0:
        return None
    return out.strip() or None


def _detect_initial_base(
    lser: 'b4.LoreSeries', topdir: Optional[str]
) -> Tuple[str, str]:
    """Determine the base commit pre-filled in the base selection dialog.

    Auto-detection tries the series-specified base-commit first, then a
    find_base() guess, then falls back to HEAD. When b4.review-apply-base
    is configured and resolves to a commit, it overrides the auto-detected
    pre-fill; the auto-detection result moves into the hint so the
    information is not lost. The value is only a pre-fill -- the dialog
    input remains editable and is validated on confirmation.

    Returns (initial_base, base_hint).
    """
    initial_base = 'HEAD'
    base_hint = ''
    if lser.base_commit and topdir:
        bc = lser.base_commit
        short = bc[:12] if len(bc) > 12 else bc
        if b4.git_commit_exists(topdir, bc):
            initial_base = short
            base_hint = f'Series base: {short}'
        else:
            base_hint = f'Series base: {short} (not in repo)'
    if topdir and initial_base == 'HEAD':
        # No usable series base -- try guessing.
        # Exclude b4 review branches -- they are never
        # useful as a base for applying new series.
        try:
            guessed, nblobs, mismatches = lser.find_base(
                topdir,
                branches=['--exclude=refs/heads/b4/review/*', '--all'],
                maxdays=30,
            )
            if guessed:
                # find_base returns a describe name (e.g. heads/foo);
                # resolve it to a SHA for the input field
                ecode, sha_out = b4.git_run_command(
                    topdir, ['rev-parse', '--verify', guessed]
                )
                sha = sha_out.strip() if ecode == 0 else ''
                short_sha = sha[:12] if sha else guessed
                if mismatches == 0:
                    initial_base = short_sha
                    base_hint = f'Guessed base: {guessed} (exact match)'
                elif nblobs != mismatches:
                    matched = nblobs - mismatches
                    initial_base = short_sha
                    base_hint = f'Guessed base: {guessed} ({matched}/{nblobs} blobs)'
                else:
                    base_hint = 'Could not find a matching base'
        except (IndexError, Exception):
            pass

    cfg_base = b4.get_main_config().get('review-apply-base')
    if cfg_base and isinstance(cfg_base, str) and topdir:
        ecode, _out = b4.git_run_command(
            topdir, ['rev-parse', '--verify', '--quiet', cfg_base + '^{commit}']
        )
        if ecode == 0:
            initial_base = cfg_base
            base_hint = f'Configured base: {cfg_base}' + (
                f'; {base_hint}' if base_hint else ''
            )
        else:
            base_hint = f'Configured base {cfg_base} not found' + (
                f'; {base_hint}' if base_hint else ''
            )

    return initial_base, base_hint


def _build_base_suggestions() -> List[str]:
    """Build the suggestion list for the base selection input.

    HEAD first, then the configured apply base and target branches,
    then recently used take branches.
    """
    suggestions: List[str] = ['HEAD']
    cfg_base = b4.get_main_config().get('review-apply-base')
    if cfg_base and isinstance(cfg_base, str) and cfg_base not in suggestions:
        suggestions.append(cfg_base)
    for cb in b4.review.tracking.get_review_target_branches():
        if cb not in suggestions:
            suggestions.append(cb)
    topdir = b4.git_get_toplevel()
    if topdir:
        gitdir = b4.git_get_common_dir(topdir)
        if gitdir:
            recent = b4.review.tracking.get_recent_take_branches(gitdir)
            if recent:
                for rb in recent:
                    if rb not in suggestions:
                        suggestions.append(rb)
    return suggestions


class _TakeWorktree:
    """Handle for the worktree a take runs in (see :func:`_take_worktree`)."""

    def __init__(self, path: str, is_temp: bool) -> None:
        self.path = path
        self.is_temp = is_temp
        self._keep = False

    def keep(self) -> None:
        """Leave a throwaway worktree in place on exit (e.g. unfinished am)."""
        self._keep = True


@contextlib.contextmanager
def _keep_current_branch(topdir: str) -> Generator[Callable[[], None], None, None]:
    """Put HEAD back where it was when the block started.

    Tracking-list actions run with the UI suspended, and some of them check
    out a branch of their own to get their work done. The list comes back up
    when they return, so the branch they moved to must not outlive them: the
    worktree is the user's, shared with their other terminals, and they never
    asked to be moved. Restores however the block ends, including when the
    action bails out early or something under it raises.

    Yields the restore itself, for the callers that need to be off the branch
    before the block ends -- deleting it, say, which git refuses while it is
    checked out. Calling it early is safe: the one on the way out then has
    nothing left to do.
    """
    start_head = b4.git_head_restore_args(topdir)

    def restore() -> None:
        if start_head and b4.git_head_restore_args(topdir) != start_head:
            b4.git_run_command(topdir, start_head, logstderr=True)

    try:
        yield restore
    finally:
        restore()


@contextlib.contextmanager
def _land_off_deleted_branch(
    topdir: str, branch: str, fallback: List[str]
) -> Generator[None, None, None]:
    """Land HEAD back on *fallback* if *branch* is deleted out from under it.

    Deleting the branch HEAD is on means getting off it first, and git can
    only be pointed at the parent commit: it does not know where the user
    was before they came here. We do, so put them back where the session
    started rather than leave the worktree detached at a commit for them to
    find later.  *fallback* is git-checkout args, so a session that started
    on a detached HEAD lands back on its own commit and not on this one.

    Does nothing unless HEAD was on *branch* going in and is detached
    coming out, which only the deletion can have done -- nothing in
    between suspends the UI, so the user cannot have moved it themselves.
    """
    on_branch = b4.git_get_current_branch(topdir) == branch
    try:
        yield
    finally:
        if on_branch and fallback and b4.git_get_current_branch(topdir) is None:
            b4.git_run_command(topdir, fallback, logstderr=True)


@contextlib.contextmanager
def _take_worktree(
    topdir: str, target_branch: str
) -> Generator[Optional[_TakeWorktree], None, None]:
    """Yield the worktree in which to apply a take onto *target_branch*.

    The review TUI may be driven from a different worktree than the one the
    series is applied to, and the target may even be checked out elsewhere, so
    the take must run wherever the branch lives rather than in the current
    checkout. Runs in whichever worktree already holds *target_branch*; when it
    is checked out nowhere, a throwaway ``b4-take-worktree`` is created and
    removed on exit unless the caller marks it :meth:`~_TakeWorktree.keep`.
    Yields ``None`` (after logging and waiting) when no worktree can be
    established, so the caller can simply return.
    """
    # A throwaway worktree from a previously interrupted take may still be
    # registered -- possibly even holding target_branch, in which case the
    # resolution below would latch onto it and never clean it up. Remove any
    # such leftover up front; this also clears a bare leftover directory that a
    # plain `worktree remove` would choke on.
    common_dir = b4.git_get_common_dir(topdir)
    if common_dir:
        leftover = os.path.join(common_dir, 'b4-take-worktree')
        if os.path.exists(leftover):
            b4.git_run_command(topdir, ['worktree', 'remove', '--force', leftover])
            if os.path.isdir(leftover):
                shutil.rmtree(leftover, ignore_errors=True)
        # Prune unconditionally: a directory deleted out-of-band leaves only a
        # registration that _worktree_for_branch would still resolve to a dead
        # path.
        b4.git_run_command(topdir, ['worktree', 'prune'])

    work_dir = _worktree_for_branch(topdir, target_branch)
    temp_wt: Optional[str] = None
    if work_dir is None:
        if not common_dir:
            logger.critical('Unable to determine git common dir')
            _wait_for_enter()
            yield None
            return
        temp_wt = os.path.join(common_dir, 'b4-take-worktree')
        ecode, out = b4.git_run_command(
            topdir, ['worktree', 'add', temp_wt, target_branch], logstderr=True
        )
        if ecode != 0:
            logger.critical(
                'Could not create a worktree for %s: %s', target_branch, out.strip()
            )
            _wait_for_enter()
            yield None
            return
        work_dir = temp_wt
    else:
        # The target is checked out in a real worktree. If it is mid-op or its
        # index is conflicted -- an earlier take the user walked away from, a
        # crash, or their own unfinished am/merge -- a take's git-am/git-merge
        # would fail cryptically on top of it (and "git merge --abort" cannot
        # clear a bare unmerged index). Refuse up front with the fix rather than
        # poison the take; the state may be the user's own, so we never discard
        # it for them.
        stuck = b4._worktree_inprogress_op(work_dir)
        if stuck is None and b4._worktree_has_unmerged(work_dir):
            logger.critical(
                'Target worktree %s has a conflicted index (unmerged files).',
                work_dir,
            )
            logger.critical(
                'Resolve them, or run "git reset --merge" there, then retry.'
            )
            _wait_for_enter()
            yield None
            return
        if stuck is not None:
            logger.critical(
                'Target worktree %s has an unfinished git-%s.', work_dir, stuck
            )
            logger.critical(
                'Finish it, or run "git %s --abort" there, then retry.', stuck
            )
            _wait_for_enter()
            yield None
            return

    handle = _TakeWorktree(work_dir, is_temp=temp_wt is not None)
    try:
        yield handle
    finally:
        if temp_wt and not handle._keep:
            b4.git_run_command(topdir, ['worktree', 'remove', '--force', temp_wt])


def _resolve_worktree_take_conflict(
    wt: '_TakeWorktree',
    op: str,
    pre_head: str,
    in_progress: Callable[[str], object],
) -> bool:
    """Drop to a shell so the user can finish a conflicted take in *wt*.

    *op* is the git subcommand (``am`` or ``merge``) shown in the hints.
    *pre_head* is *wt*'s HEAD captured before *op* ran, used to tell a completed
    operation (HEAD moved) from an abort (HEAD unchanged). *in_progress* reports
    whether *op* is still mid-flight in the worktree.

    Returns True when the user finished *op*, False when they aborted it or left
    the shell without finishing. An unfinished throwaway worktree is kept so the
    user can complete it by hand.
    """
    logger.info('You can resolve the conflict now.')
    logger.info(
        'Use "git %s --continue" after resolving, or "git %s --abort" to give up.',
        op,
        op,
    )
    _suspend_to_shell(hint='b4 conflict', cwd=wt.path)
    if in_progress(wt.path):
        logger.warning('Conflict resolution incomplete')
        if wt.is_temp:
            # A throwaway worktree is isolated, so an unfinished op poisons
            # nothing -- keep it for the user to finish or abort by hand (the
            # next take force-removes it regardless).
            wt.keep()
            logger.warning('Finish or abort it in: %s', wt.path)
            logger.warning('Run "git %s --abort" to clean up', op)
        else:
            # A real checkout must never be left mid-op: the unmerged index
            # breaks the user's own git work and makes the next take fail
            # cryptically on top of it (and "git merge --abort" cannot clear a
            # bare unmerged index). Abort what we started.
            if b4._abort_worktree_op(wt.path):
                logger.warning('Aborted incomplete %s in %s', op, wt.path)
        return False
    ecode, current_head = b4.git_run_command(
        wt.path, ['rev-parse', 'HEAD'], logstderr=True
    )
    if ecode != 0 or current_head.strip() == pre_head:
        logger.warning('Conflict resolution aborted')
        return False
    return True


def _format_snooze_until(value: str) -> str:
    """Format a snoozed_until value for display.

    If *value* starts with ``tag:`` return ``until tag <tagname>``.
    If *value* contains ``T`` (a full ISO datetime), show a relative
    duration like "in 2h 30m" plus the local date/time.  Otherwise
    return the value as-is (backward compat for date-only strings).
    """
    if value.startswith('tag:'):
        return f'until tag {value[4:]}'
    if 'T' not in value:
        return f'until {value}'
    try:
        target = datetime.datetime.fromisoformat(value)
        if target.tzinfo is None:
            target = target.replace(tzinfo=datetime.timezone.utc)
        now = datetime.datetime.now(datetime.timezone.utc)
        delta = target - now
        total_seconds = int(delta.total_seconds())
        if total_seconds <= 0:
            return 'expired'
        parts: List[str] = []
        days = total_seconds // 86400
        remaining = total_seconds % 86400
        hours = remaining // 3600
        remaining %= 3600
        minutes = remaining // 60
        if days:
            parts.append(f'{days}d')
        if hours:
            parts.append(f'{hours}h')
        if minutes:
            parts.append(f'{minutes}m')
        relative = ' '.join(parts) if parts else '<1m'
        local_dt = target.astimezone()
        local_str = local_dt.strftime('%Y-%m-%d %H:%M')
        return f'wakes in {relative} ({local_str})'
    except (ValueError, TypeError):
        return value


def _parse_art_from_message(commit_msg: str) -> Optional[Tuple[int, int, int]]:
    """Parse (Acked, Reviewed, Tested) trailer counts from a commit message."""
    marker = '--- b4-review-tracking ---'
    if marker not in commit_msg:
        return None
    json_text = commit_msg.split(marker, maxsplit=1)[1].strip()
    lines = [ln for ln in json_text.splitlines() if not ln.startswith('#')]
    try:
        tracking = json.loads('\n'.join(lines))
    except (json.JSONDecodeError, ValueError):
        return None

    acked = reviewed = tested = 0
    all_followups: List[Dict[str, Any]] = list(tracking.get('followups', []))
    for patch in tracking.get('patches', []):
        all_followups.extend(patch.get('followups', []))
    for fu in all_followups:
        for t in fu.get('trailers', []):
            name = t.split(':', 1)[0].strip().lower()
            if name == 'acked-by':
                acked += 1
            elif name == 'reviewed-by':
                reviewed += 1
            elif name == 'tested-by':
                tested += 1
    return (acked, reviewed, tested)


def _get_art_counts(topdir: str, branch: str) -> Optional[Tuple[int, int, int]]:
    """Load (Acked, Reviewed, Tested) trailer counts from a review branch."""
    ecode, out = b4.git_run_command(topdir, ['log', '-1', '--format=%B', branch])
    if ecode > 0 or not out:
        return None
    return _parse_art_from_message(out)


def _get_review_branch_tips(topdir: str) -> Dict[str, str]:
    """Return {branch_short_name: commit_sha} for all b4/review/* branches.

    Uses a single git for-each-ref call instead of per-branch rev-parse.
    """
    gitargs = [
        'for-each-ref',
        '--format=%(refname:short) %(objectname)',
        'refs/heads/b4/review/',
    ]
    lines = b4.git_get_command_lines(topdir, gitargs)
    result: Dict[str, str] = {}
    for line in lines:
        parts = line.split(None, 1)
        if len(parts) == 2:
            result[parts[0]] = parts[1]
    return result


def _get_art_counts_batch(
    topdir: str,
    branch_shas: Dict[str, str],
) -> Dict[str, Tuple[int, int, int]]:
    """Load ART trailer counts for multiple branches in one git call.

    Uses git cat-file --batch to read all tracking commits at once.
    """
    if not branch_shas:
        return {}

    sha_list = list(branch_shas.items())
    stdin_data = '\n'.join(sha for _, sha in sha_list).encode() + b'\n'

    ecode, out = b4.git_run_command(
        topdir,
        ['cat-file', '--batch'],
        stdin=stdin_data,
        decode=False,
    )
    if ecode != 0:
        return {}

    result: Dict[str, Tuple[int, int, int]] = {}
    data = out
    assert isinstance(data, bytes)
    pos = 0
    for branch_name, _sha in sha_list:
        newline = data.find(b'\n', pos)
        if newline < 0:
            break
        header = data[pos:newline].decode(errors='replace')
        parts = header.split()
        if len(parts) < 3 or parts[1] == 'missing':
            pos = newline + 1
            continue
        size = int(parts[2])
        content_start = newline + 1
        content_end = content_start + size
        content = data[content_start:content_end].decode(errors='replace')
        pos = content_end + 1  # skip trailing \n

        # Raw commit object: headers\n\n<message>
        msg_start = content.find('\n\n')
        if msg_start < 0:
            continue
        commit_msg = content[msg_start + 2 :]

        art = _parse_art_from_message(commit_msg)
        if art is not None:
            result[branch_name] = art

    return result


def _format_attestation(att: str, app: Any = None) -> Optional[RichText]:
    """Format an attestation DB value into a Rich text snippet.

    Returns None when there is nothing to display (e.g. 'pending', 'none').
    """
    entries = att.split(';') if att and att not in ('pending', 'none') else []
    if not entries:
        return None
    # resolve_styles needs the app; fall back to plain colours if unavailable
    if app is not None:
        ts = resolve_styles(app)
    else:
        ts = {'success': 'green', 'error': 'red', 'warning': 'dark_orange'}
    text = RichText()
    for i, entry in enumerate(entries):
        if i > 0:
            text.append(', ')
        status, identity = entry.split(':', 1) if ':' in entry else (entry, '')
        if status == 'signed':
            text.append(f'\u2714 {identity}', style=ts['success'])
        elif status == 'nokey':
            text.append(f'? {identity}', style=ts['warning'])
            text.append(' (no key)', style='dim')
        elif status == 'badsig':
            text.append(f'\u2718 {identity}', style=ts['error'])
            text.append(' (signature failed)', style='dim')
        else:
            text.append(entry)
    return text


class TrackedSeriesItem(ListItem):
    """A single tracked series entry in the listing."""

    DEFAULT_CSS = """
    TrackedSeriesItem.non-actionable Label {
        text-style: dim;
    }
    TrackedSeriesItem.gone Label {
        text-style: dim italic;
    }
    """

    def __init__(self, series: Dict[str, Any]) -> None:
        super().__init__()
        self.series = series
        status = series.get('status', 'new')
        if _effective_tier(series) >= 2:
            self.add_class('non-actionable')
        if status == 'gone':
            self.add_class('gone')

    def compose(self) -> ComposeResult:
        subject = self.series.get('subject', '(no subject)')
        submitter = self.series.get('sender_name', 'Unknown')
        revision = self.series.get('revision', 1)
        num_patches = self.series.get('num_patches', 0)
        status = self.series.get('status', 'new')
        effective = 'queued' if self.series.get('queued') else status
        symbol = _STATUS_SYMBOLS.get(effective, '?')
        # The suffix flag is a single slot; needs_update (rev_count == 0) and
        # has_newer (a known newer revision exists) are mutually exclusive, so
        # at most one applies.  '*' = fetch revision data; '↑' = upgrade available.
        if self.series.get('needs_update'):
            flag = '*'
        elif self.series.get('has_newer'):
            flag = '↑'
        else:
            flag = ' '
        art = self.series.get('art')
        if art:
            a, r, t = art
            art_str = f'{a}·{r}·{t}'
        else:
            art_str = '-'
        fc = self.series.get('message_count')
        sc = self.series.get('seen_message_count')
        if fc is not None:
            delta = (fc - sc) if (sc is not None and fc > sc) else 0
        else:
            delta = 0
        # Msgs display: "1" (all seen), "6" accent (all new), "6(3)" mixed
        if fc is None:
            fu_base = '-'
            fu_badge = ''
            base_accent = False
        elif fc == 0:
            fu_base = '0'
            fu_badge = ''
            base_accent = False
        elif delta == fc:
            # All follow-ups are new
            fu_base = str(fc)
            fu_badge = ''
            base_accent = True
        elif delta > 0:
            # Mixed: total + (unseen)
            fu_base = str(fc)
            fu_badge = f'({delta})'
            base_accent = False
        else:
            # All seen
            fu_base = str(fc)
            fu_badge = ''
            base_accent = False
        # Build compact prefix using LoreSubject to extract subsystem/modifier tokens
        ls = b4.LoreSubject(subject)
        extras = ls.get_extra_prefixes(exclude=['patch'])
        width = len(str(num_patches)) if num_patches > 0 else 1
        parts = extras + [f'v{revision}', f'{"0" * width}/{num_patches:0{width}d}']
        subject_display = f'[{",".join(parts)}] {ls.subject}'
        if display_width(submitter) > 20:
            while display_width(submitter) > 19:
                submitter = submitter[:-1]
            submitter += '…'
        submitter = pad_display(submitter, 20)
        att = self.series.get('attestation') or ''
        att_entries = att.split(';') if att and att not in ('pending', 'none') else []
        label = RichText(no_wrap=True, overflow='ellipsis')
        label.append(submitter)
        if att_entries and all(e.startswith('signed:') for e in att_entries):
            ts = resolve_styles(self.app)
            label.append('\u2714', style=ts['success'])  # ✔
        else:
            label.append(' ')
        label.append(' ')
        label.append(art_str.rjust(7))
        base_style = ''
        badge_style = ''
        if base_accent or fu_badge:
            ts = resolve_styles(self.app)
            accent = f'bold {ts["warning"]}'
            if base_accent:
                base_style = accent
            if fu_badge:
                badge_style = accent
        label.append(f'  {fu_base.rjust(3)}', style=base_style)
        label.append(f'{fu_badge:<3s}', style=badge_style)
        label.append(f'  {symbol}{flag}  {subject_display}')
        yield Label(label, markup=False)


class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
    """Textual app for browsing tracked series.

    Returns the branch name to review, or None to exit.
    """

    TITLE = 'b4 review'

    DEFAULT_CSS = """
    TrackingApp {
        layout: vertical;
    }
    #tracking-title {
        dock: top;
        width: 100%;
        height: 1;
        background: $primary-darken-2;
        color: $text;
        text-style: bold;
    }
    #title-left {
        width: 1fr;
        content-align: left middle;
    }
    #title-right {
        width: auto;
        content-align: right middle;
        padding: 0 1;
    }
    #tracking-header {
        width: 100%;
        height: 1;
        background: $surface;
        color: $text-muted;
    }
    #tracking-list {
        height: 1fr;
    }
    #tracking-empty {
        height: 1fr;
        content-align: center middle;
        color: $text-muted;
    }
    #details-panel {
        dock: bottom;
        width: 100%;
        height: 0;
        background: $surface;
        border-top: solid $primary;
        padding: 0 1;
        overflow: hidden;
    }
    .details-label {
        color: $text-muted;
        width: 13;
    }
    .details-row {
        height: 1;
    }
    #detail-revisions.has-upgrade {
        color: ansi_bright_yellow;
        text-style: bold;
    }
    TrackingApp:ansi #tracking-title {
        background: ansi_bright_black;
        color: ansi_default;
        text-style: bold;
    }
    TrackingApp:ansi #title-left {
        background: ansi_bright_black;
        color: ansi_default;
        text-style: bold;
    }
    TrackingApp:ansi #title-right {
        background: ansi_bright_black;
        color: ansi_default;
        text-style: bold;
    }
    TrackingApp:ansi #tracking-header {
        background: ansi_default;
        color: ansi_default;
        text-style: dim;
    }
    TrackingApp:ansi #details-panel {
        background: ansi_default;
        border-top: solid ansi_default;
    }
    TrackingApp:ansi .details-label {
        color: ansi_default;
        text-style: dim;
    }
    """

    BINDING_GROUPS = {
        'review': 'Series',
        'check': 'Series',
        'thread': 'Series',
        'range_diff': 'Series',
        'action': 'Series',
        'update_one': 'Series',
        'target_branch': 'Series',
        'update_all': 'App',
        'process_queue': 'App',
        'limit': 'App',
        'suspend': 'App',
        'patchwork': 'App',
        'quit': 'App',
        'help': 'App',
    }

    BINDINGS = [
        # Hidden navigation bindings
        Binding('j', 'cursor_down', 'Down', show=False),
        Binding('k', 'cursor_up', 'Up', show=False),
        Binding('escape', 'hide_details', 'Close', show=False),
        # Series-specific actions
        Binding('r', 'review', 'review'),
        Binding('t', 'target_branch', 'target'),
        Binding('c', 'check', 'ci'),
        Binding('e', 'thread', 'thread'),
        Binding('a', 'action', 'action'),
        Binding('u', 'update_one', 'update'),
        Binding('d', 'range_diff', 'range-diff'),
        # App-global actions
        Binding('l', 'limit', 'limit'),
        Binding('s', 'suspend', 'shell'),
        Binding('p', 'patchwork', 'patchwork'),
        Binding('U', 'update_all', 'Update all', key_display='U'),
        Binding('T', 'process_queue', 'Queue', key_display='T'),
        *QUIT_BINDINGS,
        Binding('question_mark', 'help', 'help', key_display='?'),
    ]

    def __init__(
        self,
        identifier: str,
        original_branch: Optional[str] = None,
        focus_change_id: Optional[str] = None,
        email_dryrun: bool = False,
        patatt_sign: bool = True,
        original_head: Optional[List[str]] = None,
    ) -> None:
        super().__init__()
        self._identifier = identifier
        self._original_branch = original_branch
        # Where to put HEAD back when an action detaches it.  A caller that
        # only named a branch gets that branch back.
        self._original_head: List[str] = original_head or (
            ['checkout', original_branch] if original_branch else []
        )
        self._focus_change_id = focus_change_id
        self._email_dryrun = email_dryrun
        self._patatt_sign = patatt_sign
        self._all_series: List[Dict[str, Any]] = []
        self._selected_series: Optional[Dict[str, Any]] = None
        self._limit_pattern: str = ''
        self._db_mtime: float = 0.0
        # Detect patchwork configuration
        config = b4.get_main_config()
        self._pwkey = str(config.get('pw-key', ''))
        self._pwurl = str(config.get('pw-url', ''))
        self._pwproj = str(config.get('pw-project', ''))
        # Remember last snooze choices within the session
        self._last_snooze_source: str = ''
        self._last_snooze_input: str = ''
        # CI check modal state
        self._check_loading: Optional[CheckLoadingScreen] = None
        # Transient state for the manual revision-linking flow
        self._link_ctx: Optional[Dict[str, Any]] = None
        # Thanks queue count
        self._queue_count: int = 0
        # Show target branch binding only when configured
        self._has_target_branches = bool(
            b4.review.tracking.get_review_target_branches()
        )
        # Cached data for _load_series — invalidated by _invalidate_caches()
        # when u/U update runs or actions change tracking data.
        self._cached_branch_tips: Optional[Dict[str, str]] = None
        self._cached_newest_revisions: Optional[Dict[str, int]] = None
        self._cached_revision_counts: Optional[Dict[str, int]] = None
        self._cached_revisions: Optional[Dict[str, List[Dict[str, Any]]]] = None
        self._cached_art_counts: Optional[Dict[str, Tuple[int, int, int]]] = None

    def _invalidate_caches(self, change_id: Optional[str] = None) -> None:
        """Drop cached data so the next _load_series re-fetches.

        If change_id is given, only evict that series from the ART
        cache (branch tips and revision data are cheap dict lookups
        and get rebuilt from the bulk query anyway).  Without
        change_id, drop everything.
        """
        if change_id is not None:
            branch_name = f'b4/review/{change_id}'
            if self._cached_art_counts and branch_name in self._cached_art_counts:
                del self._cached_art_counts[branch_name]
            return
        self._cached_branch_tips = None
        self._cached_newest_revisions = None
        self._cached_revision_counts = None
        self._cached_revisions = None
        self._cached_art_counts = None

    def _refresh_msg_count(self, series: Dict[str, Any], total_messages: int) -> None:
        """Opportunistically refresh message count after fetching messages."""
        if not self._identifier:
            return
        b4.review.tracking.refresh_message_count(
            self._identifier,
            series.get('change_id', ''),
            series.get('revision', 1),
            total_messages,
        )

    def compose(self) -> ComposeResult:
        title = f' Tracked Series — {self._identifier}'
        if self._email_dryrun:
            title += ' (DRY-RUN)'
        with Horizontal(id='tracking-title'):
            yield Static(title, id='title-left')
            yield Static('', id='title-right')
        with Vertical(id='details-panel'):
            with Horizontal(classes='details-row'):
                yield Static('Subject:', classes='details-label')
                yield Static('', id='detail-subject', markup=False)
            with Horizontal(classes='details-row'):
                yield Static('From:', classes='details-label')
                yield Static('', id='detail-from', markup=False)
            with Horizontal(classes='details-row', id='detail-attestation-row'):
                yield Static('Attestation:', classes='details-label')
                yield Static('', id='detail-attestation')
            with Horizontal(classes='details-row'):
                yield Static('Sent:', classes='details-label')
                yield Static('', id='detail-sent', markup=False)
            with Horizontal(classes='details-row'):
                yield Static('Status:', classes='details-label')
                yield Static('', id='detail-status', markup=False)
            with Horizontal(classes='details-row'):
                yield Static('Change-ID:', classes='details-label')
                yield Static('', id='detail-changeid', markup=False)
            with Horizontal(classes='details-row'):
                yield Static('Link:', classes='details-label')
                yield Static('', id='detail-link', markup=False)
            with Horizontal(classes='details-row', id='detail-revisions-row'):
                yield Static('Revisions:', classes='details-label')
                yield Static('', id='detail-revisions', markup=False)
            with Horizontal(classes='details-row', id='detail-branch-row'):
                yield Static('Branch:', classes='details-label')
                yield Static('', id='detail-branch', markup=False)
            with Horizontal(classes='details-row', id='detail-target-row'):
                yield Static('Target:', classes='details-label')
                yield Static('', id='detail-target', markup=False)
        yield SeparatedFooter()

    def on_mount(self) -> None:
        _fix_ansi_theme(self)
        self._refresh_queue_indicator()
        self._load_series()
        self.set_interval(1, self._check_db_changed)
        topdir = b4.git_get_toplevel()
        if topdir and b4.review.tracking.db_exists(self._identifier):
            self.run_worker(
                lambda: self._startup_rescan(topdir),
                name='_startup_rescan',
                thread=True,
            )

    def _startup_rescan(self, topdir: str) -> Dict[str, int]:
        """Rescan review branches in the background on app startup."""
        with _quiet_worker():
            return b4.review.tracking.rescan_branches(self._identifier, topdir)

    async def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        if event.worker.name == '_link_fetch':
            if event.state == WorkerState.SUCCESS:
                self._on_link_fetched(event.worker.result)
            elif event.state == WorkerState.ERROR:
                self.notify('Could not fetch series', severity='error')
            return
        if event.worker.name != '_startup_rescan':
            return
        if event.state == WorkerState.SUCCESS:
            result = event.worker.result or {}
            gone = result.get('gone', 0)
            if gone:
                self.notify(f'{gone} review branch(es) are gone', severity='warning')
            # Only rebuild the list when something actually changed; the DB is
            # only written when a branch SHA differs, so _check_db_changed()
            # naturally stays quiet on a no-op rescan.
            if gone or result.get('changed', 0):
                # Skip while a modal is active — _check_db_changed() will
                # pick up the change once the modal closes.
                if len(self.app.screen_stack) > 1:
                    return
                if self._selected_series:
                    self._focus_change_id = self._selected_series.get('change_id')
                self._invalidate_caches()
                self._load_series()

    def _auto_wake_snoozed(self) -> None:
        """Auto-wake snoozed series whose wake-up condition has been met."""
        b4.review.tracking.auto_wake_snoozed(self._identifier, b4.git_get_toplevel())

    def _load_series(self) -> None:
        self._auto_wake_snoozed()

        all_series = b4.review.tracking.get_all_tracked_series(self._identifier)
        self._all_series = [s for s in all_series if s.get('status') != 'archived']
        # Record the database mtime so the polling timer can detect changes
        try:
            db_path = b4.review.tracking.get_db_path(self._identifier)
            self._db_mtime = os.path.getmtime(db_path)
        except OSError:
            pass

        topdir = b4.git_get_toplevel()

        # --- Bulk branch lookup (1 subprocess instead of N) ---
        if self._cached_branch_tips is None and topdir:
            self._cached_branch_tips = _get_review_branch_tips(topdir)
        branch_tips = self._cached_branch_tips or {}
        existing_branches = set(branch_tips.keys())

        # --- Bulk DB queries (2 queries instead of 2N) ---
        try:
            conn = b4.review.tracking.get_db(self._identifier)
        except Exception:
            conn = None
        if self._cached_newest_revisions is None and conn:
            try:
                self._cached_newest_revisions = (
                    b4.review.tracking.get_all_newest_revisions(conn)
                )
                self._cached_revision_counts = (
                    b4.review.tracking.get_all_revision_counts(conn)
                )
                self._cached_revisions = b4.review.tracking.get_all_revisions_grouped(
                    conn
                )
            except Exception:
                pass
        newest_revisions = self._cached_newest_revisions or {}
        revision_counts = self._cached_revision_counts or {}
        all_revisions = self._cached_revisions or {}
        if conn:
            conn.close()

        # --- First pass: branch existence + revision flags ---
        art_branches: Dict[str, str] = {}
        for series in self._all_series:
            change_id = series.get('change_id', '')
            branch_name = f'b4/review/{change_id}'

            if series.get('status') == 'new':
                if branch_name in existing_branches:
                    series['status'] = 'reviewing'

            current_rev = series.get('revision', 1)
            newest = newest_revisions.get(change_id)
            if newest is not None and newest > current_rev:
                series['has_newer'] = True
            rev_count = revision_counts.get(change_id, 0)
            if rev_count > 1:
                series['has_multiple_revisions'] = True
            if rev_count == 0 and series.get('status') not in (
                'new',
                'gone',
                'snoozed',
            ):
                series['needs_update'] = True
            # Stash revisions list for the detail panel
            series['_revisions'] = all_revisions.get(change_id, [])

            # Collect branches needing ART counts
            if topdir and series.get('status') in (
                'reviewing',
                'replied',
                'partial',
                'waiting',
            ):
                if branch_name in branch_tips:
                    art_branches[branch_name] = branch_tips[branch_name]

        # --- Bulk ART counts (1 subprocess instead of N) ---
        if self._cached_art_counts is None and art_branches and topdir:
            self._cached_art_counts = _get_art_counts_batch(topdir, art_branches)
        art_map = self._cached_art_counts or {}
        for series in self._all_series:
            change_id = series.get('change_id', '')
            branch_name = f'b4/review/{change_id}'
            if branch_name in art_map:
                series['art'] = art_map[branch_name]

        # Tag accepted series that have a queued thank-you letter.
        # This is a display-only pseudo-state, not stored in the DB.
        queued_cids = b4.ty.get_queued_change_ids(dryrun=self._email_dryrun)
        for series in self._all_series:
            if (
                series.get('status') == 'accepted'
                and series.get('change_id', '') in queued_cids
            ):
                series['queued'] = True
        # Sort into three tiers: active → action required → inactive.
        # Within each tier, sort by when the maintainer started tracking
        # (added_at descending) — newest-tracked at the top, like an inbox.
        self._all_series.sort(
            key=lambda s: s.get('added_at') or s.get('sent_at') or '',
            reverse=True,
        )
        self._all_series.sort(key=_effective_tier)
        self.call_later(self._refresh_list)

    def _check_db_changed(self) -> None:
        """Poll the database file mtime and reload if it changed."""
        # Skip while a modal is active — the DB may be changing (e.g. during
        # an update) and rebuilding the list behind the modal causes flickering.
        if len(self.app.screen_stack) > 1:
            return
        try:
            db_path = b4.review.tracking.get_db_path(self._identifier)
            mtime = os.path.getmtime(db_path)
        except OSError:
            return
        if mtime != self._db_mtime:
            if self._selected_series:
                self._focus_change_id = self._selected_series.get('change_id')
            self._invalidate_caches()
            self._load_series()

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

        The pattern is split on whitespace.  Tokens starting with
        ``s:`` filter by status substring, ``t:`` by target-branch
        substring, ``up:`` by whether a newer revision is available, and
        bare tokens by subject or sender name.  All tokens must match
        (AND logic).
        """
        for token in pattern.lower().split():
            if token.startswith('s:'):
                needle = token[2:]
                if needle not in (series.get('status', '') or '').lower():
                    return False
            elif token.startswith('t:'):
                needle = token[2:]
                if needle not in (series.get('target_branch', '') or '').lower():
                    return False
            elif token.startswith('up:'):
                # Boolean filter on has_newer: `up:` / `up:1` / `up:yes` keep
                # only upgradable series; `up:no` / `up:0` / `up:false` invert.
                want = token[3:] not in ('no', '0', 'false')
                if bool(series.get('has_newer')) != want:
                    return False
            else:
                if (
                    token not in (series.get('subject', '') or '').lower()
                    and token not in (series.get('sender_name', '') or '').lower()
                ):
                    return False
        return True

    async def _refresh_list(self) -> None:
        display_series = self._all_series
        if self._limit_pattern:
            display_series = [
                s for s in display_series if self._matches_limit(s, self._limit_pattern)
            ]

        try:
            left = self.query_one('#title-left', Static)
        except NoMatches:
            # A modal screen is active or the app is shutting down —
            # the widget lives on the default screen and is not
            # reachable right now.  The 1-second _check_db_changed
            # timer will re-trigger the refresh once the modal closes.
            return
        title_text = f' Tracked Series — {self._identifier}'
        if self._email_dryrun:
            title_text += ' (DRY-RUN)'
        if self._limit_pattern:
            title_text += f' (limit: {self._limit_pattern})'
        left.update(title_text)

        scroll_y = ReplacementListView.capture_scroll(self, '#tracking-list')

        # Suppress rendering while we swap old widgets for new ones.
        # Without this, the remove-then-mount sequence can produce a
        # single intermediate frame showing only the title bar before
        # the new list is mounted.
        with self.app.batch_update():
            # Remove existing list/empty widgets
            for widget in list(
                self.query('#tracking-header, #tracking-list, #tracking-empty')
            ):
                await widget.remove()

            if not display_series:
                empty = Static(
                    'No tracked series. Use "b4 review track" to add series.',
                    id='tracking-empty',
                )
                await self.mount(empty, before=self.query_one(Footer))
                return

            header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":>6s}  {"S":<4s}{"Subject"}'
            header = Static(header_text, id='tracking-header')

            list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series]
            lv = ReplacementListView(*list_items, id='tracking-list', scroll_y=scroll_y)
            await self.mount(header, before=self.query_one(Footer))
            await self.mount(lv, before=self.query_one(Footer))

        new_index = 0
        if self._focus_change_id:
            for idx, item in enumerate(list_items):
                if (
                    isinstance(item, TrackedSeriesItem)
                    and item.series.get('change_id') == self._focus_change_id
                ):
                    new_index = idx
                    break
            self._focus_change_id = None
        lv.index = new_index
        lv.focus()

        # Populate the details panel for the highlighted item now that
        # the widget tree is stable.  The Highlighted event may have
        # fired during batch_update before #details-panel was queryable.
        # Also sync _selected_series here so action_action() reads fresh
        # status without waiting for the async Highlighted message to
        # be processed from the queue.
        highlighted = lv.highlighted_child
        if isinstance(highlighted, TrackedSeriesItem):
            self._selected_series = highlighted.series
            self._show_details(highlighted.series)

    def action_limit(self) -> None:
        self.push_screen(
            LimitScreen(
                self._limit_pattern,
                hint='Prefixes: s:<status>  t:<target-branch>  up: (upgradable)',
            ),
            callback=self._on_limit,
        )

    def _on_limit(self, result: Optional[str]) -> None:
        if result is None:
            return
        self._limit_pattern = result
        if self._selected_series:
            self._focus_change_id = self._selected_series.get('change_id')
        self._load_series()

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

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

    def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
        if event.list_view.id != 'tracking-list':
            return
        item = event.item
        if isinstance(item, TrackedSeriesItem):
            self._selected_series = item.series
            self._show_details(item.series)
        self.refresh_bindings()

    def on_list_view_selected(self, event: ListView.Selected) -> None:
        if event.list_view.id == 'tracking-list':
            if not self._selected_series:
                return
            status = self._selected_series.get('status', 'new')
            if status == 'reviewing':
                self.action_review()
            else:
                self.action_action()

    _STATE_ACTIONS: Dict[str, frozenset[str]] = {
        'new': frozenset(
            {'review', 'range_diff', 'abandon', 'snooze', 'waiting', 'target_branch'}
        ),
        'reviewing': frozenset(
            {
                'review',
                'range_diff',
                'take',
                'rebase',
                'abandon',
                'waiting',
                'snooze',
                'target_branch',
            }
        ),
        'replied': frozenset(
            {
                'review',
                'range_diff',
                'take',
                'rebase',
                'archive',
                'waiting',
                'snooze',
                'target_branch',
            }
        ),
        'waiting': frozenset(
            {'review', 'range_diff', 'abandon', 'archive', 'snooze', 'target_branch'}
        ),
        'accepted': frozenset({'review', 'range_diff', 'thank', 'archive'}),
        'partial': frozenset(
            {
                'review',
                'range_diff',
                'take',
                'rebase',
                'thank',
                'abandon',
                'waiting',
                'snooze',
                'target_branch',
                'archive',
            }
        ),
        'snoozed': frozenset(
            {'review', 'range_diff', 'unsnooze', 'abandon', 'target_branch'}
        ),
        'thanked': frozenset({'review', 'archive'}),
        'gone': frozenset({'abandon', 'review'}),
    }
    # All state-gated actions (union of all per-state sets)
    _GATED_ACTIONS = frozenset().union(*_STATE_ACTIONS.values())

    def check_action(self, action: str, parameters: Tuple[Any, ...]) -> Optional[bool]:
        """Hide status-specific actions based on the selected series."""
        if action == 'process_queue':
            return self._queue_count > 0
        if action == 'patchwork':
            return bool(self._pwkey and self._pwurl and self._pwproj)
        if action == 'action':
            return self._selected_series is not None
        if action == 'update_revision':
            # Upgrade availability is governed by has_newer alone — it is an
            # axis orthogonal to status, so a newer revision can be taken from
            # any state that can hold one (including accepted/thanked, e.g. a
            # series kept back from auto-archiving because a newer revision
            # appeared), not only a checked-out 'reviewing' branch.
            return bool(
                self._selected_series and self._selected_series.get('has_newer')
            )
        if action in self._GATED_ACTIONS:
            if not self._selected_series:
                return False
            status = self._selected_series.get('status', 'new')
            if action not in self._STATE_ACTIONS.get(status, frozenset()):
                return False
            if action == 'range_diff':
                return bool(self._selected_series.get('has_multiple_revisions'))
            if action == 'target_branch':
                return self._has_target_branches
            return True
        return True

    def action_action(self) -> None:
        """Open a modal with context-sensitive actions for the selected series."""
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        actions: list[tuple[str, str]] = []
        if status in ('new', 'gone'):
            actions.append(('review', 'Review'))
            if status == 'new' and self._selected_series.get('has_newer'):
                actions.append(('upgrade', 'Upgrade to newer revision'))
            if status == 'new':
                actions.append(('link', 'Manually link a revision'))
            actions.append(('abandon', 'Abandon series'))
            if status == 'new':
                actions.append(('waiting', 'Mark as waiting on new revision'))
                actions.append(('snooze', 'Snooze (defer until later)'))
        elif status == 'snoozed':
            actions.append(('unsnooze', 'Wake up (unsnooze)'))
            actions.append(('abandon', 'Abandon series'))
            actions.append(('archive', 'Archive series'))
        else:
            if status in ('reviewing', 'replied'):
                actions.append(('take', 'Take (apply to branch)'))
            if status == 'partial':
                actions.append(('take', 'Take remaining patches'))
            if status in ('reviewing', 'replied', 'partial'):
                actions.append(('rebase', 'Rebase review branch'))
                actions.append(('waiting', 'Mark as waiting on new revision'))
                actions.append(('snooze', 'Snooze (defer until later)'))
            if status in (
                'reviewing',
                'replied',
                'partial',
                'waiting',
                'accepted',
                'thanked',
            ) and self._selected_series.get('has_newer'):
                actions.append(('upgrade', 'Upgrade to newer revision'))
            if status == 'waiting':
                actions.append(('review', 'Review'))
            if status in ('accepted', 'partial'):
                actions.append(('thank', 'Send thank-you'))
            if status in ('reviewing', 'replied', 'partial', 'waiting'):
                actions.append(('link', 'Manually link a revision'))
            # 'Return to reviewing' sits just above the abandon/archive block
            # rather than at the top of the menu.
            if status in ('accepted', 'partial', 'thanked'):
                actions.append(('review', 'Return to reviewing'))
            if status != 'thanked':
                actions.append(('abandon', 'Abandon series'))
            actions.append(('archive', 'Archive series'))
        self.push_screen(
            ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS),
            callback=self._on_action_selected,
        )

    def _on_action_selected(self, action: Optional[str]) -> None:
        """Dispatch the chosen action to the corresponding handler."""
        if action is None:
            return
        handler = {
            'review': self.action_review,
            'take': self.action_take,
            'rebase': self.action_rebase,
            'abandon': self.action_abandon,
            'thank': self.action_thank,
            'upgrade': self.action_update_revision,
            'link': self.action_link_revision,
            'archive': self.action_archive,
            'waiting': self.action_waiting,
            'snooze': self.action_snooze,
            'unsnooze': self.action_unsnooze,
        }.get(action)
        if handler:
            handler()

    def action_review(self) -> None:
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        if status in (
            'reviewing',
            'replied',
            'partial',
            'waiting',
            'accepted',
            'snoozed',
            'thanked',
        ):
            # Already checked out - go to review mode
            change_id = self._selected_series.get('change_id', '')
            revision = self._selected_series.get('revision')
            branch_name = f'b4/review/{change_id}'
            topdir = b4.git_get_toplevel()
            if status == 'thanked' and (
                not topdir or not b4.git_branch_exists(topdir, branch_name)
            ):
                self.notify(
                    'Review branch no longer exists; cannot reopen', severity='warning'
                )
                return
            try:
                conn = b4.review.tracking.get_db(self._identifier)
            except Exception:
                conn = None
            try:
                if status == 'snoozed':
                    # Unsnooze: clear snoozed_until + restore tracking commit
                    if topdir and b4.git_branch_exists(topdir, branch_name):
                        try:
                            b4.review.tracking.restore_snoozed_tracking(
                                topdir, branch_name
                            )
                        except (SystemExit, Exception):
                            pass
                    if conn:
                        b4.review.tracking.unsnooze_series(
                            conn, change_id, 'reviewing', revision=revision
                        )
                elif status in ('waiting', 'accepted', 'thanked'):
                    # Bring back to reviewing on re-entry
                    if conn:
                        b4.review.tracking.update_series_status(
                            conn, change_id, 'reviewing', revision=revision
                        )
                    if topdir:
                        b4.review.update_tracking_status(
                            topdir, branch_name, 'reviewing'
                        )
                    if status == 'thanked':
                        pw_sid = self._selected_series.get('pw_series_id')
                        if pw_sid:
                            try:
                                b4.review.pw_update_series_state(
                                    pw_sid, 'under-review', archived=False
                                )
                            except Exception:
                                pass
                # Clear the followup badge — user is about to read this series
                if conn and self._identifier and isinstance(revision, int):
                    b4.review.tracking.mark_all_messages_seen(conn, change_id, revision)
            except Exception:
                pass
            finally:
                if conn:
                    conn.close()
            self.exit(branch_name)
        elif self._selected_series.get('has_newer'):
            # New series with a newer revision available — ask which to review
            current_rev = self._selected_series.get('revision', 1)
            change_id = self._selected_series.get('change_id', '')
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                newest = b4.review.tracking.get_newest_revision(conn, change_id)
                conn.close()
            except Exception:
                newest = None
            if newest and newest > current_rev:
                self.push_screen(
                    RevisionChoiceScreen(current_rev, newest),
                    callback=self._on_revision_choice,
                )
            else:
                self._checkout_new_series()
        else:
            # New series - need to check out
            self._checkout_new_series()

    def _on_revision_choice(self, chosen: Optional[int]) -> None:
        """Handle the revision chosen from the RevisionChoiceScreen."""
        if chosen is None:
            return
        series = self._selected_series
        if not series:
            return
        current_rev = series.get('revision', 1)
        if chosen != current_rev:
            # Swap to the chosen revision's message_id from the DB
            change_id = series.get('change_id', '')
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                revs = b4.review.tracking.get_revisions(conn, change_id)
                conn.close()
            except Exception:
                self.notify('Could not load revision data', severity='error')
                return
            rev_info = None
            for rev in revs:
                if rev['revision'] == chosen:
                    rev_info = rev
                    break
            if rev_info is None:
                self.notify(
                    f'Revision v{chosen} not found in database', severity='error'
                )
                return
            series['message_id'] = rev_info['message_id']
            series['revision'] = chosen
            # Persist the switch so it survives checkout failures
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                b4.review.tracking.update_series_revision(
                    conn,
                    change_id,
                    current_rev,
                    chosen,
                    rev_info['message_id'],
                    rev_info.get('subject'),
                )
                conn.close()
            except Exception:
                pass
        self._checkout_new_series()

    def action_thread(self) -> None:
        """View a series thread in the lite thread viewer."""
        if not self._selected_series:
            return
        message_id = self._selected_series.get('message_id', '')
        if not message_id:
            self.notify('No message-id available for this series', severity='error')
            return
        tracking_info = None
        if self._identifier:
            tracking_info = {
                'identifier': self._identifier,
                'change_id': self._selected_series.get('change_id', ''),
                'revision': self._selected_series.get('revision', 1),
            }
        self._focus_change_id = self._selected_series.get('change_id')
        from b4.review_tui._lite_app import LiteThreadScreen

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

    def _checkout_new_series(self) -> None:
        """Retrieve series, build am-ready mbox, and show base selection."""
        series = self._selected_series
        if not series:
            return

        message_id = series.get('message_id', '')
        if not message_id:
            self.notify('No message-id available for this series', severity='error')
            return

        def _fetch_and_prepare() -> Tuple[b4.LoreSeries, bytes, str, str]:
            with _quiet_worker():
                msgs = b4.review.retrieve_series_messages(series, self._identifier)
                self._refresh_msg_count(series, len(msgs))
                wantver = series.get('revision')
                try:
                    lser = b4.review._get_lore_series(msgs, wantver=wantver)
                except LookupError:
                    if wantver is not None and b4.can_network:
                        # The stored message-id may point to a different
                        # version's thread.  Search for the wanted version
                        # in other threads before giving up.
                        msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
                        if wantver > 1:
                            msgs = b4.mbox.get_extra_series(
                                msgs, direction=-1, wantvers=[wantver], nocache=True
                            )
                        lser = b4.review._get_lore_series(msgs, wantver=wantver)
                    else:
                        raise

                am_msgs = lser.get_am_ready(
                    noaddtrailers=True,
                    addmysob=False,
                    addlink=False,
                    cherrypick=None,
                    copyccs=False,
                    allowbadchars=False,
                    showchecks=False,
                )
                if not am_msgs:
                    raise LookupError('No patches ready for applying')

                ifh = io.BytesIO()
                b4.save_git_am_mbox(am_msgs, ifh)
                ambytes = ifh.getvalue()

                # Determine best base: configured, series-specified or guessed
                topdir = b4.git_get_toplevel()
                initial_base, base_hint = _detect_initial_base(lser, topdir)

                # Check attestation while we have the messages
                att = b4.review.check_series_attestation(lser)
                if att and self._identifier:
                    b4.review.tracking.update_attestation(
                        self._identifier,
                        series.get('change_id', ''),
                        series.get('revision', 1),
                        att,
                    )

                return lser, ambytes, initial_base, base_hint

        self.push_screen(
            WorkerScreen('Retrieving series\u2026', _fetch_and_prepare),
            callback=lambda result: self._on_series_fetched(result, series),
        )

    def _on_series_fetched(self, result: Any, series: Dict[str, Any]) -> None:
        """Handle the result from the series fetch worker."""
        if result is None:
            return

        lser, ambytes, initial_base, base_hint = result

        base_suggestions = _build_base_suggestions()

        self.push_screen(
            BaseSelectionScreen(
                initial_base,
                lser,
                ambytes,
                base_suggestions=base_suggestions,
                base_hint=base_hint,
                subject=series.get('subject', ''),
            ),
            callback=lambda base_sha: self._on_base_selected(
                base_sha, lser, series, ambytes
            ),
        )

    def _on_base_selected(
        self,
        base_sha: Optional[str],
        lser: b4.LoreSeries,
        series: Dict[str, Any],
        ambytes: bytes,
    ) -> None:
        """Handle base selection screen result."""
        if base_sha is None:
            self.notify('Checkout cancelled', severity='information')
            return
        self._do_checkout(lser, series, base_commit=base_sha, ambytes=ambytes)

    def _discover_newer_versions(
        self, change_id: str, current_rev: int, review_branch: str
    ) -> List[int]:
        """Look up newer revision numbers from tracking data and DB."""
        newer_versions: List[int] = []
        topdir = b4.git_get_toplevel()
        if topdir:
            try:
                _ct, trk = b4.review.load_tracking(topdir, review_branch)
                newer_versions = trk.get('series', {}).get('newer-versions', [])
            except SystemExit:
                pass
        if not newer_versions and self._identifier and change_id:
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                newest = b4.review.tracking.get_newest_revision(conn, change_id)
                conn.close()
                if newest is not None and newest > current_rev:
                    newer_versions = list(range(current_rev + 1, newest + 1))
            except Exception:
                pass
        return newer_versions

    @staticmethod
    def _resolve_base_commit(topdir: str, lser: 'b4.LoreSeries') -> Optional[str]:
        """Determine the base commit for a series, guessing if needed.

        Returns the base commit SHA or None if it cannot be determined.
        """
        base_commit = lser.base_commit
        need_guess = False
        if base_commit:
            if not b4.git_commit_exists(topdir, base_commit):
                logger.warning(
                    'Base commit %s not found in repository, will try to guess',
                    base_commit,
                )
                need_guess = True
        else:
            need_guess = True

        if need_guess:
            logger.info('Guessing base commit...')
            try:
                base_commit, nblobs, mismatches = lser.find_base(
                    topdir, branches=None, maxdays=30
                )
                if mismatches == 0:
                    logger.info('Base: %s (exact match)', base_commit)
                elif nblobs == mismatches:
                    logger.warning('Base: failed to find matching base')
                    base_commit = None
                else:
                    logger.info(
                        'Base: %s (best guess, %s/%s blobs matched)',
                        base_commit,
                        nblobs - mismatches,
                        nblobs,
                    )
            except IndexError as ex:
                logger.warning('Base: failed to guess (%s)', ex)
                base_commit = None

        return base_commit

    def _do_checkout(
        self,
        lser: b4.LoreSeries,
        series: Dict[str, Any],
        base_commit: str,
        ambytes: bytes,
    ) -> None:
        """Create the review branch for the series.

        Args:
            lser: The LoreSeries to check out
            series: Tracking database series dict
            base_commit: Resolved base commit SHA (from BaseSelectionScreen)
            ambytes: Pre-built mbox bytes for git-am
        """
        topdir = b4.git_get_toplevel()
        if not topdir:
            self.notify('Not in a git repository', severity='error')
            return

        # Determine branch name from change_id
        change_id = series.get('change_id', '')
        branch_name = f'b4/review/{change_id}'

        # Suspend UI for all checkout operations that might log
        checkout_success = False
        with self.suspend():
            # Find the top msgid
            top_msgid = None
            first_body = None
            for lmsg in lser.patches:
                if lmsg is not None:
                    first_body = lmsg.body
                    top_msgid = lmsg.msgid
                    break

            if top_msgid is None or first_body is None:
                logger.critical('Could not find any patches in the series')
                _wait_for_enter()
                return

            # Get linkmask
            config = b4.get_main_config()
            linkmask = str(config.get('linkmask', ''))
            if '%s' not in linkmask:
                logger.critical('linkmask not configured properly')
                _wait_for_enter()
                return
            linkurl = linkmask % top_msgid

            # Prepare blob ancestors for three-way merge if needed
            if lser.complete:
                _checked, mismatches = lser.check_applies_clean(gitdir=topdir)
                if mismatches:
                    rstart, rend = lser.make_fake_am_range(gitdir=topdir)
                    if rstart and rend:
                        logger.info(
                            'Prepared fake commit range for 3-way merge (%.12s..%.12s)',
                            rstart,
                            rend,
                        )

            _is_rt = bool(series.get('is_rethreaded'))
            # create_review_branch() reports failure by exiting rather than
            # raising, and SystemExit is not an Exception, so catch it too --
            # letting it out of here unwinds straight through the suspend and
            # takes the session down over one series that would not apply.
            try:
                logger.info('Base: %s', base_commit)
                # Only the am can conflict, and it runs before anything is
                # created, so the resolve belongs around it rather than around
                # the whole block: creating the branch is then written once and
                # covered by the handler below on both routes to it.
                try:
                    b4.git_fetch_am_into_repo(
                        topdir,
                        ambytes=ambytes,
                        at_base=base_commit,
                        origin=linkurl,
                        am_flags=['-3'],
                        resolve=True,
                    )
                except b4.AmConflictError as cex:
                    if not b4.resolve_am_conflict_in_shell(topdir, cex, origin=linkurl):
                        _wait_for_enter()
                        return

                # Create the review branch
                b4.review.create_review_branch(
                    topdir,
                    branch_name,
                    base_commit,
                    lser,
                    linkurl,
                    linkmask,
                    num_prereqs=0,
                    identifier=self._identifier,
                    status='reviewing',
                    is_rethreaded=_is_rt,
                )
                logger.info('Review branch created: %s', branch_name)
                checkout_success = True
            except (Exception, SystemExit) as ex:
                # SystemExit only carries the exit code; create_review_branch()
                # has already said why on its way out.
                reason = 'see above' if isinstance(ex, SystemExit) else str(ex)
                logger.critical('Error creating review branch: %s', reason)
                _wait_for_enter()

        if not checkout_success:
            return

        # Update series status in database
        if self._identifier:
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                conn.execute(
                    'UPDATE series SET status = ?, revision = ?, message_id = ? WHERE track_id = ?',
                    (
                        'reviewing',
                        series.get('revision'),
                        series.get('message_id'),
                        series.get('track_id'),
                    ),
                )
                conn.commit()
                conn.close()
            except Exception as ex:
                logger.warning('Failed to update series status: %s', ex)

        # Carry per-series target branch from DB to tracking commit
        _co_change_id = series.get('change_id', '')
        if self._identifier and _co_change_id and topdir:
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                db_target = b4.review.tracking.get_target_branch(
                    conn, _co_change_id, revision=series.get('revision')
                )
                conn.close()
                if db_target and b4.git_branch_exists(topdir, branch_name):
                    cover_text, tracking = b4.review.load_tracking(topdir, branch_name)
                    trk_series = tracking.get('series', {})
                    if not trk_series.get('target-branch'):
                        trk_series['target-branch'] = db_target
                        tracking['series'] = trk_series
                        b4.review.save_tracking_ref(
                            topdir, branch_name, cover_text, tracking
                        )
            except (SystemExit, Exception):
                pass

        # Update Patchwork state if series was tracked from Patchwork
        pw_series_id = series.get('pw_series_id')
        if pw_series_id:
            b4.review.pw_update_series_state(pw_series_id, 'under-review')

        # Clear the followup badge — user is about to review this series
        _co_change_id = series.get('change_id', '')
        _co_revision = series.get('revision', 1)
        if self._identifier and _co_change_id and isinstance(_co_revision, int):
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                b4.review.tracking.mark_all_messages_seen(
                    conn, _co_change_id, _co_revision
                )
                conn.close()
            except Exception:
                pass

        # Exit to review mode
        self.exit(branch_name)

    def _show_details(self, series: Dict[str, Any]) -> None:

        try:
            panel = self.query_one('#details-panel', Vertical)
        except NoMatches:
            return

        raw_subject = series.get('subject', '(no subject)')
        revision = series.get('revision', 1)
        num_patches = series.get('num_patches', 0) or 0
        ls = b4.LoreSubject(raw_subject)
        extras = ls.get_extra_prefixes(exclude=['patch'])
        width = len(str(num_patches)) if num_patches > 0 else 1
        parts = extras + [f'v{revision}', f'{"0" * width}/{num_patches:0{width}d}']
        subject = f'[{",".join(parts)}] {ls.subject}'

        sender_name = series.get('sender_name', 'Unknown')
        sender_email = series.get('sender_email', '')
        from_str = f'{sender_name} <{sender_email}>' if sender_email else sender_name
        change_id = series.get('change_id', '')
        message_id = series.get('message_id', '')

        # Create link URL from message-id using linkmask
        link_url = ''
        if message_id:
            config = b4.get_main_config()
            linkmask = config.get('linkmask', b4.LOREADDR + '/%s')
            if isinstance(linkmask, str) and '%s' in linkmask:
                link_url = linkmask % message_id

        # Convert ISO date to RFC 822 in local timezone
        sent_str = 'Unknown'
        sent_at = series.get('sent_at', '')
        if sent_at:
            try:
                dt = datetime.datetime.fromisoformat(sent_at)
                # Convert to local timezone
                local_dt = dt.astimezone()
                sent_str = email.utils.format_datetime(local_dt)
            except (ValueError, TypeError):
                sent_str = sent_at

        status_raw = series.get('status', 'new')
        symbol = _STATUS_SYMBOLS.get(status_raw, '?')
        status_str = f'{symbol}  {status_raw}'
        if status_raw == 'snoozed':
            snoozed_until = series.get('snoozed_until')
            if snoozed_until:
                status_str += f' ({_format_snooze_until(snoozed_until)})'

        self.query_one('#detail-subject', Static).update(subject)
        self.query_one('#detail-from', Static).update(from_str)
        self.query_one('#detail-sent', Static).update(sent_str)
        self.query_one('#detail-status', Static).update(status_str)
        self.query_one('#detail-changeid', Static).update(change_id)
        self.query_one('#detail-link', Static).update(link_url)

        # Attestation row
        att = series.get('attestation') or ''
        att_row = self.query_one('#detail-attestation-row', Horizontal)
        att_widget = self.query_one('#detail-attestation', Static)
        if att == 'pending' or att == '':
            att_widget.update(RichText('pending (run [u]pdate)', style='dim'))
            att_row.display = True
        elif att == 'none':
            att_widget.update(RichText('no signatures', style='dim'))
            att_row.display = True
        else:
            att_text = _format_attestation(att, app=self)
            if att_text is not None:
                att_widget.update(att_text)
                att_row.display = True
            else:
                att_row.display = False

        # Show known revisions (precomputed in _load_series)
        revisions_row = self.query_one('#detail-revisions-row', Horizontal)
        revs = series.get('_revisions', [])
        if revs:
            rev_str = ', '.join(f'v{r["revision"]}' for r in revs)
            rev_widget = self.query_one('#detail-revisions', Static)
            if series.get('has_newer'):
                newest = max(r['revision'] for r in revs)
                rev_str += f' (v{newest} available — upgrade with [a]ction)'
                rev_widget.add_class('has-upgrade')
            else:
                rev_widget.remove_class('has-upgrade')
            rev_widget.update(rev_str)
            revisions_row.display = True
        else:
            if series.get('needs_update'):
                rev_widget = self.query_one('#detail-revisions', Static)
                rev_widget.add_class('has-upgrade')
                rev_widget.update('run [u]pdate to load revision data')
                revisions_row.display = True
            else:
                revisions_row.display = False

        # Show branch name for series with a review branch
        status = series.get('status', 'new')
        branch_row = self.query_one('#detail-branch-row', Horizontal)
        if status in ('reviewing', 'replied', 'partial', 'waiting', 'snoozed'):
            branch_name = f'b4/review/{change_id}'
            self.query_one('#detail-branch', Static).update(branch_name)
            branch_row.display = True
        else:
            branch_row.display = False

        # Show target branch from preloaded series data or config default
        target_row = self.query_one('#detail-target-row', Horizontal)
        target_branch = (
            series.get('target_branch')
            or b4.review.tracking.get_review_target_branch_default()
        )
        if target_branch:
            self.query_one('#detail-target', Static).update(target_branch)
            target_row.display = True
        else:
            target_row.display = False

        visible_rows = sum(1 for child in panel.children if child.display)
        panel.styles.height = visible_rows + 2  # +1 border-top, +1 footer

    def _resolve_target_branch(self, series: Dict[str, Any]) -> Optional[str]:
        """Resolve the target branch for a series.

        Priority: tracking commit > DB > config default.
        """
        change_id = series.get('change_id', '')
        status = series.get('status', 'new')
        topdir = b4.git_get_toplevel()

        # Check tracking commit for states with a review branch
        if (
            status in ('reviewing', 'replied', 'partial', 'waiting', 'snoozed')
            and topdir
        ):
            review_branch = f'b4/review/{change_id}'
            if b4.git_branch_exists(topdir, review_branch):
                try:
                    _cover, tracking = b4.review.load_tracking(topdir, review_branch)
                    trk_target = tracking.get('series', {}).get('target-branch')
                    if trk_target:
                        return str(trk_target)
                except (SystemExit, Exception):
                    pass

        # Check DB
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            db_target = b4.review.tracking.get_target_branch(
                conn, change_id, revision=series.get('revision')
            )
            conn.close()
            if db_target:
                return db_target
        except Exception:
            pass

        # Fall back to config default
        return b4.review.tracking.get_review_target_branch_default()

    def action_target_branch(self) -> None:
        """Set the target branch for the selected series."""
        if not self._selected_series:
            return
        series = self._selected_series
        change_id = series.get('change_id', '')

        # Load current target
        current_target = self._resolve_target_branch(series) or ''

        # Build suggestion list from config branches + recent take branches
        suggestions: List[str] = list(b4.review.tracking.get_review_target_branches())
        topdir = b4.git_get_toplevel()
        if topdir:
            gitdir = b4.git_get_common_dir(topdir)
            if gitdir:
                recent = b4.review.tracking.get_recent_take_branches(gitdir)
                for rb in recent:
                    if rb not in suggestions:
                        suggestions.append(rb)

        # Pass review branch for local applicability checks, or
        # message_id for lore fetch if no local branch
        status = series.get('status', 'new')
        review_branch: Optional[str] = None
        if status in ('reviewing', 'replied', 'partial', 'waiting', 'snoozed'):
            rb = f'b4/review/{change_id}'
            if topdir and b4.git_branch_exists(topdir, rb):
                review_branch = rb

        self.push_screen(
            TargetBranchScreen(
                current_target,
                suggestions=suggestions or None,
                subject=series.get('subject', ''),
                message_id=series.get('message_id', ''),
                revision=series.get('revision'),
                review_branch=review_branch,
            ),
            callback=self._on_target_branch_set,
        )

    def _on_target_branch_set(self, result: Optional[str]) -> None:
        """Handle target branch dialog result."""
        if result is None:
            # Cancelled
            return
        series = self._selected_series
        if not series:
            return
        change_id = series.get('change_id', '')
        revision = series.get('revision')
        # Empty string means clear
        target_value = result if result else None

        # Update tracking commit if review branch exists
        topdir = b4.git_get_toplevel()
        status = series.get('status', 'new')
        review_branch = f'b4/review/{change_id}'
        if topdir and status in (
            'reviewing',
            'replied',
            'partial',
            'waiting',
            'snoozed',
        ):
            if b4.git_branch_exists(topdir, review_branch):
                try:
                    cover_text, tracking = b4.review.load_tracking(
                        topdir, review_branch
                    )
                    trk_series = tracking.get('series', {})
                    if target_value:
                        trk_series['target-branch'] = target_value
                    else:
                        trk_series.pop('target-branch', None)
                    tracking['series'] = trk_series
                    b4.review.save_tracking_ref(
                        topdir, review_branch, cover_text, tracking
                    )
                except (SystemExit, Exception) as ex:
                    logger.warning('Could not update tracking commit: %s', ex)

        # Update database
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.update_target_branch(
                conn, change_id, target_value, revision=revision
            )
            conn.close()
        except Exception as ex:
            logger.warning('Could not update target branch in DB: %s', ex)

        if target_value:
            self.notify(f'Target branch set to {target_value}')
        else:
            self.notify('Target branch cleared')

        # Refresh details panel
        self._show_details(series)

    def action_update_one(self) -> None:
        """Fetch thread and update revisions/trailers for the selected series."""
        if not self._selected_series:
            self.notify('No series selected', severity='warning')
            return

        config = b4.get_main_config()
        linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
        topdir = b4.git_get_toplevel()

        self._focus_change_id = self._selected_series.get('change_id')
        self.push_screen(
            UpdateAllScreen(
                [self._selected_series], self._identifier, linkmask, topdir
            ),
            callback=self._on_update_complete,
        )

    def action_update_all(self) -> None:
        """Fetch threads and update revisions/trailers for all tracked series."""
        if not self._all_series:
            self.notify('No tracked series to update', severity='warning')
            return

        config = b4.get_main_config()
        linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
        topdir = b4.git_get_toplevel()

        # Skip snoozed series during update-all
        update_list = [s for s in self._all_series if s.get('status') != 'snoozed']

        if self._selected_series:
            self._focus_change_id = self._selected_series.get('change_id')
        self.push_screen(
            UpdateAllScreen(update_list, self._identifier, linkmask, topdir),
            callback=self._on_update_complete,
        )

    def _on_update_complete(self, result: Optional[Dict[str, Any]]) -> None:
        """Build a summary notification from an update result."""
        if result is None:
            return
        if result.get('busy'):
            self.notify(
                'Series update is already running elsewhere (cron?)',
                severity='warning',
            )
            return
        checked = result.get('series_checked', 0)
        updated = result.get('series_updated', 0)
        errors = result.get('errors', 0)
        gone = result.get('gone', 0)
        error_details: list[tuple[str, str]] = result.get('error_details', [])

        if gone:
            self.notify(f'{gone} review branch(es) are gone', severity='warning')

        parts = [f'Checked {checked} series']
        if updated:
            parts.append(f'{updated} updated')
        if errors:
            parts.append(f'{errors} error(s)')
        skipped = result.get('checked_out_skipped', 0)
        if skipped:
            parts.append(f'{skipped} checked-out branch(es) left alone')

        severity: Literal['information', 'warning'] = (
            'warning' if errors else 'information'
        )
        self.notify(', '.join(parts), severity=severity)
        for submitter, error in error_details:
            logger.warning('Update error (%s): %s', submitter, error)
            self.notify(f'{submitter}: {error}', severity='error')
        self._invalidate_caches()
        self._load_series()

    def action_hide_details(self) -> None:
        panel = self.query_one('#details-panel', Vertical)
        if self._selected_series is not None:
            panel.styles.height = 0
            self._selected_series = None

    def action_take(self) -> None:
        """Show take options dialog for the selected series."""
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        if status not in ('reviewing', 'replied', 'partial'):
            self.notify('Series must be checked out before taking', severity='warning')
            return

        series = self._selected_series

        # Determine the target branch suggestion.
        # Priority: per-series target > recent take branch > config default >
        # original branch > master/main
        per_series_target = self._resolve_target_branch(series)
        target_branch = ''
        if per_series_target:
            target_branch = per_series_target
        elif self._original_branch:
            target_branch = self._original_branch
        else:
            topdir = b4.git_get_toplevel()
            if topdir and b4.git_branch_exists(topdir, 'master'):
                target_branch = 'master'
            elif topdir and b4.git_branch_exists(topdir, 'main'):
                target_branch = 'main'

        change_id = series.get('change_id', '')
        review_branch = f'b4/review/{change_id}'

        # Check if a newer revision is known to exist
        current_rev = series.get('revision', 1)
        newer_versions = self._discover_newer_versions(
            change_id, current_rev, review_branch
        )

        if newer_versions:
            # Require explicit confirmation before taking an older revision
            self.push_screen(
                NewerRevisionWarningScreen(current_rev, newer_versions),
                callback=lambda proceed: self._on_newer_revision_acknowledged(
                    proceed, target_branch, change_id, review_branch, series
                ),
            )
        else:
            self._show_take_screen(target_branch, change_id, review_branch, series)

    def _on_newer_revision_acknowledged(
        self,
        proceed: Optional[bool],
        target_branch: str,
        change_id: str,
        review_branch: str,
        series: Dict[str, Any],
    ) -> None:
        """Handle result of the newer-revision warning."""
        if not proceed:
            return
        self._show_take_screen(target_branch, change_id, review_branch, series)

    def _show_take_screen(
        self,
        target_branch: str,
        change_id: str,
        review_branch: str,
        series: Dict[str, Any],
    ) -> None:
        """Push the TakeScreen dialog."""
        num_patches = series.get('num_patches', 0) or 0
        # Start with user config preference; skip detection below may override it.
        _valid_take_methods = {'merge', 'linear', 'cherry-pick'}
        b4cfg = b4.get_config_from_git(r'b4\..*')
        cfg_method = str(b4cfg.get('review-default-take-method', ''))
        default_method: Optional[str] = (
            cfg_method if cfg_method in _valid_take_methods else None
        )
        has_cover = True
        topdir = b4.git_get_toplevel()
        if topdir:
            try:
                cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
                has_cover = NO_COVER_NOTE not in cover_text
                usercfg = b4.get_user_config()
                patches = tracking.get('patches', [])
                if any(
                    b4.review._get_patch_state(p, usercfg) == 'skip' for p in patches
                ):
                    default_method = 'cherry-pick'
            except Exception:
                pass
        recent_branches = None
        gitdir = b4.git_get_common_dir(topdir) if topdir else None
        if gitdir:
            recent_branches = b4.review.tracking.get_recent_take_branches(gitdir)
        # Only use recent-take as default if no per-series target was provided
        per_series_target = self._resolve_target_branch(series)
        if recent_branches and not per_series_target:
            target_branch = recent_branches[0]
        # Build the suggestion list: config branches + recent take branches
        all_suggestions: List[str] = list(
            b4.review.tracking.get_review_target_branches()
        )
        if recent_branches:
            for rb in recent_branches:
                if rb not in all_suggestions:
                    all_suggestions.append(rb)
        if target_branch and target_branch not in all_suggestions:
            all_suggestions.append(target_branch)
        recent_branches = all_suggestions or None
        # Default the Signed-off-by checkbox from the configured signoff intent
        # (shazam-merge-flags defaults to --signoff), so config drives the
        # default while the user can still override it per-take.
        default_signoff = '--signoff' in shlex.split(
            str(b4cfg.get('shazam-merge-flags', '--signoff'))
        )
        try:
            default_thank_archive = b4.get_git_bool(
                str(b4cfg.get('review-thank-and-archive', 'no'))
            )
        except ValueError:
            default_thank_archive = False
        take_screen = TakeScreen(
            target_branch,
            review_branch,
            num_patches=num_patches,
            default_method=default_method,
            recent_branches=recent_branches,
            subject=series.get('subject', ''),
            default_signoff=default_signoff,
            has_cover=has_cover,
            default_thank_archive=default_thank_archive,
        )
        self.push_screen(
            take_screen,
            callback=lambda confirmed: self._on_take_confirmed(
                confirmed, change_id, review_branch, take_screen, series
            ),
        )

    def _on_take_confirmed(
        self,
        confirmed: Optional[bool],
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
    ) -> None:
        """Handle take screen result — proceed to patch selection or confirm.

        The cherry-pick method always offers the patch picker. The merge
        method offers it only when some patches are skipped, so a merge can
        still produce a cover-letter merge commit while excluding the
        skipped patches; with nothing skipped it merges the whole series.
        """
        if not confirmed:
            return
        method = take_screen.method_result
        if method in ('cherry-pick', 'merge'):
            # Load tracking to get the patch list for selection
            topdir = b4.git_get_toplevel()
            if not topdir:
                self.notify('Not in a git repository', severity='error')
                return
            try:
                _cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
            except SystemExit:
                self.notify('Could not load tracking data', severity='error')
                return
            patches = tracking.get('patches', [])
            if not patches:
                self.notify('No patches found in tracking data', severity='error')
                return
            usercfg = b4.get_user_config()
            preselected = [
                i + 1
                for i, p in enumerate(patches)
                if b4.review._get_patch_state(p, usercfg) != 'skip'
            ]
            has_skips = len(preselected) != len(patches)
            # Merge with nothing skipped takes the whole series, no picker.
            if method == 'cherry-pick' or has_skips:
                # Only pre-populate when some patches are actually skipped
                pre = preselected if has_skips else None
                pick_screen = CherryPickScreen(patches, preselected=pre)
                self.push_screen(
                    pick_screen,
                    callback=lambda picked: self._on_cherrypick_confirmed(
                        picked,
                        change_id,
                        review_branch,
                        take_screen,
                        series,
                        pick_screen,
                    ),
                )
                return
        self._show_take_confirm(
            method,
            take_screen.target_result,
            change_id,
            review_branch,
            take_screen,
            series,
        )

    def _on_cherrypick_confirmed(
        self,
        confirmed: Optional[bool],
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        pick_screen: 'CherryPickScreen',
    ) -> None:
        """Handle patch selection — proceed to confirm screen.

        Preserves the originally chosen method (cherry-pick or merge) so a
        skip-trimmed merge still produces a merge commit rather than a
        sequence of cherry-picks.
        """
        if not confirmed:
            return
        self._show_take_confirm(
            take_screen.method_result,
            take_screen.target_result,
            change_id,
            review_branch,
            take_screen,
            series,
            cherrypick=pick_screen.selected_indices,
        )

    def _show_take_confirm(
        self,
        method: str,
        target_branch: str,
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        cherrypick: Optional[List[int]] = None,
    ) -> None:
        """Push the TakeConfirmScreen for final confirmation."""
        subject = series.get('subject', '')
        confirm_screen = TakeConfirmScreen(
            method, target_branch, review_branch, subject=subject, cherrypick=cherrypick
        )
        self.push_screen(
            confirm_screen,
            callback=lambda ok: self._on_take_final(
                ok,
                method,
                change_id,
                review_branch,
                take_screen,
                series,
                confirm_screen,
                cherrypick,
            ),
        )

    def _on_take_final(
        self,
        confirmed: Optional[bool],
        method: str,
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        confirm_screen: 'TakeConfirmScreen',
        cherrypick: Optional[List[int]] = None,
    ) -> None:
        """Execute the actual take after final confirmation."""
        if not confirmed:
            return
        take_screen.accept_series = confirm_screen.accept_series
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        if method == 'merge':
            with self.suspend():
                new_status = self._do_take_merge(
                    change_id, review_branch, take_screen, series, cherrypick=cherrypick
                )
            self._load_series()
        else:
            with self.suspend():
                new_status = self._do_take_am(
                    change_id, review_branch, take_screen, series, cherrypick=cherrypick
                )
            self._load_series()
        if take_screen.thank_and_archive:
            if new_status == 'accepted':
                # Chain straight into the thank flow; a successful send or
                # delivery of the queued message will archive the series.
                chained = dict(series)
                chained['status'] = new_status
                self._start_thank(chained, archive_after=True)
            elif new_status == 'partial':
                self.notify(
                    'Thank & archive skipped: series only partially applied',
                    severity='warning',
                )
            elif new_status == 'unrecorded':
                self.notify(
                    'Thank & archive skipped: the take was not recorded',
                    severity='warning',
                )
            elif not take_screen.accept_series:
                self.notify(
                    'Thank & archive skipped: series not marked accepted',
                    severity='warning',
                )
            else:
                # _do_take_* returns None both for "accept was unchecked" and
                # for "the take never finished"; only the latter is left here,
                # and claiming a status the series never reached would send the
                # maintainer looking for a take that did not happen.
                self.notify(
                    'Thank & archive skipped: take did not complete',
                    severity='warning',
                )

    @staticmethod
    def _record_take_metadata(
        topdir: str,
        review_branch: str,
        target_branch: str,
        commit_ids: List[str],
        cherrypick: Optional[List[int]] = None,
        accepted: bool = True,
    ) -> Optional[str]:
        """Record taken commit IDs in the tracking data on the review branch.

        Computes patch coverage and returns the resulting series status:
        'accepted' if all patches are now taken, 'partial' if some remain,
        'unrecorded' if the patches went in but the tracking data could not
        be read, or None if *accepted* is False (no status change requested).

        Args:
            topdir: Repository top-level directory.
            review_branch: The b4/review/... branch with the tracking commit.
            target_branch: Branch the patches were applied to.
            commit_ids: Ordered list of commit SHAs that were applied.
            cherrypick: If set, the 1-based patch indices that were picked.
            accepted: If True, mark the series status based on patch coverage.
        """
        try:
            cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
        except SystemExit:
            # The patches are applied; only the record of it is missing, which
            # is a different thing from a take that never got this far.
            logger.critical('Could not load tracking data for recording the take')
            return 'unrecorded'

        series = tracking.get('series', {})
        take_info = {
            'branch': target_branch,
            'date': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d'),
            'accepted': accepted,
        }
        series['taken'] = take_info
        series.setdefault('takes', []).append(take_info)

        # Record the resulting target-branch tip for CI lookups (e.g. KernelCI).
        # Resolve the branch ref, not HEAD: the merge-take path advances
        # target_branch in a separate worktree and never moves the current
        # checkout, so HEAD here is the launch branch, not the merge commit.
        ecode, tip_out = b4.git_run_command(topdir, ['rev-parse', target_branch])
        if ecode == 0 and tip_out.strip():
            tip_entry = {
                'date': take_info['date'],
                'branch': target_branch,
                'sha': tip_out.strip(),
            }
            series.setdefault('branch-tips', []).append(tip_entry)

        patches = tracking.get('patches', [])
        if cherrypick:
            # Map selected 1-based indices to commit IDs
            for ci, pidx in enumerate(cherrypick):
                pi = pidx - 1  # convert to 0-based patches list index
                if pi < len(patches) and ci < len(commit_ids):
                    patches[pi]['taken'] = {'commit-id': commit_ids[ci]}
        else:
            # All patches taken in order
            for pi, patch in enumerate(patches):
                if pi < len(commit_ids):
                    patch['taken'] = {'commit-id': commit_ids[pi]}

        # Determine new status from coverage: if every patch now has a taken
        # marker the series is fully accepted; otherwise it is partially applied.
        new_status: Optional[str] = None
        if accepted:
            all_taken = patches and all(p.get('taken') for p in patches)
            new_status = 'accepted' if all_taken else 'partial'
            series['status'] = new_status

        tracking['series'] = series
        if not b4.review.save_tracking_ref(topdir, review_branch, cover_text, tracking):
            logger.warning('Could not save take metadata to tracking commit')
        return new_status

    def _do_take_merge(
        self,
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        cherrypick: Optional[List[int]] = None,
    ) -> Optional[str]:
        """Perform a merge-based take operation.

        When *cherrypick* is set, only those 1-based patch indices are
        included in the merged side, allowing a cover-letter merge commit
        that excludes skipped patches.

        Returns the resulting series status ('accepted'/'partial'), or
        None when the take did not complete or no status change was
        requested.
        """
        target_branch = take_screen.target_result

        # Setup
        topdir = b4.git_get_toplevel()
        if not topdir:
            logger.critical('Not in a git repository')
            return None

        # Prepare trailer-amended mbox bytes from local review branch
        ambytes = self._prepare_am_messages(
            review_branch, take_screen, series, cherrypick=cherrypick
        )
        if ambytes is None:
            return None

        try:
            cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
        except SystemExit:
            logger.critical('Could not load tracking data from %s', review_branch)
            _wait_for_enter()
            return None

        t_series = tracking.get('series', {})

        # Build merge message from template
        config = b4.get_config_from_git('b4\\..*')
        merge_template = b4.mbox.DEFAULT_MERGE_TEMPLATE
        if config.get('shazam-merge-template'):
            try:
                merge_template = b4.read_template(str(config['shazam-merge-template']))
            except FileNotFoundError:
                logger.critical(
                    'ERROR: shazam-merge-template says to use %s, but it does not exist',
                    config['shazam-merge-template'],
                )
                _wait_for_enter()
                return None

        # Extract cover message body
        covermessage = ''
        if cover_text:
            _githeaders, message, _trailers, _basement, _sig = (
                b4.LoreMessage.get_body_parts(cover_text)
            )
            covermessage = message.strip()
        if not covermessage:
            covermessage = '(no cover letter message)'

        # Clean subject: strip [PATCH vN M/N] prefix
        subject = t_series.get('subject', '')
        clean_subject = b4.LoreSubject(subject).subject or subject

        # Determine number of patches actually being merged
        if cherrypick:
            num_patches = len(cherrypick)
        else:
            num_patches = len(tracking.get('patches', []))

        # Populate template
        tptvals = {
            'seriestitle': clean_subject,
            'authorname': t_series.get('fromname', ''),
            'authoremail': t_series.get('fromemail', ''),
            'covermessage': covermessage,
            'midurl': t_series.get('link', ''),
            'mid': t_series.get('header-info', {}).get('msgid', ''),
            'patch_or_series': 'patch series' if num_patches > 1 else 'patch',
        }
        body = Template(merge_template).safe_substitute(tptvals)

        # Strip Link: line if not requested
        if not take_screen.add_link:
            body = re.sub(r'^Link:.*\n?', '', body, flags=re.MULTILINE)

        # Signed-off-by is not baked into the body: it rides on git-merge's
        # --signoff flag (see _shazam_merge_flags), reconciled with the take
        # dialog's checkbox, so it can never double up with shazam-merge-flags.

        # Apply trailer-amended patches in a sparse worktree and fetch
        # into FETCH_HEAD, so individual commits carry their trailers.
        # The merge message is opened for editing below by git-merge --edit
        # (mirroring "b4 shazam"), so the --log shortlog git appends is
        # visible and editable there.
        base_commit = t_series.get('base-commit', '')
        if not base_commit:
            # Fall back to target branch HEAD
            ecode, out = b4.git_run_command(topdir, ['rev-parse', target_branch])
            if ecode != 0:
                logger.critical('Could not resolve %s', target_branch)
                _wait_for_enter()
                return None
            base_commit = out.strip()

        # Merge in whichever worktree holds the target branch (or a throwaway
        # one), so the current checkout is never disturbed.
        with _take_worktree(topdir, target_branch) as wt:
            if wt is None:
                return None
            merge_dir = wt.path
            # Apply trailer-amended patches in a sparse worktree and fetch into
            # the target worktree's FETCH_HEAD (which is per-worktree), so the
            # merge below sees them and each commit carries its trailers.
            try:
                b4.git_fetch_am_into_repo(
                    merge_dir,
                    ambytes,
                    at_base=base_commit,
                    origin=t_series.get('link', ''),
                    am_flags=['-3'],
                    resolve=True,
                )
            except b4.AmConflictError as cex:
                if not b4.resolve_am_conflict_in_shell(
                    merge_dir, cex, origin=t_series.get('link', '')
                ):
                    _wait_for_enter()
                    return None
            except RuntimeError:
                _wait_for_enter()
                return None

            # Write merge message to the target worktree's git dir
            ecode, gitdir = b4.git_run_command(merge_dir, ['rev-parse', '--git-dir'])
            if ecode != 0:
                logger.critical('Unable to find git directory')
                _wait_for_enter()
                return None
            mmf = os.path.join(gitdir.strip(), 'b4-merge-msg')
            with open(mmf, 'w') as fh:
                fh.write(body)

            # Merge FETCH_HEAD (trailer-amended patches) instead of the review
            # branch directly, so each commit carries its trailers. Mirror
            # "b4 shazam": git-merge builds the final message from -F + the
            # flags and opens the editor (--edit), so b4.shazam-merge-flags
            # such as --log/--stat/--gpg-sign take effect and the appended
            # shortlog is visible. Signed-off-by rides on --signoff in the
            # flags, reconciled with the take dialog's checkbox (see
            # _shazam_merge_flags).
            mergeflags = _shazam_merge_flags(config, take_screen.add_signoff)
            out = ''
            if hasattr(sys, '_running_in_pytest'):
                # Tests have no tty for an interactive editor; run the merge
                # non-interactively through the captured runner, like the CLI.
                mergeargs = [
                    'merge',
                    '--no-ff',
                    '-F',
                    mmf,
                    '--no-edit',
                    'FETCH_HEAD',
                ] + mergeflags
                ecode, out = b4.git_run_command(
                    merge_dir, mergeargs, logstderr=True, rundir=merge_dir
                )
            else:
                # Run git directly with an inherited tty (under the caller's
                # suspend()) so git can open the editor, like _suspend_to_shell.
                mergeargs = [
                    'git',
                    '-C',
                    merge_dir,
                    'merge',
                    '--no-ff',
                    '-F',
                    mmf,
                    '--edit',
                    'FETCH_HEAD',
                ] + mergeflags
                ecode = subprocess.run(mergeargs).returncode

            # Clean up message file
            try:
                os.unlink(mmf)
            except OSError:
                pass

            if ecode != 0:
                # A conflicted merge can be finished by hand; let the user
                # resolve it in the worktree instead of discarding the take
                # (mirrors the git-am path above and "b4 shazam"). Anything
                # else (e.g. a dirty worktree) leaves no merge to resolve, so
                # abort and bail as before.
                if not b4._worktree_merge_in_progress(merge_dir):
                    logger.critical(
                        'Merge failed%s', f': {out.strip()}' if out.strip() else ''
                    )
                    logger.critical('Aborting merge...')
                    b4.git_run_command(
                        merge_dir,
                        ['merge', '--abort'],
                        logstderr=True,
                        rundir=merge_dir,
                    )
                    _wait_for_enter()
                    return None
                logger.critical('Merge conflict:')
                if out.strip():
                    logger.critical(out.strip())
                # HEAD still points at the pre-merge tip while the conflicted
                # merge sits uncommitted; capture it to tell a finished merge
                # from an abort.
                ecode, pre_merge_head = b4.git_run_command(
                    merge_dir, ['rev-parse', 'HEAD']
                )
                pre_merge_head = pre_merge_head.strip() if ecode == 0 else ''
                if not _resolve_worktree_take_conflict(
                    wt, 'merge', pre_merge_head, b4._worktree_merge_in_progress
                ):
                    _wait_for_enter()
                    return None
                logger.info('Conflict resolved, series merged.')

            logger.info('Merged %s into %s', review_branch, target_branch)

            # Record per-patch commit IDs from the merged branch.
            # After --no-ff merge, HEAD^2 is the tip of the merged side;
            # the individual patch commits are base_commit..HEAD^2.
            ecode, out = b4.git_run_command(
                merge_dir, ['rev-list', '--reverse', f'{base_commit}..HEAD^2']
            )
            new_status: Optional[str] = None
            if ecode == 0 and out.strip():
                commit_ids = out.strip().splitlines()
                new_status = self._record_take_metadata(
                    topdir,
                    review_branch,
                    target_branch,
                    commit_ids,
                    cherrypick=cherrypick,
                    accepted=take_screen.accept_series,
                )

            self._finalize_take(topdir, target_branch, change_id, t_series, new_status)
            _wait_for_enter()
            return new_status

    def _finalize_take(
        self,
        topdir: str,
        target_branch: str,
        change_id: str,
        series: Dict[str, Any],
        new_status: Optional[str],
    ) -> None:
        """Common post-take steps: record branch, update DB, update Patchwork.

        *new_status* is the status computed by _record_take_metadata:
        'accepted', 'partial', 'unrecorded' (nothing was written to the
        tracking commit, so there is no coverage to propagate), or None (when
        the user did not request a status change).
        """
        common_dir = b4.git_get_common_dir(topdir)
        if common_dir:
            b4.review.tracking.record_take_branch(common_dir, target_branch)

        if new_status in ('accepted', 'partial') and self._identifier and change_id:
            revision = series.get('revision')
            existing_target = None
            try:
                conn = b4.review.tracking.get_db(self._identifier)
                b4.review.tracking.update_series_status(
                    conn, change_id, new_status, revision=revision
                )
                # Record the take target as the series target branch if not already set
                existing_target = b4.review.tracking.get_target_branch(
                    conn, change_id, revision=revision
                )
                if not existing_target:
                    b4.review.tracking.update_target_branch(
                        conn, change_id, target_branch, revision=revision
                    )
                conn.close()
            except Exception as ex:
                logger.warning('Could not update series status: %s', ex)
            # Also update the tracking commit
            review_branch = f'b4/review/{change_id}'
            if not existing_target and b4.git_branch_exists(topdir, review_branch):
                try:
                    cover_text, tracking = b4.review.load_tracking(
                        topdir, review_branch
                    )
                    trk_series = tracking.get('series', {})
                    if not trk_series.get('target-branch'):
                        trk_series['target-branch'] = target_branch
                        tracking['series'] = trk_series
                        b4.review.save_tracking_ref(
                            topdir, review_branch, cover_text, tracking
                        )
                except (SystemExit, Exception):
                    pass

        if new_status == 'accepted' and self._selected_series:
            pw_sid = self._selected_series.get('pw_series_id')
            if pw_sid:
                b4.review.pw_update_series_state(pw_sid, 'accepted')

    def _prepare_am_messages(
        self,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        cherrypick: Optional[List[int]] = None,
    ) -> Optional[bytes]:
        """Generate patches from local review branch and prepare trailer-amended mbox bytes.

        Loads tracking from the review branch, generates patches from local
        commits (instead of re-fetching from lore), injects original
        Message-IDs and follow-up trailers, and runs get_am_ready() to
        produce mbox bytes suitable for git-am.

        Returns mbox bytes on success, or None on failure.
        """
        topdir = b4.git_get_toplevel()
        if not topdir:
            logger.critical('Not in a git repository')
            return None

        # Load tracking to get follow-up trailers
        try:
            _cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
        except SystemExit:
            logger.critical('Could not load tracking data from %s', review_branch)
            _wait_for_enter()
            return None

        t_series = tracking.get('series', {})

        # Determine patch range from local review branch
        first_patch = t_series.get('first-patch-commit', '')
        if first_patch:
            range_start = f'{first_patch}~1'
        else:
            range_start = t_series.get('base-commit', '')
        range_end = f'{review_branch}~1'  # exclude tracking commit

        if not range_start:
            logger.critical('Cannot determine patch range for %s', review_branch)
            _wait_for_enter()
            return None

        # Generate patches from local commits
        revision = t_series.get('revision', 1)
        try:
            local_patches = b4.git_range_to_patches(
                topdir, range_start, range_end, revision=revision
            )
        except RuntimeError as ex:
            logger.critical('Could not generate patches: %s', ex)
            _wait_for_enter()
            return None

        if not local_patches:
            logger.critical('No patches found in range %s..%s', range_start, range_end)
            _wait_for_enter()
            return None

        # Inject original Message-IDs so Link trailers point to lore
        patches_meta = tracking.get('patches', [])
        for i, (_commit, msg) in enumerate(local_patches):
            if i < len(patches_meta):
                orig_msgid = patches_meta[i].get('header-info', {}).get('msgid', '')
                if orig_msgid:
                    msg.add_header('Message-Id', f'<{orig_msgid}>')

        # Build LoreSeries from local patches
        lmbx = b4.LoreMailbox()
        for _commit, msg in local_patches:
            lmbx.add_message(msg)

        lser = lmbx.get_series(
            revision, sloppytrailers=False, codereview_trailers=False
        )
        if lser is None:
            logger.critical('Could not build series from local patches')
            _wait_for_enter()
            return None

        # Apply cover follow-up trailers to every patch (no cover letter
        # in local patches, so distribute to all)
        cover_followups = tracking.get('followups', [])
        for followup in cover_followups:
            for tstr in followup.get('trailers', []):
                if ': ' not in tstr:
                    continue
                tname, tval = tstr.split(': ', maxsplit=1)
                fltr = b4.LoreTrailer(name=tname, value=tval)
                for lmsg in lser.patches[1:]:
                    if lmsg is not None and fltr not in lmsg.followup_trailers:
                        lmsg.followup_trailers.append(fltr)

        # Apply per-patch follow-up trailers
        for i, pmeta in enumerate(patches_meta):
            pidx = i + 1  # patches[0] is cover letter
            if pidx >= len(lser.patches) or lser.patches[pidx] is None:
                continue
            patch = lser.patches[pidx]
            assert patch is not None
            for followup in pmeta.get('followups', []):
                for tstr in followup.get('trailers', []):
                    if ': ' not in tstr:
                        continue
                    tname, tval = tstr.split(': ', maxsplit=1)
                    fltr = b4.LoreTrailer(name=tname, value=tval)
                    if fltr not in patch.followup_trailers:
                        patch.followup_trailers.append(fltr)

        # Get am-ready messages
        am_msgs = lser.get_am_ready(
            noaddtrailers=False,
            addmysob=take_screen.add_signoff,
            addlink=take_screen.add_link,
            cherrypick=cherrypick,
            copyccs=False,
            allowbadchars=False,
        )
        if not am_msgs:
            logger.critical('No patches ready for applying')
            _wait_for_enter()
            return None

        if cherrypick:
            logger.info(
                'Prepared %d patch(es) (cherry-picked: %s)',
                len(am_msgs),
                ', '.join(str(x) for x in cherrypick),
            )
        else:
            logger.info('Prepared %d patch(es)', len(am_msgs))

        # Build mbox bytes for git-am
        ifh = io.BytesIO()
        b4.save_git_am_mbox(am_msgs, ifh)
        return ifh.getvalue()

    def _do_take_am(
        self,
        change_id: str,
        review_branch: str,
        take_screen: 'TakeScreen',
        series: Dict[str, Any],
        cherrypick: Optional[List[int]],
    ) -> Optional[str]:
        """Perform a linear or cherry-pick take via git-am.

        Returns the resulting series status ('accepted'/'partial'), or
        None when the take did not complete or no status change was
        requested.
        """
        target_branch = take_screen.target_result

        topdir = b4.git_get_toplevel()
        if not topdir:
            logger.critical('Not in a git repository')
            return None

        ambytes = self._prepare_am_messages(
            review_branch, take_screen, series, cherrypick=cherrypick
        )
        if ambytes is None:
            return None

        # Apply in whichever worktree holds the target branch (or a throwaway
        # one), so the current checkout is never disturbed.
        with _take_worktree(topdir, target_branch) as wt:
            if wt is None:
                return None
            am_dir = wt.path

            # Save HEAD before git-am so we can find the new commits afterwards
            ecode, out = b4.git_run_command(am_dir, ['rev-parse', 'HEAD'])
            pre_am_head = out.strip() if ecode == 0 else ''

            # Run git-am with three-way merge in the target worktree. Anchor to
            # am_dir via rundir: git_run_command targets a primary worktree with
            # --git-dir (not -C), so without it git-am writes files into b4's
            # cwd rather than the target worktree (cf. git_fetch_am_into_repo).
            ecode, out = b4.git_run_command(
                am_dir, ['am', '-3'], stdin=ambytes, logstderr=True, rundir=am_dir
            )
            if ecode != 0:
                logger.critical('git-am failed:')
                logger.critical(out.strip())
                if not _resolve_worktree_take_conflict(
                    wt, 'am', pre_am_head, b4._worktree_rebase_apply_dir
                ):
                    _wait_for_enter()
                    return None
                logger.info('Conflict resolved, patches applied.')

            logger.info(out.strip())
            logger.info('Applied patches to %s', target_branch)

            # Record per-patch commit IDs in the tracking data
            new_status: Optional[str] = None
            if pre_am_head:
                ecode, out = b4.git_run_command(
                    am_dir, ['rev-list', '--reverse', f'{pre_am_head}..HEAD']
                )
                if ecode == 0:
                    commit_ids = out.strip().splitlines()
                    new_status = self._record_take_metadata(
                        topdir,
                        review_branch,
                        target_branch,
                        commit_ids,
                        cherrypick=cherrypick,
                        accepted=take_screen.accept_series,
                    )

            self._finalize_take(topdir, target_branch, change_id, series, new_status)
            _wait_for_enter()
            return new_status

    def action_rebase(self) -> None:
        """Rebase the review branch on top of current HEAD."""
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        if status not in ('reviewing', 'replied', 'partial'):
            self.notify(
                'Series must be checked out before rebasing', severity='warning'
            )
            return

        series = self._selected_series
        change_id = series.get('change_id', '')
        review_branch = f'b4/review/{change_id}'

        # Determine the default target branch.
        # Priority: per-series target > recent take branch > original branch
        per_series_target = self._resolve_target_branch(series)
        current_branch = self._original_branch or 'HEAD'
        recent_branches = None
        topdir = b4.git_get_toplevel()
        if topdir:
            gitdir = b4.git_get_common_dir(topdir)
            if gitdir:
                recent_branches = b4.review.tracking.get_recent_take_branches(gitdir)
        if per_series_target:
            current_branch = per_series_target
        elif recent_branches:
            current_branch = recent_branches[0]
        # Ensure the original branch is always in the suggestion list
        if (
            current_branch
            and recent_branches is not None
            and current_branch not in recent_branches
        ):
            recent_branches.append(current_branch)
        elif current_branch and recent_branches is None:
            recent_branches = [current_branch]

        rebase_screen = RebaseScreen(
            current_branch,
            review_branch,
            recent_branches=recent_branches,
            subject=self._selected_series.get('subject', ''),
        )
        self.push_screen(
            rebase_screen,
            callback=lambda confirmed: self._on_rebase_confirmed(
                confirmed, review_branch, rebase_screen
            ),
        )

    def _on_rebase_confirmed(
        self,
        confirmed: Optional[bool],
        review_branch: str,
        rebase_screen: 'RebaseScreen',
    ) -> None:
        """Handle rebase confirmation result."""
        if not confirmed:
            return

        # Run rebase in suspended mode to show output
        with self.suspend():
            self._do_rebase(review_branch, rebase_screen.target_result)

    def _do_rebase(self, review_branch: str, target_branch: str) -> None:
        """Perform the actual rebase operation."""
        topdir = b4.git_get_toplevel()
        if not topdir:
            logger.critical('Not in a git repository')
            return

        # Load tracking data from the review branch
        try:
            cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
        except SystemExit:
            logger.critical('Could not load tracking data from %s', review_branch)
            _wait_for_enter()
            return

        series = tracking.get('series', {})
        base_commit = series.get('base-commit', '')

        # Resolve target branch to a commit SHA
        ecode, out = b4.git_run_command(topdir, ['rev-parse', target_branch])
        if ecode != 0:
            logger.critical('Could not resolve %s', target_branch)
            _wait_for_enter()
            return
        target_head = out.strip()

        # Check if series is already based on target
        ecode, out = b4.git_run_command(topdir, ['rev-parse', base_commit])
        if ecode == 0 and out.strip() == target_head:
            logger.info('Series is already based on %s', target_branch)
            _wait_for_enter()
            return

        # Get the range of commits in the review branch (excluding tracking commit)
        # The tracking commit is at the tip, so we need HEAD~1
        ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{review_branch}~1'])
        if ecode != 0:
            logger.critical('Could not resolve review branch tip')
            _wait_for_enter()
            return
        series_tip = out.strip()

        # Check if series applies cleanly to target using sparse worktree
        logger.info('Testing if series applies cleanly to %s...', target_branch)
        applies_clean = False
        try:
            with b4.git_temp_worktree(topdir, target_head) as gwt:
                # Set up sparse checkout for minimal disk usage
                ecode, out = b4.git_run_command(
                    gwt,
                    [*b4.SCRATCH_GIT_OPTS, 'sparse-checkout', 'set'],
                    logstderr=True,
                )
                if ecode != 0:
                    logger.warning('Could not set up sparse checkout: %s', out.strip())
                ecode, out = b4.git_run_command(
                    gwt, [*b4.SCRATCH_GIT_OPTS, 'checkout', '-f'], logstderr=True
                )
                if ecode != 0:
                    logger.warning(
                        'Could not checkout sparse worktree: %s', out.strip()
                    )

                # Try cherry-picking the commits
                gitargs = [
                    *b4.SCRATCH_GIT_OPTS,
                    'cherry-pick',
                    f'{base_commit}..{series_tip}',
                ]
                ecode, out = b4.git_run_command(gwt, gitargs, logstderr=True)
                if ecode != 0:
                    logger.warning('Series does not apply cleanly to %s', target_branch)
                    logger.warning('Will attempt rebase with conflict resolution')
                else:
                    applies_clean = True
                    logger.info('Series applies cleanly')
        except Exception as ex:
            logger.critical('Error testing series applicability: %s', ex)
            _wait_for_enter()
            return

        # Perform the actual rebase
        logger.info('Rebasing %s onto %s...', review_branch, target_branch)

        # Remember where we are so we can restore on failure
        ecode, original_branch = b4.git_run_command(
            topdir, ['rev-parse', '--abbrev-ref', 'HEAD'], logstderr=True
        )
        if ecode != 0:
            logger.critical('Could not determine current branch')
            _wait_for_enter()
            return
        original_branch = original_branch.strip()

        # First, checkout the review branch (at the tracking commit)
        ecode, out = b4.git_run_command(
            topdir, ['checkout', review_branch], logstderr=True
        )
        if ecode != 0:
            logger.critical('Could not checkout review branch: %s', out.strip())
            _wait_for_enter()
            return

        # Save the tracking commit SHA so we can restore on failure
        ecode, tracking_commit = b4.git_run_command(
            topdir, ['rev-parse', 'HEAD'], logstderr=True
        )
        if ecode != 0:
            logger.critical('Could not resolve tracking commit')
            b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True)
            _wait_for_enter()
            return
        tracking_commit = tracking_commit.strip()

        # Reset to before the tracking commit (now at series_tip)
        ecode, out = b4.git_run_command(
            topdir, ['reset', '--hard', 'HEAD~1'], logstderr=True
        )
        if ecode != 0:
            logger.critical(
                'Could not reset to before tracking commit: %s', out.strip()
            )
            b4.git_run_command(
                topdir, ['reset', '--hard', tracking_commit], logstderr=True
            )
            b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True)
            _wait_for_enter()
            return

        # Rebase the patches onto target_head
        # --onto target_head base_commit means: take commits after base_commit and replay onto target_head
        ecode, out = b4.git_run_command(
            topdir, ['rebase', '--onto', target_head, base_commit], logstderr=True
        )
        if ecode != 0:
            if applies_clean:
                # Test said clean but real rebase failed — something is wrong, abort
                logger.critical('Rebase failed unexpectedly: %s', out.strip())
                logger.critical('Aborting rebase...')
                b4.git_run_command(topdir, ['rebase', '--abort'], logstderr=True)
                b4.git_run_command(
                    topdir, ['reset', '--hard', tracking_commit], logstderr=True
                )
                b4.git_run_command(
                    topdir, ['checkout', original_branch], logstderr=True
                )
                _wait_for_enter()
                return

            logger.critical('---')
            logger.critical(out.strip())
            logger.critical('---')
            logger.critical('Rebase had conflicts.')
            logger.info('You can resolve the conflicts in your working tree.')
            logger.info(
                'Use "git rebase --continue" after resolving, or "git rebase --abort" to give up.'
            )
            _suspend_to_shell(hint='b4 rebase')
            # Check if rebase is still in progress (user exited without finishing)
            ecode, gitdir = b4.git_run_command(
                topdir, ['rev-parse', '--git-dir'], logstderr=True
            )
            rebase_in_progress = False
            if ecode == 0:
                gitdir = gitdir.strip()
                rebase_in_progress = os.path.isdir(
                    os.path.join(gitdir, 'rebase-merge')
                ) or os.path.isdir(os.path.join(gitdir, 'rebase-apply'))
            if rebase_in_progress:
                logger.warning('Rebase not completed, aborting')
                b4.git_run_command(topdir, ['rebase', '--abort'], logstderr=True)
                b4.git_run_command(
                    topdir, ['reset', '--hard', tracking_commit], logstderr=True
                )
                b4.git_run_command(
                    topdir, ['checkout', original_branch], logstderr=True
                )
                _wait_for_enter()
                return
            # Check if the rebase was aborted (HEAD back at pre-rebase state)
            ecode, current_head = b4.git_run_command(
                topdir, ['rev-parse', 'HEAD'], logstderr=True
            )
            if ecode != 0 or current_head.strip() == series_tip:
                logger.warning('Rebase was aborted')
                b4.git_run_command(
                    topdir, ['reset', '--hard', tracking_commit], logstderr=True
                )
                b4.git_run_command(
                    topdir, ['checkout', original_branch], logstderr=True
                )
                _wait_for_enter()
                return
            # Verify target is an ancestor of HEAD (rebase actually landed)
            ecode, _out = b4.git_run_command(
                topdir,
                ['merge-base', '--is-ancestor', target_head, 'HEAD'],
                logstderr=True,
            )
            if ecode != 0:
                logger.warning(
                    'Rebase result does not include %s, something went wrong',
                    target_branch,
                )
                b4.git_run_command(
                    topdir, ['reset', '--hard', tracking_commit], logstderr=True
                )
                b4.git_run_command(
                    topdir, ['checkout', original_branch], logstderr=True
                )
                _wait_for_enter()
                return
            logger.info('Rebase conflicts resolved')

        # Update tracking data with new base commit
        series['base-commit'] = target_head

        # Enumerate new patch commit SHAs and update first-patch-commit
        ecode, out = b4.git_run_command(
            topdir, ['rev-list', '--reverse', f'{target_head}..HEAD']
        )
        if ecode == 0 and out.strip():
            new_shas = out.strip().splitlines()
            series['first-patch-commit'] = new_shas[0]
            # Re-anchor inline comments against rebased diffs
            patches = tracking.get('patches', [])
            b4.review.reanchor_patch_comments(topdir, new_shas, patches)

        tracking['series'] = series

        # Re-create the tracking commit
        commit_msg = cover_text + '\n\n' + b4.review.make_review_magic_json(tracking)
        ecode, out = b4.git_run_command(
            topdir,
            ['commit', '--allow-empty', '-F', '-'],
            stdin=commit_msg.encode(),
            logstderr=True,
        )
        if ecode != 0:
            logger.critical('Could not create new tracking commit: %s', out.strip())
            _wait_for_enter()
            return

        # Switch back to the original branch
        b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True)
        logger.info('Successfully rebased %s onto %s', review_branch, target_head[:12])
        _wait_for_enter()

    def _get_check_context(self) -> Optional[Tuple[str, str, str]]:
        if not self._selected_series:
            return None
        message_id = self._selected_series.get('message_id', '')
        subject = self._selected_series.get('subject', '(no subject)')
        change_id = self._selected_series.get('change_id', '')
        return (message_id, subject, change_id)

    def action_range_diff(self) -> None:
        """Show range-diff between the current review and another revision."""
        if not self._selected_series:
            return

        change_id = self._selected_series.get('change_id', '')
        current_rev = self._selected_series.get('revision', 1)

        try:
            conn = b4.review.tracking.get_db(self._identifier)
            revisions = b4.review.tracking.get_revisions(conn, change_id)
            conn.close()
        except Exception as ex:
            self.notify(f'Could not load revisions: {ex}', severity='error')
            return

        others = [r for r in revisions if r['revision'] != current_rev]
        if not others:
            self.notify('No other known revisions. Try \\[u]pdate.', severity='warning')
            return

        self.push_screen(
            RangeDiffScreen(current_rev, revisions),
            callback=lambda chosen: self._on_range_diff_selected(
                chosen, change_id, current_rev
            ),
        )

    def _on_range_diff_selected(
        self, chosen: Optional[int], change_id: str, current_rev: int
    ) -> None:
        """Handle the revision chosen from the range-diff modal."""
        if chosen is None:
            return
        with self.suspend():
            self._do_range_diff(change_id, current_rev, chosen)

    def _do_range_diff(self, change_id: str, current_rev: int, other_rev: int) -> None:
        """Compute and display range-diff between two revisions."""
        topdir = b4.git_get_toplevel()
        if not topdir:
            logger.critical('Not in a git repository')
            _wait_for_enter()
            return

        out = compute_range_diff(
            topdir, self._identifier, change_id, current_rev, other_rev
        )
        if out is None:
            _wait_for_enter()
            return

        if not out:
            logger.info(
                'No differences found between v%d and v%d',
                min(other_rev, current_rev),
                max(other_rev, current_rev),
            )
            _wait_for_enter()
            return

        b4.view_in_pager(out.encode(), filehint='range-diff.txt')

    def _delete_review_branch(
        self, topdir: str, review_branch: str, notify: bool = True
    ) -> bool:
        """Delete a review branch, switching away if currently on it.

        Thin TUI wrapper around b4.review.delete_review_branch() that turns
        its error string into a notification.  Interactive, so detaching HEAD
        to get off the branch is fine here -- as long as we do not leave it
        that way.

        Returns True on success, False on failure.
        """
        with _land_off_deleted_branch(topdir, review_branch, self._original_head):
            ok, err = b4.review.delete_review_branch(
                topdir, review_branch, allow_switch=True
            )
        if not ok and notify:
            self.notify(err, severity='error')
        return ok

    def action_abandon(self) -> None:
        """Abandon the selected series."""
        if not self._selected_series:
            return
        change_id = self._selected_series.get('change_id', '')
        revision = self._selected_series.get('revision')
        review_branch = f'b4/review/{change_id}'
        has_branch = b4.git_branch_exists(None, review_branch)
        self.push_screen(
            AbandonConfirmScreen(
                change_id,
                review_branch,
                has_branch,
                subject=self._selected_series.get('subject', ''),
            ),
            callback=lambda confirmed: self._on_abandon_confirmed(
                confirmed, change_id, review_branch, has_branch, revision=revision
            ),
        )

    def _on_abandon_confirmed(
        self,
        confirmed: Optional[bool],
        change_id: str,
        review_branch: str,
        has_branch: bool,
        revision: Optional[int] = None,
    ) -> None:
        if not confirmed:
            return
        topdir = b4.git_get_toplevel()
        # Delete the review branch if it exists
        if has_branch and topdir:
            if not self._delete_review_branch(topdir, review_branch):
                return
        # Delete all revisions for this change-id from the tracking database.
        # Passing no revision removes every row including archived prior
        # revisions, so the series can be re-tracked from scratch if needed.
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.delete_series(conn, change_id)
            conn.close()
        except Exception as ex:
            self.notify(f'DB error: {ex}', severity='error')
            return
        self.notify(f'Abandoned {change_id}')
        self._selected_series = None
        panel = self.query_one('#details-panel', Vertical)
        panel.styles.height = 0
        self._invalidate_caches(change_id)
        self._load_series()

    def action_update_revision(self) -> None:
        """Upgrade the tracked series to a newer revision.

        Available from any state once a newer revision is known: the gate is
        ``has_newer``, not status.  How the upgrade runs depends on whether a
        review branch actually exists on disk, not on the status label:

        - No branch (a ``new`` series, or one parked as ``waiting`` straight
          from ``new``): the tracked revision is advanced in the database.
        - A branch exists (``reviewing``/``replied``/``partial``, or a
          ``waiting`` series that was parked from one of those): the full git
          upgrade workflow runs, archiving the old branch and landing the
          result in ``reviewing`` — the "upgrade and return to reviewing in
          one step" the waiting flow was missing.
        """
        if not self._selected_series:
            return
        if not self._selected_series.get('has_newer'):
            self.notify('No newer revision available to upgrade to', severity='warning')
            return

        change_id = self._selected_series.get('change_id', '')
        current_rev = self._selected_series.get('revision', 1)
        review_branch = f'b4/review/{change_id}'

        # Discover newer revisions from tracking data and DB
        newer_versions = self._discover_newer_versions(
            change_id, current_rev, review_branch
        )

        if not newer_versions:
            self.notify(
                'No newer revisions known. Try \\[u]pdate first.', severity='warning'
            )
            return

        # Look up revision metadata from the DB
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            revisions = b4.review.tracking.get_revisions(conn, change_id)
            conn.close()
        except Exception as ex:
            self.notify(f'Could not load revisions: {ex}', severity='error')
            return

        newer_revs = [r for r in revisions if r['revision'] in newer_versions]
        if not newer_revs:
            self.notify(
                'No newer revisions known. Try \\[u]pdate first.', severity='warning'
            )
            return

        # Route on whether a review branch exists, not on the status label: a
        # series parked as 'waiting' directly from 'new' carries the waiting
        # status but has no branch to upgrade, so it takes the DB-only path.
        topdir = b4.git_get_toplevel()
        has_branch = bool(topdir) and b4.git_branch_exists(topdir, review_branch)

        if not has_branch:
            # No review branch — just update the DB record
            if len(newer_revs) == 1:
                self._do_switch_revision(change_id, current_rev, newer_revs[0])
                return
            self.push_screen(
                UpdateRevisionScreen(current_rev, revisions),
                callback=lambda chosen: (
                    self._switch_revision_by_number(
                        change_id, current_rev, chosen, revisions
                    )
                    if chosen is not None
                    else None
                ),
            )
            return

        if len(newer_revs) == 1:
            # Only one newer revision — go straight to the upgrade
            self._do_update_revision(change_id, current_rev, newer_revs[0]['revision'])
            return

        self.push_screen(
            UpdateRevisionScreen(current_rev, revisions),
            callback=lambda chosen: (
                self._do_update_revision(change_id, current_rev, chosen)
                if chosen is not None
                else None
            ),
        )

    def action_link_revision(self) -> None:
        """Manually link another revision to the selected series by msgid.

        For series whose new versions auto-discovery missed (no change-id,
        reworded subject).  Prompts for a message-id/URL, fetches it, shows a
        confirmation, then records it as a manual revision (absorbing a stray
        duplicate if one is found).
        """
        if not self._selected_series:
            return
        change_id = self._selected_series.get('change_id', '')
        if not change_id:
            return
        subject = self._selected_series.get('subject', '')
        self.push_screen(
            LinkRevisionScreen(subject=subject),
            callback=lambda sid: self._start_link_fetch(change_id, sid),
        )

    def _start_link_fetch(self, change_id: str, series_id: Optional[str]) -> None:
        """Kick off the background fetch for a revision being linked."""
        if not series_id:
            return
        self._link_ctx = {'change_id': change_id, 'series_id': series_id}
        self.notify(f'Fetching {series_id}…')
        run_lore_worker(
            self,
            lambda: b4.review.tracking.fetch_series_for_link(series_id),
            name='_link_fetch',
        )

    def _on_link_fetched(self, lser: Optional[Any]) -> None:
        """Preview the fetched series and ask for confirmation before linking."""
        ctx = self._link_ctx
        if not ctx:
            return
        if lser is None:
            self.notify(
                'Could not find a series at that message-id', severity='warning'
            )
            self._link_ctx = None
            return
        change_id = ctx['change_id']
        revision = int(lser.revision)
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            known = {
                r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id)
            }
            stray = b4.review.tracking.find_revision_by_fingerprint(
                conn, lser.fingerprint
            )
            conn.close()
        except Exception as ex:
            self.notify(f'DB error: {ex}', severity='error')
            self._link_ctx = None
            return

        collision = revision in known
        warning = ''
        if collision:
            warning = f'v{revision} is already tracked — linking replaces it.'
        elif stray is not None and stray['change_id'] != change_id:
            warning = 'Already tracked as a separate series — it will be absorbed.'

        num_patches = sum(1 for p in lser.patches[1:] if p is not None)
        sender = getattr(lser, 'fromname', '') or getattr(lser, 'fromemail', '')
        ctx['lser'] = lser
        ctx['force'] = collision
        self.push_screen(
            LinkRevisionConfirmScreen(
                subject=getattr(lser, 'subject', ''),
                revision=revision,
                num_patches=num_patches,
                sender=sender,
                warning=warning,
            ),
            callback=self._on_link_confirmed,
        )

    def _on_link_confirmed(self, proceed: Optional[bool]) -> None:
        """Record the linked revision once the user confirms."""
        ctx = self._link_ctx
        self._link_ctx = None
        if not proceed or not ctx or 'lser' not in ctx:
            return
        change_id = ctx['change_id']
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            result = b4.review.tracking.record_linked_revision(
                conn, change_id, ctx['lser'], force=ctx.get('force', False)
            )
            conn.close()
        except Exception as ex:
            self.notify(f'Could not link revision: {ex}', severity='error')
            return
        rev = result.get('revision')
        if result.get('absorbed'):
            self.notify(f'Linked v{rev} (absorbed a duplicate)')
        else:
            self.notify(f'Linked v{rev}')
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def _do_switch_revision(
        self, change_id: str, current_rev: int, rev_info: Dict[str, Any]
    ) -> None:
        """Switch a not-yet-checked-out series to a different revision.

        Simply updates the database record — no branch operations needed.
        """
        target_rev = rev_info['revision']
        new_msgid = rev_info.get('message_id', '')
        new_subject = rev_info.get('subject')
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.update_series_revision(
                conn, change_id, current_rev, target_rev, new_msgid, new_subject
            )
            conn.close()
        except Exception as ex:
            self.notify(f'Could not update revision: {ex}', severity='error')
            return
        self.notify(f'Now tracking v{target_rev}')
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def _switch_revision_by_number(
        self,
        change_id: str,
        current_rev: int,
        chosen: int,
        revisions: List[Dict[str, Any]],
    ) -> None:
        """Callback wrapper: find the revision dict and call _do_switch_revision."""
        for rev in revisions:
            if rev['revision'] == chosen:
                self._do_switch_revision(change_id, current_rev, rev)
                return
        self.notify(f'Revision v{chosen} not found', severity='error')

    def _do_update_revision(
        self, change_id: str, current_rev: int, target_rev: int
    ) -> None:
        """Upgrade the review branch from *current_rev* to *target_rev*.

        Uses a three-phase workflow so the old review branch is never
        modified until the new revision has been successfully applied:

        Phase 1 (worker): fetch the new series and compute base hints.
        Phase 2 (modal):  show BaseSelectionScreen for the user.
        Phase 3 (suspend): apply to a temporary upgrade branch, then
                 archive the old branch and rename on success.
        """
        review_branch = f'b4/review/{change_id}'

        # Look up message-id for the target revision from DB
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            revisions = b4.review.tracking.get_revisions(conn, change_id)
            conn.close()
        except Exception as ex:
            self.notify(f'Could not load revisions: {ex}', severity='error')
            return

        target_msgid = ''
        target_subject = ''
        target_is_rethreaded = False
        for r in revisions:
            if r['revision'] == target_rev:
                target_msgid = r.get('message_id', '')
                target_subject = r.get('subject', '')
                # Per-revision rethread state — the target revision may need
                # reassembly from stored patches even if the currently tracked
                # revision does not (and vice versa).
                target_is_rethreaded = bool(r.get('is_rethreaded'))
                break

        if not target_msgid:
            self.notify(f'No message-id recorded for v{target_rev}', severity='error')
            return

        # Phase 1: fetch series and compute base in a worker thread
        def _fetch_update() -> Tuple[b4.LoreSeries, bytes, str, str, int]:
            with _quiet_worker():
                target_series = dict(self._selected_series or {})
                target_series['message_id'] = target_msgid
                target_series['revision'] = target_rev
                target_series['is_rethreaded'] = target_is_rethreaded
                msgs = b4.review.retrieve_series_messages(
                    target_series, self._identifier
                )
                lser = b4.review._get_lore_series(msgs)

                am_msgs = lser.get_am_ready(
                    noaddtrailers=True,
                    addmysob=False,
                    addlink=False,
                    cherrypick=None,
                    copyccs=False,
                    allowbadchars=False,
                    showchecks=False,
                )
                if not am_msgs:
                    raise LookupError('No patches ready for applying')

                ifh = io.BytesIO()
                b4.save_git_am_mbox(am_msgs, ifh)
                ambytes = ifh.getvalue()

                # Determine best base: configured, series-specified or guessed
                topdir = b4.git_get_toplevel()
                initial_base, base_hint = _detect_initial_base(lser, topdir)

                return lser, ambytes, initial_base, base_hint, len(am_msgs)

        self.push_screen(
            WorkerScreen('Fetching new revision\u2026', _fetch_update),
            callback=lambda result: self._on_update_prepared(
                result,
                change_id,
                current_rev,
                target_rev,
                target_msgid,
                target_subject,
                review_branch,
                target_is_rethreaded,
            ),
        )

    def _on_update_prepared(
        self,
        result: Any,
        change_id: str,
        current_rev: int,
        target_rev: int,
        target_msgid: str,
        target_subject: str,
        review_branch: str,
        target_is_rethreaded: bool = False,
    ) -> None:
        """Phase 2: show BaseSelectionScreen after fetching the new series."""
        if result is None:
            return

        lser, ambytes, initial_base, base_hint, num_am = result

        # The resolved series knows its own title — its cover letter's when it
        # has one.  Prefer it over the catalog's, which may predate the cover
        # being seen and name patch 1/N instead (bug 8bb6e4c).  A thread
        # missing both cover and patch 1 still am-preps but leaves LoreSeries'
        # '(untitled)' placeholder — the catalog title beats that.
        lser_subject = getattr(lser, 'subject', '') or ''
        if lser_subject and lser_subject != '(untitled)':
            target_subject = lser_subject

        base_suggestions = _build_base_suggestions()

        self.push_screen(
            BaseSelectionScreen(
                initial_base,
                lser,
                ambytes,
                base_suggestions=base_suggestions,
                base_hint=base_hint,
                subject=target_subject,
            ),
            callback=lambda base_sha: self._on_update_base_selected(
                base_sha,
                lser,
                ambytes,
                num_am,
                change_id,
                current_rev,
                target_rev,
                target_msgid,
                target_subject,
                review_branch,
                target_is_rethreaded,
            ),
        )

    def _on_update_base_selected(
        self,
        base_sha: Optional[str],
        lser: b4.LoreSeries,
        ambytes: bytes,
        num_am: int,
        change_id: str,
        current_rev: int,
        target_rev: int,
        target_msgid: str,
        target_subject: str,
        review_branch: str,
        target_is_rethreaded: bool = False,
    ) -> None:
        """Phase 3: apply new revision to upgrade branch, then swap.

        The old review branch is never touched until the apply succeeds.
        On failure the upgrade branch is deleted and the old branch
        remains intact.
        """
        if base_sha is None:
            self.notify('Update cancelled', severity='information')
            return

        upgrade_branch = f'b4/review/_tmp-{change_id}-v{target_rev}-upgrade'

        topdir = b4.git_get_toplevel()
        if not topdir:
            self.notify('Not in a git repository', severity='error')
            return

        # The apply below checks out the upgrade branch and renames it onto the
        # review branch, so HEAD ends up there whether the upgrade succeeds or
        # not. The tracking list comes back up when this returns; leaving the
        # worktree moved strands the user on the review branch when they quit.
        with self.suspend(), _keep_current_branch(topdir) as restore_branch:
            # --- 1. Save maintainer review data keyed by patch-id ---
            logger.info('Saving review data from v%d...', current_rev)
            patch_ids = b4.review.get_review_branch_patch_ids(topdir, review_branch)
            _cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
            patches = tracking.get('patches', [])

            saved_reviews: Dict[str, Dict[str, Any]] = {}
            prior_patch_ids: set[str] = set()
            for idx, _sha, patch_id in patch_ids:
                if patch_id is None or idx >= len(patches):
                    continue
                prior_patch_ids.add(patch_id)
                reviews = patches[idx].get('reviews')
                if reviews:
                    saved_reviews[patch_id] = {
                        'reviews': reviews,
                    }

            logger.info('Saved review data for %d patch(es)', len(saved_reviews))

            # --- 1b. Render prior review context ---
            old_series = tracking.get('series', {})
            usercfg = b4.get_user_config()
            maintainer_email = str(usercfg.get('email', ''))
            prior_context = b4.review.tracking.render_prior_review_context(
                maintainer_email, current_rev, old_series, patches
            )
            prior_thread_blob = old_series.get('thread-context-blob', '')
            prior_msgid = old_series.get('header-info', {}).get('msgid', '')

            # --- 1c. Record the current rev's mbox blob in the DB ---
            # Do this before archiving so the blob SHA survives for range-diff.
            # The blob may later be GC'd; callers must tolerate a missing blob.
            if self._identifier:
                cur_mbox_blob = old_series.get('thread-blob', '')
                if cur_mbox_blob:
                    try:
                        _conn = b4.review.tracking.get_db(self._identifier)
                        b4.review.tracking.set_revision_thread_blob(
                            _conn, change_id, current_rev, cur_mbox_blob
                        )
                        _conn.close()
                    except Exception as _ex:
                        logger.debug(
                            'Could not record thread blob for v%d: %s', current_rev, _ex
                        )

            # --- 2. Resolve metadata for git-am ---
            top_msgid = None
            first_body = None
            for lmsg in lser.patches:
                if lmsg is not None:
                    first_body = lmsg.body
                    top_msgid = lmsg.msgid
                    break

            if top_msgid is None or first_body is None:
                logger.critical('Could not find any patches in the series')
                _wait_for_enter()
                return

            config = b4.get_main_config()
            linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
            if '%s' not in linkmask:
                logger.critical('linkmask not configured properly')
                _wait_for_enter()
                return
            linkurl = linkmask % top_msgid

            # Prepare blob ancestors for three-way merge if needed
            if lser.complete:
                _checked, mismatches = lser.check_applies_clean(gitdir=topdir)
                if mismatches:
                    rstart, rend = lser.make_fake_am_range(gitdir=topdir)
                    if rstart and rend:
                        logger.info(
                            'Prepared fake commit range for 3-way merge (%.12s..%.12s)',
                            rstart,
                            rend,
                        )

            # --- 3. Apply to temporary upgrade branch ---
            # Use the target revision's own rethread state, not the currently
            # tracked revision's — they can differ across an upgrade.
            _is_rt = bool(target_is_rethreaded)
            # create_review_branch() reports failure by exiting rather than
            # raising, and SystemExit is not an Exception, so catch it too --
            # letting it out of here skips the cleanup below and unwinds
            # straight through the suspend and into the UI.
            try:
                logger.info('Base: %s', base_sha)
                try:
                    b4.git_fetch_am_into_repo(
                        topdir,
                        ambytes=ambytes,
                        at_base=base_sha,
                        origin=linkurl,
                        am_flags=['-3'],
                        resolve=True,
                    )
                except b4.AmConflictError as cex:
                    if not b4.resolve_am_conflict_in_shell(topdir, cex, origin=linkurl):
                        # User aborted.  Nothing this run made is left -- the
                        # upgrade branch is not created until the am lands --
                        # but the name is derived from the change-id and the
                        # revision, so an earlier attempt that ended somewhere
                        # without cleanup left one sitting on it.  Take it with
                        # us rather than let it fail the next attempt.
                        if b4.git_branch_exists(topdir, upgrade_branch):
                            b4.git_run_command(topdir, ['branch', '-D', upgrade_branch])
                        _wait_for_enter()
                        return
                b4.review.create_review_branch(
                    topdir,
                    upgrade_branch,
                    base_sha,
                    lser,
                    linkurl,
                    linkmask,
                    num_prereqs=0,
                    identifier=self._identifier,
                    status='reviewing',
                    is_rethreaded=_is_rt,
                )
                logger.info('Upgrade branch created: %s', upgrade_branch)
            except (Exception, SystemExit) as ex:
                # SystemExit only carries the exit code; create_review_branch()
                # has already said why on its way out.
                reason = 'see above' if isinstance(ex, SystemExit) else str(ex)
                logger.critical('Error creating review branch: %s', reason)
                # Off the half-built branch before dropping it: git refuses to
                # delete the branch it has checked out.
                restore_branch()
                if b4.git_branch_exists(topdir, upgrade_branch):
                    b4.git_run_command(topdir, ['branch', '-D', upgrade_branch])
                _wait_for_enter()
                return

            # --- 4. Apply succeeded — restore reviews onto upgrade branch ---
            logger.info('Restoring reviews...')
            new_patch_ids = b4.review.get_review_branch_patch_ids(
                topdir, upgrade_branch
            )
            new_cover_text, new_tracking = b4.review.load_tracking(
                topdir, upgrade_branch
            )
            new_patches = new_tracking.get('patches', [])

            usercfg = b4.get_user_config()
            my_email = str(usercfg.get('email', 'unknown@example.com'))
            # If the maintainer already sent their review (status='replied'),
            # don't carry over prior comments verbatim — they are stale and
            # may refer to issues already addressed in the new version.  The
            # prior feedback is still accessible via the "Prior" (P) screen.
            # Only carry over WIP review content when the maintainer hadn't
            # sent anything yet (e.g. the submitter sent a new version while
            # the maintainer was still drafting their review).
            old_status = old_series.get('status', '')
            carry_over_reviews = old_status != 'replied'
            restored = 0
            for idx, _sha, patch_id in new_patch_ids:
                if patch_id is None or idx >= len(new_patches):
                    continue
                if patch_id not in prior_patch_ids:
                    continue
                if carry_over_reviews and patch_id in saved_reviews:
                    # Deepcopy so we don't mutate the saved review data
                    reviews = copy.deepcopy(saved_reviews[patch_id]['reviews'])
                    restored += 1
                else:
                    reviews = {}
                # Mark the patch as unchanged so the TUI can show a visual cue.
                # _get_patch_state() will override this with any derived state
                # (draft, done, …) if the maintainer already has review content.
                my_review = reviews.setdefault(
                    my_email, {'name': str(usercfg.get('name', ''))}
                )
                my_review['patch-state'] = 'unchanged'
                reviews['b4-upgrade@internal'] = {
                    'name': 'B4 Upgrade',
                    'note': f'Patch unchanged from v{current_rev}',
                }
                new_patches[idx]['reviews'] = reviews

            # Re-anchor inline comments against new revision diffs.
            # Only needed when WIP review comments were carried over.
            if carry_over_reviews:
                new_shas = [sha for _idx, sha, _pid in new_patch_ids]
                b4.review.reanchor_patch_comments(topdir, new_shas, new_patches)

            new_tracking['series']['status'] = 'reviewing'
            # Ensure the tracking commit has the real change-id, not
            # the temporary upgrade branch name.
            new_tracking['series']['change-id'] = change_id
            if prior_context:
                new_tracking['series']['prior-review-context'] = prior_context
            if prior_thread_blob:
                new_tracking['series']['prior-thread-context-blob'] = prior_thread_blob
            if prior_msgid:
                new_tracking['series']['prior-revision-msgid'] = prior_msgid
            b4.review.save_tracking_ref(
                topdir, upgrade_branch, new_cover_text, new_tracking
            )
            logger.info(
                'Restored reviews for %d of %d patch(es)', restored, len(new_patch_ids)
            )

            # --- 5. Archive old branch and rename upgrade → review ---
            logger.info('Archiving v%d...', current_rev)
            pw_series_id = None
            if self._selected_series:
                pw_series_id = self._selected_series.get('pw_series_id')
            if not self._archive_branch(
                change_id,
                current_rev,
                review_branch,
                pw_series_id=pw_series_id,
                notify=False,
            ):
                logger.critical('Failed to archive v%d', current_rev)
                # Upgrade branch has new data but old branch could not be
                # archived.  Leave both branches so the user can recover.
                _wait_for_enter()
                return

            ecode, out = b4.git_run_command(
                topdir, ['branch', '-m', upgrade_branch, review_branch]
            )
            if ecode > 0:
                logger.critical('Failed to rename upgrade branch: %s', out.strip())
                _wait_for_enter()
                return

            # --- 6. Update tracking database ---
            if self._identifier:
                try:
                    conn = b4.review.tracking.get_db(self._identifier)
                    sender_name = getattr(lser, 'fromname', '') or ''
                    sender_email = getattr(lser, 'fromemail', '') or ''
                    sent_at = ''
                    ref_msg = None
                    for p in lser.patches:
                        if p is not None:
                            ref_msg = p
                            break
                    if ref_msg and ref_msg.date:
                        sent_at = ref_msg.date.isoformat()
                    b4.review.tracking.add_series_to_db(
                        conn,
                        change_id,
                        target_rev,
                        target_subject,
                        sender_name,
                        sender_email,
                        sent_at,
                        target_msgid,
                        lser.expected or num_am,
                    )
                    b4.review.tracking.update_series_status(
                        conn, change_id, 'reviewing', revision=target_rev
                    )
                    conn.close()
                except Exception as ex:
                    logger.warning('Failed to update DB for v%d: %s', target_rev, ex)

            logger.info('Upgrade to v%d complete', target_rev)
            _wait_for_enter()

        # Return to the tracking list with the upgraded series focused
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def action_waiting(self) -> None:
        """Put the selected series into waiting state."""
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        if status not in ('new', 'reviewing', 'replied', 'partial'):
            return
        change_id = self._selected_series.get('change_id', '')
        revision = self._selected_series.get('revision')
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.update_series_status(
                conn, change_id, 'waiting', revision=revision
            )
            conn.close()
        except Exception as ex:
            self.notify(f'Error: {ex}', severity='error')
            return
        topdir = b4.git_get_toplevel()
        if topdir and status != 'new':
            branch_name = f'b4/review/{change_id}'
            b4.review.update_tracking_status(topdir, branch_name, 'waiting')
        self.notify('Series moved to waiting')
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def action_snooze(self) -> None:
        """Snooze the selected series until a future date."""
        if not self._selected_series:
            return
        status = self._selected_series.get('status', 'new')
        if status not in ('new', 'reviewing', 'replied', 'partial'):
            self.notify('Cannot snooze a series in this state', severity='warning')
            return
        self.push_screen(
            SnoozeScreen(
                last_source=self._last_snooze_source,
                last_input=self._last_snooze_input,
                subject=self._selected_series.get('subject', ''),
            ),
            callback=self._on_snooze_confirmed,
        )

    def _on_snooze_confirmed(self, result: Optional[Dict[str, str]]) -> None:
        """Handle snooze dialog result."""
        if result is None:
            return
        series = self._selected_series
        if not series:
            return
        change_id = series.get('change_id', '')
        revision = series.get('revision')
        previous_status = series.get('status', 'new')
        until_value = result['until']

        # Update tracking commit metadata
        topdir = b4.git_get_toplevel()
        review_branch = f'b4/review/{change_id}'
        if topdir and b4.git_branch_exists(topdir, review_branch):
            try:
                cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
                trk_series = tracking.get('series', {})
                trk_series['status'] = 'snoozed'
                trk_series['snoozed'] = {
                    'until': until_value,
                    'previous_state': previous_status,
                }
                tracking['series'] = trk_series
                b4.review.save_tracking_ref(topdir, review_branch, cover_text, tracking)
            except (SystemExit, Exception) as ex:
                logger.warning('Could not update tracking commit: %s', ex)

        # Update database
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.snooze_series(
                conn, change_id, until_value, revision=revision
            )
            conn.close()
        except Exception as ex:
            self.notify(f'Error: {ex}', severity='error')
            return

        # Remember snooze choices for next time
        self._last_snooze_source = result.get('source', '')
        self._last_snooze_input = result.get('input', '')

        self.notify(f'Snoozed, {_format_snooze_until(until_value)}')
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def action_unsnooze(self) -> None:
        """Wake up a snoozed series, restoring its previous state."""
        if not self._selected_series:
            return
        if self._selected_series.get('status') != 'snoozed':
            return
        change_id = self._selected_series.get('change_id', '')
        revision = self._selected_series.get('revision')

        # Read previous state from tracking commit
        previous_status = 'reviewing'  # default fallback
        topdir = b4.git_get_toplevel()
        review_branch = f'b4/review/{change_id}'
        if topdir and b4.git_branch_exists(topdir, review_branch):
            try:
                previous_status = b4.review.tracking.restore_snoozed_tracking(
                    topdir, review_branch
                )
            except (SystemExit, Exception) as ex:
                logger.warning('Could not update tracking commit: %s', ex)

        # Update database
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            b4.review.tracking.unsnooze_series(
                conn, change_id, previous_status, revision=revision
            )
            conn.close()
        except Exception as ex:
            self.notify(f'Error: {ex}', severity='error')
            return

        self.notify(f'Unsnoozed, restored to {previous_status}')
        self._focus_change_id = change_id
        self._invalidate_caches(change_id)
        self._load_series()

    def action_archive(self) -> None:
        """Archive a taken/thanked series."""
        if not self._selected_series:
            return
        change_id = self._selected_series.get('change_id', '')
        revision = self._selected_series.get('revision')
        pw_series_id = self._selected_series.get('pw_series_id')
        review_branch = f'b4/review/{change_id}'
        has_branch = b4.git_branch_exists(None, review_branch)
        self.push_screen(
            ArchiveConfirmScreen(
                change_id,
                review_branch,
                has_branch,
                subject=self._selected_series.get('subject', ''),
            ),
            callback=lambda confirmed: self._on_archive_confirmed(
                confirmed,
                change_id,
                review_branch,
                has_branch,
                pw_series_id,
                revision=revision,
            ),
        )

    def _archive_branch(
        self,
        change_id: str,
        revision: Optional[int],
        review_branch: str,
        pw_series_id: Optional[int] = None,
        notify: bool = True,
    ) -> bool:
        """Archive a review branch and update the tracking database.

        Thin TUI wrapper around b4.review.archive_series(): creates a
        tar.gz archive of the cover letter, tracking metadata, and
        patches, then deletes the branch and marks the series as
        archived.  Returns True on success.

        When *notify* is False, TUI notifications are suppressed (useful
        when called from within ``suspend()``).
        """
        topdir = b4.git_get_toplevel()
        if not topdir:
            if notify:
                self.notify('Not in a git repository', severity='error')
            return False

        # Archiving deletes the branch, so it detaches HEAD to get off it the
        # same way an outright delete does.
        with _land_off_deleted_branch(topdir, review_branch, self._original_head):
            ok, detail = b4.review.archive_series(
                topdir,
                self._identifier,
                change_id,
                revision=revision,
                pw_series_id=pw_series_id,
                allow_switch=True,
            )
        if not ok:
            if notify:
                self.notify(detail, severity='error')
            return False

        if notify:
            if detail:
                self.notify(f'Archived {change_id} to {detail}')
            else:
                self.notify(f'Archived {change_id}')
        return True

    def _on_archive_confirmed(
        self,
        confirmed: Optional[bool],
        change_id: str,
        review_branch: str,
        has_branch: bool,
        pw_series_id: Optional[int] = None,
        revision: Optional[int] = None,
    ) -> None:
        if not confirmed:
            return
        if self._archive_branch(
            change_id, revision, review_branch, pw_series_id=pw_series_id
        ):
            self._selected_series = None
            panel = self.query_one('#details-panel', Vertical)
            panel.styles.height = 0
            self._invalidate_caches(change_id)
            self._load_series()

    def action_thank(self) -> None:
        """Compose and preview a thank-you reply for a taken series."""
        series = self._selected_series
        if not series:
            return
        if series.get('status', 'new') not in ('accepted', 'partial'):
            self.notify(
                'Series must be accepted before sending thanks', severity='warning'
            )
            return
        self._start_thank(series)

    def _start_thank(self, series: Dict[str, Any], archive_after: bool = False) -> None:
        """Generate the thank-you message for *series* and show the preview.

        With *archive_after*, a successful send — or the eventual
        delivery of a queued message — also archives the series (unless
        a newer revision has shown up by then).
        """
        import argparse

        change_id = series.get('change_id', '')
        review_branch = f'b4/review/{change_id}'
        topdir = b4.git_get_toplevel()
        if not topdir:
            self.notify('Not in a git repository', severity='error')
            return

        try:
            cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
        except SystemExit:
            self.notify('Could not load tracking data', severity='error')
            return

        trk_series = tracking.get('series', {})
        hi = trk_series.get('header-info', {})
        taken = trk_series.get('taken', {})
        target_branch = taken.get('branch', 'unknown')

        # Build per-patch tuples (subject, pwhash, msgid, prefix) and commits list
        trk_patches = tracking.get('patches', [])
        expected = trk_series.get('expected', len(trk_patches))
        padlen = len(str(expected)) if expected else 1
        patches_tuples: List[Tuple[str, str, str, str]] = []
        commits: List[Tuple[int, Optional[str]]] = []
        has_untaken = False
        for pi, pmeta in enumerate(trk_patches):
            title = pmeta.get('title', '')
            clean_subject = b4.LoreSubject(title).subject or title
            msgid = pmeta.get('header-info', {}).get('msgid', '')
            prefix = '%s/%s' % (str(pi + 1).zfill(padlen), expected)
            patches_tuples.append((clean_subject, '', msgid, prefix))
            patch_taken = pmeta.get('taken', {})
            cid = patch_taken.get('commit-id')
            if cid:
                commits.append((pi + 1, cid))
            else:
                has_untaken = True

        # Assemble jsondata for ty.generate_am_thanks
        usercfg = b4.get_user_config()
        config = b4.get_main_config()
        jsondata: Dict[str, Any] = {
            'fromname': trk_series.get('fromname', ''),
            'fromemail': trk_series.get('fromemail', ''),
            'to': hi.get('to', ''),
            'cc': hi.get('cc', ''),
            'references': hi.get('references', ''),
            'msgid': hi.get('msgid', ''),
            'sentdate': hi.get('sentdate', ''),
            'subject': trk_series.get('subject', ''),
            'myname': config.get('thanks-from-name', usercfg.get('name', '')),
            'myemail': config.get('thanks-from-email', usercfg.get('email', '')),
            'signature': b4.get_email_signature(),
            'quote': b4.make_quote(cover_text),
            'patches': patches_tuples,
            'commits': commits,
            'cherrypick': has_untaken,
            'branch': target_branch,
        }

        # Build a minimal cmdargs for ty.generate_am_thanks / make_reply
        cmdargs = argparse.Namespace()
        cmdargs.metoo = False
        cmdargs.since = None

        try:
            msg = b4.ty.generate_am_thanks(topdir, jsondata, target_branch, cmdargs)
        except Exception as ex:
            self.notify(f'Failed to generate thank-you: {ex}', severity='error')
            return

        # Compute checkurl from last taken commit for queue support
        checkurl: Optional[str] = None
        checkcommit: Optional[str] = None
        cidmask = config.get('thanks-commit-url-mask')
        if isinstance(cidmask, str) and cidmask and '%' in cidmask:
            # Find the last commit ID (highest patch index with a commit)
            last_cid: Optional[str] = None
            for _idx, cid in commits:
                if cid is not None:
                    last_cid = cid
            if last_cid:
                checkurl = cidmask % last_cid
                checkcommit = last_cid

        # The repo and branch the "applied to" line refers to; queue
        # delivery verifies the commit is reachable from there
        checkrepo = b4.ty.get_check_repo_for_branch(
            topdir, target_branch, checkurl or ''
        )
        # set_branch_details rewrites 'branch' to the remote branch name
        _cbranch = jsondata.get('branch')
        checkbranch = _cbranch if isinstance(_cbranch, str) else target_branch

        self._show_thank_preview(
            msg,
            series,
            checkurl=checkurl,
            checkcommit=checkcommit,
            archive_after=archive_after,
            checkrepo=checkrepo,
            checkbranch=checkbranch,
        )

    def _show_thank_preview(
        self,
        msg: email.message.EmailMessage,
        series: Dict[str, Any],
        checkurl: Optional[str] = None,
        checkcommit: Optional[str] = None,
        archive_after: bool = False,
        checkrepo: Optional[str] = None,
        checkbranch: Optional[str] = None,
    ) -> None:
        """Push the ThankScreen modal and handle edit/send/queue/cancel."""

        def _on_thank_result(result: Optional[str]) -> None:
            if result is None:
                return
            if result == '__EDIT__':
                self._edit_thank_message(
                    msg,
                    series,
                    checkurl=checkurl,
                    checkcommit=checkcommit,
                    archive_after=archive_after,
                    checkrepo=checkrepo,
                    checkbranch=checkbranch,
                )
            elif result == '__SEND__':
                self._send_thank_message(msg, series, archive_after=archive_after)
            elif result == '__QUEUE__' and checkurl:
                self._queue_thank_message(
                    msg,
                    series,
                    checkurl,
                    checkcommit,
                    archive_after=archive_after,
                    checkrepo=checkrepo,
                    checkbranch=checkbranch,
                )

        self.push_screen(ThankScreen(msg, checkurl=checkurl), _on_thank_result)

    def _edit_thank_message(
        self,
        msg: email.message.EmailMessage,
        series: Dict[str, Any],
        checkurl: Optional[str] = None,
        checkcommit: Optional[str] = None,
        archive_after: bool = False,
        checkrepo: Optional[str] = None,
        checkbranch: Optional[str] = None,
    ) -> None:
        """Open the thank-you message in $EDITOR and re-show preview."""
        msg_bytes = msg.as_bytes(policy=b4.emlpolicy)
        edited = suspend_and_edit(self, msg_bytes, 'thanks.eml')
        if edited is None:
            return
        new_msg = email.parser.BytesParser(policy=b4.emlpolicy).parsebytes(edited)
        self._show_thank_preview(
            new_msg,
            series,
            checkurl=checkurl,
            checkcommit=checkcommit,
            archive_after=archive_after,
            checkrepo=checkrepo,
            checkbranch=checkbranch,
        )

    def _queue_thank_message(
        self,
        msg: email.message.EmailMessage,
        series: Dict[str, Any],
        checkurl: str,
        checkcommit: Optional[str] = None,
        archive_after: bool = False,
        checkrepo: Optional[str] = None,
        checkbranch: Optional[str] = None,
    ) -> None:
        """Queue the thanks message for delivery once commits are public."""
        change_id = series.get('change_id', '')
        revision = series.get('revision', 1)
        try:
            b4.ty.queue_message(
                msg,
                checkurl,
                change_id,
                revision,
                dryrun=self._email_dryrun,
                checkcommit=checkcommit,
                archive_after=archive_after,
                checkrepo=checkrepo,
                checkbranch=checkbranch,
            )
        except Exception as ex:
            self.notify(f'Failed to queue message: {ex}', severity='error')
            return

        if archive_after:
            self.notify('Queued — will send and archive when commits are published')
        else:
            self.notify('Queued — will send when commits are published')
        self._refresh_queue_indicator()

    def _send_thank_message(
        self,
        msg: email.message.EmailMessage,
        series: Dict[str, Any],
        archive_after: bool = False,
    ) -> None:
        """Send the thank-you message via SMTP."""
        try:
            with self.suspend():
                smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun)
                sent = b4.send_mail(
                    smtp,
                    [msg],
                    fromaddr=fromaddr,
                    patatt_sign=self._patatt_sign,
                    dryrun=self._email_dryrun,
                    output_dir=None,
                    reflect=False,
                )
        except Exception as ex:
            self.notify(f'Send failed: {ex}', severity='error')
            return
        if sent is None:
            self.notify('Failed to send thank-you message', severity='error')
            return
        if self._email_dryrun:
            # Nothing went out, so there is nothing to record.  Marking the
            # series thanked here -- and archiving it, which deletes the
            # review branch -- would spend a real series on a rehearsal.
            self.notify('Dry-run: thank-you logged, not sent')
            return
        self.notify('Thank-you message sent')

        # The message is out, so what follows is bookkeeping and gets its own
        # handler.  Reporting a failed archive as 'Send failed' would tell the
        # maintainer to send a note that is already on the list, and letting
        # it escape would unwind out of the screen callback and take the rest
        # of the session down over an already-successful send.
        try:
            mark_outgoing_seen([msg])
            change_id = series.get('change_id', '')
            revision = series.get('revision')
            if self._identifier and change_id:
                try:
                    conn = b4.review.tracking.get_db(self._identifier)
                    b4.review.tracking.update_series_status(
                        conn, change_id, 'thanked', revision=revision
                    )
                    conn.close()
                except Exception as ex:
                    logger.warning('Could not update series status: %s', ex)
                topdir = b4.git_get_toplevel()
                if topdir:
                    review_branch = f'b4/review/{change_id}'
                    b4.review.update_tracking_status(topdir, review_branch, 'thanked')
            if archive_after:
                self._archive_after_thanks(series)
            self._focus_change_id = change_id
            self._invalidate_caches(change_id)
            self._load_series()
        except Exception as ex:
            logger.debug('Post-send bookkeeping failed: %s', ex, exc_info=True)
            self.notify(f'Sent, but recording it failed: {ex}', severity='warning')

    def _archive_after_thanks(self, series: Dict[str, Any]) -> None:
        """Archive a just-thanked series, unless a newer revision is known."""
        change_id = series.get('change_id', '')
        revision = series.get('revision')
        newest = None
        try:
            conn = b4.review.tracking.get_db(self._identifier)
            newest = b4.review.tracking.get_newest_revision(conn, change_id)
            conn.close()
        except Exception:
            newest = None
        if newest is not None and revision and newest > revision:
            self.notify('Not archived: newer revision available')
            return
        review_branch = f'b4/review/{change_id}'
        if self._archive_branch(
            change_id,
            revision,
            review_branch,
            pw_series_id=series.get('pw_series_id'),
        ):
            self._selected_series = None
            panel = self.query_one('#details-panel', Vertical)
            panel.styles.height = 0

    def _refresh_queue_indicator(self) -> None:
        """Update the title-bar queue count and Q binding visibility."""
        self._queue_count = b4.ty.get_queued_count(dryrun=self._email_dryrun)
        try:
            right = self.query_one('#title-right', Static)
        except Exception:
            return
        if self._queue_count:
            right.update(f'{self._queue_count} queued ')
        else:
            right.update('')
        self.refresh_bindings()

    def action_process_queue(self) -> None:
        """Show the queue modal and optionally deliver."""
        entries = b4.ty.get_queued_messages(dryrun=self._email_dryrun)
        if not entries:
            self.notify('No queued thanks messages')
            return

        def _on_queue_result(result: Optional[str]) -> None:
            if result == '__DELIVER__':
                self._deliver_queue()

        self.push_screen(QueueScreen(entries), _on_queue_result)

    def _deliver_queue(self) -> None:
        """Push a delivery modal with progress bar.

        Delivered series are marked 'thanked' by process_queue itself
        (DB + tracking commit); the callback only reports and reloads.
        """

        def _on_delivery_result(
            result: Union[None, str, Tuple[int, int, List[Tuple[str, int]]]],
        ) -> None:
            if result == QUEUE_BUSY:
                self.notify(
                    'Queue delivery is already running elsewhere (cron?)',
                    severity='warning',
                )
                self._refresh_queue_indicator()
                return
            if result is None or isinstance(result, str):
                self.notify('Queue delivery cancelled or failed', severity='warning')
                self._refresh_queue_indicator()
                return
            delivered, pending, delivered_series = result
            parts = []
            if delivered:
                parts.append(f'{delivered} delivered')
            if pending:
                parts.append(f'{pending} still pending')
            self.notify(', '.join(parts) if parts else 'Queue empty')
            self._refresh_queue_indicator()
            if delivered_series:
                if self._selected_series:
                    self._focus_change_id = self._selected_series.get('change_id')
                self._load_series()

        self.push_screen(
            QueueDeliveryScreen(
                self._queue_count,
                dryrun=self._email_dryrun,
                patatt_sign=self._patatt_sign,
                identifier=self._identifier,
                topdir=b4.git_get_toplevel(),
            ),
            _on_delivery_result,
        )

    #: Sentinel return value indicating the user wants to open PwApp.
    PATCHWORK_SENTINEL = '__patchwork__'

    def action_patchwork(self) -> None:
        """Exit to the outer loop so it can launch the Patchwork TUI."""
        if not (self._pwkey and self._pwurl and self._pwproj):
            self.notify(
                'Patchwork not configured (need b4.pw-key, b4.pw-url, b4.pw-project)',
                severity='error',
            )
            return
        self.exit(self.PATCHWORK_SENTINEL)

    def action_suspend(self) -> None:
        """Suspend the TUI and drop to an interactive shell."""
        with self.suspend():
            _suspend_to_shell()

    def action_help(self) -> None:
        """Show keybinding help."""
        self.push_screen(HelpScreen(TRACKING_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)
