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

import argparse
import email
import email.parser
import email.utils
import json
import os
import re
import sys
from email.message import EmailMessage
from pathlib import Path
from string import Template
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast

import b4

ConfigDictT = b4.ConfigDictT
JsonDictT = Dict[str, Union[str, int, List[Any], Dict[str, Any]]]

logger = b4.logger

DEFAULT_PR_TEMPLATE = (
    """
On ${sentdate}, ${fromname} wrote:
${quote}

Merged, thanks!

${summary}

Best regards,
--"""
    + ' '
    + """
${signature}
"""
)

DEFAULT_AM_TEMPLATE = (
    """
On ${sentdate}, ${fromname} wrote:
${quote}

Applied, thanks!

${summary}

Best regards,
--"""
    + ' '
    + """
${signature}
"""
)

# Used to track commits created by current user
MY_COMMITS: Optional[Dict[str, Tuple[str, str, List[str]]]] = None
# Used to track additional branch info
BRANCH_INFO: Optional[Dict[str, str]] = None


def git_get_merge_id(
    gitdir: Optional[str], commit_id: str, branch: Optional[str] = None
) -> Optional[str]:
    # get merge commit id
    args = ['rev-list', '%s..' % commit_id, '--ancestry-path']
    if branch is not None:
        args += [branch]
    lines = b4.git_get_command_lines(gitdir, args)
    if not len(lines):
        return None
    return lines[-1]


def git_get_rev_diff(gitdir: Optional[str], rev: str) -> Tuple[int, str]:
    args = ['diff', '%s~..%s' % (rev, rev)]
    return b4.git_run_command(gitdir, args)


def git_get_commit_message(gitdir: Optional[str], rev: str) -> Tuple[int, str]:
    args = ['log', '--format=%B', '-1', rev]
    return b4.git_run_command(gitdir, args)


def make_reply(
    reply_template: str,
    jsondata: JsonDictT,
    gitdir: Optional[str],
    cmdargs: argparse.Namespace,
) -> EmailMessage:
    msg = EmailMessage()
    msg['From'] = '%s <%s>' % (jsondata['myname'], jsondata['myemail'])
    excludes = b4.get_excluded_addrs()
    assert isinstance(jsondata['fromname'], str), 'fromname must be a string'
    assert isinstance(jsondata['fromemail'], str), 'fromname must be a string'
    assert isinstance(jsondata['to'], str), 'to must be a string'
    assert isinstance(jsondata['cc'], str), 'cc must be a string'
    assert isinstance(jsondata['myemail'], str), 'msgid must be a string'
    newto = b4.cleanup_email_addrs(
        [(jsondata['fromname'], jsondata['fromemail'])], excludes, gitdir
    )

    # Exclude ourselves and original sender from allto or allcc
    if not cmdargs.metoo:
        excludes.add(jsondata['myemail'])
    excludes.add(jsondata['fromemail'])
    allto = b4.cleanup_email_addrs(
        email.utils.getaddresses([jsondata['to']]), excludes, gitdir
    )
    allcc = b4.cleanup_email_addrs(
        email.utils.getaddresses([jsondata['cc']]), excludes, gitdir
    )

    if newto:
        allto += newto

    msg.add_header('To', b4.format_addrs(allto))
    if allcc:
        msg.add_header('Cc', b4.format_addrs(allcc))
    msg['In-Reply-To'] = '<%s>' % jsondata['msgid']
    if isinstance(jsondata['references'], list) and len(jsondata['references']):
        msg['References'] = '%s <%s>' % (jsondata['references'], jsondata['msgid'])
    else:
        msg['References'] = '<%s>' % jsondata['msgid']

    assert isinstance(jsondata['subject'], str), 'subject must be a string'
    subject = re.sub(r'^Re:\s+', '', jsondata['subject'], flags=re.I)
    if jsondata.get('cherrypick'):
        msg.add_header('Subject', 'Re: (subset) ' + subject)
    else:
        msg.add_header('Subject', 'Re: ' + subject)

    msg['Message-Id'] = b4.make_msgid(idstring='b4-ty', allow_custom_msgid_cmd=True)
    msg['Date'] = email.utils.formatdate(localtime=True)
    body = Template(reply_template).safe_substitute(jsondata)
    msg.set_payload(body, charset='utf-8')
    msg.set_charset('utf-8')

    return msg


def auto_locate_pr(
    gitdir: Optional[str], jsondata: JsonDictT, branch: str
) -> Optional[str]:
    pr_commit_id = jsondata['pr_commit_id']
    assert isinstance(pr_commit_id, str), 'pr_commit_id must be a string'
    logger.debug('Checking %s', jsondata['pr_commit_id'])
    if not b4.git_commit_exists(gitdir, pr_commit_id):
        return None

    onbranches = b4.git_branch_contains(gitdir, pr_commit_id)
    if not len(onbranches):
        logger.debug('%s is not on any branches', pr_commit_id)
        return None
    if branch not in onbranches:
        logger.debug('%s is not on branch %s', pr_commit_id, branch)
        return None

    # Get the merge commit
    merge_commit_id = git_get_merge_id(gitdir, pr_commit_id, branch)
    if not merge_commit_id:
        logger.debug('Could not get a merge commit-id for %s', pr_commit_id)
        return None

    # Check that we are the author of the merge commit
    gitargs = ['show', '--format=%ae', merge_commit_id]
    out = b4.git_get_command_lines(gitdir, gitargs)
    if not out:
        logger.debug('Could not get merge commit author for %s', pr_commit_id)
        return None

    usercfg = b4.get_user_config()
    if usercfg['email'] not in out:
        logger.debug('Merged by a different author, ignoring %s', pr_commit_id)
        logger.debug('Author: %s', out[0])
        return None

    return merge_commit_id


def get_all_commits(
    gitdir: Optional[str],
    branch: str,
    since: str = '1.week',
    committer: Optional[str] = None,
) -> Dict[str, Tuple[str, str, List[str]]]:
    global MY_COMMITS
    if MY_COMMITS is not None:
        return MY_COMMITS

    MY_COMMITS = dict()
    if committer is None:
        usercfg = b4.get_user_config()
        _ce = usercfg.get('email')
        if isinstance(_ce, str):
            committer = _ce
        else:
            logger.critical(
                'No committer email found in user config, please set user.email'
            )
            sys.exit(1)

    gitargs = [
        'log',
        '--committer',
        committer,
        '--no-mailmap',
        '--no-abbrev',
        '--no-decorate',
        '--oneline',
        '--since',
        since,
        branch,
    ]
    lines = b4.git_get_command_lines(gitdir, gitargs)
    if not len(lines):
        logger.debug('No new commits from the current user --since=%s', since)
        return MY_COMMITS

    logger.info('Found %s of your commits since %s', len(lines), since)
    logger.info('Calculating patch hashes, may take a moment...')
    # Get patch hash of each commit
    for line in lines:
        commit_id, subject = line.split(maxsplit=1)
        _ecode, out = git_get_rev_diff(gitdir, commit_id)
        pwhash = b4.LoreMessage.get_patchwork_hash(out)
        logger.debug('phash=%s', pwhash)
        # get all message-id or link trailers
        _ecode, out = git_get_commit_message(gitdir, commit_id)
        matches = re.findall(
            r'^\s*(?:message-id|link):[ \t]+(\S+)\s*$', out, flags=re.I | re.M
        )
        trackers: List[str] = list()
        if matches:
            for tvalue in matches:
                trackers.append(str(tvalue))

        MY_COMMITS[pwhash] = (commit_id, subject, trackers)

    return MY_COMMITS


