#!/usr/bin/env python3
"""Poll public-predecessor exit paths with branch-state and coordinate watches."""
from __future__ import annotations

from argparse import Namespace
from collections import deque
import html
from pathlib import Path
from typing import Any

import probe_runtime_input_path as runtime_input
import probe_runtime_key_sequences as key_sequences
import probe_runtime_selected_pointer_poll as selected_poll
from probe_runtime_input_path import DEFAULT_PREFIX, OUT, ROOT
from probe_runtime_selected_pointer_multislot_savedata_poll import build_summary
from probe_runtime_selected_pointer_poll import write_outputs
from summarize_map_tiles import load_maps
from summarize_original_collision_route_audit import can_move_from_tile


OUTPUT_PREFIX = "runtime_selected_pointer_predecessor_coordinate_branch_state_poll"
SOURCE_SAVE = "data/public_savedata/flack3r/savedat2.dat"
SOURCE_MAP = "map2_02d"
TARGET_MAP = "map1_01a"
START_TILE = {"x": 5, "y": 15}

CAMERA_TILE_X = 0x004576DC
CAMERA_TILE_Y = 0x004576DE
ACTIVE_ACTOR_COUNT = 0x004576E8
ACTIVE_ACTOR_ORDER = 0x004576E9
ACTIVE_ACTOR_SLOT_TABLE = 0x00574538
ACTOR_HISTORY_INDEX_TABLE = 0x00574540
ACTOR_SEED_X_TABLE = 0x00574550
ACTOR_SEED_Y_TABLE = 0x00574552
ACTOR_HISTORY_DIRECTION_TABLE = 0x00574554
ACTOR_HISTORY_STRIDE_BYTES = 6
ACTOR_HISTORY_SLOT_COUNT = 7
ACTOR_POINTER_TABLE = 0x0059DD70
ACTOR_POINTER_SLOT_COUNT = 6
EVENT_WATCH_VALUE_KEYS = {
    "activeSelectionFlag",
    "cameraTilePair",
    "activeActorCount",
    "actor0PointerRuntime",
    "actor0PointerStatic",
    "actor0TilePair",
    "actor1TilePair",
    "actor2TilePair",
    "trail0TilePair",
    "trail1TilePair",
    "trail2TilePair",
    "secondaryBranchState0",
}

OBJECT_FRAME_SELECTOR_OFFSET = 0x28
OBJECT_ANIMATION_STATE_OFFSET = 0x68
OBJECT_FOOTPRINT_X_OFFSET = 0xE6
OBJECT_FOOTPRINT_Y_OFFSET = 0xE7
OBJECT_TILE_X_OFFSET = 0xE8
OBJECT_TILE_Y_OFFSET = 0xEA

BRANCH_STATE_WATCH_VALUES = {
    "activeSelectionFlag": (0x00457744, 1),
    **{
        f"secondaryBranchState{index}": (0x0059E360 + index, 1)
        for index in range(12)
    },
}

RECIPROCAL_EXIT_CANDIDATES = [
    {
        "name": "predecessor-coordinate-top-auto",
        "sourceMap": SOURCE_MAP,
        "targetMap": TARGET_MAP,
        "startTile": START_TILE,
        "candidateTile": {"x": 46, "y": 0},
        "candidateSide": "top",
        "targetSpawn": {"x": 16, "y": 47, "side": "bottom"},
        "reason": "coordinate confirmation for the reciprocal map1_01a bottom-exit hint",
    },
    {
        "name": "predecessor-coordinate-bottom-auto",
        "sourceMap": SOURCE_MAP,
        "targetMap": TARGET_MAP,
        "startTile": START_TILE,
        "candidateTile": {"x": 47, "y": 47},
        "candidateSide": "bottom",
        "targetSpawn": {"x": 18, "y": 0, "side": "top"},
        "reason": "coordinate confirmation for the reciprocal map1_01a top-exit hint",
    },
    {
        "name": "predecessor-coordinate-right-auto",
        "sourceMap": SOURCE_MAP,
        "targetMap": TARGET_MAP,
        "startTile": START_TILE,
        "candidateTile": {"x": 95, "y": 16},
        "candidateSide": "right",
        "targetSpawn": {"x": 3, "y": 14, "side": "left"},
        "reason": "coordinate confirmation for the reciprocal map1_01a left-exit hint",
    },
]

