#!/usr/bin/env python3
"""Trace the battle hit/damage calculation layer from EXE evidence.

This report starts from the static EXE path:

    action opcode a6 -> 0x433f0e(attacker, target)

The goal is not to claim a complete combat engine yet.  It separates the parts
that are now strongly evidenced from the parts that still need a controlled
runtime sample such as miss, critical, status success/failure, or recovery.
"""
from __future__ import annotations

import html
import json
import re
import struct
import subprocess
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
RESULT_LAYER = OUT / "battle_result_layer_trace_review.json"
ACTION_MAPPING = OUT / "battle_action_mapping.json"

IMAGE_BASE = 0x00400000
FAMILY_TABLE_VA = 0x00546970


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


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


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}


def pe_sections(blob: bytes) -> tuple[int, list[dict[str, int | str]]]:
    pe_offset = struct.unpack_from("<I", blob, 0x3C)[0]
    opt_size = struct.unpack_from("<H", blob, pe_offset + 20)[0]
    image_base = struct.unpack_from("<I", blob, pe_offset + 52)[0]
    section_count = struct.unpack_from("<H", blob, pe_offset + 6)[0]
    section_base = pe_offset + 24 + opt_size
    sections: list[dict[str, int | str]] = []
    for index in range(section_count):
        offset = section_base + index * 40
        name = blob[offset : offset + 8].rstrip(b"\0").decode("ascii", "replace")
        virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from("<IIII", blob, offset + 8)
        sections.append(
            {
                "name": name,
                "virtualAddress": virtual_address,
                "virtualSize": virtual_size,
                "rawSize": raw_size,
                "rawPtr": raw_ptr,
            }
        )
    return image_base, sections


def va_to_offset(va: int, sections: list[dict[str, int | str]], image_base: int = IMAGE_BASE) -> int | None:
    rva = va - image_base
    for section in sections:
        start = int(section["virtualAddress"])
        span = max(int(section["virtualSize"]), int(section["rawSize"]))
        if start <= rva < start + span:
            return int(section["rawPtr"]) + (rva - start)
    return None


def read_u32(blob: bytes, sections: list[dict[str, int | str]], va: int) -> int:
    offset = va_to_offset(va, sections)
    if offset is None:
        raise ValueError(f"cannot map VA {hex32(va)}")
    return struct.unpack_from("<I", blob, offset)[0]


