#!/usr/bin/env python3
"""Run a WebKit browser smoke test for out/web_playtest_route.html.

Run it under Xvfb in headless VMs:

    xvfb-run -a python3 tools/verify_route_guide_browser.py --base http://127.0.0.1:8013
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import socket
import subprocess
import time
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
from urllib.request import Request, urlopen


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXPECTED_ROUTE_BLOCK_REASONS = [
    "no map1_01a->map2_02d transition-review row",
    "no strict map1_01a event transition",
    "coordinate refs are non-promotable",
    "variant coordinate scan found no strict source coordinate",
    "tile signature matches only as geometry/tile evidence",
]
EXPECTED_ROUTE_BLOCKER_SHORT_TEXT = "trial-only: no map1_01a->map2_02d transition-review row +4"
EXPECTED_ROUTE_BLOCKER_MISSING_EVIDENCE = [
    "strict source coordinate",
    "tile hotspot confirmation",
    "selected-root execution ref",
    "real selector 2:0 captured savedata",
    "runtime trace/equivalent selected-root proof",
]
EXPECTED_ROUTE_BLOCKER_EXTERNAL_INPUT_IDS = [
    "real-selector-2-0-save",
    "normal-route-runtime-trace",
    "strict-source-hotspot",
]
EXPECTED_STRICT_HOTSPOT_MISSING_EVIDENCE = [
    "strict map1_01a event/coordinate source row for map2_02d",
    "route review or tile hotspot tied to map1_01a -> map2_02d",
    "strict source-target link for map1_01a -> map2_02d",
    "equivalent runtime trigger proof replacing selector-only adjacency",
]
EXPECTED_STRICT_HOTSPOT_FAILED_GATES = [
    "strict-source-coordinate",
    "tile-hotspot-confirmation",
    "strict-target-link",
    "equivalent-runtime-trigger-proof",
]
EXPECTED_STRICT_HOTSPOT_REJECTION = "selector-only-scene-list-no-strict-hotspot"


class WebDriverError(RuntimeError):
    pass


def browser_display_available() -> bool:
    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))


def mini_browser_session_requested(path: str, payload: dict[str, Any] | None) -> bool:
    if path != "/session" or not isinstance(payload, dict):
        return False
    capabilities = payload.get("capabilities") or {}
    always_match = capabilities.get("alwaysMatch") or {}
    return always_match.get("browserName") == "MiniBrowser"


def has_expected_route_blocker_evidence(entry: dict[str, Any]) -> bool:
    inputs = entry.get("routeBlockerExternalProofInputs") or []
    input_ids = [str(row.get("id") or "") for row in inputs if isinstance(row, dict)]
    checklist = entry.get("routeBlockerStrictHotspotChecklist") or {}
    candidates = checklist.get("candidateRows") or []
    first_candidate = candidates[0] if candidates else {}
    first_source_tile = first_candidate.get("sourceTile") or {}
    first_target_spawn = first_candidate.get("targetSpawn") or {}
    return (
        entry.get("routeBlockerMissingEvidence") == EXPECTED_ROUTE_BLOCKER_MISSING_EVIDENCE
        and entry.get("routeBlockerMissingEvidenceCount") == len(EXPECTED_ROUTE_BLOCKER_MISSING_EVIDENCE)
        and entry.get("routeBlockerExternalProofInputCount") == len(EXPECTED_ROUTE_BLOCKER_EXTERNAL_INPUT_IDS)
        and input_ids == EXPECTED_ROUTE_BLOCKER_EXTERNAL_INPUT_IDS
        and entry.get("routeBlockerEvidenceMatrixUrl") == "route_blocker_evidence_matrix.html"
        and entry.get("routeBlockerExternalProofHandoffUrl") == "route_promotion_external_proof_handoff.html"
        and entry.get("routeBlockerExternalProofHandoffStatus") == "blocked"
        and entry.get("routeBlockerExternalProofHandoffAchieved") is False
        and entry.get("routeBlockerCompletionAuditUrl") == "completion_audit.html"
        and entry.get("routeBlockerGoalChecklistUrl") == "goal_completion_checklist.html"
        and entry.get("routeBlockerStrictEventCoverageUrl") == "strict_event_source_coverage.html"
        and entry.get("browserRouteBlockerEvidenceChecklistImplemented") is True
        and entry.get("browserRouteBlockerStrictHotspotChecklistImplemented") is True
        and entry.get("routeBlockerStrictHotspotCandidateCount") == 4
        and entry.get("routeBlockerStrictHotspotRejectedCandidateCount") == 4
        and entry.get("routeBlockerStrictHotspotMissingEvidence") == EXPECTED_STRICT_HOTSPOT_MISSING_EVIDENCE
        and entry.get("routeBlockerStrictHotspotMissingEvidenceCount") == len(EXPECTED_STRICT_HOTSPOT_MISSING_EVIDENCE)
        and entry.get("routeBlockerStrictHotspotFailedGateIds") == EXPECTED_STRICT_HOTSPOT_FAILED_GATES
        and entry.get("routeBlockerStrictHotspotFailedGateCount") == len(EXPECTED_STRICT_HOTSPOT_FAILED_GATES)
        and entry.get("routeBlockerStrictHotspotRejectionClassification") == EXPECTED_STRICT_HOTSPOT_REJECTION
        and checklist.get("source") == "map1_01a"
        and checklist.get("target") == "map2_02d"
        and checklist.get("promotionStatus") == "blocked"
        and checklist.get("proofFound") is False
        and checklist.get("strictSourceHotspotProofFound") is False
        and checklist.get("tileHotspotConfirmed") is False
        and checklist.get("candidateCount") == 4
        and checklist.get("rejectedCandidateCount") == 4
        and checklist.get("rejectionClassification") == EXPECTED_STRICT_HOTSPOT_REJECTION
        and len(candidates) == 4
        and first_candidate.get("side") == "top"
        and first_candidate.get("coordinateStatus") == "coordinate-like-hit-without-table-text-ref"
        and first_candidate.get("coordinatePromotable") is False
        and first_candidate.get("variantStrictCoordinateEvidenceFound") is False
        and first_candidate.get("routeReviewRowCount") == 0
        and first_candidate.get("eventTransitionCount") == 0
        and first_candidate.get("blockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
        and first_source_tile.get("x") == 18
        and first_source_tile.get("y") == 0
        and first_source_tile.get("geometryStandable") is True
        and first_source_tile.get("sourceOriginalStandable") is True
        and first_target_spawn.get("side") == "bottom"
        and first_target_spawn.get("x") == 47
        and first_target_spawn.get("y") == 47
        and first_target_spawn.get("targetSpawnOriginalStandable") is True
        and "map1_01a&startTile=18%2C0" in str(first_candidate.get("routeAssistUrl") or "")
        and "map2_02d&startTile=47%2C47" in str(first_candidate.get("targetSpawnReviewUrl") or "")
        and any("0x00540714" in str(row.get("acceptedSignal") or "") for row in inputs if isinstance(row, dict))
        and any("refresh_savedata_route_proof.py" in str(row.get("refresh") or "") for row in inputs if isinstance(row, dict))
    )


def free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def request_json(port: int, method: str, path: str, payload: dict[str, Any] | None = None, timeout: float = 10) -> dict[str, Any] | None:
    if mini_browser_session_requested(path, payload) and not browser_display_available():
        raise WebDriverError(
            "MiniBrowser requires DISPLAY/WAYLAND_DISPLAY. "
            "Run this browser smoke test with `xvfb-run -a ...` in headless VMs."
        )
    data = None if payload is None else json.dumps(payload).encode("utf-8")
    request = Request(
        f"http://127.0.0.1:{port}{path}",
        data=data,
        method=method,
        headers={"Content-Type": "application/json"},
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            body = response.read().decode("utf-8")
    except TimeoutError as exc:
        raise WebDriverError(f"WebDriver request timed out after {timeout}s: {method} {path}") from exc
    return json.loads(body) if body else None


def wait_for_driver(port: int, proc: subprocess.Popen[bytes], timeout: float = 8) -> None:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if proc.poll() is not None:
            raise WebDriverError(f"WebKitWebDriver exited early with status {proc.returncode}")
        try:
            status = request_json(port, "GET", "/status", timeout=0.5)
        except Exception:
            time.sleep(0.1)
            continue
        if status and status.get("value", {}).get("ready") is True:
            return
        time.sleep(0.1)
    raise WebDriverError("WebKitWebDriver did not become ready")


def execute_js(port: int, session_id: str, script: str, timeout: float = 8) -> Any:
    response = request_json(
        port,
        "POST",
        f"/session/{session_id}/execute/sync",
        {"script": script, "args": []},
        timeout=timeout,
    )
    if not response or "value" not in response:
        raise WebDriverError(f"bad execute/sync response: {response!r}")
    return response["value"]


def route_page_state_script() -> str:
    return """
