#!/usr/bin/env python3
"""Build the canonical battle skill timeline evidence bundle.

This file intentionally does not add new reverse-engineering claims.  It
normalizes already generated EXE-grounded reports so browser pages can stop
mixing old/manual review data with the current battle timeline evidence.
"""

from __future__ import annotations

import json
from collections import Counter
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
OUTPUT_JSON = OUT / "battle_skill_timeline_canonical.json"
OUTPUT_JS = OUT / "battle_skill_timeline_canonical.js"


def load_json(name: str) -> dict[str, Any]:
    return json.loads((OUT / name).read_text(encoding="utf-8"))


def dump_json(path: Path, data: dict[str, Any]) -> None:
    path.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")


def key_player(row: dict[str, Any]) -> tuple[str, str]:
    return str(row.get("ownerKey") or ""), str(row.get("skillIdHex") or "").lower()


def compact_event(event: dict[str, Any]) -> dict[str, Any]:
    keep = [
        "type",
        "kind",
        "tick",
        "vaHex",
        "opcode",
        "summary",
        "source",
        "sourceVaHex",
        "label",
        "index",
        "spriteHex",
        "frame",
        "frameIndex",
        "selector",
        "selectorHex",
        "gate",
        "repeatSourceVaHex",
        "repeatIteration",
        "repeatTotalCount",
        "wlkNo",
        "normalWlkNo",
        "altWlkNo",
        "mode",
        "helperId",
        "movementMode",
        "motionMode",
        "motionKind",
        "stepDivisor",
        "divisor",
        "targetRangePolicy",
        "destHex",
        "immFixed",
        "maskHex",
    ]
    return {name: event.get(name) for name in keep if event.get(name) is not None}


def frame_events(timeline: dict[str, Any]) -> list[dict[str, Any]]:
    return [compact_event(event) for event in timeline.get("frameEvents") or []]


def compact_timeline(timeline: dict[str, Any]) -> dict[str, Any]:
    return {
        "events": [compact_event(event) for event in timeline.get("events") or []],
        "frameEvents": [compact_event(event) for event in timeline.get("frameEvents") or []],
        "hitEvents": [compact_event(event) for event in timeline.get("hitEvents") or []],
        "repeatLoops": [compact_event(event) for event in timeline.get("repeatLoops") or []],
    }


def timeline_events(timeline: dict[str, Any], *event_types: str) -> list[dict[str, Any]]:
    wanted = set(event_types)
    out: list[dict[str, Any]] = []
    for event in timeline.get("events") or []:
        event_type = event.get("type") or event.get("kind")
        if event_type in wanted:
            out.append(compact_event(event))
    return out


def wlk_calls_from_timeline(timeline: dict[str, Any]) -> list[dict[str, Any]]:
    calls = timeline_events(timeline, "effect-sound", "result-sound")
    calls.sort(key=lambda item: (item.get("tick") if isinstance(item.get("tick"), int) else 0, item.get("vaHex") or ""))
    return calls


def has_attack_result(labels: list[Any]) -> bool:
    return any(str(label).startswith("공격/상태 판정") for label in labels)


def has_recovery_result(labels: list[Any]) -> bool:
    return any(str(label) == "회복/부활" for label in labels)