def disassemble(start_va: int, stop_va: int) -> str:
    try:
        result = subprocess.run(
            [
                "objdump",
                "-Mintel",
                "-D",
                "-b",
                "pei-i386",
                f"--start-address=0x{start_va:08x}",
                f"--stop-address=0x{stop_va:08x}",
                str(EXE),
            ],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        return f"; disassembly unavailable: {exc}"
    return "\n".join(line for line in result.stdout.splitlines() if re.match(r"\s*[0-9a-f]{6,8}:", line))


def extract_refs(disasm: str) -> dict[str, Any]:
    calls = []
    for line in disasm.splitlines():
        match = re.search(r"\bcall\s+(?:DWORD PTR )?(?:.*?0x)?([0-9a-f]{6,8})\b", line)
        if match:
            calls.append({"targetVaHex": f"0x{int(match.group(1), 16):08x}", "line": line.strip()})
    offsets = sorted(set(int(match.group(1), 16) for match in re.finditer(r"\+0x([0-9a-f]+)\]", disasm)))
    data_refs = sorted(set(int(match.group(1), 16) for match in re.finditer(r"0x(59e2a4|4d2488|4d2494|546970|546a38|48bcaa|59db30|59e34d)", disasm)))
    flag_ops = []
    for line in disasm.splitlines():
        if "[eax+0x62]" in line or "[ecx+0x62]" in line:
            if "0x" in line or "and" in line or "or" in line or "test" in line:
                flag_ops.append(line.strip())
    return {
        "calls": calls,
        "actorOffsetsHex": [f"+0x{offset:02x}" for offset in offsets],
        "dataRefsHex": [f"0x{ref:08x}" for ref in data_refs],
        "flagOps": flag_ops[:16],
        "lineCount": len([line for line in disasm.splitlines() if line.strip()]),
    }


def parse_unit(unit_hex: str) -> dict[str, Any]:
    values: list[int] = []
    for part in str(unit_hex).split():
        try:
            values.append(int(part, 16))
        except ValueError:
            values.append(0)
    while len(values) < 8:
        values.append(0)
    return {
        "raw": unit_hex,
        "power0": values[0],
        "power1": values[1],
        "power2": values[2],
        "reserved3": values[3],
        "targetScope": values[4],
        "family": values[5],
        "hitClass": values[6],
        "status": values[7],
    }


STATUS_LABELS = {
    0x00: "none",
    0x01: "넘어짐",
    0x02: "휙날아감",
    0x03: "행동정지",
    0x04: "독",
    0x05: "마비",
    0x06: "졸림",
}


FUNCTION_SPECS = [
    {
        "va": 0x00433A30,
        "end": 0x00433ACA,
        "label": "target eligibility gate",
        "meaning": "a6가 대상에게 0x433f0e를 호출하기 전에 쓰는 대상 가능 여부 검사. target +0x62 bit 0x80과 attacker +0x58/+0x59를 본다.",
        "confidence": "confirmed path helper",
    },
    {
        "va": 0x00433F0E,
        "end": 0x004340AE,
        "label": "hit unit loader and family dispatcher",
        "meaning": "skill payload를 찾고 hitIndex(0x0059e2a4)로 8-byte hit unit을 고른다. unit byte0..2를 attacker +0x80/+0x82/+0x84와 곱해 +0x70/+0x72/+0x74에 저장하고, family table 0x00546970으로 분기한다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x004340AE,
        "end": 0x0043414D,
        "label": "target result mode dispatcher",
        "meaning": "target +0x6d가 0이면 0x4353f8(HP 감소) 후 0x434e84(상태/피격 자세), 2이면 0x435295(HP 회복), 1이면 적용 없이 종료한다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x0043414D,
        "end": 0x00434218,
        "label": "common physical family coordinator",
        "meaning": "family 0x10..0x19의 공통 진입점. result value를 초기화하고, branch flag 0x20/0x08, alt-result flag 0x10, status byte +0x6c, damage routine을 순서대로 처리한다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x00434218,
        "end": 0x004343DB,
        "label": "hit/avoid branch flag test",
        "meaning": "attacker/target 전투 수치와 random(0x427730)을 비교해 target +0x62 bit 0x20 또는 0x08을 세운다. unit byte[6]을 target +0x36+index 명중 보정 테이블에 사용한다. 표시 분기는 full miss / guard-glancing chip result로 승격됐다.",
        "confidence": "confirmed hit/avoid branch",
    },
    {
        "va": 0x004343DB,
        "end": 0x004344AB,
        "label": "alternate result / critical flag gate",
        "meaning": "성공 시 attacker +0x62 bit 0x10을 세운다. 0xc2 handler가 이 비트로 normal/alt WLK operand를 고르고, user observation의 critical presentation(텍스트 없음, 섬광/전용음/높은 데미지)과 일치한다.",
        "confidence": "static critical/alternate path grounded",
    },
    {
        "va": 0x004344AB,
        "end": 0x0043461F,
        "label": "status success gate",
        "meaning": "payload status byte target +0x6c가 있으면 attacker/target 수치 및 target status resistance table(+0x39+status)을 비교한다. 실패하면 target +0x6c를 0으로 지운다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x0043461F,
        "end": 0x00434737,
        "label": "normal damage formula",
        "meaning": "attacker +0x70, +0x1c, target +0x7a, +0x20, target resistance table(+0x1c+family) 등을 사용해 temporary result +0x6e를 만든다. 최소값 보정 random(1..3)이 있다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x00434737,
        "end": 0x0043485E,
        "label": "alternate/critical damage formula",
        "meaning": "normal damage와 유사하지만 계산 비율이 다르고, 낮은 결과에서 random(1..5) 보정 후 attacker +0x62 bit 0x10을 끈다. 0x10 result sound/helper 소비와 연결되는 critical/alternate damage formula다.",
        "confidence": "static critical/alternate formula grounded",
    },
    {
        "va": 0x00434E84,
        "end": 0x0043512E,
        "label": "target state/pose display after damage",
        "meaning": "target +0x6c 상태 byte와 +0x62 flags를 보고 target +0x2a pose, +0x64 timer, +0x68 display update flag를 설정한다.",
        "confidence": "strong static evidence",
    },
    {
        "va": 0x00435295,
        "end": 0x00435356,
        "label": "HP recovery apply",
        "meaning": "target +0x08 current HP를 +0x0a max HP까지 증가시키고, trace mode에서는 applied value를 +0x6e에 쓴다.",
        "confidence": "confirmed by field writes",
    },
    {
        "va": 0x00435356,
        "end": 0x004353F8,
        "label": "MP recovery apply",
        "meaning": "target +0x0e current MP를 +0x10 max MP까지 증가시키고, trace mode에서는 applied value를 +0x6e에 쓴다.",
        "confidence": "confirmed by field writes",
    },
    {
        "va": 0x004353F8,
        "end": 0x004354EB,
        "label": "HP damage apply",
        "meaning": "target +0x08 current HP에서 result를 빼고, 0 이하가 되면 HP 0, target +0x62 bit 0x40, death/knockout cleanup 0x4354eb를 호출한다.",
        "confidence": "confirmed by field writes",
    },
]


def family_label(family: int, handler: int) -> str:
    if family == 0x01:
        return "simple flag set family"
    if 0x10 <= family <= 0x19:
        return "common physical damage family"
    if family in {0x20, 0x21, 0x23}:
        return "recovery-like family: HP/MP branch by family"
    if family == 0x22:
        return "pose/state reset family"
    if 0x24 <= family <= 0x2C:
        return "character mode/buff gauge family"
    if family == 0x2D:
        return "pose cycle family"
    if family == 0x2E:
        return "random pose/timer family"
    if family == 0x2F:
        return "clear fields +0x2f..+0x31"
    if family >= 0x32:
        return "small special handler family"
    if handler == 0x004357D0:
        return "default/no-op or shared fallback"
    return "unclassified family"


def build_family_rows(blob: bytes, sections: list[dict[str, int | str]], mapping: dict[str, Any]) -> list[dict[str, Any]]:
    samples: defaultdict[int, list[str]] = defaultdict(list)
    statuses: defaultdict[int, Counter[int]] = defaultdict(Counter)
    scopes: defaultdict[int, Counter[int]] = defaultdict(Counter)
    powers: defaultdict[int, list[tuple[int, int, int]]] = defaultdict(list)
    rows = (mapping.get("playerRows") or []) + (mapping.get("sharedRows") or [])
    for row in rows:
        for unit_hex in row.get("unitsHex") or []:
            unit = parse_unit(unit_hex)
            family = unit["family"]
            statuses[family][unit["status"]] += 1
            scopes[family][unit["targetScope"]] += 1
            if len(samples[family]) < 8:
                samples[family].append(f"{row.get('ownerName')} {row.get('name')} {row.get('skillIdHex')} :: {unit_hex}")
            if len(powers[family]) < 12:
                powers[family].append((unit["power0"], unit["power1"], unit["power2"]))

    family_rows = []
    for family in range(0x40):
        handler = read_u32(blob, sections, FAMILY_TABLE_VA + family * 4)
        status_labels = [
            f"0x{status:02x} {STATUS_LABELS.get(status, 'unknown')} x{count}"
            for status, count in sorted(statuses[family].items())
        ]
        scope_labels = [f"0x{scope:02x} x{count}" for scope, count in sorted(scopes[family].items())]
        family_rows.append(
            {
                "family": family,
                "familyHex": f"0x{family:02x}",
                "handlerVa": handler,
                "handlerVaHex": hex32(handler),
                "label": family_label(family, handler),
                "sampleCount": sum(statuses[family].values()),
                "statuses": status_labels,
                "targetScopes": scope_labels,
                "powerSamples": [f"{a}/{b}/{c}" for a, b, c in powers[family][:6]],
                "samples": samples[family],
            }
        )
    return family_rows


def function_rows() -> list[dict[str, Any]]:
    rows = []
    for spec in FUNCTION_SPECS:
        disasm = disassemble(spec["va"], spec["end"])
        refs = extract_refs(disasm)
        excerpt_lines = disasm.splitlines()
        # Keep focused excerpts: first and any lines with key fields/calls.
        key_lines = []
        for line in excerpt_lines:
            if any(token in line for token in ["0x6e", "0x6d", "0x6c", "0x6b", "0x62", "0x546970", "0x4d2488", "0x4d2494", "0x59e2a4", "call"]):
                key_lines.append(line)
        rows.append(
            {
                **spec,
                "vaHex": hex32(spec["va"]),
                "endHex": hex32(spec["end"]),
                "refs": refs,
                "excerpt": "\n".join((excerpt_lines[:8] + ["..."] + key_lines[:42]) if len(excerpt_lines) > 8 else excerpt_lines),
            }
        )
    return rows


def unit_summary(mapping: dict[str, Any]) -> dict[str, Any]:
    rows = (mapping.get("playerRows") or []) + (mapping.get("sharedRows") or [])
    unit_count = 0
    family_counter: Counter[int] = Counter()
    status_counter: Counter[int] = Counter()
    scope_counter: Counter[int] = Counter()
    hit_class_counter: Counter[int] = Counter()
    power_triples: Counter[tuple[int, int, int]] = Counter()
    examples = []
    for row in rows:
        for unit_hex in row.get("unitsHex") or []:
            unit = parse_unit(unit_hex)
            unit_count += 1
            family_counter[unit["family"]] += 1
            status_counter[unit["status"]] += 1
            scope_counter[unit["targetScope"]] += 1
            hit_class_counter[unit["hitClass"]] += 1
            power_triples[(unit["power0"], unit["power1"], unit["power2"])] += 1
            if len(examples) < 12:
                examples.append(
                    {
                        "owner": row.get("ownerName"),
                        "skill": row.get("name"),
                        "skillIdHex": row.get("skillIdHex"),
                        "unit": unit_hex,
                        "familyHex": f"0x{unit['family']:02x}",
                        "statusHex": f"0x{unit['status']:02x}",
                        "targetScopeHex": f"0x{unit['targetScope']:02x}",
                        "hitClassHex": f"0x{unit['hitClass']:02x}",
                    }
                )
    return {
        "skillRecordCount": len(rows),
        "unitCount": unit_count,
        "familyCounts": {f"0x{k:02x}": v for k, v in family_counter.most_common()},
        "statusCounts": {f"0x{k:02x} {STATUS_LABELS.get(k, 'unknown')}": v for k, v in status_counter.most_common()},
        "targetScopeCounts": {f"0x{k:02x}": v for k, v in scope_counter.most_common()},
        "hitClassCounts": {f"0x{k:02x}": v for k, v in hit_class_counter.most_common()},
        "commonPowerTriples": [{"triple": f"{a}/{b}/{c}", "count": c} for (a, b, c), c in power_triples.most_common(16)],
        "examples": examples,
    }


def build() -> dict[str, Any]:
    blob = EXE.read_bytes()
    image_base, sections = pe_sections(blob)
    mapping = read_json(ACTION_MAPPING)
    result_layer = read_json(RESULT_LAYER)
    family_rows = build_family_rows(blob, sections, mapping)
    funcs = function_rows()

    handler_counts = Counter(row["handlerVaHex"] for row in family_rows)
    family_groups = []
    for handler_hex, count in handler_counts.most_common():
        families = [row["familyHex"] for row in family_rows if row["handlerVaHex"] == handler_hex]
        labels = sorted({row["label"] for row in family_rows if row["handlerVaHex"] == handler_hex})
        family_groups.append({"handlerVaHex": handler_hex, "count": count, "families": families, "labels": labels})

    data = {
        "version": 1,
        "kind": "hwanse-battle-damage-formula-trace-review",
        "source": str(Path(__file__).relative_to(ROOT)),
        "status": "partial-formula-trace",
        "exe": {
            "path": str(EXE.relative_to(ROOT)),
            "imageBaseHex": hex32(image_base),
            "familyTableVaHex": hex32(FAMILY_TABLE_VA),
        },
        "summary": {
            "confirmed": [
                "0x433f0e가 hit unit payload를 선택하고 unit byte 0..2/5/7을 전투 결과 필드로 넘기는 지점이다.",
                "hit unit byte[3]은 현재 추출된 모든 unit에서 0x00으로 고정되어 reserved/unused로 둔다.",
                "hit unit byte[4]는 target scope id다. 0x01 self/setup, 0x05 all allies, 0x06 all enemies, 0x09 one ally, 0x0a one enemy로 분리된다.",
                "0x433649는 payload hit-unit byte[4]를 읽고, 0x433545/0x4335c6은 이를 runtime actor +0x67 target selector에 쓴다.",
                "0x40fa6d는 target scope bit 0x08로 selected-target branch를 가르고, 0x40f57c action setup은 selected target latch 0x0059e347을 actor +0x61로 복사한다.",
                "0x433c99/0x433dc0은 actor +0x67 low nibble과 +0x61 selected target index로 target loop의 start/end actor index를 계산하고, 0x435538은 같은 selector로 hit-unit target loop를 돈다.",
                "hit unit byte[6]은 hit/avoid class index다. 0x4342e8..0x4342ee가 unit[6]을 읽어 target +0x36+index 명중 보정 테이블을 참조한다.",
                "byte[6] 0..3은 내부 명중 판정 class로 승격한다. 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수 판정이다.",
                "actor row +0x28..+0x2b는 target +0x36..+0x39 hit/avoid class table과 같은 계열의 전투 보정 테이블로 쓰인다.",
                "0x00546970은 hit unit family byte로 인덱싱되는 family handler table이다.",
                "0x4340ae는 target +0x6d result mode dispatcher이다. mode 0은 HP damage, mode 2는 HP recovery로 분기한다.",
                "0x4353f8은 target +0x08 current HP 감소 함수, 0x435295는 target +0x08 HP 회복 함수, 0x435356은 MP 회복 함수로 확인된다.",
                "payload status byte target +0x6c는 0x4344ab에서 성공/실패 검사를 거쳐 실패 시 0으로 지워진다.",
                "target +0x62 bit 0x20/0x08의 표시 분기는 battle_result_display_branch_review와 battle_result_helper_label_review에서 각각 full miss / guard-glancing chip result로 확정됐다.",
                "target +0x6e result value는 battle_damage_digit_display_review에서 btl_etc F54..F63 숫자 표시 입력으로 확정됐다.",
                "attacker +0x62 bit 0x10은 stat/random gate로 세워지는 alternate result bit이며, alternate damage formula와 0xc2 alt result sound/helper를 선택한 뒤 소비 시 지워진다.",
                "actor +0x28은 0x4343db에서 attacker-side RNG range/base로 쓰여 +0x62 bit 0x10 critical/alternate 판정을 만들고, 0x4344ab 상태 성공 gate에서도 attacker-side 계수로 참여한다. 상태창/장비/레벨업 운 경로와 분리된 숨은 전투 발동률 계수다.",
                "0x40ef16 result consumer는 +0x10일 때 stream +3 WLK를 읽고 helper runner 0x402321 / child script table [0x442da1+0x150] 경로를 사용한 뒤 +0x10을 지운다.",
                "battle_stat_flag_status_semantics_review에서 actor +0x1c/+0x20/+0x22/+0x24/+0x26은 공격력/방어력/기술력/순발력/운으로 승격됐다.",
                "critical은 별도 텍스트가 아니라 섬광·전용 사운드·높은 데미지로 인지된다는 수동 관찰이 있고, 이는 0x10의 alternate damage/result sound/helper 경로와 맞다.",
            ],
            "notYetConfirmed": [
                "critical alternate WLK id 14와 palette flash/fade child script는 확인됐다. 남은 것은 브라우저 러너에서 VM tick을 wall-clock ms로 환산하는 미세 타이밍이다.",
            ],
            "familyHandlerUniqueCount": len(handler_counts),
            "familyRows": len(family_rows),
            "functionRows": len(funcs),
        },
        "pipeline": [
            "a6 target loop",
            "0x433a30 target eligibility",
            "0x433f0e hit unit select: payload + 0x16 + hitIndex*8",
            "unit byte0..2 × attacker +0x80/+0x82/+0x84 -> attacker +0x70/+0x72/+0x74",
            "unit byte3 -> reserved/unused constant 0x00 in current records",
            "unit byte4 -> 0x433649 -> actor +0x67 target selector; selected target latch 0x0059e347 -> actor +0x61",
            "unit byte5 -> target +0x6b family -> 0x00546970[family]",
            "unit byte6 -> hit/avoid class index used against runtime actor +0x36 table",
            "unit byte7 -> target +0x6c status effect id",
            "family handler computes result into temporary +0x6e and target +0x6d mode",
            "0x4340ae applies target result: damage/recover/display state",
            "target +0x68 display update spawns target result object script and 0xc2 result sound",
        ],
        "fieldEvidence": [
            {"offset": "+0x08", "role": "current HP", "evidence": "0x4353f8 subtracts from it; 0x435295 adds to it"},
            {"offset": "+0x0a", "role": "max HP", "evidence": "0x435295 clamps +0x08 to it"},
            {"offset": "+0x0e", "role": "current MP", "evidence": "0x435356 adds to it"},
            {"offset": "+0x10", "role": "max MP", "evidence": "0x435356 clamps +0x0e to it"},
            {"offset": "+0x28", "role": "숨은 상태/크리티컬 발동률 계수", "evidence": "0x4343db에서 attacker-side RNG range/base로 쓰이고 +0x62 bit 0x10 판정을 만든다. 0x4344ab 상태 성공 gate에서도 attacker-side 계수로 읽힌다. 상태창/장비/레벨업 운 경로에는 나타나지 않는다"},
            {"offset": "+0x58", "role": "special action family switch", "evidence": "0x433f0e alternate table 0x546a38 path if nonzero"},
            {"offset": "+0x59", "role": "skill id", "evidence": "0x433f0e payload table index"},
            {"offset": "+0x61", "role": "selected target actor index", "evidence": "0x40f65f copies selected target latch 0x0059e347 into actor +0x61; 0x433c99/0x433dc0/0x435538 consume it as a single-target range"},
            {"offset": "+0x62", "role": "battle result/status flags", "evidence": "0x20/0x08 branch flags, 0x10 alternate-result, 0x40 zero-HP/death, 0x80 unavailable/down state"},
            {"offset": "+0x68", "role": "display update flag", "evidence": "state/result functions set it to 1"},
            {"offset": "+0x6a", "role": "target last skill id", "evidence": "0x433f0e copies attacker +0x59 into target +0x6a"},
            {"offset": "+0x6b", "role": "hit family id", "evidence": "unit byte5 is copied here and indexes target resistance table"},
            {"offset": "+0x6c", "role": "status effect/result id", "evidence": "unit byte7 copied here; 0x4344ab may clear it when the status gate fails"},
            {"offset": "+0x6d", "role": "result apply mode", "evidence": "0x4340ae switch: 0 damage, 1 no apply, 2 recovery"},
            {"offset": "+0x6e", "role": "result value", "evidence": "runtime damage number; 0x4353f8/0x435295 consume it"},
            {"offset": "unit byte[4]", "role": "target scope id", "evidence": "0x433649 reads payload +0x16 + hitIndex*8 + 4, 0x433545/0x4335c6 write it to actor +0x67, 0x40fa6d uses bit 0x08 to branch into nested target-selection script"},
            {"offset": "unit byte[6]", "role": "hit/avoid class index", "evidence": "0x4342e8 reads unit[6], then 0x4342ee indexes target actor +0x36+unitByte6. Labels: 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수"},
            {"offset": "+0x70/+0x72/+0x74", "role": "unit-scaled attack components", "evidence": "unit bytes 0..2 multiply attacker +0x80/+0x82/+0x84"},
            {"offset": "+0x80/+0x82/+0x84", "role": "skill/context coefficients", "evidence": "source operands for unit byte0..2 scaling"},
        ],
        "unitSummary": unit_summary(mapping),
        "familyGroups": family_groups,
        "familyRows": family_rows,
        "functionRows": funcs,
        "staticEvidence": {
            "actionMappingSummary": mapping.get("summary", {}),
            "resultLayerSummary": result_layer.get("summary", {}),
        },
        "nextSteps": [
            "0x10 critical path의 WLK id 14 sound role과 palette flash/fade child script는 확정됐다. 남은 작업은 browser runner에서 VM tick 기반 섬광 지속시간을 wall-clock으로 맞추는 것이다.",
            "browser 전투 엔진 반영은 지금 단계에서 HP/MP apply와 payload family/status/critical presentation 구조를 넣고, +0x28은 숨은 전투 발동률 계수로 표기한다.",
        ],
    }
    return data


def write_md(data: dict[str, Any]) -> str:
    lines = [
        "# Battle Damage Formula Trace Review",
        "",
        "## 요약",
    ]
    for item in data["summary"]["confirmed"]:
        lines.append(f"- 확정: {item}")
    for item in data["summary"]["notYetConfirmed"]:
        lines.append(f"- 미확정: {item}")
    lines.extend(["", "## 파이프라인"])
    for item in data["pipeline"]:
        lines.append(f"- {item}")
    lines.extend(["", "## 필드 근거"])
    for row in data["fieldEvidence"]:
        lines.append(f"- `{row['offset']}`: {row['role']} - {row['evidence']}")
    lines.extend(["", "## Family Handler Groups"])
    for row in data["familyGroups"]:
        lines.append(f"- `{row['handlerVaHex']}`: {row['count']} families, {', '.join(row['families'])} / {', '.join(row['labels'])}")
    lines.extend(["", "## 주요 함수"])
    for row in data["functionRows"]:
        lines.append(f"- `{row['vaHex']}` {row['label']}: {row['meaning']}")
    lines.extend(["", "## 다음 작업"])
    for item in data["nextSteps"]:
        lines.append(f"- {item}")
    return "\n".join(lines) + "\n"


def write_html(data: dict[str, Any]) -> str:
    css = """
    :root{color-scheme:light dark;--bg:#f6f3ee;--panel:#fffaf2;--ink:#241f1a;--muted:#6c6257;--line:#d8cdbc;--accent:#2d6b57;--warn:#a25b13;--code:#17130f}
    @media (prefers-color-scheme: dark){:root{--bg:#171513;--panel:#211d19;--ink:#f1e8db;--muted:#b9aa97;--line:#40382f;--accent:#78c6a3;--warn:#f0b15f;--code:#f7ead8}}
    body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}
    header{padding:24px 28px 12px} h1{margin:0 0 8px;font-size:28px} h2{margin:0 0 12px;font-size:19px}
    main{padding:0 28px 32px;display:grid;gap:18px}.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px;box-shadow:0 1px 1px #0001}
    nav a{margin-right:12px;color:var(--accent);font-weight:700;text-decoration:none}.muted{color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
    table{width:100%;border-collapse:collapse;background:var(--panel)}th,td{border:1px solid var(--line);padding:7px 8px;vertical-align:top}th{position:sticky;top:0;background:var(--panel);z-index:1;text-align:left}
    code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}pre{margin:0;white-space:pre-wrap;max-height:360px;overflow:auto;background:var(--code);color:#f9ead2;padding:10px;border-radius:6px}
    .pill{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:2px 8px;margin:2px;background:#00000008}.warn{color:var(--warn);font-weight:700}.ok{color:var(--accent);font-weight:700}
    details{border:1px solid var(--line);border-radius:6px;padding:8px;margin:8px 0}summary{cursor:pointer;font-weight:700}
    """
    confirmed = "".join(f"<li><span class='ok'>확정</span> {esc(item)}</li>" for item in data["summary"]["confirmed"])
    pending = "".join(f"<li><span class='warn'>미확정</span> {esc(item)}</li>" for item in data["summary"]["notYetConfirmed"])
    pipeline = "".join(f"<li>{esc(item)}</li>" for item in data["pipeline"])
    fields = "".join(
        f"<tr><td><code>{esc(row['offset'])}</code></td><td>{esc(row['role'])}</td><td>{esc(row['evidence'])}</td></tr>"
        for row in data["fieldEvidence"]
    )
    unit = data["unitSummary"]
    unit_summary = "".join(
        f"<span class='pill'>{esc(key)}: {esc(value)}</span>"
        for key, value in list(unit["familyCounts"].items())[:24]
    )
    status_summary = "".join(f"<span class='pill'>{esc(key)}: {esc(value)}</span>" for key, value in unit["statusCounts"].items())
    target_scope_summary = "".join(f"<span class='pill'>{esc(key)}: {esc(value)}</span>" for key, value in unit["targetScopeCounts"].items())
    hit_class_summary = "".join(f"<span class='pill'>{esc(key)}: {esc(value)}</span>" for key, value in unit["hitClassCounts"].items())
    family_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['familyHex'])}</code></td>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td>{esc(row['label'])}</td>"
        f"<td>{esc(row['sampleCount'])}</td>"
        f"<td>{'<br>'.join(esc(x) for x in row['statuses'])}</td>"
        f"<td>{'<br>'.join(esc(x) for x in row['targetScopes'])}</td>"
        f"<td>{'<br>'.join(esc(x) for x in row['samples'][:5])}</td>"
        "</tr>"
        for row in data["familyRows"]
    )
    function_cards = []
    for row in data["functionRows"]:
        calls = "".join(f"<li><code>{esc(call['targetVaHex'])}</code> {esc(call['line'])}</li>" for call in row["refs"]["calls"][:10]) or "<li>-</li>"
        offsets = " ".join(f"<span class='pill'>{esc(offset)}</span>" for offset in row["refs"]["actorOffsetsHex"])
        data_refs = " ".join(f"<span class='pill'>{esc(ref)}</span>" for ref in row["refs"]["dataRefsHex"])
        flag_ops = "".join(f"<li><code>{esc(line)}</code></li>" for line in row["refs"]["flagOps"]) or "<li>-</li>"
        function_cards.append(
            f"""
            <details>
              <summary><code>{esc(row['vaHex'])}</code> {esc(row['label'])} <span class="muted">({esc(row['confidence'])})</span></summary>
              <p>{esc(row['meaning'])}</p>
              <p><b>offsets</b> {offsets or '-'}</p>
              <p><b>data refs</b> {data_refs or '-'}</p>
              <p><b>calls</b></p><ul>{calls}</ul>
              <p><b>flag ops</b></p><ul>{flag_ops}</ul>
              <pre>{esc(row['excerpt'])}</pre>
            </details>
            """
        )
    groups = "".join(
        f"<tr><td><code>{esc(row['handlerVaHex'])}</code></td><td>{esc(row['count'])}</td><td>{', '.join(esc(x) for x in row['families'])}</td><td>{'<br>'.join(esc(x) for x in row['labels'])}</td></tr>"
        for row in data["familyGroups"]
    )
    next_steps = "".join(f"<li>{esc(item)}</li>" for item in data["nextSteps"])
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>전투 데미지/명중 계산 추적</title>
  <style>{css}</style>