const links = [...document.querySelectorAll('a')].map((link) => ({
  text: link.textContent || '',
  href: link.getAttribute('href') || '',
  absoluteHref: link.href || '',
}));
const text = document.body?.innerText || '';
return {
  readyState: document.readyState,
  title: document.querySelector('h1')?.textContent || '',
  statusText: text.includes('routeAssist-required'),
  confirmedStart: text.includes('Confirmed start:') && text.includes('map1_02b'),
  blockerText: text.includes('map1_01a -> map2_02d'),
  routeAssistReachableText: text.includes('RouteAssist reachable: 25 maps'),
  routePathText: text.includes('map1_02b -> map1_01a -> map2_02d -> map2_18d'),
  savedataBlockedText: (
    (text.includes('no-saves-found') || text.includes('valid-saves-without-current-selector')) &&
    text.includes('selector 2:0') &&
    (text.includes('Current selector real saves') || text.includes('current selector real saves'))
  ),
  requiredSaveBytesText: text.includes('0x0002') && text.includes('0x0003') && text.includes('0x00540714'),
  linkCount: links.length,
  routeGoalLinks: links.filter((link) => link.href.includes('routeGoal=')).length,
  routeGoalMap218dLinks: links.filter((link) => link.href.includes('routeGoal=map2_18d')).length,
  matrixLinks: links.filter((link) => link.href.includes('route_blocker_evidence_matrix.html')).length,
  completionAuditLinks: links.filter((link) => link.href.includes('completion_audit.html')).length,
  savedatScanLinks: links.filter((link) => link.href.includes('savedatScan=1')).length,
  savedatUrlLinks: links.filter((link) => link.href.includes('savedatUrl=')).length,
};
"""


def wait_for_route_page(port: int, session_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_page_state_script(), timeout=3)
        last_state = state
        if (
            state.get("readyState") == "complete"
            and state.get("title") == "Hwanse Web Playtest Route"
            and state.get("statusText") is True
            and state.get("confirmedStart") is True
            and state.get("blockerText") is True
            and state.get("routeAssistReachableText") is True
            and state.get("routePathText") is True
            and state.get("savedataBlockedText") is True
            and state.get("requiredSaveBytesText") is True
            and state.get("routeGoalMap218dLinks", 0) >= 1
            and state.get("matrixLinks", 0) >= 1
            and state.get("completionAuditLinks", 0) >= 1
            and state.get("savedatScanLinks", 0) >= 1
            and state.get("savedatUrlLinks", 0) >= 2
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route guide did not expose expected route evidence: {last_state!r}")


def click_map218d_goal_script() -> str:
    return """
const link = [...document.querySelectorAll('a')]
  .find((item) => item.href.includes('routeGoal=map2_18d') && item.href.includes('trialTransitions=routeAssist'));
if (!link) return false;
link.click();
return true;
"""


def runtime_route_goal_state_script() -> str:
    return """
if (typeof render === 'function') render();
const routeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: new URLSearchParams(location.search).get('routeGoal') || '',
  trialTransitions: new URLSearchParams(location.search).get('trialTransitions') || '',
  routeButtons: {
    nextHidden: document.getElementById('routeNextButton')?.hidden ?? null,
    targetHidden: document.getElementById('routeTargetButton')?.hidden ?? null,
    frontierHidden: document.getElementById('routeFrontierSelect')?.hidden ?? null,
  },
  routeSelectCount: document.getElementById('routePathSelect')?.options.length || 0,
  routeWaypoint,
};
"""


def wait_for_route_goal_runtime(port: int, session_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, runtime_route_goal_state_script(), timeout=3)
        last_state = state
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_02b"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and state.get("routeSelectCount", 0) >= 20
            and (state.get("routeWaypoint") or {}).get("rendered") is True
            and (state.get("routeWaypoint") or {}).get("browserRouteAssistWaypointImplemented") is True
            and (state.get("routeWaypoint") or {}).get("routeTarget") == "map2_18d"
            and (state.get("routeWaypoint") or {}).get("sourceMap") == "map1_02b"
            and (state.get("routeWaypoint") or {}).get("targetMap") == "map1_01a"
            and ((state.get("routeWaypoint") or {}).get("tile") or {}).get("x") == 11
            and ((state.get("routeWaypoint") or {}).get("tile") or {}).get("y") == 12
            and (state.get("routeWaypoint") or {}).get("stepKind") == "confirmed"
            and (state.get("routeWaypoint") or {}).get("progressKind") == "map-transition"
            and (state.get("routeWaypoint") or {}).get("originalRoutePromotionImplemented") is False
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"routeGoal runtime did not load from guide link: {last_state!r}")


def route_waypoint_prompt_state_script() -> str:
    return """
if (typeof render === 'function') render();
const prompt = window.HWANSE_LAST_ACTION_PROMPT || null;
return {
  readyState: document.readyState,
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: new URLSearchParams(location.search).get('routeGoal') || '',
  trialTransitions: new URLSearchParams(location.search).get('trialTransitions') || '',
  prompt,
};
"""


def wait_for_route_waypoint_prompt(port: int, session_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_prompt_state_script(), timeout=3)
        last_state = state
        prompt = state.get("prompt") or {}
        if (
            state.get("readyState") == "complete"
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_02b"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and prompt.get("kind") == "route-assist-waypoint"
            and prompt.get("text") == "Enter -> 다음 map1_01a"
            and prompt.get("routeTarget") == "map2_18d"
            and prompt.get("sourceMap") == "map1_02b"
            and prompt.get("targetMap") == "map1_01a"
            and prompt.get("stepKind") == "confirmed"
            and prompt.get("progressKind") == "map-transition"
            and (prompt.get("waypointTile") or {}).get("x") == 11
            and (prompt.get("waypointTile") or {}).get("y") == 12
            and prompt.get("browserRouteAssistWaypointActionPromptImplemented") is True
            and prompt.get("originalRoutePromotionImplemented") is False
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint action prompt did not render: {last_state!r}")


def press_route_waypoint_enter_script() -> str:
    return """
if (typeof render === 'function') render();
const prompt = window.HWANSE_LAST_ACTION_PROMPT || null;
const event = new KeyboardEvent('keydown', {
  bubbles: true,
  cancelable: true,
  key: 'Enter',
  code: 'Enter',
});
window.dispatchEvent(event);
return { pressed: true, defaultPrevented: event.defaultPrevented, prompt };
"""


def click_route_waypoint_mobile_action_script() -> str:
    return """
if (typeof render === 'function') render();
sessionStorage.removeItem('HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_ACTION');
sessionStorage.removeItem('HWANSE_LAST_ROUTE_CONTROL_ACTION');
sessionStorage.removeItem('HWANSE_LAST_ROUTE_CONTROL_SOUND');
window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_ACTION = null;
window.HWANSE_LAST_ROUTE_CONTROL_ACTION = null;
window.HWANSE_LAST_ROUTE_CONTROL_SOUND = null;
const prompt = window.HWANSE_LAST_ACTION_PROMPT || null;
const waypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const button = document.getElementById('virtualActionButton');
if (!button) {
  return { clicked: false, reason: 'missing-virtualActionButton', prompt, waypoint };
}
button.dispatchEvent(new PointerEvent('pointerdown', {
  pointerId: 92,
  pointerType: 'touch',
  bubbles: true,
  cancelable: true,
}));
button.dispatchEvent(new PointerEvent('pointerup', {
  pointerId: 92,
  pointerType: 'touch',
  bubbles: true,
  cancelable: true,
}));
return {
  clicked: true,
  buttonId: 'virtualActionButton',
  prompt,
  waypoint,
};
"""


def click_route_waypoint_script() -> str:
    return """