MOVE_ORDER = [
    ("Right", 1, 0),
    ("Left", -1, 0),
    ("Down", 0, 1),
    ("Up", 0, -1),
]
MOVE_DELTAS = {name: (dx, dy) for name, dx, dy in MOVE_ORDER}

_ORIGINAL_RUNTIME_SAMPLE_PROCESS = runtime_input.sample_process
_ORIGINAL_SELECTED_SAMPLE_PROCESS = selected_poll.sample_process
_ORIGINAL_KEY_SEQUENCE_SAMPLE_PROCESS = key_sequences.sample_process


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value & 0xFFFFFFFF:08x}"


def value_hex(value: int | None, size: int) -> str | None:
    if value is None:
        return None
    return f"0x{value & ((1 << (size * 8)) - 1):0{size * 2}x}"


def pack_pair(x: int, y: int) -> int:
    return ((y & 0xFFFF) << 16) | (x & 0xFFFF)


def pair_hex(x: int, y: int) -> str:
    return value_hex(pack_pair(x, y), 4) or "0x00000000"


def unpack_pair_hex(raw: str | None) -> dict[str, int] | None:
    if not raw:
        return None
    value = int(raw, 16)
    return {"x": value & 0xFFFF, "y": (value >> 16) & 0xFFFF}


def runtime_address_for_static(base: int, static_va: int) -> int:
    return base + (static_va - runtime_input.IMAGE_BASE)


def static_from_runtime_address(base: int, runtime_address: int | None) -> int | None:
    if runtime_address is None:
        return None
    if base <= runtime_address < base + runtime_input.IMAGE_SIZE:
        return runtime_input.IMAGE_BASE + (runtime_address - base)
    return None


def read_u(mem: Any, address: int, size: int) -> int | None:
    try:
        mem.seek(address)
        raw = mem.read(size)
    except OSError:
        return None
    if len(raw) != size:
        return None
    return int.from_bytes(raw, "little")


def add_watch(
    watches: dict[str, dict[str, Any]],
    name: str,
    *,
    value: int | None,
    size: int,
    runtime_address: int | None = None,
    static_va: int | None = None,
) -> None:
    watches[name] = {
        "staticVaHex": hex32(static_va),
        "runtimeAddressHex": hex32(runtime_address),
        "size": size,
        "valueHex": value_hex(value, size),
        "value": value,
        "readOk": value is not None,
    }


def add_static_watch(
    mem: Any,
    base: int,
    watches: dict[str, dict[str, Any]],
    name: str,
    static_va: int,
    size: int,
) -> int | None:
    runtime_address = runtime_address_for_static(base, static_va)
    value = read_u(mem, runtime_address, size)
    add_watch(watches, name, value=value, size=size, runtime_address=runtime_address, static_va=static_va)
    return value


def add_pair_watch(
    watches: dict[str, dict[str, Any]],
    name: str,
    *,
    x: int | None,
    y: int | None,
    runtime_address: int | None = None,
    static_va: int | None = None,
) -> None:
    value = pack_pair(x, y) if x is not None and y is not None else None
    add_watch(watches, name, value=value, size=4, runtime_address=runtime_address, static_va=static_va)


