#!/usr/bin/env python3
"""Reproducible instruction-range comparator and ordered-layout builder.

This is the Phase 5 matching harness. It has four subcommands:

  range   compile one C candidate with the fingerprinted original toolchain,
          assemble it, extract its exact instruction range, and compare that
          range byte-for-byte against the validated original executable.
  plan    print the address-ordered build plan for a region registry.
  build   generate and link the ordered binary: header, data gaps taken from
          the original, and the registry's C regions compiled in address order.
  gate    build, then compare the whole rebuilt executable (cmp + SHA-1).

Cross-references to unmatched functions and globals are supplied as absolute
assembler definitions. `--defsym NAME=0xADDR` adds one on the command line;
`--symbols FILE` loads a tracked registry of `NAME<TAB>address` rows. They are
resolved by the **linker**, so the assembler emits `%hi`/`%lo` relocations and
the linker applies the HI16 carry adjustment the original toolchain used.

`cc1` output is passed through `maspsx` (ASPSX emulator) before GNU `as`, which
is what gives the original's non-reordered delay-slot and `la`/`addiu` forms.
The ASPSX version is pinned to the SDK banner value (`2.81`).

A region row may carry an optional fourth field of per-region flag overrides:
space-separated `key=value` tokens with key `cc1` or `as`, each value a
comma-separated flag list. The flags are appended to the effective toolchain
flags for that region only (so `cc1=-O0` overrides a global `-O2`).

The original executable and every generated artifact are caller-supplied or
written to a caller-selected fresh directory. This tool never embeds game bytes
in its own source or output and refuses to write into an existing directory.

Exit codes: 0 success/match, 1 mismatch, 2 usage or environment error.

Toolchain (identified in Phase 6, correcting Phase 5): PsyQ 4.0's `CC1PSX` reports
`GNU C 2.7.2.SN32.3.7.0002`. The open decompals/old-gcc `gcc-2.7.2-psx` build is
instruction-identical to it across 21 probe files. Flags: `-O2 -G0` (the macro
address form is the default; this compiler has no `-mno-split-addresses`).
Input to cc1 must be preprocessed. The SDK 4.0 assembler is ASPSX 2.56, which
maspsx emulates.
"""

from __future__ import annotations

import argparse
import hashlib
from dataclasses import dataclass, replace
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
from typing import Sequence


REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CC1 = REPO_ROOT / "tools/old-gcc/gcc-2.7.2-psx/cc1"
_BINUTILS = REPO_ROOT / "tools/mipsel-none-elf-binutils/prefix/usr/bin"
DEFAULT_AS = _BINUTILS / "mipsel-none-elf-as"
DEFAULT_LD = _BINUTILS / "mipsel-none-elf-ld"
DEFAULT_OBJCOPY = _BINUTILS / "mipsel-none-elf-objcopy"
DEFAULT_NM = _BINUTILS / "mipsel-none-elf-nm"

# A symbol whose name is an address is a placeholder for that address, so it
# needs no registry row: `func_80017AD4` resolves to 0x80017AD4. The convention
# is already used by the registry (`g_80122354`, `D_8012E2C8`). A wrong address
# cannot pass unnoticed -- the byte gate compares the whole binary -- so the only
# cost of an implicit symbol is a loud mismatch, never a silent one.
ADDRESS_SYMBOL = re.compile(r"^(?:func|D|g|lbl)_([0-9A-Fa-f]{8})$")

DEFAULT_CPP_FLAGS = ["-E", "-P", "-undef"]
DEFAULT_CC1_FLAGS = ["-quiet", "-O2", "-G0"]
DEFAULT_AS_FLAGS = ["-march=r3000", "-G0"]
DEFAULT_MASPSX = REPO_ROOT / "tools/maspsx/maspsx.py"
# The PsyQ 4.0 SDK banner reports `Psy-Q ASPSX version 2.56`.
DEFAULT_ASPSX_VERSION = "2.56"

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


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


# --------------------------------------------------------------------------
# small helpers
# --------------------------------------------------------------------------


def sha1_file(path: Path) -> str:
    digest = hashlib.sha1()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def run(command: Sequence[str], cwd: Path | None = None, stdout=None) -> None:
    completed = subprocess.run(
        list(command), cwd=cwd, stdout=stdout, stderr=subprocess.PIPE
    )
    if completed.returncode:
        text = completed.stderr.decode("utf-8", "replace").strip().splitlines()
        message = text[0] if text else "no diagnostic"
        raise ToolError(f"command failed ({completed.returncode}): {message}")


def run_filter(command: Sequence[str], source: Path, destination: Path) -> None:
    """Run a stdin-to-stdout filter (used for the maspsx stage)."""
    with source.open("rb") as stdin, destination.open("wb") as stdout:
        completed = subprocess.run(
            list(command), stdin=stdin, stdout=stdout, stderr=subprocess.PIPE
        )
    if completed.returncode:
        text = completed.stderr.decode("utf-8", "replace").strip().splitlines()
        message = text[0] if text else "no diagnostic"
        raise ToolError(f"command failed ({completed.returncode}): {message}")


def parse_address(text: str) -> int:
    try:
        return int(text, 0)
    except ValueError as exc:
        raise ToolError(f"not an address: {text!r}") from exc


def parse_hex_address(text: str, line_number: int) -> int:
    """A symbol-registry address: hexadecimal, with or without a 0x prefix."""
    try:
        address = int(text, 16)
    except ValueError as exc:
        raise ToolError(f"symbols line {line_number}: not a hex address: {text!r}") from exc
    if not 0 <= address <= 0xFFFFFFFF:
        raise ToolError(f"symbols line {line_number}: address out of range")
    return address


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 fresh_directory(path: Path, label: str) -> Path:
    if path.exists() or path.is_symlink():
        raise ToolError(f"{label} already exists: {path}")
    return path


# --------------------------------------------------------------------------
# pure logic (unit-tested without any external tool)
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class PsxExe:
    entry: int
    text_address: int
    text_size: int

    @property
    def payload_end(self) -> int:
        return self.text_address + self.text_size


def parse_psx_exe(header: bytes) -> PsxExe:
    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 PsxExe(entry, text_address, text_size)


@dataclass(frozen=True)
class Region:
    start: int
    end: int
    source: str
    cc1_flags: tuple[str, ...] = ()
    as_flags: tuple[str, ...] = ()
    no_gp: tuple[str, ...] = ()
    no_maspsx: bool = False
    maspsx_flags: tuple[str, ...] = ()
    move_rewrite: bool = False
    cc1bin: str = ""


