#!/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 argparse
import datetime
import email.message
import email.utils
import io
import json
import logging
import os
import re
import shutil
import sys
import urllib.parse
from string import Template
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union

import liblore.utils
import requests

import b4
import b4.mbox
import b4.review
import b4.review.tracking
import b4.ty

logger = b4.logger

REVIEW_MAGIC_MARKER = '--- b4-review-tracking ---'
REVIEW_BRANCH_PREFIX = 'b4/review/'
COMMIT_MESSAGE_PATH = ':message'
# Synthesized into the cover text when the author sent no cover letter;
# consumers check for it to detect cover-letter absence.
NO_COVER_NOTE = 'NOTE: No cover letter provided by the author.'
# Maximum number of diff context lines quoted above a comment in reply emails.
# The @@ hunk header is always included regardless of this limit.
_REPLY_CONTEXT_LINES = 5


def _strip_subject(text: str) -> List[str]:
    """Return the body lines of a commit/cover message.

    Strips the subject line and any leading blank lines, returning the
    remaining body as a list of strings.
    """
    lines = text.strip().splitlines()
    if lines:
        lines = lines[1:]
        while lines and not lines[0].strip():
            lines.pop(0)
    return lines


def make_review_magic_json(data: Dict[str, Any]) -> str:
    mj = (
        f'{REVIEW_MAGIC_MARKER}\n'
        '# This section is used internally by b4 review for tracking purposes.\n'
    )
    return mj + json.dumps(data, indent=2)


def _collect_followups(lmsg: b4.LoreMessage, linkmask: str) -> List[Dict[str, Any]]:
    # Skip follow-up trailers already present in the message body
    # (e.g. carried over from earlier revisions via codereview_trailers)
    body_trailers = b4.LoreMessage.get_body_parts(lmsg.body)[2]
    followups_by_msgid: Dict[str, Dict[str, Any]] = {}
    for fltr in lmsg.followup_trailers:
        if fltr.lmsg is None:
            continue
        if fltr in body_trailers:
            continue
        fmsgid = fltr.lmsg.msgid
        if fmsgid not in followups_by_msgid:
            followups_by_msgid[fmsgid] = {
                'link': linkmask % fmsgid,
                'fromname': fltr.lmsg.fromname,
                'fromemail': fltr.lmsg.fromemail,
                'trailers': list(),
            }
        followups_by_msgid[fmsgid]['trailers'].append(f'{fltr.name}: {fltr.value}')
    return list(followups_by_msgid.values())


def _collect_reply_headers(lmsg: b4.LoreMessage) -> Dict[str, str]:
    try:
        allto = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('to', [])])
    except Exception as ex:
        allto = []
        logger.debug('Unable to parse the To: header in %s: %s', lmsg.msgid, str(ex))
    try:
        allcc = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('cc', [])])
    except Exception as ex:
        allcc = []
        logger.debug('Unable to parse the Cc: header in %s: %s', lmsg.msgid, str(ex))
    try:
        reply_to = email.utils.getaddresses(
            [str(x) for x in lmsg.msg.get_all('reply-to', [])]
        )
    except Exception as ex:
        reply_to = []
        logger.debug(
            'Unable to parse the Reply-To: header in %s: %s', lmsg.msgid, str(ex)
        )

    headers: Dict[str, str] = {
        'msgid': lmsg.msgid,
        'to': b4.format_addrs(allto, clean=False),
        'cc': b4.format_addrs(allcc, clean=False),
        'references': b4.LoreMessage.clean_header(lmsg.msg['References']),
        'sentdate': b4.LoreMessage.clean_header(lmsg.msg['Date']),
    }
    if reply_to:
        headers['reply-to'] = b4.format_addrs(reply_to, clean=False)
    return headers


def check_series_attestation(lser: b4.LoreSeries) -> Optional[str]:
    """Check attestation status for a series.

    Returns a semicolon-separated string of per-attestor results.
    Each entry is ``status:identity`` where status is one of:
        signed  — signature verified successfully
        nokey   — signed but we don't have the key
        badsig  — we have the key and verification failed

    Examples:
        'signed:DKIM/kernel.org'
        'signed:DKIM/kernel.org;nokey:ed25519/user@example.com'
        'badsig:ed25519/user@example.com'
        'none'     — checked, no signatures found
        None       — attestation policy is off
    """
    config = b4.get_main_config()
    attpolicy = str(config.get('attestation-policy', 'softfail'))
    if attpolicy == 'off':
        return None

    try:
        maxdays = int(str(config.get('attestation-staleness-days', '0')))
    except ValueError:
        maxdays = 0

    # Collect unique attestor entries across all patches.
    # Use (status, identity) as key to deduplicate.
    seen: Set[Tuple[str, str]] = set()
    for lmsg in lser.patches[1:]:
        if lmsg is None:
            continue
        attestations, _passing, _critical = lmsg.get_attestation_status(
            attpolicy, maxdays
        )
        for att in attestations:
            key = (att.get('status', ''), att.get('identity', ''))
            seen.add(key)

    if not seen:
        return 'none'
    return ';'.join(f'{s}:{i}' for s, i in sorted(seen))


def _retrieve_messages(message_id: str) -> List[email.message.EmailMessage]:
    """Fetch messages for a series from lore by message-id.

    Returns the list of email.message.Message objects.
    Raises LookupError if retrieval fails or returns no messages.
    """
    cmdargs = argparse.Namespace(
        msgid=message_id,
        localmbox=None,
        nocache=True,
        noparent=False,
        wantname=None,
        wantver=None,
    )
    _ret_msgid, msgs = b4.retrieve_messages(cmdargs)
    if not msgs:
        raise LookupError(f'Could not retrieve messages for {message_id}')
    return msgs


def retrieve_series_messages(
    series: Dict[str, Any], identifier: str
) -> List[email.message.EmailMessage]:
    """Fetch messages for a tracked series, using stored patch info when available.

    For rethreaded series, reads the series_patches table to fetch each
    patch individually and runs the rethread pipeline. For normal series,
    falls back to the standard single-msgid retrieval.
    """
    change_id = series.get('change_id', '')
    revision = series.get('revision')
    message_id = series.get('message_id', '')
    is_rethreaded = series.get('is_rethreaded', False)

    if is_rethreaded and change_id and revision is not None:
        conn = b4.review.tracking.get_db(identifier)
        patches = b4.review.tracking.get_series_patches(conn, change_id, int(revision))
        conn.close()

        if patches:
            msgids = [p['message_id'] for p in patches if p['position'] > 0]
            if len(msgids) >= 2:
                _msgids, all_msgs = b4.fetch_rethread_messages(msgids, nocache=True)
                _cover_msgid, msgs = b4.LoreSeries.rethread_series(msgids, all_msgs)
                if not msgs:
                    raise LookupError(
                        f'Could not retrieve series patches for {change_id}'
                    )
                return msgs

    if not message_id:
        raise LookupError('No message-id for this series')
    return _retrieve_messages(message_id)


def _get_lore_series(
    msgs: List[email.message.EmailMessage],
    sloppytrailers: bool = False,
    wantver: Optional[int] = None,
) -> 'b4.LoreSeries':
    """Build a LoreMailbox from messages and return the requested series version.

    When *wantver* is ``None`` (the default), the highest version found
    in the retrieved messages is used.

    Raises LookupError if no series is found.
    """
    lmbx = b4.LoreMailbox()
    for msg in msgs:
        lmbx.add_message(msg)
    if not lmbx.series:
        raise LookupError('No series found in retrieved messages')
    if wantver is None:
        wantver = max(lmbx.series.keys())
    if wantver not in lmbx.series:
        found = ', '.join(f'v{v}' for v in sorted(lmbx.series.keys()))
        raise LookupError(
            f'Series version {wantver} not found in retrieved messages (found: {found})'
        )
    lser = lmbx.get_series(
        wantver, sloppytrailers=sloppytrailers, codereview_trailers=False
    )
    if not lser:
        raise LookupError(f'Could not find series version {wantver}')
    return lser


def get_reference_message(lser: 'b4.LoreSeries') -> 'b4.LoreMessage':
    """Return the cover letter if present, otherwise the first patch.

    Raises LookupError if neither is available.
    """
    ref_msg = None
    if lser.has_cover and lser.patches[0] is not None:
        ref_msg = lser.patches[0]
    elif len(lser.patches) > 1 and lser.patches[1] is not None:
        ref_msg = lser.patches[1]
    if ref_msg is None:
        raise LookupError('Could not find a reference message in the series')
    return ref_msg


def create_review_branch(
    topdir: str,
    branch_name: str,
    base_commit: str,
    lser: b4.LoreSeries,
    linkurl: str,
    linkmask: str,
    num_prereqs: int = 0,
    identifier: Optional[str] = None,
    status: str = 'reviewing',
    is_rethreaded: bool = False,
) -> None:
    # Verify branch does not already exist
    ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', branch_name])
    if ecode == 0:
        logger.critical('Branch %s already exists', branch_name)
        sys.exit(1)

    # Save the current position for potential restore on error: everything
    # past the checkout below runs on the new branch, and git refuses to
    # delete the branch HEAD is sitting on.
    restore_head = b4.git_head_restore_args(topdir)

    def drop_half_built_branch() -> None:
        """Put the caller back and take the unfinished branch with us."""
        if restore_head:
            b4.git_run_command(topdir, restore_head, logstderr=True)
        b4.git_run_command(topdir, ['branch', '-D', branch_name], logstderr=True)

    # Resolve base_commit to a concrete hash before checkout changes HEAD
    ecode, out = b4.git_run_command(
        topdir, ['rev-parse', f'{base_commit}^{{}}'], logstderr=True
    )
    if ecode > 0:
        logger.critical('Unable to resolve base commit %s', base_commit)
        sys.exit(1)
    resolved_base = out.strip()

    # Create and check out the review branch
    ecode, out = b4.git_run_command(
        topdir, ['checkout', '-b', branch_name, resolved_base], logstderr=True
    )
    if ecode > 0:
        logger.critical('Unable to create branch %s at %s', branch_name, resolved_base)
        logger.critical(out.strip())
        sys.exit(1)

    # Cherry-pick the applied patches from FETCH_HEAD
    ecode, out = b4.git_run_command(
        topdir, ['cherry-pick', f'{resolved_base}..FETCH_HEAD'], logstderr=True
    )
    if ecode > 0:
        logger.critical('Unable to cherry-pick patches onto review branch')
        logger.critical(out.strip())
        # Abort the cherry-pick if in progress
        b4.git_run_command(topdir, ['cherry-pick', '--abort'], logstderr=True)
        drop_half_built_branch()
        sys.exit(1)

    # Record the first patch commit (the one right after base)
    ecode, out = b4.git_run_command(
        topdir, ['rev-list', '--reverse', f'{resolved_base}..HEAD'], logstderr=True
    )
    if ecode > 0 or not out.strip():
        logger.critical('Unable to determine first patch commit')
        # HEAD is on a branch that was never finished, as in the other two
        # post-checkout failures around this one.
        drop_half_built_branch()
        sys.exit(1)
    all_commits = out.strip().splitlines()
    prereq_commits = all_commits[:num_prereqs]
    first_patch_commit = all_commits[num_prereqs]

    # Build cover letter content
    clmsg: Optional[b4.LoreMessage] = None
    if lser.has_cover and lser.patches[0] is not None:
        clmsg = lser.patches[0]
        cover_content = clmsg.subject + '\n\n' + clmsg.body
    elif lser.patches[1] is not None:
        clmsg = lser.patches[1]
        cover_content = clmsg.subject + '\n\n' + NO_COVER_NOTE
    else:
        cover_content = 'NOTE: No cover letter or first patch available.'

    # Build cover letter follow-ups and reply headers
    cover_followups = _collect_followups(clmsg, linkmask) if clmsg else list()
    cover_reply_headers = _collect_reply_headers(clmsg) if clmsg else {}

    # Build per-patch metadata
    patches_meta = list()
    for pmsg in lser.patches[1:]:
        if pmsg is None:
            continue
        _gh, _msg, _tr, pbasement, _sig = b4.LoreMessage.get_body_parts(pmsg.body)
        pmeta: Dict[str, Any] = {
            'title': pmsg.full_subject,
            'link': linkmask % pmsg.msgid,
            'header-info': _collect_reply_headers(pmsg),
            'followups': _collect_followups(pmsg, linkmask),
        }
        if pbasement.strip():
            # Keep only the notes before the diff (diffstat, changelog, etc.)
            diff_start = b4.DIFF_RE.search(pbasement)
            notes = pbasement[: diff_start.start()] if diff_start else pbasement
            if notes.strip():
                pmeta['basement'] = notes
        patches_meta.append(pmeta)

    # Build tracking metadata
    tracked_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
    change_id = lser.change_id or branch_name.removeprefix(REVIEW_BRANCH_PREFIX)

    # Seed the portable revisions catalog from the DB so every known version
    # (including a pending rethreaded upgrade and its stitched patch list)
    # travels with the branch and can be rebuilt on another machine.
    known_revisions: List[Dict[str, Any]] = []
    if identifier:
        try:
            _conn = b4.review.tracking.get_db(identifier)
            known_revisions = b4.review.tracking.build_known_revisions(_conn, change_id)
            _conn.close()
        except Exception as ex:
            logger.debug('Could not seed known-revisions for %s: %s', change_id, ex)

    tracking: Dict[str, Any] = {
        'series': {
            'identifier': identifier,
            'status': status,
            'revision': lser.revision,
            'change-id': change_id,
            'link': linkurl,
            'subject': clmsg.full_subject if clmsg else '',
            'fromname': lser.fromname or '',
            'fromemail': lser.fromemail or '',
            'expected': lser.expected,
            'complete': lser.complete,
            'base-commit': resolved_base,
            'prerequisite-commits': prereq_commits,
            'first-patch-commit': first_patch_commit,
            'header-info': cover_reply_headers,
            'tracked-at': tracked_at,
            'is-rethreaded': is_rethreaded,
        },
        'followups': cover_followups,
        'patches': patches_meta,
        'known-revisions': known_revisions,
    }

    # Create the tracking commit at the tip of the branch
    commit_msg = cover_content + '\n\n' + 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('Unable to create tracking commit')
        logger.critical(out.strip())
        drop_half_built_branch()
        sys.exit(1)

    # Mark cover + patch messages as Seen in the messages DB
    try:
        from b4.review import messages

        entries = []
        for pmsg in lser.patches:
            if pmsg is None or not pmsg.msgid:
                continue
            msg_date = pmsg.date.isoformat() if pmsg.date else None
            entries.append({'msgid': pmsg.msgid, 'msg_date': msg_date})
        if entries:
            conn = messages.get_db()
            messages.set_flags_bulk(conn, entries, 'Seen')
            conn.close()
    except Exception:
        pass

    logger.info('Review branch %s created successfully', branch_name)


def main(cmdargs: argparse.Namespace) -> None:
    if not hasattr(cmdargs, 'review_subcmd') or cmdargs.review_subcmd is None:
        logger.critical('Please specify a review sub-command (e.g.: b4 review tui)')
        sys.exit(1)

    if cmdargs.review_subcmd == 'tui':
        cmd_tui(cmdargs)
    elif cmdargs.review_subcmd == 'enroll':
        b4.review.tracking.cmd_enroll(cmdargs)
    elif cmdargs.review_subcmd == 'track':
        b4.review.tracking.cmd_track(cmdargs)
    elif cmdargs.review_subcmd == 'forget':
        b4.review.tracking.cmd_forget(cmdargs)
    elif cmdargs.review_subcmd == 'show-info':
        cmd_show_info(cmdargs)
    elif cmdargs.review_subcmd == 'cron':
        cmd_cron(cmdargs)


def get_review_branch_patch_ids(
    topdir: str, branch: str
) -> List[Tuple[int, str, Optional[str]]]:
    """Compute stable patch-ids for every patch commit on a review branch.

    Loads tracking data to find the first-patch-commit, then iterates
    the commit range and computes ``git patch-id --stable`` for each.

    Returns a list of ``(index, sha, patch_id)`` tuples where *index*
    is zero-based and *patch_id* may be ``None`` if computation failed.
    """
    _cover_text, tracking = load_tracking(topdir, branch)
    series_info = tracking.get('series', {})
    first_patch = series_info.get('first-patch-commit', '')
    if not first_patch:
        return []

    ecode, out = b4.git_run_command(
        topdir, ['rev-list', '--reverse', f'{first_patch}~1..{branch}~1']
    )
    if ecode > 0 or not out.strip():
        return []

    result: List[Tuple[int, str, Optional[str]]] = []
    for idx, sha in enumerate(out.strip().splitlines()):
        ecode, bpatch = b4.git_run_command(
            topdir,
            ['show', '--format=email', '--binary', '--encoding=utf-8', sha],
            decode=False,
        )
        if ecode > 0:
            result.append((idx, sha, None))
            continue
        ecode, pid_out = b4.git_run_command(
            topdir, ['patch-id', '--stable'], stdin=bpatch
        )
        if ecode > 0 or not pid_out.strip():
            result.append((idx, sha, None))
            continue
        patch_id = pid_out.split(maxsplit=1)[0]
        result.append((idx, sha, patch_id))
    return result


