#!/usr/bin/env python3
"""Ranked match worklist for the USA executable.

Phase 6 selected match targets by hand from the boundary inventory. This tool
turns the Phase 7 evidence into a single deterministic queue: which candidates
to attempt, in what order, and what each one costs.

## Eligibility

A candidate is listed only if all of these hold:

  * it carries an extent (`tools/sf3_extents` grade `exact` or `fallthrough`);
  * its body is not degenerate -- an all-zero body is a zero-filled region the
    walk ran through, not code (see `docs/PHASE7_DUPES.md`);
  * it is not already registered in the region registry;
  * it is not the header entry (CRT startup is not compiler output);
  * it is not named by `--exclude`.

Candidates graded `indirect` (jump-table switch), `escape` (control flow leaves
the region), `outside`, `runaway`, `contained` or `standalone` are excluded and
counted, not listed.

## Ranking

Rows are ordered by `(tier, size, address)`, so the order is reproducible from
the tracked inputs alone:

  * **tier 0** -- a duplicate group representative with a code body: one match
    registers several addresses at once (the `registers` column).
  * **tier 1** -- `exact` and `leaf` (no call instruction in the body): no
    cross-references, so no symbol rows are needed.
  * **tier 2** -- `exact` and not a leaf: needs symbol rows for its callees.
  * **tier 3** -- `fallthrough`: no reachable terminal, so the extent may include
    alignment padding; the body may still be matchable.

`shape` is `frame` when the body sets up a stack frame (`addiu sp,sp,-N`, N > 0),
`leaf` when it makes no call and has no frame, and `call` otherwise.

## Output

A tracked TSV of `rank<TAB>address<TAB>end<TAB>size<TAB>grade<TAB>tier<TAB>shape<TAB>calls<TAB>duplicate<TAB>registers`,
followed by a comment block recording the exclusion counts by reason. Addresses,
sizes, grades, counts and group labels only -- no instruction bytes.

Nothing here promotes a candidate to a match: a match still requires an
instruction-identical `sf3_match range` comparison and the clean full-binary
gate. The worklist says where to look, not what is correct.

Exit codes: 0 success, 2 usage or environment error.
"""

from __future__ import annotations

import argparse
from pathlib import Path
import struct
import sys
from typing import Sequence


EXE_MAGIC = b"PS-X EXE"
HEADER_SIZE = 0x800
PAYLOAD_LMA = 0x800

ELIGIBLE_GRADES = ("exact", "fallthrough")

HEADER_LINES = (
    "# Syphon Filter 3 (USA) match worklist.",
    "# Columns: rank<TAB>address<TAB>end<TAB>size<TAB>grade<TAB>tier<TAB>shape<TAB>calls"
    "<TAB>duplicate<TAB>registers.",
    "# Ordered by (tier, size, address): tier 0 = duplicate group with code (one",
    "# match, several addresses), 1 = exact leaf, 2 = exact non-leaf, 3 = fallthrough.",
    "# shape: frame = sets up a stack frame, leaf = no call and no frame, else call.",
    "# Addresses, sizes, grades, counts and group labels only; no bytes.",
    "# A worklist entry is not a match: verify it with sf3_match range and make gate.",
)


class ToolError(Exception):
    """A usage or environment problem; maps to exit code 2."""


def parse_hex(text: str, label: str) -> int:
    try:
        return int(text, 16)
    except ValueError as exc:
        raise ToolError(f"{label}: not a hex address: {text!r}") from exc


def require_file(path: Path, label: str) -> Path:
    if not path.is_file():
        raise ToolError(f"{label} is not a regular file: {path}")
    return path


def resolve_output(path: Path, force: bool) -> Path:
    if path.exists() or path.is_symlink():
        if not force:
            raise ToolError(f"output already exists (use --force to overwrite): {path}")
        if not path.is_file() or path.is_symlink():
            raise ToolError(f"output is not a regular file: {path}")
    return path


def parse_psx_exe(header: bytes) -> tuple[int, int, int]:
    if len(header) < HEADER_SIZE:
        raise ToolError("executable is smaller than a PS-X EXE header")
    if header[:8] != EXE_MAGIC:
        raise ToolError("executable does not carry the PS-X EXE magic")
    entry, _gp, text_address, text_size = struct.unpack_from("<IIII", header, 0x10)
    if text_size == 0:
        raise ToolError("PS-X EXE header declares an empty payload")
    return entry, text_address, text_size