def auto_locate_series(
    gitdir: Optional[str], jsondata: JsonDictT, branch: str, since: str = '1.week'
) -> List[Tuple[int, Optional[str]]]:
    commits = get_all_commits(gitdir, branch, since)

    patchids = set(commits.keys())
    # We need to find all of them in the commits
    found: List[Tuple[int, Optional[str]]] = list()
    at = 0
    assert isinstance(jsondata['patches'], list), 'patches must be a list'
    for patch in jsondata['patches']:
        at += 1
        logger.debug('Checking %s', patch)
        if patch[1] in patchids:
            logger.debug('Found: %s', patch[0])
            found.append((at, commits[patch[1]][0]))
        else:
            # try to locate by subject
            success = False
            for commit in commits.values():
                if commit[1] == patch[0]:
                    logger.debug('Matched using subject')
                    found.append((at, commit[0]))
                    success = True
                    break

            if success:
                continue

            # try to locate by tracker
            for commit in commits.values():
                if len(patch) > 2 and len(patch[2]) and len(commit[2]):
                    for tracker in commit[2]:
                        if tracker.find(patch[2]) >= 0:
                            logger.debug('Matched using recorded message-id')
                            found.append((at, commit[0]))
                            success = True
                            break
                if success:
                    break

            if not success:
                logger.debug('  Failed to find a match for: %s', patch[0])
                found.append((at, None))

    return found


def set_branch_details(
    gitdir: Optional[str], branch: str, jsondata: JsonDictT, config: ConfigDictT
) -> Tuple[JsonDictT, ConfigDictT]:
    binfo = get_branch_info(gitdir, branch)
    jsondata['branch'] = branch
    for key, val in binfo.items():
        if key == 'b4-treename':
            config['thanks-treename'] = val
        elif key == 'b4-commit-url-mask':
            config['thanks-commit-url-mask'] = val
        elif key == 'b4-pr-template':
            config['thanks-pr-template'] = val
        elif key == 'b4-am-template':
            config['thanks-am-template'] = val
        elif key == 'branch':
            jsondata['branch'] = val

    if 'thanks-treename' in config and isinstance(config['thanks-treename'], str):
        jsondata['treename'] = config['thanks-treename']
    elif 'url' in binfo:
        try:
            # Try to grab the last two chunks of the path
            purl = Path(binfo['url'])
            jsondata['treename'] = os.path.join(purl.parts[-2], purl.parts[-1])
        except Exception:
            # Something went wrong... just use the whole URL
            jsondata['treename'] = binfo['url']
    else:
        jsondata['treename'] = 'local tree'

    return jsondata, config


def generate_pr_thanks(
    gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace
) -> EmailMessage:
    config = b4.get_main_config()
    jsondata, config = set_branch_details(gitdir, branch, jsondata, config)
    thanks_template = DEFAULT_PR_TEMPLATE
    _ctpr = config.get('thanks-pr-template')
    if isinstance(_ctpr, str) and _ctpr:
        # Try to load this template instead
        try:
            thanks_template = b4.read_template(_ctpr)
        except FileNotFoundError:
            logger.critical(
                'ERROR: thanks-pr-template says to use %s, but it does not exist',
                config['thanks-pr-template'],
            )
            sys.exit(2)

    if 'merge_commit_id' not in jsondata:
        assert 'pr_commit_id' in jsondata, 'pr_commit_id must be present in jsondata'
        assert isinstance(jsondata['pr_commit_id'], str), (
            'pr_commit_id must be a string'
        )
        merge_commit_id = git_get_merge_id(gitdir, jsondata['pr_commit_id'])
        if not merge_commit_id:
            logger.critical('Could not get merge commit id for %s', jsondata['subject'])
            logger.critical('Was it actually merged?')
            sys.exit(1)
        jsondata['merge_commit_id'] = merge_commit_id
    # Make a summary
    cidmask = config['thanks-commit-url-mask']
    if not cidmask:
        cidmask = 'merge commit: %s'
    assert isinstance(cidmask, str), 'thanks-commit-url-mask must be a string'
    jsondata['summary'] = cidmask % jsondata['merge_commit_id']
    msg = make_reply(thanks_template, jsondata, gitdir, cmdargs)
    return msg


def generate_am_thanks(
    gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace
) -> EmailMessage:
    global BRANCH_INFO
    BRANCH_INFO = None
    config = b4.get_main_config()
    jsondata, config = set_branch_details(gitdir, branch, jsondata, config)
    thanks_template = DEFAULT_AM_TEMPLATE
    _ctat = config.get('thanks-am-template')
    if isinstance(_ctat, str) and _ctat:
        # Try to load this template instead
        try:
            thanks_template = b4.read_template(_ctat)
        except FileNotFoundError:
            logger.critical(
                'ERROR: thanks-am-template says to use %s, but it does not exist',
                config['thanks-am-template'],
            )
            sys.exit(2)
    if 'commits' not in jsondata:
        commits = auto_locate_series(gitdir, jsondata, branch, cmdargs.since)
    else:
        assert isinstance(jsondata['commits'], list), 'commits must be a list'
        commits = jsondata['commits']

    cidmask = config['thanks-commit-url-mask']
    if not cidmask:
        cidmask = 'commit: %s'
    assert isinstance(cidmask, str), 'thanks-commit-url-mask must be a string'
    slines = list()
    nomatch = 0
    padlen = len(str(len(commits)))
    patches = cast(List[Tuple[str, str, str, str]], jsondata['patches'])
    for at, cid in commits:
        try:
            prefix = '[%s] ' % patches[at - 1][3]
        except IndexError:
            prefix = '[%s/%s] ' % (str(at).zfill(padlen), len(commits))
        slines.append('%s%s' % (prefix, str(patches[at - 1][0])))
        if cid is None:
            slines.append('%s(no commit info)' % (' ' * len(prefix)))
            nomatch += 1
        else:
            slines.append('%s%s' % (' ' * len(prefix), cidmask % cid))
    jsondata['summary'] = '\n'.join(slines)
    if nomatch == len(commits):
        logger.critical(
            '  WARNING: None of the patches matched for: %s', jsondata['subject']
        )
        logger.critical('           Please review the resulting message')
    elif nomatch > 0:
        logger.critical(
            '  WARNING: Could not match %s of %s patches in: %s',
            nomatch,
            len(commits),
            jsondata['subject'],
        )
        logger.critical('           Please review the resulting message')

    msg = make_reply(thanks_template, jsondata, gitdir, cmdargs)
    return msg