def sample_process_with_coordinates(pid: int, base: int) -> dict[str, Any]:
    sample = _ORIGINAL_RUNTIME_SAMPLE_PROCESS(pid, base)
    if not sample.get("readOk"):
        return sample
    watches = dict(sample.get("watchValues") or {})
    actor_rows: list[dict[str, Any]] = []
    trail_rows: list[dict[str, Any]] = []
    try:
        with (Path("/proc") / str(pid) / "mem").open("r+b", buffering=0) as mem:
            camera_x = add_static_watch(mem, base, watches, "cameraTileX", CAMERA_TILE_X, 2)
            camera_y = add_static_watch(mem, base, watches, "cameraTileY", CAMERA_TILE_Y, 2)
            add_pair_watch(
                watches,
                "cameraTilePair",
                x=camera_x,
                y=camera_y,
                runtime_address=runtime_address_for_static(base, CAMERA_TILE_X),
                static_va=CAMERA_TILE_X,
            )
            add_static_watch(mem, base, watches, "activeActorCount", ACTIVE_ACTOR_COUNT, 1)
            for index in range(ACTOR_POINTER_SLOT_COUNT):
                add_static_watch(mem, base, watches, f"activeActorOrder{index}", ACTIVE_ACTOR_ORDER + index, 1)
                add_static_watch(mem, base, watches, f"activeActorSlot{index}", ACTIVE_ACTOR_SLOT_TABLE + index, 1)
            for index in range(3):
                add_static_watch(mem, base, watches, f"actorHistoryIndex{index}", ACTOR_HISTORY_INDEX_TABLE + index, 1)
            for index in range(ACTOR_HISTORY_SLOT_COUNT):
                x_static = ACTOR_SEED_X_TABLE + index * ACTOR_HISTORY_STRIDE_BYTES
                y_static = ACTOR_SEED_Y_TABLE + index * ACTOR_HISTORY_STRIDE_BYTES
                direction_static = ACTOR_HISTORY_DIRECTION_TABLE + index * ACTOR_HISTORY_STRIDE_BYTES
                x = read_u(mem, runtime_address_for_static(base, x_static), 2)
                y = read_u(mem, runtime_address_for_static(base, y_static), 2)
                direction = read_u(mem, runtime_address_for_static(base, direction_static), 2)
                add_pair_watch(
                    watches,
                    f"trail{index}TilePair",
                    x=x,
                    y=y,
                    runtime_address=runtime_address_for_static(base, x_static),
                    static_va=x_static,
                )
                add_watch(
                    watches,
                    f"trail{index}Direction",
                    value=direction,
                    size=2,
                    runtime_address=runtime_address_for_static(base, direction_static),
                    static_va=direction_static,
                )
                trail_rows.append({"slot": index, "tile": {"x": x, "y": y}, "direction": direction})
            for index in range(ACTOR_POINTER_SLOT_COUNT):
                pointer_static = ACTOR_POINTER_TABLE + index * 4
                pointer_runtime_address = runtime_address_for_static(base, pointer_static)
                pointer = read_u(mem, pointer_runtime_address, 4)
                add_watch(
                    watches,
                    f"actor{index}PointerRuntime",
                    value=pointer,
                    size=4,
                    runtime_address=pointer_runtime_address,
                    static_va=pointer_static,
                )
                pointer_static_va = static_from_runtime_address(base, pointer)
                add_watch(
                    watches,
                    f"actor{index}PointerStatic",
                    value=pointer_static_va,
                    size=4,
                    runtime_address=pointer_runtime_address,
                    static_va=pointer_static,
                )
                if not pointer:
                    actor_rows.append({"slot": index, "pointerRuntimeHex": None, "readOk": False})
                    continue
                tile_x = read_u(mem, pointer + OBJECT_TILE_X_OFFSET, 2)
                tile_y = read_u(mem, pointer + OBJECT_TILE_Y_OFFSET, 2)
                add_watch(
                    watches,
                    f"actor{index}TileX",
                    value=tile_x,
                    size=2,
                    runtime_address=pointer + OBJECT_TILE_X_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_TILE_X_OFFSET),
                )
                add_watch(
                    watches,
                    f"actor{index}TileY",
                    value=tile_y,
                    size=2,
                    runtime_address=pointer + OBJECT_TILE_Y_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_TILE_Y_OFFSET),
                )
                add_pair_watch(
                    watches,
                    f"actor{index}TilePair",
                    x=tile_x,
                    y=tile_y,
                    runtime_address=pointer + OBJECT_TILE_X_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_TILE_X_OFFSET),
                )
                add_watch(
                    watches,
                    f"actor{index}AnimationState",
                    value=read_u(mem, pointer + OBJECT_ANIMATION_STATE_OFFSET, 4),
                    size=4,
                    runtime_address=pointer + OBJECT_ANIMATION_STATE_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_ANIMATION_STATE_OFFSET),
                )
                add_watch(
                    watches,
                    f"actor{index}FrameSelector",
                    value=read_u(mem, pointer + OBJECT_FRAME_SELECTOR_OFFSET, 4),
                    size=4,
                    runtime_address=pointer + OBJECT_FRAME_SELECTOR_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_FRAME_SELECTOR_OFFSET),
                )
                footprint_x = read_u(mem, pointer + OBJECT_FOOTPRINT_X_OFFSET, 1)
                footprint_y = read_u(mem, pointer + OBJECT_FOOTPRINT_Y_OFFSET, 1)
                add_watch(
                    watches,
                    f"actor{index}FootprintX",
                    value=footprint_x,
                    size=1,
                    runtime_address=pointer + OBJECT_FOOTPRINT_X_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_FOOTPRINT_X_OFFSET),
                )
                add_watch(
                    watches,
                    f"actor{index}FootprintY",
                    value=footprint_y,
                    size=1,
                    runtime_address=pointer + OBJECT_FOOTPRINT_Y_OFFSET,
                    static_va=static_from_runtime_address(base, pointer + OBJECT_FOOTPRINT_Y_OFFSET),
                )
                actor_rows.append({
                    "slot": index,
                    "pointerRuntimeHex": hex32(pointer),
                    "pointerStaticHex": hex32(pointer_static_va),
                    "tile": {"x": tile_x, "y": tile_y},
                    "footprint": {"x": footprint_x, "y": footprint_y},
                    "readOk": tile_x is not None and tile_y is not None,
                })
    except OSError as exc:
        sample["coordinateReadOk"] = False
        sample["coordinateReadError"] = str(exc)
    else:
        sample["coordinateReadOk"] = True
        sample["actorCoordinateRows"] = actor_rows
        sample["trailCoordinateRows"] = trail_rows
    sample["watchValues"] = watches
    return sample