def load_extents(path: Path) -> list[tuple[int, int, str]]:
    """Read a generated extents table; keep rows that carry an extent."""
    rows: list[tuple[int, int, str]] = []
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split("\t")
        if len(fields) != 7:
            raise ToolError(f"extents line {number}: expected seven fields")
        if fields[1] == "-":
            continue
        address = parse_hex(fields[0], f"extents line {number}")
        end = parse_hex(fields[1], f"extents line {number}")
        if end <= address:
            raise ToolError(f"extents line {number}: end is not after address")
        rows.append((address, end, fields[5]))
    if not rows:
        raise ToolError("extents table contains no rows with an extent")
    return rows


def load_entry_addresses(path: Path) -> set[int]:
    """Addresses graded `entry` in the boundary inventory."""
    entries: set[int] = set()
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split("\t")
        if len(fields) != 2:
            raise ToolError(f"inventory line {number}: expected 'address<TAB>grades'")
        names = {name.strip() for name in fields[1].split(",")}
        if "entry" in names:
            entries.add(parse_hex(fields[0].strip(), f"inventory line {number}"))
    return entries


def load_registered_starts(path: Path) -> set[int]:
    """Starts already registered in the region registry."""
    starts: set[int] = set()
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split("\t")
        if len(fields) < 3:
            raise ToolError(f"region line {number}: expected at least three fields")
        starts.add(parse_hex(fields[0].strip(), f"region line {number}"))
    return starts


def load_negative_starts(path: Path) -> set[int]:
    """Open-negative starts from the tracked negatives index.

    Phase 9 measured that recorded negatives cluster at the head of every
    ranked partition: 39 of the first 40 worklist rows and 57 of 271 tier-1
    rows (21%) were addresses a previous session had already attempted and
    recorded as near-match/blocked. The worklist is regenerated from tracked
    inputs, so the negatives index must be the tracked input; the phase
    imports worker/coordinator negatives into `config/near_match_negatives.tsv`
    each cycle rather than only at phase close.
    """
    starts: set[int] = set()
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split("\t")
        if len(fields) < 2:
            raise ToolError(f"negatives line {number}: expected at least two fields")
        starts.add(parse_hex(fields[0].strip(), f"negatives line {number}"))
    return starts


def load_duplicate_groups(path: Path) -> dict[int, tuple[str, int]]:
    """Map every member address to (group label, member count)."""
    members: dict[int, tuple[str, int]] = {}
    for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split("\t")
        if len(fields) != 6:
            raise ToolError(f"census line {number}: expected six fields")
        label, _size, count, addresses, _grades, flags = fields
        if flags == "zero":
            continue
        for text in addresses.split(","):
            members[parse_hex(text.strip(), f"census line {number}")] = (label, int(count))
    return members


def body_shape(body: bytes) -> tuple[str, int]:
    """Classify a body as `frame`, `leaf` or `call`, and count its calls."""
    calls = 0
    framed = False
    for offset in range(0, len(body) - 3, 4):
        word = struct.unpack_from("<I", body, offset)[0]
        opcode = word >> 26
        if opcode == 0x03:
            calls += 1
        elif opcode == 0 and (word & 0x3F) == 0x09:
            calls += 1
        elif opcode == 0x09:
            rs = (word >> 21) & 0x1F
            rt = (word >> 16) & 0x1F
            immediate = word & 0xFFFF
            if rs == 29 and rt == 29 and immediate != 0:
                framed = True
    if framed:
        return "frame", calls
    if calls:
        return "call", calls
    return "leaf", calls