# A region's optional fourth field: space-separated `key=value` overrides.
# The value is a comma-separated flag list; the keys name the toolchain stage.
# `gp=-NAME` excludes a symbol from the registry's gp markers for this region
# only, because the access form is a property of the site, not of the symbol:
# 0x80121F84 is read gp-relative at 0x800A80BC and written absolutely at
# 0x8002D288 in this executable.
# `maspsx=off` runs the region without the ASPSX emulation stage, because
# maspsx's unconditional `nop` for a jump destroys the delay-slot fill that GNU
# `as` reorder mode performs on an expanded symbol store (0x80102B10, 0x800F8B6C,
# 0x800F3160), while the ASPSX `la`/`addiu` form needs maspsx (func_8002D2BC).
# The same key also takes Phase 10's opt-in maspsx modes, which are local additions
# to the pinned vendored tool (tools/patches/maspsx-phase10-r1r2.patch):
#   `maspsx=noreordernop` suppresses maspsx's unconditional reorder `nop` after a
#     branch/jump so GNU `as` can fill the slot itself (worker C's R1).
#   `maspsx=regread` additionally treats a following `jr`/`jalr` that uses the
#     loaded register as needing the load-delay `nop` (worker C's R2).
# Both default off, so every region without them compiles exactly as before.
_REGION_OPTION_KEYS = ("cc1", "as", "gp", "maspsx", "cc1bin")

# ---------------------------------------------------------------------------
# `cc1bin=NAME` -- SELECT A DIFFERENT cc1 BINARY FOR THIS REGION.
# DEVELOPER-APPROVED IN PHASE 12, AND RESTRICTED. Read this before using it.
# ---------------------------------------------------------------------------
# Worker C found that the project's cc1 cannot build a whole class of the original's
# functions, and proved why. Measured over the registry and the negatives index:
#
#   * 616 of 616 REGISTERED regions contain EXACTLY ONE `jr $31`. ZERO contain two.
#   * 12 of the 193 NEGATIVES rows contain two or more. That is the entire blocked class.
#   * It is NOT reachable by flags: 14 flag sets on the default cc1 all yield one exit.
#   * And it produces bodies: THREE registered regions (0x800FF43C, 0x800FF47C, 0x80108578)
#     are `differing_bytes=0 result=MATCH` with gcc-2.8.1-psx/cc1 where the default cc1 gives
#     56/60/52 B LENGTH-MISMATCH. All three were reproduced independently by the coordinator
#     from fresh work directories before the lever was authorised, and the gate is byte-exact
#     for the whole binary with them in the registry. All ten builds are already vendored
#     under tools/old-gcc/.
#
#   * THE MECHANISM IS NOT ESTABLISHED, AND THIS COMMENT USED TO STATE IT AS IF IT WERE.
#     The first version said "2.7.2 emits one shared return epilogue while 2.8.x emits one
#     per return". Worker C's 4-return probe reproduces that (3 exits on 2.8.0/2.8.1/
#     2.91.66/2.95.2, 1 on the earlier six), and the coordinator reproduced C's probe
#     exactly -- but worker D could not reproduce it at all: on a 3-return framed probe AND
#     a 3-return frameless probe, ALL TEN builds emitted one shared exit. Both probes are
#     real, so the compiler-side result is SHAPE-DEPENDENT and the general claim is false.
#     The census and the three bodies are what justify this lever; the mechanism sentence was
#     a hypothesis that got written down as a finding. Recorded rather than quietly deleted,
#     because the next reader will otherwise re-derive it and believe it.
#
# WHY THE RESTRICTION IS ENFORCED HERE AND NOT LEFT TO A CONVENTION. The project's rule is
# that the corpus must all be built by one compiler configuration, because a per-function
# choice of compiler can manufacture matches that are byte-exact and wrong -- the gate cannot
# tell the difference, so the gate cannot catch it. Allowing a second BINARY is therefore a
# real widening, and the only defence that is worth anything is a mechanical one:
#
#   **A REGION MAY NAME AN ALTERNATIVE cc1 ONLY IF ITS ORIGINAL BODY CONTAINS AT LEAST TWO
#   FUNCTION-EXIT JUMPS (`jr $31`).** That is exactly the class the default cc1 provably
#   cannot emit, so the lever can only ever be applied to a row that HAS a named reason.
#   Anything else raises here, before a single object is compiled.
#
# The whole-binary gate is unchanged and still has to be byte-exact for every region at once.
_EXIT_JUMP = 0x03E00008          # `jr $31`, the only return encoding the corpus uses
EXIT_JUMPS_REQUIRED = 2          # the mechanism, not a threshold to be tuned
OLD_GCC_ROOT = REPO_ROOT / "tools" / "old-gcc"


def resolve_cc1bin(name: str, line_number: int) -> Path:
    """Map `cc1bin=NAME` to `tools/old-gcc/NAME/cc1`, refusing anything else.

    A bare directory name only -- no slashes and no path traversal -- so the set of
    compilers a region can name is exactly the set of vendored, pinned builds already
    in the repository. This is what makes the choice auditable after the fact.
    """
    if "/" in name or name in (".", "..") or not name:
        raise ToolError(
            f"regions line {line_number}: cc1bin wants a bare directory name under "
            f"tools/old-gcc/, got {name!r}"
        )
    candidate = OLD_GCC_ROOT / name / "cc1"
    if not candidate.is_file():
        available = sorted(p.name for p in OLD_GCC_ROOT.iterdir() if p.is_dir())
        raise ToolError(
            f"regions line {line_number}: no such cc1 build {name!r}; available: "
            f"{', '.join(available)}"
        )
    return candidate