def find_path(info: dict[str, Any], start: dict[str, int], target: dict[str, int]) -> list[str]:
    start_cell = (int(start["x"]), int(start["y"]))
    target_cell = (int(target["x"]), int(target["y"]))
    queue: deque[tuple[int, int]] = deque([start_cell])
    previous: dict[tuple[int, int], tuple[int, int] | None] = {start_cell: None}
    move_to: dict[tuple[int, int], str] = {}
    while queue:
        x, y = queue.popleft()
        if (x, y) == target_cell:
            break
        for key, dx, dy in MOVE_ORDER:
            next_cell = (x + dx, y + dy)
            if next_cell in previous:
                continue
            if can_move_from_tile(info, x, y, dx, dy):
                previous[next_cell] = (x, y)
                move_to[next_cell] = key
                queue.append(next_cell)
    if target_cell not in previous:
        raise SystemExit(f"no original-layer1 path from {start_cell} to {target_cell}")
    keys: list[str] = []
    current = target_cell
    while current != start_cell:
        keys.append(move_to[current])
        parent = previous[current]
        if parent is None:
            break
        current = parent
    keys.reverse()
    return keys


def run_lengths(keys: list[str]) -> list[dict[str, int | str]]:
    rows: list[dict[str, int | str]] = []
    for key in keys:
        if rows and rows[-1]["key"] == key:
            rows[-1]["count"] = int(rows[-1]["count"]) + 1
        else:
            rows.append({"key": key, "count": 1})
    return rows