def delay_slot_start(start: int, payload: bytes) -> bool:
    """True when the word immediately before `start` is a branch/jump.

    A MIPS branch or jump at address X always executes the instruction at
    X+4 (its delay slot) before transferring control, so no function can
    begin at X+4. Phase 9 measured this class against the corpus: 5 of the
    1,730 worklist rows start in a delay slot (two confirmed by real
    disassembly — a loop `bne` and a `jal`), while **0 of the 158
    registered regions** does. The word test is mechanical, exactly like
    `trapping_arithmetic`: opcodes 1..7 are the branch/jump family, all of
    which carry one delay slot on the R3000 ISA.

    The mechanism behind the bad starts is the inventory's `jal` grade being
    a raw-word scan: the "caller" word that grades a candidate can live in
    data, and the walk's `exact` extent only looks forward, so it cannot
    detect a start that is one word too late. Ghidra reports zero
    cross-references for all five and no code at all for `0x800C1304`.

    Limit: like the trapping check, this is sufficient but not complete —
    a body that starts after a data word with a branch opcode would be
    excluded by mistake, so a candidate excluded this way is recorded in the
    worklist header's counts, never silently dropped from the record.
    """
    if start < 4:
        return False
    word = struct.unpack_from("<I", payload, start - 4)[0]
    return 1 <= (word >> 26) <= 7


def bad_extent_start(start: int, end: int, payload: bytes) -> bool:
    """True when a candidate start looks like a wrong extent, not a function.

    Worker A reported nine worklist rows graded `exact` that are NOT function
    starts: several open with an instruction reading a register the range never
    defines, and several have no return instruction in range at all. This
    predicate flags the combined tell:

    * the first instruction reads a register the range never writes, and that
      register is not a function parameter (a0-a3), the gp base, the stack
      pointer, or $zero; or
    * the range contains no `jr`/`jalr` return at all.

    Measured in Phase 9 against the corpus: the detector flags 7 of the 1,666
    worklist rows (including the 4-byte `fallthrough` rows `0x80100808` and
    `0x80180808` and the runaway walk `0x801800C4` that the named/counted
    exclusions never caught because they are tier-3) and **0 of the 288
    registered regions** — the class is disjoint from the matched corpus,
    exactly like the delay-slot and trapping classes.

    Limit: sufficient but not complete, like the other word tests. A leaf that
    legitimately reads a caller-set register other than the parameters would be
    excluded by mistake; the exclusion is recorded in the worklist header's
    counts, never silent. The six worker-A rows missing from the pool entirely
    (0x8009F064 etc.) are Ghidra-list finds, not worklist rows, and are out of
    this predicate's scope.
    """
    if end - start < 4:
        return False
    first = struct.unpack_from("<I", payload, start)[0]
    op = first >> 26
    rs, rt = (first >> 21) & 0x1F, (first >> 16) & 0x1F

    reads = set()
    if op == 0x00:
        # r-type: reads rs and rt (lr/jr reads rs; funcs handled below)
        reads.update((rs, rt))
    elif op in (0x01, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
                0x0c, 0x0d, 0x0e, 0x20, 0x21, 0x23, 0x24, 0x25, 0x26,
                0x27, 0x28, 0x29, 0x2b):
        reads.add(rs)
    if first == 0x03E00008:  # `jr ra` as the first instruction: legal leaf stub
        reads.discard(31)
    reads.discard(0)  # $zero reads are always fine
    # a0-a3 (4-7) are parameters; gp is 28, sp is 29.
    reads -= {4, 5, 6, 7, 28, 29}
    if not reads:
        return False

    defined = set()
    has_return = False
    for offset in range(0, end - start - 3, 4):
        w = struct.unpack_from("<I", payload, start + offset)[0]
        op = w >> 26
        rt, rd = (w >> 16) & 0x1F, (w >> 11) & 0x1F
        if op == 0x00:
            funct = w & 0x3F
            if funct in (0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
                         0x00, 0x03, 0x07, 0x18, 0x19, 0x1a, 0x1b, 0x2a,
                         0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32,
                         0x33, 0x34, 0x35, 0x36, 0x37):
                defined.add(rd)
            if funct == 0x09:  # jalr rd
                defined.add(rd)
            if funct in (0x08, 0x09):  # jr / jalr
                has_return = True
        elif op in (0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x20,
                    0x21, 0x23, 0x24, 0x25, 0x26, 0x27):
            defined.add(rt)
        elif op == 0x03:  # jal
            defined.add(31)

    undef = reads - defined
    return bool(undef) or not has_return