def wlk_evidence(
    *,
    actor_kind: str,
    wlk_calls: list[dict[str, Any]],
    result_family_labels: list[Any] | None = None,
    hit_events: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Classify direct WLK evidence without inventing missing sounds.

    Some EXE display streams do not contain a per-skill WLK opcode.  Attack rows
    can still make sound through the common hit/miss/critical result layer, which
    is outside the display VM stream normalized here.  We keep those rows explicit
    so an empty ``wlkCalls`` list is not confused with an unresolved parser gap.
    """

    labels = result_family_labels or []
    if wlk_calls:
        return {
            "wlkEvidenceStatus": "direct-display-wlk",
            "wlkEvidenceLabel": "직접 WLK 호출",
            "wlkEvidenceNote": "display VM 안에서 effect/result WLK 이벤트가 검출됨.",
            "wlkEvidenceUnresolved": False,
        }
    if has_attack_result(labels):
        return {
            "wlkEvidenceStatus": "global-result-sound-layer",
            "wlkEvidenceLabel": "전역 판정음 경로",
            "wlkEvidenceNote": "display VM에는 직접 WLK 호출이 없고, 타격/미스/크리티컬 판정음은 공통 전투 결과 레이어에서 처리되는 유형.",
            "wlkEvidenceUnresolved": False,
        }
    if has_recovery_result(labels) or not hit_events:
        return {
            "wlkEvidenceStatus": "no-direct-wlk-support",
            "wlkEvidenceLabel": "직접 WLK 없음",
            "wlkEvidenceNote": f"{actor_kind} 비공격/회복/상태 명령으로, 현재 EXE display VM 근거상 직접 WLK 이벤트가 없음.",
            "wlkEvidenceUnresolved": False,
        }
    return {
        "wlkEvidenceStatus": "unresolved",
        "wlkEvidenceLabel": "WLK 미해결",
        "wlkEvidenceNote": "직접 WLK도 전역 판정음/지원 명령 분류도 확인되지 않음.",
        "wlkEvidenceUnresolved": True,
    }


def player_alignment(row: dict[str, Any] | None) -> tuple[str, str]:
    if not row:
        return "unconfirmed", "미확정"
    visual = row.get("visualAlignmentStatus")
    if row.get("status") != "confirmed":
        return "unconfirmed", "미확정"
    if visual == "phase-table-start-confirmed":
        return "start-confirmed", "시작점 확정"
    if visual in {"family-start-confirmed", "display-start-corrected", "display-table-with-family-confidence"}:
        return "visual-confirmed", "시각 정렬 확정"
    return "unconfirmed", "미확정"


def player_helper_role(
    *,
    timeline_role: Any,
    helper_ids: list[int],
    hit_events: list[dict[str, Any]],
    wlk_calls: list[dict[str, Any]],
    position_class: Any,
) -> str:
    """Normalize helper role labels without adding new EXE claims.

    Older timeline reports used ``unclassified-helper-pattern`` for two very
    different cases: rows with helper ids that need effect interpretation, and
    plain actor-only rows that have no helper call at all.  The latter should
    not look like unresolved helper analysis in active pages.
    """

    role = str(timeline_role or "")
    if helper_ids:
        return role or "helper-present"
    if position_class == "self-support" and not hit_events:
        return "actor-only-support"
    if hit_events:
        return "actor-only-result-timeline"
    if wlk_calls:
        return "actor-only-cast-wlk"
    return "actor-only-silent"


def build_player_rows() -> list[dict[str, Any]]:
    mapping = load_json("battle_action_mapping.json")
    timeline_report = load_json("battle_action_event_timeline_review.json")
    status_report = load_json("battle_skill_timeline_status.json")

    timeline_by_key = {key_player(row): row for row in timeline_report.get("rows") or []}
    status_by_key = {key_player(row): row for row in status_report.get("rows") or []}

    rows: list[dict[str, Any]] = []
    for action in mapping.get("playerRows") or []:
        row_key = key_player(action)
        timeline_row = timeline_by_key.get(row_key) or {}
        status_row = status_by_key.get(row_key) or {}
        timeline = timeline_row.get("timeline") or {}
        alignment_class, alignment_label = player_alignment(status_row)
        helper_ids = [
            int(event["helperId"])
            for event in timeline_events(timeline, "helper-call")
            if isinstance(event.get("helperId"), int)
        ]
        hit_events = [compact_event(event) for event in timeline.get("hitEvents") or []]
        result_sounds = timeline_events(timeline, "result-sound")
        effect_sounds = timeline_events(timeline, "effect-sound")
        wlk_calls = wlk_calls_from_timeline(timeline)
        wlk_status = wlk_evidence(
            actor_kind="player",
            wlk_calls=wlk_calls,
            result_family_labels=[],
            hit_events=hit_events,
        )
        rows.append(
            {
                "key": f"player:{row_key[0]}:{row_key[1]}",
                "actorKind": "player",
                "ownerKey": action.get("ownerKey"),
                "ownerName": action.get("ownerName"),
                "skillId": action.get("skillId"),
                "skillIdHex": action.get("skillIdHex"),
                "skillName": action.get("name"),
                "levelOrFixed": action.get("levelOrFixed"),
                "mpCost": action.get("mpCost"),
                "payloadVaHex": action.get("payloadVaHex"),
                "entryVaHex": action.get("entryVaHex"),
                "displayVmStartVaHex": timeline_row.get("entryStartVaHex"),
                "displayStartSource": timeline_row.get("startSource"),
                "status": status_row.get("status"),
                "reasons": status_row.get("reasons") or [],
                "notes": status_row.get("notes") or [],
                "hitClass": status_row.get("hitClass"),
                "helperBodyOnlyIds": status_row.get("helperBodyOnlyIds") or [],
                "timelineStatus": status_row.get("status"),
                "visualAlignmentStatus": status_row.get("visualAlignmentStatus"),
                "visualAlignmentNote": status_row.get("visualAlignmentNote"),
                "alignmentClass": alignment_class,
                "alignmentLabel": alignment_label,
                "confidence": timeline_row.get("confidence"),
                "familyName": timeline_row.get("familyName"),
                "sourceNote": timeline_row.get("note"),
                "timeline": compact_timeline(timeline),
                "frameSequence": timeline_row.get("frameSequence") or [],
                "frameEvents": frame_events(timeline),
                "durationGate": timeline_row.get("durationGateFromFrames"),
                "movementEvents": timeline_events(timeline, "movement", "actor-write"),
                "hitEvents": hit_events,
                "resultSounds": result_sounds,
                "effectSounds": effect_sounds,
                "wlkCalls": wlk_calls,
                **wlk_status,
                "helperIds": sorted(set(helper_ids)),
                "helperCalls": timeline_events(timeline, "helper-call"),
                "repeatLoops": [compact_event(event) for event in timeline.get("repeatLoops") or []],
                "positionClass": timeline_row.get("positionClass"),
                "helperRole": player_helper_role(
                    timeline_role=timeline_row.get("helperRole"),
                    helper_ids=sorted(set(helper_ids)),
                    hit_events=hit_events,
                    wlk_calls=wlk_calls,
                    position_class=timeline_row.get("positionClass"),
                ),
                "rawHelperRole": timeline_row.get("helperRole"),
                "payloadUnits": action.get("units") or [],
            }
        )
    return rows


def monster_alignment(row: dict[str, Any]) -> tuple[str, str]:
    if row.get("displayMatchStatus") == "matched":
        return "visual-confirmed", "시각 정렬 확정"
    if row.get("displayPointerVaHex"):
        return "display-table-review", "표시 검토 필요"
    return "unconfirmed", "미확정"


def monster_wlk_calls(row: dict[str, Any]) -> list[dict[str, Any]]:
    timeline = ((row.get("timelineSummary") or {}).get("displayTimeline") or {})
    return wlk_calls_from_timeline(timeline)


def build_monster_rows() -> list[dict[str, Any]]:
    effect_report = load_json("battle_monster_action_effect_review.json")
    rows: list[dict[str, Any]] = []
    for row in effect_report.get("rows") or []:
        timeline_summary = row.get("timelineSummary") or {}
        display_timeline = timeline_summary.get("displayTimeline") or {}
        alignment_class, alignment_label = monster_alignment(row)
        result_sounds = row.get("resultSounds") or []
        effect_sounds = row.get("effectSounds") or []
        wlk_calls = monster_wlk_calls(row)
        result_family_labels = row.get("resultFamilyLabels") or []
        hit_events = [
            compact_event(event)
            for event in display_timeline.get("events") or []
            if (event.get("type") or event.get("kind")) in {"result-sound", "actor-flag"}
        ]
        wlk_status = wlk_evidence(
            actor_kind="monster",
            wlk_calls=wlk_calls,
            result_family_labels=result_family_labels,
            hit_events=hit_events,
        )
        rows.append(
            {
                "key": f"monster:{row.get('key')}",
                "actorKind": "monster",
                "enemyName": row.get("enemyName"),
                "cns": row.get("cns"),
                "actorTableIdHex": row.get("actorTableIdHex"),
                "enemyStatIndex": row.get("enemyStatIndex"),
                "sharedActionIdHex": row.get("sharedActionIdHex"),
                "skillName": row.get("sharedActionName"),
                "visibleSlotHex": row.get("visibleSlotHex"),
                "displayPhaseHex": row.get("displayPhaseHex"),
                "displayVmStartVaHex": row.get("displayPointerVaHex"),
                "displayStartSource": "monster-descriptor-visible-slot",
                "descriptorKey": row.get("key"),
                "timelineSummary": row.get("timelineSummary") or {},
                "weightPercent": row.get("weightPercent"),
                "choiceWeightKind": row.get("choiceWeightKind"),
                "displayMatchStatus": row.get("displayMatchStatus"),
                "presentationClass": row.get("presentationClass"),
                "visualClass": row.get("visualClass"),
                "alignmentClass": alignment_class,
                "alignmentLabel": alignment_label,
                "frameSequence": row.get("frameSelectorSequence") or [],
                "frameEvents": [
                    compact_event(event)
                    for event in display_timeline.get("expandedLocalFrames") or []
                ],
                "durationGate": timeline_summary.get("expandedLocalDurationGate"),
                "movementEvents": [
                    compact_event(event)
                    for event in display_timeline.get("events") or []
                    if (event.get("type") or event.get("kind")) in {"movement", "position-write", "actor-write"}
                ],
                "hitEvents": hit_events,
                "resultSounds": result_sounds,
                "effectSounds": effect_sounds,
                "wlkCalls": wlk_calls,
                **wlk_status,
                "helperIds": [
                    helper.get("helperId")
                    for helper in row.get("helpers") or []
                    if isinstance(helper.get("helperId"), int)
                ],
                "helpers": row.get("helpers") or [],
                "helperCalls": row.get("helpers") or [],
                "repeatLoops": [
                    compact_event(event)
                    for event in display_timeline.get("events") or []
                    if (event.get("type") or event.get("kind")) == "repeat-loop"
                ],
                "targetScopeLabels": row.get("targetScopeLabels") or [],
                "resultFamilyLabels": result_family_labels,
                "statusLabels": row.get("statusLabels") or [],
                "coefficientTriples": row.get("coefficientTriples") or [],
            }
        )
    return rows


def build() -> dict[str, Any]:
    player_rows = build_player_rows()
    monster_rows = build_monster_rows()
    all_rows = player_rows + monster_rows
    alignment_counts = Counter(row["alignmentClass"] for row in all_rows)
    wlk_evidence_counts = Counter(row.get("wlkEvidenceStatus") for row in all_rows)
    player_timeline_status_counts = Counter(row.get("timelineStatus") for row in player_rows)
    alignment_by_actor = {
        actor: dict(Counter(row["alignmentClass"] for row in all_rows if row["actorKind"] == actor))
        for actor in ("player", "monster")
    }
    canonical_rows_ready = (
        alignment_counts.get("display-table-review", 0) == 0
        and alignment_counts.get("unconfirmed", 0) == 0
        and player_timeline_status_counts.get("needs-review", 0) == 0
    )
    return {
        "version": 1,
        "kind": "hwanse-battle-skill-timeline-canonical",
        "status": "canonical-exe-evidence-bundle",
        "runtimeUsed": False,
        "source": [
            "out/battle_action_mapping.json",
            "out/battle_action_event_timeline_review.json",
            "out/battle_skill_timeline_status.json",
            "out/battle_monster_action_effect_review.json",
        ],
        "classificationLegend": {
            "visual-confirmed": "시각 정렬 확정: named branch-family/override 또는 monster descriptor visible-slot 근거가 display VM과 연결됨.",
            "start-confirmed": "시작점 확정: per-actor phase display table에서 직접 얻은 EXE display VM 시작점. family/override보다 약한 등급이지만 시작점 자체는 확정.",
            "display-table-review": "표시 검토 필요: VM stream은 있지만 named family start보다 약한 근거라 시각 검토 대상.",
            "unconfirmed": "미확정: display VM, frame/gate, helper, 또는 hit/WLK 근거가 부족함.",
        },
        "summary": {
            "playerRows": len(player_rows),
            "monsterRows": len(monster_rows),
            "totalRows": len(all_rows),
            "alignmentCounts": dict(alignment_counts),
            "alignmentByActorKind": alignment_by_actor,
            "playerTimelineStatusCounts": dict(player_timeline_status_counts),
            "canonicalRowsReady": canonical_rows_ready,
            "goalReady": False,
            "goalReadyReason": (
                "Deprecated: this bundle only proves canonical EXE timeline rows. "
                "Goal completion additionally requires browser playback regression "
                "checks for actor frames, helper effects, sounds, and shared page binding."
            ),
            "playerVisualAlignmentStatusCounts": dict(Counter(row.get("visualAlignmentStatus") for row in player_rows)),
            "monsterPresentationClassCounts": dict(Counter(row.get("presentationClass") for row in monster_rows)),
            "rowsWithFrames": sum(1 for row in all_rows if row.get("frameEvents")),
            "rowsWithWlkCalls": sum(1 for row in all_rows if row.get("wlkCalls")),
            "wlkEvidenceStatusCounts": dict(wlk_evidence_counts),
            "wlkEvidenceUnresolvedRows": sum(1 for row in all_rows if row.get("wlkEvidenceUnresolved")),
            "rowsWithMovement": sum(1 for row in all_rows if row.get("movementEvents")),
            "rowsWithHelpers": sum(1 for row in all_rows if row.get("helperIds")),
        },
        "notes": [
            "This is the active canonical battle timeline evidence bundle for browser pages.",
            "It separates execution timeline confirmation from visual alignment strength.",
            "Manual observation can be compared against this bundle, but should not override EXE-derived fields in active pages.",
            "Effect animation high-level behavior is intentionally not promoted here unless it is represented by existing helper/timeline reports.",
            "Rows without direct display-VM WLK are classified as support/silent rows or common battle result-sound rows, instead of being left ambiguous.",
            "canonicalRowsReady means EXE timeline rows are matched; it is not the goal-completion gate.",
            "goalReady is intentionally false here because browser playback/effect regression checks live outside this data bundle.",
        ],
        "playerActions": player_rows,
        "monsterActions": monster_rows,
    }


def main() -> None:
    report = build()
    dump_json(OUTPUT_JSON, report)
    OUTPUT_JS.write_text(
        "window.HWANSE_BATTLE_SKILL_TIMELINE_CANONICAL = "
        + json.dumps(report, ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )
    print(f"wrote {OUTPUT_JSON}")
    print(f"wrote {OUTPUT_JS}")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
