#!/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.utils
import json
import os
import pathlib
import signal
import sqlite3
import sys
import types
from typing import Any, Dict, List, Optional, Set, Tuple

import b4
import b4.mbox
import liblore

logger = b4.logger

REVIEW_METADATA_DIR = 'b4-review'
REVIEW_METADATA_FILE = 'metadata.json'

SCHEMA_VERSION = 10

SERIES_PATCHES_DDL = """
CREATE TABLE IF NOT EXISTS series_patches (
    change_id  TEXT NOT NULL,
    revision   INTEGER NOT NULL,
    position   INTEGER NOT NULL,
    message_id TEXT NOT NULL,
    subject    TEXT,
    PRIMARY KEY (change_id, revision, position)
)"""

SCHEMA_SQL = (
    """
CREATE TABLE IF NOT EXISTS schema_version (
    version INTEGER PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS series (
    track_id INTEGER PRIMARY KEY,
    change_id TEXT NOT NULL,
    revision INTEGER NOT NULL,
    subject TEXT,
    sender_name TEXT,
    sender_email TEXT,
    sent_at TEXT,
    added_at TEXT,
    message_id TEXT,
    num_patches INTEGER,
    pw_series_id INTEGER,
    status TEXT DEFAULT 'new',
    fingerprint TEXT,
    branch_sha TEXT,
    message_count INT,
    seen_message_count INT,
    last_update_check TEXT,
    last_activity_at TEXT,
    snoozed_until TEXT,
    attestation TEXT DEFAULT 'pending',
    target_branch TEXT,
    is_rethreaded INTEGER DEFAULT 0,
    UNIQUE (change_id, revision)
);

CREATE TABLE IF NOT EXISTS revisions (
    change_id   TEXT NOT NULL,
    revision    INTEGER NOT NULL,
    message_id  TEXT NOT NULL,
    subject     TEXT,
    link        TEXT,
    found_at    TEXT,
    thread_blob TEXT,
    fingerprint TEXT,
    source      TEXT DEFAULT 'heuristic',
    is_rethreaded INTEGER DEFAULT 0,
    PRIMARY KEY (change_id, revision)
);

CREATE INDEX IF NOT EXISTS idx_revisions_fingerprint ON revisions(fingerprint);

"""
    + SERIES_PATCHES_DDL
    + ';'
)


def get_review_data_dir() -> str:
    """Get the review data directory path."""
    datadir = b4.get_data_dir()
    reviewdir = os.path.join(datadir, 'review')
    pathlib.Path(reviewdir).mkdir(parents=True, exist_ok=True)
    return reviewdir


def get_db_path(identifier: str) -> str:
    """Get the database path for a project identifier."""
    reviewdir = get_review_data_dir()
    return os.path.join(reviewdir, f'{identifier}.sqlite3')


def db_exists(identifier: str) -> bool:
    """Check if a database exists for the given identifier."""
    return os.path.exists(get_db_path(identifier))


def _configure_conn(conn: sqlite3.Connection) -> None:
    """Apply per-connection settings.

    A generous busy_timeout keeps concurrent writers (the TUI and a
    ``b4 review cron`` sweep) waiting on each other's short write
    transactions instead of throwing 'database is locked'.
    """
    conn.execute('PRAGMA busy_timeout = 15000')


def init_db(identifier: str) -> sqlite3.Connection:
    """Initialize a new database for the given identifier."""
    db_path = get_db_path(identifier)
    conn = sqlite3.connect(db_path)
    _configure_conn(conn)
    conn.executescript(SCHEMA_SQL)
    conn.execute(
        'INSERT OR REPLACE INTO schema_version (version) VALUES (?)', (SCHEMA_VERSION,)
    )
    conn.commit()
    return conn


def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
    """Apply any pending schema migrations in-place."""
    row = conn.execute('SELECT version FROM schema_version').fetchone()
    version = row[0] if row else 0
    if version >= SCHEMA_VERSION:
        return
    if version < 2:
        conn.execute('ALTER TABLE series ADD COLUMN branch_sha TEXT')
        conn.execute('ALTER TABLE series ADD COLUMN followup_count INT')
        conn.execute('ALTER TABLE series ADD COLUMN seen_followup_count INT')
        conn.execute('ALTER TABLE series ADD COLUMN last_update_check TEXT')
        conn.execute('ALTER TABLE series ADD COLUMN last_activity_at TEXT')
    if version < 3:
        conn.execute("UPDATE series SET status = 'accepted' WHERE status = 'taken'")
        conn.execute('ALTER TABLE series ADD COLUMN snoozed_until TEXT')
    if version < 4:
        conn.execute('ALTER TABLE series RENAME COLUMN followup_count TO message_count')
        conn.execute(
            'ALTER TABLE series RENAME COLUMN seen_followup_count TO seen_message_count'
        )
    if version < 5:
        conn.execute("ALTER TABLE series ADD COLUMN attestation TEXT DEFAULT 'pending'")
    if version < 6:
        conn.execute('ALTER TABLE series ADD COLUMN target_branch TEXT')
    if version < 7:
        conn.execute(SERIES_PATCHES_DDL)
        conn.execute('ALTER TABLE series ADD COLUMN is_rethreaded INTEGER DEFAULT 0')
    if version < 8:
        # Older DBs may not have the revisions table at all; create it if absent.
        conn.execute("""CREATE TABLE IF NOT EXISTS revisions (
            change_id  TEXT NOT NULL,
            revision   INTEGER NOT NULL,
            message_id TEXT NOT NULL,
            subject    TEXT,
            link       TEXT,
            found_at   TEXT,
            PRIMARY KEY (change_id, revision)
        )""")
        # Add thread_blob only if the table didn't already have it.
        existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
        if 'thread_blob' not in existing:
            conn.execute('ALTER TABLE revisions ADD COLUMN thread_blob TEXT')
    if version < 9:
        # Per-revision fingerprint + provenance for manual revision linking.
        existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
        if 'fingerprint' not in existing:
            conn.execute('ALTER TABLE revisions ADD COLUMN fingerprint TEXT')
        if 'source' not in existing:
            # Existing rows were auto-discovered, so they default to 'heuristic'.
            conn.execute(
                "ALTER TABLE revisions ADD COLUMN source TEXT DEFAULT 'heuristic'"
            )
        conn.execute(
            'CREATE INDEX IF NOT EXISTS idx_revisions_fingerprint'
            ' ON revisions(fingerprint)'
        )
    if version < 10:
        # Per-revision rethread state, so a series tracked normally at vN and
        # rethreaded at vN+1 (or vice versa) reassembles the right revision.
        existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
        if 'is_rethreaded' not in existing:
            conn.execute(
                'ALTER TABLE revisions ADD COLUMN is_rethreaded INTEGER DEFAULT 0'
            )
    conn.execute('UPDATE schema_version SET version = ?', (SCHEMA_VERSION,))
    conn.commit()


def get_db(identifier: str) -> sqlite3.Connection:
    """Get a database connection for the given identifier."""
    db_path = get_db_path(identifier)
    if not os.path.exists(db_path):
        raise FileNotFoundError(f'No database found for identifier: {identifier}')
    conn = sqlite3.connect(db_path)
    _configure_conn(conn)
    conn.row_factory = sqlite3.Row
    _migrate_db_if_needed(conn)
    return conn


def get_repo_metadata_path(gitdir: str) -> str:
    """Get the path to the metadata file in a git repository."""
    return os.path.join(gitdir, REVIEW_METADATA_DIR, REVIEW_METADATA_FILE)


def get_repo_identifier(topdir: str) -> Optional[str]:
    """Get the project identifier from a repository's metadata file."""
    gitdir = b4.git_get_common_dir(topdir)
    if not gitdir:
        return None
    metadata_path = get_repo_metadata_path(gitdir)
    if not os.path.exists(metadata_path):
        return None
    try:
        with open(metadata_path, 'r') as f:
            data = json.load(f)
            identifier = data.get('identifier')
            if isinstance(identifier, str):
                return identifier
            return None
    except (json.JSONDecodeError, OSError) as e:
        logger.warning('Failed to read metadata file: %s', e)
        return None


def _get_repopath_path(identifier: str) -> str:
    """Path of the sidecar file recording an identifier's repo toplevel."""
    return os.path.join(get_review_data_dir(), f'{identifier}.repopath')


def record_repo_path(identifier: str, topdir: str) -> None:
    """Record the repository toplevel for an identifier.

    This is the reverse of the in-repo metadata file: it lets
    ``b4 review cron -i __all__`` find each project's repository (for
    branch rescans and the thanks queue) without being run from inside
    it.  Refreshed opportunistically whenever the TUI or cron runs from
    within the repository, so it survives repositories being moved.
    """
    path = _get_repopath_path(identifier)
    try:
        with open(path) as fh:
            if fh.read().strip() == topdir:
                return
    except OSError:
        pass
    try:
        with open(path, 'w') as fh:
            fh.write(topdir + '\n')
    except OSError as ex:
        logger.debug('Could not record repo path for %s: %s', identifier, ex)


def get_repo_path(identifier: str) -> Optional[str]:
    """Return the recorded repository toplevel for an identifier.

    Returns None when no path was recorded or the recorded path no
    longer belongs to this identifier (e.g. the repository was moved
    or deleted).
    """
    try:
        with open(_get_repopath_path(identifier)) as fh:
            candidate = fh.read().strip()
    except OSError:
        return None
    if candidate and get_repo_identifier(candidate) == identifier:
        return candidate
    return None


def get_known_projects() -> List[Tuple[str, Optional[str]]]:
    """Return (identifier, topdir) for every known tracking database.

    *topdir* is the recorded repository toplevel, or None (see
    get_repo_path).
    """
    reviewdir = get_review_data_dir()
    projects: List[Tuple[str, Optional[str]]] = []
    for fname in sorted(os.listdir(reviewdir)):
        if not fname.endswith('.sqlite3'):
            continue
        identifier = fname[: -len('.sqlite3')]
        projects.append((identifier, get_repo_path(identifier)))
    return projects


def save_repo_metadata(gitdir: str, identifier: str) -> None:
    """Save project metadata to the repository's .git directory."""
    metadata_dir = os.path.join(gitdir, REVIEW_METADATA_DIR)
    pathlib.Path(metadata_dir).mkdir(parents=True, exist_ok=True)
    metadata_path = os.path.join(metadata_dir, REVIEW_METADATA_FILE)
    with open(metadata_path, 'w') as f:
        json.dump({'identifier': identifier}, f, indent=2)
        f.write('\n')


def get_recent_take_branches(gitdir: str, limit: int = 20) -> List[str]:
    """Return recently used take branches, most recent first."""
    metadata_path = get_repo_metadata_path(gitdir)
    if not os.path.exists(metadata_path):
        return []
    try:
        with open(metadata_path, 'r') as f:
            data = json.load(f)
    except (json.JSONDecodeError, OSError):
        return []
    branches = data.get('recent-take-branches', [])
    if not isinstance(branches, list):
        return []
    return [b for b in branches if isinstance(b, str)][:limit]


def record_take_branch(gitdir: str, branch: str) -> None:
    """Prepend branch to the recent-take-branches list in metadata.json."""
    metadata_dir = os.path.join(gitdir, REVIEW_METADATA_DIR)
    pathlib.Path(metadata_dir).mkdir(parents=True, exist_ok=True)
    metadata_path = get_repo_metadata_path(gitdir)
    data: object = {}
    if os.path.exists(metadata_path):
        try:
            with open(metadata_path, 'r') as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError):
            pass
    if not isinstance(data, dict):
        data = {}
    branches = data.get('recent-take-branches', [])
    if not isinstance(branches, list):
        branches = []
    # Remove existing entry so we can move it to the front
    branches = [b for b in branches if b != branch]
    branches.insert(0, branch)
    data['recent-take-branches'] = branches[:20]
    with open(metadata_path, 'w') as f:
        json.dump(data, f, indent=2)
        f.write('\n')