# A thank-you item line, marked '+' (send) or 'x' (skip).
_TY_REVIEW_ITEM_RE = re.compile(r'^\s*([+xX])\s+(\S.*?)\s*$')


def render_ty_review(sections: List[Tuple[str, List[str]]]) -> bytes:
    """Render the interactive thank-you review buffer.

    `sections` is an ordered list of (subject, [detail_line, ...]); each detail
    line is shown verbatim as a '#' comment beneath its item. Each item is
    marked '+' (send) and may be flipped to 'x' to skip it.
    """
    lines = [
        '# b4 ty: review the thank-you notes about to be sent.',
        '#',
        '# These series and pull requests were detected as applied or merged.',
        "# To SKIP one, change its leading '+' to 'x'; skipped items are left",
        '# pending and will be offered again next time. Lines beginning with',
        '# "#" are comments. Do not edit, add, remove, or reorder the item',
        '# lines themselves.',
        '#',
    ]
    for subject, details in sections:
        lines.append(f'+ {subject}')
        for detail in details:
            lines.append(f'    # {detail}')
        lines.append('')
    return ('\n'.join(lines) + '\n').encode('utf-8')


def parse_ty_review(data: bytes, sections: List[Tuple[str, List[str]]]) -> Set[int]:
    """Parse the edited review buffer and return the item indices to skip.

    The index is into the in-order sequence of items. Parsing verifies the item
    subjects, in order, against what was rendered; any structural divergence
    (added, removed, reordered, or edited item lines) raises ValueError so we
    never thank the wrong contributor. Only the leading '+'/'x' marker is
    allowed to change.
    """
    expected = [subject for subject, _details in sections]

    parsed: List[Tuple[str, str]] = []
    for line in data.decode('utf-8', errors='replace').splitlines():
        stripped = line.lstrip()
        if not stripped or stripped.startswith('#'):
            continue
        imatch = _TY_REVIEW_ITEM_RE.match(line)
        if imatch:
            parsed.append((imatch.group(1).lower(), imatch.group(2)))
            continue
        raise ValueError('unexpected line in review: %r' % line)

    if len(parsed) != len(expected):
        raise ValueError(
            'expected %d item line(s), found %d -- did you add or remove lines?'
            % (len(expected), len(parsed))
        )

    skipped: Set[int] = set()
    for idx, ((marker, subject), esubject) in enumerate(zip(parsed, expected)):
        if subject != esubject:
            raise ValueError(
                'item text changed: expected %r, got %r' % (esubject, subject)
            )
        if marker == 'x':
            skipped.add(idx)
    return skipped


def get_applied_info(
    gitdir: Optional[str], jsondata: JsonDictT
) -> Tuple[Optional[str], List[str]]:
    """Resolve the applied commits recorded by the auto-thankanator.

    Returns (applied_date, commit_lines):

    * applied_date -- the committer date (RFC2822) of the most recently applied
      commit, i.e. when the work finished landing, or None if nothing resolved.
    * commit_lines -- one '# '-ready line per resolved commit, e.g.
      "[1/5] commit-id: 1a2b3c4d5e6f" for a series, or "merge-commit: <id>" for
      a pull request. Patches with no match are skipped, so a gap in the
      numbering shows which ones did not land.
    """
    # (label, commit_id) in display order.
    entries: List[Tuple[str, str]] = []
    if 'merge_commit_id' in jsondata:
        merge_commit_id = jsondata['merge_commit_id']
        if isinstance(merge_commit_id, str) and merge_commit_id:
            entries.append(('merge-commit', merge_commit_id))
    elif 'commits' in jsondata:
        commits = cast(List[Tuple[int, Optional[str]]], jsondata['commits'])
        total = len(commits)
        for at, commit_id in commits:
            if commit_id:
                entries.append(('[%d/%d]' % (at, total), commit_id))
    if not entries:
        return None, []

    commit_lines: List[str] = []
    best_ts = -1
    best_date: Optional[str] = None
    for label, commit_id in entries:
        # %ct picks the latest; %cD is the RFC2822 date; %h the short hash.
        gitargs = ['show', '-s', '--format=%ct%x00%cD%x00%h', commit_id]
        out = b4.git_get_command_lines(gitdir, gitargs)
        if not out:
            continue
        parts = out[0].split('\x00')
        if len(parts) != 3:
            continue
        ts_str, rfcdate, shorthash = parts
        if label == 'merge-commit':
            commit_lines.append('%s: %s' % (label, shorthash))
        else:
            commit_lines.append('%s commit-id: %s' % (label, shorthash))
        try:
            ts = int(ts_str)
        except ValueError:
            continue
        if ts > best_ts:
            best_ts = ts
            best_date = rfcdate
    return best_date, commit_lines


def interactive_ty_review(
    applied: List[JsonDictT], gitdir: Optional[str] = None
) -> List[JsonDictT]:
    """Open an editor letting the user skip thank-yous before they are sent.

    Returns the (possibly shorter) list of items to thank for, in order.
    Skipped items are simply dropped from the returned list; their tracking
    files are left untouched, so they reappear on the next run.
    """
    config = b4.get_main_config()
    linkmask = config.get('linkmask')
    sections: List[Tuple[str, List[str]]] = []
    for jsondata in applied:
        details: List[str] = [
            'From: %s <%s>' % (jsondata['fromname'], jsondata['fromemail']),
            'Sent: %s' % jsondata['sentdate'],
        ]
        if isinstance(linkmask, str) and linkmask:
            details.append('Link: %s' % (linkmask % jsondata['msgid']))
        applied_date, commit_lines = get_applied_info(gitdir, jsondata)
        if commit_lines:
            details.append('---')
            details.extend(commit_lines)
            details.append('---')
        if applied_date:
            details.append('Applied: %s' % applied_date)
        sections.append((str(jsondata['subject']), details))

    buf = render_ty_review(sections)
    edited = b4.edit_in_editor(
        buf, filehint='b4-ty-review.COMMIT_EDITMSG', topdir=gitdir
    )
    try:
        skipped = parse_ty_review(edited, sections)
    except ValueError as ex:
        logger.critical('CRITICAL: could not parse the thank-you review: %s', ex)
        logger.critical('No messages were sent.')
        sys.exit(1)

    kept = [jd for idx, jd in enumerate(applied) if idx not in skipped]
    if skipped:
        logger.info('Skipping %d thank-you(s); left pending for later.', len(skipped))
    return kept