</head>
<body>
  <header>
    <h1>전투 데미지/명중 계산 추적</h1>
    <p class="muted">EXE 정적 디스어셈블과 전투 payload 테이블 근거로, 데미지 계산으로 들어가는 함수 흐름을 분리한 리뷰입니다.</p>
    <nav>
      <a href="../web/index.html">홈</a>
      <a href="../web/battle_simulator.html">전투 기술 실행</a>
      <a href="battle_result_layer_trace_review.html">결과 레이어</a>
      <a href="battle_damage_formula_trace_review.json">JSON</a>
      <a href="battle_damage_formula_trace_review.md">MD</a>
    </nav>
  </header>
  <main>
    <section class="card">
      <h2>판정</h2>
      <div class="grid">
        <div><ul>{confirmed}</ul></div>
        <div><ul>{pending}</ul></div>
      </div>
    </section>
    <section class="card">
      <h2>계산 파이프라인</h2>
      <ol>{pipeline}</ol>
    </section>
    <section class="card">
      <h2>Actor/Target 필드 근거</h2>
      <table><thead><tr><th>offset</th><th>역할</th><th>근거</th></tr></thead><tbody>{fields}</tbody></table>
    </section>
    <section class="card">
      <h2>Payload Unit 요약</h2>
      <p>기술 레코드 {esc(unit['skillRecordCount'])}개, hit unit {esc(unit['unitCount'])}개.</p>
      <p><b>family</b> {unit_summary}</p>
      <p><b>status</b> {status_summary}</p>
      <p><b>target scope byte[4]</b> {target_scope_summary}</p>
      <p><b>hit/avoid class byte[6]</b> {hit_class_summary}</p>
    </section>
    <section class="card">
      <h2>Family Handler 그룹</h2>
      <table><thead><tr><th>handler</th><th>개수</th><th>families</th><th>해석</th></tr></thead><tbody>{groups}</tbody></table>
    </section>
    <section class="card">
      <h2>Family Table 상세</h2>
      <table><thead><tr><th>family</th><th>handler</th><th>분류</th><th>unit 수</th><th>status</th><th>target scope</th><th>샘플</th></tr></thead><tbody>{family_rows}</tbody></table>
    </section>
    <section class="card">
      <h2>주요 함수</h2>
      {''.join(function_cards)}
    </section>
    <section class="card">
      <h2>다음 작업</h2>
      <ul>{next_steps}</ul>
    </section>
  </main>
</body>
</html>
"""


def main() -> None:
    data = build()
    OUT.mkdir(exist_ok=True)
    (OUT / "battle_damage_formula_trace_review.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    (OUT / "battle_damage_formula_trace_review.md").write_text(write_md(data), encoding="utf-8")
    (OUT / "battle_damage_formula_trace_review.html").write_text(write_html(data), encoding="utf-8")


if __name__ == "__main__":
    main()