def load_tracking(topdir: str, branch: str) -> Tuple[str, Dict[str, Any]]:
    """Load and parse the tracking commit at the tip of a review branch.

    Returns (cover_text, tracking_dict).
    """
    ecode, out = b4.git_run_command(topdir, ['log', '-1', '--format=%B', branch])
    if ecode > 0:
        logger.critical('Unable to read tracking commit from %s', branch)
        sys.exit(1)

    commit_msg = out.strip()
    if REVIEW_MAGIC_MARKER not in commit_msg:
        logger.critical(
            'Branch %s does not contain a valid review tracking commit', branch
        )
        sys.exit(1)

    parts = commit_msg.split(REVIEW_MAGIC_MARKER, maxsplit=1)
    cover_text = parts[0].strip()
    json_text = parts[1].strip()
    # Strip the comment line that follows the marker
    json_lines = json_text.splitlines()
    json_clean = []
    for line in json_lines:
        if line.startswith('#'):
            continue
        json_clean.append(line)
    tracking = json.loads('\n'.join(json_clean))
    return cover_text, tracking


def get_review_info(topdir: str, branch: str) -> Dict[str, Union[str, int, bool, None]]:
    """Collect review branch metadata in a flat dict suitable for scripting.

    Returns key-value pairs describing the branch, its series metadata, and
    dynamic ``commit-{short}`` entries for each patch commit.
    """
    _cover_text, tracking = load_tracking(topdir, branch)
    series = tracking['series']

    sender = ''
    if series.get('fromname') or series.get('fromemail'):
        sender = f'{series.get("fromname", "")} <{series.get("fromemail", "")}>'

    first_patch = series.get('first-patch-commit')
    prereqs = series.get('prerequisite-commits', [])

    # Resolve target branch: per-series tracking data, then config default
    target_branch = series.get('target-branch')
    if not target_branch:
        target_branch = b4.review.tracking.get_review_target_branch_default()

    info: Dict[str, Union[str, int, bool, None]] = {
        'branch': branch,
        'change-id': series.get('change-id'),
        'revision': series.get('revision'),
        'status': series.get('status'),
        'identifier': series.get('identifier'),
        'subject': series.get('subject'),
        'sender': sender,
        'link': series.get('link'),
        'base-commit': series.get('base-commit'),
        'first-patch-commit': first_patch,
        'series-range': None,
        'num-patches': 0,
        'num-prereqs': len(prereqs),
        'complete': series.get('complete'),
        'target-branch': target_branch,
    }

    # Enumerate patch commits
    if first_patch:
        # Range: first-patch-commit~1..branch~1 (excludes the tracking commit at tip)
        commit_range = f'{first_patch}~1..{branch}~1'
        lines = b4.git_get_command_lines(
            topdir,
            [
                'log',
                '--reverse',
                '--format=%h %s',
                commit_range,
            ],
        )
        info['series-range'] = f'{first_patch}..{branch}~1'
        info['num-patches'] = len(lines)
        for line in lines:
            short, subject = line.split(maxsplit=1)
            info[f'commit-{short}'] = subject

    return info


def show_review_info(param: str, as_json: bool = False) -> None:
    """Parse *param* and print review branch info.

    Mirrors ``ez.show_info()`` param parsing: ``[branch:]key``.
    """
    mybranch: Optional[str] = None
    topdir = b4.git_get_toplevel()
    if not topdir:
        logger.critical('Not in a git repository.')
        sys.exit(1)

    if ':' in param:
        chunks = param.split(':', maxsplit=1)
        if len(chunks[0]):
            if b4.git_branch_exists(topdir, chunks[0]):
                mybranch = chunks[0]
            elif b4.git_branch_exists(topdir, f'{REVIEW_BRANCH_PREFIX}{chunks[0]}'):
                mybranch = f'{REVIEW_BRANCH_PREFIX}{chunks[0]}'
            else:
                logger.critical('No such branch: %s', chunks[0])
                sys.exit(1)
        else:
            mybranch = b4.git_get_current_branch(topdir)
        getval = chunks[1] if len(chunks[1]) else '_all'
    elif b4.git_branch_exists(topdir, param):
        mybranch = param
        getval = '_all'
    else:
        mybranch = b4.git_get_current_branch(topdir)
        getval = param

    if mybranch is None:
        logger.critical('No branch specified and no current branch found')
        sys.exit(1)

    if not mybranch.startswith(REVIEW_BRANCH_PREFIX):
        logger.critical(
            'Branch %s does not look like a review branch (expected prefix %s)',
            mybranch,
            REVIEW_BRANCH_PREFIX,
        )
        sys.exit(1)

    info = get_review_info(topdir, mybranch)

    if as_json:
        print(json.dumps(info, indent=2))
        return

    if getval == '_all':
        for key, val in info.items():
            if val is not None:
                print(f'{key}: {val}')
    elif getval in info:
        print(info[getval])
    else:
        logger.critical('No info about %s', getval)
        sys.exit(1)


def list_review_branches(as_json: bool = False) -> None:
    """List all review branches with summary info."""
    topdir = b4.git_get_toplevel()
    if not topdir:
        logger.critical('Not in a git repository.')
        sys.exit(1)

    branches = b4.review.tracking.get_review_branches(topdir)
    if not branches:
        logger.info('No review branches found')
        return

    all_info: List[Dict[str, Union[str, int, bool, None]]] = []
    for branch in branches:
        try:
            info = get_review_info(topdir, branch)
            all_info.append(info)
        except (SystemExit, Exception) as ex:
            logger.warning('Skipping %s: %s', branch, ex)

    if as_json:
        print(json.dumps(all_info, indent=2))
        return

    for idx, info in enumerate(all_info):
        if idx > 0:
            print()
        for key in (
            'branch',
            'change-id',
            'status',
            'subject',
            'sender',
            'revision',
            'num-patches',
            'complete',
        ):
            val = info.get(key)
            if val is not None:
                print(f'{key}: {val}')


def cmd_show_info(cmdargs: argparse.Namespace) -> None:
    """Dispatcher for ``b4 review show-info``."""
    if cmdargs.list_branches:
        list_review_branches(as_json=cmdargs.json_output)
    else:
        show_review_info(cmdargs.param, as_json=cmdargs.json_output)


def save_tracking_ref(
    topdir: str, branch: str, cover_text: str, tracking: Dict[str, Any]
) -> bool:
    """Amend the tracking commit at the tip of a ref without checkout.

    Uses git commit-tree + git update-ref so that commit.gpgsign and
    hooks are not triggered — tracking commits are ephemeral and do
    not benefit from signing.  Returns True on success.
    """
    if not branch.startswith(REVIEW_BRANCH_PREFIX):
        logger.critical(
            'Refusing to write tracking commit to non-review branch: %s', branch
        )
        return False
    commit_msg = cover_text + '\n\n' + make_review_magic_json(tracking)
    ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{branch}^{{tree}}'])
    if ecode > 0:
        return False
    tree = out.strip()
    ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{branch}~1'])
    if ecode > 0:
        return False
    parent = out.strip()
    ecode, out = b4.git_run_command(
        topdir,
        ['commit-tree', tree, '-p', parent, '-F', '-'],
        stdin=commit_msg.encode(),
    )
    if ecode > 0:
        return False
    new_sha = out.strip()
    ecode, out = b4.git_run_command(
        topdir, ['update-ref', f'refs/heads/{branch}', new_sha]
    )
    return ecode == 0


def update_tracking_status(topdir: str, branch: str, status: str) -> bool:
    """Update the status field in a review branch's tracking commit."""
    try:
        cover_text, tracking = load_tracking(topdir, branch)
        tracking['series']['status'] = status
        return save_tracking_ref(topdir, branch, cover_text, tracking)
    except (Exception, SystemExit):
        logger.warning('Could not update tracking commit status on %s', branch)
        return False


def delete_review_branch(
    topdir: str, review_branch: str, allow_switch: bool = True
) -> Tuple[bool, str]:
    """Delete a review branch, optionally switching away if checked out.

    When HEAD is on *review_branch*, *allow_switch* permits detaching to
    its parent commit first (fine for interactive callers); without it
    the deletion is refused, so a non-interactive caller (queue
    delivery, cron) never yanks the checkout out from under the user.

    Returns (success, error_message).
    """
    if b4.git_get_current_branch(topdir) == review_branch:
        if not allow_switch:
            return False, f'{review_branch} is currently checked out'
        ecode, out = b4.git_run_command(
            topdir, ['rev-parse', f'{review_branch}~1'], logstderr=True
        )
        if ecode > 0:
            return False, 'Could not determine parent commit'
        ecode, out = b4.git_run_command(
            topdir, ['checkout', out.strip()], logstderr=True
        )
        if ecode > 0:
            return False, f'Could not switch away from {review_branch}'
    ecode, _out = b4.git_run_command(
        topdir, ['branch', '-D', review_branch], logstderr=True
    )
    if ecode > 0:
        return False, f'Failed to delete branch {review_branch}'
    return True, ''


def archive_series(
    topdir: Optional[str],
    identifier: str,
    change_id: str,
    revision: Optional[int] = None,
    pw_series_id: Optional[int] = None,
    allow_switch: bool = False,
) -> Tuple[bool, str]:
    """Archive a tracked series.

    Tars the cover letter, tracking metadata and patches from the
    review branch into the data directory, deletes the branch, marks
    the series 'archived' in the tracking database, and (when
    *pw_series_id* is given) archives the series in Patchwork.  A
    series whose review branch is already gone is archived
    database-only, so the operation is idempotent.

    Returns (success, detail): detail is the archive tarball path on
    success (empty for a database-only archive), or an error message.
    Never raises: callers archive *after* a thank-you has gone out, and a
    failure here must not be mistaken for a failure to deliver it.
    """
    # Imported here: the tarball machinery is only needed when archiving.
    # b4.review.tracking is re-imported alongside b4.ez because a local
    # "import b4.ez" rebinds the name b4 locally.
    import tarfile
    import time

    import b4.ez
    import b4.review.tracking

    review_branch = f'{REVIEW_BRANCH_PREFIX}{change_id}'
    tarpath = ''
    has_branch = bool(topdir) and b4.git_branch_exists(topdir, review_branch)
    if has_branch and topdir:
        try:
            cover_text, tracking = load_tracking(topdir, review_branch)
        except (Exception, SystemExit):
            return False, f'Could not load tracking data from {review_branch}'

        series_info = tracking.get('series', {})
        first_patch = series_info.get('first-patch-commit', '')
        if not first_patch:
            return False, 'No patch commits found in tracking data'

        try:
            tio = io.BytesIO()
            mnow = int(time.time())
            with tarfile.open(fileobj=tio, mode='w:gz') as tfh:
                # Add cover letter
                ifh = io.BytesIO()
                ifh.write(cover_text.encode())
                b4.ez.write_to_tar(tfh, f'{change_id}/cover.txt', mnow, ifh)
                ifh.close()
                # Add tracking metadata
                ifh = io.BytesIO()
                ifh.write(make_review_magic_json(tracking).encode())
                b4.ez.write_to_tar(tfh, f'{change_id}/tracking.js', mnow, ifh)
                ifh.close()
                # Add patches as mbox
                patches = b4.git_range_to_patches(
                    topdir, f'{first_patch}~1', f'{review_branch}~1'
                )
                if patches:
                    ifh = io.BytesIO()
                    b4.save_git_am_mbox([patch[1] for patch in patches], ifh)
                    b4.ez.write_to_tar(tfh, f'{change_id}/patches.mbx', mnow, ifh)
                    ifh.close()

            # Write archive to data directory
            datadir = b4.get_data_dir()
            archpath = os.path.join(datadir, 'review-archived')
            os.makedirs(archpath, exist_ok=True)
            tarpath = os.path.join(archpath, f'{change_id}.tar.gz')
            with open(tarpath, mode='wb') as tout:
                tout.write(tio.getvalue())
        except Exception as ex:
            # The branch is still intact, so this is safe to retry
            return False, f'Could not write archive for {change_id}: {ex}'

        ok, err = delete_review_branch(topdir, review_branch, allow_switch=allow_switch)
        if not ok:
            return False, err

    # Update tracking database
    conn = None
    try:
        conn = b4.review.tracking.get_db(identifier)
        b4.review.tracking.update_series_status(
            conn, change_id, 'archived', revision=revision
        )
    except Exception as ex:
        return False, f'DB error: {ex}'
    finally:
        if conn is not None:
            conn.close()

    # Mark as archived in Patchwork.  The local archive is already done and
    # cannot be retried, so a Patchwork hiccup is a warning, not a failure.
    if pw_series_id:
        try:
            pw_update_series_state(pw_series_id, 'accepted', archived=True)
        except Exception as ex:
            logger.warning(
                'Could not archive series %s in Patchwork: %s', change_id, ex
            )

    return True, tarpath


def save_tracking(topdir: str, cover_text: str, tracking: Dict[str, Any]) -> None:
    """Amend the tip tracking commit on the current branch.

    Resolves HEAD to a branch name and delegates to save_tracking_ref().
    """
    branch = b4.git_get_current_branch(topdir)
    if not branch:
        logger.critical('Unable to resolve HEAD to a branch')
        sys.exit(1)
    if not save_tracking_ref(topdir, branch, cover_text, tracking):
        logger.critical('Unable to amend tracking commit')
        sys.exit(1)