def resolve_identifier(
    cmdargs: argparse.Namespace, topdir: Optional[str] = None
) -> Optional[str]:
    """Resolve project identifier from command args or repository metadata."""
    if hasattr(cmdargs, 'identifier') and cmdargs.identifier:
        return str(cmdargs.identifier)
    if topdir is None:
        topdir = b4.git_get_toplevel()
    if topdir:
        return get_repo_identifier(topdir)
    return None


def cmd_enroll(cmdargs: argparse.Namespace) -> None:
    """Enroll a repository for review tracking."""
    if cmdargs.repo_path:
        repo_path = os.path.abspath(cmdargs.repo_path)
        if not os.path.isdir(repo_path):
            logger.critical('Repository path does not exist: %s', repo_path)
            sys.exit(1)
    else:
        # Use current directory
        repo_path_opt = b4.git_get_toplevel()
        if not repo_path_opt:
            logger.critical(
                'Not in a git repository. Specify a path or run from within a repository.'
            )
            sys.exit(1)
        repo_path = repo_path_opt

    # Resolve the shared .git directory (works for both main repos and worktrees)
    gitdir = b4.git_get_common_dir(repo_path)
    if not gitdir:
        logger.critical('Not a git repository: %s', repo_path)
        sys.exit(1)

    # Determine identifier
    if cmdargs.identifier:
        identifier = cmdargs.identifier
    else:
        identifier = os.path.basename(repo_path)

    # Validate identifier (basic sanity check)
    if not identifier or '/' in identifier or identifier.startswith('.'):
        logger.critical('Invalid identifier: %s', identifier)
        sys.exit(1)

    # Check if this repository is already enrolled
    existing_id = get_repo_identifier(repo_path)
    if existing_id:
        if existing_id == identifier:
            logger.info('Repository already enrolled with identifier: %s', existing_id)
            sys.exit(0)
        logger.critical('Repository already enrolled with identifier: %s', existing_id)
        sys.exit(1)

    # Check if database already exists
    if db_exists(identifier):
        logger.info('Database already exists: %s', get_db_path(identifier))
        try:
            answer = input('Use existing database for this repository? (y/N) ')
        except KeyboardInterrupt:
            logger.info('')
            sys.exit(130)
        if answer.strip().lower() not in ('y', 'yes'):
            logger.info('Enroll cancelled.')
            sys.exit(0)
        logger.info('Using existing database: %s', get_db_path(identifier))
    else:
        # Create the database
        conn = init_db(identifier)
        conn.close()
        logger.info('Created tracking database: %s', get_db_path(identifier))

    # Create metadata file in shared .git directory
    save_repo_metadata(gitdir, identifier)
    logger.info('Created metadata file: %s', get_repo_metadata_path(gitdir))
    record_repo_path(identifier, repo_path)

    logger.info('Project enrolled successfully with identifier: %s', identifier)


def add_series_to_db(
    conn: sqlite3.Connection,
    change_id: str,
    revision: int,
    subject: Optional[str],
    sender_name: Optional[str],
    sender_email: Optional[str],
    sent_at: Optional[str],
    message_id: str,
    num_patches: int,
    pw_series_id: Optional[int] = None,
    fingerprint: Optional[str] = None,
    added_at: Optional[str] = None,
    is_rethreaded: bool = False,
) -> int:
    """Add a series to the tracking database. Returns the track_id."""
    if added_at is None:
        added_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
    cursor = conn.execute(
        """
        INSERT INTO series
        (change_id, revision, subject, sender_name, sender_email, sent_at, added_at,
         message_id, num_patches, pw_series_id, fingerprint, is_rethreaded)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT (change_id, revision) DO UPDATE SET
            subject = excluded.subject,
            sender_name = excluded.sender_name,
            sender_email = excluded.sender_email,
            sent_at = excluded.sent_at,
            added_at = COALESCE(series.added_at, excluded.added_at),
            message_id = excluded.message_id,
            num_patches = excluded.num_patches,
            pw_series_id = excluded.pw_series_id,
            fingerprint = excluded.fingerprint,
            is_rethreaded = excluded.is_rethreaded
        RETURNING track_id
    """,
        (
            change_id,
            revision,
            subject,
            sender_name,
            sender_email,
            sent_at,
            added_at,
            message_id,
            num_patches,
            pw_series_id,
            fingerprint,
            int(is_rethreaded),
        ),
    )
    track_id = cursor.fetchone()[0]
    conn.commit()
    return int(track_id)


def cmd_track(cmdargs: argparse.Namespace) -> None:
    """Track a series in the review database."""
    topdir = b4.git_get_toplevel()

    # Resolve identifier
    identifier = resolve_identifier(cmdargs, topdir)
    if not identifier:
        logger.critical('Could not determine project identifier.')
        logger.critical('Run from an enrolled repository or specify -i identifier')
        sys.exit(1)

    if not db_exists(identifier):
        if topdir and get_repo_identifier(topdir) == identifier:
            logger.info('Recreating missing database for %s', identifier)
            conn = init_db(identifier)
            conn.close()
            rescan_branches(identifier, topdir)
        else:
            logger.critical('Project not enrolled: %s', identifier)
            logger.critical('Run "b4 review enroll" first')
            sys.exit(1)

    # Parse the series identifier (message-id, URL, or change-id)
    # Support reading from stdin if no series_id provided
    rethread = getattr(cmdargs, 'rethread', None)
    if rethread:
        if cmdargs.series_id:
            logger.critical(
                '--rethread cannot be used with a positional series_id argument'
            )
            sys.exit(1)
        series_id = None
    else:
        series_id = cmdargs.series_id
        if not series_id:
            series_id = b4.get_msgid_from_stdin()
            if not series_id:
                logger.critical('No series identifier provided')
                logger.critical(
                    'Pipe a message or pass msgid/URL/change-id as parameter'
                )
                sys.exit(1)

    # Set up cmdargs for retrieve_messages
    cmdargs.msgid = series_id
    cmdargs.localmbox = None
    cmdargs.nocache = True
    cmdargs.noparent = False
    cmdargs.wantname = None
    cmdargs.wantver = None

    # Retrieve the series — cancellable via Ctrl-C
    def _sigint_cancel(sig: int, frame: Optional[types.FrameType]) -> None:
        b4.get_lore_node().cancel()
        raise KeyboardInterrupt()

    _old_sigint = signal.signal(signal.SIGINT, _sigint_cancel)
    try:
        _msgid: Optional[str]
        msgs: Optional[List[Any]]
        if rethread:
            logger.info('Rethreading series from %d message IDs', len(rethread))
            # retrieve_rethreaded_messages reports whether it actually had to
            # stitch the patches together (was_rethreaded), or found and used
            # a properly-threaded copy of the same version posted to the list.
            _msgid, msgs, was_rethreaded = b4.retrieve_rethreaded_messages(cmdargs)
        else:
            logger.info('Retrieving series: %s', series_id)
            _msgid, msgs = b4.retrieve_messages(cmdargs)
            was_rethreaded = False
        if not msgs:
            logger.critical('Could not retrieve series: %s', series_id)
            sys.exit(1)

        # Build the mailbox to determine the series revision
        lmbx = b4.LoreMailbox()
        for msg in msgs:
            lmbx.add_message(msg)

        if not lmbx.series:
            logger.critical('No series found in retrieved messages')
            sys.exit(1)

        # Get the latest revision by default, or specified version
        if hasattr(cmdargs, 'wantver') and cmdargs.wantver:
            wanted_ver = cmdargs.wantver
        else:
            wanted_ver = max(lmbx.series.keys())

        # Discover all available revisions (newer and older)
        if b4.can_network:
            msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
            if wanted_ver > 1:
                msgs = b4.mbox.get_extra_series(
                    msgs,
                    direction=-1,
                    wantvers=list(range(1, wanted_ver)),
                    nocache=True,
                )
            # Rebuild the mailbox with all discovered messages
            lmbx = b4.LoreMailbox()
            for msg in msgs:
                lmbx.add_message(msg)
    except (KeyboardInterrupt, liblore.OperationCancelledError):
        logger.info('Interrupted, aborting')
        sys.exit(130)
    finally:
        signal.signal(signal.SIGINT, _old_sigint)

    lser = lmbx.get_series(wanted_ver, sloppytrailers=False, codereview_trailers=False)
    if not lser:
        logger.critical('Could not find series version %d', wanted_ver)
        sys.exit(1)

    # Extract series metadata
    revision = lser.revision
    sender_name = lser.fromname
    sender_email = lser.fromemail
    num_patches = lser.expected

    fingerprint = lser.fingerprint

    # Get message-id from cover letter or first patch.
    # For rethreaded series, skip the synthetic cover and use the first
    # real patch so the stored msgid is fetchable from lore.
    ref_msg: Optional[b4.LoreMessage] = None
    if was_rethreaded:
        for p in lser.patches[1:]:
            if p is not None:
                ref_msg = p
                break
    else:
        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:
        logger.critical('Could not find cover letter or first patch')
        sys.exit(1)

    message_id = ref_msg.msgid

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

    # Determine whether this thread already belongs to a tracked series.
    # An exact change-id/fingerprint match on the requested version catches
    # re-tracking the same series (or a newer version of a series that carries
    # an embedded change-id).  Failing that, the sibling revisions discovered
    # alongside it reveal a new version of an already-tracked series whose
    # synthesized change-id and per-revision fingerprint both differ from the
    # tracked version (bug 70fe607).
    config = b4.get_main_config()
    linkmask = str(config.get('linkmask', ''))
    conn = get_db(identifier)
    existing = conn.execute(
        'SELECT change_id FROM series WHERE change_id = ? OR fingerprint = ?',
        (change_id, fingerprint),
    ).fetchone()
    existing_change_id: Optional[str] = str(existing[0]) if existing else None
    if existing_change_id is None:
        discovered: List[Tuple[Optional[str], Optional[str]]] = []
        for v in sorted(lmbx.series.keys()):
            v_msgid, _, _ = _raw_revision_ref(
                lmbx, v, rethreaded=(was_rethreaded and v == revision)
            )
            discovered.append((getattr(lmbx.series[v], 'fingerprint', None), v_msgid))
        existing_change_id = find_existing_change_id(conn, discovered)

    # The rethreaded revision can't be re-fetched from a single message-id, so
    # its member patches must be recorded under whichever change-id wins.
    rethreaded_revs: Optional[Set[int]] = {revision} if was_rethreaded else None

    if existing_change_id is not None:
        # Already tracked: fold any newly discovered revisions into the
        # existing series instead of creating a duplicate (bug 70fe607).
        new_revs = _record_discovered_revisions(
            conn, existing_change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
        )
        srow = conn.execute(
            'SELECT status, revision FROM series WHERE change_id = ?',
            (existing_change_id,),
        ).fetchone()
        conn.close()
        status_str = str(srow[0]) if srow else 'unknown'
        cur_rev = int(srow[1]) if srow else 0
        if new_revs:
            # Mirror the freshly-recorded catalog onto the existing review
            # branch so the pending upgrade travels on push (it can't be
            # re-derived from lore when rethreaded).
            sync_revisions_catalog_to_branch(topdir, identifier, existing_change_id)
            logger.info(
                'Series already tracked as %s (status: %s, v%d)',
                existing_change_id,
                status_str,
                cur_rev,
            )
            logger.info(
                '  Recorded new revision(s): %s',
                ', '.join(f'v{v}' for v in new_revs),
            )
            if status_str == 'archived':
                # Archived series are not shown in the review TUI, so the
                # usual upgrade advice would point at nothing actionable.
                logger.info('  This series is archived and not shown in the TUI.')
                logger.info(
                    '  Use "b4 review forget %s" to erase it and track afresh.',
                    existing_change_id,
                )
            else:
                logger.info(
                    '  Upgrade the series in the review TUI to the new version.'
                )
            return
        logger.critical(
            'This series is already tracked (status: %s, v%d)', status_str, cur_rev
        )
        logger.critical('Change-ID: %s', existing_change_id)
        logger.critical(
            'Use "b4 review forget %s" to erase it and track afresh.',
            existing_change_id,
        )
        sys.exit(1)

    # Not tracked yet — create a new series and record its revisions.
    sent_at: Optional[str] = None
    if ref_msg.date:
        sent_at = ref_msg.date.isoformat()

    subject = lser.subject
    add_series_to_db(
        conn,
        change_id,
        revision,
        subject,
        sender_name,
        sender_email,
        sent_at,
        message_id,
        num_patches,
        fingerprint=fingerprint,
        is_rethreaded=was_rethreaded,
    )
    add_series_patches(conn, change_id, revision, lser)
    _record_discovered_revisions(
        conn, change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
    )
    conn.close()
    # Mirror the catalog onto the review branch when one already exists (a
    # fresh track usually has none yet — this is then a harmless no-op).
    sync_revisions_catalog_to_branch(topdir, identifier, change_id)

    logger.info('Tracked series: %s v%d (%d patches)', subject, revision, num_patches)
    logger.info('  From: %s <%s>', sender_name, sender_email)
    logger.info('  Change-ID: %s', change_id)
    logger.info('  Message-ID: %s', message_id)
    if len(lmbx.series) > 1:
        versions = ', '.join(f'v{v}' for v in sorted(lmbx.series.keys()))
        logger.info('  Known revisions: %s', versions)