def auto_thankanator(cmdargs: argparse.Namespace) -> None:
    gitdir = cmdargs.gitdir
    wantbranch = get_wanted_branch(cmdargs)
    logger.info('Auto-thankanating commits in %s', wantbranch)
    tracked = list_tracked()
    if not len(tracked):
        logger.info('Nothing to do')
        sys.exit(0)

    applied = list()
    for jsondata in tracked:
        if 'pr_commit_id' in jsondata:
            # this is a pull request
            merge_commit_id = auto_locate_pr(gitdir, jsondata, wantbranch)
            if merge_commit_id is None:
                continue
            jsondata['merge_commit_id'] = merge_commit_id
        else:
            # This is a patch series
            commits = auto_locate_series(
                gitdir, jsondata, wantbranch, since=cmdargs.since
            )
            # Weed out series that have no matches at all
            found = False
            for commit in commits:
                if commit[1] is not None:
                    found = True
                    break
            if not found:
                continue
            jsondata['commits'] = commits
        applied.append(jsondata)
        logger.info('  Located: %s', jsondata['subject'])

    if not len(applied):
        logger.info('Nothing to do')
        sys.exit(0)

    if getattr(cmdargs, 'interactive', False):
        applied = interactive_ty_review(applied, gitdir)
        if not applied:
            logger.info('Nothing to do')
            sys.exit(0)

    logger.info('---')
    send_messages(applied, wantbranch, cmdargs)
    sys.exit(0)


def send_messages(
    listing: List[JsonDictT], branch: str, cmdargs: argparse.Namespace
) -> None:
    logger.info('Generating %s thank-you letters', len(listing))
    gitdir = cmdargs.gitdir
    datadir = b4.get_data_dir()
    fromaddr = None
    smtp = None
    config = b4.get_main_config()

    _ctse = config.get('ty-send-email', 'no')
    assert isinstance(_ctse, str), 'ty-send-email must be a string'

    if cmdargs.sendemail or b4.get_git_bool(_ctse):
        send_email = True
        try:
            smtp, fromaddr = b4.get_smtp()
        except Exception as ex:
            logger.critical('Failed to configure the smtp connection:')
            logger.critical(ex)
            sys.exit(1)
    else:
        # We write .thanks notes
        send_email = False
        # Check if the outdir exists and if it has any .thanks files in it
        if not os.path.exists(cmdargs.outdir):
            os.mkdir(cmdargs.outdir)

    usercfg = b4.get_user_config()
    config = b4.get_main_config()
    user_name = config.get('thanks-from-name', usercfg['name'])
    assert isinstance(user_name, str), 'thanks-from-name must be a string'
    user_email = config.get('thanks-from-email', usercfg['email'])
    assert isinstance(user_email, str), 'thanks-from-email must be a string'
    signature = b4.get_email_signature()

    outgoing = 0
    msgids: List[str] = list()
    for jsondata in listing:
        jsondata['myname'] = user_name
        jsondata['myemail'] = user_email
        jsondata['signature'] = signature
        if 'pr_commit_id' in jsondata:
            # This is a pull request
            msg = generate_pr_thanks(gitdir, jsondata, branch, cmdargs)
        else:
            # This is a patch series
            msg = generate_am_thanks(gitdir, jsondata, branch, cmdargs)

        assert isinstance(jsondata['msgid'], str), 'msgid must be a string'
        msgids.append(jsondata['msgid'])
        assert isinstance(jsondata['patches'], list), 'patches must be a list'
        patches = cast(List[Tuple[str, str, str, str]], jsondata['patches'])
        for pdata in patches:
            msgids.append(pdata[2])

        outgoing += 1
        if send_email:
            if not fromaddr:
                fromaddr = user_email
            logger.info(
                '  Sending: %s', b4.LoreMessage.clean_header(msg.get('subject'))
            )
            b4.send_mail(smtp, [msg], fromaddr, dryrun=cmdargs.dryrun)
        else:
            assert isinstance(jsondata['fromemail'], str), 'fromname must be a string'
            assert isinstance(jsondata['subject'], str), 'subject must be a string'
            slug_from = re.sub(r'\W', '_', jsondata['fromemail'])
            slug_subj = re.sub(r'\W', '_', jsondata['subject'])
            slug = '%s_%s' % (slug_from.lower(), slug_subj.lower())
            slug = re.sub(r'_+', '_', slug)
            outfile = os.path.join(cmdargs.outdir, '%s.thanks' % slug)
            logger.info('  Writing: %s', outfile)
            with open(outfile, 'wb') as fh:
                fh.write(msg.as_bytes(policy=b4.emlpolicy))
        if cmdargs.dryrun:
            logger.info('Dry run, preserving tracked series.')
        else:
            assert isinstance(jsondata['trackfile'], str), 'trackfile must be a string'
            logger.debug('Cleaning up: %s', jsondata['trackfile'])
            fullpath = os.path.join(datadir, jsondata['trackfile'])
            os.rename(fullpath, '%s.sent' % fullpath)

    logger.info('---')
    if not outgoing:
        logger.info('No thanks necessary.')
        return

    pwstate = cmdargs.pw_set_state
    if not pwstate:
        pwstate = config.get('pw-accept-state')

    if send_email:
        if cmdargs.dryrun:
            logger.info('DRYRUN: generated %s thank-you letters', outgoing)
        else:
            logger.info('Sent %s thank-you letters', outgoing)
            if pwstate:
                assert isinstance(pwstate, str), 'pwstate must be a string'
                b4.patchwork_set_state(msgids, pwstate)
    else:
        if pwstate and not cmdargs.dryrun:
            assert isinstance(pwstate, str), 'pwstate must be a string'
            b4.patchwork_set_state(msgids, pwstate)
            logger.info('---')
        logger.debug('Wrote %s thank-you letters', outgoing)
        logger.info('You can now run:')
        logger.info('  git send-email %s/*.thanks', cmdargs.outdir)


def list_tracked() -> List[JsonDictT]:
    # find all tracked bits
    tracked = list()
    datadir = b4.get_data_dir()
    # Work around https://github.com/astral-sh/ty/issues/2799, which widens the
    # sorted element type when the key function accepts a broader path-like type
    # than Path.
    paths = sorted(Path(datadir).iterdir(), key=lambda path: path.stat().st_mtime)
    for fullpath in paths:
        if fullpath.suffix not in ('.pr', '.am'):
            continue
        with fullpath.open('r', encoding='utf-8') as fh:
            jsondata = json.load(fh)
            jsondata['trackfile'] = fullpath.name
            if fullpath.suffix == '.pr':
                jsondata['pr_commit_id'] = fullpath.stem
        tracked.append(jsondata)
    return tracked


def write_tracked(tracked: List[JsonDictT]) -> None:
    counter = 1
    config = b4.get_main_config()
    logger.info('Currently tracking:')
    linkmask = config.get('linkmask')
    for entry in tracked:
        logger.info('%3d: %s', counter, entry['subject'])
        logger.info('       From: %s <%s>', entry['fromname'], entry['fromemail'])
        logger.info('       Date: %s', entry['sentdate'])
        if isinstance(linkmask, str) and linkmask:
            logger.info('       Link: %s', linkmask % entry['msgid'])
        counter += 1