def path_points(start: dict[str, int], keys: list[str]) -> list[dict[str, int]]:
    x = int(start["x"])
    y = int(start["y"])
    points = [{"x": x, "y": y}]
    for key in keys:
        dx, dy = MOVE_DELTAS[key]
        x += dx
        y += dy
        points.append({"x": x, "y": y})
    return points


def planned_sequences() -> tuple[list[str], list[dict[str, Any]]]:
    maps = load_maps(ROOT / "out" / "maps.js")
    info = maps[SOURCE_MAP]
    sequences: list[str] = []
    plans: list[dict[str, Any]] = []
    for candidate in RECIPROCAL_EXIT_CANDIDATES:
        target_tile = candidate["candidateTile"]
        path_keys = find_path(info, START_TILE, target_tile)
        keys = ["Down", "Return", *path_keys, "Return"]
        sequences.append(f"{candidate['name']}={','.join(keys)}")
        plans.append({
            **candidate,
            "pathStepCount": len(path_keys),
            "pathRunLengths": run_lengths(path_keys),
            "pathKeys": path_keys,
            "pathPoints": path_points(START_TILE, path_keys),
            "sequenceKeyCount": len(keys),
            "sequenceKeys": keys,
        })
    return sequences, plans


def unique_values(row: dict[str, Any], name: str) -> list[dict[str, Any]]:
    values = ((row.get("uniqueWatchValues") or {}).get(name) or [])
    return [value for value in values if value.get("valueHex") is not None]


def unique_pair_set(row: dict[str, Any], name: str) -> set[str]:
    return {value["valueHex"] for value in unique_values(row, name)}


def analyze_coordinates(summary: dict[str, Any], plans: list[dict[str, Any]]) -> dict[str, Any]:
    plans_by_name = {plan["name"]: plan for plan in plans}
    rows = []
    any_target_reached = False
    any_start_reached = False
    for row in summary.get("rows") or []:
        plan = plans_by_name.get(row.get("name"))
        if not plan:
            continue
        start_hex = pair_hex(int(START_TILE["x"]), int(START_TILE["y"]))
        target = plan["candidateTile"]
        target_hex = pair_hex(int(target["x"]), int(target["y"]))
        path_pair_hexes = {
            pair_hex(int(point["x"]), int(point["y"]))
            for point in plan.get("pathPoints") or []
        }
        slot_rows = []
        movement_slots = []
        start_slots = []
        target_slots = []
        best_slot: dict[str, Any] | None = None
        for slot in range(ACTOR_POINTER_SLOT_COUNT):
            values = unique_pair_set(row, f"actor{slot}TilePair")
            decoded = [unpack_pair_hex(raw) for raw in sorted(values)]
            decoded = [item for item in decoded if item is not None]
            path_hits = values & path_pair_hexes
            min_distance = None
            if decoded:
                min_distance = min(
                    abs(int(item["x"]) - int(target["x"])) + abs(int(item["y"]) - int(target["y"]))
                    for item in decoded
                )
            slot_row = {
                "slot": slot,
                "uniqueTilePairCount": len(values),
                "movementObserved": len(values) > 1,
                "startObserved": start_hex in values,
                "targetObserved": target_hex in values,
                "plannedPathHitCount": len(path_hits),
                "plannedPathPointCount": len(path_pair_hexes),
                "plannedPathCoverageRatio": round(len(path_hits) / max(len(path_pair_hexes), 1), 4),
                "minManhattanDistanceToTarget": min_distance,
                "samplePairs": decoded[:12],
                "samplePairsTruncated": max(0, len(decoded) - 12),
            }
            slot_rows.append(slot_row)
            if slot_row["movementObserved"]:
                movement_slots.append(slot)
            if slot_row["startObserved"]:
                start_slots.append(slot)
            if slot_row["targetObserved"]:
                target_slots.append(slot)
            if best_slot is None or slot_row["plannedPathHitCount"] > best_slot["plannedPathHitCount"]:
                best_slot = slot_row
        row_summary = {
            "name": row.get("name"),
            "candidateTile": target,
            "candidateSide": plan.get("candidateSide"),
            "sequenceKeyCount": len(row.get("keys") or []),
            "sampleCount": row.get("sampleCount"),
            "eventCount": row.get("eventCount"),
            "eventsTruncated": row.get("eventsTruncated"),
            "startTilePairHex": start_hex,
            "targetTilePairHex": target_hex,
            "startObservedByActorSlots": start_slots,
            "targetObservedByActorSlots": target_slots,
            "movementObservedByActorSlots": movement_slots,
            "bestActorSlot": best_slot,
            "actorSlots": slot_rows,
            "activeActorCountValues": unique_values(row, "activeActorCount"),
            "cameraTilePairs": [
                unpack_pair_hex(value["valueHex"]) | {"count": value.get("count")}
                for value in unique_values(row, "cameraTilePair")
                if unpack_pair_hex(value["valueHex"])
            ],
            "branchStateAllZero": all(
                (unique_values(row, f"secondaryBranchState{index}") or [{}])[0].get("valueHex") == "0x00"
                and len(unique_values(row, f"secondaryBranchState{index}")) == 1
                for index in range(12)
            ),
        }
        any_target_reached = any_target_reached or bool(target_slots)
        any_start_reached = any_start_reached or bool(start_slots)
        rows.append(row_summary)
    return {
        "anyStartTileObserved": any_start_reached,
        "anyTargetTileObserved": any_target_reached,
        "sequenceCount": len(rows),
        "rows": rows,
        "classification": "coordinate-target-reached" if any_target_reached else "coordinate-target-not-observed",
    }