def cmd_forget(cmdargs: argparse.Namespace) -> None:
    """Erase all records of a tracked series after confirmation.

    Accepts a message-id, lore URL, or change-id.  Unlike the TUI's
    abandon action this works on any tracked series regardless of
    status, so it is the escape hatch for series the TUI no longer
    shows (e.g. archived ones that a new posting keeps matching).
    """
    topdir = b4.git_get_toplevel()

    identifier = resolve_identifier(cmdargs, topdir)
    if not identifier:
        logger.critical('Could not determine project identifier.')
        logger.critical('Run from an enrolled repository or specify -i identifier')
        sys.exit(1)

    if not db_exists(identifier):
        logger.critical('Project not enrolled: %s', identifier)
        logger.critical('Run "b4 review enroll" first')
        sys.exit(1)

    series_id = cmdargs.series_id
    if not series_id:
        series_id = b4.get_msgid_from_stdin()
        if not series_id:
            logger.critical('No series identifier provided')
            logger.critical('Pipe a message or pass msgid/URL/change-id as parameter')
            sys.exit(1)

    token = b4.parse_msgid(series_id)
    conn = get_db(identifier)
    change_id = find_tracked_change_id(conn, token)
    if change_id is None:
        conn.close()
        logger.critical('No tracked series matches: %s', token)
        logger.critical('Pass a change-id or any message-id recorded for the series')
        sys.exit(1)

    # Show what is about to be erased.  A change_id can have multiple
    # series rows (legacy databases); describe the newest one.
    srow = conn.execute(
        'SELECT subject, sender_name, sender_email, status, revision, '
        'num_patches, sent_at FROM series WHERE change_id = ? '
        'ORDER BY revision DESC LIMIT 1',
        (change_id,),
    ).fetchone()
    revisions = get_revisions(conn, change_id)

    logger.info('Found tracked series:')
    logger.info('  Change-ID: %s', change_id)
    if srow:
        subject, s_name, s_email, status, revision, num_patches, sent_at = srow
        logger.info('    Subject: %s', subject or '(no subject)')
        logger.info('       From: %s <%s>', s_name or '?', s_email or '?')
        logger.info('     Status: %s (v%d, %d patches)', status, revision, num_patches)
        if sent_at:
            logger.info('       Sent: %s', sent_at)
    if revisions:
        versions = ', '.join(f'v{r["revision"]}' for r in revisions)
        logger.info('  Known revisions: %s', versions)

    review_branch = f'b4/review/{change_id}'
    has_branch = bool(topdir) and b4.git_branch_exists(topdir, review_branch)
    if has_branch:
        if b4.git_get_current_branch(topdir) == review_branch:
            conn.close()
            logger.critical(
                'Review branch %s is currently checked out; switch away first',
                review_branch,
            )
            sys.exit(1)
        logger.info('  Review branch %s will be DELETED', review_branch)

    logger.info('---')
    try:
        answer = input('Forget this series and erase all its records? (y/N) ')
    except KeyboardInterrupt:
        logger.info('')
        conn.close()
        sys.exit(130)
    if answer.strip().lower() not in ('y', 'yes'):
        logger.info('Aborted, nothing forgotten.')
        conn.close()
        sys.exit(0)

    if has_branch and topdir:
        ecode, out = b4.git_run_command(
            topdir, ['branch', '-D', review_branch], logstderr=True
        )
        if ecode != 0:
            conn.close()
            logger.critical('Could not delete branch %s: %s', review_branch, out)
            sys.exit(1)
        logger.info('Deleted review branch: %s', review_branch)

    delete_series(conn, change_id)
    conn.close()
    logger.info('Forgot series: %s', change_id)


def get_tracked_pw_series_ids(identifier: str) -> set[int]:
    """Get the set of Patchwork series IDs that are tracked for a project."""
    if not db_exists(identifier):
        return set()
    try:
        conn = get_db(identifier)
        cursor = conn.execute(
            'SELECT pw_series_id FROM series WHERE pw_series_id IS NOT NULL'
        )
        result = {row[0] for row in cursor.fetchall()}
        conn.close()
        return result
    except Exception:
        return set()


def is_pw_series_tracked(identifier: str, pw_series_id: int) -> bool:
    """Check if a Patchwork series ID is tracked."""
    if not db_exists(identifier):
        return False
    try:
        conn = get_db(identifier)
        cursor = conn.execute(
            'SELECT 1 FROM series WHERE pw_series_id = ? LIMIT 1', (pw_series_id,)
        )
        result = cursor.fetchone() is not None
        conn.close()
        return result
    except Exception:
        return False


def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]:
    """Get all tracked series for the TUI listing.

    Returns a list of dicts with keys: track_id, change_id, revision, subject,
    sender_name, sender_email, sent_at, added_at, status, num_patches,
    message_id, pw_series_id, message_count, seen_message_count.
    """
    if not db_exists(identifier):
        return []
    try:
        conn = get_db(identifier)
        cursor = conn.execute("""
            SELECT track_id, change_id, revision, subject, sender_name, sender_email,
                   sent_at, added_at, status, num_patches, message_id, pw_series_id,
                   message_count, seen_message_count, last_activity_at, attestation,
                   target_branch, is_rethreaded, snoozed_until
            FROM series
            ORDER BY added_at DESC
        """)
        result = []
        for row in cursor.fetchall():
            result.append(
                {
                    'track_id': row[0],
                    'change_id': row[1],
                    'revision': row[2],
                    'subject': row[3] or '(no subject)',
                    'sender_name': row[4] or 'Unknown',
                    'sender_email': row[5] or '',
                    'sent_at': row[6] or '',
                    'added_at': row[7] or '',
                    'status': row[8] or 'new',
                    'num_patches': row[9] or 0,
                    'message_id': row[10] or '',
                    'pw_series_id': row[11],
                    'message_count': row[12],
                    'seen_message_count': row[13],
                    'last_activity_at': row[14],
                    'attestation': row[15],
                    'target_branch': row[16],
                    'is_rethreaded': bool(row[17]),
                    'snoozed_until': row[18],
                }
            )
        conn.close()
        return result
    except Exception:
        return []


# Provenance ranking for a revision's `source`.  A higher rank wins: a manual
# link must override, and never be downgraded by, automated discovery.
_SOURCE_RANK = {'heuristic': 0, 'auto-consume': 0, 'manual': 1}


def _source_rank(source: Optional[str]) -> int:
    """Return the precedence rank of a revision source (unknown -> 0)."""
    return _SOURCE_RANK.get(source or '', 0)