def thank_selected(cmdargs: argparse.Namespace) -> None:
    tracked = list_tracked()
    if not len(tracked):
        logger.info('Nothing to do')
        sys.exit(0)

    if cmdargs.thankfor == 'all':
        listing = tracked
    else:
        listing = list()
        for num in b4.parse_int_range(cmdargs.thankfor, upper=len(tracked)):
            try:
                index = int(num) - 1
                listing.append(tracked[index])
            except ValueError:
                logger.critical('Please provide the number of the message')
                logger.info('---')
                write_tracked(tracked)
                sys.exit(1)
            except IndexError:
                logger.critical('Invalid index: %s', num)
                logger.info('---')
                write_tracked(tracked)
                sys.exit(1)
    if not len(listing):
        logger.info('Nothing to do')
        sys.exit(0)

    wantbranch = get_wanted_branch(cmdargs)
    send_messages(listing, wantbranch, cmdargs)
    sys.exit(0)


def discard_selected(cmdargs: argparse.Namespace) -> None:
    tracked = list_tracked()
    if not len(tracked):
        logger.info('Nothing to do')
        sys.exit(0)

    if cmdargs.discard == 'all':
        listing = tracked
    else:
        listing = list()
        for num in b4.parse_int_range(cmdargs.discard, upper=len(tracked)):
            try:
                index = int(num) - 1
                listing.append(tracked[index])
            except ValueError:
                logger.critical('Please provide the number of the message')
                logger.info('---')
                write_tracked(tracked)
                sys.exit(1)
            except IndexError:
                logger.critical('Invalid index: %s', num)
                logger.info('---')
                write_tracked(tracked)
                sys.exit(1)

    if not len(listing):
        logger.info('Nothing to do')
        sys.exit(0)

    datadir = b4.get_data_dir()
    logger.info('Discarding %s messages', len(listing))
    msgids: List[str] = list()
    for jsondata in listing:
        trackfile = jsondata['trackfile']
        assert isinstance(trackfile, str), 'trackfile must be a string'
        fullpath = os.path.join(datadir, trackfile)
        os.rename(fullpath, '%s.discarded' % fullpath)
        logger.info('  Discarded: %s', jsondata['subject'])
        msgid = jsondata['msgid']
        assert isinstance(msgid, str), 'msgid must be a string'
        msgids.append(msgid)
        patches = cast(List[Tuple[str, str, str, str]], jsondata['patches'])
        for pdata in patches:
            msgids.append(pdata[2])

    config = b4.get_main_config()
    pwstate = cmdargs.pw_set_state
    if not pwstate:
        pwstate = config.get('pw-discard-state')
    if pwstate:
        assert isinstance(pwstate, str), 'pwstate must be a string'
        b4.patchwork_set_state(msgids, pwstate)

    sys.exit(0)


def check_stale_thanks(outdir: str) -> None:
    if os.path.exists(outdir):
        for entry in Path(outdir).iterdir():
            if entry.suffix == '.thanks':
                logger.critical('ERROR: Found existing .thanks files in: %s', outdir)
                logger.critical(
                    '       Please send them first (or delete if already sent).'
                )
                logger.critical('       Refusing to run to avoid potential confusion.')
                sys.exit(1)


def get_wanted_branch(cmdargs: argparse.Namespace) -> str:
    global BRANCH_INFO
    gitdir = cmdargs.gitdir
    if not cmdargs.branch:
        # Find out our current branch
        gitargs = ['symbolic-ref', '-q', 'HEAD']
        ecode, out = b4.git_run_command(gitdir, gitargs)
        if ecode > 0:
            logger.critical('Not able to get current branch (git symbolic-ref HEAD)')
            sys.exit(1)
        wantbranch = re.sub(r'^refs/heads/', '', out.strip())
        logger.debug('will check branch=%s', wantbranch)
    else:
        # Make sure it's a real branch
        gitargs = ['branch', '--format=%(refname)', '--list', '--all', cmdargs.branch]
        lines = b4.git_get_command_lines(gitdir, gitargs)
        if not len(lines):
            logger.critical(
                'Requested branch not found in git branch --list --all %s',
                cmdargs.branch,
            )
            sys.exit(1)
        wantbranch = cmdargs.branch

    return wantbranch


def _get_queue_dir(dryrun: bool = False, topdir: Optional[str] = None) -> str:
    """Return the path to the thanks queue directory.

    Uses .git/b4-review/queue (via git-common-dir for worktree
    support) of *topdir*, or of the current directory when not given.
    Dry-run messages go into a ``dryrun`` subdirectory.  When no git
    repository can be found, returns a path that will not exist so
    callers that check os.path.isdir() get empty results.
    """
    gitdir = b4.git_get_common_dir(topdir)
    if not gitdir:
        return ''
    qdir = os.path.join(gitdir, 'b4-review', 'queue')
    if dryrun:
        qdir = os.path.join(qdir, 'dryrun')
    return qdir


def _get_queue_lock_path(topdir: Optional[str] = None) -> str:
    """Path of the flock guarding queue delivery.

    Shared by the TUI and ``b4 review cron`` so two delivery runs can
    never process the queue concurrently (which would double-send).
    """
    gitdir = b4.git_get_common_dir(topdir)
    if not gitdir:
        return ''
    return os.path.join(gitdir, 'b4-review', 'queue.lock')


def _queue_filename(change_id: str, revision: int) -> str:
    """Return the queue filename for a series: ``{change_id}-v{revision}.msg``."""
    return f'{change_id}-v{revision}.msg'


def _parse_queue_file(filepath: str) -> Optional[EmailMessage]:
    """Parse an RFC 2822 .msg file, returning None on error."""
    try:
        with open(filepath, 'rb') as fh:
            msg = email.parser.BytesParser(policy=b4.emlpolicy).parse(fh)
            assert isinstance(msg, EmailMessage)
            return msg
    except Exception:
        return None


# Internal headers riding on queued messages; stripped before sending.
QUEUE_CHECK_HEADERS = (
    'X-Check-URL',
    'X-Check-Commit',
    'X-Check-Repo',
    'X-Check-Branch',
    'X-B4-Archive-After-Send',
)

_CHECKURL_RES = (
    # cgit: https://host/path/repo.git/commit/?id=<sha> (optionally ?h=branch&id=)
    re.compile(
        r'^(?P<repo>https?://.+?)/commit/\?(?:[^&]*&)*id=(?P<commit>[0-9a-fA-F]{7,64})'
    ),
    # github/gitea: <repo>/commit/<sha>; gitlab: <repo>/-/commit/<sha>
    re.compile(r'^(?P<repo>https?://.+?)(?:/-)?/commit/(?P<commit>[0-9a-fA-F]{7,64})'),
)


def _parse_checkurl(checkurl: str) -> Tuple[Optional[str], Optional[str]]:
    """Extract (repo_url, commit_id) from a thanks-commit-url-mask URL.

    The repo URL is only derivable from cgit/github/gitlab-style commit
    URLs; for anything else (e.g. git.kernel.org /username/c/ shortlinks)
    we can usually still recover the commit id from a trailing hex run.
    Either element may be None.
    """
    for pat in _CHECKURL_RES:
        m = pat.match(checkurl)
        if m:
            return m.group('repo'), m.group('commit')
    m = re.search(r'([0-9a-fA-F]{7,64})$', checkurl.rstrip('/'))
    if m:
        return None, m.group(1)
    return None, None


