#!/usr/bin/env python3
"""Verify the trimmed active web/data surface.

This verifier intentionally checks only current entry points and compact
handoff artifacts. Legacy per-step audit pages are no longer part of the
active surface.
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse

import verify_battle_effect_pixel_compare_selftest


ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "web"
OUT = ROOT / "out"
DOCS = ROOT / "docs"


ACTIVE_WEB_PAGES = [
    "index.html",
    "game.html",
    "movement_test.html",
    "map_gallery.html",
    "map_review.html",
    "map_occlusion.html",
    "cns_rect_review.html",
    "ui_window_review.html",
    "battle_analysis.html",
    "battle_formula_calculator.html",
    "battle_end_reward_review.html",
    "battle_skill_timeline_review.html",
    "battle_skill_complete_pattern_review.html",
    "battle_rs_skill_runtime_review.html",
    "battle_effect_animation_pattern_review.html",
    "battle_simulator.html",
    "field_encounter_static_review.html",
    "field_rng_selector_opcode1c_review.html",
    "field_step_encounter_boundary_review.html",
    "field_active_object_trigger_boundary_review.html",
    "field_battle_resource_boundary_review.html",
    "field_encounter_formation_boundary_review.html",
    "field_encounter_formation_stream_review.html",
    "field_encounter_formation_opcode_cluster_review.html",
    "field_encounter_formation_context_review.html",
    "battle_effect_visual_review.html",
    "monster_stats.html",
    "monster_review.html",
    "audio_review.html",
    "midi_bgm_test.html",
    "item_equipment_skill_static.html",
    "skill_equipment_gate_review.html",
    "item_inventory_state_layout_review.html",
    "ui_grid_review.html",
    "dialogue_review.html",
    "dialogue_prompt_player.html",
    "dialogue_template_text_face_review.html",
    "dialogue_face_slot_selector_review.html",
    "dialogue_face_selector_gap_review.html",
    "completion_review.html",
    "event_review.html",
    "format_review.html",
    "render_pipeline_model_review.html",
    "surface_wrapper_catalog_review.html",
    "draw_flags_color_key_review.html",
    "palette_pipeline_review.html",
    "non_palette_motion_producer_review.html",
    "scene_event_vm_review.html",
    "scene_event_vm_command_stream_candidates.html",
    "scene_event_vm_execution_route_review.html",
    "scene_event_coordinate_semantics_review.html",
    "scene_event_conditional_map_route_review.html",
    "scene_event_vm_branch_flag_review.html",
    "scene_event_global_flag_candidate_review.html",
    "scene_event_text_consumer_trace_review.html",
    "scene_event_vm_prompt_sequence_review.html",
    "scene_event_vm_choice_target_review.html",
    "scene_event_vm_random_gate_review.html",
    "selected_scene_text_root_consumer_review.html",
    "selected_root_live_writer_frontier_review.html",
    "selected_root_opcode_matrix_review.html",
    "save_selector_dispatch_table_context.html",
    "generic_vm_stream_producer_frontier_review.html",
    "scene_seq_resource_record_link_review.html",
    "selector_root_structure_review.html",
    "resource_loader_consumer_trace_review.html",
    "scene_event_runtime_evidence_handoff.html",
    "global_mode_state_frontier_review.html",
    "input_handler_review.html",
    "menu_review.html",
    "hud_menu_opener_frontier_review.html",
    "hud_menu_selected_root_frontier_review.html",
    "hud_menu_mode_state_dispatcher_review.html",
    "runtime_trace_hud_open_review.html",
    "hud_runtime_static_bridge_review.html",
    "hud_runtime_surface_handle_review.html",
    "intro_runtime_fade_review.html",
    "intro_battle_runtime_review.html",
    "intro_battle_helper_dictionary_review.html",
    "menu_cancel_window_consumer_review.html",
    "map3_active_initializer_review.html",
    "map_extra_consumer_review.html",
    "resource_reference_review.html",
    "hud/index.html",
]


ACTIVE_OUT_FILES = [
    "maps_runtime.js",
    "maps.js",
    "cns_rect_review_data.json",
    "cns_rect_review_data.js",
    "scene_manifest.json",
    "scene_events.json",
    "event_dialogue_blocks.json",
    "story_prompts.json",
    "text_tables.js",
    "static_analysis_remaining_work.json",
    "scene_event_runtime_evidence_handoff.json",
    "scene_event_vm_opcode_dictionary.json",
    "scene_event_vm_command_stream_candidates.json",
    "scene_event_vm_execution_route_review.json",
    "scene_event_vm_prompt_sequence_review.json",
    "scene_event_vm_choice_target_review.json",
    "scene_event_vm_random_gate_review.json",
    "selected_scene_text_root_consumer_review.json",
    "selected_root_live_writer_frontier_review.json",
    "selected_root_opcode_matrix_review.json",
    "save_selector_dispatch_table_context.json",
    "generic_vm_stream_producer_frontier_review.json",
    "global_mode_state_frontier_review.json",
    "battle_end_reward_review.json",
    "battle_skill_timeline_canonical.json",
    "battle_skill_timeline_canonical.js",
    "battle_rs_skill_runtime_review.json",
    "battle_effect_runner_binding_review.json",
    "battle_effect_runner_binding_review.html",
    "battle_effect_visual_assertion_review.json",
    "battle_effect_visual_assertion_review.html",
    "battle_effect_pixel_oracle_plan.json",
    "battle_effect_capture_checklist.json",
    "battle_effect_pixel_compare_report.json",
    "candidate_browser_smoke_summary.json",
    "candidate_browser_smoke_summary.html",
    "menu_candidate_review_summary.json",
    "menu_candidate_review_summary.html",
    "hud_menu_opener_frontier_review.json",
    "hud_menu_selected_root_frontier_review.json",
    "hud_menu_mode_state_dispatcher_review.json",
    "runtime_trace_hud_open_review.json",
    "hud_runtime_static_bridge_review.json",
    "hud_runtime_surface_handle_review.json",
    "intro_runtime_fade_review.json",
    "intro_battle_runtime_review.json",
    "intro_battle_helper_dictionary_review.json",
    "completion_candidate_review_summary.json",
    "completion_candidate_review_summary.html",
    "movement_candidate_review_summary.json",
    "movement_candidate_review_summary.html",
    "dialogue_candidate_review_summary.json",
    "dialogue_candidate_review_summary.html",
    "render_pipeline_model_review.json",
    "surface_wrapper_catalog_review.json",
    "draw_flags_color_key_review.json",
    "palette_pipeline_review.json",
    "non_palette_motion_producer_review.json",
]


REQUIRED_DOCS = [
    "SCENE_EVENT_VM_GOAL.md",
    "SCENE_EVENT_VM_REFERENCE.md",
    "LEGACY_CLEANUP_GOAL.md",
    "BATTLE_SKILL_TIMELINE_GOAL.md",
]


ENGINE_MODULES = [
    "engine/audio/midi_bgm_player.js",
    "engine/audio/midi_soundfont.js",
    "engine/audio/midi_soundfont_storage.js",
    "engine/audio/midi_spessasynth_adapter.js",
    "engine/battle/animation.js",
    "engine/cns/renderer.js",
    "engine/field/runtime.js",
]


REMOVED_ENGINE_PATHS = [
    "web/cns_renderer.js",
    "web/field_runtime.js",
    "web/midi_bgm_player.js",
    "web/midi_soundfont.js",
    "web/midi_soundfont_storage.js",
    "web/midi_spessasynth_adapter.js",
    "../web/cns_renderer.js",
    "../web/field_runtime.js",
    "../web/midi_bgm_player.js",
    "../web/midi_soundfont.js",
    "../web/midi_soundfont_storage.js",
    "../web/midi_spessasynth_adapter.js",
    "engine/cns_renderer.js",
    "engine/field_runtime.js",
    "engine/battle_animation.js",
]


REMOVED_ENGINE_FILES = [
    "cns_renderer.js",
    "field_runtime.js",
    "midi_bgm_player.js",
    "midi_soundfont.js",
    "midi_soundfont_storage.js",
    "midi_spessasynth_adapter.js",
    "engine/cns_renderer.js",
    "engine/field_runtime.js",
    "engine/battle_animation.js",
]


def read(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def read_lenient(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="replace")


def require(condition: bool, message: str) -> None:
    if not condition:
        raise AssertionError(message)


def assert_exists() -> None:
    for rel in ACTIVE_WEB_PAGES:
        require((WEB / rel).exists(), f"missing active web page: web/{rel}")
    for rel in ACTIVE_OUT_FILES:
        require((OUT / rel).exists(), f"missing active out file: out/{rel}")
    for rel in REQUIRED_DOCS:
        require((DOCS / rel).exists(), f"missing doc: docs/{rel}")
    for rel in ENGINE_MODULES:
        require((WEB / rel).exists(), f"missing engine module: web/{rel}")


def assert_handoff() -> None:
    data = json.loads(read(OUT / "scene_event_runtime_evidence_handoff.json"))
    require(data.get("completionDecision") == "do-not-call-update-goal", "handoff must keep goal incomplete decision")
    watchpoints = data.get("watchpoints", [])
    require(len(watchpoints) == 5, "handoff should keep five runtime watchpoints")
    vas = {str(item.get("vaHex", "")).lower() for item in watchpoints}
    require({"0x004576da", "0x004576db", "0x0059de30", "0x004576e8", "0x004576e9"} <= vas, "handoff watchpoint VA set changed")
    frontier = data.get("frontierSummary", {})
    require(frontier.get("staticFrontierSaturated") is True, "static frontier should remain saturated")
    require(frontier.get("routeSpecificProducerPromoted") is False, "route-specific producer should remain unpromoted")
    html = read(WEB / "scene_event_runtime_evidence_handoff.html")
    require("HWANSE_SCENE_EVENT_RUNTIME_EVIDENCE_HANDOFF_READY" in html, "handoff web marker missing")


def assert_battle_skill_timeline_canonical() -> None:
    data = json.loads(read(OUT / "battle_skill_timeline_canonical.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-skill-timeline-canonical", "battle canonical kind changed")
    require(summary.get("playerRows") == 156, "battle canonical should keep 156 player action rows")
    require(summary.get("monsterRows") == 294, "battle canonical should keep 294 monster action rows")
    require(summary.get("totalRows") == 450, "battle canonical should keep 450 total action rows")
    player_rows = data.get("playerActions") or []
    allowed_unconfirmed = {}
    unconfirmed_rows = [
        row for row in player_rows
        if row.get("alignmentClass") == "unconfirmed" or row.get("status") == "needs-review"
    ]
    require(
        len(unconfirmed_rows) == len(allowed_unconfirmed),
        f"battle canonical has unexpected unconfirmed active rows: {len(unconfirmed_rows)}",
    )
    for row in unconfirmed_rows:
        row_key = (str(row.get("ownerKey") or ""), str(row.get("skillIdHex") or "").lower())
        require(row_key in allowed_unconfirmed, f"unexpected unconfirmed battle row: {row_key}")
        require(
            row.get("visualAlignmentStatus") == allowed_unconfirmed[row_key],
            f"unexpected unconfirmed reason for battle row: {row_key}",
        )
    require(summary.get("rowsWithFrames", 0) >= 447, "battle canonical frame coverage regressed")
    require(summary.get("rowsWithWlkCalls", 0) >= 435, "battle canonical WLK coverage regressed")
    require(summary.get("wlkEvidenceUnresolvedRows", 1) == 0, "battle canonical has unresolved WLK evidence rows")
    require(summary.get("wlkEvidenceStatusCounts", {}).get("direct-display-wlk", 0) >= 435, "battle canonical direct WLK evidence regressed")
    require(all(row.get("timeline") for row in player_rows), "player canonical rows must carry engine timeline data")
    monster_rows = data.get("monsterActions") or []
    require(all(row.get("timelineSummary") for row in monster_rows), "monster canonical rows must carry display timeline summaries")
    all_rows = player_rows + monster_rows
    require(all(row.get("wlkEvidenceStatus") for row in all_rows), "battle canonical rows must classify WLK evidence status")
    require(not any(row.get("wlkEvidenceUnresolved") for row in all_rows), "battle canonical includes unresolved WLK evidence")
    for rel in [
        "battle_skill_timeline_review.html",
        "battle_formula_calculator.html",
        "battle_simulator.html",
    ]:
        html = read(WEB / rel)
        require("battle_skill_timeline_canonical.json" in html, f"web/{rel} must load battle canonical data")
        require("legacy synthetic dust fallback" not in html, f"web/{rel} still exposes legacy synthetic dust fallback")
        require("battle_frame_gate_review.json" not in html, f"web/{rel} still loads old player frame gate review directly")
        require("battle_action_event_timeline_review.json" not in html, f"web/{rel} still loads old player timeline review directly")
    regression_namespace = {
        "__name__": "__verify_web_assets__",
        "__file__": str(ROOT / "tools" / "verify_battle_skill_playback_regressions.py"),
    }
    exec(
        (ROOT / "tools" / "verify_battle_skill_playback_regressions.py").read_text(encoding="utf-8"),
        regression_namespace,
    )
    regression_namespace["main"]()

    browser_smoke = json.loads(read(OUT / "battle_skill_timeline_browser_smoke.json"))
    require(browser_smoke.get("status") == "ok", "battle skill browser timeline smoke must pass")
    require(browser_smoke.get("checkedRows", 0) >= 6, "battle skill browser smoke coverage regressed")
    require(browser_smoke.get("checkedEffectRows", 0) >= 6, "battle skill browser effect smoke coverage regressed")
    require(
        browser_smoke.get("checkedExecutionRequirementRows", 0) >= 52,
        "battle skill browser execution-requirement coverage regressed",
    )
    requirement_buckets = set(browser_smoke.get("checkedExecutionRequirementBuckets") or [])
    for required in {
        "random placement/range present",
        "runner must instantiate child objects over time",
        "child motion loop present",
        "palette transform effect; no CNS frame stream",
    }:
        require(required in requirement_buckets, f"battle skill browser smoke missing execution-requirement bucket {required}")
    checked = {
        (str(row.get("ownerKey") or ""), str(row.get("skillIdHex") or "").lower(), row.get("levelOrFixed"))
        for row in browser_smoke.get("checks") or []
    }
    for required in {
        ("rinshan", "0x05", None),
        ("rinshan", "0x06", None),
        ("rinshan", "0x09", 1),
        ("rinshan", "0x0c", 4),
        ("ataho", "0x2f", 1),
        ("ataho", "0x32", 4),
    }:
        require(required in checked, f"battle skill browser smoke missing regression row {required}")
    effect_checked = {
        (str(row.get("ownerKey") or ""), str(row.get("skillIdHex") or "").lower(), row.get("levelOrFixed"))
        for row in browser_smoke.get("effectChecks") or []
        if int(row.get("effectFrameCount") or 0) > 0
    }
    for row in browser_smoke.get("effectChecks") or []:
        if int(row.get("effectFrameCount") or 0) > 0:
            stats = row.get("effectCanvasStats") or {}
            require(int(stats.get("nonEmptyCanvasCount") or 0) > 0, f"effect smoke row has blank canvases: {row}")
            preview = row.get("previewRender") or {}
            preview_stats = row.get("previewCanvasStats") or {}
            require(int(preview.get("activeEffectCount") or 0) > 0, f"effect smoke row has no active preview effects: {row}")
            require(int(preview_stats.get("nonEmptyCanvasCount") or 0) > 0, f"effect smoke row has blank preview canvas: {row}")
            require(int(preview_stats.get("coloredCanvasCount") or 0) > 0, f"effect smoke row has no colored preview pixels: {row}")
    for required in {
        ("ataho", "0x0d", 4),
        ("ataho", "0x11", 4),
        ("ataho", "0x15", 4),
        ("ataho", "0x19", 4),
        ("rinshan", "0x10", 4),
        ("rinshan", "0x18", 4),
    }:
        require(required in effect_checked, f"battle skill browser smoke missing effect row {required}")
    for row in browser_smoke.get("executionRequirementChecks") or []:
        diagnostics = row.get("effectDiagnostics") or {}
        require(diagnostics, f"execution requirement row missing diagnostics: {row}")
        if int(row.get("effectFrameCount") or 0) > 0:
            stats = row.get("effectCanvasStats") or {}
            require(int(stats.get("nonEmptyCanvasCount") or 0) > 0, f"execution requirement row has blank effect canvases: {row}")
            preview = row.get("previewRender") or {}
            preview_stats = row.get("previewCanvasStats") or {}
            require(int(preview.get("activeEffectCount") or 0) > 0, f"execution requirement row has no active preview effects: {row}")
            require(int(preview_stats.get("nonEmptyCanvasCount") or 0) > 0, f"execution requirement row has blank preview canvas: {row}")
            require(int(preview_stats.get("coloredCanvasCount") or 0) > 0, f"execution requirement row has no colored preview pixels: {row}")


def assert_battle_effect_runner_binding() -> None:
    data = json.loads(read(OUT / "battle_effect_runner_binding_review.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-effect-runner-binding-review", "battle effect runner binding kind changed")
    require(data.get("status") == "runner-binding-reviewed", "battle effect runner binding status changed")
    require(summary.get("effectSkillRows") == 156, "battle effect runner should cover 156 player skill rows")
    require(summary.get("skillsWithEffectFrames") == 92, "battle effect frame row count changed")
    require(summary.get("skillsWithReviewFlags") == 0, "battle effect runner has review flags")
    require(summary.get("skillsWithExecutionRequirements") == 52, "battle effect execution requirement count changed")
    require(summary.get("engineChecksPassed") == summary.get("engineChecksTotal"), "battle effect engine binding checks failed")
    require(summary.get("pageChecksPassed") == summary.get("pageChecksTotal"), "battle effect page binding checks failed")
    require(summary.get("pagesUsingPlaybackEffectFrames") == 3, "all three battle pages should consume playback.effectFrames")
    require(summary.get("pagesRenderingHelperEffectLayer") == 3, "all three battle pages should render helper effect layers")
    require(summary.get("pagesWithGenericResultEffectsOnly") == 0, "battle pages regressed to generic result effects only")
    requirements = {row.get("requirement"): row for row in data.get("requirements") or []}
    for requirement in {
        "random placement/range present",
        "runner must instantiate child objects over time",
        "child motion loop present",
        "palette transform effect; no CNS frame stream",
    }:
        require(requirement in requirements, f"battle effect requirement bucket missing: {requirement}")


def assert_battle_effect_visual_assertions() -> None:
    data = json.loads(read(OUT / "battle_effect_visual_assertion_review.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-effect-visual-assertion-review", "battle effect visual assertion kind changed")
    require(data.get("status") == "visual-assertions-pass", "battle effect visual assertions are not passing")
    require(summary.get("requirementRows") == 52, "battle visual assertion should cover 52 execution requirement rows")
    require(summary.get("representativeEffectRows") == 13, "battle visual assertion representative coverage changed")
    require(summary.get("requirementGroups") == 4, "battle visual assertion requirement group count changed")
    require(summary.get("effectClassGroups") == 3, "battle visual assertion effect class group count changed")
    require(summary.get("failedGroups") == 0, "battle visual assertion has failed groups")
    require(summary.get("requirementRowsWithColoredPreview") == 52, "battle visual assertion colored preview coverage regressed")
    require(summary.get("representativeRowsWithColoredPreview") == 13, "battle visual assertion representative preview coverage regressed")
    require(summary.get("rowsWithEffectFrames") == 52, "battle visual assertion effect frame coverage regressed")
    require(summary.get("rowsWithTransformDiagnostics") == 52, "battle visual assertion transform diagnostics coverage regressed")
    require(summary.get("rowsWithMotionDiagnostics") == 32, "battle visual assertion motion diagnostics coverage changed")
    require(summary.get("rowsWithSpawnOverTime") == 52, "battle visual assertion spawn-over-time coverage regressed")
    require(summary.get("rowsWithLoopPreview") == 22, "battle visual assertion loop preview coverage changed")
    require(summary.get("rowsWithNestedVisual") == 12, "battle visual assertion nested visual coverage changed")
    require(summary.get("rowsWithSparkleMultiSprite") == 17, "battle visual assertion sparkle multi-sprite coverage changed")
    require(summary.get("rowsWithPaletteRequirement") == 1, "battle visual assertion palette requirement coverage changed")
    require(summary.get("rowsWithHiddenCandidates") == 0, "battle visual assertion hidden candidate coverage changed")
    require(summary.get("motionModel_explicit_motion_delta") == 32, "battle visual assertion explicit motion model coverage changed")
    require(summary.get("motionModel_loop_counter_placement_stream") == 9, "battle visual assertion loop/counter placement model coverage changed")
    require(summary.get("motionModel_sparkle_stream") == 6, "battle visual assertion sparkle stream model coverage changed")
    require(summary.get("motionModel_direct_placement_stream") == 4, "battle visual assertion direct placement model coverage changed")
    require(summary.get("motionModel_palette_only_frameScript") == 1, "battle visual assertion palette-only motion model coverage changed")
    require(summary.get("motionModelClassifiedRows") == 52, "battle visual assertion motion model classification coverage regressed")
    require(summary.get("pixelOracleStatus") == "not-original-runtime-pixel-oracle", "battle visual assertion must not claim original runtime pixel oracle")
    requirements = {row.get("requirement"): row for row in data.get("requirementAssertions") or []}
    for requirement in {
        "random placement/range present",
        "runner must instantiate child objects over time",
        "child motion loop present",
        "palette transform effect; no CNS frame stream",
    }:
        row = requirements.get(requirement)
        require(row is not None, f"battle visual assertion requirement missing: {requirement}")
        require(row.get("status") == "pass", f"battle visual assertion requirement failed: {requirement}")
    classes = {row.get("effectAnimationClass"): row for row in data.get("effectClassAssertions") or []}
    for class_name in {"frameScript-effect", "init-frame-effect", "spawn-stream-effect"}:
        row = classes.get(class_name)
        require(row is not None, f"battle visual assertion class missing: {class_name}")
        require(row.get("status") == "pass", f"battle visual assertion class failed: {class_name}")
    html = read(OUT / "battle_effect_visual_assertion_review.html")
    require("Battle Effect Visual Assertion Review" in html, "battle effect visual assertion out page missing title")


def assert_battle_effect_pixel_oracle_plan() -> None:
    data = json.loads(read(OUT / "battle_effect_pixel_oracle_plan.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-effect-pixel-oracle-plan", "battle effect pixel oracle plan kind changed")
    require(data.get("status") == "capture-oracle-not-built", "battle effect pixel oracle plan must remain a handoff, not a completed oracle")
    require(summary.get("webVisualAssertionStatus") == "visual-assertions-pass", "battle pixel oracle plan should consume passing web visual assertions")
    require(summary.get("requiredFullOracleRows") == 52, "battle pixel oracle full capture row count changed")
    require(summary.get("recommendedFirstPassRows") >= 8, "battle pixel oracle first-pass plan lost coverage")
    require(summary.get("completionDecision") == "do-not-call-update-goal", "battle pixel oracle plan must not close the active goal")
    rows = data.get("recommendedFirstPassRows") or []
    tokens = {token for row in rows for token in row.get("coverageTokens") or []}
    for token in {
        "requirement:random placement/range present",
        "requirement:runner must instantiate child objects over time",
        "requirement:child motion loop present",
        "requirement:palette transform effect; no CNS frame stream",
        "class:frameScript-effect",
        "class:init-frame-effect",
        "class:spawn-stream-effect",
    }:
        require(token in tokens, f"battle pixel oracle first-pass plan missing coverage token: {token}")

def assert_battle_effect_capture_checklist() -> None:
    data = json.loads(read(OUT / "battle_effect_capture_checklist.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-effect-capture-checklist", "battle effect capture checklist kind changed")
    require(data.get("status") in {"waiting-for-captures", "ready-for-first-pass-compare"}, "battle effect capture checklist status changed")
    require(summary.get("manifestPath") == "data/battle_effect_capture_manifest.json", "battle capture manifest path changed")
    require(summary.get("firstPassRows") == 8, "battle capture checklist first-pass row count changed")
    require(summary.get("fullOracleRows") == 52, "battle capture checklist full oracle row count changed")
    require(summary.get("completionDecision") == "do-not-call-update-goal", "battle capture checklist must not close the active goal")
    if summary.get("manifestExists") is False:
        require(summary.get("firstPassReady") == 0, "absent manifest should not report ready first-pass rows")
        require(summary.get("firstPassMissing") == 8, "absent manifest should report all first-pass rows missing")
    template = data.get("captureManifestTemplate") or {}
    require(template.get("kind") == "hwanse-battle-effect-capture-manifest", "battle capture manifest template kind changed")
    require(len(template.get("captures") or []) == 8, "battle capture manifest template should include 8 first-pass captures")

def assert_battle_effect_pixel_compare_report() -> None:
    data = json.loads(read(OUT / "battle_effect_pixel_compare_report.json"))
    summary = data.get("summary", {})
    require(data.get("kind") == "hwanse-battle-effect-pixel-compare-report", "battle pixel compare report kind changed")
    require(data.get("status") in {"waiting-for-captures", "first-pass-incomplete", "first-pass-passed-full-incomplete", "full-oracle-passed"}, "battle pixel compare report status changed")
    require(summary.get("firstPassRowsExpected") == 8, "battle pixel compare first-pass expected count changed")
    require(summary.get("fullRowsExpected") == 52, "battle pixel compare full expected count changed")
    if summary.get("manifestExists") is False:
        require(summary.get("completionDecision") == "do-not-call-update-goal", "absent capture manifest must not close goal")
        require(summary.get("firstPassRowsCompared") == 0, "absent capture manifest should not compare first-pass rows")
        require(summary.get("fullRowsCompared") == 0, "absent capture manifest should not compare full rows")
    if data.get("status") == "full-oracle-passed":
        require(summary.get("completionDecision") == "eligible-for-update-goal", "full pixel oracle pass should be eligible to close goal")
    else:
        require(summary.get("completionDecision") == "do-not-call-update-goal", "incomplete pixel compare must not close goal")

def assert_battle_effect_pixel_compare_selftest() -> None:
    report = verify_battle_effect_pixel_compare_selftest.run_selftest()
    require(report.get("status") == "ok", "battle pixel compare self-test did not pass")
    statuses = {row.get("id"): row.get("status") for row in report.get("checks") or []}
    require(statuses.get("pass") == "pass", "battle pixel compare self-test pass case failed")
    require(statuses.get("diff") == "differs", "battle pixel compare self-test diff case failed")
    require(statuses.get("frame-count-mismatch") == "frame-count-mismatch", "battle pixel compare self-test mismatch case failed")


def local_targets_from_html(html: str) -> list[str]:
    targets: list[str] = []
    for attr in ("href", "src"):
        for value in re.findall(rf"{attr}=[\"']([^\"']+)[\"']", html):
            parsed = urlparse(value)
            if parsed.scheme or value.startswith("#") or value.startswith("mailto:"):
                continue
            if "${" in value:
                continue
            targets.append(value.split("#", 1)[0].split("?", 1)[0])
    return [target for target in targets if target]


def web_html_pages() -> list[str]:
    return sorted(path.relative_to(WEB).as_posix() for path in WEB.rglob("*.html"))


def resolve_local_target(source_rel: str, target: str) -> Path | None:
    source_path = WEB / source_rel
    resolved = (source_path.parent / target).resolve()
    try:
        resolved.relative_to(ROOT)
    except ValueError:
        return None
    return resolved


def assert_index_links() -> None:
    index = read(WEB / "index.html")
    for rel in [
        "game.html?game=1",
        "progress.html",
        "map_review.html",
        "resource_reference_review.html",
        "menu_review.html",
        "hud_menu_preview.html",
        "hud_menu_opener_frontier_review.html",
        "hud_menu_selected_root_frontier_review.html",
        "hud_menu_mode_state_dispatcher_review.html",
        "runtime_trace_hud_open_review.html",
        "hud_runtime_static_bridge_review.html",
        "hud_runtime_surface_handle_review.html",
        "intro_runtime_fade_review.html",
        "global_mode_state_frontier_review.html",
        "selected_root_live_writer_frontier_review.html",
        "selected_root_opcode_matrix_review.html",
        "save_selector_dispatch_table_context.html",
        "generic_vm_stream_producer_frontier_review.html",
        "menu_descriptor_stack_review.html",
        "cns_rect_review.html",
        "battle_formula_calculator.html",
        "battle_skill_timeline_review.html",
        "battle_simulator.html",
        "battle_analysis.html",
        "render_pipeline_model_review.html",
        "palette_pipeline_review.html",
        "non_palette_motion_producer_review.html",
        "field_encounter_static_review.html",
        "field_encounter_formation_boundary_review.html",
        "field_encounter_formation_stream_review.html",
        "field_encounter_producer_boundary_review.html",
        "static_analysis_remaining_work.html",
        "dialogue_prompt_player.html",
        "scene_event_runtime_evidence_handoff.html",
    ]:
        require(f'href="{rel}"' in index, f"index should link {rel}")
    forbidden_fragments = [
        "_audit.html",
        "scene_event_prompt_producer_goal_review.html",
        "scene_event_goal_completion_audit.html",
        "scene_event_producer_static_frontier_saturation_audit.html",
    ]
    for fragment in forbidden_fragments:
        require(fragment not in index, f"index still exposes legacy fragment: {fragment}")


def assert_web_html_reachable_from_index() -> None:
    pages = web_html_pages()
    page_set = set(pages)
    links = {rel: set() for rel in pages}
    for rel in pages:
        html = read(WEB / rel)
        for target in local_targets_from_html(html):
            if not target.endswith(".html"):
                continue
            resolved = resolve_local_target(rel, target)
            if resolved is None:
                continue
            try:
                target_rel = resolved.relative_to(WEB).as_posix()
            except ValueError:
                continue
            if target_rel in page_set:
                links[rel].add(target_rel)

    reachable = {"index.html"}
    changed = True
    while changed:
        changed = False
        for rel in list(reachable):
            for target_rel in links.get(rel, set()):
                if target_rel not in reachable:
                    reachable.add(target_rel)
                    changed = True

    missing = sorted(page_set - reachable)
    require(not missing, f"web html pages not reachable from index: {', '.join(missing[:20])}")


def assert_no_broken_web_html_links() -> None:
    for rel in web_html_pages():
        path = WEB / rel
        html = read(path)
        for target in local_targets_from_html(html):
            resolved = resolve_local_target(rel, target)
            if resolved is None:
                continue
            require(resolved.exists(), f"broken link in web/{rel}: {target}")


def assert_no_removed_engine_paths() -> None:
    for rel in REMOVED_ENGINE_FILES:
        require(not (WEB / rel).exists(), f"removed engine file reappeared: web/{rel}")

    search_paths = [WEB / rel for rel in ACTIVE_WEB_PAGES]
    search_paths.extend(OUT / rel for rel in ACTIVE_OUT_FILES)
    search_paths.extend(path for path in (ROOT / "tools").glob("*.py") if path.name != "verify_web_assets.py")
    for path in search_paths:
        text = read_lenient(path)
        rel = path.relative_to(ROOT).as_posix()
        for removed in REMOVED_ENGINE_PATHS:
            require(removed not in text, f"{rel} still references removed engine path: {removed}")


def assert_render_pipeline_model_review() -> None:
    data = json.loads(read(OUT / "render_pipeline_model_review.json"))
    require(data.get("kind") == "hwanse-render-pipeline-model-review", "render pipeline kind changed")
    require(data.get("status") == "field-battle-render-model-grounded", "render pipeline status changed")
    summary = data.get("summary") or {}
    require(summary.get("displayResolution") == "640x480x8", "render pipeline display resolution changed")
    require(summary.get("surfaceWrapperTable") == "0x0055abd8", "render pipeline wrapper table changed")
    field = data.get("fieldPipeline") or {}
    battle = data.get("battlePipeline") or {}
    require(field.get("evidenceStatus") == "tilemap-dirty-render-consumer-grounded", "field render model status changed")
    require(battle.get("evidenceStatus") == "object-sprite-helper-model-grounded", "battle render model status changed")
    negative = field.get("negativeEvidence") or {}
    require(negative.get("drawTimeUsesLayer1Flag0x40") is False, "field draw-time 0x40 evidence changed")
    require(negative.get("visibleMotionProducerFoundInRedrawConsumer") is False, "field 0x40 producer should remain unpromoted")
    require((battle.get("globalsAndFields") or {}).get("object+0x28"), "battle object+0x28 model missing")
    html = read(WEB / "render_pipeline_model_review.html")
    require("HWANSE_RENDER_PIPELINE_MODEL_READY" in html, "render pipeline web marker missing")


def assert_surface_wrapper_catalog_review() -> None:
    data = json.loads(read(OUT / "surface_wrapper_catalog_review.json"))
    require(data.get("kind") == "hwanse-surface-wrapper-catalog-review", "surface wrapper kind changed")
    require(data.get("status") == "wrapper-grid-vs-descriptor-catalog-classified", "surface wrapper status changed")
    summary = data.get("summary") or {}
    require(summary.get("surfaceWrapperTable") == "0x0055abd8", "surface wrapper table changed")
    require(summary.get("objectDrawSourceField") == "object+0x28", "surface wrapper object field changed")
    require(summary.get("descriptorPlus4Assets", 0) >= 100, "descriptor +4 asset count unexpectedly low")
    require(summary.get("fixedGridAssets", 0) >= 4, "fixed-grid asset count unexpectedly low")
    require(summary.get("wrapperGridFullyExplainsDescriptorCatalogs") is False, "wrapper/descriptor boundary changed")
    offsets = summary.get("wrapperFieldOffsets") or {}
    for key in ["+0x2e", "+0x30", "+0x32", "+0x34"]:
        require(key in offsets, f"surface wrapper offset {key} missing")
    rows = data.get("rows") or []
    require(any(row.get("asset") == "btl_at" and row.get("wrapperGridSufficient") is False for row in rows), "btl_at descriptor boundary missing")
    require(any(row.get("asset") == "face_01" and row.get("wrapperGridSufficient") is True for row in rows), "face_01 fixed-grid boundary missing")
    require(any(row.get("asset") == "window" and row.get("wrapperRole") == "window-template-system" for row in rows), "window template boundary missing")
    html = read(WEB / "surface_wrapper_catalog_review.html")
    require("HWANSE_SURFACE_WRAPPER_CATALOG_READY" in html, "surface wrapper web marker missing")


def assert_draw_flags_color_key_review() -> None:
    data = json.loads(read(OUT / "draw_flags_color_key_review.json"))
    require(data.get("kind") == "hwanse-draw-flags-color-key-review", "draw flags kind changed")
    require(data.get("status") == "object-flag-and-blt-flag-boundary-reviewed", "draw flags status changed")
    summary = data.get("summary") or {}
    require(summary.get("objectFlagField") == "object+0x2c", "draw flags object field changed")
    require(summary.get("lowBitsClipSlotMask") == "0x00000007", "draw flags low-bit clip mask changed")
    require(summary.get("scaledBranchMask") == "0x00000208", "draw flags scaled branch mask changed")
    require(summary.get("centeredScaleBit") == "object+0x2d bit 0x02", "draw flags center bit changed")
    require(summary.get("normalDrawFlagGlobal") == "0x004676f4", "draw flags normal global changed")
    require(summary.get("scaledDrawFlagGlobal") == "0x004676f8", "draw flags scaled global changed")
    calls = data.get("directDrawCallClasses") or []
    require(len(calls) >= 10, "draw flags DirectDraw call class coverage regressed")
    call_vas = {str(row.get("vaHex", "")).lower() for row in calls}
    require({"0x00417722", "0x00419b4a", "0x00419bc4", "0x00419bf2"} <= call_vas, "draw flags key call sites missing")
    consumers = data.get("objectFlagConsumers") or []
    require(any(row.get("mask") == "0x00000208" for row in consumers), "draw flags 0x0208 consumer missing")
    require(any(row.get("mask") == "0x00000007" for row in consumers), "draw flags clip-slot consumer missing")
    bytes_rows = data.get("byteEvidence") or []
    byte_vas = {str(row.get("vaHex", "")).lower() for row in bytes_rows}
    require({"0x00416d79", "0x00419a2a", "0x00417722"} <= byte_vas, "draw flags byte evidence missing")
    require(all(row.get("available") for row in bytes_rows), "draw flags byte evidence should be available from Hwanse2.exe")
    verification = data.get("verification") or {}
    require(verification.get("notSimplyTransparencyBit") is True, "draw flags transparency boundary changed")
    html = read(WEB / "draw_flags_color_key_review.html")
    require("HWANSE_DRAW_FLAGS_COLOR_KEY_READY" in html, "draw flags web marker missing")


def assert_palette_pipeline_review() -> None:
    data = json.loads(read(OUT / "palette_pipeline_review.json"))
    require(data.get("kind") == "hwanse-palette-pipeline-review", "palette pipeline kind changed")
    require(data.get("status") == "palette-buffer-copy-apply-path-grounded", "palette pipeline status changed")
    summary = data.get("summary") or {}
    require(summary.get("displayMode") == "640x480x8 indexed color", "palette display mode changed")
    require(summary.get("paletteObjectGlobal") == "0x004676c4", "palette object global changed")
    require(summary.get("paletteHeaderGlobal") == "0x004676e4", "palette header global changed")
    require(summary.get("paletteEntriesGlobal") == "0x004676e8", "palette entries global changed")
    require(summary.get("entryCount") == 256, "palette entry count changed")
    require(summary.get("entryStrideBytes") == 4, "palette entry stride changed")
    calls = data.get("directDrawPaletteCalls") or []
    call_vas = {str(row.get("vaHex", "")).lower() for row in calls}
    require({"0x0041652e", "0x00416649", "0x00416708", "0x00416856"} <= call_vas, "palette key DirectDraw calls missing")
    routines = data.get("updateRoutines") or []
    routine_vas = {str(row.get("vaHex", "")).lower() for row in routines}
    require({"0x00415c53", "0x004165b8", "0x00416677", "0x00416736"} <= routine_vas, "palette update routines missing")
    bytes_rows = data.get("byteEvidence") or []
    byte_vas = {str(row.get("vaHex", "")).lower() for row in bytes_rows}
    require({"0x00415c58", "0x00416649", "0x00416708", "0x004167f1"} <= byte_vas, "palette byte evidence missing")
    require(all(row.get("available") for row in bytes_rows), "palette byte evidence should be available from Hwanse2.exe")
    verification = data.get("verification") or {}
    require(verification.get("notMapTileMotionProof") is True, "palette/map-motion boundary changed")
    vm_boundary = data.get("paletteVmBoundary") or {}
    require("0x38" in (vm_boundary.get("knownOpcodeFamilies") or []), "palette opcode 0x38 boundary missing")
    require("0x39" in (vm_boundary.get("knownOpcodeFamilies") or []), "palette opcode 0x39 boundary missing")
    html = read(WEB / "palette_pipeline_review.html")
    require("HWANSE_PALETTE_PIPELINE_READY" in html, "palette pipeline web marker missing")


def assert_non_palette_motion_producer_review() -> None:
    data = json.loads(read(OUT / "non_palette_motion_producer_review.json"))
    require(data.get("kind") == "hwanse-non-palette-motion-producer-review", "non-palette motion kind changed")
    require(data.get("status") == "battle-motion-grounded-map-tile-motion-producer-unbound", "non-palette motion status changed")
    summary = data.get("summary") or {}
    map_summary = summary.get("mapSummary") or {}
    battle_summary = summary.get("battleSummary") or {}
    require(map_summary.get("animatedMapCount") == 5, "non-palette motion animated map count changed")
    require(map_summary.get("liveBufferCandidateVmTileWriteRefCount") == 6, "non-palette motion live tile writer ref count changed")
    require(map_summary.get("liveBufferAnimatedRootBindingCount") == 0, "non-palette motion map binding unexpectedly promoted")
    require(map_summary.get("frameScriptTileWriteProducerFound") is False, "non-palette motion frame-script producer changed")
    require(map_summary.get("drawTimeVisibleMotionTransformFound") is False, "non-palette motion draw-time transform changed")
    require(battle_summary.get("helpersWithPositionOrMotion") == 84, "non-palette motion battle helper coverage changed")
    require(battle_summary.get("skillsWithHelpers") == 96, "non-palette motion skill/helper join count changed")
    require(battle_summary.get("motionModelClassifiedRows") == 52, "non-palette motion classified row count changed")
    require(battle_summary.get("pixelOracleStatus") == "not-original-runtime-pixel-oracle", "non-palette motion pixel oracle boundary changed")
    decisions = data.get("promotionDecisions") or []
    require(any(row.get("area") == "field map visible tile motion" and row.get("decision") == "keep unpromoted" for row in decisions), "map tile motion decision missing")
    require(any(row.get("area") == "battle effect helper motion" and row.get("decision") == "promote for runner use" for row in decisions), "battle helper motion decision missing")
    html = read(WEB / "non_palette_motion_producer_review.html")
    require("HWANSE_NON_PALETTE_MOTION_PRODUCER_READY" in html, "non-palette motion web marker missing")


def main() -> int:
    checks = [
        assert_exists,
        assert_handoff,
        assert_battle_skill_timeline_canonical,
        assert_battle_effect_runner_binding,
        assert_battle_effect_visual_assertions,
        assert_battle_effect_pixel_oracle_plan,
        assert_battle_effect_capture_checklist,
        assert_battle_effect_pixel_compare_report,
        assert_battle_effect_pixel_compare_selftest,
        assert_index_links,
        assert_web_html_reachable_from_index,
        assert_no_broken_web_html_links,
        assert_no_removed_engine_paths,
        assert_render_pipeline_model_review,
        assert_surface_wrapper_catalog_review,
        assert_draw_flags_color_key_review,
        assert_palette_pipeline_review,
        assert_non_palette_motion_producer_review,
    ]
    try:
        for check in checks:
            check()
    except AssertionError as exc:
        print(f"verify_web_assets failed: {exc}", file=sys.stderr)
        return 1
    print("verify_web_assets ok")
    return 0


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