def coordinate_markdown(summary: dict[str, Any]) -> str:
    analysis = summary.get("coordinateAnalysis") or {}
    lines = [
        "",
        "## Coordinate Confirmation",
        "",
        f"- classification: `{analysis.get('classification')}`",
        f"- start tile observed: {analysis.get('anyStartTileObserved')}",
        f"- candidate target tile observed: {analysis.get('anyTargetTileObserved')}",
        "",
        "| sequence | candidate | samples | movement slots | start slots | target slots | best slot coverage | min target distance | branch state |",
        "| --- | --- | ---: | --- | --- | --- | ---: | ---: | --- |",
    ]
    for row in analysis.get("rows") or []:
        best = row.get("bestActorSlot") or {}
        candidate = row.get("candidateTile") or {}
        lines.append(
            f"| `{row.get('name')}` | `{candidate.get('x')},{candidate.get('y')}` "
            f"{row.get('candidateSide') or ''} | {row.get('sampleCount')} | "
            f"`{','.join(str(item) for item in row.get('movementObservedByActorSlots') or []) or '-'}` | "
            f"`{','.join(str(item) for item in row.get('startObservedByActorSlots') or []) or '-'}` | "
            f"`{','.join(str(item) for item in row.get('targetObservedByActorSlots') or []) or '-'}` | "
            f"{best.get('plannedPathHitCount')}/{best.get('plannedPathPointCount')} | "
            f"{best.get('minManhattanDistanceToTarget')} | "
            f"{'all-zero' if row.get('branchStateAllZero') else 'mixed'} |"
        )
    lines.extend([
        "",
        "Coordinate watches read camera words `0x004576dc/0x004576de`, active actor/order globals, "
        "party trail ring entries, and runtime actor pointer-table object fields `+0xe8/+0xea`.",
    ])
    return "\n".join(lines)