def _get_check_repo(checkurl: str) -> Optional[str]:
    """Return the git URL of the public repo to check commits against.

    The b4.thanks-check-repo config option takes precedence; otherwise
    attempt to derive the repo URL from the commit check URL.
    """
    config = b4.get_main_config()
    crepo = config.get('thanks-check-repo')
    if isinstance(crepo, str) and crepo:
        return crepo
    repo, _commit = _parse_checkurl(checkurl)
    return repo


def get_check_repo_for_branch(
    gitdir: Optional[str], branch: str, checkurl: str = ''
) -> Optional[str]:
    """Return the git URL to verify queued-thanks commits against.

    When the branch named in the thanks message tracks a remote, that
    remote is what "applied to branch X" refers to, so having the commit
    reachable there is what qualifies as pushed. Priority:

    1. remote.<name>.b4-check-repo on the branch's remote
    2. the b4.thanks-check-repo config option
    3. the branch's remote URL
    4. a repo URL derived from the commit check URL
    """
    # Long-lived callers (the TUI) thank series on different branches;
    # never serve another branch's cached remote info
    global BRANCH_INFO
    BRANCH_INFO = None
    binfo = get_branch_info(gitdir, branch)
    crepo = binfo.get('b4-check-repo')
    if crepo:
        return crepo
    config = b4.get_main_config()
    ccrepo = config.get('thanks-check-repo')
    if isinstance(ccrepo, str) and ccrepo:
        return ccrepo
    crepo = binfo.get('url')
    if crepo:
        return crepo
    if checkurl:
        repo, _commit = _parse_checkurl(checkurl)
        return repo
    return None


def commit_reachable_on_remote(
    commit: str, repo_url: str, branch: str = '', gitdir: Optional[str] = None
) -> Optional[bool]:
    """Check if a commit is reachable from a branch advertised by repo_url.

    A commit only counts as published once a ref on the public repo
    contains it. Merely existing in the remote odb is not enough: hosts
    with shared object storage (grokmirror objstore repos, github fork
    networks) will happily serve commit pages for objects that were
    pushed to a sibling repo but never published in this one.

    With *branch*, only that branch counts when the remote advertises
    it — the thanks message claims the commit went into that specific
    branch. If the remote does not advertise it (renamed, merged and
    deleted), any advertised branch is accepted as before.

    Ancestry is computed locally against the advertised tips, so tips we
    do not have objects for are ignored.  That happens in *gitdir* — the
    repository the commit was applied in.  It defaults to the process
    cwd, which is only right for callers that operate on it; a queue
    sweep covering several projects must name the tree each message
    belongs to.

    Returns True/False, or None if the state could not be determined
    (e.g. the remote is unreachable, or none of the advertised tips are
    in the local repository).
    """
    gitargs = [
        '-c',
        'http.lowSpeedLimit=1000',
        '-c',
        'http.lowSpeedTime=15',
        'ls-remote',
        '--heads',
        repo_url,
    ]
    ecode, out = b4.git_run_command(gitdir, gitargs)
    if ecode > 0:
        logger.debug('ls-remote failed for %s (exit code %s)', repo_url, ecode)
        return None
    tips: Set[str] = set()
    branchtip: Optional[str] = None
    for line in out.splitlines():
        chunks = line.split(None, 1)
        if chunks:
            tips.add(chunks[0])
            if branch and len(chunks) > 1 and chunks[1] == f'refs/heads/{branch}':
                branchtip = chunks[0]
    if branchtip:
        tips = {branchtip}
    elif branch:
        logger.debug(
            '%s does not advertise refs/heads/%s, checking all heads',
            repo_url,
            branch,
        )
    if not tips:
        return False
    # Filter out tips we don't have locally -- we can't compute ancestry
    # for them, so treat them as not containing the commit
    stdin = ('\n'.join(sorted(tips)) + '\n').encode()
    _ecode, out = b4.git_run_command(
        gitdir, ['cat-file', '--batch-check=%(objectname) %(objecttype)'], stdin=stdin
    )
    known: List[str] = []
    for line in out.splitlines():
        chunks = line.split()
        if len(chunks) == 2 and chunks[1] == 'commit':
            known.append(chunks[0])
    if not known:
        # Undetermined, not unpublished: without the objects we cannot
        # say anything about the commit, and reporting "not yet visible"
        # would hide a stale (or simply wrong) local repository behind
        # what looks like normal waiting.  Warn rather than debug: a cron
        # sweep silences narration but keeps warnings, and this one is a
        # standing misconfiguration that would otherwise wait forever.
        logger.warning(
            'None of the heads advertised by %s exist in %s; cannot tell '
            'whether the commit is published',
            repo_url,
            gitdir or os.getcwd(),
        )
        return None
    # Empty output means every commit reachable from ours is also
    # reachable from one of the known tips, i.e. ours is published
    ecode, out = b4.git_run_command(gitdir, ['rev-list', '-1', commit, '--not', *known])
    if ecode > 0:
        logger.debug('rev-list failed for %s (exit code %s)', commit, ecode)
        return None
    return not out.strip()


def _check_published(
    checkurl: str,
    checkcommit: str,
    checkrepo: str,
    checkbranch: str = '',
    gitdir: Optional[str] = None,
) -> Optional[bool]:
    """Tri-state publish check for a queued thanks message.

    Returns True when the commit is verified published, False when it is
    not yet visible, None when the check could not be performed. Prefers
    ref reachability on the public repo; falls back to an HTTP status
    check of checkurl when no repo URL is available (that check cannot
    detect unpublished commits served out of shared object storage).
    """
    if checkurl and not checkcommit:
        _repo, pcommit = _parse_checkurl(checkurl)
        checkcommit = pcommit or ''
    if checkurl and not checkrepo:
        checkrepo = _get_check_repo(checkurl) or ''
    if checkcommit and checkrepo:
        return commit_reachable_on_remote(
            checkcommit, checkrepo, branch=checkbranch, gitdir=gitdir
        )
    if not checkurl:
        return True
    try:
        session = b4.get_requests_session()
        resp = session.head(checkurl, timeout=15, allow_redirects=True)
        return resp.status_code < 300
    except Exception:
        return None