def restores_unsaved_callee_saved(start: int, end: int, payload: bytes) -> bool:
    """True when the body restores a callee-saved register it never saves.

    Worker A's Phase 10 scan. A body doing `lw s0,N(sp)` with no matching
    `sw s0,M(sp)` cannot be a whole function: the register it restores was
    established by an enclosing prologue that the derived extent cut off. These
    rows are `jal` targets INSIDE a real function, so the extent walk began
    mid-body. That makes them a distinct class from `bad_extent_start`, which
    flags starts that are not function entries at all — these ARE entries, but of
    a fragment, and they are unbounded grind for whoever picks them up.

    Measured in Phase 10: flags 10 of the 1,265 worklist rows and **0 of the 462
    registered regions** — disjoint from the matched corpus, exactly like the
    delay-slot, trapping and bad-extent classes.

    Limit: sufficient but not complete, like the other word tests. It catches
    only rows that RESTORE an unsaved register, not rows that merely read one, so
    the true count is a lower bound. A leaf that legitimately reloads a
    caller-set register from the stack would be excluded by mistake; the
    exclusion is recorded in the worklist header's counts, never silent.
    """
    callee_saved = frozenset(range(16, 24)) | {30}  # s0-s7, fp
    saved: set[int] = set()
    restored: set[int] = set()
    for offset in range(0, end - start - 3, 4):
        w = struct.unpack_from("<I", payload, start + offset)[0]
        op, rs, rt = w >> 26, (w >> 21) & 0x1F, (w >> 16) & 0x1F
        if rs != 29 or rt not in callee_saved:
            continue
        if op == 0x2B:    # sw rt, off(sp)
            saved.add(rt)
        elif op == 0x23:  # lw rt, off(sp)
            restored.add(rt)
    return bool(restored - saved)


def division_check(start: int, end: int, payload: bytes) -> bool:
    """True when the body contains a compiler-generated division check.

    Worker D's Phase 11 census: `break` NEVER appears without `div` and `div` NEVER
    appears without `break` -- 75 worklist rows, 0 exceptions. The shape is GCC's
    divmodsi4 with MASK_CHECK_ZERO_DIV|MASK_CHECK_RANGE_DIV: div / bnez+break 7
    (zero check) / li at,-1 + bne + lui at,0x8000 + bne quotient,at + break 6 (range
    check) / mflo. `break` cannot be produced from C.

    Compiler matrix measured by worker D: every available cc1 either emits a BARE div
    with no check (open 2.5.7-2.91.66-psx, and Sony CC1PSX 4.0-4.5, which do not
    accept -mcheck-zero-division), or emits a check with a DIFFERENT shape (open
    2.95.2-psx and CC1PSX 4.6: mflo BEFORE the check, scratch $3/$4, li $4,0x80000000
    compared against the DIVISOR; the original checks before mflo, uses $at, and
    compares the QUOTIENT). So the class is not reproducible with this toolchain.

    Measured disjoint from the matched corpus: **0 of 493 registered regions contains
    a div, a rem or a break.** Same signature as finding 26's trapping class. 75 rows
    = 6.3% of the pool and 16.2% of the unproven >244 B band.

    Limit: sufficient but not complete, like the other word tests. A `break` used as a
    deliberate trap (finding 23) would be excluded too; the exclusion is recorded in
    the worklist header's counts, never silent.
    """
    for offset in range(0, end - start - 3, 4):
        w = struct.unpack_from("<I", payload, start + offset)[0]
        if (w >> 26) == 0 and (w & 0x3F) in (0x0D, 0x1A, 0x1B):  # break, div, divu
            return True
    return False


def trapping_arithmetic(body: bytes) -> int:
    """Count trapping `add`/`sub`/`addi` instructions in a body.

    Phase 8 measured that this class is disjoint from the matched corpus: 0 of the
    144 registered regions contains one, while 50 of the 1,937 `exact` extents do
    (410 instructions). All ten available `cc1` builds and the real PsyQ `CC1PSX`
    4.0-4.6 emit `addu`/`subu` for signed arithmetic and reject `-ftrapv`, so a
    body needing the trapping form cannot be reproduced with this toolchain at
    all: it is a compiler-class difference, not a source-shape problem.

    Phase 9 widened the class to the immediate forms: opcode 0x08 (`addi`, which
    traps on overflow) appears in 7 of 1,666 worklist rows (frame adjustments
    `addi sp,sp,-28`, loop counters) while **0 of 310 registered regions**
    contains one — same disjointness signal. `addiu` (0x09) is ubiquitous and
    normal, so the 0x08 occurrences are the anomaly. The R-type forms counted
    here are funct 0x20 (`add`) and 0x22 (`sub`).
    """
    count = 0
    for offset in range(0, len(body) - 3, 4):
        word = struct.unpack_from("<I", body, offset)[0]
        if (word >> 26) == 0 and (word & 0x3F) in (0x20, 0x22):
            count += 1
        elif (word >> 26) == 0x08:  # addi (immediate, traps on overflow)
            count += 1
    return count