if (typeof render === 'function') render();
const marker = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const canvas = document.getElementById('screen');
if (!marker?.rendered || !marker.visible || !canvas) {
  return { clicked: false, marker, reason: 'missing-marker' };
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = rect.left + (marker.screenX + marker.screenSize / 2) / scaleX;
const clientY = rect.top + (marker.screenY + marker.screenSize / 2) / scaleY;
const EventCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
canvas.dispatchEvent(new EventCtor('pointerdown', {
  bubbles: true,
  cancelable: true,
  pointerId: 91,
  pointerType: 'mouse',
  isPrimary: true,
  clientX,
  clientY,
}));
return { clicked: true, marker, clientX, clientY };
"""


def route_waypoint_click_state_script() -> str:
    return """
function parseSessionJson(key) {
  try {
    return JSON.parse(sessionStorage.getItem(key) || 'null') || null;
  } catch (_error) {
    return null;
  }
}
const params = new URLSearchParams(location.search);
const routeWaypointAction = parseSessionJson('HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_ACTION');
const routeControlAction = parseSessionJson('HWANSE_LAST_ROUTE_CONTROL_ACTION');
const routeControlSound = parseSessionJson('HWANSE_LAST_ROUTE_CONTROL_SOUND');
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: params.get('routeGoal') || '',
  routeAutoSave: params.get('routeAutoSave') || '',
  trialTransitions: params.get('trialTransitions') || '',
  startTile: params.get('startTile') || '',
  routeWaypointAction,
  routeControlAction,
  routeControlSound,
};
"""


def route_waypoint_guide_state_script() -> str:
    return """
if (typeof render === 'function') render();
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: new URLSearchParams(location.search).get('routeGoal') || '',
  trialTransitions: new URLSearchParams(location.search).get('trialTransitions') || '',
  routeWaypoint: window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null,
  routeWaypointGuide: window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null,
  actionPrompt: window.HWANSE_LAST_ACTION_PROMPT || null,
  hudLines: window.HWANSE_LAST_PLAY_HUD_LINES || [],
};
"""


def wait_for_route_waypoint_guide_runtime(port: int, session_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_guide_state_script(), timeout=3)
        last_state = state
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        hud_lines = state.get("hudLines") or []
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint.get("source") == "route-assist-waypoint"
            and waypoint.get("active") is True
            and waypoint.get("sourceMap") == "map1_01a"
            and waypoint.get("targetMap") == "map2_02d"
            and (waypoint.get("tile") or {}).get("x") == 18
            and (waypoint.get("tile") or {}).get("y") == 0
            and (waypoint.get("playerTile") or {}).get("x") == 11
            and (waypoint.get("playerTile") or {}).get("y") == 11
            and waypoint.get("distanceX") == 7
            and waypoint.get("distanceY") == -11
            and waypoint.get("manhattanDistance") == 18
            and waypoint.get("progressKind") == "route-candidate"
            and waypoint.get("progressCompleted") is False
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("stepBlockerText", "").startswith("trial-only: no map1_01a->map2_02d transition-review row")
            and waypoint.get("strictHotspotStatus") == "blocked"
            and "no strict map1_01a event transition" in str(waypoint.get("promotionBlockSummary") or "")
            and guide.get("source") == "route-assist-waypoint-guide"
            and guide.get("active") is True
            and guide.get("rendered") is True
            and guide.get("lineRendered") is True
            and guide.get("directionLabel") == "북동"
            and guide.get("distanceText") == "18칸"
            and guide.get("routeTarget") == "map2_18d"
            and guide.get("sourceMap") == "map1_01a"
            and guide.get("targetMap") == "map2_02d"
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and guide.get("stepBlockerText", "").startswith("trial-only: no map1_01a->map2_02d transition-review row")
            and guide.get("strictHotspotStatus") == "blocked"
            and guide.get("browserRouteAssistWaypointGuideImplemented") is True
            and guide.get("originalRoutePromotionImplemented") is False
            and any(
                "길 다음 map2_02d 북동 18칸" in str(line)
                and "trial-only: no" in str(line)
                for line in hud_lines
            )
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint guide did not render expected direction: {last_state!r}")


def wait_for_route_waypoint_nudge_prompt_runtime(port: int, session_id: str) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_guide_state_script(), timeout=3)
        last_state = state
        prompt = state.get("actionPrompt") or {}
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint.get("sourceMap") == "map1_01a"
            and waypoint.get("targetMap") == "map2_02d"
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("strictHotspotStatus") == "blocked"
            and guide.get("source") == "route-assist-waypoint-guide"
            and guide.get("directionLabel") == "북동"
            and guide.get("distanceText") == "18칸"
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and prompt.get("kind") == "route-assist-waypoint-nudge"
            and prompt.get("text") == f"목표 -> 길 안내 map2_02d 북동 18칸 · {EXPECTED_ROUTE_BLOCKER_SHORT_TEXT}"
            and prompt.get("sourceMap") == "map1_01a"
            and prompt.get("targetMap") == "map2_02d"
            and prompt.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(prompt)
            and prompt.get("stepBlockerText", "").startswith("trial-only: no map1_01a->map2_02d transition-review row")
            and prompt.get("strictHotspotStatus") == "blocked"
            and prompt.get("directionLabel") == "북동"
            and prompt.get("distanceText") == "18칸"
            and (prompt.get("waypointTile") or {}).get("x") == 18
            and (prompt.get("waypointTile") or {}).get("y") == 0
            and (prompt.get("playerTile") or {}).get("x") == 10
            and (prompt.get("playerTile") or {}).get("y") == 10
            and (prompt.get("nextTile") or {}).get("x") == 11
            and (prompt.get("nextTile") or {}).get("y") == 10
            and prompt.get("browserRouteAssistWaypointNudgePromptImplemented") is True
            and prompt.get("originalRoutePromotionImplemented") is False
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint nudge prompt did not render on a clear tile: {last_state!r}")


def press_route_waypoint_nudge_script() -> str:
    return """
if (typeof render === 'function') render();
const beforeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const beforeGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const event = new KeyboardEvent('keydown', {
  bubbles: true,
  cancelable: true,
  key: 'g',
  code: 'KeyG',
});
window.dispatchEvent(event);
return { pressed: true, defaultPrevented: event.defaultPrevented, beforeWaypoint, beforeGuide };
"""


def click_route_waypoint_quick_objective_nudge_script(button_id: str, mode: str) -> str:
    return f"""
activeDialogue = null;
menuOpen = false;
if (typeof setupRoutePathSelect === 'function') setupRoutePathSelect();
if (typeof render === 'function') render();
sessionStorage.removeItem('HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE');
sessionStorage.removeItem('HWANSE_LAST_QUICK_OBJECTIVE_ACTION');
sessionStorage.removeItem('HWANSE_LAST_QUICK_OBJECTIVE_SOUND');
window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE = null;
window.HWANSE_LAST_QUICK_OBJECTIVE_ACTION = null;
window.HWANSE_LAST_QUICK_OBJECTIVE_SOUND = null;
const button = document.getElementById('{button_id}');
const beforeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const beforeGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const beforeFoot = typeof footTile === 'function' ? footTile() : null;
const beforeTitle = button?.title || '';
const beforeText = button?.textContent || '';
const beforeHidden = button?.hidden ?? null;
const beforeDisabled = button?.disabled ?? null;
if (!button) {{
  return {{ clicked: false, missing: '{button_id}', beforeWaypoint, beforeGuide, beforeFoot }};
}}
if ('{mode}' === 'pointer') {{
  button.dispatchEvent(new PointerEvent('pointerdown', {{
    pointerId: 93,
    pointerType: 'touch',
    bubbles: true,
    cancelable: true,
  }}));
  button.dispatchEvent(new PointerEvent('pointerup', {{
    pointerId: 93,
    pointerType: 'touch',
    bubbles: true,
    cancelable: true,
  }}));
}} else {{
  button.dispatchEvent(new MouseEvent('click', {{
    bubbles: true,
    cancelable: true,
    detail: 1,
  }}));
}}
return {{
  clicked: true,
  mode: '{mode}',
  buttonId: '{button_id}',
  beforeWaypoint,
  beforeGuide,
  beforeFoot,
  beforeTitle,
  beforeText,
  beforeHidden,
  beforeDisabled,
}};
"""


def route_waypoint_nudge_state_script() -> str:
    return """