def coordinate_html(summary: dict[str, Any]) -> str:
    analysis = summary.get("coordinateAnalysis") or {}
    rows = []
    for row in analysis.get("rows") or []:
        best = row.get("bestActorSlot") or {}
        candidate = row.get("candidateTile") or {}
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('name')))}</code></td>"
            f"<td><code>{html.escape(str(candidate.get('x')) + ',' + str(candidate.get('y')))}</code></td>"
            f"<td>{html.escape(str(row.get('sampleCount')))}</td>"
            f"<td><code>{html.escape(','.join(str(item) for item in row.get('movementObservedByActorSlots') or []) or '-')}</code></td>"
            f"<td><code>{html.escape(','.join(str(item) for item in row.get('startObservedByActorSlots') or []) or '-')}</code></td>"
            f"<td><code>{html.escape(','.join(str(item) for item in row.get('targetObservedByActorSlots') or []) or '-')}</code></td>"
            f"<td>{html.escape(str(best.get('plannedPathHitCount')))} / {html.escape(str(best.get('plannedPathPointCount')))}</td>"
            f"<td>{html.escape(str(best.get('minManhattanDistanceToTarget')))}</td>"
            f"<td>{'all-zero' if row.get('branchStateAllZero') else 'mixed'}</td>"
            "</tr>"
        )
    return "\n".join([
        "<h2>Coordinate Confirmation</h2>",
        "<ul>",
        f"<li>classification: <code>{html.escape(str(analysis.get('classification')))}</code></li>",
        f"<li>start tile observed: {html.escape(str(analysis.get('anyStartTileObserved')))}</li>",
        f"<li>candidate target tile observed: {html.escape(str(analysis.get('anyTargetTileObserved')))}</li>",
        "</ul>",
        "<table><thead><tr><th>sequence</th><th>candidate</th><th>samples</th><th>movement slots</th><th>start slots</th><th>target slots</th><th>best path coverage</th><th>min target distance</th><th>branch state</th></tr></thead><tbody>",
        "\n".join(rows),
        "</tbody></table>",
    ])


def write_coordinate_outputs(summary: dict[str, Any]) -> None:
    write_outputs(summary, OUT, OUTPUT_PREFIX)
    html_path = OUT / f"{OUTPUT_PREFIX}.html"
    html_path.write_text(html_path.read_text(encoding="utf-8") + coordinate_html(summary), encoding="utf-8")


def prune_sample_for_output(sample: dict[str, Any]) -> None:
    watch_values = sample.get("watchValues")
    if isinstance(watch_values, dict):
        sample["watchValues"] = {
            name: value
            for name, value in watch_values.items()
            if name in EVENT_WATCH_VALUE_KEYS
        }
    sample.pop("actorCoordinateRows", None)
    sample.pop("trailCoordinateRows", None)


def prune_summary_for_output(summary: dict[str, Any]) -> None:
    for row in summary.get("rows") or []:
        for event in row.get("events") or []:
            prune_sample_for_output(event)
        for prelude_row in row.get("preludeRows") or []:
            for sample_key in ("heldSample", "eventSample"):
                sample = prelude_row.get(sample_key)
                if isinstance(sample, dict):
                    prune_sample_for_output(sample)


def install_coordinate_sampler() -> None:
    runtime_input.sample_process = sample_process_with_coordinates
    selected_poll.sample_process = sample_process_with_coordinates
    key_sequences.sample_process = sample_process_with_coordinates


def restore_coordinate_sampler() -> None:
    runtime_input.sample_process = _ORIGINAL_RUNTIME_SAMPLE_PROCESS
    selected_poll.sample_process = _ORIGINAL_SELECTED_SAMPLE_PROCESS
    key_sequences.sample_process = _ORIGINAL_KEY_SEQUENCE_SAMPLE_PROCESS