class Candidate:
    """One ranked worklist entry."""

    __slots__ = ("address", "end", "grade", "tier", "shape", "calls", "duplicate", "registers")

    def __init__(self, address: int, end: int, grade: str, tier: int, shape: str,
                 calls: int, duplicate: str, registers: int) -> None:
        self.address = address
        self.end = end
        self.grade = grade
        self.tier = tier
        self.shape = shape
        self.calls = calls
        self.duplicate = duplicate
        self.registers = registers

    @property
    def size(self) -> int:
        return self.end - self.address

    @property
    def sort_key(self) -> tuple[int, int, int]:
        return (self.tier, self.size, self.address)


def build_worklist(payload: bytes, text_address: int, rows: Sequence[tuple[int, int, str]],
                   duplicates: dict[int, tuple[str, int]], excluded: set[int],
                   registered: set[int], entries: set[int], negatives: set[int] = frozenset(),
                   allow_trapping: bool = False) -> tuple[list[Candidate], dict[str, int]]:
    """Rank eligible candidates and count every exclusion reason."""
    reasons = {
        "no_extent": 0,
        "low_confidence_grade": 0,
        "degenerate_body": 0,
        "delay_slot_start": 0,
        "bad_extent_start": 0,
        "restores_unsaved": 0,
        "division_check": 0,
        "already_registered": 0,
        "header_entry": 0,
        "named_exclusion": 0,
        "recorded_negative": 0,
        "trapping_arith": 0,
    }
    candidates: list[Candidate] = []
    seen_duplicates: set[str] = set()

    for address, end, grade in rows:
        size = end - address
        if grade not in ELIGIBLE_GRADES:
            reasons["low_confidence_grade"] += 1
            continue
        start = address - text_address
        if delay_slot_start(start, payload):
            # Measured disjoint from the matched corpus: a branch/jump always
            # executes its delay slot, so the row's start cannot be a function
            # entry and the walk's forward-only extent is meaningless.
            reasons["delay_slot_start"] += 1
            continue
        if bad_extent_start(start, start + size, payload):
            # The first instruction reads a register the range never defines
            # (not a parameter), or the range has no return at all: the
            # extent is mid-body or a prologue-only fragment, not a function.
            # Measured disjoint from the matched corpus (0 flagged of 288).
            reasons["bad_extent_start"] += 1
            continue
        if restores_unsaved_callee_saved(start, start + size, payload):
            # The body restores a callee-saved register it never saves, so the
            # extent starts inside a real function's body (worker A's Phase 10
            # scan). Measured disjoint from the matched corpus: 0 flagged of 462.
            reasons["restores_unsaved"] += 1
            continue
        if division_check(start, start + size, payload):
            # Compiler-generated division check (worker D's Phase 11 census): div and
            # break always co-occur, 75 rows, and no available cc1 emits this shape.
            # Measured disjoint from the matched corpus: 0 flagged of 493.
            reasons["division_check"] += 1
            continue
        body = payload[start:start + size]
        if len(body) != size:
            raise ToolError(f"extent 0x{address:08X}..0x{end:08X} is outside the payload")
        if not any(body):
            reasons["degenerate_body"] += 1
            continue
        if not allow_trapping and trapping_arithmetic(body):
            # Measured disjoint from the matched corpus: no available compiler
            # emits the trapping form, so these bodies cannot be matched and must
            # not consume a worker's budget.
            reasons["trapping_arith"] += 1
            continue
        if address in registered:
            reasons["already_registered"] += 1
            continue
        if address in entries:
            reasons["header_entry"] += 1
            continue
        if address in excluded:
            reasons["named_exclusion"] += 1
            continue
        if address in negatives:
            # Recorded as attempted-and-unmatched by an earlier session; the
            # index is a tracked input so the worklist stays reproducible.
            reasons["recorded_negative"] += 1
            continue

        shape, calls = body_shape(body)
        group = duplicates.get(address)
        if group is not None:
            label, count = group
            if label in seen_duplicates:
                # Only the lowest-address member of a group is listed.
                continue
            seen_duplicates.add(label)
            tier = 0
            registers = count
            duplicate = label
        else:
            if grade == "fallthrough":
                tier = 3
            elif shape == "leaf":
                tier = 1
            else:
                tier = 2
            registers = 1
            duplicate = "-"
        candidates.append(Candidate(address, end, grade, tier, shape, calls, duplicate, registers))

    candidates.sort(key=lambda candidate: candidate.sort_key)
    return candidates, reasons