function parseSessionJson(key) {
  try {
    return JSON.parse(sessionStorage.getItem(key) || 'null') || null;
  } catch (_error) {
    return null;
  }
}
if (typeof render === 'function') render();
const params = new URLSearchParams(location.search);
const routeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const routeWaypointGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const routeWaypointNudge = parseSessionJson('HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE') || window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE || null;
const quickObjective = parseSessionJson('HWANSE_LAST_QUICK_OBJECTIVE_ACTION') || window.HWANSE_LAST_QUICK_OBJECTIVE_ACTION || null;
const quickObjectiveSound = parseSessionJson('HWANSE_LAST_QUICK_OBJECTIVE_SOUND') || window.HWANSE_LAST_QUICK_OBJECTIVE_SOUND || null;
const fieldMovementSound = window.HWANSE_LAST_FIELD_MOVEMENT_SOUND || null;
const foot = typeof footTile === 'function' ? footTile() : null;
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: params.get('routeGoal') || '',
  trialTransitions: params.get('trialTransitions') || '',
  foot,
  routeWaypoint,
  routeWaypointGuide,
  routeWaypointNudge,
  quickObjective,
  quickObjectiveSound,
  fieldMovementSound,
  hudLines: window.HWANSE_LAST_PLAY_HUD_LINES || [],
};
"""


def route_waypoint_follow_state_script() -> str:
    return """
function parseSessionJson(key) {
  try {
    return JSON.parse(sessionStorage.getItem(key) || 'null') || null;
  } catch (_error) {
    return null;
  }
}
if (typeof render === 'function') render();
const params = new URLSearchParams(location.search);
const routeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const routeWaypointGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const routeWaypointNudge = parseSessionJson('HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE') || window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_NUDGE || null;
const quickObjective = parseSessionJson('HWANSE_LAST_QUICK_OBJECTIVE_ACTION') || window.HWANSE_LAST_QUICK_OBJECTIVE_ACTION || null;
const quickObjectiveSound = parseSessionJson('HWANSE_LAST_QUICK_OBJECTIVE_SOUND') || window.HWANSE_LAST_QUICK_OBJECTIVE_SOUND || null;
const routeControlAction = parseSessionJson('HWANSE_LAST_ROUTE_CONTROL_ACTION') || window.HWANSE_LAST_ROUTE_CONTROL_ACTION || null;
const routeControlSound = parseSessionJson('HWANSE_LAST_ROUTE_CONTROL_SOUND') || window.HWANSE_LAST_ROUTE_CONTROL_SOUND || null;
const fieldMovementSound = window.HWANSE_LAST_FIELD_MOVEMENT_SOUND || null;
const foot = typeof footTile === 'function' ? footTile() : null;
return {
  readyState: document.readyState,
  href: location.href,
  search: location.search,
  hasScreen: !!document.getElementById('screen'),
  scene: typeof scene === 'undefined' ? '' : scene,
  mapName: typeof map === 'undefined' || !map ? '' : map.name,
  routeGoal: params.get('routeGoal') || '',
  routeAutoSave: params.get('routeAutoSave') || '',
  trialTransitions: params.get('trialTransitions') || '',
  foot,
  routeWaypoint,
  routeWaypointGuide,
  routeWaypointNudge,
  quickObjective,
  quickObjectiveSound,
  routeControlAction,
  routeControlSound,
  fieldMovementSound,
  objectiveAction: window.HWANSE_LAST_OBJECTIVE_ACTION || null,
  hudLines: window.HWANSE_LAST_PLAY_HUD_LINES || [],
};
"""


def route_waypoint_follow_keyboard_objective_script() -> str:
    return """
if (typeof render === 'function') render();
const beforeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const beforeGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const beforeFoot = typeof footTile === 'function' ? footTile() : null;
const event = new KeyboardEvent('keydown', {
  bubbles: true,
  cancelable: true,
  key: 'g',
  code: 'KeyG',
});
window.dispatchEvent(event);
return { pressed: true, defaultPrevented: event.defaultPrevented, beforeWaypoint, beforeGuide, beforeFoot };
"""


def route_waypoint_follow_button_objective_script(button_id: str, mode: str) -> str:
    return f"""