def queue_message(
    msg: EmailMessage,
    checkurl: str,
    change_id: str,
    revision: int,
    dryrun: bool = False,
    checkcommit: Optional[str] = None,
    archive_after: bool = False,
    checkrepo: Optional[str] = None,
    checkbranch: Optional[str] = None,
) -> None:
    """Write a thanks message to the file-based queue.

    With *archive_after*, the series is archived once the message is
    delivered (unless a newer revision has shown up by then — see
    process_queue).
    """
    qdir = _get_queue_dir(dryrun=dryrun)
    os.makedirs(qdir, exist_ok=True)
    # Inject the check info as headers for process_queue()
    for hdr in QUEUE_CHECK_HEADERS:
        if hdr in msg:
            del msg[hdr]
    msg['X-Check-URL'] = checkurl
    if not checkcommit:
        _repo, checkcommit = _parse_checkurl(checkurl)
    if not checkrepo:
        checkrepo = _get_check_repo(checkurl)
    if checkcommit:
        msg['X-Check-Commit'] = checkcommit
    if checkrepo:
        msg['X-Check-Repo'] = checkrepo
    if checkbranch:
        msg['X-Check-Branch'] = checkbranch
    if archive_after:
        msg['X-B4-Archive-After-Send'] = 'yes'
    filepath = os.path.join(qdir, _queue_filename(change_id, revision))
    # Write to a temp name and rename into place, so a concurrent
    # delivery sweep can never see (and send) a half-written message.
    tmppath = filepath + '.tmp'
    with open(tmppath, 'wb') as fh:
        fh.write(msg.as_bytes(policy=b4.emlpolicy))
    os.rename(tmppath, filepath)


def get_queued_count(dryrun: bool = False, topdir: Optional[str] = None) -> int:
    """Return the number of queued .msg files."""
    qdir = _get_queue_dir(dryrun=dryrun, topdir=topdir)
    if not os.path.isdir(qdir):
        return 0
    return sum(1 for f in os.listdir(qdir) if f.endswith('.msg'))


def get_queued_messages(dryrun: bool = False) -> List[Dict[str, str]]:
    """Return queued message details for display.

    Each entry has keys: filename, subject, checkurl.
    """
    qdir = _get_queue_dir(dryrun=dryrun)
    if not os.path.isdir(qdir):
        return []
    results: List[Dict[str, str]] = []
    for fname in sorted(os.listdir(qdir)):
        if not fname.endswith('.msg'):
            continue
        msg = _parse_queue_file(os.path.join(qdir, fname))
        if msg is None:
            continue
        subject = str(msg.get('Subject', '(no subject)'))
        checkurl = str(msg.get('X-Check-URL', ''))
        results.append(
            {
                'filename': fname,
                'subject': subject,
                'checkurl': checkurl,
            }
        )
    return results


def get_queued_change_ids(dryrun: bool = False) -> Set[str]:
    """Return the set of change_ids that have queued thank-you messages."""
    qdir = _get_queue_dir(dryrun=dryrun)
    if not os.path.isdir(qdir):
        return set()
    result: Set[str] = set()
    for fname in os.listdir(qdir):
        if fname.endswith('.msg'):
            cid, _rev = _parse_change_revision(fname)
            result.add(cid)
    return result


ProgressCallbackT = Optional[Callable[[int, int, str], None]]


QueueResultT = Tuple[int, int, List[Tuple[str, int]]]


def _parse_change_revision(fname: str) -> Tuple[str, int]:
    """Extract (change_id, revision) from a queue filename."""
    stem = fname
    if stem.endswith('.msg'):
        stem = stem[:-4]
    # Last component after -v is the revision
    if '-v' in stem:
        base, _, rev_str = stem.rpartition('-v')
        try:
            return (base, int(rev_str))
        except ValueError:
            pass
    return (stem, 0)


def _get_series_status(
    change_id: str, revision: int, identifier: Optional[str]
) -> Optional[str]:
    """Return the tracking-database status of a series, or None."""
    if not (identifier and change_id):
        return None
    # Imported here to keep b4.ty usable without the review machinery
    import b4.review.tracking

    try:
        conn = b4.review.tracking.get_db(identifier)
        status = b4.review.tracking.get_series_status(
            conn, change_id, revision=revision
        )
        conn.close()
    except Exception:
        return None
    return status


def _mark_series_thanked(
    change_id: str, revision: int, identifier: Optional[str], topdir: Optional[str]
) -> None:
    """Record 'thanked' status for a delivered series.

    Updates the tracking database (when *identifier* is given) and the
    review branch's tracking commit (when *topdir* is given).  Never
    raises: the thanks mail is already out, so bookkeeping failures
    must not make the caller count the message as still pending.

    A series that was archived while its message sat in the queue is
    left alone: 'thanked' must never resurrect it.
    """
    if not change_id:
        return
    # Imported here to keep b4.ty usable without the review machinery
    import b4.review
    import b4.review.tracking

    if _get_series_status(change_id, revision, identifier) == 'archived':
        return
    if identifier:
        try:
            conn = b4.review.tracking.get_db(identifier)
            b4.review.tracking.update_series_status(
                conn, change_id, 'thanked', revision=revision
            )
            conn.close()
        except Exception as ex:
            logger.warning('Could not update series status: %s', ex)
    if topdir:
        review_branch = f'b4/review/{change_id}'
        # An already-deleted branch (e.g. a manually archived series) is
        # not an error worth warning about
        if b4.git_branch_exists(topdir, review_branch):
            if b4.git_branch_checked_out(topdir, review_branch):
                # The maintainer is working on the branch right now;
                # rewriting its tip under them is off limits.  The DB
                # already records 'thanked' above.
                logger.debug(
                    '%s is checked out, not updating tracking commit',
                    review_branch,
                )
            else:
                b4.review.update_tracking_status(topdir, review_branch, 'thanked')


def _maybe_archive_after_send(
    change_id: str,
    revision: int,
    identifier: Optional[str],
    topdir: Optional[str],
    prestatus: Optional[str],
) -> str:
    """Archive a delivered series if it is still safe to do so.

    *prestatus* is the series status captured before the delivery
    marked it 'thanked'.  The archive is skipped when the maintainer
    touched the series since the message was queued (status drifted
    from 'accepted') or when a newer revision has shown up — in both
    cases the cleanup defers to the human.  Never raises: the thanks
    mail is already out.

    Returns a short annotation for the delivery report.
    """
    if not (identifier and change_id):
        return ''
    # Imported here to keep b4.ty usable without the review machinery
    import b4.review
    import b4.review.tracking

    if prestatus != 'accepted':
        return ' (not archived: series status changed since queueing)'
    newest = None
    pw_series_id = None
    try:
        conn = b4.review.tracking.get_db(identifier)
        newest = b4.review.tracking.get_newest_revision(conn, change_id)
        pw_series_id = b4.review.tracking.get_pw_series_id(
            conn, change_id, revision=revision
        )
        conn.close()
    except Exception:
        pass
    if newest is not None and revision and newest > revision:
        return ' (not archived: newer revision available)'
    review_branch = f'b4/review/{change_id}'
    if topdir and b4.git_branch_checked_out(topdir, review_branch):
        # Archiving deletes the review branch; a checked-out branch is
        # being worked on and must be left alone.
        return ' (not archived: branch is checked out)'
    try:
        ok, detail = b4.review.archive_series(
            topdir, identifier, change_id, revision=revision, pw_series_id=pw_series_id
        )
    except Exception as ex:
        ok, detail = False, str(ex)
    if not ok:
        logger.warning('Could not archive %s: %s', change_id, detail)
        return ' (not archived: see warnings)'
    return ' + archived'