def _get_my_review(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> Dict[str, Any]:
    """Return the current user's review sub-dict (read-only; may be empty)."""
    email = str(usercfg.get('email', 'unknown@example.com'))
    reviews: Dict[str, Any] = target.get('reviews', {})
    result: Dict[str, Any] = reviews.get(email, {})
    return result


def _ensure_my_review(
    target: Dict[str, Any], usercfg: b4.ConfigDictT
) -> Dict[str, Any]:
    """Return the current user's review sub-dict, creating it if needed."""
    email = str(usercfg.get('email', 'unknown@example.com'))
    name = str(usercfg.get('name', 'Unknown'))
    reviews: Dict[str, Any] = target.setdefault('reviews', {})
    entry: Dict[str, Any] = reviews.setdefault(email, {})
    entry['name'] = name
    return entry


def _cleanup_review(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> None:
    """Remove the reviewer key when its dict becomes empty (only 'name' or less)."""
    email = usercfg.get('email', 'unknown@example.com')
    reviews = target.get('reviews', {})
    if email in reviews:
        entry = reviews[email]
        # Consider empty if only 'name' key or no keys at all
        non_name_keys = [k for k in entry if k != 'name']
        if not non_name_keys:
            del reviews[email]
    if 'reviews' in target and not target['reviews']:
        del target['reviews']


_APPROVAL_TRAILER_KEYS = frozenset({'reviewed-by', 'acked-by'})
_NACK_TRAILER_KEY = 'nacked-by'


def _has_my_approval_in_thread(target: Dict[str, Any], my_email: str) -> bool:
    """Return True if I already sent an approval trailer for this patch.

    Scans the follow-up trailers collected at ingest (target['followups'])
    for an entry sent from my own address carrying a Reviewed-by or Acked-by.
    A Nacked-by from me vetoes the result, mirroring _get_patch_state()'s
    handling of a NACK in my own trailer list.
    """
    my_email_l = my_email.lower()
    found_approval = False
    for followup in target.get('followups', []):
        if str(followup.get('fromemail', '')).lower() != my_email_l:
            continue
        for trailer in followup.get('trailers', []):
            key = trailer.split(':', 1)[0].strip().lower()
            if key == _NACK_TRAILER_KEY:
                return False
            if key in _APPROVAL_TRAILER_KEYS:
                found_approval = True
    return found_approval


def _get_patch_state(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> str:
    """Derive the effective per-patch state for the current user.

    Returns 'done', 'draft', 'external', 'skip', 'unchanged', or '' (no state).
    'done', 'skip', and 'unchanged' are stored explicitly; other states are derived.
    'unchanged' is low-priority: any derived state (draft, done, external) takes
    precedence over it, so it is only returned when the maintainer has added nothing.
    The 'external' state indicates that external reviewers (agents,
    sashiko, follow-ups) have inline comments but the maintainer has
    not yet commented.
    """
    review = _get_my_review(target, usercfg)
    explicit = str(review.get('patch-state', ''))
    if explicit in ('skip', 'done', 'draft'):
        # An explicit 'draft' lets the maintainer keep editing a patch that
        # would otherwise derive 'done' from an approval trailer they have
        # already staged (see _toggle_patch_done).
        return explicit
    trailer_keys = {
        t.split(':', 1)[0].strip().lower() for t in review.get('trailers', [])
    }
    if _NACK_TRAILER_KEY in trailer_keys:
        return 'draft'
    if trailer_keys & _APPROVAL_TRAILER_KEYS:
        return 'done'
    if review.get('comments') or review.get('reply', ''):
        return 'draft'
    my_email = str(usercfg.get('email', ''))
    # Auto-detect a review I already sent to the list before tracking this
    # series in b4 review: an approval trailer carried in a follow-up from
    # my own address marks the patch 'done'.  This is a low-priority derived
    # signal -- any manual action above (skip/done/comments/reply/NACK) wins,
    # and it is never written back into review['trailers'], so it can't be
    # re-sent.
    if my_email and _has_my_approval_in_thread(target, my_email):
        return 'done'
    # Check for external reviewer comments
    all_reviews = target.get('reviews', {})
    if any(
        addr != my_email and rev.get('comments') for addr, rev in all_reviews.items()
    ):
        return 'external'
    return explicit if explicit else ''


def _set_patch_state(
    target: Dict[str, Any], usercfg: b4.ConfigDictT, state: str
) -> None:
    """Store an explicit patch state ('done', 'skip', 'draft', 'unchanged', or '' to clear)."""
    if state:
        review = _ensure_my_review(target, usercfg)
        review['patch-state'] = state
    else:
        review = _get_my_review(target, usercfg)
        review.pop('patch-state', None)
        _cleanup_review(target, usercfg)


def _toggle_patch_done(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> str:
    """Toggle the 'done' state for the current user, returning the new effective state.

    Pressing 'done' on a patch that is already done clears the explicit state.
    When clearing would still leave the patch deriving 'done' -- because an
    approval trailer (Reviewed-by/Acked-by) is staged in the maintainer's own
    review, or one is carried in a follow-up they already sent to the list --
    an explicit 'draft' state is stored instead.  This lets the maintainer
    unmark a patch they are still writing notes on without the staged trailer
    forcing it back to 'done' (and the 'draft' state guards against an
    accidental send).
    """
    current = _get_patch_state(target, usercfg)
    if current == 'done':
        _set_patch_state(target, usercfg, '')
        if _get_patch_state(target, usercfg) == 'done':
            _set_patch_state(target, usercfg, 'draft')
    else:
        _set_patch_state(target, usercfg, 'done')
    return _get_patch_state(target, usercfg)


def _apply_findings_locations(
    comments: List[Dict[str, Any]],
    locations_by_file: Dict[str, List[int]],
) -> None:
    """Anchor unpositioned inline comments using sashiko finding locations.

    After :func:`_resolve_comment_positions` runs, any comment whose
    ``line`` is still 0 (no hunk-level position was found) is a candidate
    for fallback positioning.  When *locations_by_file* contains exactly
    one candidate line number for that file, the comment's ``line`` is set
    to that value.

    This function is a no-op when *locations_by_file* is empty (i.e. when
    the sashiko response carries no structured ``locations`` data), so it
    degrades silently for older sashiko versions.
    """
    for c in comments:
        if c.get('line') != 0:
            continue
        path = c.get('path', '')
        candidates = locations_by_file.get(path)
        if candidates and len(candidates) == 1:
            c['line'] = candidates[0]


def _resolve_comment_positions(
    diff_text: str,
    comments: List[Dict[str, Any]],
) -> None:
    """Resolve comment positions using line content against the real diff.

    When an agent omits diff lines from a review file, the counted line
    numbers drift.  This function walks the actual diff, builds a mapping
    from line content text to the correct ``(path, line)`` pair, and
    updates each comment that carries a ``content`` key to the correct
    position.  Comments without ``content`` (or whose content is not
    found) keep their original counted position.

    Content is matched with the diff prefix character (``+``, ``-``,
    or ``\\x20``) stripped, so a sashiko context line (``\\x20\\tcode``)
    correctly matches an addition line (``+\\tcode``) in a new-file diff.
    When the same code line appears multiple times (e.g. ``return -EINVAL;``),
    the occurrence closest to the comment's current position is chosen.
    """
    if not comments:
        return

    # Only bother if at least one comment has content to resolve
    to_resolve = [c for c in comments if c.get('content')]
    if not to_resolve:
        return

    hunk_re = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')
    current_a_file = ''
    current_b_file = ''
    a_line = 0
    b_line = 0

    # Map normalized content -> list of (path, line) positions.
    # We strip the leading diff prefix (+/-/ ) so that the same code
    # line matches regardless of whether the source had it as context,
    # addition, or deletion.  Duplicate lines (e.g. "return -EINVAL;")
    # produce multiple entries; we pick the closest one later.
    content_map: Dict[str, List[Tuple[str, int]]] = {}

    for line in diff_text.splitlines():
        if line.startswith(('--- a/', '--- /dev/null')):
            current_a_file = line[4:]
            continue
        if line.startswith(('+++ b/', '+++ /dev/null')):
            current_b_file = line[4:]
            continue
        if line.startswith(('--- ', '+++ ')):
            continue
        if line.startswith('diff --git '):
            continue

        hm = hunk_re.match(line)
        if hm:
            a_line = int(hm.group(1))
            b_line = int(hm.group(2))
            continue

        # Strip the diff prefix for the content key
        bare = line[1:] if line and line[0] in ('+', '-', ' ') else line

        if line.startswith('-'):
            content_map.setdefault(bare, []).append((current_a_file, a_line))
            a_line += 1
        elif line.startswith('+'):
            content_map.setdefault(bare, []).append((current_b_file, b_line))
            b_line += 1
        elif line.startswith(' ') or line == '':
            content_map.setdefault(bare, []).append((current_b_file, b_line))
            a_line += 1
            b_line += 1

    for c in to_resolve:
        content = c['content']
        # Strip the diff prefix from the stored content too
        bare = content[1:] if content and content[0] in ('+', '-', ' ') else content
        positions = content_map.get(bare)
        if not positions:
            continue
        if len(positions) == 1:
            c['path'], c['line'] = positions[0]
        else:
            # Multiple occurrences — pick the one closest to the
            # comment's current (source-derived) position and path.
            cur_path = c['path']
            cur_line = c['line']
            best = min(
                positions, key=lambda p: (p[0] != cur_path, abs(p[1] - cur_line))
            )
            c['path'], c['line'] = best


def _resolve_message_positions(
    message_text: str,
    comments: List[Dict[str, Any]],
) -> None:
    """Re-anchor ``:message`` comments by content against the real body.

    Cover-letter and commit-message replies are quoted into the editor
    one body line per ``> `` line, so the counted line number matches the
    body exactly — *until* the editor re-wraps the quote.  Editors that
    treat the ``.eml`` reply buffer as mail (vim's mail filetype sets
    ``textwidth=72``) re-flow the ``> ``-quoted lines, and the line count
    then no longer lines up with the original body.  A comment's counted
    ``line`` drifts, cumulatively, the further down the message it sits.

    Each ``:message`` comment carries the text of its anchor — the last
    quoted line before it — as ``content``.  This walks the real body and
    relocates the comment to the line where that anchor text actually
    lives, mirroring :func:`_resolve_comment_positions` for diffs.

    Matching is done on a whitespace-normalized character stream of the
    body, with line breaks flattened to single spaces, so it does not
    matter how the editor re-wrapped the quote: a re-wrapped fragment is
    still a substring of the original flowing text.  The comment is
    anchored to the body line containing the *end* of the match, so it
    renders right after the quoted block — exactly where the maintainer
    placed it.

    Comments without a usable ``content`` anchor, or whose anchor is not
    found in the body, keep their counted position (graceful fallback,
    same contract as :func:`_resolve_comment_positions`).
    """
    to_resolve = [
        c
        for c in comments
        if c.get('path') == COMMIT_MESSAGE_PATH
        and c.get('line', 0) > 0
        and c.get('content')
    ]
    if not to_resolve:
        return

    body_lines = _strip_subject(message_text)
    if not body_lines:
        return

    # Build a whitespace-normalized character stream of the body and a
    # parallel list mapping each stream position back to the 1-based body
    # line it came from.  Collapsing whitespace (including the line breaks
    # between body lines) makes matching immune to editor re-wrapping.
    norm_chars: List[str] = []
    char_line: List[int] = []
    prev_space = False
    for lineno, bline in enumerate(body_lines, start=1):
        for ch in bline:
            if ch.isspace():
                if prev_space or not norm_chars:
                    continue
                norm_chars.append(' ')
                char_line.append(lineno)
                prev_space = True
            else:
                norm_chars.append(ch)
                char_line.append(lineno)
                prev_space = False
        # The line break becomes a single separating space
        if norm_chars and not prev_space:
            norm_chars.append(' ')
            char_line.append(lineno)
            prev_space = True
    norm_body = ''.join(norm_chars)

    for c in to_resolve:
        needle = ' '.join(str(c['content']).split())
        if not needle:
            continue
        # Collect every occurrence; map the end of each match to its line.
        positions: List[int] = []
        start = 0
        while True:
            idx = norm_body.find(needle, start)
            if idx < 0:
                break
            positions.append(char_line[idx + len(needle) - 1])
            start = idx + 1
        if not positions:
            continue
        if len(positions) == 1:
            c['line'] = positions[0]
        else:
            # Ambiguous anchor (e.g. a short repeated phrase): pick the
            # occurrence closest to the comment's counted position, the
            # same tie-break :func:`_resolve_comment_positions` uses.
            cur = c['line']
            c['line'] = min(positions, key=lambda ln: abs(ln - cur))


def reanchor_patch_comments(
    topdir: str,
    commit_shas: List[str],
    patches: List[Dict[str, Any]],
) -> None:
    """Re-anchor inline comment positions against current diffs.

    For each patch that has reviews with comments carrying a ``content``
    key, generate the diff from the corresponding commit SHA and call
    :func:`_resolve_comment_positions` to fix line-number drift.
    """
    for idx, sha in enumerate(commit_shas):
        if idx >= len(patches):
            break
        reviews = patches[idx].get('reviews')
        if not reviews:
            continue
        real_diff: Optional[str] = None
        for reviewer_data in reviews.values():
            comments = reviewer_data.get('comments')
            if not comments or not any(c.get('content') for c in comments):
                continue
            if real_diff is None:
                ecode, real_diff = b4.git_run_command(topdir, ['diff', f'{sha}~1', sha])
                if ecode != 0:
                    break
            _resolve_comment_positions(real_diff, comments)


def _clear_other_comments(
    all_reviews: Dict[str, Dict[str, Any]],
    my_email: str,
) -> bool:
    """Remove inline comments from all reviewers other than *my_email*.

    When the maintainer edits inline comments, all comments are
    presented as unattributed blocks and become the maintainer's own
    on save.  This function cleans up the now-superseded originals.

    Returns True if any comments were removed.
    """
    cleared = False
    for r_email in list(all_reviews):
        if r_email == my_email:
            continue
        r_data = all_reviews[r_email]
        if 'comments' not in r_data:
            continue
        del r_data['comments']
        cleared = True
        # Clean up reviewer entry if only 'name' remains
        non_name_keys = [k for k in r_data if k != 'name']
        if not non_name_keys:
            del all_reviews[r_email]
    return cleared


def _integrate_agent_reviews(
    topdir: str,
    cover_text: str,
    tracking: Dict[str, Any],
    commit_shas: List[str],
    patches: List[Dict[str, Any]],
    branch: Optional[str] = None,
) -> bool:
    """Read review files from .git/b4-review/<tip-sha>/ and merge into tracking.

    The directory-based layout uses:
    - ``identity.txt`` for reviewer attribution (``Name <email>``)
    - ``series.txt`` for cover letter review (plain text note)
    - ``NNNN.txt`` (1-indexed) for per-patch reviews with annotated diffs

    Consumption is implicit: :func:`save_tracking` amends the branch tip,
    changing its SHA so the directory no longer matches on the next run.

    Returns True if any reviews were integrated.
    """
    series = tracking['series']

    # Resolve tip SHA from explicit ref or HEAD
    ref = branch if branch else 'HEAD'
    ecode, out = b4.git_run_command(topdir, ['rev-parse', ref])
    if ecode != 0:
        return False
    head_sha = out.strip()

    review_dir = os.path.join(topdir, '.git', 'b4-review', head_sha)
    if not os.path.isdir(review_dir):
        return False

    # Read identity.txt
    identity_path = os.path.join(review_dir, 'identity.txt')
    try:
        with open(identity_path, 'r') as fh:
            identity_line = fh.read().strip()
    except OSError:
        logger.warning('b4-review/%s: missing identity.txt, skipping', head_sha[:12])
        return False

    reviewer_name, reviewer_email = email.utils.parseaddr(identity_line)
    if not reviewer_name or not reviewer_email:
        logger.warning('b4-review/%s: malformed identity.txt, skipping', head_sha[:12])
        return False

    integrated = 0

    # Read series.txt (cover letter review)
    series_path = os.path.join(review_dir, 'series.txt')
    try:
        with open(series_path, 'r') as fh:
            series_note = fh.read().strip()
    except OSError:
        series_note = ''

    if series_note:
        series_reviews: Dict[str, Any] = series.setdefault('reviews', {})
        entry: Dict[str, Any] = series_reviews.setdefault(reviewer_email, {})
        entry['name'] = reviewer_name
        old_note = entry.get('note', '')
        if old_note:
            entry['note'] = old_note + '\n\n' + series_note
        else:
            entry['note'] = series_note
        integrated += 1

    # Read NNNN.txt files (per-patch reviews, 1-indexed)
    try:
        entries = sorted(
            f for f in os.listdir(review_dir) if re.match(r'^\d{4}\.txt$', f)
        )
    except OSError:
        entries = []

    for fname in entries:
        patch_num = int(fname[:4])  # 1-indexed
        idx = patch_num - 1
        if idx < 0 or idx >= len(patches):
            logger.warning(
                'b4-review/%s/%s: patch number out of range, skipping',
                head_sha[:12],
                fname,
            )
            continue
        if idx >= len(commit_shas):
            logger.warning(
                'b4-review/%s/%s: no commit SHA for patch %d, skipping',
                head_sha[:12],
                fname,
                patch_num,
            )
            continue

        fpath = os.path.join(review_dir, fname)
        try:
            with open(fpath, 'r') as fh:
                file_text = fh.read()
        except OSError:
            continue

        # Split into overall note (before first "> diff --git") and diff
        note_text = ''
        diff_portion = ''
        diff_idx = file_text.find('\n> diff --git ')
        if diff_idx < 0 and file_text.startswith('> diff --git '):
            diff_portion = file_text
        elif diff_idx >= 0:
            note_text = file_text[:diff_idx].strip()
            diff_portion = file_text[diff_idx + 1 :]
        else:
            note_text = file_text.strip()

        # Extract inline comments from the quoted diff portion
        comments: List[Dict[str, Any]] = []
        if diff_portion:
            comments = _extract_comments_from_quoted_reply(diff_portion)

        # Resolve comment positions against the real diff
        if comments:
            sha = commit_shas[idx]
            ecode, real_diff = b4.git_run_command(topdir, ['diff', f'{sha}~1', sha])
            if ecode == 0:
                _resolve_comment_positions(real_diff, comments)

        if not note_text and not comments:
            continue

        # Store in patches[idx]['reviews'][email]
        patch = patches[idx]
        patch_reviews: Dict[str, Any] = patch.setdefault('reviews', {})
        entry = patch_reviews.setdefault(reviewer_email, {})
        entry['name'] = reviewer_name
        if comments:
            existing_comments: List[Dict[str, Any]] = entry.setdefault('comments', [])
            existing_comments.extend(comments)
        if note_text:
            old_note = entry.get('note', '')
            if old_note:
                entry['note'] = old_note + '\n\n' + note_text
            else:
                entry['note'] = note_text
        integrated += 1

    if not integrated:
        return False

    # Save tracking — this amends the branch tip, changing its SHA so
    # the directory is not re-consumed on the next run.
    if branch:
        save_tracking_ref(topdir, branch, cover_text, tracking)
    else:
        save_tracking(topdir, cover_text, tracking)
    logger.info(
        'Integrated agent review data from %d file(s) in b4-review/%s',
        integrated,
        head_sha[:12],
    )

    # Clean up the consumed review directory
    shutil.rmtree(review_dir, ignore_errors=True)

    return True


def _extract_comments_from_quoted_reply(
    text: str, capture_preamble: bool = False
) -> List[Dict[str, Any]]:
    """Extract inline comments from a ``> ``-quoted email reply.

    This is the standard mailing-list code review format: the reviewer
    quotes patch diff lines with ``> `` and intersperses their comments
    as unquoted text.  Sashiko inline reviews use the same convention.

    The ``> `` prefix is the authoritative signal for what is diff
    content vs. reviewer commentary — quoted lines are diff, unquoted
    lines are comments.  This avoids the ambiguity of stripping quotes
    first and then trying to re-parse.

    Returns a list of ``{'path', 'line', 'text', 'content'}`` dicts,
    the same shape as :func:`_extract_comments_from_quoted_reply`.
    """
    # Rejoin diff --git lines that were wrapped by the editor (e.g. vim
    # with textwidth=72 triggered by .eml filetype detection).  A wrapped
    # line looks like:
    #   > diff --git a/long/path
    #   b/long/path
    # Rejoin them so the regex can match.
    raw_lines = text.splitlines()
    joined: List[str] = []
    i = 0
    while i < len(raw_lines):
        line = raw_lines[i]
        stripped = (
            line[2:]
            if line.startswith('> ')
            else line[1:]
            if line.startswith('>')
            else None
        )
        if (
            stripped is not None
            and stripped.startswith('diff --git a/')
            and ' b/' not in stripped
        ):
            # Peek at next line for the b/ continuation
            if i + 1 < len(raw_lines):
                nxt = raw_lines[i + 1]
                if nxt.startswith(('> b/', '>b/')):
                    # Quoted continuation
                    nxt_stripped = nxt[2:] if nxt.startswith('> ') else nxt[1:]
                    joined.append(line.rstrip() + ' ' + nxt_stripped)
                    i += 2
                    continue
                if nxt.startswith('b/'):
                    # Unquoted continuation (editor dropped the > prefix)
                    joined.append(line.rstrip() + ' ' + nxt)
                    i += 2
                    continue
        joined.append(line)
        i += 1
    text = '\n'.join(joined)

    hunk_re = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')
    diff_git_re = re.compile(r'^diff --git a/(.*) b/(.*)$')

    in_diff = False
    in_hunk = False
    in_commit_msg = False
    seen_quoted = False
    msg_lineno = 0
    current_a_file = ''
    current_b_file = ''
    a_line = 0
    b_line = 0
    last_diff_path = ''
    last_diff_line = 0
    last_diff_content = ''

    comments: List[Dict[str, Any]] = []
    pending_comment_lines: List[str] = []
    preamble_lines: List[str] = []

    def _flush_comment() -> None:
        if pending_comment_lines and (last_diff_path or last_diff_content):
            text = '\n'.join(pending_comment_lines).strip()
            if text:
                comment: Dict[str, Any] = {
                    'path': last_diff_path,
                    'line': last_diff_line,
                    'text': text,
                }
                if last_diff_content:
                    comment['content'] = last_diff_content
                comments.append(comment)
        pending_comment_lines.clear()

    def _flush_preamble() -> None:
        """Store preamble text as a comment on commit message line 0."""
        text = '\n'.join(preamble_lines).strip()
        if text:
            comments.append(
                {
                    'path': COMMIT_MESSAGE_PATH,
                    'line': 0,
                    'text': text,
                }
            )
        preamble_lines.clear()

    for line in text.splitlines():
        # Unquoted lines are reviewer comments (or preamble/blank)
        if not line.startswith('>'):
            if line.strip() == '[ ... ]':
                continue
            if in_diff or in_commit_msg:
                # Accumulate as comment text (including blank lines
                # between paragraphs — they stay part of the comment)
                pending_comment_lines.append(line)
            elif not seen_quoted and capture_preamble:
                # Text before any quoted content — preamble
                # Skip the attribution line (e.g. "On ..., ... wrote:")
                if re.match(r'^On .+ wrote:\s*$', line):
                    continue
                preamble_lines.append(line)
            continue

        # Strip the quote prefix to get the raw diff line
        if line.startswith('> '):
            raw = line[2:]
        else:
            raw = line[1:]

        # Flush preamble on first quoted line
        if not seen_quoted:
            seen_quoted = True
            _flush_preamble()

        # Once we see quoted diff content, any pending comment is done
        if pending_comment_lines:
            _flush_comment()

        # Track diff structure
        if not in_diff:
            m = diff_git_re.match(raw)
            if m:
                in_diff = True
                in_commit_msg = False
                in_hunk = False
                current_a_file = m.group(1)
                current_b_file = m.group(2)
                continue
            # Handle orphan diff headers (user trimmed diff --git line)
            if raw.startswith(('--- a/', '--- /dev/null')):
                in_diff = True
                in_commit_msg = False
                current_a_file = raw[4:]
                continue
            if raw.startswith(('+++ b/', '+++ /dev/null')):
                in_diff = True
                in_commit_msg = False
                current_b_file = raw[4:]
                continue
            hm = hunk_re.match(raw)
            if hm:
                in_diff = True
                in_hunk = True
                in_commit_msg = False
                a_line = int(hm.group(1))
                b_line = int(hm.group(2))
                continue
            # Quoted lines before the diff are commit message
            in_commit_msg = True
            msg_lineno += 1
            last_diff_path = COMMIT_MESSAGE_PATH
            last_diff_line = msg_lineno
            last_diff_content = raw
            continue

        m = diff_git_re.match(raw)
        if m:
            in_hunk = False
            current_a_file = m.group(1)
            current_b_file = m.group(2)
            continue

        if raw.startswith(('--- a/', '--- /dev/null')):
            current_a_file = raw[4:]
            continue
        if raw.startswith(('+++ b/', '+++ /dev/null')):
            current_b_file = raw[4:]
            continue
        if raw.startswith(('--- ', '+++ ')):
            continue

        hm = hunk_re.match(raw)
        if hm:
            a_line = int(hm.group(1))
            b_line = int(hm.group(2))
            last_diff_path = current_b_file
            last_diff_line = b_line
            in_hunk = True
            continue

        if not in_hunk:
            # Structural lines outside hunks (index, mode, etc.)
            continue

        # Inside a hunk — track line numbers
        if raw.startswith('-'):
            last_diff_path = current_a_file
            last_diff_line = a_line
            last_diff_content = raw
            a_line += 1
        elif raw.startswith('+'):
            last_diff_path = current_b_file
            last_diff_line = b_line
            last_diff_content = raw
            b_line += 1
        elif raw.startswith(' ') or raw == '':
            last_diff_path = current_b_file
            last_diff_line = b_line
            last_diff_content = raw
            a_line += 1
            b_line += 1

    # Flush any trailing comment
    _flush_comment()

    return comments


def _integrate_sashiko_reviews(
    topdir: str,
    cover_text: str,
    tracking: Dict[str, Any],
    commit_shas: List[str],
    patches: List[Dict[str, Any]],
    branch: Optional[str] = None,
) -> bool:
    """Fetch sashiko inline reviews and merge into tracking as comments.

    Queries the sashiko API for the series message-id, converts each
    patch's ``inline_review`` into the annotated-diff format used by
    :func:`_extract_comments_from_quoted_reply`, and stores the resulting comments
    in the tracking data alongside any agent reviews.

    Returns True if any reviews were integrated.
    """
    series = tracking['series']
    config = b4.get_main_config()
    sashiko_url = str(config.get('sashiko-url', ''))
    if not sashiko_url:
        return False

    # Use the series-level message-id to query sashiko
    series_msgid = series.get('message_id', '')
    if not series_msgid:
        series_msgid = series.get('header-info', {}).get('msgid', '')
    if not series_msgid:
        return False

    from b4.review.checks import _fetch_sashiko_patchset, clear_sashiko_cache

    clear_sashiko_cache()
    patchset = _fetch_sashiko_patchset(series_msgid, sashiko_url)
    if not patchset:
        return False

    # Build a map from message-id to sashiko review data
    sashiko_reviews: Dict[str, Dict[str, Any]] = {}
    s_patches = patchset.get('patches', [])
    s_reviews = patchset.get('reviews', [])
    # Map sashiko patch id -> message_id
    spatch_msgid: Dict[int, str] = {}
    for sp in s_patches:
        sp_id = sp.get('id')
        sp_msgid = sp.get('message_id', '')
        if sp_id is not None and sp_msgid:
            spatch_msgid[sp_id] = sp_msgid
    # Map message-id -> best review with inline_review content
    for sr in s_reviews:
        patch_id = sr.get('patch_id')
        inline = sr.get('inline_review', '') or ''
        if not inline or patch_id not in spatch_msgid:
            continue
        msgid = spatch_msgid[patch_id]
        # Keep only the latest review per patch
        existing = sashiko_reviews.get(msgid)
        if existing is None or sr.get('id', 0) > existing.get('id', 0):
            sashiko_reviews[msgid] = sr

    if not sashiko_reviews:
        return False

    reviewer_name = 'sashiko.dev'
    reviewer_email = 'sashiko@sashiko.dev'
    base_url = sashiko_url.rstrip('/')
    integrated = 0

    for idx, patch in enumerate(patches):
        if idx >= len(commit_shas):
            break
        pmsgid = patch.get('header-info', {}).get('msgid', '')
        if not pmsgid or pmsgid not in sashiko_reviews:
            continue

        sr = sashiko_reviews[pmsgid]
        inline_review = sr.get('inline_review', '') or ''
        if not inline_review:
            continue

        # Skip if we already integrated this exact review
        review_id = sr.get('id', 0)
        existing_entry = patch.get('reviews', {}).get(reviewer_email, {})
        if existing_entry.get('sashiko-review-id') == review_id:
            continue

        # Build a file -> [line_numbers] map from structured finding locations
        # for use as a positional fallback when quoted-context matching fails.
        # Accepts both {file_name, line_number} and {file, line} key shapes.
        # locations can be a single dict or a list of dicts; both are handled.
        # Silently skips when findings carry no location data.
        locations_by_file: Dict[str, List[int]] = {}
        output_str = sr.get('output', '') or ''
        if output_str:
            try:
                output_json = json.loads(output_str)
                for fi in output_json.get('findings', []):
                    if not isinstance(fi, dict):
                        continue
                    locs = fi.get('locations')
                    # Normalise: single dict → 1-element list
                    if isinstance(locs, dict):
                        locs = [locs]
                    elif not isinstance(locs, list):
                        continue
                    for loc in locs:
                        if not isinstance(loc, dict):
                            continue
                        fname = loc.get('file_name') or loc.get('file', '')
                        lineno = loc.get('line_number') or loc.get('line')
                        if fname and isinstance(lineno, int) and lineno > 0:
                            locations_by_file.setdefault(fname, []).append(lineno)
            except (json.JSONDecodeError, TypeError):
                pass

        # Extract inline comments directly from the quoted format
        comments = _extract_comments_from_quoted_reply(inline_review)
        if not comments:
            continue

        # Resolve comment positions against the real diff, then fall back
        # to structured finding locations for any still-unpositioned comments.
        sha = commit_shas[idx]
        ecode, real_diff = b4.git_run_command(topdir, ['diff', f'{sha}~1', sha])
        if ecode == 0:
            _resolve_comment_positions(real_diff, comments)
            if locations_by_file:
                _apply_findings_locations(comments, locations_by_file)

        # Store in tracking
        patch_reviews: Dict[str, Any] = patch.setdefault('reviews', {})
        entry = patch_reviews.setdefault(reviewer_email, {})
        entry['name'] = reviewer_name
        entry['comments'] = comments
        entry['sashiko-review-id'] = review_id
        encoded_msgid = urllib.parse.quote(pmsgid, safe='@')
        entry['provenance'] = f'{base_url}/#/message/{encoded_msgid}'
        integrated += 1

    if not integrated:
        return False

    if branch:
        save_tracking_ref(topdir, branch, cover_text, tracking)
    else:
        save_tracking(topdir, cover_text, tracking)
    logger.info('Integrated sashiko inline reviews for %d patch(es)', integrated)
    return True


def _integrate_followup_inline_comments(
    topdir: str,
    cover_text: str,
    tracking: Dict[str, Any],
    commit_shas: List[str],
    patches: List[Dict[str, Any]],
    branch: Optional[str] = None,
) -> bool:
    """Extract inline diff comments from mailing-list follow-up messages.

    Reads the cached thread mbox from the ``thread-blob`` stored in
    tracking, parses each follow-up that quotes patch diff content,
    and converts the ``> ``-quoted reply into positioned inline comments
    using the same pipeline as agent and sashiko reviews.

    Returns True if any comments were integrated.
    """
    series = tracking['series']
    blob_sha = series.get('thread-blob', '')
    if not blob_sha:
        return False

    mbox_bytes = b4.review.tracking.get_thread_mbox(topdir, blob_sha)
    if not mbox_bytes:
        return False

    cover_msgid = series.get('header-info', {}).get('msgid', '')
    followup_comments = b4.review.tracking._parse_msgs_to_followup_comments(
        liblore.utils.split_mbox(mbox_bytes), cover_msgid, patches
    )

    integrated = 0

    for display_idx, fc_list in followup_comments.items():
        # display_idx 0 = cover letter (skip), 1..N = patches
        if display_idx < 1:
            continue
        idx = display_idx - 1
        if idx >= len(patches) or idx >= len(commit_shas):
            continue

        patch = patches[idx]

        for fc in fc_list:
            body = fc.get('body', '')
            if not body:
                continue

            # Only process follow-ups that quote diff content
            if '> diff --git ' not in body and '> @@ ' not in body:
                continue

            reviewer_email = fc.get('fromemail', '')
            if not reviewer_email:
                continue

            # Skip if we already integrated this exact follow-up message
            fc_msgid = fc.get('msgid', '')
            existing_entry = patch.get('reviews', {}).get(reviewer_email, {})
            if fc_msgid and existing_entry.get('followup-msgid') == fc_msgid:
                continue

            comments = _extract_comments_from_quoted_reply(body)
            if not comments:
                continue

            # Resolve positions against the real diff
            sha = commit_shas[idx]
            ecode, real_diff = b4.git_run_command(topdir, ['diff', f'{sha}~1', sha])
            if ecode == 0:
                _resolve_comment_positions(real_diff, comments)

            # Store under the follow-up author's identity
            reviewer_name = fc.get('fromname', '')
            patch_reviews: Dict[str, Any] = patch.setdefault('reviews', {})
            entry = patch_reviews.setdefault(reviewer_email, {})
            if reviewer_name:
                entry['name'] = reviewer_name
            entry['comments'] = comments
            if fc_msgid:
                entry['followup-msgid'] = fc_msgid
                entry['provenance'] = f'{b4.LINKADDR}/{fc_msgid}'
            integrated += 1

    if not integrated:
        return False

    if branch:
        save_tracking_ref(topdir, branch, cover_text, tracking)
    else:
        save_tracking(topdir, cover_text, tracking)
    logger.info('Integrated inline comments from %d follow-up message(s)', integrated)
    return True


def _render_quoted_diff_with_comments(
    diff_text: str,
    all_reviews: Dict[str, Dict[str, Any]],
    my_email: str,
    commit_msg: str = '',
) -> str:
    """Render a diff as ``> ``-quoted text with comments for the editor.

    Every diff line is prefixed with ``> `` (email reply convention).
    The maintainer's own comments are inserted as unquoted text.
    External reviewer comments are prefixed with ``| `` (read-only).

    If *commit_msg* is provided, the commit message body (after the
    subject line) is quoted before the diff content with comments
    interleaved.

    This format is unambiguous: ``> `` lines are diff, ``| `` lines
    are external comments, and bare lines are the maintainer's own
    comments.  Blank lines and trailing whitespace cannot corrupt it.
    """
    # Collect all comments keyed by (path, line).
    # Own comments: (text, '', '') — rendered unquoted.
    # External comments: (text, attribution, provenance) — rendered with | prefix.
    entries: List[Tuple[Dict[str, Any], str, str]] = []
    if my_email in all_reviews:
        for c in all_reviews[my_email].get('comments', []):
            entries.append((c, '', ''))
    for addr in sorted(all_reviews):
        if addr == my_email:
            continue
        rev_data = all_reviews[addr]
        name = rev_data.get('name', addr)
        attr = f'{name} <{addr}>'
        prov = rev_data.get('provenance', '')
        for c in rev_data.get('comments', []):
            entries.append((c, attr, prov))

    comment_map: Dict[Tuple[str, int], List[Tuple[str, str, str]]] = {}
    for c, attr, prov in entries:
        key = (c['path'], c['line'])
        comment_map.setdefault(key, []).append((c['text'], attr, prov))

    diff_lines = diff_text.splitlines()
    result: List[str] = [
        '# Insert your comments under the relevant lines of code. Lines',
        '# starting with "> " are quoted diff context and lines starting',
        '# with "| " are comments from other reviewers. Everything you type',
        '# is sent exactly as written and where you wrote it -- including any',
        '# trailer (Reviewed-by:, etc.) you place inline. The only change made',
        '# on send is to drop quoted diff left below your last comment; quoted',
        '# context you keep above a comment is sent as-is. Trim freely.',
        '#',
    ]
    current_a_file = ''
    current_b_file = ''
    a_line = 0
    b_line = 0
    hunk_re = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')

    def _insert(key: Tuple[str, int]) -> None:
        for text, attr, prov in comment_map.pop(key, []):
            if result:
                result.append('')
            if attr:
                # External reviewer comment — render with | prefix
                result.append(f'| {attr}:')
                result.append('|')
                for cline in text.splitlines():
                    result.append(f'| {cline}' if cline else '|')
                if prov:
                    result.append('|')
                    result.append(f'| via: {prov}')
            else:
                # Own comment — render as unquoted text
                for cline in text.splitlines():
                    result.append(cline)
            result.append('')

    # Quote commit message body with interleaved comments
    if commit_msg:
        msg_lines = _strip_subject(commit_msg)
        # Preamble comments (line 0)
        _insert((COMMIT_MESSAGE_PATH, 0))
        for msg_lineno, mline in enumerate(msg_lines, start=1):
            result.append(f'> {mline}')
            _insert((COMMIT_MESSAGE_PATH, msg_lineno))
        # Sweep up any remaining :message comments (e.g. on the
        # separator line between commit message and diff, which the
        # parser counts but _strip_subject does not include).
        remaining_msg = [k for k in comment_map if k[0] == COMMIT_MESSAGE_PATH]
        for key in sorted(remaining_msg):
            _insert(key)
        if msg_lines and diff_lines:
            result.append('>')

    for line in diff_lines:
        result.append(f'> {line}')

        if line.startswith(('--- a/', '--- /dev/null')):
            current_a_file = line[4:]
        elif line.startswith(('+++ b/', '+++ /dev/null')):
            current_b_file = line[4:]
        elif line.startswith(('--- ', '+++ ', 'diff --git ')):
            pass
        else:
            hm = hunk_re.match(line)
            if hm:
                a_line = int(hm.group(1))
                b_line = int(hm.group(2))
            elif line.startswith('-'):
                _insert((current_a_file, a_line))
                a_line += 1
            elif line.startswith('+'):
                _insert((current_b_file, b_line))
                b_line += 1
            elif line.startswith(' ') or line == '':
                _insert((current_b_file, b_line))
                a_line += 1
                b_line += 1

    return '\n'.join(result) + '\n'


def _extract_editor_comments(
    edited_text: str, diff_text: str = '', message_text: str = ''
) -> List[Dict[str, Any]]:
    """Extract comments from the quoted-diff editor format.

    Strips instruction lines (``#`` prefix) and external reviewer
    comments (``|`` prefix), then delegates to
    :func:`_extract_comments_from_quoted_reply` which handles the
    ``> ``-quoted diff with unquoted comment format.

    When *diff_text* is provided, runs :func:`_resolve_comment_positions`
    to correct diff comment positions when the user has trimmed quoted
    content.  When *message_text* is provided (the cover letter or commit
    message), runs :func:`_resolve_message_positions` to re-anchor
    ``:message`` comments after editor re-wrapping of the quoted body.
    """
    filtered: List[str] = []
    for line in edited_text.splitlines():
        # Strip editor instruction lines
        if line.startswith('#'):
            continue
        # Strip external reviewer comment blocks (| prefix)
        if line.startswith('|'):
            continue
        filtered.append(line)
    comments = _extract_comments_from_quoted_reply(
        '\n'.join(filtered), capture_preamble=True
    )
    if comments:
        if diff_text:
            _resolve_comment_positions(diff_text, comments)
        if message_text:
            _resolve_message_positions(message_text, comments)
    return comments


def _build_reply_from_comments(
    diff_text: str,
    comments: List[Dict[str, Any]],
    review_trailers: List[str],
    commit_msg: Optional[str] = None,
) -> str:
    """Build an email reply body from review comments.

    For each hunk that has comments, quotes the hunk up to the commented
    line, inserts the comment unquoted, and continues quoting. Hunks
    without comments are skipped. Trailers are appended at the end.

    If commit_msg is provided, the commit message body (minus the subject
    line) is quoted before the diff content.
    """
    # Build lookup: (path, line) -> list of comment texts
    comment_map: Dict[Tuple[str, int], List[str]] = {}
    for c in comments:
        key = (c['path'], c['line'])
        comment_map.setdefault(key, []).append(c['text'])

    # Determine which files+hunks have comments so we can skip the rest
    comment_files: set[str] = set()
    for c in comments:
        # Strip a/ or b/ prefix to get the plain path for file-level matching
        comment_files.add(c['path'])

    result: List[str] = []

    # Insert preamble comments (line 0 = before commit message)
    preamble_key = (COMMIT_MESSAGE_PATH, 0)
    for ctext in comment_map.pop(preamble_key, []):
        result.extend(ctext.splitlines())
        result.append('')

    # Optionally quote the commit message body (minus the subject line),
    # only including context around lines that have comments.
    if commit_msg:
        msg_lines = _strip_subject(commit_msg)
        # Find which message lines have comments
        msg_comment_indices: List[int] = []
        msg_insert_map: Dict[int, List[str]] = {}
        for msg_lineno in range(1, len(msg_lines) + 1):
            msg_key = (COMMIT_MESSAGE_PATH, msg_lineno)
            texts = comment_map.pop(msg_key, [])
            if texts:
                msg_comment_indices.append(msg_lineno)
                msg_insert_map[msg_lineno] = texts
        # Sweep up remaining :message comments beyond the body
        # (e.g. on the separator line between commit message and diff)
        remaining_msg = [k for k in comment_map if k[0] == COMMIT_MESSAGE_PATH]
        for key in sorted(remaining_msg):
            texts = comment_map.pop(key, [])
            if texts:
                msg_comment_indices.append(key[1])
                msg_insert_map[key[1]] = texts
        if msg_comment_indices:
            prev_quoted = 0  # last msg line index (1-based) emitted
            for comment_lineno in msg_comment_indices:
                window_start = max(
                    prev_quoted + 1, comment_lineno - _REPLY_CONTEXT_LINES
                )
                # Clamp to valid msg_lines range
                window_start = min(window_start, len(msg_lines) + 1)
                comment_quote_end = min(comment_lineno, len(msg_lines))
                skipped = window_start - (prev_quoted + 1)
                if skipped >= 10:
                    result.append('> [...]')
                elif skipped > 0:
                    for i in range(prev_quoted + 1, window_start):
                        result.append(f'> {msg_lines[i - 1]}')
                for i in range(window_start, comment_quote_end + 1):
                    result.append(f'> {msg_lines[i - 1]}')
                prev_quoted = max(comment_lineno, comment_quote_end)
                for ctext in msg_insert_map[comment_lineno]:
                    result.append('')
                    result.extend(ctext.splitlines())
                    result.append('')
            # Add separator only if diff comments remain to render
            if diff_text.strip() and comment_map:
                result.append('>')

    # Walk the diff, collecting hunks and inserting comments
    diff_lines = diff_text.splitlines()
    current_a_file = ''
    current_b_file = ''
    a_line = 0
    b_line = 0
    hunk_re = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@')

    # First pass: figure out which hunks have comments by collecting
    # hunk line ranges and checking against comment_map keys
    # We'll do this in a single pass instead, buffering hunk lines
    # and flushing them only if the hunk had comments.

    hunk_buf: List[str] = []
    hunk_comments: List[Tuple[int, str]] = []
    in_hunk = False
    _hunk_has_file_header = False
    file_header_buf: List[str] = []
    file_header_emitted = False

    def _flush_hunk() -> None:
        nonlocal file_header_emitted
        if not hunk_comments:
            hunk_buf.clear()
            return
        # Emit file header if not yet done
        if not file_header_emitted and file_header_buf:
            if result:
                result.append('>')
            for fh_line in file_header_buf:
                result.append(f'> {fh_line}')
            file_header_emitted = True
        # Build hunk output with comments inserted, limiting context to
        # _REPLY_CONTEXT_LINES lines above each comment.  The @@ header
        # (hunk_buf[0]) is always emitted; gaps between context windows
        # are represented by a skip marker or the lines themselves when few.
        insert_map: Dict[int, List[str]] = {}
        for idx, text in hunk_comments:
            insert_map.setdefault(idx, []).append(text)
        prev_quoted = -1  # last hunk_buf index that was emitted
        for comment_idx in sorted(insert_map):
            # Emit the @@ header exactly once
            if prev_quoted < 0:
                result.append(f'> {hunk_buf[0]}')
                prev_quoted = 0
            # Context window: up to _REPLY_CONTEXT_LINES before the comment,
            # but never re-emit lines already quoted by a prior comment.
            window_start = max(prev_quoted + 1, comment_idx - _REPLY_CONTEXT_LINES)
            skipped = window_start - (prev_quoted + 1)
            if skipped >= 10:
                result.append(f'> [ ... skip {skipped} lines ... ]')
            elif skipped > 0:
                # Few enough lines that it's cleaner to just include them.
                for i in range(prev_quoted + 1, window_start):
                    result.append(f'> {hunk_buf[i]}')
            for i in range(window_start, comment_idx + 1):
                result.append(f'> {hunk_buf[i]}')
            prev_quoted = comment_idx
            for text in insert_map[comment_idx]:
                result.append('')
                result.append(text)
                result.append('')
        hunk_buf.clear()
        hunk_comments.clear()

    for line in diff_lines:
        if line.startswith('diff --git '):
            _flush_hunk()
            in_hunk = False
            file_header_buf.clear()
            file_header_buf.append(line)
            file_header_emitted = False
            continue

        if line.startswith(('--- a/', '--- /dev/null')):
            current_a_file = line[4:]
            file_header_buf.append(line)
            continue
        if line.startswith(('+++ b/', '+++ /dev/null')):
            current_b_file = line[4:]
            file_header_buf.append(line)
            continue
        if line.startswith(('--- ', '+++ ')):
            file_header_buf.append(line)
            continue

        hm = hunk_re.match(line)
        if hm:
            _flush_hunk()
            a_line = int(hm.group(1))
            b_line = int(hm.group(2))
            in_hunk = True
            hunk_buf.append(line)
            continue

        if not in_hunk:
            # Could be index/mode lines etc., add to file header
            file_header_buf.append(line)
            continue

        buf_idx = len(hunk_buf)
        hunk_buf.append(line)

        if line.startswith('-'):
            key = (current_a_file, a_line)
            for text in comment_map.pop(key, []):
                hunk_comments.append((buf_idx, text))
            a_line += 1
        elif line.startswith('+'):
            key = (current_b_file, b_line)
            for text in comment_map.pop(key, []):
                hunk_comments.append((buf_idx, text))
            b_line += 1
        elif line.startswith(' ') or line == '':
            key = (current_b_file, b_line)
            for text in comment_map.pop(key, []):
                hunk_comments.append((buf_idx, text))
            a_line += 1
            b_line += 1

    _flush_hunk()

    # Append trailers
    if review_trailers:
        # Strip trailing blank lines before adding trailers
        while result and not result[-1].strip():
            result.pop()
        result.append('')
        for t in review_trailers:
            result.append(t)

    return '\n'.join(result)


def _own_message_entries(
    msgs: List[Any], myaddr: str
) -> List[Dict[str, Optional[str]]]:
    """Return flag entries for messages whose From exactly matches *myaddr*.

    Exact address match only: mail the maintainer sent from a different
    address, or whose From a list rewrote (DMARC munging), is deliberately
    left alone.  Replies sent through b4 itself are covered separately by
    message-id at send time (messages.mark_outgoing_seen), so a missed
    match here only means the message stays unread — never the reverse.
    """
    entries: List[Dict[str, Optional[str]]] = []
    myaddr = myaddr.lower()
    for msg in msgs:
        fromaddr = email.utils.parseaddr(str(msg.get('From', '')))[1]
        if not fromaddr or fromaddr.lower() != myaddr:
            continue
        msgid = b4.LoreMessage.get_clean_msgid(msg)
        if not msgid:
            continue
        msg_date: Optional[str] = None
        dval = msg.get('Date')
        if dval:
            try:
                msg_date = email.utils.parsedate_to_datetime(str(dval)).isoformat()
            except Exception:
                pass
        entries.append({'msgid': msgid, 'msg_date': msg_date})
    return entries


def _prev_thread_msgids(topdir: str, change_id: str) -> Optional[Set[str]]:
    """Msgids from the previously stored thread blob, or None if unavailable.

    None means "no reliable previous snapshot" — callers must treat that
    as "cannot tell which messages are new" and skip any accounting that
    depends on it.
    """
    branch = REVIEW_BRANCH_PREFIX + change_id
    if not b4.git_branch_exists(topdir, branch):
        return None
    try:
        _, tracking = load_tracking(topdir, branch)
    except (SystemExit, Exception):
        return None
    blob_sha = tracking.get('series', {}).get('thread-blob', '')
    if not blob_sha:
        return None
    mbox_bytes = b4.review.tracking.get_thread_mbox(topdir, blob_sha)
    if not mbox_bytes:
        return None
    msgids: Set[str] = set()
    for m in liblore.utils.split_mbox(mbox_bytes):
        mid = b4.LoreMessage.get_clean_msgid(m)
        if mid:
            msgids.add(mid)
    return msgids


def update_series_tracking(
    series: Dict[str, Any],
    identifier: str,
    linkmask: str,
    topdir: Optional[str] = None,
) -> Dict[str, Any]:
    """Fetch thread, discover revisions, update trailers for one series.

    Returns {'new_revisions': int, 'new_trailers': int,
             'error': Optional[str]}.
    """
    result: Dict[str, Any] = {
        'new_revisions': 0,
        'new_trailers': 0,
        'error': None,
    }

    change_id = series.get('change_id', '')
    current_rev = series.get('revision', 1)
    status = series.get('status', 'new')

    try:
        msgs = retrieve_series_messages(series, identifier)
    except liblore.OperationCancelledError:
        raise
    except (LookupError, Exception) as ex:
        result['error'] = str(ex)
        return result

    # Save thread messages before get_extra_series adds cross-version
    # messages — used below for message count + thread blob updates.
    thread_msgs = list(msgs)

    # Discover other revisions.  On the first update (no revisions in DB
    # yet) search both directions so older versions are recorded.  On
    # subsequent updates only look forward — re-fetching older versions
    # every time is very expensive for prolific senders whose subject
    # lines match many unrelated series.
    if b4.can_network:
        try:
            _conn = b4.review.tracking.get_db(identifier)
            _known = set(
                r['revision']
                for r in b4.review.tracking.get_revisions(_conn, change_id)
            )
            _conn.close()
        except Exception:
            _known = set()

        msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
        if current_rev > 1 and not _known:
            msgs = b4.mbox.get_extra_series(
                msgs, direction=-1, wantvers=list(range(1, current_rev)), nocache=True
            )

    lmbx = b4.LoreMailbox()
    for msg in msgs:
        lmbx.add_message(msg)

    # A newly-discovered version may have been posted improperly threaded, so
    # it shows up incomplete here.  If the author also posted a properly
    # threaded resend, prefer it — this self-heals the common case on any
    # machine without needing the rethread relationship synced from elsewhere.
    if b4.can_network:
        extra_threaded: List[Any] = []
        for v in sorted(x for x in lmbx.series if x > current_rev):
            if getattr(lmbx.series[v], 'complete', True):
                continue
            try:
                found = b4.find_threaded_copy(msgs, revision=v)
            except Exception as ex:
                logger.debug('find_threaded_copy failed for v%d: %s', v, ex)
                found = None
            if found:
                extra_threaded.extend(found[1])
        if extra_threaded:
            seen = {b4.LoreMessage.get_clean_msgid(m) for m in msgs}
            for m in extra_threaded:
                mid = b4.LoreMessage.get_clean_msgid(m)
                if mid and mid not in seen:
                    msgs.append(m)
                    seen.add(mid)
            lmbx = b4.LoreMailbox()
            for msg in msgs:
                lmbx.add_message(msg)

    # Re-check attestation on every update (key imports, policy changes, etc.)
    # Try the tracked revision first; fall back to the latest available.
    lser_att = lmbx.get_series(current_rev, sloppytrailers=False)
    if lser_att is None:
        lser_att = lmbx.get_series(sloppytrailers=False)
    if lser_att is not None:
        att = check_series_attestation(lser_att)
        b4.review.tracking.update_attestation(identifier, change_id, current_rev, att)

    # Record all discovered revisions in SQLite through the shared helper, so
    # a revision is identified by its cover letter when the author sent one.
    # Recording them here by hand picked the first present patch of the raw,
    # never-get_series()'d LoreSeries — which has no cover injected into
    # patches[0] — and left the series titled after patch 1/N (bug 8bb6e4c).
    try:
        conn = b4.review.tracking.get_db(identifier)
        new_revs = b4.review.tracking._record_discovered_revisions(
            conn, change_id, lmbx, str(linkmask)
        )
        result['new_revisions'] = len(new_revs)
        try:
            b4.review.tracking.realign_series_subject(
                conn, change_id, current_rev, lmbx
            )
        except Exception as ex:
            logger.warning('Could not realign series subject: %s', ex)
        conn.close()
    except Exception as ex:
        logger.warning('Could not record revisions: %s', ex)

    newer_vers = sorted(v for v in lmbx.series if v > current_rev)

    # Note: [u]pdate deliberately does NOT mutate a waiting series' status.
    # Discovering a newer version records it (above) and lights up the
    # has_newer signal, which the UI surfaces and the upgrade action acts on
    # explicitly; status is only ever changed by an explicit user action, not
    # as a side effect of updating.  Do not re-add auto-promotion here.

    # Update follow-up trailers if the series has a review branch.  A
    # branch that is checked out (in any worktree) is left strictly
    # alone: the maintainer is working on it right there, possibly
    # mid-rebase, and rewriting its tip under them risks corrupting
    # their work.  The database updates above have already happened;
    # the tracking commit catches up on the next sweep after the
    # branch is no longer checked out.
    branch = f'b4/review/{change_id}'
    update_branch = bool(topdir) and status in (
        'reviewing',
        'replied',
        'partial',
        'waiting',
    )
    if update_branch and topdir and b4.git_branch_checked_out(topdir, branch):
        logger.debug('%s is checked out, leaving the branch alone', branch)
        result['checked_out'] = True
        update_branch = False
    if update_branch and topdir:
        wantver = current_rev

        try:
            cover_text, tracking = load_tracking(topdir, branch)
        except SystemExit:
            result['error'] = f'Could not load tracking data from {branch}'
            return result

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

        # Update newer-versions in tracking data
        if newer_vers:
            t_series['newer-versions'] = newer_vers
        else:
            t_series.pop('newer-versions', None)

        lser = lmbx.get_series(wantver, sloppytrailers=False, codereview_trailers=True)
        if lser is None:
            result['error'] = f'Could not find series v{wantver} in retrieved messages'
            return result

        # Collect fresh cover followups
        clmsg = (
            lser.patches[0] if lser.has_cover and lser.patches[0] is not None else None
        )
        new_cover_followups = _collect_followups(clmsg, linkmask) if clmsg else list()

        # Collect fresh per-patch followups
        new_patch_followups: List[Any] = []
        for pmsg in lser.patches[1:]:
            if pmsg is None:
                new_patch_followups.append([])
                continue
            new_patch_followups.append(_collect_followups(pmsg, linkmask))

        # Count new trailers by diffing against existing data
        def _trailer_set(followups: List[Any]) -> Set[str]:
            s: Set[str] = set()
            for fu in followups:
                for t in fu.get('trailers', []):
                    s.add(t.lower())
            return s

        old_cover = _trailer_set(tracking.get('followups', []))
        new_cover = _trailer_set(new_cover_followups)
        new_count = len(new_cover - old_cover)

        patches = tracking.get('patches', [])
        for i, new_fu in enumerate(new_patch_followups):
            if i < len(patches):
                old_set = _trailer_set(patches[i].get('followups', []))
            else:
                old_set = set()
            new_count += len(_trailer_set(new_fu) - old_set)

        result['new_trailers'] = new_count

        # Update tracking data
        tracking['followups'] = new_cover_followups
        for i, new_fu in enumerate(new_patch_followups):
            if i < len(patches):
                patches[i]['followups'] = new_fu

        # Refresh the portable revisions catalog so any newly discovered
        # version (and rethread patch lists) travels with the branch on push.
        try:
            _conn = b4.review.tracking.get_db(identifier)
            tracking['known-revisions'] = b4.review.tracking.build_known_revisions(
                _conn, change_id
            )
            _conn.close()
        except Exception as ex:
            logger.debug('Could not refresh known-revisions: %s', ex)

        # Save using ref update (no checkout needed)
        if not save_tracking_ref(topdir, branch, cover_text, tracking):
            result['error'] = 'Error saving tracking data'
            return result

    # Auto-mark the maintainer's own messages as read.  Two passes:
    # replies sent through b4 were already flagged Seen at send time and
    # match here by message-id; anything whose From exactly matches the
    # configured identity is flagged Seen now.  Anything else (e.g. the
    # maintainer's other address) is deliberately not auto-flagged.
    # seen_bump counts new-to-the-thread messages that are already read,
    # so the unread badge below never lights up for them.
    seen_bump = 0
    if thread_msgs and change_id:
        mconn = None
        try:
            from b4.review import messages

            mconn = messages.get_db()
            _, myaddr = b4.get_mailfrom()
            if myaddr:
                own_entries = _own_message_entries(msgs, myaddr)
                if own_entries:
                    messages.set_flags_bulk(mconn, own_entries, 'Seen')
            if topdir:
                prev_msgids = _prev_thread_msgids(topdir, change_id)
                if prev_msgids is not None:
                    new_msgids = []
                    for msg in thread_msgs:
                        mid = b4.LoreMessage.get_clean_msgid(msg)
                        if mid and mid not in prev_msgids:
                            new_msgids.append(mid)
                    flags_map = messages.get_flags_bulk(mconn, new_msgids)
                    seen_bump = sum(
                        1
                        for mid in new_msgids
                        if 'Seen' in flags_map.get(mid, '').split()
                    )
        except Exception as ex:
            logger.debug('Could not auto-mark own messages as read: %s', ex)
        finally:
            if mconn is not None:
                mconn.close()

    # Update message count and thread blob from the already-fetched
    # thread messages.  No status filtering here: the thread is already
    # in hand, and the unread badge must keep working after a series
    # moves past reviewing (accepted/thanked), or the maintainer never
    # sees follow-up discussion on applied series.
    if thread_msgs and change_id:
        try:
            conn = b4.review.tracking.get_db(identifier)
            b4.review.tracking.update_message_count_from_msgs(
                conn,
                change_id,
                current_rev,
                thread_msgs,
                topdir=topdir,
                seen_bump=seen_bump,
            )
            conn.close()
            result['counts_updated'] = True
        except Exception as ex:
            logger.warning('Could not update message counts: %s', ex)

    return result


def _get_update_lock_path(identifier: str) -> str:
    """Path of the flock guarding a tracking-update sweep.

    Per-identifier, next to the sqlite database it protects; shared by
    the TUI's update-all and ``b4 review cron``.
    """
    reviewdir = b4.review.tracking.get_review_data_dir()
    return os.path.join(reviewdir, f'{identifier}.update.lock')


def update_all_tracking(
    identifier: str,
    linkmask: str,
    topdir: Optional[str] = None,
    series_list: Optional[List[Dict[str, Any]]] = None,
    progress_cb: Optional[Callable[[int, int, str], None]] = None,
    cancel_cb: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
    """Rescan branches, then fetch threads and update all tracked series.

    The whole sweep holds a per-identifier flock, so a cron run and the
    TUI's update-all can never write over each other; if another sweep
    is already running, b4.LockHeldError is raised immediately.

    *series_list* limits the sweep to the given series (as returned by
    tracking.get_all_tracked_series); when None, all non-snoozed series
    are updated.  *progress_cb*, when provided, is called as
    ``progress_cb(completed, total, subject)`` around each series.
    *cancel_cb* is polled between series; returning True stops the
    sweep.

    Returns a summary dict with keys: series_checked, series_updated,
    errors, gone, followup_updated, error_details, cancelled.
    """
    result: Dict[str, Any] = {
        'series_checked': 0,
        'series_updated': 0,
        'errors': 0,
        'gone': 0,
        'followup_updated': 0,
        'checked_out_skipped': 0,
        'error_details': [],
        'cancelled': False,
    }

    with b4.lockfile_nb(_get_update_lock_path(identifier)):
        # Rescan local review branches first so the DB reflects current
        # on-disk state before the network update runs.
        if topdir:
            try:
                rescan = b4.review.tracking.rescan_branches(identifier, topdir)
                result['gone'] = rescan.get('gone', 0)
            except Exception as ex:
                logger.warning('Pre-update rescan failed: %s', ex)

        if series_list is None:
            series_list = [
                s
                for s in b4.review.tracking.get_all_tracked_series(identifier)
                if s.get('status') not in ('snoozed', 'archived')
            ]

        total = len(series_list)
        for i, series in enumerate(series_list):
            if cancel_cb and cancel_cb():
                result['cancelled'] = True
                break

            subject = series.get('subject', '(no subject)')
            if progress_cb:
                progress_cb(i, total, subject)

            try:
                # Called via the package attribute: it is the established
                # patch seam for tests and TUI callers alike
                r = b4.review.update_series_tracking(
                    series, identifier, linkmask, topdir=topdir
                )
            except liblore.OperationCancelledError:
                result['cancelled'] = True
                break
            result['series_checked'] += 1
            if r.get('new_revisions') or r.get('new_trailers'):
                result['series_updated'] += 1
            if r.get('error'):
                result['errors'] += 1
                submitter = series.get('sender_name', 'unknown')
                result['error_details'].append((submitter, r['error']))
            if r.get('checked_out'):
                result['checked_out_skipped'] += 1
            if r.get('counts_updated'):
                result['followup_updated'] += 1

            if progress_cb:
                progress_cb(i + 1, total, subject)

    return result


# Cron reporting that must survive _quiet_cron suppression (e.g. from a
# progress callback invoked inside a silenced block) goes through this
# child logger; everything it emits is exempt from the filter.
cron_logger = logging.getLogger('b4.review.cron')


class _quiet_cron:
    """Context manager that silences chatty logging during a cron sweep.

    Cron reuses the same code paths as the interactive commands (lore
    lookups, thread analysis, SMTP delivery), which narrate their
    progress -- including at CRITICAL level, which b4 traditionally
    uses for "always show this" output.  That is fine in a terminal,
    but a cron job mails every line to the maintainer.

    With no *threshold*, every record is dropped while the block runs
    (like the TUI's _quiet_worker): use this when failures inside the
    block are collected and re-reported afterwards, as the update
    sweep does.  With a *threshold*, records at or above it still get
    through: the delivery sweep passes WARNING because its warnings
    and criticals are genuine errors, not narration.  The exempt
    ``cron_logger`` always gets through, for reporting from inside the
    block.  A --debug run bypasses the filter entirely.
    """

    def __init__(self, threshold: Optional[int] = None) -> None:
        self._threshold = threshold

    def __enter__(self) -> '_quiet_cron':
        class _Filter(logging.Filter):
            def __init__(self, threshold: Optional[int]) -> None:
                super().__init__()
                self._threshold = threshold

            def filter(self, record: logging.LogRecord) -> bool:
                if record.name == cron_logger.name:
                    return True
                if self._threshold is None:
                    return False
                return record.levelno >= self._threshold

        self._filt = _Filter(self._threshold)
        # Filters go on the handlers so that records propagated from
        # child loggers (b4.*, liblore) are caught too.  A handler at
        # DEBUG level means a --debug run: leave it alone.
        self._handlers = [h for h in b4.logger.handlers if h.level > logging.DEBUG]
        for h in self._handlers:
            h.addFilter(self._filt)
        return self

    def __exit__(self, *exc: object) -> None:
        for h in self._handlers:
            h.removeFilter(self._filt)


def _cron_update(identifier: str, topdir: Optional[str]) -> None:
    """Run the update portion of a cron sweep: wake snoozes, update all."""
    # Wake expired snoozes first, so newly-woken series are included in
    # the update sweep below.
    woken = b4.review.tracking.auto_wake_snoozed(identifier, topdir)
    if woken:
        logger.info('Woke %s snoozed series', woken)

    config = b4.get_main_config()
    linkmask = str(config.get('linkmask', 'https://lore.kernel.org/r/%s'))
    try:
        # Suppress everything: per-series failures are collected into
        # result['error_details'] and re-reported below, and anything
        # fatal escapes as an exception
        with _quiet_cron():
            result = update_all_tracking(identifier, linkmask, topdir=topdir)
    except b4.LockHeldError:
        logger.info('Series update already running elsewhere, skipping')
        return

    if result['gone']:
        logger.warning('%s review branch(es) are gone', result['gone'])
    if result['series_updated']:
        logger.info(
            'Updated %s of %s tracked series',
            result['series_updated'],
            result['series_checked'],
        )
    else:
        logger.debug('Checked %s series, no updates', result['series_checked'])
    if result.get('checked_out_skipped'):
        # Normal while the maintainer works on a branch — debug, not
        # cron mail.  The branch catches up on a later sweep.
        logger.debug(
            '%s branch(es) checked out, left alone', result['checked_out_skipped']
        )
    for submitter, error in result['error_details']:
        logger.warning('Update error (%s): %s', submitter, error)


def _cron_deliver(
    identifier: str, topdir: Optional[str], patatt_sign: bool, check_only: bool
) -> None:
    """Run the queue-delivery portion of a cron sweep."""
    if b4.ty.get_queued_count(topdir=topdir) == 0:
        if check_only:
            logger.info('Thanks queue is empty')
        else:
            logger.debug('Thanks queue is empty')
        return

    def _progress(completed: int, total: int, status: str) -> None:
        # Runs inside a _quiet_cron block, so report through the
        # exempt cron_logger
        if status.startswith('Checking: '):
            return
        if check_only:
            cron_logger.info('%s', status)
        elif status.startswith(('Not yet visible:', 'Check failed:')):
            # Pending is normal operation; don't generate cron mail
            cron_logger.debug('%s', status)
        elif status.startswith('Send failed:'):
            # process_queue already logged a warning
            pass
        else:
            cron_logger.info('Delivered: %s', status)

    try:
        # WARNING threshold: SMTP progress chatter is dropped, but
        # "Failed to send" warnings still make it to the cron mail
        with _quiet_cron(logging.WARNING):
            delivered, pending, _series = b4.ty.process_queue(
                patatt_sign=patatt_sign,
                progress_cb=_progress,
                check_only=check_only,
                identifier=identifier,
                topdir=topdir,
            )
    except b4.LockHeldError:
        logger.info('Queue delivery already running elsewhere, skipping')
        return

    if check_only:
        logger.info(
            '%s message(s) would be delivered, %s still pending', delivered, pending
        )
    elif delivered:
        logger.info('Delivered %s message(s), %s still pending', delivered, pending)
    else:
        logger.debug('Nothing delivered; %s still pending', pending)


def _cron_run_one(
    identifier: str,
    topdir: Optional[str],
    *,
    do_update: bool,
    do_deliver: bool,
    dryrun: bool,
    patatt_sign: bool,
) -> None:
    """Run the requested cron tasks for a single project."""
    if do_update:
        if dryrun:
            logger.info('--dry-run: skipping series update')
        else:
            _cron_update(identifier, topdir)
    if do_deliver:
        _cron_deliver(identifier, topdir, patatt_sign=patatt_sign, check_only=dryrun)


def cmd_cron(cmdargs: argparse.Namespace) -> None:
    """Run periodic review maintenance non-interactively.

    With no task flags, runs everything: snooze wake-up, branch rescan,
    series update, and queue delivery.  Designed for cron/systemd
    timers or a git-push wrapper: quiet when there is nothing to
    report, and exits 0 when another run already holds a lock.

    ``-i`` (repeatable) limits the run to the named projects.  With no
    ``-i``, runs on the enrolled repository containing the current
    directory, or on every known tracking database when not inside
    one.  A project whose repository path is not known (never enrolled
    or run from within its repository with this b4 version) gets a
    database-only update and no queue delivery.
    """
    do_update = cmdargs.cron_update
    do_deliver = cmdargs.cron_deliver
    if not do_update and not do_deliver:
        do_update = do_deliver = True
    dryrun = bool(cmdargs.dryrun)
    # Unlike the TUI, signing is opt-in: cron typically runs from a timer
    # process that has no access to agents or passphrase-protected keys
    patatt_sign = bool(cmdargs.sign)

    cwd_topdir = b4.git_get_toplevel()
    cwd_id = b4.review.tracking.get_repo_identifier(cwd_topdir) if cwd_topdir else None
    if cwd_id and not b4.review.tracking.db_exists(cwd_id):
        cwd_id = None
    if cwd_topdir and cwd_id:
        # Keep the identifier→repository mapping fresh whenever cron
        # runs from within an enrolled repository
        b4.review.tracking.record_repo_path(cwd_id, cwd_topdir)

    requested = cmdargs.identifier
    if not requested and cwd_id:
        # Inside an enrolled repository, bare cron runs on that project
        requested = [cwd_id]
    projects: List[Tuple[str, Optional[str]]]
    if not requested or '__all__' in requested:
        projects = b4.review.tracking.get_known_projects()
        if not projects:
            logger.critical('No tracking databases found.')
            sys.exit(1)
    else:
        projects = []
        for one_id in requested:
            if not b4.review.tracking.db_exists(one_id):
                logger.critical('No tracking database for identifier: %s', one_id)
                sys.exit(1)
            if one_id == cwd_id:
                one_topdir = cwd_topdir
            else:
                one_topdir = b4.review.tracking.get_repo_path(one_id)
            projects.append((one_id, one_topdir))

    for one_id, one_topdir in projects:
        logger.debug(
            'Processing project: %s (%s)',
            one_id,
            one_topdir or 'no repository path',
        )
        if do_deliver and one_topdir is None:
            logger.warning(
                'No repository path known for %s; skipping queue delivery',
                one_id,
            )
        try:
            # Apply this project's repository-local git configuration
            # (b4.*, user.*, sendemail.*) for the duration of its run
            b4.setup_config(cmdargs, topdir=one_topdir)
            _cron_run_one(
                one_id,
                one_topdir,
                do_update=do_update,
                do_deliver=bool(do_deliver and one_topdir),
                dryrun=dryrun,
                patatt_sign=patatt_sign,
            )
        except Exception as ex:
            logger.warning('Cron run failed for %s: %s', one_id, ex)
    # Restore the configuration of the starting directory
    b4.setup_config(cmdargs)


def cmd_tui(cmdargs: argparse.Namespace) -> None:
    try:
        from b4.review_tui._entry import run_tracking_tui
    except ImportError as e:
        logger.critical('The TUI requires the %s library.', e.name)
        logger.critical('Install it with: pip install b4[tui]')
        sys.exit(1)

    identifier = b4.review.tracking.resolve_identifier(cmdargs)
    if not identifier:
        logger.critical('Could not determine project identifier.')
        logger.critical('First run "b4 review enroll" or specify -i identifier')
        sys.exit(1)

    # Keep the identifier→repository mapping fresh for cron -i __all__
    tui_topdir = b4.git_get_toplevel()
    if tui_topdir and b4.review.tracking.get_repo_identifier(tui_topdir) == identifier:
        b4.review.tracking.record_repo_path(identifier, tui_topdir)

    if not b4.review.tracking.db_exists(identifier):
        topdir = b4.git_get_toplevel()
        if topdir and b4.review.tracking.get_repo_identifier(topdir) == identifier:
            # The metadata.json exists but the DB is gone — recreate it.
            logger.info('Recreating missing database for %s', identifier)
            conn = b4.review.tracking.init_db(identifier)
            conn.close()
            b4.review.tracking.rescan_branches(identifier, topdir)
        else:
            logger.critical('Project not enrolled: %s', identifier)
            logger.critical('Enroll with: b4 review enroll')
            sys.exit(1)

    run_tracking_tui(
        identifier,
        email_dryrun=cmdargs.email_dryrun,
        no_sign=cmdargs.no_sign,
        no_mouse=cmdargs.no_mouse,
    )


def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]:
    """Common setup for review tui.

    Returns dict with: topdir, branch, cover_text, tracking, series,
    patches, base_commit, commit_shas, commit_subjects, sha_map,
    abbrev_len, default_identity, usercfg, cover_subject_clean
    """
    topdir = b4.git_get_toplevel()
    if not topdir:
        logger.critical('Not in a git repository.')
        sys.exit(1)

    branch = cmdargs.branch
    if branch is None:
        ecode, out = b4.git_run_command(topdir, ['symbolic-ref', '--short', 'HEAD'])
        if ecode > 0:
            logger.critical('Could not determine current branch (detached HEAD?)')
            sys.exit(1)
        branch = out.strip()

    if not branch.startswith(REVIEW_BRANCH_PREFIX):
        logger.critical(
            'Branch %s does not look like a review branch (expected prefix %s)',
            branch,
            REVIEW_BRANCH_PREFIX,
        )
        sys.exit(1)

    # No checkout needed — all git operations use explicit refs.
    # Shell suspend and agent runs check out the branch on demand.
    cover_text, tracking = load_tracking(topdir, branch)
    series = tracking['series']
    patches = tracking['patches']
    base_commit = series['base-commit']

    # Determine abbreviation length from core.abbrev (default: git decides)
    ecode, out = b4.git_run_command(topdir, ['rev-parse', '--short', branch])
    abbrev_len = len(out.strip()) if ecode == 0 else 7

    # Get ordered commit SHAs (excluding the tracking commit at tip)
    # Use first-patch-commit to skip any prerequisite commits
    first_patch = series.get('first-patch-commit', '')
    if first_patch:
        range_spec = f'{first_patch}~1..{branch}~1'
    else:
        range_spec = f'{base_commit}..{branch}~1'
    ecode, out = b4.git_run_command(topdir, ['rev-list', '--reverse', range_spec])
    if ecode > 0 or not out.strip():
        logger.critical('Unable to list patch commits')
        sys.exit(1)
    commit_shas = out.strip().splitlines()

    # Get commit subjects
    ecode, out = b4.git_run_command(
        topdir, ['log', '--reverse', '--format=%s', range_spec]
    )
    if ecode > 0:
        logger.critical('Unable to get commit subjects')
        sys.exit(1)
    commit_subjects = out.strip().splitlines()

    # Build mapping: short SHA -> (full SHA, patch index)
    sha_map: Dict[str, Tuple[str, int]] = {}
    for idx, full_sha in enumerate(commit_shas):
        short_sha = full_sha[:abbrev_len]
        sha_map[short_sha] = (full_sha, idx)

    # Parse cover subject (strip email prefixes like [PATCH v2 0/3])
    cover_subject = series.get('subject', '')
    cover_subject_clean = b4.LoreSubject(cover_subject).subject
    if not cover_subject_clean:
        cover_subject_clean = (
            cover_text.split('\n', maxsplit=1)[0] if cover_text else '(no subject)'
        )

    # Get user identity for trailers (needed throughout the loop)
    usercfg = b4.get_user_config()
    user_name = usercfg.get('name', 'Unknown')
    user_email = usercfg.get('email', 'unknown@example.com')
    default_identity = f'{user_name} <{user_email}>'

    # Integrate agent reviews from .git/b4-review/
    _integrate_agent_reviews(
        topdir, cover_text, tracking, commit_shas, patches, branch=branch
    )

    # Integrate sashiko inline reviews (if configured)
    _integrate_sashiko_reviews(
        topdir, cover_text, tracking, commit_shas, patches, branch=branch
    )

    # Integrate inline comments from mailing-list follow-up messages
    _integrate_followup_inline_comments(
        topdir, cover_text, tracking, commit_shas, patches, branch=branch
    )

    # Ensure the plain-text thread-context-blob exists for the AI agent.
    # Runs only when thread-blob was stored before this feature existed
    # (migration) or is being seen for the first time this session.
    change_id = series.get('change-id')
    if (
        change_id
        and series.get('thread-blob')
        and not series.get('thread-context-blob')
    ):
        b4.review.tracking.ensure_thread_context_blob(
            topdir, change_id, series, patches
        )

    # Record where HEAD is so ReviewApp can put it back if it checks out the
    # review branch for shell/agent operations.  A detached HEAD is a starting
    # point too, so remember the commit alongside the branch name.
    ecode, out = b4.git_run_command(topdir, ['symbolic-ref', '--short', 'HEAD'])
    current_branch = out.strip() if ecode == 0 else None

    return {
        'topdir': topdir,
        'branch': branch,
        'original_branch': current_branch,
        'original_head': b4.git_head_restore_args(topdir),
        'cover_text': cover_text,
        'tracking': tracking,
        'series': series,
        'patches': patches,
        'base_commit': base_commit,
        'commit_shas': commit_shas,
        'commit_subjects': commit_subjects,
        'sha_map': sha_map,
        'abbrev_len': abbrev_len,
        'default_identity': default_identity,
        'usercfg': usercfg,
        'cover_subject_clean': cover_subject_clean,
    }


def _ensure_trailers_in_body(body: str, trailers: List[str]) -> str:
    """Ensure all trailers appear in the body text.

    Checks the body for existing trailers using LoreMessage.find_trailers
    and appends any missing ones before the signature block (if present).
    """
    if not trailers:
        return body
    found, _ = b4.LoreMessage.find_trailers(body, followup=True)
    found_set = set()
    for lt in found:
        found_set.add(lt.as_string().lower())
    missing = [t for t in trailers if t.lower() not in found_set]
    if not missing:
        return body
    # Append missing trailers before the signature (if any)
    parts = body.split('\n-- \n', maxsplit=1)
    main_body = parts[0].rstrip()
    main_body += '\n\n' + '\n'.join(missing)
    if len(parts) > 1:
        main_body += '\n\n-- \n' + parts[1]
    return main_body


def _normalize_line_endings(text: str) -> str:
    """Canonicalize CRLF/CR line endings to LF.

    New editor round-trips are normalized in b4.edit_in_editor, but reply
    buffers stored by older versions kept whatever bytes the editor wrote,
    so tracking data may still carry CRLF.  Everything downstream assumes
    LF-only text.
    """
    return text.replace('\r\n', '\n').replace('\r', '\n')


def _parse_reply_trailers(buffer: str) -> List[str]:
    """Parse trailers from the maintainer's own (bare) lines in a buffer.

    Quoted diff (``> ``), external-reviewer (``| ``) and instruction or
    directive (``#``) lines are excluded.  Detection uses ``followup=True``
    so that prose such as ``stable: without that fix`` is not mistaken for a
    trailer.  Used purely to build the display index — never to construct the
    outgoing email, which carries the trailers inline in the buffer itself.
    """
    bare = [
        line for line in buffer.splitlines() if not line.startswith(('>', '|', '#'))
    ]
    found, _others = b4.LoreMessage.find_trailers('\n'.join(bare), followup=True)
    return [lt.as_string() for lt in found]


_BARE_TRAILER_RE = re.compile(r'^\s*([\w-]+):\s')

# The trailer names the TUI trailer menu offers.  The menu (and
# _sync_reply_trailers below) may only ever add or remove these; any other
# trailer in a hand-edited reply buffer is the maintainer's own content.
TRAILER_MENU_NAMES = ['Acked-by', 'Reviewed-by', 'Tested-by', 'NACKed-by']


def _insert_trailer_in_reply(reply_text: str, trailer: str) -> str:
    """Append *trailer* as its own bare line in the reply buffer.

    The line is added at the end of the maintainer's content, before any
    trailing ``-- `` signature, grouped with any trailers already there.  The
    maintainer can move it afterwards — placement is just a sensible default.
    """
    lines = _normalize_line_endings(reply_text).split('\n')
    sig_idx = next((i for i, ln in enumerate(lines) if ln == '-- '), len(lines))
    head = lines[:sig_idx]
    tail = lines[sig_idx:]
    while head and not head[-1].strip():
        head.pop()
    # Separate from non-trailer content with a blank line so the trailers
    # form their own block.
    if head and not _BARE_TRAILER_RE.match(head[-1]):
        head.append('')
    head.append(trailer)
    return '\n'.join(head + tail)


def _remove_trailer_from_reply(reply_text: str, name: str) -> str:
    """Remove bare lines that are a *name* trailer from the reply buffer.

    Quoted (``> ``), external (``| ``) and instruction (``#``) lines are left
    untouched so a ``Foo-by:`` inside the quoted diff is never disturbed.
    When the removed line sat alone between blank lines, one of the blanks
    is dropped with it so no doubled gap is left behind.
    """
    lname = name.lower()
    lines = _normalize_line_endings(reply_text).split('\n')
    out: List[str] = []
    i = 0
    while i < len(lines):
        line = lines[i]
        if not line.startswith(('>', '|', '#')):
            m = _BARE_TRAILER_RE.match(line)
            if m and m.group(1).lower() == lname:
                if (
                    out
                    and not out[-1].strip()
                    and i + 1 < len(lines)
                    and not lines[i + 1].strip()
                ):
                    i += 1
                i += 1
                continue
        out.append(line)
        i += 1
    return '\n'.join(out)


def _sync_reply_trailers(reply_text: str, selected: List[str], identity: str) -> str:
    """Sync the menu-managed trailers in a hand-edited reply buffer.

    *selected* is the list of trailer names confirmed in the trailer menu.
    Only the names the menu offers (TRAILER_MENU_NAMES) are ever added or
    removed; any other trailer the maintainer typed into the buffer
    (``Fixes:``, ``Cc:``, ``Suggested-by:``, ...) is their own content and
    stays exactly where they put it.  NACKed-by supersedes the other managed
    trailers.  Returns the updated buffer, line endings normalized.
    """
    reply_text = _normalize_line_endings(reply_text)
    desired = ['NACKed-by'] if 'NACKed-by' in selected else list(selected)
    desired_lower = {n.lower() for n in desired}
    managed_lower = {n.lower() for n in TRAILER_MENU_NAMES}
    current_lower = {
        t.split(':', 1)[0].strip().lower() for t in _parse_reply_trailers(reply_text)
    }
    for lname in current_lower:
        if lname in managed_lower and lname not in desired_lower:
            reply_text = _remove_trailer_from_reply(reply_text, lname)
    for name in desired:
        if name.lower() not in current_lower:
            reply_text = _insert_trailer_in_reply(reply_text, f'{name}: {identity}')
    return reply_text


def _trim_quoted_reply(buffer: str) -> str:
    """Prepare a hand-edited reply buffer for sending.

    The maintainer's text is sent as written.  The only changes are to strip
    b4's own scaffolding — the ``#`` instruction header and ``| `` read-only
    external-reviewer lines — and to drop the run of quoted diff at the very
    bottom of the message that has no comment after it, the usual courtesy of
    trimming quoted material below your last reply.  Quoted context the
    maintainer left in place anywhere above their final comment is kept
    exactly as written; nothing is collapsed, reordered, or relocated.
    """
    lines = [line for line in buffer.splitlines() if not line.startswith(('#', '|'))]
    # Drop the trailing quoted/blank run below the maintainer's last comment.
    end = len(lines)
    while end > 0 and (lines[end - 1].startswith('>') or not lines[end - 1].strip()):
        end -= 1
    return '\n'.join(lines[:end])


def _get_reply_template() -> Optional[str]:
    """Load the user's review reply template, if one is configured.

    Follows the same mechanism as the b4-ty thanks templates: the
    ``b4.review-reply-template`` config option points at a template
    file which is read via :func:`b4.read_template` (lines starting
    with ``#`` are removed).  Returns None when no template is
    configured or the configured file does not exist.
    """
    config = b4.get_main_config()
    tptpath = config.get('review-reply-template')
    if not tptpath or not isinstance(tptpath, str):
        return None
    try:
        return b4.read_template(tptpath)
    except FileNotFoundError:
        logger.critical(
            'ERROR: review-reply-template says to use %s, but it does not exist',
            tptpath,
        )
        return None


def _apply_reply_template(
    body: str,
    series: Dict[str, Any],
    header_info: Dict[str, Any],
    orig_subject: str,
) -> str:
    """Wrap a composed review reply body in the user's template.

    The template must contain the ``${reply}`` placeholder, which
    expands to *body* (the quoted patch context, the maintainer's
    comments and any trailers).  Substitution uses
    :class:`string.Template` with the same safe-substitute semantics
    as the b4-ty thanks templates, so unknown placeholders are left
    as-is.  Returns *body* unchanged when no template is configured
    or the template has no ``${reply}`` placeholder.
    """
    tpt = _get_reply_template()
    if not tpt:
        return body
    if '${reply}' not in tpt and '$reply' not in tpt:
        logger.warning('review-reply-template has no ${reply} placeholder, ignoring it')
        return body
    fromname = str(series.get('fromname') or '')
    fromemail = str(series.get('fromemail') or '')
    if fromname.split():
        firstname = fromname.split()[0]
    else:
        firstname = fromemail.split('@')[0]
    myname, myemail = b4.get_mailfrom()
    params = {
        'reply': body,
        'fromname': fromname,
        'firstname': firstname,
        'fromemail': fromemail,
        'subject': orig_subject,
        'sentdate': str(header_info.get('sentdate') or ''),
        'msgid': str(header_info.get('msgid') or ''),
        'myname': myname,
        'myemail': myemail,
        'signature': b4.get_email_signature(),
    }
    return Template(tpt).safe_substitute(params).strip('\n')


def _build_review_email(
    series: Dict[str, Any],
    patch_meta: Optional[Dict[str, Any]],
    review: Dict[str, Any],
    cover_text: str,
    topdir: str,
    commit_sha: Optional[str],
) -> Optional[email.message.EmailMessage]:
    """Build an EmailMessage for a single review entry (cover or patch).

    Returns None if there is nothing to send.
    """
    trailers = review.get('trailers', [])
    reply_text = review.get('reply', '')
    comments = review.get('comments', [])

    if not trailers and not reply_text and not comments:
        return None

    # Determine header info and subject
    if patch_meta is not None:
        header_info = patch_meta.get('header-info', {})
        orig_subject = patch_meta.get('title', '')
    else:
        header_info = series.get('header-info', {})
        orig_subject = series.get('subject', '')

    if not header_info.get('msgid'):
        logger.debug('No message-id for %s, skipping', orig_subject)
        return None

    # Build attribution line for auto-generated replies
    orig_date = header_info.get('sentdate', '')
    orig_author = f'{series.get("fromname", "")} <{series.get("fromemail", "")}>'
    attribution = f'On {orig_date}, {orig_author} wrote:'

    # Build body
    if reply_text:
        # The maintainer's edited buffer is authoritative: send it as the
        # source of truth, trimming only quoted diff left below their last
        # comment and never relocating prose or trailers.
        body = _trim_quoted_reply(reply_text).strip()
    elif comments and patch_meta is None:
        # Cover letter with structured comments — build reply from cover text
        reply_body = _build_reply_from_comments(
            '', comments, trailers, commit_msg=cover_text
        )
        # Add blank line before preamble, but not before quoted content
        sep = '\n\n' if not reply_body.startswith('>') else '\n'
        body = attribution + sep + reply_body
    elif comments and commit_sha and topdir:
        # Auto-generate reply from inline review comments
        ecode, commit_msg = b4.git_run_command(
            topdir, ['show', '--format=%B', '--no-patch', commit_sha]
        )
        if ecode > 0:
            logger.warning('Could not get commit message for %s', commit_sha)
            return None
        ecode, diff_text = b4.git_run_command(
            topdir, ['diff', f'{commit_sha}~1', commit_sha]
        )
        if ecode > 0:
            logger.warning('Could not get diff for %s', commit_sha)
            return None
        reply_body = _build_reply_from_comments(
            diff_text, comments, trailers, commit_msg=commit_msg
        )
        sep = '\n\n' if not reply_body.startswith('>') else '\n'
        body = attribution + sep + reply_body
    else:
        # Trailer-only reply: quote the first paragraph of the original
        if patch_meta is not None and commit_sha and topdir:
            ecode, commit_msg = b4.git_run_command(
                topdir, ['show', '--format=%B', '--no-patch', commit_sha]
            )
            if ecode == 0 and commit_msg.strip():
                # Strip the subject line (already in Subject: Re: header)
                cm_body = '\n'.join(_strip_subject(commit_msg))
                body = (
                    attribution
                    + '\n'
                    + b4.make_quote(cm_body)
                    + '\n\n'
                    + '\n'.join(trailers)
                    if cm_body
                    else attribution
                    + '\n'
                    + b4.make_quote(cover_text)
                    + '\n\n'
                    + '\n'.join(trailers)
                )
            else:
                body = (
                    attribution
                    + '\n'
                    + b4.make_quote(cover_text)
                    + '\n\n'
                    + '\n'.join(trailers)
                )
        else:
            body = (
                attribution
                + '\n'
                + b4.make_quote(cover_text)
                + '\n\n'
                + '\n'.join(trailers)
            )

    # Ensure all trailers appear in the body.  Skip this for the verbatim
    # reply path: the maintainer's buffer already carries the trailers
    # wherever they placed them, and re-adding them here would duplicate or
    # relocate them.
    if not reply_text:
        body = _ensure_trailers_in_body(body, trailers)

    # Wrap the body in the user's reply template, if configured
    body = _apply_reply_template(body, series, header_info, orig_subject)

    # Append signature if not already present
    if '\n-- \n' not in body:
        signature = b4.get_email_signature()
        body = body.rstrip('\n') + '\n\n-- \n' + signature

    # Construct the EmailMessage
    user_name, user_email = b4.get_mailfrom()

    msg = email.message.EmailMessage()
    msg.set_payload(body, charset='utf-8')

    subject = orig_subject
    if not subject.lower().startswith('re:'):
        subject = f'Re: {subject}'
    msg['Subject'] = subject
    msg['From'] = b4.format_addrs([(user_name, user_email)], clean=False)

    # Build reply headers.  When the user has explicitly edited the
    # To/Cc fields via the ToCcScreen, honour their choices as-is.
    # Otherwise, apply standard reply-composition: Reply-To or From
    # as To, original To folded into Cc.
    if header_info.get('tocc-edited'):
        if header_info.get('to'):
            msg['To'] = header_info['to']
        if header_info.get('cc'):
            msg['Cc'] = header_info['cc']
    else:
        if header_info.get('reply-to'):
            to_addrs = email.utils.getaddresses([header_info['reply-to']])
        else:
            fromname = series.get('fromname', '')
            fromemail = series.get('fromemail', '')
            to_addrs = [(fromname, fromemail)]
        orig_to = email.utils.getaddresses([header_info.get('to', '')])
        orig_cc = email.utils.getaddresses([header_info.get('cc', '')])
        cc_addrs = orig_to + orig_cc

        deduped_to, deduped_cc = b4.LoreMessage.make_reply_addrs(to_addrs, cc_addrs)
        msg['To'] = b4.format_addrs(deduped_to, clean=False)
        if deduped_cc:
            msg['Cc'] = b4.format_addrs(deduped_cc, clean=False)
    if header_info.get('bcc'):
        msg['Bcc'] = header_info['bcc']
    msg['In-Reply-To'] = f'<{header_info["msgid"]}>'
    references = header_info.get('references', '')
    if references:
        msg['References'] = f'{references} <{header_info["msgid"]}>'
    else:
        msg['References'] = f'<{header_info["msgid"]}>'
    msg['Date'] = email.utils.formatdate(localtime=True)
    msg['Message-Id'] = b4.make_msgid(idstring='b4-review', allow_custom_msgid_cmd=True)

    return msg


def collect_review_emails(
    series: Dict[str, Any],
    patches: List[Dict[str, Any]],
    cover_text: str,
    topdir: str,
    commit_shas: List[str],
) -> List[email.message.EmailMessage]:
    """Collect review emails to send for the given series.

    Only sends the maintainer's own reviews, not agent or other
    reviewers' data.
    """
    usercfg = b4.get_user_config()
    my_email = str(usercfg.get('email', 'unknown@example.com'))
    msgs: List[email.message.EmailMessage] = []

    # Cover letter review (maintainer only)
    cover_review = series.get('reviews', {}).get(my_email, {})
    if (
        cover_review
        and cover_review.get('patch-state') != 'skip'
        and cover_review.get('sent-revision') is None
    ):
        msg = _build_review_email(series, None, cover_review, cover_text, topdir, None)
        if msg is not None:
            msgs.append(msg)

    # Per-patch reviews (maintainer only)
    for idx, patch_meta in enumerate(patches):
        patch_review = patch_meta.get('reviews', {}).get(my_email, {})
        if not patch_review or patch_review.get('patch-state') == 'skip':
            continue
        if patch_review.get('sent-revision') is not None:
            continue
        commit_sha = commit_shas[idx] if idx < len(commit_shas) else None
        msg = _build_review_email(
            series, patch_meta, patch_review, cover_text, topdir, commit_sha
        )
        if msg is not None:
            msgs.append(msg)

    return msgs


# Patchwork backlog gating: a neglected project can accumulate tens of
# thousands of outstanding patches (linux-kselftest had 28k+), and fetching
# them all means dozens of serial API round-trips plus a giant client-side
# grouping pass that hangs the TUI.  When the backlog is large we only pull a
# recent window.  The count we can probe cheaply is *patches*, not series (the
# state filter only exists on the patches endpoint); since a series has one or
# more patches, the patch count is an upper bound on the series count, so
# gating on it is conservative -- it never under-counts a large backlog.
# The threshold is high enough that busy-but-healthy subsystems (which can see
# hundreds of series a day) don't trip it; only a genuine pile-up does.
PW_BACKLOG_GATE = 1000
PW_WINDOW_DAYS = 30
# Patchwork's hard cap on results per page; also our fetch page size.  Patches
# arrive a page at a time, so this is the granularity of fetch progress.
PW_PER_PAGE = 250


class PwFetchResult(NamedTuple):
    """Outcome of a Patchwork series fetch.

    *series* is the (possibly windowed) listing; *outstanding* is the total
    number of outstanding patches probed before fetching; *window_days* is the
    day window the fetch was limited to, or ``None`` when the full backlog was
    retrieved.
    """

    series: List[Dict[str, Any]]
    outstanding: int
    window_days: Optional[int]


def _pw_count_outstanding(
    pses: requests.Session, patches_url: str, params: Dict[str, Any]
) -> int:
    """Cheaply count the patches matching *params*.

    Requests a single item and reads the page number from the ``last``
    pagination link: with ``per_page=1`` that page number is the total count,
    so we learn the backlog size in one tiny request instead of walking every
    page.  Falls back to counting the returned rows when there is no ``last``
    link (i.e. zero or one match).
    """
    probe = dict(params)
    probe['per_page'] = '1'
    resp = pses.get(patches_url, params=probe)
    if resp.status_code != 200:
        raise RuntimeError(f'Patchwork API error: {resp.status_code} {resp.reason}')
    last_url = resp.links.get('last', {}).get('url')
    if last_url:
        qs = urllib.parse.parse_qs(urllib.parse.urlparse(last_url).query)
        try:
            return int(qs['page'][0])
        except (KeyError, IndexError, ValueError):
            pass
    return len(resp.json())


def pw_fetch_series(
    pwkey: str,
    pwurl: str,
    pwproj: str,
    progress_cb: Optional[Callable[[int, int], None]] = None,
) -> PwFetchResult:
    """Fetch action-required series from a Patchwork instance.

    Returns a :class:`PwFetchResult`; its *series* is a list of series dicts
    sorted by date (newest first).

    A two-step gate keeps a neglected project from hanging the UI: first probe
    how many patches are outstanding; if that exceeds ``PW_BACKLOG_GATE``, only
    fetch the most recent ``PW_WINDOW_DAYS`` days instead of the whole history.

    *progress_cb*, if given, is called as ``progress_cb(fetched, total)`` once
    the total to fetch is known and after each page of patches arrives, so a
    caller (e.g. the TUI) can drive a progress bar.
    """
    pses, api_url = b4.get_patchwork_session(pwkey, pwurl)
    patches_url = '/'.join((api_url, 'patches'))
    params: Dict[str, Any] = {
        'project': pwproj,
        'state': ['new', 'under-review'],
        'order': '-date',
        'per_page': str(PW_PER_PAGE),
        'archived': 'false',
    }

    # Step 1: how big is the outstanding backlog?
    outstanding = _pw_count_outstanding(pses, patches_url, params)

    # Step 2: if it's large, restrict the fetch to a recent window.
    window_days: Optional[int] = None
    total = outstanding
    if outstanding > PW_BACKLOG_GATE:
        window_days = PW_WINDOW_DAYS
        since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
            days=window_days
        )
        params['since'] = since.strftime('%Y-%m-%dT%H:%M:%S')
        logger.debug(
            'Patchwork backlog is %d patches (> %d); limiting to last %d days',
            outstanding,
            PW_BACKLOG_GATE,
            window_days,
        )
        # The windowed set is much smaller than the all-time backlog; re-probe
        # so the progress total reflects what we'll actually fetch.
        total = _pw_count_outstanding(pses, patches_url, params)

    if progress_cb is not None:
        progress_cb(0, total)

    all_patches: List[Dict[str, Any]] = []
    url: Optional[str] = patches_url
    while url:
        resp = pses.get(url, params=params)
        if resp.status_code != 200:
            raise RuntimeError(f'Patchwork API error: {resp.status_code} {resp.reason}')
        all_patches.extend(resp.json())
        url = resp.links.get('next', {}).get('url')
        # Only pass params on the first request; pagination URLs include them
        params = {}
        if progress_cb is not None:
            progress_cb(len(all_patches), total)

    # CI check priority: higher value = worse status (worst wins per series)
    _check_priority = {'pending': 0, 'success': 1, 'warning': 2, 'fail': 3}

    # Group patches by series ID
    series_map: Dict[int, Dict[str, Any]] = {}
    for patch in all_patches:
        patch_id = patch.get('id')
        for s in patch.get('series', []):
            sid = s.get('id')
            if not sid:
                continue
            if sid not in series_map:
                submitter = patch.get('submitter', {})
                delegate = patch.get('delegate') or {}
                series_map[sid] = {
                    'id': sid,
                    'name': s.get('name', '(no subject)'),
                    'submitter': submitter.get(
                        'name', submitter.get('email', 'Unknown')
                    ),
                    'submitter_email': submitter.get('email', ''),
                    'delegate': delegate.get('username', ''),
                    'date': patch.get('date', ''),
                    'state': patch.get('state', 'new'),
                    'patch_ids': [],
                    'patch_names': {},
                    'msgid': patch.get('msgid', ''),
                    'check': patch.get('check', 'pending'),
                }
            else:
                # Keep earliest date
                existing_date = series_map[sid]['date']
                patch_date = patch.get('date', '')
                if patch_date and (not existing_date or patch_date < existing_date):
                    series_map[sid]['date'] = patch_date
                # Prefer under-review over new
                if patch.get('state') == 'under-review':
                    series_map[sid]['state'] = 'under-review'
                # Aggregate CI check: worst status wins
                patch_check = patch.get('check', 'pending')
                cur_check = series_map[sid].get('check', 'pending')
                if _check_priority.get(patch_check, 0) > _check_priority.get(
                    cur_check, 0
                ):
                    series_map[sid]['check'] = patch_check
            if patch_id:
                series_map[sid]['patch_ids'].append(patch_id)
                series_map[sid]['patch_names'][patch_id] = patch.get('name', '')

    series = sorted(series_map.values(), key=lambda s: s.get('date', ''), reverse=True)
    return PwFetchResult(
        series=series, outstanding=outstanding, window_days=window_days
    )


def pw_fetch_states(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]]:
    """Return available patch states.

    The Patchwork REST API does not expose a /states/ endpoint, so we
    use the same hardcoded defaults as git-pw.  The slug doubles as
    the display name with hyphens replaced by spaces and title-cased.

    Returns a list of state dicts with 'slug' and 'name' keys.
    """
    default_slugs = (
        'new',
        'under-review',
        'accepted',
        'rejected',
        'rfc',
        'not-applicable',
        'changes-requested',
        'awaiting-upstream',
        'superseded',
        'deferred',
    )
    return [{'slug': s, 'name': s.replace('-', ' ').title()} for s in default_slugs]


def pw_fetch_checks(
    pwkey: str, pwurl: str, patch_ids: List[int]
) -> List[Dict[str, Any]]:
    """Fetch CI check details for a list of patch IDs.

    Returns a flat list of check dicts, each augmented with 'patch_id'.
    """
    pses, api_url = b4.get_patchwork_session(pwkey, pwurl)
    all_checks: List[Dict[str, Any]] = []
    for pid in patch_ids:
        checks_url = f'{api_url}/patches/{pid}/checks/'
        try:
            resp = pses.get(checks_url)
            if resp.status_code != 200:
                continue
            for check in resp.json():
                check['patch_id'] = pid
                all_checks.append(check)
        except Exception:
            continue
    return all_checks


def pw_set_series_state(
    pwkey: str, pwurl: str, patch_ids: List[int], state: str, archived: bool
) -> Tuple[int, int]:
    """Set state and archived flag on patches by patch ID.

    Returns (success_count, failure_count).
    """
    pses, api_url = b4.get_patchwork_session(pwkey, pwurl)
    patches_url = '/'.join((api_url, 'patches'))
    ok = 0
    fail = 0
    for patch_id in patch_ids:
        patchid_url = '/'.join((patches_url, str(patch_id), ''))
        data = {
            'state': state,
            'archived': archived,
        }
        try:
            rsp = pses.patch(patchid_url, data=data, stream=False)
            rsp.raise_for_status()
            ok += 1
        except Exception as ex:
            logger.debug('Patchwork REST error on patch %s: %s', patch_id, ex)
            fail += 1
    return ok, fail


def pw_update_series_state(
    pw_series_id: int, state: str, archived: bool = False
) -> bool:
    """Update Patchwork state for a series tracked by pw_series_id.

    Looks up pw-key and pw-url from git config, fetches the patch IDs
    for the series, and sets the requested state.  Returns True on
    success (or when Patchwork is not configured), False on failure.
    """
    config = b4.get_main_config()
    pwkey = str(config.get('pw-key', ''))
    pwurl = str(config.get('pw-url', ''))
    if not (pwkey and pwurl):
        # Patchwork not configured, nothing to do
        return True

    try:
        pses, api_url = b4.get_patchwork_session(pwkey, pwurl)
        series_url = '/'.join((api_url, 'series', str(pw_series_id), ''))
        rsp = pses.get(series_url, stream=False)
        rsp.raise_for_status()
        sdata = rsp.json()
        patch_ids = [p['id'] for p in sdata.get('patches', [])]
    except Exception as ex:
        logger.debug('Could not fetch patch IDs for pw series %s: %s', pw_series_id, ex)
        return False

    if not patch_ids:
        return True

    ok, fail = pw_set_series_state(pwkey, pwurl, patch_ids, state, archived)
    if fail:
        logger.warning('Failed to update %d/%d patches in Patchwork', fail, ok + fail)
        return False
    logger.debug('Updated %d patches to state %s in Patchwork', ok, state)
    return True