if (typeof render === 'function') render();
const beforeWaypoint = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_RENDER || null;
const beforeGuide = window.HWANSE_LAST_ROUTE_ASSIST_WAYPOINT_GUIDE || null;
const beforeFoot = typeof footTile === 'function' ? footTile() : null;
const button = document.getElementById('{button_id}');
if (!button) {{
  return {{ clicked: false, missing: '{button_id}', beforeWaypoint, beforeGuide, beforeFoot }};
}}
if ('{mode}' === 'pointer') {{
  button.dispatchEvent(new PointerEvent('pointerdown', {{
    pointerId: 94,
    pointerType: 'touch',
    bubbles: true,
    cancelable: true,
  }}));
  button.dispatchEvent(new PointerEvent('pointerup', {{
    pointerId: 94,
    pointerType: 'touch',
    bubbles: true,
    cancelable: true,
  }}));
}} else {{
  button.dispatchEvent(new MouseEvent('click', {{
    bubbles: true,
    cancelable: true,
    detail: 1,
  }}));
}}
return {{ clicked: true, buttonId: '{button_id}', mode: '{mode}', beforeWaypoint, beforeGuide, beforeFoot }};
"""


def wait_for_route_waypoint_follow_ready(port: int, session_id: str, max_distance: int = 8) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_follow_state_script(), timeout=3)
        last_state = state
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        distance = waypoint.get("manhattanDistance")
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint.get("source") == "route-assist-waypoint"
            and waypoint.get("active") is True
            and waypoint.get("sourceMap") == "map1_01a"
            and waypoint.get("targetMap") == "map2_02d"
            and (waypoint.get("tile") or {}).get("x") == 18
            and (waypoint.get("tile") or {}).get("y") == 0
            and waypoint.get("onTile") is False
            and isinstance(distance, int)
            and 0 < distance <= max_distance
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("strictHotspotStatus") == "blocked"
            and guide.get("source") == "route-assist-waypoint-guide"
            and guide.get("active") is True
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and guide.get("distanceText")
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint follow-ready state did not render: {last_state!r}")


def wait_for_route_waypoint_follow_step(
    port: int,
    session_id: str,
    *,
    previous_foot: dict[str, Any],
    previous_distance: int,
    expected_input_source: str = "keyboard",
) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_follow_state_script(), timeout=3)
        last_state = state
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        nudge = state.get("routeWaypointNudge") or {}
        quick = state.get("quickObjective") or {}
        quick_sound = state.get("quickObjectiveSound") or {}
        movement_sound = state.get("fieldMovementSound") or {}
        foot = state.get("foot") or {}
        moved = foot.get("x") != previous_foot.get("x") or foot.get("y") != previous_foot.get("y")
        distance = waypoint.get("manhattanDistance")
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint.get("source") == "route-assist-waypoint"
            and waypoint.get("active") is True
            and waypoint.get("sourceMap") == "map1_01a"
            and waypoint.get("targetMap") == "map2_02d"
            and (waypoint.get("tile") or {}).get("x") == 18
            and (waypoint.get("tile") or {}).get("y") == 0
            and moved
            and isinstance(distance, int)
            and distance < previous_distance
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("strictHotspotStatus") == "blocked"
            and guide.get("source") == "route-assist-waypoint-guide"
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and nudge.get("source") == "route-assist-waypoint-nudge"
            and nudge.get("inputSource") == expected_input_source
            and nudge.get("handled") is True
            and nudge.get("movementQueued") is True
            and nudge.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(nudge)
            and nudge.get("strictHotspotStatus") == "blocked"
            and quick.get("source") == expected_input_source
            and quick.get("action") == "route-waypoint-nudge"
            and quick.get("handled") is True
            and quick_sound.get("source") == "quick-objective-sound"
            and quick_sound.get("inputSource") == expected_input_source
            and quick_sound.get("action") == "route-waypoint-nudge"
            and quick_sound.get("soundKey") == "menuConfirm"
            and quick_sound.get("soundPlayed") is True
            and movement_sound.get("source") == "field-movement-sound"
            and movement_sound.get("inputSource") == expected_input_source
            and movement_sound.get("soundKey") == "step"
            and movement_sound.get("soundPlayed") is True
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint follow step did not advance: {last_state!r}")


def walk_route_waypoint_with_control(
    port: int,
    session_id: str,
    *,
    press_script: str,
    expected_input_source: str,
    max_steps: int = 40,
) -> dict[str, Any]:
    path: list[dict[str, Any]] = []
    for step_index in range(max_steps + 1):
        state = execute_js(port, session_id, route_waypoint_follow_state_script(), timeout=3)
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        foot = state.get("foot") or {}
        if (
            state.get("readyState") == "complete"
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and waypoint.get("source") == "route-assist-waypoint"
            and waypoint.get("active") is True
            and waypoint.get("onTile") is True
            and waypoint.get("manhattanDistance") == 0
            and (waypoint.get("tile") or {}).get("x") == 18
            and (waypoint.get("tile") or {}).get("y") == 0
            and foot.get("x") == 18
            and foot.get("y") == 0
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("strictHotspotStatus") == "blocked"
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and guide.get("distanceText") == "도착"
        ):
            return {
                "arrived": True,
                "stepCount": len(path),
                "path": path,
                "arrival": state,
            }
        previous_distance = waypoint.get("manhattanDistance")
        if not isinstance(previous_distance, int):
            raise WebDriverError(f"route waypoint follow state has no distance: {state!r}")
        if step_index >= max_steps:
            break
        press = execute_js(port, session_id, press_script, timeout=3)
        if not (isinstance(press, dict) and (press.get("pressed") is True or press.get("clicked") is True)):
            raise WebDriverError(f"route waypoint follow control was not dispatched: {press!r}")
        after = wait_for_route_waypoint_follow_step(
            port,
            session_id,
            previous_foot=foot,
            previous_distance=previous_distance,
            expected_input_source=expected_input_source,
        )
        path.append({
            "index": len(path) + 1,
            "from": foot,
            "to": after.get("foot") or {},
            "distanceBefore": previous_distance,
            "distanceAfter": (after.get("routeWaypoint") or {}).get("manhattanDistance"),
            "directionLabel": ((after.get("routeWaypointGuide") or {}).get("directionLabel")),
            "distanceText": ((after.get("routeWaypointGuide") or {}).get("distanceText")),
            "sound": after.get("fieldMovementSound") or {},
            "quickObjective": after.get("quickObjective") or {},
        })
    raise WebDriverError(f"route waypoint follow did not reach the waypoint within {max_steps} steps: {path!r}")


def walk_route_waypoint_with_keyboard(port: int, session_id: str, max_steps: int = 40) -> dict[str, Any]:
    return walk_route_waypoint_with_control(
        port,
        session_id,
        press_script=route_waypoint_follow_keyboard_objective_script(),
        expected_input_source="keyboard",
        max_steps=max_steps,
    )


def wait_for_route_waypoint_follow_route_next_runtime(
    port: int,
    session_id: str,
    *,
    expected_input_source: str = "keyboard",
) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_follow_state_script(), timeout=3)
        last_state = state
        quick = state.get("quickObjective") or {}
        quick_sound = state.get("quickObjectiveSound") or {}
        control = state.get("routeControlAction") or {}
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map2_02d"
            and state.get("routeGoal") == "map2_18d"
            and state.get("routeAutoSave") == "1"
            and state.get("trialTransitions") == "routeAssist"
            and quick.get("source") == expected_input_source
            and quick.get("handled") is True
            and quick.get("action") == "route-next"
            and quick.get("routeTarget") == "map2_18d"
            and quick_sound.get("source") == "quick-objective-sound"
            and quick_sound.get("inputSource") == expected_input_source
            and quick_sound.get("action") == "route-next"
            and quick_sound.get("soundKey") == "menuConfirm"
            and quick_sound.get("soundPlayed") is True
            and control.get("action") == "route-next"
            and control.get("handled") is True
            and control.get("nextTarget") == "map2_02d"
            and control.get("routeGoal") == "map2_18d"
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint follow arrival did not route-next to map2_02d: {last_state!r}")


def wait_for_route_waypoint_nudge_runtime(
    port: int,
    session_id: str,
    *,
    expected_input_source: str = "keyboard",
    require_quick_objective: bool = True,
) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        state = execute_js(port, session_id, route_waypoint_nudge_state_script(), timeout=3)
        last_state = state
        waypoint = state.get("routeWaypoint") or {}
        guide = state.get("routeWaypointGuide") or {}
        nudge = state.get("routeWaypointNudge") or {}
        quick = state.get("quickObjective") or {}
        quick_sound = state.get("quickObjectiveSound") or {}
        movement_sound = state.get("fieldMovementSound") or {}
        foot = state.get("foot") or {}
        next_tile = nudge.get("nextTile") or {}
        hud_lines = state.get("hudLines") or []
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint.get("source") == "route-assist-waypoint"
            and waypoint.get("active") is True
            and waypoint.get("sourceMap") == "map1_01a"
            and waypoint.get("targetMap") == "map2_02d"
            and (waypoint.get("tile") or {}).get("x") == 18
            and (waypoint.get("tile") or {}).get("y") == 0
            and waypoint.get("manhattanDistance") == 17
            and waypoint.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(waypoint)
            and waypoint.get("strictHotspotStatus") == "blocked"
            and guide.get("source") == "route-assist-waypoint-guide"
            and guide.get("active") is True
            and guide.get("distanceText") == "17칸"
            and guide.get("directionLabel") == "북동"
            and guide.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(guide)
            and nudge.get("source") == "route-assist-waypoint-nudge"
            and nudge.get("inputSource") == expected_input_source
            and nudge.get("handled") is True
            and nudge.get("movementQueued") is True
            and nudge.get("sourceMap") == "map1_01a"
            and nudge.get("targetMap") == "map2_02d"
            and (nudge.get("tile") or {}).get("x") == 18
            and (nudge.get("tile") or {}).get("y") == 0
            and (nudge.get("playerTile") or {}).get("x") == 11
            and (nudge.get("playerTile") or {}).get("y") == 11
            and nudge.get("manhattanDistance") == 18
            and nudge.get("directionLabel") == "북동"
            and nudge.get("stepBlockReasons") == EXPECTED_ROUTE_BLOCK_REASONS
            and has_expected_route_blocker_evidence(nudge)
            and nudge.get("strictHotspotStatus") == "blocked"
            and nudge.get("browserRouteAssistWaypointNudgeImplemented") is True
            and nudge.get("originalRoutePromotionImplemented") is False
            and foot.get("x") == next_tile.get("x")
            and foot.get("y") == next_tile.get("y")
            and ((foot.get("x") == 11 and foot.get("y") == 10) or (foot.get("x") == 12 and foot.get("y") == 11))
            and ((waypoint.get("playerTile") or {}).get("x") == foot.get("x"))
            and ((waypoint.get("playerTile") or {}).get("y") == foot.get("y"))
            and movement_sound.get("source") == "field-movement-sound"
            and movement_sound.get("inputSource") == expected_input_source
            and movement_sound.get("soundKey") == "step"
            and movement_sound.get("soundPlayed") is True
            and any(
                "길 다음 map2_02d 북동 17칸" in str(line)
                and "trial-only: no" in str(line)
                for line in hud_lines
            )
        ):
            if require_quick_objective and not (
                quick.get("source") == expected_input_source
                and quick.get("handled") is True
                and quick.get("action") == "route-waypoint-nudge"
                and quick_sound.get("source") == "quick-objective-sound"
                and quick_sound.get("inputSource") == expected_input_source
                and quick_sound.get("action") == "route-waypoint-nudge"
                and quick_sound.get("soundKey") == "menuConfirm"
                and quick_sound.get("soundPlayed") is True
            ):
                time.sleep(0.2)
                continue
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint nudge did not move one guide step: {last_state!r}")


def wait_for_route_waypoint_click_runtime(
    port: int,
    session_id: str,
    *,
    expected_input_source: str = "route-waypoint",
) -> dict[str, Any]:
    deadline = time.monotonic() + 10
    last_state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        try:
            state = execute_js(port, session_id, route_waypoint_click_state_script(), timeout=3)
        except Exception as error:
            last_state = {"error": str(error)}
            time.sleep(0.2)
            continue
        last_state = state
        waypoint_action = state.get("routeWaypointAction") or {}
        waypoint_route_action = waypoint_action.get("routeAction") or {}
        waypoint_route_sound = waypoint_route_action.get("sound") or {}
        route_control = state.get("routeControlAction") or {}
        route_sound = state.get("routeControlSound") or {}
        if (
            state.get("readyState") == "complete"
            and state.get("hasScreen") is True
            and state.get("scene") == "map"
            and state.get("mapName") == "map1_01a"
            and state.get("routeGoal") == "map2_18d"
            and state.get("routeAutoSave") == "1"
            and state.get("trialTransitions") == "routeAssist"
            and waypoint_action.get("source") == "route-assist-waypoint-action"
            and waypoint_action.get("inputSource") == expected_input_source
            and waypoint_action.get("handled") is True
            and waypoint_action.get("routeTarget") == "map2_18d"
            and waypoint_action.get("sourceMap") == "map1_02b"
            and waypoint_action.get("targetMap") == "map1_01a"
            and ((waypoint_action.get("tile") or {}).get("x")) == 11
            and ((waypoint_action.get("tile") or {}).get("y")) == 12
            and waypoint_action.get("stepKind") == "confirmed"
            and waypoint_action.get("progressKind") == "map-transition"
            and waypoint_action.get("browserRouteAssistWaypointActionImplemented") is True
            and waypoint_action.get("originalRoutePromotionImplemented") is False
            and waypoint_route_action.get("action") == "route-next"
            and waypoint_route_action.get("handled") is True
            and waypoint_route_action.get("routeTarget") == "map2_18d"
            and waypoint_route_action.get("nextTarget") == "map1_01a"
            and waypoint_route_action.get("routeGoal") == "map2_18d"
            and "map=map1_01a" in str(waypoint_route_action.get("href") or "")
            and waypoint_route_sound.get("inputSource") == expected_input_source
            and waypoint_route_sound.get("soundKey") == "menuConfirm"
            and str(waypoint_route_sound.get("soundSrc") or "").endswith("/04.wav")
            and waypoint_route_sound.get("soundPlayed") is True
            and route_control.get("action") == "route-next"
            and route_control.get("handled") is True
            and route_control.get("routeTarget") == "map2_18d"
            and route_control.get("nextTarget") == "map1_01a"
            and route_control.get("routeGoal") == "map2_18d"
            and "map=map1_01a" in str(route_control.get("href") or "")
            and route_sound.get("source") == "route-control-sound"
            and route_sound.get("inputSource") == expected_input_source
            and route_sound.get("action") == "route-next"
            and route_sound.get("routeTarget") == "map2_18d"
            and route_sound.get("nextTarget") == "map1_01a"
            and route_sound.get("routeGoal") == "map2_18d"
            and route_sound.get("soundKey") == "menuConfirm"
            and str(route_sound.get("soundSrc") or "").endswith("/04.wav")
            and route_sound.get("soundPlayed") is True
            and route_sound.get("originalRoutePromotionImplemented") is False
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"route waypoint action did not advance to route target: {last_state!r}")


def write_report(payload: dict[str, Any]) -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "route_guide_browser_smoke.json").write_text(
        json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    waypoint_follow = payload.get("waypointFollow") or {}
    waypoint_follow_arrival = waypoint_follow.get("arrival") or {}
    waypoint_follow_guide = waypoint_follow_arrival.get("routeWaypointGuide") or {}
    waypoint_follow_next_runtime = payload.get("waypointFollowNextRuntime") or {}
    waypoint_follow_next_quick = waypoint_follow_next_runtime.get("quickObjective") or {}
    waypoint_follow_next_control = waypoint_follow_next_runtime.get("routeControlAction") or {}
    waypoint_toolbar_follow = payload.get("waypointToolbarFollow") or {}
    waypoint_toolbar_follow_arrival = waypoint_toolbar_follow.get("arrival") or {}
    waypoint_toolbar_follow_next_runtime = payload.get("waypointToolbarFollowNextRuntime") or {}
    waypoint_toolbar_follow_next_quick = waypoint_toolbar_follow_next_runtime.get("quickObjective") or {}
    waypoint_mobile_follow = payload.get("waypointMobileFollow") or {}
    waypoint_mobile_follow_arrival = waypoint_mobile_follow.get("arrival") or {}
    waypoint_mobile_follow_next_runtime = payload.get("waypointMobileFollowNextRuntime") or {}
    waypoint_mobile_follow_next_quick = waypoint_mobile_follow_next_runtime.get("quickObjective") or {}
    lines = [
        "# Route Guide Browser Smoke",
        "",
        f"- status: `{payload.get('status')}`",
        f"- url: `{payload.get('url')}`",
        f"- links: `{payload['guide']['linkCount']}`",
        f"- routeGoal links: `{payload['guide']['routeGoalLinks']}`",
        f"- completion audit links: `{payload['guide']['completionAuditLinks']}`",
        f"- savedat scan links: `{payload['guide']['savedatScanLinks']}`",
        f"- routeGoal runtime: `{payload['runtime']['mapName']}` `{payload['runtime']['routeGoal']}` `{payload['runtime']['trialTransitions']}`",
        f"- route waypoint: `{(payload['runtime'].get('routeWaypoint') or {}).get('sourceMap')}` -> `{(payload['runtime'].get('routeWaypoint') or {}).get('targetMap')}` `{(payload['runtime'].get('routeWaypoint') or {}).get('tile')}`",
        f"- route waypoint prompt: `{(payload.get('waypointPrompt') or {}).get('prompt', {}).get('text')}` `{(payload.get('waypointEnterRuntime') or {}).get('routeControlSound', {}).get('inputSource')}`",
        f"- route waypoint mobile action: `{(payload.get('waypointMobileActionPrompt') or {}).get('prompt', {}).get('text')}` `{(payload.get('waypointMobileAction') or {}).get('buttonId')}` `{(payload.get('waypointMobileActionRuntime') or {}).get('mapName')}` `{(payload.get('waypointMobileActionRuntime') or {}).get('routeControlSound', {}).get('inputSource')}`",
        f"- route waypoint click: `{payload['waypointRuntime']['mapName']}` `{payload['waypointRuntime']['routeGoal']}` `{(payload['waypointRuntime'].get('routeWaypointAction') or {}).get('source')}` `{(payload['waypointRuntime'].get('routeControlSound') or {}).get('inputSource')}`",
        f"- route waypoint guide: `{(payload.get('waypointGuide') or {}).get('mapName')}` `{((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('directionLabel')}` `{((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('distanceText')}` `{((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('stepBlockerText')}`",
        f"- route waypoint blocker evidence: `missingEvidence={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerMissingEvidenceCount')}` `externalProofInputs={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerExternalProofInputCount')}` `handoff={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerExternalProofHandoffStatus')}` `goalChecklist={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerGoalChecklistUrl')}`",
        f"- route waypoint strict hotspot checklist: `candidates={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerStrictHotspotCandidateCount')}` `rejected={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerStrictHotspotRejectedCandidateCount')}` `reject={((payload.get('waypointGuide') or {}).get('routeWaypointGuide') or {}).get('routeBlockerStrictHotspotRejectionClassification')}`",
        f"- route waypoint nudge prompt: `{((payload.get('waypointNudgePrompt') or {}).get('actionPrompt') or {}).get('text')}` `{((payload.get('waypointNudgePrompt') or {}).get('actionPrompt') or {}).get('stepBlockerText')}` `missingEvidence={((payload.get('waypointNudgePrompt') or {}).get('actionPrompt') or {}).get('routeBlockerMissingEvidenceCount')}`",
        f"- route waypoint click nudge: `{(payload.get('waypointClickNudgeRuntime') or {}).get('mapName')}` `{((payload.get('waypointClickNudgeRuntime') or {}).get('foot') or {})}` `{((payload.get('waypointClickNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('inputSource')}` `{((payload.get('waypointClickNudgeRuntime') or {}).get('routeWaypointGuide') or {}).get('distanceText')}` `{((payload.get('waypointClickNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('stepBlockerText')}` `externalProofInputs={((payload.get('waypointClickNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('routeBlockerExternalProofInputCount')}`",
        f"- route waypoint nudge: `{(payload.get('waypointNudgeRuntime') or {}).get('mapName')}` `{((payload.get('waypointNudgeRuntime') or {}).get('foot') or {})}` `{((payload.get('waypointNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('source')}` `{((payload.get('waypointNudgeRuntime') or {}).get('routeWaypointGuide') or {}).get('distanceText')}` `{((payload.get('waypointNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('stepBlockerText')}`",
        f"- route waypoint toolbar nudge: `{(payload.get('waypointToolbarNudgeRuntime') or {}).get('mapName')}` `{((payload.get('waypointToolbarNudgeRuntime') or {}).get('foot') or {})}` `{((payload.get('waypointToolbarNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('inputSource')}` `{((payload.get('waypointToolbarNudgeRuntime') or {}).get('routeWaypointGuide') or {}).get('distanceText')}`",
        f"- route waypoint mobile nudge: `{(payload.get('waypointMobileNudgeRuntime') or {}).get('mapName')}` `{((payload.get('waypointMobileNudgeRuntime') or {}).get('foot') or {})}` `{((payload.get('waypointMobileNudgeRuntime') or {}).get('routeWaypointNudge') or {}).get('inputSource')}` `{((payload.get('waypointMobileNudgeRuntime') or {}).get('routeWaypointGuide') or {}).get('distanceText')}`",
        f"- route waypoint follow: `{waypoint_follow.get('stepCount')}` `{waypoint_follow_arrival.get('foot') or {}}` `{waypoint_follow_guide.get('distanceText')}`",
        f"- route waypoint follow next: `{waypoint_follow_next_runtime.get('mapName')}` `{waypoint_follow_next_quick.get('action')}` `{waypoint_follow_next_control.get('nextTarget')}`",
        f"- route waypoint toolbar follow: `{waypoint_toolbar_follow.get('stepCount')}` `{waypoint_toolbar_follow_arrival.get('foot') or {}}` `{waypoint_toolbar_follow_next_runtime.get('mapName')}` `{waypoint_toolbar_follow_next_quick.get('source')}`",
        f"- route waypoint mobile follow: `{waypoint_mobile_follow.get('stepCount')}` `{waypoint_mobile_follow_arrival.get('foot') or {}}` `{waypoint_mobile_follow_next_runtime.get('mapName')}` `{waypoint_mobile_follow_next_quick.get('source')}`",
        "",
    ]


def verify_browser(base: str, keep_log: bool = False) -> None:
    driver_path = shutil.which("WebKitWebDriver")
    if not driver_path:
        raise WebDriverError("WebKitWebDriver is not installed; install webkit2gtk-driver and run under Xvfb")

    port = free_port()
    log_path = OUT / "route_guide_webkitdriver.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("wb") as log:
        proc = subprocess.Popen(
            [
                driver_path,
                "--host=127.0.0.1",
                f"--port={port}",
                "--replace-on-new-session",
            ],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
        session_id = ""
        try:
            wait_for_driver(port, proc)
            session = request_json(
                port,
                "POST",
                "/session",
                {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
                timeout=30,
            )
            session_id = str((session or {}).get("value", {}).get("sessionId") or "")
            if not session_id:
                raise WebDriverError(f"could not create WebKit session: {session!r}")
            request_json(
                port,
                "POST",
                f"/session/{session_id}/window/rect",
                {"x": 0, "y": 0, "width": 1180, "height": 820},
                timeout=8,
            )
            url = urljoin(base.rstrip("/") + "/", "/out/web_playtest_route.html")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
            guide = wait_for_route_page(port, session_id)
            if not execute_js(port, session_id, click_map218d_goal_script()):
                raise WebDriverError("routeGoal=map2_18d link was not clickable")
            runtime = wait_for_route_goal_runtime(port, session_id)
            waypoint_prompt = wait_for_route_waypoint_prompt(port, session_id)
            waypoint_enter = execute_js(port, session_id, press_route_waypoint_enter_script(), timeout=3)
            if not (isinstance(waypoint_enter, dict) and waypoint_enter.get("pressed") is True):
                raise WebDriverError(f"route waypoint prompt Enter was not dispatched: {waypoint_enter!r}")
            waypoint_enter_runtime = wait_for_route_waypoint_click_runtime(
                port,
                session_id,
                expected_input_source="route-waypoint-action",
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
            wait_for_route_page(port, session_id)
            if not execute_js(port, session_id, click_map218d_goal_script()):
                raise WebDriverError("routeGoal=map2_18d link was not clickable for waypoint mobile action path")
            wait_for_route_goal_runtime(port, session_id)
            waypoint_mobile_action_prompt = wait_for_route_waypoint_prompt(port, session_id)
            waypoint_mobile_action = execute_js(port, session_id, click_route_waypoint_mobile_action_script(), timeout=3)
            if not (isinstance(waypoint_mobile_action, dict) and waypoint_mobile_action.get("clicked") is True):
                raise WebDriverError(f"route waypoint mobile A was not clicked: {waypoint_mobile_action!r}")
            waypoint_mobile_action_runtime = wait_for_route_waypoint_click_runtime(
                port,
                session_id,
                expected_input_source="route-waypoint-action",
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
            wait_for_route_page(port, session_id)
            if not execute_js(port, session_id, click_map218d_goal_script()):
                raise WebDriverError("routeGoal=map2_18d link was not clickable for waypoint pointer path")
            pointer_runtime = wait_for_route_goal_runtime(port, session_id)
            waypoint_click = execute_js(port, session_id, click_route_waypoint_script(), timeout=3)
            if not (isinstance(waypoint_click, dict) and waypoint_click.get("clicked") is True):
                raise WebDriverError(f"route waypoint marker was not clickable: {waypoint_click!r}")
            waypoint_runtime = wait_for_route_waypoint_click_runtime(port, session_id)

            guide_url = urljoin(
                base.rstrip("/") + "/",
                "/web/game.html?map=map1_01a&trialTransitions=routeAssist&routeGoal=map2_18d&startTile=11%2C11&moveMs=80",
            )
            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            waypoint_guide = wait_for_route_waypoint_guide_runtime(port, session_id)
            prompt_url = urljoin(
                base.rstrip("/") + "/",
                "/web/game.html?map=map1_01a&trialTransitions=routeAssist&routeGoal=map2_18d&startTile=10%2C10&moveMs=80",
            )
            request_json(port, "POST", f"/session/{session_id}/url", {"url": prompt_url}, timeout=30)
            waypoint_nudge_prompt = wait_for_route_waypoint_nudge_prompt_runtime(port, session_id)

            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            wait_for_route_waypoint_guide_runtime(port, session_id)
            waypoint_click_nudge = execute_js(port, session_id, click_route_waypoint_script(), timeout=3)
            if not (isinstance(waypoint_click_nudge, dict) and waypoint_click_nudge.get("clicked") is True):
                raise WebDriverError(f"off-tile route waypoint marker was not clickable: {waypoint_click_nudge!r}")
            waypoint_click_nudge_runtime = wait_for_route_waypoint_nudge_runtime(
                port,
                session_id,
                expected_input_source="route-waypoint",
                require_quick_objective=False,
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            wait_for_route_waypoint_guide_runtime(port, session_id)
            waypoint_nudge = execute_js(port, session_id, press_route_waypoint_nudge_script(), timeout=3)
            if not (isinstance(waypoint_nudge, dict) and waypoint_nudge.get("pressed") is True):
                raise WebDriverError(f"route waypoint nudge key was not dispatched: {waypoint_nudge!r}")
            waypoint_nudge_runtime = wait_for_route_waypoint_nudge_runtime(port, session_id)

            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            wait_for_route_waypoint_guide_runtime(port, session_id)
            waypoint_toolbar_nudge = execute_js(
                port,
                session_id,
                click_route_waypoint_quick_objective_nudge_script("objectiveActionButton", "click"),
                timeout=3,
            )
            if not (isinstance(waypoint_toolbar_nudge, dict) and waypoint_toolbar_nudge.get("clicked") is True):
                raise WebDriverError(f"route waypoint toolbar nudge button was not clicked: {waypoint_toolbar_nudge!r}")
            waypoint_toolbar_nudge_runtime = wait_for_route_waypoint_nudge_runtime(
                port,
                session_id,
                expected_input_source="objective-action-button",
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            wait_for_route_waypoint_guide_runtime(port, session_id)
            waypoint_mobile_nudge = execute_js(
                port,
                session_id,
                click_route_waypoint_quick_objective_nudge_script("virtualObjectiveButton", "pointer"),
                timeout=3,
            )
            if not (isinstance(waypoint_mobile_nudge, dict) and waypoint_mobile_nudge.get("clicked") is True):
                raise WebDriverError(f"route waypoint mobile nudge button was not clicked: {waypoint_mobile_nudge!r}")
            waypoint_mobile_nudge_runtime = wait_for_route_waypoint_nudge_runtime(
                port,
                session_id,
                expected_input_source="virtual-objective-button",
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": guide_url}, timeout=30)
            wait_for_route_waypoint_guide_runtime(port, session_id)
            waypoint_follow = walk_route_waypoint_with_keyboard(port, session_id)
            waypoint_follow_next = execute_js(
                port,
                session_id,
                route_waypoint_follow_keyboard_objective_script(),
                timeout=3,
            )
            if not (isinstance(waypoint_follow_next, dict) and waypoint_follow_next.get("pressed") is True):
                raise WebDriverError(f"route waypoint follow route-next key was not dispatched: {waypoint_follow_next!r}")
            waypoint_follow_next_runtime = wait_for_route_waypoint_follow_route_next_runtime(port, session_id)
            short_follow_url = urljoin(
                base.rstrip("/") + "/",
                "/web/game.html?map=map1_01a&trialTransitions=routeAssist&routeGoal=map2_18d&startTile=16%2C2&moveMs=80",
            )
            request_json(port, "POST", f"/session/{session_id}/url", {"url": short_follow_url}, timeout=30)
            wait_for_route_waypoint_follow_ready(port, session_id)
            waypoint_toolbar_follow = walk_route_waypoint_with_control(
                port,
                session_id,
                press_script=route_waypoint_follow_button_objective_script("objectiveActionButton", "click"),
                expected_input_source="objective-action-button",
                max_steps=12,
            )
            waypoint_toolbar_follow_next = execute_js(
                port,
                session_id,
                route_waypoint_follow_button_objective_script("objectiveActionButton", "click"),
                timeout=3,
            )
            if not (isinstance(waypoint_toolbar_follow_next, dict) and waypoint_toolbar_follow_next.get("clicked") is True):
                raise WebDriverError(f"route waypoint toolbar follow route-next was not clicked: {waypoint_toolbar_follow_next!r}")
            waypoint_toolbar_follow_next_runtime = wait_for_route_waypoint_follow_route_next_runtime(
                port,
                session_id,
                expected_input_source="objective-action-button",
            )

            request_json(port, "POST", f"/session/{session_id}/url", {"url": short_follow_url}, timeout=30)
            wait_for_route_waypoint_follow_ready(port, session_id)
            waypoint_mobile_follow = walk_route_waypoint_with_control(
                port,
                session_id,
                press_script=route_waypoint_follow_button_objective_script("virtualObjectiveButton", "pointer"),
                expected_input_source="virtual-objective-button",
                max_steps=12,
            )
            waypoint_mobile_follow_next = execute_js(
                port,
                session_id,
                route_waypoint_follow_button_objective_script("virtualObjectiveButton", "pointer"),
                timeout=3,
            )
            if not (isinstance(waypoint_mobile_follow_next, dict) and waypoint_mobile_follow_next.get("clicked") is True):
                raise WebDriverError(f"route waypoint mobile follow route-next was not clicked: {waypoint_mobile_follow_next!r}")
            waypoint_mobile_follow_next_runtime = wait_for_route_waypoint_follow_route_next_runtime(
                port,
                session_id,
                expected_input_source="virtual-objective-button",
            )
            payload = {
                "status": "ok",
                "url": url,
                "guide": guide,
                "runtime": runtime,
                "waypointPrompt": waypoint_prompt,
                "waypointEnter": waypoint_enter,
                "waypointEnterRuntime": waypoint_enter_runtime,
                "waypointMobileActionPrompt": waypoint_mobile_action_prompt,
                "waypointMobileAction": waypoint_mobile_action,
                "waypointMobileActionRuntime": waypoint_mobile_action_runtime,
                "pointerRuntime": pointer_runtime,
                "waypointClick": waypoint_click,
                "waypointRuntime": waypoint_runtime,
                "waypointGuideUrl": guide_url,
                "waypointGuide": waypoint_guide,
                "waypointNudgePromptUrl": prompt_url,
                "waypointNudgePrompt": waypoint_nudge_prompt,
                "waypointClickNudge": waypoint_click_nudge,
                "waypointClickNudgeRuntime": waypoint_click_nudge_runtime,
                "waypointNudge": waypoint_nudge,
                "waypointNudgeRuntime": waypoint_nudge_runtime,
                "waypointToolbarNudge": waypoint_toolbar_nudge,
                "waypointToolbarNudgeRuntime": waypoint_toolbar_nudge_runtime,
                "waypointMobileNudge": waypoint_mobile_nudge,
                "waypointMobileNudgeRuntime": waypoint_mobile_nudge_runtime,
                "waypointFollow": waypoint_follow,
                "waypointFollowNext": waypoint_follow_next,
                "waypointFollowNextRuntime": waypoint_follow_next_runtime,
                "waypointShortFollowUrl": short_follow_url,
                "waypointToolbarFollow": waypoint_toolbar_follow,
                "waypointToolbarFollowNext": waypoint_toolbar_follow_next,
                "waypointToolbarFollowNextRuntime": waypoint_toolbar_follow_next_runtime,
                "waypointMobileFollow": waypoint_mobile_follow,
                "waypointMobileFollowNext": waypoint_mobile_follow_next,
                "waypointMobileFollowNextRuntime": waypoint_mobile_follow_next_runtime,
            }
            write_report(payload)
            print(
                "ok route guide browser "
                f"links={guide.get('linkCount')} routeGoal={guide.get('routeGoalLinks')} "
                f"audit={guide.get('completionAuditLinks')} "
                f"runtime={runtime.get('mapName')}->{runtime.get('routeGoal')} "
                f"waypointEnter={waypoint_enter_runtime.get('mapName')} "
                f"waypointMobileAction={waypoint_mobile_action_runtime.get('mapName')} "
                f"waypoint={waypoint_runtime.get('mapName')} "
                f"guide={((waypoint_guide.get('routeWaypointGuide') or {}).get('directionLabel'))} "
                f"blocker={((waypoint_guide.get('routeWaypointGuide') or {}).get('stepBlockerText'))} "
                f"missingEvidence={((waypoint_guide.get('routeWaypointGuide') or {}).get('routeBlockerMissingEvidenceCount'))} "
                f"externalProofInputs={((waypoint_guide.get('routeWaypointGuide') or {}).get('routeBlockerExternalProofInputCount'))} "
                f"clickNudge={((waypoint_click_nudge_runtime.get('foot') or {}).get('x'))},"
                f"{((waypoint_click_nudge_runtime.get('foot') or {}).get('y'))} "
                f"nudge={((waypoint_nudge_runtime.get('foot') or {}).get('x'))},"
                f"{((waypoint_nudge_runtime.get('foot') or {}).get('y'))} "
                f"toolbarNudge={((waypoint_toolbar_nudge_runtime.get('foot') or {}).get('x'))},"
                f"{((waypoint_toolbar_nudge_runtime.get('foot') or {}).get('y'))} "
                f"mobileNudge={((waypoint_mobile_nudge_runtime.get('foot') or {}).get('x'))},"
                f"{((waypoint_mobile_nudge_runtime.get('foot') or {}).get('y'))} "
                f"followSteps={waypoint_follow.get('stepCount')} "
                f"followNext={waypoint_follow_next_runtime.get('mapName')} "
                f"toolbarFollow={waypoint_toolbar_follow.get('stepCount')}->{waypoint_toolbar_follow_next_runtime.get('mapName')} "
                f"mobileFollow={waypoint_mobile_follow.get('stepCount')}->{waypoint_mobile_follow_next_runtime.get('mapName')}"
            )
        finally:
            if session_id:
                try:
                    request_json(port, "DELETE", f"/session/{session_id}", timeout=3)
                except Exception:
                    pass
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)
            if not keep_log:
                log_path.unlink(missing_ok=True)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="http://127.0.0.1:8013")
    parser.add_argument("--keep-log", action="store_true")
    args = parser.parse_args()
    verify_browser(args.base, keep_log=args.keep_log)


if __name__ == "__main__":
    main()