def process_queue(
    dryrun: bool = False,
    patatt_sign: bool = True,
    progress_cb: ProgressCallbackT = None,
    check_only: bool = False,
    identifier: Optional[str] = None,
    topdir: Optional[str] = None,
) -> QueueResultT:
    """Check queued messages and deliver those whose commits are visible.

    The whole run holds an exclusive flock, so a cron sweep and a TUI
    delivery can never process the queue concurrently (which would
    double-send).  If another delivery is already running,
    b4.LockHeldError is raised immediately.

    With *check_only*, messages are checked and reported but nothing
    is sent or moved; the returned 'delivered' count means 'would
    deliver'.

    When *identifier* (and *topdir*) are given, each delivered series
    is marked 'thanked' in the tracking database and its tracking
    commit as its message goes out.

    *topdir* selects which repository's queue to process (default: the
    one containing the current directory); it is also where tracking
    commits get their status update.

    *progress_cb*, when provided, is called as
    ``progress_cb(completed, total, status_text)`` after each message.
    Returns (delivered, still_pending, delivered_series) where
    delivered_series is a list of (change_id, revision) tuples.
    """
    qdir = _get_queue_dir(dryrun=dryrun, topdir=topdir)
    if not os.path.isdir(qdir):
        return (0, 0, [])

    with b4.lockfile_nb(_get_queue_lock_path(topdir)):
        return _process_queue_locked(
            qdir, dryrun, patatt_sign, progress_cb, check_only, identifier, topdir
        )


def _process_queue_locked(
    qdir: str,
    dryrun: bool,
    patatt_sign: bool,
    progress_cb: ProgressCallbackT,
    check_only: bool,
    identifier: Optional[str],
    topdir: Optional[str],
) -> QueueResultT:
    files = sorted(f for f in os.listdir(qdir) if f.endswith('.msg'))
    if not files:
        return (0, 0, [])

    sentdir = os.path.join(qdir, 'sent')
    total = len(files)
    delivered = 0
    still_pending = 0
    delivered_series: List[Tuple[str, int]] = []

    for i, fname in enumerate(files):
        filepath = os.path.join(qdir, fname)
        msg = _parse_queue_file(filepath)
        if msg is None:
            still_pending += 1
            continue

        checkurl = str(msg.get('X-Check-URL', ''))
        checkcommit = str(msg.get('X-Check-Commit', ''))
        checkrepo = str(msg.get('X-Check-Repo', ''))
        checkbranch = str(msg.get('X-Check-Branch', ''))
        archive_after = str(msg.get('X-B4-Archive-After-Send', '')).lower() == 'yes'
        subject = str(msg.get('Subject', '(no subject)'))
        if progress_cb:
            progress_cb(i, total, f'Checking: {subject}')

        # Check if the commit is publicly visible
        if not dryrun and (checkurl or (checkcommit and checkrepo)):
            published = _check_published(
                checkurl, checkcommit, checkrepo, checkbranch, gitdir=topdir
            )
            if published is None:
                still_pending += 1
                if progress_cb:
                    progress_cb(i + 1, total, f'Check failed: {subject}')
                continue
            if not published:
                still_pending += 1
                if progress_cb:
                    progress_cb(i + 1, total, f'Not yet visible: {subject}')
                continue

        if check_only:
            delivered += 1
            change_id, revision = _parse_change_revision(fname)
            delivered_series.append((change_id, revision))
            if progress_cb:
                progress_cb(i + 1, total, f'Would deliver: {subject}')
            continue

        # Strip the internal headers before sending
        for hdr in QUEUE_CHECK_HEADERS:
            if hdr in msg:
                del msg[hdr]

        # Commit is visible — deliver the message
        try:
            smtp, fromaddr = b4.get_smtp(dryrun=dryrun)
            b4.send_mail(
                smtp,
                [msg],
                fromaddr=fromaddr,
                patatt_sign=patatt_sign,
                dryrun=dryrun,
                output_dir=None,
                reflect=False,
            )
            # Move to sent/
            os.makedirs(sentdir, exist_ok=True)
            os.rename(filepath, os.path.join(sentdir, fname))
            delivered += 1
            change_id, revision = _parse_change_revision(fname)
            delivered_series.append((change_id, revision))
        except Exception as ex:
            logger.warning('Failed to send queued message %s: %s', fname, ex)
            still_pending += 1
            if progress_cb:
                progress_cb(i + 1, total, f'Send failed: {subject}')
            continue

        # The status before the 'thanked' mark below decides whether a
        # requested archive is still safe (see _maybe_archive_after_send)
        prestatus = _get_series_status(change_id, revision, identifier)
        _mark_series_thanked(change_id, revision, identifier, topdir)
        note = ''
        if archive_after:
            note = _maybe_archive_after_send(
                change_id, revision, identifier, topdir, prestatus
            )
        if progress_cb:
            progress_cb(i + 1, total, subject + note)

    return (delivered, still_pending, delivered_series)


def get_branch_info(gitdir: Optional[str], branch: str) -> Dict[str, str]:
    global BRANCH_INFO
    if BRANCH_INFO is not None:
        return BRANCH_INFO

    BRANCH_INFO = dict()

    remotecfg = b4.get_config_from_git('branch\\.%s\\..*' % branch)
    if 'remote' not in remotecfg:
        # Did not find a matching branch entry, so look at remotes
        gitargs = ['remote', 'show']
        lines = b4.git_get_command_lines(gitdir, gitargs)
        if not len(lines):
            # No remotes? Hmm...
            return BRANCH_INFO

        remote = None
        for entry in lines:
            if branch.find(f'{entry}/') == 0:
                remote = entry
                break

        if remote is None:
            # Not found any matching remotes
            return BRANCH_INFO

        BRANCH_INFO['remote'] = remote
        BRANCH_INFO['branch'] = branch.replace(f'{remote}/', '')

    else:
        BRANCH_INFO['remote'] = remotecfg['remote']
        if 'merge' in remotecfg:
            BRANCH_INFO['branch'] = re.sub(r'^refs/heads/', '', remotecfg['merge'])

    # Grab template overrides
    remotecfg = b4.get_config_from_git('remote\\.%s\\..*' % BRANCH_INFO['remote'])
    BRANCH_INFO.update(remotecfg)

    return BRANCH_INFO


def main(cmdargs: argparse.Namespace) -> None:
    usercfg = b4.get_user_config()
    if 'email' not in usercfg:
        logger.critical('Please set user.email in gitconfig to use this feature.')
        sys.exit(1)

    if cmdargs.auto or getattr(cmdargs, 'interactive', False):
        check_stale_thanks(cmdargs.outdir)
        auto_thankanator(cmdargs)
    elif cmdargs.thankfor:
        check_stale_thanks(cmdargs.outdir)
        thank_selected(cmdargs)
    elif cmdargs.discard:
        discard_selected(cmdargs)
    else:
        tracked = list_tracked()
        if not len(tracked):
            logger.info('No thanks necessary.')
            sys.exit(0)
        write_tracked(tracked)
        logger.info('---')
        logger.info('You can send them using number ranges, e.g:')
        logger.info('  b4 ty -t 1-3,5,7-')