def exit_jump_count(data: bytes, exe: PsxExe, start: int, end: int) -> int:
    """Count `jr $31` words in a region of the original payload.

    Uses the same `PAYLOAD_LMA + (address - text_address)` arithmetic as the range
    comparison, so this census and the byte comparison can never disagree about
    where a region starts.
    """
    first = PAYLOAD_LMA + (start - exe.text_address)
    return sum(
        1 for i in range((end - start) // 4)
        if int.from_bytes(data[first + 4 * i:first + 4 * i + 4], "little") == _EXIT_JUMP
    )
#   `maspsx=moves` (Phase 11, worker B's oracle result) is the important one: the real
#     ASPSX does NOT fill delay slots at all -- all five SDK assemblers (2.56-2.86) and
#     every option produce maspsx's exact shape. What fills the original's slots is GNU
#     `as` in REORDER mode, i.e. maspsx OFF, and the only real gap is one mnemonic: `as`
#     expands cc1's `move` to `or` where ASPSX emits `addu`. So this mode runs the
#     `move`->`addu` rewrite and NO maspsx stage, letting `as` fill exactly the slots cc1
#     left empty while leaving cc1's own `.set noreorder` windows alone.
_MASPSX_MODES = {"off": "--off", "noreordernop": "--no-jump-slot-nop",
                  "regread": "--nop-on-reg-read", "moves": "moves",
                  "nopmarker": "--honour-nop-marker",
                  "epilogue": "--fill-epilogue"}


def parse_region_options(text: str, line_number: int) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...], bool, tuple[str, ...], bool, str]:
    """Parse the optional per-region override field.

    Returns `(cc1_flags, as_flags, no_gp, no_maspsx, maspsx_flags)`. `gp=-NAME` names a symbol
    the registry marks `gp` that this region accesses absolutely instead, and
    `maspsx=off` drops the ASPSX emulation stage for this region.
    """
    overrides: dict[str, tuple[str, ...]] = {}
    for token in text.split():
        if "=" not in token:
            raise ToolError(
                f"regions line {line_number}: expected 'key=value' override, got {token!r}"
            )
        key, _, value = token.partition("=")
        if key not in _REGION_OPTION_KEYS:
            raise ToolError(
                f"regions line {line_number}: unknown override key {key!r}"
            )
        if key in overrides:
            raise ToolError(
                f"regions line {line_number}: duplicate override key {key!r}"
            )
        flags = tuple(part for part in value.split(",") if part)
        if not flags:
            raise ToolError(f"regions line {line_number}: empty override for {key!r}")
        overrides[key] = flags
    no_gp: list[str] = []
    for name in overrides.get("gp", ()):
        if not name.startswith("-") or len(name) == 1:
            raise ToolError(
                f"regions line {line_number}: expected 'gp=-NAME', got {name!r}"
            )
        if not _SYMBOL_NAME.match(name[1:]):
            raise ToolError(
                f"regions line {line_number}: invalid symbol name {name[1:]!r}"
            )
        no_gp.append(name[1:])
    no_maspsx = False
    move_rewrite = False
    maspsx_flags: list[str] = []
    for value in overrides.get("maspsx", ()):
        if value not in _MASPSX_MODES:
            raise ToolError(
                f"regions line {line_number}: expected one of "
                f"{', '.join(sorted(_MASPSX_MODES))} for 'maspsx', got {value!r}"
            )
        if value == "off":
            no_maspsx = True
        elif value == "moves":
            # `moves` implies maspsx off: the rewrite replaces the maspsx stage entirely.
            no_maspsx = True
            move_rewrite = True
        else:
            maspsx_flags.append(_MASPSX_MODES[value])
    # `cc1bin=NAME` names an alternative cc1 BUILD, not a flag list, so it takes exactly
    # one value. `resolve_cc1bin` refuses anything but a vendored directory name, and
    # `validate_regions` refuses it entirely on a region the default cc1 CAN build.
    cc1bin = ""
    names = overrides.get("cc1bin", ())
    if names:
        if len(names) != 1:
            raise ToolError(
                f"regions line {line_number}: cc1bin names ONE build, got {len(names)}"
            )
        resolve_cc1bin(names[0], line_number)
        cc1bin = names[0]
    return (overrides.get("cc1", ()), overrides.get("as", ()), tuple(no_gp), no_maspsx,
            tuple(maspsx_flags), move_rewrite, cc1bin)