def add_revision(
    conn: sqlite3.Connection,
    change_id: str,
    revision: int,
    message_id: str,
    subject: Optional[str] = None,
    link: Optional[str] = None,
    fingerprint: Optional[str] = None,
    source: str = 'heuristic',
    is_rethreaded: bool = False,
    subject_from_cover: bool = False,
) -> None:
    """Insert a revision record, ignoring core fields if already present.

    The core fields (message_id, subject, link, found_at) follow first-wins
    semantics — re-adding an existing revision leaves them untouched.  Four
    fields are reconciled on re-add, however:

    - ``fingerprint`` is backfilled if the existing row lacks one.
    - ``source`` is upgraded when the incoming provenance outranks the
      stored one, so a manual link wins over (and is never downgraded by)
      automated discovery.
    - ``is_rethreaded`` is sticky: once a revision is known to need
      rethreading, a later plain re-add must not clear the flag.
    - ``subject`` is reconciled when *subject_from_cover* says the incoming
      subject comes from the series' cover letter: it supersedes a
      first-patch fallback recorded before the cover was seen (bug 8bb6e4c),
      unless the stored row's provenance outranks the incoming one.
    """
    found_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
    conn.execute(
        """INSERT OR IGNORE INTO revisions
        (change_id, revision, message_id, subject, link, found_at,
         fingerprint, source, is_rethreaded)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (
            change_id,
            revision,
            message_id,
            subject,
            link,
            found_at,
            fingerprint,
            source,
            int(is_rethreaded),
        ),
    )
    # Promote the rethread flag on re-add, but never clear it.
    if is_rethreaded:
        conn.execute(
            'UPDATE revisions SET is_rethreaded = 1'
            ' WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        )
    # Backfill a missing fingerprint without disturbing an existing one.
    if fingerprint:
        conn.execute(
            'UPDATE revisions SET fingerprint = ?'
            ' WHERE change_id = ? AND revision = ?'
            " AND (fingerprint IS NULL OR fingerprint = '')",
            (fingerprint, change_id, revision),
        )
    # Upgrade provenance only when the incoming source outranks the stored one.
    row = conn.execute(
        'SELECT source, subject FROM revisions WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    if row is not None and _source_rank(source) > _source_rank(row[0]):
        conn.execute(
            'UPDATE revisions SET source = ? WHERE change_id = ? AND revision = ?',
            (source, change_id, revision),
        )
    # A cover-letter subject supersedes a first-patch fallback recorded before
    # the cover was seen, but never overrides a higher-ranked (manual) link.
    if (
        subject
        and subject_from_cover
        and row is not None
        and row[1] != subject
        and _source_rank(row[0]) <= _source_rank(source)
    ):
        conn.execute(
            'UPDATE revisions SET subject = ? WHERE change_id = ? AND revision = ?',
            (subject, change_id, revision),
        )
    conn.commit()


def set_revision_thread_blob(
    conn: sqlite3.Connection, change_id: str, revision: int, blob_sha: str
) -> None:
    """Record the git blob SHA of the cached mbox thread for a revision.

    The blob may later become unreachable (GC'd), so callers that read this
    value must tolerate a missing blob and fall back to a lore fetch.
    """
    conn.execute(
        'UPDATE revisions SET thread_blob = ? WHERE change_id = ? AND revision = ?',
        (blob_sha, change_id, revision),
    )
    conn.commit()


def add_series_patches(
    conn: sqlite3.Connection, change_id: str, revision: int, lser: 'b4.LoreSeries'
) -> None:
    """Store the member patches for a tracked series.

    Iterates lser.patches and inserts one row per non-None patch.
    Deletes any existing rows first so the call is idempotent.
    """
    conn.execute(
        'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    )
    rows = []
    for position, lmsg in enumerate(lser.patches):
        if lmsg is None:
            continue
        rows.append((change_id, revision, position, lmsg.msgid, lmsg.subject))
    if rows:
        conn.executemany(
            'INSERT INTO series_patches (change_id, revision, position, message_id, subject)'
            ' VALUES (?, ?, ?, ?, ?)',
            rows,
        )
    conn.commit()


def get_series_patches(
    conn: sqlite3.Connection, change_id: str, revision: int
) -> List[Dict[str, Any]]:
    """Return the stored patches for a series, ordered by position."""
    cols = ('position', 'message_id', 'subject')
    cursor = conn.execute(
        'SELECT position, message_id, subject FROM series_patches'
        ' WHERE change_id = ? AND revision = ? ORDER BY position ASC',
        (change_id, revision),
    )
    return [dict(zip(cols, row)) for row in cursor.fetchall()]


def build_known_revisions(
    conn: sqlite3.Connection, change_id: str
) -> List[Dict[str, Any]]:
    """Serialize the per-change_id revisions catalog for the tracking commit.

    Returns a list of plain dicts (one per known revision) mirroring the
    ``revisions`` table, with the stitched member patches inlined for
    rethreaded revisions.  This is the portable form stored under
    ``known-revisions`` in the review branch tracking commit so another machine
    can rebuild the catalog — including a *pending* rethreaded upgrade that
    cannot be re-derived from the mailing list — from git alone.
    """
    known: List[Dict[str, Any]] = []
    for r in get_revisions(conn, change_id):
        entry: Dict[str, Any] = {
            'revision': int(r['revision']),
            'message-id': r.get('message_id') or '',
        }
        if r.get('subject'):
            entry['subject'] = r['subject']
        if r.get('link'):
            entry['link'] = r['link']
        if r.get('fingerprint'):
            entry['fingerprint'] = r['fingerprint']
        if r.get('source'):
            entry['source'] = r['source']
        if r.get('is_rethreaded'):
            entry['is-rethreaded'] = True
            entry['patches'] = [
                {
                    'position': p['position'],
                    'message-id': p['message_id'],
                    'subject': p.get('subject') or '',
                }
                for p in get_series_patches(conn, change_id, int(r['revision']))
            ]
        known.append(entry)
    return known


def record_known_revisions(
    conn: sqlite3.Connection, change_id: str, known: Optional[List[Dict[str, Any]]]
) -> None:
    """Replay a serialized revisions catalog into the local DB.

    The inverse of :func:`build_known_revisions`.  Relies on ``add_revision``'s
    convergent semantics — first-wins core fields, no-downgrade ``source``, and
    sticky ``is_rethreaded`` — so replaying a catalog is purely additive and
    safe to run on every sync.
    """
    for entry in known or []:
        raw_rev = entry.get('revision')
        message_id = entry.get('message-id') or ''
        if raw_rev is None or not message_id:
            continue
        try:
            revision = int(raw_rev)
        except (TypeError, ValueError):
            continue
        is_rethreaded = bool(entry.get('is-rethreaded'))
        add_revision(
            conn,
            change_id,
            revision,
            message_id,
            subject=entry.get('subject'),
            link=entry.get('link'),
            fingerprint=entry.get('fingerprint'),
            source=entry.get('source') or 'heuristic',
            is_rethreaded=is_rethreaded,
        )
        patches = entry.get('patches') or []
        if is_rethreaded and patches:
            rows = [
                (
                    change_id,
                    revision,
                    int(p['position']),
                    p['message-id'],
                    p.get('subject') or '',
                )
                for p in patches
                if p.get('message-id') and p.get('position') is not None
            ]
            if rows:
                conn.execute(
                    'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
                    (change_id, revision),
                )
                conn.executemany(
                    'INSERT INTO series_patches'
                    ' (change_id, revision, position, message_id, subject)'
                    ' VALUES (?, ?, ?, ?, ?)',
                    rows,
                )
    conn.commit()


def sync_revisions_catalog_to_branch(
    topdir: Optional[str], identifier: str, change_id: str
) -> bool:
    """Mirror the DB revisions catalog into the review branch tracking commit.

    Writes (or refreshes) the ``known-revisions`` block on
    ``b4/review/<change_id>`` so a pending upgrade — notably a rethreaded one
    that cannot be re-derived from lore — travels with the branch on push.
    No-op (returns False) when there is no topdir, no such branch, or the
    catalog is already current.
    """
    if not topdir:
        return False
    import b4.review

    branch = f'b4/review/{change_id}'
    ecode, _ = b4.git_run_command(topdir, ['rev-parse', '--verify', branch])
    if ecode != 0:
        return False
    try:
        cover_text, tracking = b4.review.load_tracking(topdir, branch)
    except (SystemExit, Exception):
        return False
    conn = get_db(identifier)
    try:
        known = build_known_revisions(conn, change_id)
    finally:
        conn.close()
    if tracking.get('known-revisions') == known:
        return False
    tracking['known-revisions'] = known
    return bool(b4.review.save_tracking_ref(topdir, branch, cover_text, tracking))


_REVISION_COLS = (
    'change_id',
    'revision',
    'message_id',
    'subject',
    'link',
    'found_at',
    'thread_blob',
    'fingerprint',
    'source',
    'is_rethreaded',
)

_REVISION_SELECT = (
    'SELECT change_id, revision, message_id, subject, link, found_at,'
    ' thread_blob, fingerprint, source, is_rethreaded FROM revisions'
)


def get_revisions(conn: sqlite3.Connection, change_id: str) -> list[dict[str, Any]]:
    """Return all known revisions for a change_id, ordered ascending."""
    cursor = conn.execute(
        _REVISION_SELECT + ' WHERE change_id = ? ORDER BY revision ASC',
        (change_id,),
    )
    return [dict(zip(_REVISION_COLS, row)) for row in cursor.fetchall()]


def find_revision_by_fingerprint(
    conn: sqlite3.Connection, fingerprint: Optional[str]
) -> Optional[dict[str, Any]]:
    """Return the revision row owning a fingerprint, or None.

    Used to detect when a posting being linked is already tracked (possibly
    under a different change_id) so it can be absorbed rather than duplicated.
    An empty or None fingerprint never matches.
    """
    if not fingerprint:
        return None
    row = conn.execute(
        _REVISION_SELECT + ' WHERE fingerprint = ? LIMIT 1',
        (fingerprint,),
    ).fetchone()
    if row is None:
        return None
    return dict(zip(_REVISION_COLS, row))


def find_existing_change_id(
    conn: sqlite3.Connection,
    revisions: List[Tuple[Optional[str], Optional[str]]],
) -> Optional[str]:
    """Return the change_id of a tracked series this thread already belongs to.

    *revisions* is a list of ``(fingerprint, message_id)`` pairs, one per
    revision discovered for the thread being tracked.

    A *new version* of an already-tracked series has a different content
    fingerprint and, when its change-id is synthesized from date+slug+
    fingerprint, a different change-id than the tracked version -- so an exact
    change-id/fingerprint match on the requested version alone fails to
    recognize it (bug 70fe607).  The older sibling revisions discovered
    alongside it, however, still carry the message-ids (and fingerprints) they
    had when first tracked, so the overlap can be recognized through them.

    Message-ids are the reliable signal: every tracked and auto-discovered
    revision records one, and a given message belongs to exactly one series.
    Fingerprints are only stored for the originally tracked revision (in
    ``series``) and for manually linked revisions (in ``revisions``), and can
    collide for content-poor series (bug 78c0fa1), so they are consulted only
    as a fallback and must never override a message-id match.

    Accordingly this runs in two passes: first match message-ids across every
    discovered revision, and only if none match fall back to fingerprints.
    Doing fingerprints first would let a colliding fingerprint re-home a thread
    onto an unrelated series even when a message-id points elsewhere.
    """
    for _fingerprint, message_id in revisions:
        if message_id:
            row = conn.execute(
                'SELECT change_id FROM series WHERE message_id = ? LIMIT 1',
                (message_id,),
            ).fetchone()
            if row is not None:
                return str(row[0])
            row = conn.execute(
                'SELECT change_id FROM revisions WHERE message_id = ? LIMIT 1',
                (message_id,),
            ).fetchone()
            if row is not None:
                return str(row[0])
    for fingerprint, _message_id in revisions:
        if fingerprint:
            row = conn.execute(
                'SELECT change_id FROM series WHERE fingerprint = ? LIMIT 1',
                (fingerprint,),
            ).fetchone()
            if row is not None:
                return str(row[0])
            match = find_revision_by_fingerprint(conn, fingerprint)
            if match is not None:
                return str(match['change_id'])
    return None


def find_tracked_change_id(conn: sqlite3.Connection, token: str) -> Optional[str]:
    """Resolve *token* to the change_id of a tracked series, or None.

    *token* may be a change-id or a clean message-id (no angle brackets).
    Message-ids are looked up across every table that records one, so any
    message b4 has ever associated with the series works: the primary
    tracked message, a discovered/linked revision, or an individual patch.
    """
    if not token:
        return None
    for query in (
        'SELECT change_id FROM series WHERE change_id = ? LIMIT 1',
        'SELECT change_id FROM series WHERE message_id = ? LIMIT 1',
        'SELECT change_id FROM revisions WHERE message_id = ? LIMIT 1',
        'SELECT change_id FROM series_patches WHERE message_id = ? LIMIT 1',
    ):
        row = conn.execute(query, (token,)).fetchone()
        if row is not None:
            return str(row[0])
    return None


def _raw_revision_ref(
    lmbx: 'b4.LoreMailbox', revision: int, rethreaded: bool = False
) -> Tuple[str, str, bool]:
    """Return (msgid, subject, from_cover) identifying *revision* in *lmbx*.

    Prefer the parse-time cover letter, matching what the primary tracked
    revision stores: a raw (never ``get_series()``'d) revision has no cover
    injected into ``patches[0]``, so its first present patch would
    misidentify the series (bug 8bb6e4c).  Rethreaded revisions skip the
    cover, mirroring the primary path — their stored msgid must be a real
    patch.  Falls back to the first present patch when there is no cover.
    """
    if not rethreaded:
        cover = lmbx.covers.get(revision)
        if cover is not None:
            c_msgid = str(getattr(cover, 'msgid', '') or '')
            if c_msgid:
                c_subject = str(
                    getattr(cover, 'full_subject', '')
                    or getattr(cover, 'subject', '')
                    or ''
                )
                return c_msgid, c_subject, True
    v_ser = lmbx.series.get(revision)
    try:
        for p in getattr(v_ser, 'patches', None) or []:
            if p is not None:
                p_msgid = str(getattr(p, 'msgid', '') or '')
                p_subject = str(
                    getattr(p, 'full_subject', '') or getattr(p, 'subject', '') or ''
                )
                return p_msgid, p_subject, False
    except Exception:
        pass
    return '', '', False


def _record_discovered_revisions(
    conn: sqlite3.Connection,
    change_id: str,
    lmbx: 'b4.LoreMailbox',
    linkmask: str,
    rethreaded_revs: Optional[Set[int]] = None,
) -> List[int]:
    """Record every revision discovered in *lmbx* under *change_id*.

    Returns the revision numbers that were not already known for *change_id*
    (the genuinely new revisions).  Each revision's content fingerprint is
    stored alongside it so later tracking can recognize the series by content.
    A revision's identifying msgid/subject prefer its cover letter when the
    author sent one (see :func:`_raw_revision_ref`).

    Revisions in *rethreaded_revs* were stitched together from individually
    fetched patches (improper threading), so they are flagged rethreaded and
    their member patch message-ids are recorded — fetching the recorded
    message-id alone would not reconstitute the series, so retrieval must read
    the stored patches instead.
    """
    if rethreaded_revs is None:
        rethreaded_revs = set()
    known = {r['revision'] for r in get_revisions(conn, change_id)}
    new_revs: List[int] = []
    for v in sorted(lmbx.series.keys()):
        v_ser = lmbx.series[v]
        is_rt = v in rethreaded_revs
        v_msgid, v_subject, from_cover = _raw_revision_ref(lmbx, v, rethreaded=is_rt)
        if not v_msgid:
            continue
        v_link = (linkmask % v_msgid) if '%s' in linkmask else ''
        add_revision(
            conn,
            change_id,
            v,
            v_msgid,
            v_subject,
            v_link,
            fingerprint=getattr(v_ser, 'fingerprint', None),
            is_rethreaded=is_rt,
            subject_from_cover=from_cover,
        )
        if is_rt:
            add_series_patches(conn, change_id, v, v_ser)
        if v not in known:
            new_revs.append(v)
    return new_revs


def absorb_series_as_revision(
    conn: sqlite3.Connection,
    into_change_id: str,
    stray_change_id: str,
    revision: int,
) -> bool:
    """Re-home a stray stand-alone series as a revision of another change_id.

    When a posting is independently tracked under its own ``stray_change_id``
    but is really revision *revision* of ``into_change_id`` (e.g. a v-bump
    auto-discovery failed to connect), fold it in: record it as a manually
    linked revision, copy its patches, and delete the stray series wholesale
    (its ``series``, ``revisions``, and ``series_patches`` rows).

    The target's other revisions are left untouched.  Returns True if a stray
    series was absorbed, or False if none existed (making the call a safe
    no-op, including when invoked a second time).
    """
    srow = conn.execute(
        'SELECT revision, subject, message_id, fingerprint, is_rethreaded FROM series'
        ' WHERE change_id = ?',
        (stray_change_id,),
    ).fetchone()
    if srow is None:
        return False
    stray_rev = srow[0]

    # Prefer the per-revision record for link/fingerprint, falling back to the
    # series row when the stray was never recorded in the revisions table.
    rrow = conn.execute(
        'SELECT message_id, subject, link, fingerprint, is_rethreaded FROM revisions'
        ' WHERE change_id = ? AND revision = ?',
        (stray_change_id, stray_rev),
    ).fetchone()
    # Treat the stray as rethreaded if either its series row or its
    # per-revision record says so — both are set when tracked via --rethread,
    # but be defensive about a partially-populated stray.
    series_rt = bool(srow[4])
    if rrow is not None:
        message_id, subject, link, fingerprint = rrow[0], rrow[1], rrow[2], rrow[3]
        is_rethreaded = bool(rrow[4]) or series_rt
    else:
        message_id, subject, link, fingerprint = srow[2], srow[1], None, srow[3]
        is_rethreaded = series_rt

    add_revision(
        conn,
        into_change_id,
        revision,
        message_id,
        subject=subject,
        link=link,
        fingerprint=fingerprint,
        source='manual',
        is_rethreaded=is_rethreaded,
    )

    # Copy the stray's patches onto the target revision, replacing any present.
    conn.execute(
        'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
        (into_change_id, revision),
    )
    conn.execute(
        'INSERT INTO series_patches (change_id, revision, position, message_id, subject)'
        ' SELECT ?, ?, position, message_id, subject FROM series_patches'
        ' WHERE change_id = ? AND revision = ?',
        (into_change_id, revision, stray_change_id, stray_rev),
    )

    # Remove the stray series entirely.
    conn.execute('DELETE FROM series WHERE change_id = ?', (stray_change_id,))
    conn.execute('DELETE FROM revisions WHERE change_id = ?', (stray_change_id,))
    conn.execute('DELETE FROM series_patches WHERE change_id = ?', (stray_change_id,))
    conn.commit()
    return True


def _linkmask_url(message_id: str) -> str:
    """Render the configured lore link for a message-id, or '' if unset."""
    try:
        config = b4.get_main_config()
        linkmask = str(config.get('linkmask') or '')
    except Exception:
        linkmask = ''
    if message_id and '%s' in linkmask:
        return linkmask % message_id
    return ''


def _promote_if_waiting(conn: sqlite3.Connection, change_id: str) -> bool:
    """Promote a waiting series back to reviewing; return whether it changed."""
    row = conn.execute(
        "SELECT 1 FROM series WHERE change_id = ? AND status = 'waiting' LIMIT 1",
        (change_id,),
    ).fetchone()
    if row is None:
        return False
    conn.execute(
        "UPDATE series SET status = 'reviewing'"
        " WHERE change_id = ? AND status = 'waiting'",
        (change_id,),
    )
    conn.commit()
    return True


def _series_ref_message(lser: 'b4.LoreSeries') -> Optional['b4.LoreMessage']:
    """Pick the cover letter, or the first real patch, to source the msgid."""
    if getattr(lser, 'has_cover', False) and lser.patches[0] is not None:
        return lser.patches[0]
    for patch in lser.patches[1:]:
        if patch is not None:
            return patch
    return None


def record_linked_revision(
    conn: sqlite3.Connection,
    change_id: str,
    lser: 'b4.LoreSeries',
    force: bool = False,
    is_rethreaded: bool = False,
) -> Dict[str, Any]:
    """Fold a fetched series into *change_id* as a manually linked revision.

    The revision number is taken from the series itself, so this links a
    newer or an older version equally.  Behaviour:

    - If that revision is already known for *change_id* and *force* is False,
      returns ``status='collision'`` without mutating anything, so the caller
      can confirm before overwriting.
    - If the posting is already tracked as its own stray series (matched by
      fingerprint under a different change_id), that series is absorbed
      (``absorbed=True``) rather than duplicated.
    - Otherwise the revision and its patches are recorded with
      ``source='manual'``.

    When *is_rethreaded* is set, the fetched series was stitched from
    individually-fetched patches, so the revision is flagged accordingly and
    retrieval will reassemble it from the stored patches.

    A waiting target series is promoted back to reviewing (``promoted=True``).
    Returns a result dict with keys ``status``, ``revision``, ``absorbed``,
    and ``promoted``.
    """
    revision = int(lser.revision)
    result: Dict[str, Any] = {
        'status': 'linked',
        'revision': revision,
        'absorbed': False,
        'promoted': False,
    }

    ref_msg = _series_ref_message(lser)
    if ref_msg is None:
        result['status'] = 'no-series'
        return result

    known = {r['revision'] for r in get_revisions(conn, change_id)}
    if revision in known and not force:
        result['status'] = 'collision'
        return result

    fingerprint = lser.fingerprint
    stray = find_revision_by_fingerprint(conn, fingerprint)
    if stray is not None and stray['change_id'] != change_id:
        absorb_series_as_revision(conn, change_id, stray['change_id'], revision)
        result['absorbed'] = True
    else:
        message_id = ref_msg.msgid
        add_revision(
            conn,
            change_id,
            revision,
            message_id,
            subject=lser.subject,
            link=_linkmask_url(message_id),
            fingerprint=fingerprint,
            source='manual',
            is_rethreaded=is_rethreaded,
        )
        add_series_patches(conn, change_id, revision, lser)

    result['promoted'] = _promote_if_waiting(conn, change_id)
    return result


def unlink_revision(conn: sqlite3.Connection, change_id: str, revision: int) -> bool:
    """Remove a manually linked revision (and its patches).

    Refuses to remove a heuristically discovered revision so a manual undo
    can never delete something auto-discovery would just re-add.  Returns
    True if a manual revision was removed, False otherwise.
    """
    row = conn.execute(
        'SELECT source FROM revisions WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    if row is None or row[0] != 'manual':
        return False
    conn.execute(
        'DELETE FROM revisions WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    )
    conn.execute(
        'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    )
    conn.commit()
    return True


def fetch_series_for_link(series_id: str) -> Optional['b4.LoreSeries']:
    """Retrieve *series_id* from lore and return its series, or None.

    Returns None when the fetch yields no messages or no parseable series.
    Used by the link flow to obtain a series for a confirmation preview
    before any database mutation happens.
    """
    cmdargs = argparse.Namespace(
        msgid=series_id,
        localmbox=None,
        nocache=True,
        noparent=False,
        wantname=None,
        wantver=None,
    )
    try:
        _msgid, msgs = b4.retrieve_messages(cmdargs)
    except Exception:
        msgs = None
    if not msgs:
        return None
    lmbx = b4.LoreMailbox()
    for msg in msgs:
        lmbx.add_message(msg)
    return lmbx.get_series()


def link_revision(
    identifier: str, change_id: str, series_id: str, force: bool = False
) -> Dict[str, Any]:
    """Fetch *series_id* from lore and link it into *change_id*.

    Thin wrapper over record_linked_revision(): retrieves the posting, builds
    its series, and delegates.  A fetch that returns nothing is a graceful
    no-op (``status='not-found'``) that leaves the database untouched.
    """
    conn = get_db(identifier)
    try:
        lser = fetch_series_for_link(series_id)
        if lser is None:
            return {
                'status': 'not-found',
                'revision': None,
                'absorbed': False,
                'promoted': False,
            }
        return record_linked_revision(conn, change_id, lser, force=force)
    finally:
        conn.close()


def get_newest_revision(conn: sqlite3.Connection, change_id: str) -> Optional[int]:
    """Return the highest known revision number, or None."""
    cursor = conn.execute(
        'SELECT MAX(revision) FROM revisions WHERE change_id = ?', (change_id,)
    )
    row = cursor.fetchone()
    if row and row[0] is not None:
        return int(row[0])
    return None


def get_all_newest_revisions(conn: sqlite3.Connection) -> dict[str, int]:
    """Return {change_id: max_revision} for all change_ids with revisions."""
    cursor = conn.execute(
        'SELECT change_id, MAX(revision) FROM revisions GROUP BY change_id'
    )
    return {row[0]: int(row[1]) for row in cursor.fetchall()}


def get_all_revision_counts(conn: sqlite3.Connection) -> dict[str, int]:
    """Return {change_id: revision_count} for all change_ids."""
    cursor = conn.execute(
        'SELECT change_id, COUNT(*) FROM revisions GROUP BY change_id'
    )
    return {row[0]: int(row[1]) for row in cursor.fetchall()}


def get_all_revisions_grouped(
    conn: sqlite3.Connection,
) -> dict[str, list[dict[str, Any]]]:
    """Return {change_id: [rev_dicts]} for all change_ids, ordered ascending."""
    cols = (
        'change_id',
        'revision',
        'message_id',
        'subject',
        'link',
        'found_at',
        'thread_blob',
    )
    cursor = conn.execute(
        'SELECT change_id, revision, message_id, subject, link, found_at, thread_blob '
        'FROM revisions ORDER BY change_id, revision ASC'
    )
    result: dict[str, list[dict[str, Any]]] = {}
    for row in cursor.fetchall():
        entry: dict[str, Any] = dict(zip(cols, row))
        result.setdefault(row[0], []).append(entry)
    return result


def update_attestation(
    identifier: str, change_id: str, revision: int, attestation: Optional[str]
) -> None:
    """Store attestation result for a tracked series."""
    try:
        conn = get_db(identifier)
        conn.execute(
            'UPDATE series SET attestation = ? WHERE change_id = ? AND revision = ?',
            (attestation, change_id, revision),
        )
        conn.commit()
        conn.close()
    except Exception:
        pass


def update_series_status(
    conn: sqlite3.Connection,
    change_id: str,
    status: str,
    revision: Optional[int] = None,
) -> None:
    """Update the status of a tracked series.

    When *revision* is given only that specific revision is updated;
    otherwise all revisions sharing the *change_id* are updated (legacy
    behaviour kept for backwards compatibility).

    Always stamps last_activity_at with the current UTC time so that
    within-group sort reflects maintainer activity as well as thread activity.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if revision is not None:
        conn.execute(
            'UPDATE series SET status = ?, last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (status, now, change_id, revision),
        )
    else:
        conn.execute(
            'UPDATE series SET status = ?, last_activity_at = ? WHERE change_id = ?',
            (status, now, change_id),
        )
    conn.commit()


def update_series_revision(
    conn: sqlite3.Connection,
    change_id: str,
    old_revision: int,
    new_revision: int,
    new_message_id: str,
    new_subject: Optional[str] = None,
) -> None:
    """Switch a tracked series to a different revision number.

    Used when a not-yet-checked-out series should track a different
    revision without going through the full review checkout flow.
    Updates the revision, message_id, and optionally subject columns.
    Resets message_count and seen_message_count so the next update
    fetches fresh counts for the new revision's thread.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if new_subject is not None:
        conn.execute(
            'UPDATE series SET revision = ?, message_id = ?, subject = ?,'
            ' message_count = NULL, seen_message_count = NULL,'
            ' last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (new_revision, new_message_id, new_subject, now, change_id, old_revision),
        )
    else:
        conn.execute(
            'UPDATE series SET revision = ?, message_id = ?,'
            ' message_count = NULL, seen_message_count = NULL,'
            ' last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (new_revision, new_message_id, now, change_id, old_revision),
        )
    conn.commit()


def realign_series_subject(
    conn: sqlite3.Connection,
    change_id: str,
    revision: int,
    lmbx: 'b4.LoreMailbox',
) -> bool:
    """Re-title a tracked series from *lmbx*'s cover letter for *revision*.

    ``series.subject`` is only ever written when a series is first tracked or
    upgraded, so a row that took its title from the first patch before the
    cover letter was seen (bug 8bb6e4c) keeps showing it forever — unlike the
    revisions catalog, which heals itself on every re-add (see
    :func:`add_revision`).

    Only a cover-derived title is trusted: with nothing but a first-patch
    fallback in hand there is nothing better than what is already stored, and
    writing it back would corrupt a correctly titled row.  The bare titles are
    what get compared, since the tracking and upgrade paths format the
    ``[PATCH vN x/y]`` prefix differently and neither is wrong.  Returns True
    if the row was re-titled.
    """
    _msgid, subject, from_cover = _raw_revision_ref(lmbx, revision)
    if not from_cover or not subject:
        return False
    row = conn.execute(
        'SELECT subject FROM series WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    if row is None:
        return False
    if b4.LoreSubject(str(row[0] or '')).subject == b4.LoreSubject(subject).subject:
        return False
    conn.execute(
        'UPDATE series SET subject = ? WHERE change_id = ? AND revision = ?',
        (subject, change_id, revision),
    )
    conn.commit()
    return True


def snooze_series(
    conn: sqlite3.Connection,
    change_id: str,
    until_date: str,
    revision: Optional[int] = None,
) -> None:
    """Set a series to snoozed status with a wake-up date.

    Args:
        conn: Database connection.
        change_id: The change-id of the series.
        until_date: ISO date string (YYYY-MM-DD) when the series should wake up.
        revision: If given, only snooze that specific revision.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if revision is not None:
        conn.execute(
            'UPDATE series SET status = ?, snoozed_until = ?, last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            ('snoozed', until_date, now, change_id, revision),
        )
    else:
        conn.execute(
            'UPDATE series SET status = ?, snoozed_until = ?, last_activity_at = ?'
            ' WHERE change_id = ?',
            ('snoozed', until_date, now, change_id),
        )
    conn.commit()


def unsnooze_series(
    conn: sqlite3.Connection,
    change_id: str,
    previous_status: str,
    revision: Optional[int] = None,
) -> None:
    """Restore a snoozed series to its previous status.

    Args:
        conn: Database connection.
        change_id: The change-id of the series.
        previous_status: The status to restore (e.g. 'reviewing').
        revision: If given, only unsnooze that specific revision.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if revision is not None:
        conn.execute(
            'UPDATE series SET status = ?, snoozed_until = NULL, last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (previous_status, now, change_id, revision),
        )
    else:
        conn.execute(
            'UPDATE series SET status = ?, snoozed_until = NULL, last_activity_at = ?'
            ' WHERE change_id = ?',
            (previous_status, now, change_id),
        )
    conn.commit()


def get_expired_snoozed(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
    """Return all snoozed series whose wake-up time has passed."""
    cursor = conn.execute(
        'SELECT change_id, revision, snoozed_until FROM series'
        " WHERE status = 'snoozed'"
        " AND snoozed_until <= strftime('%Y-%m-%dT%H:%M:%S', 'now')"
    )
    results = []
    for row in cursor:
        results.append(
            {
                'change_id': row[0],
                'revision': row[1],
                'snoozed_until': row[2],
            }
        )
    return results


def get_tag_snoozed(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
    """Return all snoozed series waiting for a git tag to appear."""
    cursor = conn.execute(
        'SELECT change_id, revision, snoozed_until FROM series'
        " WHERE status = 'snoozed'"
        " AND snoozed_until LIKE 'tag:%'"
    )
    results = []
    for row in cursor:
        results.append(
            {
                'change_id': row[0],
                'revision': row[1],
                'snoozed_until': row[2],
            }
        )
    return results


def restore_snoozed_tracking(topdir: str, review_branch: str) -> str:
    """Restore previous state from snoozed tracking commit metadata.

    Reads the snoozed dict from the tracking commit, restores the
    previous status, removes the snoozed key, and saves the commit.
    Returns the previous status (defaults to 'reviewing').
    """
    import b4.review

    cover_text, tracking = b4.review.load_tracking(topdir, review_branch)
    trk_series = tracking.get('series', {})
    snoozed_info = trk_series.get('snoozed', {})
    prev_status = str(snoozed_info.get('previous_state', 'reviewing'))
    trk_series['status'] = prev_status
    trk_series.pop('snoozed', None)
    tracking['series'] = trk_series
    b4.review.save_tracking_ref(topdir, review_branch, cover_text, tracking)
    return prev_status


def _wake_snoozed_entry(
    conn: sqlite3.Connection, entry: Dict[str, Any], topdir: Optional[str]
) -> bool:
    """Restore a single snoozed series to its previous state.

    A series whose review branch is currently checked out is not woken
    at all: the wake-up writes both the tracking commit and the
    database, and rewriting a checked-out branch under the maintainer
    is off limits.  Waking only the database side would leave the two
    contradicting each other (the next rescan would re-snooze it).
    Returns True if the series was woken.
    """
    cid = entry['change_id']
    rev = entry['revision']
    prev_status = 'reviewing'
    review_branch = f'b4/review/{cid}'
    if topdir and b4.git_branch_exists(topdir, review_branch):
        if b4.git_branch_checked_out(topdir, review_branch):
            logger.debug('%s is checked out, deferring snooze wake-up', review_branch)
            return False
        try:
            prev_status = restore_snoozed_tracking(topdir, review_branch)
        except (SystemExit, Exception):
            pass
    unsnooze_series(conn, cid, prev_status, revision=rev)
    return True


def auto_wake_snoozed(identifier: str, topdir: Optional[str]) -> int:
    """Wake snoozed series whose wake-up condition has been met.

    Checks two conditions:
    - Time-based: the snoozed_until date has passed.
    - Tag-based: the target git tag now exists.

    For each woken series, restores the previous status in both the
    tracking commit and the database.  Returns the number woken.
    """
    woken = 0
    try:
        conn = get_db(identifier)
    except (FileNotFoundError, Exception):
        return woken
    try:
        for entry in get_expired_snoozed(conn):
            if _wake_snoozed_entry(conn, entry, topdir):
                woken += 1
        if topdir:
            for entry in get_tag_snoozed(conn):
                tagname = entry['snoozed_until'][4:]  # strip 'tag:' prefix
                if b4.git_revparse_tag(topdir, tagname):
                    if _wake_snoozed_entry(conn, entry, topdir):
                        woken += 1
    finally:
        conn.close()
    return woken


def get_snoozed_until(
    conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None
) -> Optional[str]:
    """Return the snoozed_until date for a series, or None."""
    if revision is not None:
        row = conn.execute(
            'SELECT snoozed_until FROM series WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        ).fetchone()
    else:
        row = conn.execute(
            'SELECT snoozed_until FROM series WHERE change_id = ?', (change_id,)
        ).fetchone()
    return row[0] if row else None


def update_target_branch(
    conn: sqlite3.Connection,
    change_id: str,
    target_branch: Optional[str],
    revision: Optional[int] = None,
) -> None:
    """Set or clear the per-series target branch in the database."""
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if revision is not None:
        conn.execute(
            'UPDATE series SET target_branch = ?, last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (target_branch, now, change_id, revision),
        )
    else:
        conn.execute(
            'UPDATE series SET target_branch = ?, last_activity_at = ?'
            ' WHERE change_id = ?',
            (target_branch, now, change_id),
        )
    conn.commit()


def get_target_branch(
    conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None
) -> Optional[str]:
    """Return the per-series target branch, or None."""
    if revision is not None:
        row = conn.execute(
            'SELECT target_branch FROM series WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        ).fetchone()
    else:
        row = conn.execute(
            'SELECT target_branch FROM series WHERE change_id = ?', (change_id,)
        ).fetchone()
    return row[0] if row else None


def get_series_status(
    conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None
) -> Optional[str]:
    """Return the current status of a tracked series, or None if unknown."""
    if revision is not None:
        row = conn.execute(
            'SELECT status FROM series WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        ).fetchone()
    else:
        row = conn.execute(
            'SELECT status FROM series WHERE change_id = ?', (change_id,)
        ).fetchone()
    return row[0] if row else None


def get_pw_series_id(
    conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None
) -> Optional[int]:
    """Return the Patchwork series id of a tracked series, or None."""
    if revision is not None:
        row = conn.execute(
            'SELECT pw_series_id FROM series WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        ).fetchone()
    else:
        row = conn.execute(
            'SELECT pw_series_id FROM series WHERE change_id = ?', (change_id,)
        ).fetchone()
    return int(row[0]) if row and row[0] is not None else None


def get_review_target_branches() -> list[str]:
    """Return all configured review-target-branch values."""
    config = b4.get_main_config()
    val = config.get('review-target-branch')
    match val:
        case None:
            return []
        case list():
            return [v for v in val if v]
        case str():
            if val:
                return [val]
            return []


def get_review_target_branch_default() -> Optional[str]:
    """Return the single configured target branch, or None if 0 or 2+."""
    branches = get_review_target_branches()
    if len(branches) == 1:
        return branches[0]
    return None


def get_review_branches(topdir: Optional[str] = None) -> list[str]:
    """List all b4/review/* branch names."""
    gitargs = ['for-each-ref', '--format=%(refname:short)', 'refs/heads/b4/review/']
    return b4.git_get_command_lines(topdir, gitargs)


def _latest_date_from_msgs(msgs: List[Any]) -> Optional[str]:
    """Return the most recent Date header from EmailMessage objects as ISO timestamp."""
    latest: Optional[datetime.datetime] = None
    for msg in msgs:
        date_hdr = msg.get('Date')
        if not date_hdr:
            continue
        try:
            dt = email.utils.parsedate_to_datetime(str(date_hdr))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=datetime.timezone.utc)
            if latest is None or dt > latest:
                latest = dt
        except Exception:
            continue
    if latest is None:
        return None
    return latest.astimezone(datetime.timezone.utc).isoformat()


def update_message_count_from_msgs(
    conn: sqlite3.Connection,
    change_id: str,
    revision: int,
    msgs: List[Any],
    topdir: Optional[str] = None,
    seen_bump: int = 0,
) -> bool:
    """Update message count and thread blob from already-fetched messages.

    On the first call (``message_count IS NULL``), initialises both
    ``message_count`` and ``seen_message_count`` to the same value so
    no unread badge appears until *new* activity arrives.  On subsequent
    calls only ``message_count`` is updated (so the badge reflects new
    replies), except that ``seen_message_count`` is advanced by
    *seen_bump* — the caller's count of new-to-the-thread messages that
    are already read (e.g. the maintainer's own replies).

    Returns True if the database was changed, False otherwise.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    count = len(msgs)
    last_activity = _latest_date_from_msgs(msgs)

    row = conn.execute(
        'SELECT message_count, seen_message_count FROM series'
        ' WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    existing_count = row['message_count'] if row else None

    if existing_count is None:
        # First fetch — initialise seen = count (no badge yet)
        conn.execute(
            'UPDATE series'
            ' SET message_count = ?, seen_message_count = ?,'
            '     last_update_check = ?, last_activity_at = ?'
            ' WHERE change_id = ? AND revision = ?',
            (count, count, now, last_activity, change_id, revision),
        )
    elif count != existing_count:
        # Count changed — update count but not seen (badge will appear),
        # save for any already-read new messages reported by the caller
        if seen_bump > 0:
            new_seen = min(count, (row['seen_message_count'] or 0) + seen_bump)
            conn.execute(
                'UPDATE series'
                ' SET message_count = ?, seen_message_count = ?,'
                '     last_update_check = ?,'
                '     last_activity_at = COALESCE(?, last_activity_at)'
                ' WHERE change_id = ? AND revision = ?',
                (count, new_seen, now, last_activity, change_id, revision),
            )
        else:
            conn.execute(
                'UPDATE series'
                ' SET message_count = ?, last_update_check = ?,'
                '     last_activity_at = COALESCE(?, last_activity_at)'
                ' WHERE change_id = ? AND revision = ?',
                (count, now, last_activity, change_id, revision),
            )
    else:
        # No change — just stamp the check time, skip commit
        conn.execute(
            'UPDATE series SET last_update_check = ?'
            ' WHERE change_id = ? AND revision = ?',
            (now, change_id, revision),
        )
        conn.commit()
        return False

    conn.commit()
    if topdir and msgs:
        _store_thread_blob(topdir, change_id, msgs)
    return True


def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional[str]:
    """Serialize msgs to mboxrd and write as a git blob; update tracking commit.

    Also writes thread-context-blob (the plain-text rendered context for the
    AI agent) in the same save_tracking_ref call to avoid a second write.

    Returns the mbox blob SHA, or None on failure.  Non-fatal: a failure here
    just means the next 'f' press will fall back to a live lore fetch.
    """
    # Local import first — avoids circular deps AND prevents UnboundLocalError
    # that would occur if `import b4.review` appeared after a `b4.xxx` call.
    import io

    import b4.review as _b4_review

    buf = io.BytesIO()
    b4.save_mboxrd_mbox(msgs, buf)
    mbox_bytes = buf.getvalue()
    if not mbox_bytes:
        logger.debug('No bytes to store for thread blob for %s', change_id)
        return None

    ecode, out = b4.git_run_command(
        topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes
    )
    if ecode != 0:
        logger.debug('Could not write thread blob for %s', change_id)
        return None
    blob_sha = out.strip()

    branch_name = f'b4/review/{change_id}'
    if b4.git_branch_exists(topdir, branch_name):
        try:
            cover_text, tracking = _b4_review.load_tracking(topdir, branch_name)
            series = tracking.get('series', {})
            changed = False

            if series.get('thread-blob') != blob_sha:
                series['thread-blob'] = blob_sha
                changed = True

            # Build and store the context blob in the same write.
            cover_msgid = series.get('header-info', {}).get('msgid')
            cover_subject = series.get('subject', '')
            patches = tracking.get('patches', [])
            followup_comments = _parse_msgs_to_followup_comments(
                msgs, cover_msgid, patches
            )
            context_text = _render_thread_context(
                followup_comments, patches, cover_subject
            )
            if context_text.strip():
                ctx_bytes = context_text.encode()
                ecode2, ctx_out = b4.git_run_command(
                    topdir, ['hash-object', '-w', '--stdin'], stdin=ctx_bytes
                )
                if ecode2 == 0:
                    ctx_sha = ctx_out.strip()
                    if series.get('thread-context-blob') != ctx_sha:
                        series['thread-context-blob'] = ctx_sha
                        changed = True

            if changed:
                _b4_review.save_tracking_ref(topdir, branch_name, cover_text, tracking)
        except Exception as ex:
            logger.debug('Could not update thread blobs for %s: %s', change_id, ex)
    return blob_sha


def get_thread_mbox(topdir: str, blob_sha: str) -> Optional[bytes]:
    """Read cached thread mbox bytes from a git blob; None if unavailable (e.g. GC'd)."""
    ecode, out = b4.git_run_command(
        topdir, ['cat-file', 'blob', blob_sha], decode=False
    )
    if ecode != 0:
        logger.debug("Followup blob %s not found (may have been GC'd)", blob_sha)
        return None
    return out


def _resolve_patch_for_followup_local(
    in_reply_to: Optional[str],
    patch_msgids: Dict[str, int],
    msgid_map: Dict[str, Any],
) -> Optional[int]:
    """Walk the in_reply_to chain to find which patch a follow-up belongs to.

    Returns the display index (0=cover, 1..N=patches) or None.
    Duplicated from review_tui._common to avoid a cross-layer import.
    """
    seen: set[str] = set()
    current = in_reply_to
    while current and current not in seen:
        if current in patch_msgids:
            return patch_msgids[current]
        seen.add(current)
        lmsg = msgid_map.get(current)
        if lmsg is None:
            break
        current = lmsg.in_reply_to
    return None


def _get_followup_depth_local(
    in_reply_to: Optional[str],
    patch_msgids: Dict[str, int],
    msgid_map: Dict[str, Any],
    max_depth: int = 5,
) -> int:
    """Return the threading depth of a follow-up relative to its patch.

    Depth 0 = direct reply to a patch; depth N = N hops through follow-up
    replies. Capped at max_depth to prevent runaway indentation.
    Duplicated from review_tui._common to avoid a cross-layer import.
    """
    depth = 0
    seen: Set[str] = set()
    current = in_reply_to
    while current and current not in seen:
        if current in patch_msgids:
            break
        seen.add(current)
        lmsg = msgid_map.get(current)
        if lmsg is None:
            break
        depth += 1
        current = lmsg.in_reply_to
    return min(depth, max_depth)


def _parse_msgs_to_followup_comments(
    msgs: List[Any],
    cover_msgid: Optional[str],
    patches: List[Dict[str, Any]],
) -> Dict[int, List[Dict[str, Any]]]:
    """Parse email messages into a followup-comments dict.

    Returns Dict[display_idx -> List[{body, fromname, fromemail, date}]].
    display_idx 0 = cover letter, 1..N = patches.
    """
    minimised = b4.mbox.minimize_thread(list(msgs))
    minimised_body_map: Dict[str, str] = {}
    for mmsg in minimised:
        mid = b4.LoreMessage.clean_header(mmsg.get('Message-ID', ''))
        if mid:
            mid = mid.strip('<>')
            payload = mmsg.get_payload(decode=True)
            if isinstance(payload, bytes):
                minimised_body_map[mid] = payload.decode(errors='replace')
            elif isinstance(payload, str):
                minimised_body_map[mid] = payload

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

    patch_msgids: Dict[str, int] = {}
    if cover_msgid:
        patch_msgids[cover_msgid] = 0
    for i, pmeta in enumerate(patches):
        pmsgid = pmeta.get('header-info', {}).get('msgid')
        if pmsgid:
            patch_msgids[pmsgid] = i + 1

    followup_comments: Dict[int, List[Dict[str, Any]]] = {}
    for lmsg in sorted(lmbx.followups, key=lambda m: m.date):
        display_idx = _resolve_patch_for_followup_local(
            lmsg.in_reply_to, patch_msgids, lmbx.msgid_map
        )
        if display_idx is None:
            continue
        mbody = minimised_body_map.get(lmsg.msgid, '').strip()
        if not mbody:
            continue
        mbody_lines = [ln for ln in mbody.splitlines() if ln.strip()]
        if len(mbody_lines) <= 2 and mbody_lines[-1].strip().endswith(':'):
            continue
        _htrs, _cmsg, mtrs, _basement, _sig = b4.LoreMessage.get_body_parts(lmsg.body)
        if mtrs:
            trailer_block = '\n'.join(t.as_string() for t in mtrs)
            mbody = mbody.rstrip('\n') + '\n\n' + trailer_block
        entry: Dict[str, Any] = {
            'body': mbody,
            'fromname': lmsg.fromname,
            'fromemail': lmsg.fromemail,
            'date': lmsg.date,
            'msgid': lmsg.msgid,
            'subject': lmsg.subject,
            'depth': _get_followup_depth_local(
                lmsg.in_reply_to, patch_msgids, lmbx.msgid_map
            ),
        }
        followup_comments.setdefault(display_idx, []).append(entry)

    for fc_list in followup_comments.values():
        fc_list.sort(key=lambda e: e['date'])

    return followup_comments


def _render_thread_context(
    followup_comments: Dict[int, List[Dict[str, Any]]],
    patches: List[Dict[str, Any]],
    cover_subject: str,
) -> str:
    """Render a followup-comments dict as a human-readable plain-text string."""
    lines: List[str] = []
    n_patches = len(patches)
    for display_idx in sorted(followup_comments.keys()):
        fc_list = followup_comments[display_idx]
        if not fc_list:
            continue
        if display_idx == 0:
            section = f'Follow-up: cover letter ({cover_subject})'
        else:
            patch_idx = display_idx - 1
            title = (
                patches[patch_idx].get('title', f'patch {display_idx}')
                if patch_idx < len(patches)
                else f'patch {display_idx}'
            )
            section = f'Follow-up: patch {display_idx}/{n_patches} — {title}'
        lines.append(f'== {section} ==')
        lines.append('')
        for entry in fc_list:
            date_str = (
                entry['date'].strftime('%Y-%m-%d %H:%M %z') if entry.get('date') else ''
            )
            lines.append(
                f'From: {entry["fromname"]} <{entry["fromemail"]}> | {date_str}'
            )
            lines.append('')
            lines.append(entry['body'].rstrip())
            lines.append('')
    return '\n'.join(lines)


def render_prior_review_context(
    maintainer_email: str,
    revision: int,
    series: Dict[str, Any],
    patches: List[Dict[str, Any]],
) -> str:
    """Render the maintainer's notes and replies into a plain-text summary.

    Only includes the maintainer's own review data (notes and replies),
    not agent or community feedback. This is stored in the tracking dict
    during revision upgrades so it survives git gc (unlike loose blobs).
    """
    lines: List[str] = []
    n_patches = len(patches)

    lines.append(f'== Prior review feedback from v{revision} ==')
    lines.append('')

    def _render_review_section(review: Dict[str, Any]) -> List[str]:
        """Render a single reviewer's feedback for one patch or cover letter."""
        out: List[str] = []
        note = review.get('note', '').strip()
        reply = review.get('reply', '').strip()
        trailers: List[Any] = review.get('trailers', [])
        comments: List[Any] = review.get('comments', [])
        if note:
            out.append('Private note:')
            out.append(note)
            out.append('')
        if reply:
            out.append('Public reply:')
            out.append(reply)
            out.append('')
        if trailers:
            out.append('Trailers:')
            for t in trailers:
                out.append(f'  {t}')
            out.append('')
        if comments:
            out.append('Inline comments:')
            for c in comments:
                path = c.get('path', '')
                line = c.get('line', 0)
                text = c.get('text', '').strip()
                out.append(f'  [{path}:{line}]')
                for tline in text.splitlines():
                    out.append(f'    {tline}')
                out.append('')
        return out

    # Cover letter feedback
    cover_review = series.get('reviews', {}).get(maintainer_email, {})
    cover_section = _render_review_section(cover_review)
    if cover_section:
        cover_subject = series.get('subject', 'cover letter')
        lines.append(f'== Cover letter: {cover_subject} ==')
        lines.extend(cover_section)

    # Per-patch feedback
    for idx, patch in enumerate(patches):
        patch_num = idx + 1
        title = patch.get('title', f'patch {patch_num}')
        review = patch.get('reviews', {}).get(maintainer_email, {})
        patch_section = _render_review_section(review)

        lines.append(f'== Patch {patch_num}/{n_patches} — {title} ==')
        if not patch_section:
            lines.append('[No feedback]')
            lines.append('')
        else:
            lines.extend(patch_section)

    return '\n'.join(lines)


def ensure_thread_context_blob(
    topdir: str, change_id: str, series: Dict[str, Any], patches: List[Dict[str, Any]]
) -> Optional[str]:
    """Ensure thread-context-blob exists in the tracking commit.

    Migration aid: if thread-blob was written before this feature existed
    (no thread-context-blob key), parse the stored mbox and write the context
    blob now.  Also updates the in-memory *series* dict so the caller sees
    the new key immediately.

    Returns the context blob SHA, or None if thread-blob doesn't exist or the
    operation fails.
    """
    import b4.review as _b4_review

    if series.get('thread-context-blob'):
        return str(series['thread-context-blob'])

    blob_sha = series.get('thread-blob')
    if not blob_sha:
        return None

    mbox_bytes = get_thread_mbox(topdir, blob_sha)
    if not mbox_bytes:
        return None

    msgs = b4.split_and_dedupe_pi_results(mbox_bytes)
    if not msgs:
        return None

    cover_msgid = series.get('header-info', {}).get('msgid')
    cover_subject = series.get('subject', '')
    followup_comments = _parse_msgs_to_followup_comments(msgs, cover_msgid, patches)
    context_text = _render_thread_context(followup_comments, patches, cover_subject)
    if not context_text.strip():
        return None

    ctx_bytes = context_text.encode()
    ecode, out = b4.git_run_command(
        topdir, ['hash-object', '-w', '--stdin'], stdin=ctx_bytes
    )
    if ecode != 0:
        logger.debug('Could not write thread-context blob for %s', change_id)
        return None
    ctx_sha = out.strip()

    branch_name = f'b4/review/{change_id}'
    if b4.git_branch_exists(topdir, branch_name):
        try:
            cover_text, tracking = _b4_review.load_tracking(topdir, branch_name)
            if tracking.get('series', {}).get('thread-context-blob') != ctx_sha:
                tracking['series']['thread-context-blob'] = ctx_sha
                _b4_review.save_tracking_ref(topdir, branch_name, cover_text, tracking)
        except Exception as ex:
            logger.debug(
                'Could not persist thread-context-blob for %s: %s', change_id, ex
            )

    series['thread-context-blob'] = ctx_sha
    return ctx_sha


def mark_all_messages_seen(
    conn: sqlite3.Connection, change_id: str, revision: int
) -> None:
    """Set seen_message_count = message_count, clearing the unread badge."""
    conn.execute(
        'UPDATE series SET seen_message_count = message_count'
        ' WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    )
    conn.commit()


def sync_seen_from_unseen_count(
    identifier: str, change_id: str, revision: int, unseen_count: int
) -> bool:
    """Sync seen_message_count so the unread badge matches the messages DB.

    Sets ``seen_message_count = message_count - unseen_count``, clamped
    to [0, message_count].  Only writes when the value actually changes.

    Returns True if the database was updated, False otherwise.
    """
    try:
        conn = get_db(identifier)
    except FileNotFoundError:
        return False

    row = conn.execute(
        'SELECT message_count, seen_message_count FROM series'
        ' WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    if row is None:
        conn.close()
        return False

    fc = row['message_count']
    if fc is None:
        conn.close()
        return False

    new_seen = max(0, min(fc, fc - unseen_count))
    if new_seen == row['seen_message_count']:
        conn.close()
        return False

    conn.execute(
        'UPDATE series SET seen_message_count = ? WHERE change_id = ? AND revision = ?',
        (new_seen, change_id, revision),
    )
    conn.commit()
    conn.close()
    return True


def refresh_message_count(
    identifier: str, change_id: str, revision: int, total_messages: int
) -> bool:
    """Opportunistically refresh the message count from already-fetched messages.

    Called when thread messages have been fetched for another purpose (e.g.
    viewing in the lite email viewer, checking out for review, or
    taking/accepting a series).

    Only ``message_count`` and ``last_update_check`` are updated;
    ``seen_message_count`` is left unchanged so the unread badge
    continues to reflect the actual read state from the messages DB.
    When ``message_count`` was NULL (first fetch), ``seen_message_count``
    is initialised to the same value (no badge) as a safe default.

    Only writes to the database when the count differs from the stored
    value, keeping the DB mtime stable when nothing changed.

    Returns True if the database was updated, False otherwise.
    """
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    try:
        conn = get_db(identifier)
    except FileNotFoundError:
        return False

    row = conn.execute(
        'SELECT message_count, seen_message_count FROM series'
        ' WHERE change_id = ? AND revision = ?',
        (change_id, revision),
    ).fetchone()
    if row is None:
        conn.close()
        return False

    count = total_messages
    old_count = row['message_count']

    if old_count is not None and count == old_count:
        # Nothing changed — skip the write to keep the DB mtime stable.
        conn.close()
        return False

    if old_count is None:
        # First fetch: initialise both counts equally (no badge).
        conn.execute(
            'UPDATE series SET message_count = ?, seen_message_count = ?,'
            '  last_update_check = ?'
            ' WHERE change_id = ? AND revision = ?',
            (count, count, now, change_id, revision),
        )
    else:
        # Count changed: update only message_count; cap seen if it
        # exceeds the new count (possible when dedup reduces the total).
        seen = row['seen_message_count']
        if seen is not None and seen > count:
            conn.execute(
                'UPDATE series SET message_count = ?, seen_message_count = ?,'
                '  last_update_check = ?'
                ' WHERE change_id = ? AND revision = ?',
                (count, count, now, change_id, revision),
            )
        else:
            conn.execute(
                'UPDATE series SET message_count = ?, last_update_check = ?'
                ' WHERE change_id = ? AND revision = ?',
                (count, now, change_id, revision),
            )

    conn.commit()
    conn.close()
    return True


def rescan_branches(
    identifier: str, topdir: str, branch: Optional[str] = None
) -> Dict[str, int]:
    """Rescan review branches and sync status/metadata into the tracking DB.

    Iterates b4/review/* branches (or a single branch if specified).  For each
    branch the HEAD commit SHA is compared against the value stored in the DB;
    if they match the branch is skipped entirely (fast path).  Only branches
    whose SHA has changed (or that have no stored SHA) are fully re-read and
    upserted.  When doing a full rescan (branch=None), series whose branches
    have disappeared are marked as 'gone'.

    Returns ``{'gone': n, 'changed': n}`` where ``changed`` is the number of
    branches whose SHA differed and were re-processed.
    """
    import b4.review

    if branch:
        branches = [branch]
    else:
        branches = get_review_branches(topdir)

    conn = get_db(identifier)
    scanned_change_ids: set[str] = set()
    changed = 0

    for br in branches:
        # Skip temporary branches (e.g. upgrade work branches)
        if br.startswith('b4/review/_tmp-'):
            continue
        # Derive change_id from the branch name for the fast SHA check.
        change_id_from_branch = br.removeprefix('b4/review/')

        # Read the current HEAD SHA with a single cheap rev-parse call.
        ecode, sha_out = b4.git_run_command(topdir, ['rev-parse', br])
        if ecode != 0:
            logger.warning('Could not resolve HEAD for %s, skipping', br)
            continue
        current_sha = sha_out.strip()

        # Check the stored SHA for the most recent revision of this change_id.
        stored = conn.execute(
            'SELECT branch_sha FROM series WHERE change_id = ?'
            ' ORDER BY revision DESC LIMIT 1',
            (change_id_from_branch,),
        ).fetchone()
        if stored and stored['branch_sha'] == current_sha:
            # Branch HEAD unchanged — skip the expensive tracking-commit read.
            scanned_change_ids.add(change_id_from_branch)
            continue

        # SHA changed (or no record yet) — do the full read and upsert.
        try:
            _cover_text, tracking = b4.review.load_tracking(topdir, br)
        except (SystemExit, Exception):
            logger.warning('Could not load tracking from %s, skipping', br)
            continue

        series = tracking.get('series', {})
        track_id_value = series.get('identifier')

        # Verify identifier matches (skip if mismatch)
        if track_id_value and track_id_value != identifier:
            logger.warning(
                'Branch %s has identifier %s, expected %s; skipping',
                br,
                track_id_value,
                identifier,
            )
            continue

        change_id = series.get('change-id')
        if not change_id:
            logger.warning('Branch %s has no change-id, skipping', br)
            continue

        scanned_change_ids.add(change_id)
        status = series.get('status', 'reviewing')
        revision = series.get('revision', 1)

        # Parse sent date from header-info
        sent_at = None
        sentdate = series.get('header-info', {}).get('sentdate')
        if sentdate:
            try:
                dt = email.utils.parsedate_to_datetime(sentdate)
                sent_at = dt.isoformat()
            except Exception:
                pass

        message_id = series.get('header-info', {}).get('msgid', '')
        is_rethreaded = series.get('is-rethreaded', False)

        # Upsert metadata and sync status from the tracking commit.
        tracked_at = series.get('tracked-at')
        add_series_to_db(
            conn,
            change_id,
            revision=revision,
            subject=series.get('subject'),
            sender_name=series.get('fromname'),
            sender_email=series.get('fromemail'),
            sent_at=sent_at,
            message_id=message_id,
            num_patches=series.get('expected', 0),
            added_at=tracked_at,
            is_rethreaded=is_rethreaded,
        )
        update_series_status(conn, change_id, status, revision=revision)

        # Rebuild series_patches from tracking commit data
        tracking_patches = tracking.get('patches', [])
        if tracking_patches:
            conn.execute(
                'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
                (change_id, revision),
            )
            rows = []
            for i, p in enumerate(tracking_patches, 1):
                p_msgid = p.get('header-info', {}).get('msgid', '')
                if p_msgid:
                    rows.append((change_id, revision, i, p_msgid, p.get('title', '')))
            if rows:
                conn.executemany(
                    'INSERT INTO series_patches (change_id, revision, position, message_id, subject)'
                    ' VALUES (?, ?, ?, ?, ?)',
                    rows,
                )
                conn.commit()

        # Rebuild the full revisions catalog (all known versions, including a
        # pending rethreaded upgrade and its stitched patch list) from the
        # portable known-revisions block.  Re-derivable for normally-threaded
        # series via lore, but the rethreaded ones are not, so this is what
        # makes the relationship survive a push/sync to another machine.
        record_known_revisions(conn, change_id, tracking.get('known-revisions'))

        # Persist the new HEAD SHA so future rescans can skip this branch.
        conn.execute(
            'UPDATE series SET branch_sha = ? WHERE change_id = ? AND revision = ?',
            (current_sha, change_id, revision),
        )
        conn.commit()

        logger.info('Rescanned: %s (status: %s)', change_id, status)
        changed += 1

    # Full rescan: mark missing branches as "gone"
    gone = 0
    if not branch:
        all_series = get_all_tracked_series(identifier)
        active_statuses = ('reviewing', 'replied', 'partial', 'waiting')
        for s in all_series:
            sid = s.get('change_id')
            if not sid:
                continue
            if s.get('status') in active_statuses and sid not in scanned_change_ids:
                branch_name = f'b4/review/{sid}'
                if not b4.git_branch_exists(topdir, branch_name):
                    update_series_status(conn, sid, 'gone', revision=s.get('revision'))
                    logger.info('Marked as gone: %s', sid)
                    gone += 1

    conn.close()
    return {'gone': gone, 'changed': changed}


def delete_series(
    conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None
) -> None:
    """Delete a series from the database.

    When *revision* is given only that specific revision is removed;
    otherwise all revisions sharing the *change_id* are removed (legacy
    behaviour kept for backwards compatibility).
    """
    if revision is not None:
        conn.execute(
            'DELETE FROM revisions WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        )
        conn.execute(
            'DELETE FROM series WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        )
        conn.execute(
            'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
            (change_id, revision),
        )
    else:
        conn.execute('DELETE FROM revisions WHERE change_id = ?', (change_id,))
        conn.execute('DELETE FROM series WHERE change_id = ?', (change_id,))
        conn.execute('DELETE FROM series_patches WHERE change_id = ?', (change_id,))
    conn.commit()