def format_worklist(candidates: Sequence[Candidate], reasons: dict[str, int]) -> str:
    lines = list(HEADER_LINES)
    for rank, candidate in enumerate(candidates, 1):
        lines.append("\t".join((
            str(rank), f"0x{candidate.address:08X}", f"0x{candidate.end:08X}",
            str(candidate.size), candidate.grade, str(candidate.tier), candidate.shape,
            str(candidate.calls), candidate.duplicate, str(candidate.registers),
        )))
    lines.append("#")
    lines.append(f"# listed={len(candidates)}")
    for reason in sorted(reasons):
        lines.append(f"# excluded_{reason}={reasons[reason]}")
    return "\n".join(lines) + "\n"


def command_plan(args: argparse.Namespace) -> int:
    exe_path = require_file(args.exe, "executable")
    extents_path = require_file(args.extents, "function extents")
    inventory_path = require_file(args.inventory, "function inventory")
    census_path = require_file(args.census, "duplicate-body census")
    regions_path = require_file(args.regions, "region registry")
    negatives_path = require_file(args.negatives, "negatives index")
    out = resolve_output(args.out, args.force)

    data = exe_path.read_bytes()
    _entry, text_address, text_size = parse_psx_exe(data)
    payload = data[PAYLOAD_LMA:PAYLOAD_LMA + text_size]

    rows = load_extents(extents_path)
    duplicates = load_duplicate_groups(census_path)
    entries = load_entry_addresses(inventory_path)
    registered = load_registered_starts(regions_path)
    negatives = load_negative_starts(negatives_path)
    excluded = {parse_hex(text, "--exclude") for text in args.exclude}

    candidates, reasons = build_worklist(payload, text_address, rows, duplicates,
                                         excluded, registered, entries, negatives,
                                         args.allow_trapping)
    if args.limit is not None:
        if args.limit < 1:
            raise ToolError("--limit must be at least 1")
        candidates = candidates[:args.limit]

    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(format_worklist(candidates, reasons), encoding="ascii")

    tiers = {tier: 0 for tier in range(4)}
    for candidate in candidates:
        tiers[candidate.tier] = tiers.get(candidate.tier, 0) + 1
    print(f"listed={len(candidates)}")
    for tier in sorted(tiers):
        print(f"tier_{tier}={tiers[tier]}")
    for reason in sorted(reasons):
        print(f"excluded_{reason}={reasons[reason]}")
    print(f"output={out}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    plan_parser = subparsers.add_parser("plan", help="build the ranked worklist")
    plan_parser.add_argument("--exe", required=True, type=Path)
    plan_parser.add_argument("--extents", required=True, type=Path)
    plan_parser.add_argument("--inventory", required=True, type=Path)
    plan_parser.add_argument("--census", required=True, type=Path)
    plan_parser.add_argument("--regions", required=True, type=Path)
    plan_parser.add_argument("--negatives", required=True, type=Path,
                             help="tracked open-negatives index (NAME<TAB>... rows)")
    plan_parser.add_argument("--out", required=True, type=Path)
    plan_parser.add_argument("--force", action="store_true",
                             help="overwrite an existing output file")
    plan_parser.add_argument("--exclude", action="append", default=[],
                             help="address to exclude (repeatable)")
    plan_parser.add_argument("--limit", type=int, default=None,
                             help="keep only the first N entries")
    plan_parser.add_argument("--allow-trapping", action="store_true", dest="allow_trapping",
                             help="list bodies containing trapping add/sub, which no available "
                                  "compiler emits")
    plan_parser.set_defaults(handler=command_plan)

    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        return args.handler(args)
    except ToolError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