def main() -> None:
    runtime_input.ROUTE_WATCH_VALUES = dict(runtime_input.ROUTE_WATCH_VALUES)
    runtime_input.ROUTE_WATCH_VALUES.update(BRANCH_STATE_WATCH_VALUES)
    sequences, plans = planned_sequences()
    args = Namespace(
        startup_wait=18.0,
        hold=0.7,
        gap=0.25,
        interval=0.02,
        prelude="input-path",
        sequence=sequences,
        slot_source=[f"1={SOURCE_SAVE}"],
        case_aliases=True,
        staged_kind="public predecessor coordinate branch-state watch",
        prefix=DEFAULT_PREFIX,
        out_dir=OUT,
        output_prefix=OUTPUT_PREFIX,
    )
    install_coordinate_sampler()
    try:
        summary = build_summary(args)
    finally:
        restore_coordinate_sampler()
    coordinate_analysis = analyze_coordinates(summary, plans)
    summary["objective"] = "public predecessor reciprocal-exit path poll with branch-state and coordinate watches"
    summary["targetedExitCandidates"] = plans
    summary["branchStateWatchValues"] = BRANCH_STATE_WATCH_VALUES
    summary["coordinateWatchModel"] = {
        "cameraTileGlobalsHex": [hex32(CAMERA_TILE_X), hex32(CAMERA_TILE_Y)],
        "activeActorCountGlobalHex": hex32(ACTIVE_ACTOR_COUNT),
        "activeActorOrderBaseHex": hex32(ACTIVE_ACTOR_ORDER),
        "activeActorSlotTableHex": hex32(ACTIVE_ACTOR_SLOT_TABLE),
        "activeActorPointerTableHex": hex32(ACTOR_POINTER_TABLE),
        "actorObjectTileOffsetsHex": [hex32(OBJECT_TILE_X_OFFSET), hex32(OBJECT_TILE_Y_OFFSET)],
        "partyTrailRingHex": {
            "historyIndexTable": hex32(ACTOR_HISTORY_INDEX_TABLE),
            "seedTileXTable": hex32(ACTOR_SEED_X_TABLE),
            "seedTileYTable": hex32(ACTOR_SEED_Y_TABLE),
            "directionTable": hex32(ACTOR_HISTORY_DIRECTION_TABLE),
            "slotCount": ACTOR_HISTORY_SLOT_COUNT,
            "strideBytes": ACTOR_HISTORY_STRIDE_BYTES,
        },
    }
    summary["pathPlanner"] = {
        "map": SOURCE_MAP,
        "startTile": START_TILE,
        "collisionModel": "original layer1 direction flags via can_move_from_tile",
        "excludedAlreadyCoveredCandidate": {
            "candidateTile": {"x": 1, "y": 14},
            "candidateSide": "left",
            "coveredBy": "runtime_selected_pointer_predecessor_nearest_exit_branch_state_poll",
        },
    }
    summary["coordinateAnalysis"] = coordinate_analysis
    summary["coordinateBranchStateSplit"] = {
        "classification": coordinate_analysis.get("classification"),
        "anyStartTileObserved": coordinate_analysis.get("anyStartTileObserved"),
        "anyTargetTileObserved": coordinate_analysis.get("anyTargetTileObserved"),
        "anyReachedCurrentRoot": summary.get("anyReachedCurrentRoot"),
        "anyReachedRouteSelectorContext": summary.get("anyReachedRouteSelectorContext"),
    }
    summary["conclusion"] = (
        f"Staged the public predecessor save and polled selector, branch-state, and runtime coordinate watches "
        f"across {summary.get('sequenceCount')} reciprocal-exit path(s). "
        f"Coordinate classification={coordinate_analysis.get('classification')}; "
        f"target tile observed={coordinate_analysis.get('anyTargetTileObserved')}; "
        f"route selector 2:0 reached={summary.get('anyReachedRouteSelectorContext')}. "
        "This is coordinate-qualified runtime evidence for the predecessor paths, not normal route promotion."
    )
    prune_summary_for_output(summary)
    write_coordinate_outputs(summary)
    print(f"wrote predecessor coordinate branch-state poll -> {OUT / (OUTPUT_PREFIX + '.html')}")


if __name__ == "__main__":
    main()
