#!/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 json
import os
import re
import shutil
import sys
import urllib.parse

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

from typing import Dict, Any, List, Optional, Set, Tuple, Union

logger = b4.logger

REVIEW_MAGIC_MARKER = '--- b4-review-tracking ---'
REVIEW_BRANCH_PREFIX = 'b4/review/'
COMMIT_MESSAGE_PATH = ':message'
# 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 _should_promote_waiting(newer_vers: List[int],
                            previously_known: Set[int]) -> bool:
    """Decide whether a waiting series should be promoted to reviewing.

    Only promotes when at least one of the newer versions was not
    already known before this update cycle.  This prevents a broken
    version (e.g. v2 that fails to apply) from repeatedly waking the
    series after the maintainer puts it back into waiting.
    """
    return any(v not in previously_known for v in newer_vers)


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 _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:
        raise LookupError(f'Series version {wantver} not found in retrieved messages')
    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') -> 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 current branch for potential restore on error
    current_branch: Optional[str] = None
    ecode, out = b4.git_run_command(topdir, ['symbolic-ref', '--short', 'HEAD'])
    if ecode == 0:
        current_branch = out.strip()

    # 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)
        # Restore previous branch
        if current_branch:
            b4.git_run_command(topdir, ['checkout', current_branch], logstderr=True)
        b4.git_run_command(topdir, ['branch', '-D', branch_name], logstderr=True)
        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')
        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'
                         'NOTE: No cover letter provided by the author.')
    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()
    tracking: Dict[str, Any] = {
        'series': {
            'identifier': identifier,
            'status': status,
            'revision': lser.revision,
            'change-id': lser.change_id or branch_name.removeprefix(REVIEW_BRANCH_PREFIX),
            '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,
        },
        'followups': cover_followups,
        'patches': patches_meta,
    }

    # 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())
        # Restore previous branch
        if current_branch:
            b4.git_run_command(topdir, ['checkout', current_branch], logstderr=True)
        b4.git_run_command(topdir, ['branch', '-D', branch_name], logstderr=True)
        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 == 'show-info':
        cmd_show_info(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.
    """
    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 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 _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', or '' (no state).
    Only 'done' and 'skip' are stored explicitly; other states are derived.
    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'):
        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'
    # Check for external reviewer comments
    my_email = str(usercfg.get('email', ''))
    all_reviews = target.get('reviews', {})
    if any(addr != my_email and rev.get('comments')
           for addr, rev in all_reviews.items()):
        return 'external'
    return ''


def _set_patch_state(target: Dict[str, Any], usercfg: b4.ConfigDictT,
                     state: str) -> None:
    """Store an explicit patch state ('done', 'skip', 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 _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 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]],
) -> bool:
    """Read review files from .git/b4-review/<HEAD-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 HEAD, changing
    its SHA so the directory no longer matches on the next run.

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

    # Resolve HEAD SHA
    ecode, out = b4.git_run_command(topdir, ['rev-parse', 'HEAD'])
    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 HEAD, changing its SHA so the
    # directory is not re-consumed on the next run.
    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]],
) -> 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

        # 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
        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 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

    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]],
) -> 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(
        b4.mailsplit_bytes(mbox_bytes, os.path.join(topdir, '.git', 'b4-followup-tmp')),
        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

    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] = [
        '# Add your code comments below. There is no need to trim or delete',
        '# any existing content -- just 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.',
        '# The final email will be reformatted automatically to include only',
        '# the sections that have your comments.',
        '#',
    ]
    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 = '') -> 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 positions when the user has trimmed quoted content.
    """
    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 diff_text and comments:
        _resolve_comment_positions(diff_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 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,
    }

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

    if not message_id:
        result['error'] = 'No message-id for this series'
        return result

    try:
        msgs = _retrieve_messages(message_id)
    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)

    # 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, keeping track of what
    # was already known so we can distinguish genuinely new versions.
    previously_known: Set[int] = set()
    try:
        conn = b4.review.tracking.get_db(identifier)
        previously_known = set(r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id))
        for v in sorted(lmbx.series.keys()):
            v_ser = lmbx.series[v]
            v_msgid = ''
            v_subject = ''
            if hasattr(v_ser, 'patches') and v_ser.patches:
                for p in v_ser.patches:
                    if p is not None:
                        v_msgid = p.msgid
                        v_subject = getattr(p, 'full_subject', '') or getattr(p, 'subject', '')
                        break
            v_link = (linkmask % v_msgid) if v_msgid and '%s' in str(linkmask) else ''
            b4.review.tracking.add_revision(conn, change_id, v, v_msgid, v_subject, v_link)
            if v not in previously_known:
                result['new_revisions'] += 1
        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)

    # Auto-promote waiting series only when a genuinely new revision
    # is discovered — one not already known before this update.  This
    # prevents a broken version (e.g. v2 that fails to apply) from
    # repeatedly waking the series after the maintainer puts it back
    # into waiting.
    if status == 'waiting' and _should_promote_waiting(
            newer_vers, previously_known):
            try:
                conn = b4.review.tracking.get_db(identifier)
                b4.review.tracking.update_series_status(
                    conn, change_id, 'reviewing',
                    revision=series.get('revision'))
                conn.close()
                result['promoted'] = True
            except Exception as ex:
                logger.warning('Could not promote waiting series: %s', ex)

    # Update follow-up trailers if the series has a review branch
    if status in ('reviewing', 'replied', 'waiting') and topdir:
        branch = f'b4/review/{change_id}'
        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

        # 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

    # Update message count and thread blob from the already-fetched
    # thread messages.  This replaces the old separate Stage 2 call to
    # update_message_counts(), avoiding duplicate lore lookups entirely.
    skip_counts = frozenset(('archived', 'accepted', 'thanked', 'snoozed'))
    if status not in skip_counts and 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)
            conn.close()
            result['counts_updated'] = True
        except Exception as ex:
            logger.warning('Could not update message counts: %s', ex)

    return result


def cmd_tui(cmdargs: argparse.Namespace) -> None:
    try:
        import b4.review_tui
    except ImportError:
        logger.critical('The TUI requires the textual library.')
        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)

    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)

    b4.review_tui.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)

    # Ensure we are on the review branch
    ecode, out = b4.git_run_command(topdir, ['symbolic-ref', '--short', 'HEAD'])
    current_branch = out.strip() if ecode == 0 else None
    if current_branch != branch:
        ecode, out = b4.git_run_command(topdir, ['checkout', branch], logstderr=True)
        if ecode > 0:
            logger.critical('Could not check out branch %s', branch)
            logger.critical(out.strip())
            sys.exit(1)

    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', 'HEAD'])
    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..HEAD~1'
    else:
        range_spec = f'{base_commit}..HEAD~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)

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

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

    # 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)

    return {
        'topdir': topdir,
        'branch': branch,
        '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 _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:
        body = 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
    body = _ensure_trailers_in_body(body, trailers)

    # 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'] = f'{user_name} <{user_email}>'

    # 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')

    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':
        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
        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


def pw_fetch_series(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]]:
    """Fetch action-required series from a Patchwork instance.

    Returns a list of series dicts sorted by date (newest first).
    """
    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': '250',
        'archived': 'false',
    }

    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 = {}

    # 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', '')

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


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