def parse_regions(text: str) -> list[Region]:
    regions: list[Region] = []
    for number, raw in enumerate(text.splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split(None, 3)
        if len(fields) not in (3, 4):
            raise ToolError(
                f"regions line {number}: expected 'start end source [overrides]'"
            )
        start = parse_address(fields[0])
        end = parse_address(fields[1])
        if not 0 <= start < end:
            raise ToolError(f"regions line {number}: invalid range")
        cc1_flags, as_flags, no_gp, no_maspsx, maspsx_flags, move_rewrite, cc1bin = (
            (), (), (), False, (), False, "")
        if len(fields) == 4:
            (cc1_flags, as_flags, no_gp, no_maspsx, maspsx_flags,
             move_rewrite, cc1bin) = parse_region_options(fields[3], number)
        regions.append(Region(start, end, fields[2], cc1_flags, as_flags, no_gp, no_maspsx,
                              maspsx_flags, move_rewrite, cc1bin))
    regions.sort(key=lambda region: region.start)
    for left, right in zip(regions, regions[1:]):
        if right.start < left.end:
            raise ToolError(f"regions overlap at 0x{right.start:08X}")
    return regions


# A tracked symbol registry: `NAME<TAB>address[<TAB>gp]` rows naming absolute
# addresses. The optional `gp` marker forces a gp-relative access.
_SYMBOL_NAME = re.compile(r"[A-Za-z_.][A-Za-z0-9_.]*\Z")


@dataclass(frozen=True)
class SymbolTable:
    defsyms: tuple[str, ...] = ()
    gp_names: frozenset[str] = frozenset()


def parse_symbols(text: str) -> SymbolTable:
    """Parse a symbol registry into linker `--defsym` args and gp markers.

    Columns are `NAME<TAB>address[<TAB>gp]`. A `gp` marker means the original
    accessed the symbol `gp`-relative, so the harness rewrites its macro
    accesses to explicit `%gp_rel` (GNU `as` will not do this for a symbol whose
    section it cannot see).
    """
    defsyms: list[str] = []
    gp_names: set[str] = set()
    seen: set[str] = set()
    for number, raw in enumerate(text.splitlines(), 1):
        line = raw.split("#", 1)[0].strip()
        if not line:
            continue
        fields = line.split()
        if len(fields) not in (2, 3):
            raise ToolError(f"symbols line {number}: expected 'NAME address [gp]'")
        name = fields[0]
        if not _SYMBOL_NAME.match(name):
            raise ToolError(f"symbols line {number}: invalid symbol name {name!r}")
        if name in seen:
            raise ToolError(f"symbols line {number}: duplicate symbol {name!r}")
        address = parse_hex_address(fields[1], number)
        if len(fields) == 3:
            if fields[2] != "gp":
                raise ToolError(f"symbols line {number}: unknown marker {fields[2]!r}")
            gp_names.add(name)
        seen.add(name)
        defsyms.append(f"{name}=0x{address:X}")
    return SymbolTable(tuple(defsyms), frozenset(gp_names))


def validate_regions(regions: Sequence[Region], exe: PsxExe, data: bytes) -> None:
    for region in regions:
        if region.start < exe.text_address or region.end > exe.payload_end:
            raise ToolError(
                f"region 0x{region.start:08X}..0x{region.end:08X} leaves the payload"
            )
    _validate_cc1bin_regions(regions, exe, data)


def _validate_cc1bin_regions(regions: Sequence[Region], exe: PsxExe, data: bytes) -> None:
    """THE RESTRICTION ON `cc1bin`, ENFORCED AS A HARD FAILURE.

    A region may name an alternative cc1 ONLY if its original body contains at least
    `EXIT_JUMPS_REQUIRED` function-exit jumps, because that is the class the default cc1
    provably cannot emit (616/616 registered regions have exactly one; the 12 rows that
    have two or more are the whole blocked class).

    This is checked here, before anything is compiled, so an unjustified `cc1bin` cannot
    reach a candidate and cannot reach the registry. The point is that no reviewer and no
    convention is needed: the build refuses. The whole-binary gate cannot catch a wrong
    compiler choice -- a byte-exact match is byte-exact -- so the only defence that works
    is to require a NAMED MECHANISM in the original bytes.
    """
    for region in regions:
        if not region.cc1bin:
            continue
        found = exit_jump_count(data, exe, region.start, region.end)
        if found < EXIT_JUMPS_REQUIRED:
            raise ToolError(
                f"region 0x{region.start:08X}..0x{region.end:08X} names cc1bin="
                f"{region.cc1bin} but its original body has only {found} function-exit "
                f"jump(s) (`jr $31`); {EXIT_JUMPS_REQUIRED} are required. The alternative-cc1 "
                f"lever is restricted to the multi-return class the default cc1 cannot emit "
                f"-- see the note above _EXIT_JUMP in this file."
            )


def payload_gaps(exe: PsxExe, regions: Sequence[Region]) -> list[tuple[int, int]]:
    """The payload ranges not covered by a C region, in address order."""
    gaps: list[tuple[int, int]] = []
    cursor = exe.text_address
    for region in regions:
        if region.start > cursor:
            gaps.append((cursor, region.start))
        cursor = region.end
    if cursor < exe.payload_end:
        gaps.append((cursor, exe.payload_end))
    return gaps


@dataclass(frozen=True)
class Diff:
    expected_length: int
    actual_length: int
    differing_bytes: int
    first_difference: int

    @property
    def identical(self) -> bool:
        return self.differing_bytes == 0


def compare_bytes(expected: bytes, actual: bytes) -> Diff:
    shared = min(len(expected), len(actual))
    first = -1
    differing = 0
    for index in range(shared):
        if expected[index] != actual[index]:
            differing += 1
            if first < 0:
                first = index
    differing += abs(len(expected) - len(actual))
    return Diff(len(expected), len(actual), differing, first)


@dataclass(frozen=True)
class LayoutItem:
    kind: str  # "asm" (data gap) or "c" (region source)
    start: int
    end: int
    source: Path  # gap .s path, or the region's C source
    object_name: str


def plan_layout(
    exe: PsxExe, regions: Sequence[Region], out: Path
) -> list[LayoutItem]:
    items: list[LayoutItem] = []
    for index, (start, end) in enumerate(payload_gaps(exe, regions)):
        items.append(
            LayoutItem("asm", start, end, out / f"gap_{index:03d}.s", f"gap_{index:03d}.o")
        )
    for index, region in enumerate(regions):
        items.append(
            LayoutItem(
                "c",
                region.start,
                region.end,
                Path(region.source),
                f"region_{index:03d}.o",
            )
        )
    items.sort(key=lambda item: item.start)
    return items


def linker_script(items: Sequence[LayoutItem], text_address: int) -> str:
    lines = [
        'OUTPUT_FORMAT("elf32-littlemips")',
        "ENTRY(_start)",
        "SECTIONS",
        "{",
        "  .header 0x0 : { *(.header) }",
        f"  .main 0x{text_address:X} : AT(0x{PAYLOAD_LMA:X})",
        "  {",
    ]
    for item in items:
        if item.kind == "asm":
            lines.append(f"    {item.object_name}(.data)")
        else:
            lines.append(f"    {item.object_name}(.text .rodata .data)")
    lines += [
        "  }",
        "  /DISCARD/ : { *(.MIPS.abiflags) *(.reginfo) *(.pdr) *(.gnu.attributes)"
        " *(.comment) *(.note*) *(.mdebug*) }",
        "}",
        "",
    ]
    return "\n".join(lines)


def gap_source(exe_path: Path, start: int, end: int) -> str:
    offset = PAYLOAD_LMA + (start - 0x80010000)
    return (
        '.section .data,"a",@progbits\n'
        f'.incbin "{exe_path}",{offset},{end - start}\n'
    )


def header_source(exe_path: Path) -> str:
    return (
        '.section .header,"a",@progbits\n'
        f'.incbin "{exe_path}",0,{HEADER_SIZE}\n'
    )


# --------------------------------------------------------------------------
# toolchain steps
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class Toolchain:
    cpp: Path
    cc1: Path
    assembler: Path
    linker: Path
    objcopy: Path
    cpp_flags: Sequence[str]
    cc1_flags: Sequence[str]
    as_flags: Sequence[str]
    defsyms: Sequence[str]
    gp_symbols: frozenset[str] = frozenset()
    maspsx: Path | None = None
    maspsx_flags: tuple[str, ...] = ()
    move_rewrite: bool = False
    aspsx_version: str = DEFAULT_ASPSX_VERSION
    nm: Path | None = None


# A symbol macro access that can be forced gp-relative.
_GP_ACCESS = re.compile(
    r"^(?P<indent>\s*)(?P<op>lw|lh|lhu|lb|lbu|lwl|lwr|sw|sh|sb|swl|swr)"
    r"\s+(?P<rt>\$[A-Za-z0-9]+),\s*(?P<sym>[A-Za-z_.][A-Za-z0-9_.]*)"
    r"(?P<addend>[+-][0-9]+)?\s*$"
)
_GP_LA = re.compile(
    r"^(?P<indent>\s*)la\s+(?P<rt>\$[A-Za-z0-9]+),\s*"
    r"(?P<sym>[A-Za-z_.][A-Za-z0-9_.]*)(?P<addend>[+-][0-9]+)?\s*$"
)

# cc1 2.8.x addresses a global with a two-instruction `%hi`/`%lo` PAIR instead of the
# bare `sw $2,SYM` form the 2.7.2 default emits:
#
#     2.7.2-psx :  sw   $2,D_80121E88
#     2.8.1-psx :  lui  $3,%hi(D_80121E88) # high
#                  sw   $2,%lo(D_80121E88)($3)
#
# Both lines are invisible to `_GP_ACCESS`/`_GP_LA`, so a gp-relative region built with an
# alternative cc1 came out FOUR BYTES LONG (the `lui`) -- measured by worker C on
# 0x800FFBBC: 52 B where 48 B is correct. That made the alternative-cc1 lever unusable for
# any row that touches a gp-marked global, which is most of the class.
#
# The transform is a PAIR rewrite: drop the `lui` and address the global through `$gp`
# directly, which is what the original does. The trailing `# high`/`# low` comments 2.8.x
# emits are tolerated because they are diagnostics, not operands.
_GP_HI = re.compile(
    r"^(?P<indent>\s*)lui\s+(?P<rt>\$[A-Za-z0-9]+),\s*"
    r"%hi\((?P<sym>[A-Za-z_.][A-Za-z0-9_.]*)\)\s*(?:#.*)?$"
)
_GP_LO = re.compile(
    r"^(?P<indent>\s*)(?P<op>lw|lh|lhu|lb|lbu|lwl|lwr|sw|sh|sb|swl|swr)"
    r"\s+(?P<rt>\$[A-Za-z0-9]+),\s*"
    r"%lo\((?P<sym>[A-Za-z_.][A-Za-z0-9_.]*)\)"
    r"\((?P<base>\$[A-Za-z0-9]+)\)\s*(?:#.*)?$"
)


# cc1's `move d,s` macro. GNU `as` expands it to `or d,s,$zero` (funct 0x25); the
# real ASPSX emits `addu d,s,$zero` (funct 0x21). One mnemonic, one byte, and it is
# the only difference between the harness's reorder-mode output and the original for
# the whole maspsx-off class (worker B's Phase 11 oracle result).
_MOVE_MACRO = re.compile(r"^(\s*)move\s+(\$[A-Za-z0-9]+)\s*,\s*(\$[A-Za-z0-9]+)\s*$")


def rewrite_move_macro(text: str) -> str:
    """Rewrite cc1's `move d,s` to the `addu d,s,$zero` form ASPSX emits.

    Used with the maspsx stage DISABLED so GNU `as` stays in its default reorder mode
    and fills exactly the jump slots cc1 left empty, while cc1's own
    `.set noreorder`/`.set reorder` windows are preserved. Verified on 0x800FA5D8:
    132 bytes / 0 differing with this rewrite, 148 LENGTH-MISMATCH without it.

    Only the mnemonic is touched, so the transform is byte-neutral everywhere else.
    """
    lines: list[str] = []
    for line in text.splitlines():
        match = _MOVE_MACRO.match(line)
        if match:
            indent, dest, source = match.groups()
            lines.append(f"{indent}addu\t{dest},{source},$zero")
        else:
            lines.append(line)
    return "\n".join(lines) + "\n"


# Instructions whose FIRST register operand is NOT a destination. Used by the gp
# rewrite to decide when the register holding a `%hi(SYM)` stops holding it. `jalr` is
# listed as non-defining on purpose: the two-operand form does write its first register,
# so treating it as non-defining is CONSERVATIVE -- it can only keep a `%hi` alive, never
# delete a live one.
_NON_DEFINING = frozenset({
    "sw", "sh", "sb", "swl", "swr", "sdl", "sdr",
    "beq", "bne", "blez", "bgtz", "bltz", "bgez", "bltzal", "bgezal",
    "j", "jal", "jr", "jalr", "mult", "multu", "div", "divu", "nop", "syscall", "break",
})
_INSTRUCTION = re.compile(r"^\s*(?P<op>[a-z][a-z0-9.]*)\s+(?P<rest>.*)$")
_REGISTER = re.compile(r"\$[0-9]+")


def rewrite_gp_accesses(text: str, gp_symbols: frozenset[str]) -> str:
    """Force `%gp_rel` access for symbols the original read through `gp`.

    Two cc1 forms reach here, and they need different treatment:

    * the 2.7.2 shape, a bare `<op> $r,SYM` / `la $r,SYM` macro (`_GP_ACCESS`,
      `_GP_LA`) -- rewritten in place, one line in, one line out;
    * the 2.8.x shape, an explicit `lui $R,%hi(SYM)` + `<op> $r,%lo(SYM)($R)` PAIR --
      measured on 2.8.1-psx. The access becomes `%gp_rel(SYM)($gp)` and the `%hi` must
      be REMOVED, because otherwise the region is 4 bytes long. Worker C measured that
      on 0x800FFBBC: 52 B where 48 B is correct.

    The pair is the subtle case, and the first implementation of it was wrong in two
    ways, both caught before they reached the registry:

    1. it required the two lines to be ADJACENT, but maspsx emits a blank line between
       them, so it silently did nothing;
    2. it deleted the `%hi` unconditionally, which is WRONG when the register is reused
       by a later access to the same global -- the later access is then left addressing
       a register nothing wrote. The result is plausible, the right length, and wrong.

    So the `%hi` is deleted only when EVERY use of its register before that register is
    redefined was itself rewritten, and redefinition is tracked explicitly. `$R` is
    redefined when an instruction writes it (destination = first register operand of a
    defining instruction); after that point the `%hi` no longer describes `$R`, and uses
    beyond it do not keep the `%hi` alive.
    """
    if not gp_symbols:
        return text
    result: list[str] = []
    # register -> [symbol, index of its %hi in `result`, rewritten uses, unrewritten use]
    # `entries` holds every entry ever created, because a register that is REDEFINED is
    # removed from `carrying` and its `%hi` must still be considered for deletion. Reading
    # the decision off `carrying` alone loses exactly the entries that matter.
    carrying: dict[str, list] = {}
    entries: list[list] = []
    for line in text.splitlines():
        high = _GP_HI.match(line)
        if high and high.group("sym") in gp_symbols:
            result.append(line)
            entry = [high.group("sym"), len(result) - 1, 0, False]
            carrying[high.group("rt")] = entry
            entries.append(entry)
            continue

        access = _GP_LO.match(line)
        if access is not None and access.group("sym") in gp_symbols:
            held = carrying.get(access.group("base"))
            if held is not None and held[0] == access.group("sym"):
                result.append(
                    f"{access.group('indent')}{access.group('op')} {access.group('rt')},"
                    f"%gp_rel({access.group('sym')})($gp)"
                )
                held[2] += 1
                # PHASE 12, WORKER A. The rewrite above is a REDEFINITION of its
                # destination register, and this `continue` used to reach the next
                # line without saying so. The consequence was a stale `%hi` that
                # survived:
                #
                #     lui $2,%hi(D_801221CC)      <- kept, wrongly
                #     lw  $2,%gp_rel(D_801221CC)($gp)
                #
                # The base register has been replaced by `$gp`, so the `%hi` entry
                # is no longer carried by `base` -- but `rt` now holds whatever the
                # load produced, and the register's NEXT use was therefore scored by
                # the "any other line" branch below as an UNREWRITTEN use of a
                # `%hi`-carrying register, which deliberately keeps the `%hi`.
                # Net effect: the region came out exactly 4 bytes long -- one
                # instruction, the `lui`.
                #
                # The bug is invisible until a gp-marked LOAD feeds anything, which
                # is why it went unnoticed: worker C's `0x800FFBBC` survived only
                # because its rewritten access was a STORE whose base is not used
                # again. FOUR of the seven multi-exit rows are gp-loading rows of
                # this shape.
                #
                # If `rt == base` this drops the base too, which is correct: that
                # register has been redefined by the load it was the base of.
                carrying.pop(access.group("rt"), None)
                continue

        match = _GP_ACCESS.match(line)
        if match and match.group("sym") in gp_symbols:
            symbol = match.group("sym") + (match.group("addend") or "")
            result.append(
                f"{match.group('indent')}{match.group('op')} "
                f"{match.group('rt')},%gp_rel({symbol})($gp)"
            )
            continue

        match = _GP_LA.match(line)
        if match and match.group("sym") in gp_symbols:
            symbol = match.group("sym") + (match.group("addend") or "")
            result.append(
                f"{match.group('indent')}addiu {match.group('rt')},"
                f"$gp,%gp_rel({symbol})"
            )
            continue

        # Any other line: registers it mentions that are carrying a `%hi` are uses we
        # did not rewrite, so that `%hi` must stay. Registers it WRITES stop carrying.
        instruction = _INSTRUCTION.match(line)
        instruction_op = instruction.group("op") if instruction else ""
        operands = _REGISTER.findall(instruction.group("rest")) if instruction else []
        destination = None
        if operands and instruction_op and not instruction_op.startswith(".") \
                and instruction_op not in _NON_DEFINING:
            destination = operands[0]
        for register in _REGISTER.findall(line):
            if register == destination:
                continue
            held = carrying.get(register)
            if held is not None:
                held[3] = True
        result.append(line)
        if destination is not None:
            carrying.pop(destination, None)

    # Delete a `%hi` whose uses were all rewritten. A `%hi` with NO rewritten use is not
    # ours to delete -- it may address a symbol the registry does not mark gp.
    dropped = {entry[1] for entry in entries if entry[2] > 0 and not entry[3]}
    return "\n".join(
        line for index, line in enumerate(result) if index not in dropped
    ) + ("\n" if text.endswith("\n") else "")


def compile_c(source: Path, out_object: Path, work: Path, tools: Toolchain) -> None:
    """Preprocess, compile, ASPSX-emulate and assemble one C source.

    Symbols are left undefined so the assembler emits `%hi`/`%lo` relocations;
    the linker resolves them (see `link_object_bytes` and `_build`).
    """
    require_file(source, "C source")
    work.mkdir(parents=True, exist_ok=True)
    preprocessed = work / (out_object.stem + ".i")
    assembly = work / (out_object.stem + ".s")
    with preprocessed.open("wb") as handle:
        run([str(tools.cpp), *tools.cpp_flags, str(source)], stdout=handle)
    run([str(tools.cc1), *tools.cc1_flags, str(preprocessed), "-o", str(assembly)])
    if tools.move_rewrite:
        # Phase 11: the `moves` mode replaces the maspsx stage with the single
        # `move`->`addu` rewrite, so GNU `as` reorder mode performs the fills.
        transformed = work / (out_object.stem + ".moves.s")
        transformed.write_text(rewrite_move_macro(assembly.read_text(encoding="utf-8")),
                               encoding="utf-8")
        assembly = transformed
    elif tools.maspsx is not None:
        transformed = work / (out_object.stem + ".maspsx.s")
        run_filter(
            [sys.executable, str(tools.maspsx),
             f"--aspsx-version={tools.aspsx_version}",
             *tools.maspsx_flags],
            assembly, transformed,
        )
        assembly = transformed
    if tools.gp_symbols:
        rewritten = work / (out_object.stem + ".gp.s")
        rewritten.write_text(
            rewrite_gp_accesses(assembly.read_text(encoding="utf-8"), tools.gp_symbols),
            encoding="utf-8",
        )
        assembly = rewritten
    run([str(tools.assembler), *tools.as_flags, "-o", str(out_object), str(assembly)])


def assemble_asm(source: Path, out_object: Path, tools: Toolchain) -> None:
    run([str(tools.assembler), *tools.as_flags, "-o", str(out_object), str(source)])


def single_linker_script(start: int) -> str:
    """A minimal script placing one object's `.text` at `start`.

    Using an explicit script (rather than `-Ttext`) avoids the default linker
    script's own symbol definitions, which would otherwise shadow a user symbol
    such as `__bss_start`.
    """
    return "\n".join([
        'OUTPUT_FORMAT("elf32-littlemips")',
        "SECTIONS",
        "{",
        f"  .text 0x{start:X} : {{ *(.text) }}",
        "  /DISCARD/ : { *(.MIPS.abiflags) *(.reginfo) *(.pdr) *(.gnu.attributes)"
        " *(.comment) *(.note*) *(.mdebug*) }",
        "}",
        "",
    ])


def link_object_bytes(
    object_path: Path, start: int, work: Path, tools: Toolchain
) -> bytes:
    """Link one object at `start` and return its relocated `.text` bytes."""
    script = work / "single.ld"
    script.write_text(single_linker_script(start), encoding="ascii")
    elf = work / "single.elf"
    binary = work / "single.bin"
    command = [str(tools.linker), "-T", str(script), "--no-check-sections"]
    for symbol in tools.defsyms:
        command += ["--defsym", symbol]
    command += ["-o", str(elf), str(object_path)]
    run(command)
    run([str(tools.objcopy), "-O", "binary", "--only-section=.text",
         str(elf), str(binary)])
    return binary.read_bytes()


def extract_text_bytes(object_path: Path, destination: Path, tools: Toolchain) -> bytes:
    run([str(tools.objcopy), "-O", "binary", "--only-section=.text",
         str(object_path), str(destination)])
    return destination.read_bytes()


# A symbol that never exists: `--keep-global-symbol` then localizes everything else.
LOCALIZE_SENTINEL = "__sf3_keep_no_global_symbol"


def localize_symbols(object_path: Path, tools: Toolchain) -> None:
    """Make a region object's symbols local.

    Region objects are placed by the linker script, not by symbol name, and any
    cross-reference is supplied as an absolute `--defsym`. Localizing therefore
    changes nothing about the emitted bytes, and it lets one shared source file
    be instantiated for several regions without a duplicate-symbol clash.
    """
    run([str(tools.objcopy), f"--keep-global-symbol={LOCALIZE_SENTINEL}",
         str(object_path)])


# --------------------------------------------------------------------------
# subcommands
# --------------------------------------------------------------------------


def command_range(args: argparse.Namespace) -> int:
    exe_path = require_file(args.exe, "executable")
    source = require_file(args.source, "C source")
    if args.end <= args.start:
        raise ToolError("--end must be greater than --start")
    work = fresh_directory(args.work, "work directory")

    data = exe_path.read_bytes()
    exe = parse_psx_exe(data)
    if args.start < exe.text_address or args.end > exe.payload_end:
        raise ToolError("requested range leaves the payload")

    work.mkdir(parents=True)
    object_path = work / "candidate.o"
    tools = args.toolchain
    compile_c(source, object_path, work, tools)
    tools = replace(tools, defsyms=resolve_undefined_symbols(
        tools.defsyms, undefined_symbols(object_path, tools), str(source)))
    candidate = link_object_bytes(object_path, args.start, work, tools)

    expected_length = args.end - args.start
    if len(candidate) != expected_length:
        print(f"range=0x{args.start:08X}..0x{args.end:08X}")
        print(f"expected_bytes={expected_length}")
        print(f"candidate_bytes={len(candidate)}")
        print("result=LENGTH-MISMATCH")
        return 1

    offset = PAYLOAD_LMA + (args.start - exe.text_address)
    expected = data[offset:offset + expected_length]
    diff = compare_bytes(expected, candidate)

    print(f"range=0x{args.start:08X}..0x{args.end:08X}")
    print(f"candidate_bytes={len(candidate)}")
    print(f"differing_bytes={diff.differing_bytes}")
    if diff.identical:
        print("result=MATCH")
        return 0
    print(f"first_difference=0x{args.start + diff.first_difference:08X}")
    print("result=DIFF")
    return 1


def _read_regions(path: Path) -> list[Region]:
    require_file(path, "region registry")
    return parse_regions(path.read_text(encoding="utf-8"))


def command_plan(args: argparse.Namespace) -> int:
    exe_path = require_file(args.exe, "executable")
    exe = parse_psx_exe(exe_path.read_bytes())
    regions = _read_regions(args.regions)
    validate_regions(regions, exe, data)
    out = args.out if args.out else Path(".")
    for item in plan_layout(exe, regions, out):
        print(f"{item.kind}\t0x{item.start:08X}\t0x{item.end:08X}\t{item.source}\t{item.object_name}")
    return 0


def _build(args: argparse.Namespace) -> tuple[Path, Path]:
    exe_path = require_file(args.exe, "executable")
    data = exe_path.read_bytes()
    exe = parse_psx_exe(data)
    regions = _read_regions(args.regions)
    validate_regions(regions, exe, data)
    for region in regions:
        require_file(Path(region.source), f"C source for 0x{region.start:08X}")

    out = fresh_directory(args.out, "output directory")
    out.mkdir(parents=True)
    work = out / "work"
    work.mkdir()

    items = plan_layout(exe, regions, out)
    resolved_exe = exe_path.resolve()
    header = out / "header.s"
    header.write_text(header_source(resolved_exe), encoding="ascii")
    for item in items:
        if item.kind == "asm":
            item.source.write_text(
                gap_source(resolved_exe, item.start, item.end), encoding="ascii"
            )

    tools = args.toolchain
    # Symbols an address-named placeholder can satisfy are added as the objects
    # that reference them are built; anything else fails loudly, naming the
    # symbol, instead of leaving a bare linker error.
    link_defsyms = list(tools.defsyms)
    by_start = {region.start: region for region in regions}
    objects: list[str] = ["header.o"]
    assemble_asm(header, out / "header.o", tools)
    for item in items:
        object_path = out / item.object_name
        if item.kind == "asm":
            assemble_asm(item.source, object_path, tools)
        else:
            region = by_start[item.start]
            region_tools = replace(
                tools,
                cc1=resolve_cc1bin(region.cc1bin, 0) if region.cc1bin else tools.cc1,
                cc1_flags=[*tools.cc1_flags, *region.cc1_flags],
                as_flags=[*tools.as_flags, *region.as_flags],
                gp_symbols=tools.gp_symbols - frozenset(region.no_gp),
                maspsx=None if region.no_maspsx else tools.maspsx,
                maspsx_flags=region.maspsx_flags,
                move_rewrite=region.move_rewrite,
            )
            compile_c(item.source, object_path, work, region_tools)
            localize_symbols(object_path, tools)
            link_defsyms = resolve_undefined_symbols(
                link_defsyms, undefined_symbols(object_path, tools), item.source)
        objects.append(item.object_name)

    script = out / "link.ld"
    script.write_text(linker_script(items, exe.text_address), encoding="ascii")
    elf = out / "scus_946_40.elf"
    link_command = [str(tools.linker), "-T", script.name, "--no-check-sections"]
    for symbol in link_defsyms:
        link_command += ["--defsym", symbol]
    link_command += ["-o", elf.name, *objects]
    run(link_command, cwd=out)
    rebuilt = out / "scus_946_40.rebuilt"
    run([str(tools.objcopy), "-O", "binary", elf.name, rebuilt.name], cwd=out)
    return exe_path, rebuilt


def region_summary(regions: Sequence[Region]) -> list[str]:
    """Report how much C the build actually contains."""
    lines = [f"c_regions={len(regions)}"]
    if not regions:
        lines.append("c_regions_note=none: build contains no C (data baseline only)")
    return lines


def command_build(args: argparse.Namespace) -> int:
    exe_path, rebuilt = _build(args)
    regions = _read_regions(args.regions)
    for line in region_summary(regions):
        print(line)
    print(f"rebuilt={rebuilt}")
    print(f"rebuilt_bytes={rebuilt.stat().st_size}")
    print(f"rebuilt_sha1={sha1_file(rebuilt)}")
    print(f"original_sha1={sha1_file(exe_path)}")
    return 0


def command_gate(args: argparse.Namespace) -> int:
    exe_path, rebuilt = _build(args)
    regions = _read_regions(args.regions)
    expected = exe_path.read_bytes()
    actual = rebuilt.read_bytes()
    diff = compare_bytes(expected, actual)
    for line in region_summary(regions):
        print(line)
    print(f"rebuilt={rebuilt}")
    print(f"rebuilt_bytes={len(actual)}")
    print(f"original_bytes={len(expected)}")
    print(f"differing_bytes={diff.differing_bytes}")
    print(f"rebuilt_sha1={sha1_file(rebuilt)}")
    print(f"original_sha1={sha1_file(exe_path)}")
    if not diff.identical:
        print("result=DIFF")
        return 1
    if args.expect_sha1 and sha1_file(rebuilt) != args.expect_sha1.lower():
        print("result=SHA1-MISMATCH")
        return 1
    print("result=MATCH")
    return 0


# --------------------------------------------------------------------------
# argument parsing
# --------------------------------------------------------------------------


def add_toolchain_arguments(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("--cpp", type=Path, default=None,
                        help="preprocessor (default: the one on PATH)")
    parser.add_argument("--cc1", type=Path, default=DEFAULT_CC1)
    parser.add_argument("--assembler", type=Path, default=DEFAULT_AS)
    parser.add_argument("--linker", type=Path, default=DEFAULT_LD)
    parser.add_argument("--objcopy", type=Path, default=DEFAULT_OBJCOPY)
    parser.add_argument("--nm", type=Path, default=DEFAULT_NM,
                        help="nm, used to resolve address-named symbols implicitly")
    parser.add_argument("--maspsx", type=Path, default=DEFAULT_MASPSX,
                        help="ASPSX emulator run between cc1 and the assembler")
    parser.add_argument("--no-maspsx", action="store_true",
                        help="assemble cc1 output directly, without maspsx")
    parser.add_argument("--no-jump-slot-nop", action="store_true",
                        help="maspsx mode: suppress the unconditional reorder nop after a "
                             "branch/jump so GNU as can fill the slot (region: maspsx=noreordernop)")
    parser.add_argument("--fill-epilogue", action="store_true",
                        help="maspsx mode: move the frame release INTO the jump's delay slot "
                             "(region token: maspsx=epilogue). 120 unclaimed rows have this "
                             "shape in the original; GNU as will not do the fill itself.")
    parser.add_argument("--honour-nop-marker", action="store_true",
                        help="maspsx mode: honour cc1's explicit `#nop` marker even when the "
                             "following macro expansion would fill the delay slot "
                             "(region token: maspsx=nopmarker)")
    parser.add_argument("--maspsx-moves", action="store_true",
                        help="run the `move`->`addu` rewrite and NO maspsx stage, so GNU as "
                             "reorder mode fills the slots (region token: maspsx=moves)")
    parser.add_argument("--nop-on-reg-read", action="store_true",
                        help="maspsx mode: also treat a following jr/jalr that uses the loaded "
                             "register as needing the load-delay nop (region: maspsx=regread)")
    parser.add_argument("--aspsx-version", default=DEFAULT_ASPSX_VERSION,
                        help="ASPSX version for maspsx (default: the SDK's 2.81)")
    parser.add_argument("--cpp-flag", action="append", default=[],
                        help="extra preprocessor flag (repeatable)")
    parser.add_argument("--cc1-flag", action="append", default=[],
                        help="extra cc1 flag, replacing the defaults when given")
    parser.add_argument("--as-flag", action="append", default=[],
                        help="extra assembler flag, replacing the defaults when given")
    parser.add_argument("--defsym", action="append", default=[],
                        help="assembler --defsym NAME=0xADDR (repeatable)")
    parser.add_argument("--no-gp", action="append", default=[], dest="no_gp",
                        help="do not treat NAME as gp-relative despite its registry marker (repeatable)")
    parser.add_argument("--symbols", type=Path, default=None,
                        help="tracked symbol registry file (NAME<TAB>address rows)")


def undefined_symbols(object_path: Path, tools: Toolchain) -> set[str]:
    """The symbols one assembled object still needs defined.

    Read from the object itself rather than guessed from its C source, so a
    symbol the source defines but never references is not mistaken for one that
    needs resolving.
    """
    if tools.nm is None:
        return set()
    completed = subprocess.run(
        [str(tools.nm), "-u", str(object_path)],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    )
    if completed.returncode:
        text = completed.stderr.decode("utf-8", "replace").strip().splitlines()
        message = text[0] if text else "no diagnostic"
        raise ToolError(f"nm failed ({completed.returncode}): {message}")
    names: set[str] = set()
    for line in completed.stdout.decode("utf-8", "replace").splitlines():
        fields = line.split()
        if fields:
            names.add(fields[-1])
    return names


def resolve_undefined_symbols(defsyms: Sequence[str], undefined: set[str],
                              label: str) -> list[str]:
    """Add an address-named placeholder for each undefined symbol; fail loudly otherwise.

    A name that is neither in the registry nor shaped like an address cannot be
    resolved, and the link would fail with a bare `ld` message. Failing here
    instead names the symbol and says what to do about it.
    """
    known = {defsym.split("=", 1)[0] for defsym in defsyms}
    resolved = list(defsyms)
    unresolved: list[str] = []
    for name in sorted(undefined):
        if name in known:
            continue
        match = ADDRESS_SYMBOL.match(name)
        if match is not None:
            resolved.append(f"{name}=0x{int(match.group(1), 16):X}")
            known.add(name)
        else:
            unresolved.append(name)
    if unresolved:
        listed = ", ".join(unresolved)
        raise ToolError(
            f"unresolved symbol(s) referenced by {label}: {listed}; add a row to the symbol "
            "registry (NAME<TAB>address[<TAB>gp]) or name the symbol func_XXXXXXXX / "
            "D_XXXXXXXX so it resolves to that address"
        )
    return resolved


def resolve_toolchain(args: argparse.Namespace) -> Toolchain:
    cpp = args.cpp
    if cpp is None:
        located = shutil.which("cpp")
        if not located:
            raise ToolError("no preprocessor found; pass --cpp")
        cpp = Path(located)
    defsyms = list(args.defsym)
    gp_names: set[str] = set()
    if args.symbols is not None:
        symbols_path = require_file(args.symbols, "symbol registry")
        table = parse_symbols(symbols_path.read_text(encoding="utf-8"))
        defsyms = list(table.defsyms) + defsyms
        gp_names |= table.gp_names
    maspsx = None
    if not args.no_maspsx:
        maspsx = require_file(args.maspsx, "maspsx")
    return Toolchain(
        cpp=require_file(cpp, "preprocessor"),
        cc1=require_file(args.cc1, "cc1"),
        assembler=require_file(args.assembler, "assembler"),
        linker=require_file(args.linker, "linker"),
        objcopy=require_file(args.objcopy, "objcopy"),
        nm=require_file(args.nm, "nm") if args.nm is not None else None,
        cpp_flags=args.cpp_flag or DEFAULT_CPP_FLAGS,
        cc1_flags=args.cc1_flag or DEFAULT_CC1_FLAGS,
        as_flags=args.as_flag or DEFAULT_AS_FLAGS,
        defsyms=defsyms,
        gp_symbols=frozenset(gp_names - set(getattr(args, "no_gp", ()))),
        maspsx=maspsx,
        move_rewrite=bool(getattr(args, "maspsx_moves", False)),
        maspsx_flags=tuple(
            flag for flag, enabled in (
                ("--no-jump-slot-nop", getattr(args, "no_jump_slot_nop", False)),
                ("--nop-on-reg-read", getattr(args, "nop_on_reg_read", False)),
                ("--honour-nop-marker", getattr(args, "honour_nop_marker", False)),
                ("--fill-epilogue", getattr(args, "fill_epilogue", False))) if enabled),
        aspsx_version=args.aspsx_version,
    )


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

    range_parser = subparsers.add_parser("range", help="compare one C candidate's range")
    range_parser.add_argument("--exe", required=True, type=Path)
    range_parser.add_argument("--source", required=True, type=Path)
    range_parser.add_argument("--start", required=True, type=parse_address)
    range_parser.add_argument("--end", required=True, type=parse_address)
    range_parser.add_argument("--work", required=True, type=Path)
    add_toolchain_arguments(range_parser)
    range_parser.set_defaults(handler=command_range)

    plan_parser = subparsers.add_parser("plan", help="print the ordered build plan")
    plan_parser.add_argument("--exe", required=True, type=Path)
    plan_parser.add_argument("--regions", required=True, type=Path)
    plan_parser.add_argument("--out", type=Path, default=None)
    plan_parser.set_defaults(handler=command_plan)

    for name, handler, help_text in (
        ("build", command_build, "build the ordered binary"),
        ("gate", command_gate, "build, then compare the whole executable"),
    ):
        sub = subparsers.add_parser(name, help=help_text)
        sub.add_argument("--exe", required=True, type=Path)
        sub.add_argument("--regions", required=True, type=Path)
        sub.add_argument("--out", required=True, type=Path)
        add_toolchain_arguments(sub)
        if name == "gate":
            sub.add_argument("--expect-sha1", default=None)
        sub.set_defaults(handler=handler)

    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if hasattr(args, "cc1"):
            args.toolchain = resolve_toolchain(args)
        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())
