#!/usr/bin/env python3
"""Verify that battle skill preview pages consume canonical EXE timelines.

This is a non-browser regression guard for bugs where the UI re-used legacy
payload/mapping rows and shifted Rinshan skills such as Taunt / Face Claw.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
CANONICAL_PATH = ROOT / "out" / "battle_skill_timeline_canonical.json"
MAPPING_PATH = ROOT / "out" / "battle_action_mapping.json"
COMPLETE_PATTERN_PATH = ROOT / "out" / "battle_skill_complete_pattern_review.json"
IMPLEMENTATION_GAP_PATH = ROOT / "out" / "battle_skill_implementation_gap_review.json"
SOUND_ROLE_PATH = ROOT / "out" / "battle_sound_role_review.json"
RESULT_DISPLAY_BRANCH_PATH = ROOT / "out" / "battle_result_display_branch_review.json"
BATTLE_ANALYSIS_SUMMARY_PATH = ROOT / "out" / "battle_analysis_summary.json"
EFFECT_PATTERN_PATH = ROOT / "out" / "battle_effect_animation_pattern_review.json"
EFFECT_RUNNER_BINDING_PATH = ROOT / "out" / "battle_effect_runner_binding_review.json"
ENGINE_PATH = ROOT / "web" / "engine" / "battle" / "animation.js"
COMPLETE_PATTERN_WEB_PATH = ROOT / "web" / "battle_skill_complete_pattern_review.html"
BATTLE_EFFECT_PATTERN_WEB_PATH = ROOT / "web" / "battle_effect_animation_pattern_review.html"
BATTLE_ANALYSIS_WEB_PATH = ROOT / "web" / "battle_analysis.html"
HTML_PATHS = [
    ROOT / "web" / "battle_skill_timeline_review.html",
    ROOT / "web" / "battle_formula_calculator.html",
    ROOT / "web" / "battle_simulator.html",
]


EXPECTED_PLAYER_ACTION_COUNT = 156
EXPECTED_SUPPORT_POLICY_COUNT = 25
EXPECTED_EFFECT_EXECUTION_REQUIREMENTS = 52
EXPECTED_EFFECT_RENDER_PAGES = 3
ENGINE_SCRIPT_SRC = 'src="engine/battle/animation.js"'


def fail(message: str) -> None:
    raise AssertionError(message)


def load_json(path: Path):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        fail(f"missing file: {path.relative_to(ROOT)}")


def key(row: dict) -> tuple[str, str, object]:
    return (
        row.get("ownerKey", ""),
        str(row.get("skillIdHex", "")).lower(),
        row.get("levelOrFixed"),
    )


def require_row(index: dict[tuple[str, str, object], dict], owner: str, skill: str, level: object) -> dict:
    row = index.get((owner, skill, level))
    if not row:
        fail(f"missing canonical action row: {owner} {skill} level={level!r}")
    return row


def assert_sequence(row: dict, expected: list[int]) -> None:
    actual = row.get("frameSequence")
    if actual != expected:
        fail(
            f"{row.get('ownerKey')} {row.get('skillName')} {row.get('skillIdHex')} "
            f"level={row.get('levelOrFixed')} frameSequence mismatch:\n"
            f"  expected {expected}\n"
            f"  actual   {actual}"
        )


def assert_hits(row: dict, expected_count: int) -> None:
    actual_count = len(row.get("hitEvents") or [])
    if actual_count != expected_count:
        fail(
            f"{row.get('ownerKey')} {row.get('skillName')} {row.get('skillIdHex')} "
            f"level={row.get('levelOrFixed')} hitEvents mismatch: "
            f"expected {expected_count}, actual {actual_count}"
        )


def assert_confirmed(row: dict) -> None:
    if row.get("status") != "confirmed":
        fail(f"row is not confirmed: {row.get('key')} status={row.get('status')}")


def verify_canonical_rows() -> None:
    data = load_json(CANONICAL_PATH)
    summary = data.get("summary") or {}
    if summary.get("canonicalRowsReady") is not True:
        fail(
            "canonical summary canonicalRowsReady must be true after EXE row promotion, "
            f"actual={summary.get('canonicalRowsReady')!r}"
        )
    if summary.get("goalReady") is not False:
        fail(
            "canonical summary goalReady must remain false; row promotion is not the "
            f"browser playback/effect completion gate, actual={summary.get('goalReady')!r}"
        )
    if "browser playback" not in str(summary.get("goalReadyReason") or ""):
        fail("canonical summary goalReadyReason must explain the browser playback completion gate")
    if summary.get("wlkEvidenceUnresolvedRows") not in (0, None):
        fail(f"canonical summary has unresolved WLK rows: {summary.get('wlkEvidenceUnresolvedRows')}")
    timeline_counts = summary.get("playerTimelineStatusCounts") or {}
    if timeline_counts.get("confirmed") != EXPECTED_PLAYER_ACTION_COUNT:
        fail(
            "canonical summary playerTimelineStatusCounts.confirmed mismatch: "
            f"expected {EXPECTED_PLAYER_ACTION_COUNT}, actual {timeline_counts.get('confirmed')}"
        )

    rows = data.get("playerActions")
    if not isinstance(rows, list):
        fail("canonical playerActions must be a list")
    if len(rows) != EXPECTED_PLAYER_ACTION_COUNT:
        fail(f"canonical playerActions count mismatch: expected {EXPECTED_PLAYER_ACTION_COUNT}, actual {len(rows)}")
    unnormalized_helper_rows = [
        row for row in rows
        if row.get("helperRole") == "unclassified-helper-pattern"
    ]
    if unnormalized_helper_rows:
        sample = ", ".join(
            f"{row.get('ownerKey')} {row.get('skillIdHex')}"
            for row in unnormalized_helper_rows[:5]
        )
        fail(f"canonical player helperRole still has unnormalized rows: {sample}")

    index = {key(row): row for row in rows}

    checks = [
        ("rinshan", "0x05", None, "도발",
         [51, 52, 53, 51, 52, 53, 51, 52, 53, 54, 4, 5, 4], 0),
        ("rinshan", "0x06", None, "꼬챙이 꿰기",
         [20, 21, 22, 23, 23, 3], 1),
        ("rinshan", "0x09", 1, "안면백조권",
         [6, 7, 8, 9, 9, 16, 17, 18, 19, 19, 6, 7, 8, 9, 9, 3], 3),
        ("rinshan", "0x0a", 2, "안면백조권",
         [6, 7, 8, 9, 9, 16, 17, 18, 19, 19, 33, 34, 38, 39, 39, 3], 3),
        ("rinshan", "0x0b", 3, "안면백조권",
         [6, 7, 8, 9, 9, 16, 17, 18, 19, 19, 33, 34, 35, 36, 37, 35, 34, 41, 40, 3], 3),
        ("rinshan", "0x0c", 4, "안면백조권",
         [6, 7, 8, 9, 9, 16, 17, 18, 19, 19, 33, 34, 38, 39, 39, 33, 34, 35, 36, 37, 35, 34, 42, 42, 41, 40, 3], 4),
        ("ataho", "0x2f", 1, "맹호룬룬권",
         [44, 45, 46, 47, 44, 48, 49, 50, 0], 2),
        ("ataho", "0x32", 4, "맹호룬룬권",
         [44, 45, 46, 47, 44, 48, 49, 50, 12, 13, 25, 26, 8, 9, 10, 11, 0], 5),
    ]

    for owner, skill, level, expected_name, frames, hit_count in checks:
        row = require_row(index, owner, skill, level)
        assert_confirmed(row)
        if row.get("skillName") != expected_name:
            fail(f"{owner} {skill} level={level!r} name mismatch: {row.get('skillName')} != {expected_name}")
        assert_sequence(row, frames)
        assert_hits(row, hit_count)


def verify_mapping_still_present() -> None:
    data = load_json(MAPPING_PATH)
    rows = data.get("playerRows")
    if not isinstance(rows, list):
        fail("mapping playerRows must be a list")
    if len(rows) != EXPECTED_PLAYER_ACTION_COUNT:
        fail(f"mapping playerRows count mismatch: expected {EXPECTED_PLAYER_ACTION_COUNT}, actual {len(rows)}")
    lookup = {key(row): row for row in rows}
    for owner, skill, level in [
        ("rinshan", "0x05", None),
        ("rinshan", "0x06", None),
        ("rinshan", "0x09", 1),
        ("ataho", "0x32", 4),
    ]:
        if (owner, skill, level) not in lookup:
            fail(f"missing mapping metadata row for canonical merge: {owner} {skill} level={level!r}")


def verify_complete_pattern_review() -> None:
    data = load_json(COMPLETE_PATTERN_PATH)
    summary = data.get("summary") or {}
    if summary.get("rows") != EXPECTED_PLAYER_ACTION_COUNT:
        fail(
            "complete pattern row count mismatch: "
            f"expected {EXPECTED_PLAYER_ACTION_COUNT}, actual {summary.get('rows')}"
        )
    if summary.get("canonicalConfirmedRows") != EXPECTED_PLAYER_ACTION_COUNT:
        fail(
            "complete pattern canonicalConfirmedRows mismatch: "
            f"expected {EXPECTED_PLAYER_ACTION_COUNT}, actual {summary.get('canonicalConfirmedRows')}"
        )
    if summary.get("rowsWithUnresolvedFlags") != 0:
        fail(f"complete pattern still has unresolved flags: {summary.get('rowsWithUnresolvedFlags')}")
    unresolved_rows = data.get("unresolvedRows") or []
    if unresolved_rows:
        sample = ", ".join(f"{row.get('ownerKey')} {row.get('skillIdHex')}" for row in unresolved_rows[:5])
        fail(f"complete pattern unresolvedRows must be empty, sample: {sample}")
    if summary.get("rowsWithSupportPolicy") != EXPECTED_SUPPORT_POLICY_COUNT:
        fail(
            "support/status policy count mismatch: "
            f"expected {EXPECTED_SUPPORT_POLICY_COUNT}, actual {summary.get('rowsWithSupportPolicy')}"
        )
    support_rows = data.get("supportPolicyRows") or []
    if len(support_rows) != EXPECTED_SUPPORT_POLICY_COUNT:
        fail(
            "supportPolicyRows length mismatch: "
            f"expected {EXPECTED_SUPPORT_POLICY_COUNT}, actual {len(support_rows)}"
        )
    for row in support_rows:
        if "support/status UI policy" not in (row.get("policyFlags") or []):
            fail(f"support policy row missing policyFlags: {row.get('ownerKey')} {row.get('skillIdHex')}")
        if row.get("unresolvedFlags"):
            fail(f"support policy row must not carry unresolvedFlags: {row.get('ownerKey')} {row.get('skillIdHex')}")


def verify_sound_role_review() -> None:
    data = load_json(SOUND_ROLE_PATH)
    critical = data.get("criticalStaticPath") or {}
    if critical.get("commonAltWlkNo") != 14:
        fail(f"critical alternate WLK must be 0-based id 14, actual={critical.get('commonAltWlkNo')!r}")
    serialized = json.dumps(data, ensure_ascii=False)
    stale_phrases = [
        "runtime confirms",
        "runtime playback",
        "런타임 확인",
        "opening " + "auto-run",
        "supplemental " + "cross-check",
    ]
    for phrase in stale_phrases:
        if phrase in serialized:
            fail(f"sound role report still contains stale runtime-as-primary phrase: {phrase}")


def verify_result_display_branch_review() -> None:
    data = load_json(RESULT_DISPLAY_BRANCH_PATH)
    serialized = json.dumps(data, ensure_ascii=False)
    stale_phrases = [
        "오프닝 " + "런타임 확인",
        "repeated-hit runtime rows",
        "opening " + "auto-run",
        "supplemental " + "cross-check",
    ]
    for phrase in stale_phrases:
        if phrase in serialized:
            fail(f"result display branch report still contains stale runtime-as-primary phrase: {phrase}")


def verify_implementation_gap_review() -> None:
    data = load_json(IMPLEMENTATION_GAP_PATH)
    if data.get("status") != "no-unresolved-implementation-gaps":
        fail(f"implementation gap status must be no-unresolved-implementation-gaps, actual={data.get('status')!r}")
    summary = data.get("summary") or {}
    expected_zero_keys = [
        "rowsWithFlags",
        "presentationPolicyRows",
        "runnerImplementationGapRows",
        "staticAnalysisGapRows",
        "unclassifiedGapRows",
    ]
    for key_name in expected_zero_keys:
        if summary.get(key_name) != 0:
            fail(f"implementation gap summary {key_name} must be 0, actual={summary.get(key_name)!r}")
    if summary.get("supportPolicyRowsOutsideGap") != EXPECTED_SUPPORT_POLICY_COUNT:
        fail(
            "implementation gap supportPolicyRowsOutsideGap mismatch: "
            f"expected {EXPECTED_SUPPORT_POLICY_COUNT}, actual {summary.get('supportPolicyRowsOutsideGap')}"
        )
    if data.get("rows"):
        fail("implementation gap rows must be empty after canonical promotion")


def verify_web_binding() -> None:
    engine = ENGINE_PATH.read_text(encoding="utf-8")
    required_engine_snippets = [
        "function mergePlayerActions",
        "function helperEffectFrames",
        "function helperEffectEndTick",
        "function extendFramesForHelperEffects",
        "function buildPlayerPlayback",
        "row?.hitClass === \"no-result-sound-weapon-basic-effect-path\"",
        "const effectiveHitCount = hasResultSound || allowNoResultHit ? requestedHitCount : 0",
        "mergePlayerActions,",
        "helperEffectFrames,",
        "helperEffectEndTick,",
        "extendFramesForHelperEffects,",
        "buildPlayerPlayback,",
    ]
    for snippet in required_engine_snippets:
        if snippet not in engine:
            fail(f"battle animation engine missing canonical binding snippet: {snippet}")

    for path in HTML_PATHS:
        text = path.read_text(encoding="utf-8")
        rel = path.relative_to(ROOT)
        if ENGINE_SCRIPT_SRC not in text:
            fail(f"{rel} missing canonical engine script reference {ENGINE_SCRIPT_SRC}")
        if "BattleAnimation.mergePlayerActions" not in text:
            fail(f"{rel} does not consume canonical rows through BattleAnimation.mergePlayerActions")
        for snippet in [
            "battle_helper_sync_timing_review.json",
            "battle_helper_visual_behavior_review.json",
            "battle_helper_position_motion_review.json",
            "BattleAnimation.buildPlayerPlayback",
            "helperSyncIndex:",
            "helperVisualIndex:",
            "helperPositionIndex:",
        ]:
            if snippet not in text:
                fail(f"{rel} missing shared helper/effect playback snippet: {snippet}")

    timeline_page = (ROOT / "web" / "battle_skill_timeline_review.html").read_text(encoding="utf-8")
    if "BattleAnimation.helperEffectFrames" not in timeline_page:
        fail("battle_skill_timeline_review.html must delegate helper effect frames to BattleAnimation.helperEffectFrames")

    complete_page = COMPLETE_PATTERN_WEB_PATH.read_text(encoding="utf-8")
    complete_required = [
        "rowsWithSupportPolicy",
        "policyFlags",
        "effectiveImplementationState",
        "지원/상태 정책",
    ]
    for snippet in complete_required:
        if snippet not in complete_page:
            fail(f"{COMPLETE_PATTERN_WEB_PATH.relative_to(ROOT)} missing complete pattern policy snippet: {snippet}")

    battle_analysis_page = BATTLE_ANALYSIS_WEB_PATH.read_text(encoding="utf-8")
    battle_analysis_summary = BATTLE_ANALYSIS_SUMMARY_PATH.read_text(encoding="utf-8")
    required_current_snippets = [
        "전투 기술 구현 gap 해소",
        "no-unresolved-implementation-gaps",
        "supportPolicyRowsOutsideGap: 25",
    ]
    stale_snippets = [
        "전투 기술 구현 gap 분류",
        "unresolved-flags-classified",
        "rowsWithFlags: 25",
        "presentationPolicyRows: 25",
    ]
    for text, rel in [
        (battle_analysis_page, BATTLE_ANALYSIS_WEB_PATH.relative_to(ROOT)),
        (battle_analysis_summary, BATTLE_ANALYSIS_SUMMARY_PATH.relative_to(ROOT)),
    ]:
        for snippet in required_current_snippets:
            if snippet not in text:
                fail(f"{rel} missing current battle analysis snippet: {snippet}")
        for snippet in stale_snippets:
            if snippet in text:
                fail(f"{rel} still contains stale battle gap snippet: {snippet}")


def verify_effect_pattern_review() -> None:
    data = load_json(EFFECT_PATTERN_PATH)
    if data.get("runtimeUsed") is not False:
        fail("effect animation pattern review must be static-only and mark runtimeUsed false")
    if data.get("status") != "effect-helper-animation-patterns-joined-by-skill":
        fail(f"unexpected effect pattern status: {data.get('status')!r}")
    summary = data.get("summary") or {}
    if summary.get("skillRows") != EXPECTED_PLAYER_ACTION_COUNT:
        fail(f"effect pattern skillRows mismatch: {summary.get('skillRows')!r}")
    if summary.get("skillsWithReviewFlags") != 0:
        fail(f"effect pattern review flags must be 0, actual={summary.get('skillsWithReviewFlags')!r}")
    if summary.get("skillsWithExecutionRequirements") != EXPECTED_EFFECT_EXECUTION_REQUIREMENTS:
        fail(
            "effect execution requirement count mismatch: "
            f"expected {EXPECTED_EFFECT_EXECUTION_REQUIREMENTS}, actual {summary.get('skillsWithExecutionRequirements')!r}"
        )
    requirement_counts = summary.get("executionRequirementCounts") 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",
    ]:
        if required not in requirement_counts:
            fail(f"effect pattern missing execution requirement bucket: {required}")

    page = BATTLE_EFFECT_PATTERN_WEB_PATH.read_text(encoding="utf-8")
    for snippet in [
        'id="requirement"',
        'id="requirementState"',
        "실행 요구 있음",
        "실행 요구 없음",
        "row.executionRequirements",
    ]:
        if snippet not in page:
            fail(f"{BATTLE_EFFECT_PATTERN_WEB_PATH.relative_to(ROOT)} missing effect requirement UI snippet: {snippet}")

    binding = load_json(EFFECT_RUNNER_BINDING_PATH)
    if binding.get("status") != "runner-binding-reviewed":
        fail(f"unexpected effect runner binding status: {binding.get('status')!r}")
    binding_summary = binding.get("summary") or {}
    if binding_summary.get("skillsWithExecutionRequirements") != EXPECTED_EFFECT_EXECUTION_REQUIREMENTS:
        fail(
            "effect runner binding requirement count mismatch: "
            f"expected {EXPECTED_EFFECT_EXECUTION_REQUIREMENTS}, "
            f"actual {binding_summary.get('skillsWithExecutionRequirements')!r}"
        )
    if binding_summary.get("engineChecksPassed") != binding_summary.get("engineChecksTotal"):
        fail(f"effect runner engine checks are not all green: {binding_summary}")
    if binding_summary.get("pageChecksPassed") != binding_summary.get("pageChecksTotal"):
        fail(f"effect runner page checks are not all green: {binding_summary}")
    if binding_summary.get("pagesUsingPlaybackEffectFrames") != EXPECTED_EFFECT_RENDER_PAGES:
        fail(
            "effect runner pages using playback.effectFrames mismatch: "
            f"expected {EXPECTED_EFFECT_RENDER_PAGES}, actual {binding_summary.get('pagesUsingPlaybackEffectFrames')!r}"
        )
    if binding_summary.get("pagesRenderingHelperEffectLayer") != EXPECTED_EFFECT_RENDER_PAGES:
        fail(
            "effect runner helper-effect render pages mismatch: "
            f"expected {EXPECTED_EFFECT_RENDER_PAGES}, actual {binding_summary.get('pagesRenderingHelperEffectLayer')!r}"
        )
    for page in binding.get("pageChecks") or []:
        if not page.get("usesPlaybackEffectFrames") or not page.get("rendersHelperEffectLayer"):
            fail(f"effect runner page is not rendering helper effect frames: {page.get('page')}")


def main() -> int:
    try:
        verify_canonical_rows()
        verify_mapping_still_present()
        verify_complete_pattern_review()
        verify_sound_role_review()
        verify_result_display_branch_review()
        verify_implementation_gap_review()
        verify_web_binding()
        verify_effect_pattern_review()
    except AssertionError as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        return 1

    print("battle skill canonical binding verified")
    print(f"- canonical player actions: {EXPECTED_PLAYER_ACTION_COUNT}")
    print("- guarded regressions: Rinshan taunt, weapon poke, face claw levels 1-4, Ataho Runrun levels 1/4")
    print("- complete pattern unresolved rows: 0; support/status policy rows: 25")
    print("- implementation gaps: 0")
    print("- sound role guard: critical WLK id 14; no opening playback evidence")
    print("- result display branch is static EXE grounded")
    print("- battle_analysis hub reflects resolved implementation gaps")
    print("- pages use canonical merge: battle_skill_timeline_review, battle_formula_calculator, battle_simulator")
    print(
        "- effect runner binding: shared helper sync/visual data loaded; "
        f"execution requirements tracked: {EXPECTED_EFFECT_EXECUTION_REQUIREMENTS}"
    )
    print(f"- effect runner binding review: engine/page checks all green; helper effect render pages {EXPECTED_EFFECT_RENDER_PAGES}/3")
    return 0


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