#!/usr/bin/env python3
"""Build a review for battle hit-unit result family dispatch.

The action payload records contain 8-byte hit units.  Byte 5 of each unit is
not an animation id: routine 0x00433f0e copies it to target actor +0x6b and
uses it as an index into the dispatch table at 0x00546970.  This report keeps
that layer separate from frame/effect helpers and documents the result-family
meaning currently proven from the EXE.
"""
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

from probe_exe_scene_tables import read_sections, va_to_offset


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

RESULT_TABLE_VA = 0x00546970
RESULT_TABLE_ENTRY_COUNT = 50
SPECIAL_TABLE_VA = 0x00546A38
SPECIAL_TABLE_ENTRY_COUNT = 6


ROLE_BY_FUNCTION = {
    0x00000000: "zero/table boundary",
    0x004357D0: "no-op/default result family",
    0x0043485E: "sets actor +0x62 bit 0x04; flow fan-out request",
    0x0043414D: "common damage/status formula path",
    0x0043487A: "HP recovery / revive-style apply path",
    0x0043497D: "timed-status recovery/special recovery path",
    0x00434A4A: "Ataho drunk accumulator / drunk-state promotion path",
    0x00434CBB: "Smash eye-candy state progression path",
    0x00434D8D: "Rinshan taunt randomized state path",
    0x00434E29: "Sukyeong element-guard reset path",
    0x004357DB: "actor +0x58 special handler table entry 0",
    0x004357E6: "actor +0x58 special handler table entry 1",
    0x00435810: "actor +0x58 special handler table entry 2",
    0x0043583C: "actor +0x58 special handler table entry 3",
    0x004358AB: "actor +0x58 special handler table entry 4",
    0x004358D5: "actor +0x58 special handler table entry 5",
}


FAMILY_NOTES = {
    1: "Only 도주 uses this family in the current action records. It sets +0x62 bit 0x04, then script opcode 0x40dcf7 fans out +0x5b/+0x5c/+0x60 and global 0x59e334=2.",
    2: "방어-only family in current records. The result dispatcher is intentionally no-op; defensive behavior is carried by command/action-layer state, not by a result apply routine.",
    3: "인법·몸감추기-only family in current records. The result dispatcher is intentionally no-op; visual/state behavior is carried outside result-family apply.",
    16: "Start of common damage/status result families. These call the shared formula path 0x43414d.",
    17: "Common damage/status result family.",
    18: "Common damage/status result family.",
    19: "Common damage/status result family.",
    20: "Common damage/status result family.",
    21: "Common damage/status result family.",
    22: "Common damage/status result family.",
    23: "Common damage/status result family.",
    24: "Common damage/status result family.",
    25: "Common damage/status result family.",
    32: "Recovery/apply family via 0x43487a.",
    33: "Recovery/apply family via 0x43487a.",
    34: "Special recovery/timed-status path via 0x43497d.",
    35: "Recovery/apply family via 0x43487a.",
    36: "아타호 취기 누적 family. 0x434a4a class 0x24: random(8)+1 repeated 2 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    37: "아타호 취기 누적 family. 0x434a4a class 0x25: random(0x20)+1 repeated 1 time, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    38: "아타호 취기 누적 family. 0x434a4a class 0x26: random(8)+1 repeated 4 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    39: "아타호 취기 누적 family. 0x434a4a class 0x27: random(0x0c)+1 repeated 4 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    40: "아타호 취기 누적 family. 0x434a4a class 0x28: random(0x20)+1 repeated 2 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    41: "아타호 취기 누적 family. 0x434a4a class 0x29: random(0x14)+1 repeated 4 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    42: "아타호 취기 누적 family. 0x434a4a class 0x2a: random(0x0c)+1 repeated 8 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    43: "아타호 취기 누적 family. 0x434a4a class 0x2b: random(0x20)+1 repeated 4 times. EXE table에는 있으나 grounded player/shared action records 281개에서는 참조되지 않는 table-only entry다.",
    44: "아타호 취기 누적 family. 0x434a4a class 0x2c: random(0x14)+1 repeated 8 times, 취기 +0x65와 주량 경험치 +0x1a 누적.",
    45: "스마슈 눈요기 family. +0x2a 11..14를 진행시키고 14 이후 15 푸쉬 + timed lock(+0x62 bit 0x02)을 건다.",
    46: "린샹 도발 family. random(4)+0x10으로 16..19를 고르고, 현재 상태와 같은 값이 다시 나오면 20 피곤함 + timed lock을 건다.",
    47: "수경 family. +0x2f/+0x30/+0x31 fireGuard/waterIceGuard/windThunderGuard bytes를 clear/reset한다. damage formula의 actor+0x1c+family 경로와 action payload family 0x13/0x14/0x15 이름 대조로 순서를 확정했다.",
}

STATE_FAMILY_DETAILS = [
    {
        "family": 2,
        "familyHex": "0x02",
        "routineVaHex": "0x004357d0",
        "label": "방어",
        "effect": "result-family layer에서는 no-op",
        "detail": "현재 action records에서 아타호/린샹/스마슈 방어만 사용한다. 방어의 실제 자세/계수 반영은 command/action layer 또는 actor mode 쪽으로 분리해서 봐야 한다.",
    },
    {
        "family": 3,
        "familyHex": "0x03",
        "routineVaHex": "0x004357d0",
        "label": "인법·몸감추기",
        "effect": "result-family layer에서는 no-op",
        "detail": "스마슈/공용 몸감추기 계열만 사용한다. 눈요기와 달리 이 family 자체가 +0x2a를 갱신하지 않는다.",
    },
]

DRUNK_FAMILY_DETAILS = [
    (36, "0x24", 0x08, 2, "2..16", "조금 취한 철권/술 계열 천조족"),
    (37, "0x25", 0x20, 1, "1..32", "지옥 다리후리기/주구격/노주 계열 천조족"),
    (38, "0x26", 0x08, 4, "4..32", "마시기 record 0x004d24cc"),
    (39, "0x27", 0x0C, 4, "4..48", "만취한 철권/주 백약지장"),
    (40, "0x28", 0x20, 2, "2..64", "마시기 record 0x004d24d4, 염가열소/취호염무/화주 계열 천조족"),
    (41, "0x29", 0x14, 4, "4..80", "마시기 record 0x004d24dc, 인사불성 철권/노익장 대폭발/귀신살 계열 천조족"),
    (42, "0x2a", 0x0C, 8, "8..96", "마시기 record 0x004d24e4"),
    (43, "0x2b", 0x20, 4, "4..128", "EXE table에만 존재, grounded player/shared action records 미사용"),
    (44, "0x2c", 0x14, 8, "8..160", "마시기 record 0x004d24ec"),
]

STATE_FAMILY_DETAILS.extend(
    {
        "family": family,
        "familyHex": f"0x{family:02x}",
        "routineVaHex": "0x00434a4a",
        "label": "아타호 취기 누적",
        "effect": f"{class_hex}: sum(random({range_value})+1 x {repeat}) = {total_range}",
        "detail": (
            f"{examples}. 증가량은 취기 actor +0x65와 주량 경험치 actor +0x1a에 더해진다. "
            "취기는 0xff에서 cap되고, 0/0x21/0x42/0x64 임계값을 넘으면 +0x2a가 6/7/8/9 "
            "(얼근히 취함/만취함/정신없이 취함/대호)로 승급한다. 주량 경험치가 100을 넘으면 "
            "주량 actor +0x18이 최대 100까지 1 증가한 뒤 주량 경험치는 0으로 리셋된다."
        ),
    }
    for family, class_hex, range_value, repeat, total_range, examples in DRUNK_FAMILY_DETAILS
)

STATE_FAMILY_DETAILS.extend(
    [
        {
            "family": 45,
            "familyHex": "0x2d",
            "routineVaHex": "0x00434cbb",
            "label": "스마슈 눈요기",
            "effect": "+0x2a 11..15 progression",
            "detail": "처음이면 11 흥분으로 진입한다. 11..13은 다음 단계로 진행한다. 14 대폭발에서 다시 걸리면 15 푸쉬로 전환하고 +0x64=random(2)+2, +0x62 bit 0x02 timed command/action lock을 건다.",
        },
        {
            "family": 46,
            "familyHex": "0x2e",
            "routineVaHex": "0x00434d8d",
            "label": "린샹 도발",
            "effect": "random(4)+0x10 => +0x2a 16..19",
            "detail": "16..19(위압적인 자세/노발충천/무뚝뚝한 표정/여왕님) 중 하나를 고른다. 현재 상태와 같은 값이 다시 나오면 20 피곤함으로 전환하고 +0x64=random(2)+2, +0x62 bit 0x02 timed lock을 건다.",
        },
        {
            "family": 47,
            "familyHex": "0x2f",
            "routineVaHex": "0x00434e29",
            "label": "수경",
            "effect": "+0x2f/+0x30/+0x31 fire/water-ice/wind-thunder guard clear",
            "detail": "수경 레코드만 이 family를 사용한다. damage formula는 actor +0x1c + family를 가변 index로 읽으므로 family 0x13/0x14/0x15가 actor +0x2f/+0x30/+0x31에 대응한다. action payload 기술명 집계가 0x13=화염, 0x14=수빙, 0x15=풍뢰를 지지한다.",
        },
    ]
)

TARGET_SCOPE_LABELS = {
    0x01: "self/setup/no enemy target",
    0x05: "all allies",
    0x06: "all enemies",
    0x09: "one ally",
    0x0A: "one enemy",
}

HIT_CLASS_LABELS = {
    0x00: "상단/공중/상승 판정",
    0x01: "일반 타격 판정",
    0x02: "하단/지면/다운 판정",
    0x03: "잡기/밀착 특수 판정",
}

HIT_CLASS_NOTES = {
    0x00: "폭전축, 하이킥, 선렬각 일부, 유미쌍조, 맹호난무처럼 상단/공중 이동/상승형 타격에 쓰인다.",
    0x01: "정권, 베기, 장풍/속성기, 회복/자기 상태 setup 등 기본 판정이다.",
    0x02: "다리후리기, 로 킥, 지진/대폭진처럼 지면/하단/넘어짐 축에 쓰인다. 박쥐류는 이 보정이 0이라 잘 맞지 않는 구조와 일치한다.",
    0x03: "던지기, 엉겨붙기, 휘감기, 흡혈, 분신술 일부처럼 일반 타격과 다른 잡기/밀착 특수 판정에 쓰인다.",
}

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

EVIDENCE_SPECS = [
    {
        "label": "hit-unit setup selects action payload and dispatches by unit[5]",
        "range": (0x00433F78, 0x004340A9),
        "needles": [
            "0x4d2488",
            "0x4d2494",
            "0x59e2a4",
            "[eax+0x5]",
            "0x546970",
        ],
        "context": 3,
    },
    {
        "label": "hit-unit setup resets per-hit +0x62 bits before dispatch",
        "range": (0x00433F0E, 0x00433F39),
        "needles": ["[eax+0x62]", "and    cl,0xc3"],
        "context": 1,
    },
    {
        "label": "family 1 producer sets +0x62 bit 0x04",
        "range": (0x0043485E, 0x0043487A),
        "needles": ["or     cl,0x4", "[eax+0x62]"],
        "context": 4,
    },
    {
        "label": "script opcode consumes bit 0x04 and fans out +0x5b/+0x5c/+0x60",
        "range": (0x0040DCF7, 0x0040DDF9),
        "needles": [
            "test   cl,0x4",
            "[eax+0x04]",
            "ds:0x4576e8",
            "[eax+0x5b]",
            "[eax+0x5c]",
            "[eax+0x60]",
            "0x59e334",
        ],
        "context": 3,
    },
    {
        "label": "actor +0x58 uses a separate adjacent special handler table",
        "range": (0x00433F49, 0x00433F73),
        "needles": ["[eax+0x58]", "[eax+0x59]", "0x546a38"],
        "context": 3,
    },
    {
        "label": "hit/avoid routine indexes target +0x36 table with unit[6]",
        "range": (0x004342A2, 0x00434310),
        "needles": ["[eax+0x6]", "+0x36", "0x64"],
        "context": 2,
    },
    {
        "label": "family 36..44 select Ataho drunk increment formula",
        "range": (0x00434A4A, 0x00434CB6),
        "needles": [
            "mov    WORD PTR [ebp-0x4]",
            "mov    WORD PTR [ebp-0x8]",
            "[eax+0x65]",
            "[eax+0x2a]",
            "[eax+0x69]",
        ],
        "context": 1,
    },
    {
        "label": "family 45 Smash eye-candy progresses +0x2a and can set timed lock",
        "range": (0x00434CBB, 0x00434D8D),
        "needles": ["[eax+0x2a]", "0xf", "or     cl,0x2", "[eax+0x64]", "[eax+0x68]"],
        "context": 2,
    },
    {
        "label": "family 46 Rinshan taunt chooses random +0x10..+0x13 and can set tired lock",
        "range": (0x00434D8D, 0x00434E29),
        "needles": ["push   0x4", "add    eax,0x10", "[eax+0x2a]", "0x14", "or     cl,0x2"],
        "context": 2,
    },
    {
        "label": "family 47 Sukyeong clears element-guard bytes",
        "range": (0x00434E29, 0x00434E49),
        "needles": ["[eax+0x2f]", "[eax+0x30]", "[eax+0x31]"],
        "context": 1,
    },
]


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


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


def read_dword_table(blob: bytes, sections: list[dict], base_va: int, count: int) -> list[int]:
    offset = va_to_offset(sections, base_va)
    if offset is None:
        raise ValueError(f"cannot map VA {hx(base_va)}")
    return [struct.unpack_from("<I", blob, offset + index * 4)[0] for index in range(count)]


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 compact_lines(disasm: str, needles: list[str], *, context: int = 0) -> list[str]:
    lines = disasm.splitlines()
    picked: list[tuple[int, str]] = []
    for index, line in enumerate(lines):
        if any(needle in line for needle in needles):
            for i in range(max(0, index - context), min(len(lines), index + context + 1)):
                picked.append((i, lines[i]))
    seen: set[int] = set()
    out: list[str] = []
    for index, line in picked:
        if index in seen:
            continue
        seen.add(index)
        out.append(line)
    return out


def read_skill_rows() -> list[dict[str, Any]]:
    if ACTION_MAPPING.exists():
        data = json.loads(ACTION_MAPPING.read_text(encoding="utf-8"))
        rows: list[dict[str, Any]] = []
        for index, row in enumerate((data.get("playerRows") or []) + (data.get("sharedRows") or [])):
            units = []
            for unit_hex in row.get("unitsHex") or []:
                values = []
                for part in str(unit_hex).split():
                    try:
                        values.append(int(part, 16))
                    except ValueError:
                        values.append(0)
                if len(values) == 8:
                    units.append(values)
            rows.append(
                {
                    "index": index,
                    "recordVaHex": row.get("entryVaHex") or row.get("recordVaHex"),
                    "name": f"{row.get('ownerName') or ''} {row.get('name') or ''}".strip(),
                    "sourceClass": "player-action" if not row.get("shared") else "shared-battle-action",
                    "sequenceUnits": units,
                }
            )
        return rows
    if not SKILL_RECORDS.exists():
        return []
    data = json.loads(SKILL_RECORDS.read_text(encoding="utf-8"))
    return list(data.get("rows") or [])


def collect_unit_usage(rows: list[dict[str, Any]]) -> dict[str, Any]:
    usage: dict[int, list[dict[str, Any]]] = defaultdict(list)
    byte_counters: dict[int, Counter[int]] = {index: Counter() for index in range(8)}
    target_scope_examples: dict[int, list[dict[str, Any]]] = defaultdict(list)
    hit_class_examples: dict[int, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        for unit_index, unit in enumerate(row.get("sequenceUnits") or []):
            if len(unit) != 8:
                continue
            for byte_index, value in enumerate(unit):
                byte_counters[byte_index][int(value)] += 1
            family = int(unit[5])
            entry = {
                "recordIndex": row.get("index"),
                "recordVaHex": row.get("recordVaHex"),
                "name": row.get("name"),
                "sourceClass": row.get("sourceClass"),
                "unitIndex": unit_index,
                "unit": unit,
                "unitHex": " ".join(f"{byte:02x}" for byte in unit),
                "targetScope": unit[4],
                "hitClass": unit[6],
                "statusEffectValue": unit[7],
            }
            usage[family].append(entry)
            if len(target_scope_examples[unit[4]]) < 8:
                target_scope_examples[unit[4]].append(entry)
            if len(hit_class_examples[unit[6]]) < 8:
                hit_class_examples[unit[6]].append(entry)

    rows_out = []
    for family in sorted(usage):
        entries = usage[family]
        record_names = sorted({str(entry["name"]) for entry in entries})
        rows_out.append(
            {
                "family": family,
                "familyHex": f"0x{family:02x}",
                "unitCount": len(entries),
                "recordCount": len({entry["recordIndex"] for entry in entries}),
                "recordNames": record_names,
                "examples": entries[:10],
                "statusEffectValues": sorted({entry["statusEffectValue"] for entry in entries}),
                "statusEffectLabels": [
                    f"{value}:{STATUS_EFFECT_LABELS.get(value, 'unknown')}"
                    for value in sorted({entry["statusEffectValue"] for entry in entries})
                ],
                "note": FAMILY_NOTES.get(family, ""),
            }
        )
    return {
        "unitCount": sum(len(entries) for entries in usage.values()),
        "familyCount": len(usage),
        "families": rows_out,
        "counter": dict(sorted(Counter({family: len(entries) for family, entries in usage.items()}).items())),
        "byteValueCounts": {
            f"byte[{byte_index}]": {f"0x{value:02x}": count for value, count in sorted(counter.items())}
            for byte_index, counter in byte_counters.items()
        },
        "targetScopeRows": [
            {
                "value": value,
                "valueHex": f"0x{value:02x}",
                "label": TARGET_SCOPE_LABELS.get(value, "unknown target scope"),
                "count": byte_counters[4][value],
                "examples": target_scope_examples[value],
            }
            for value in sorted(byte_counters[4])
        ],
        "hitClassRows": [
            {
                "value": value,
                "valueHex": f"0x{value:02x}",
                "label": HIT_CLASS_LABELS.get(value, "unknown hit/avoid class"),
                "note": HIT_CLASS_NOTES.get(value, ""),
                "count": byte_counters[6][value],
                "examples": hit_class_examples[value],
            }
            for value in sorted(byte_counters[6])
        ],
    }


def build_dispatch_rows(table: list[int], usage_by_family: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for index, function_va in enumerate(table):
        usage = usage_by_family.get(index, {})
        rows.append(
            {
                "index": index,
                "indexHex": f"0x{index:02x}",
                "tableEntryVa": RESULT_TABLE_VA + index * 4,
                "tableEntryVaHex": hx(RESULT_TABLE_VA + index * 4),
                "functionVa": function_va,
                "functionVaHex": hx(function_va),
                "role": ROLE_BY_FUNCTION.get(function_va, "unclassified result routine"),
                "unitCount": usage.get("unitCount", 0),
                "recordCount": usage.get("recordCount", 0),
                "examples": usage.get("examples", []),
                "note": FAMILY_NOTES.get(index, ""),
            }
        )
    return rows


def build_data() -> dict[str, Any]:
    blob = EXE.read_bytes()
    sections = read_sections(blob)
    result_table = read_dword_table(blob, sections, RESULT_TABLE_VA, RESULT_TABLE_ENTRY_COUNT)
    special_table = read_dword_table(blob, sections, SPECIAL_TABLE_VA, SPECIAL_TABLE_ENTRY_COUNT)

    skill_rows = read_skill_rows()
    unit_usage = collect_unit_usage(skill_rows)
    usage_map = {row["family"]: row for row in unit_usage["families"]}
    dispatch_rows = build_dispatch_rows(result_table, usage_map)

    evidence = []
    for spec in EVIDENCE_SPECS:
        start, stop = spec["range"]
        disasm = disassemble(start, stop)
        evidence.append(
            {
                "label": spec["label"],
                "startVa": start,
                "stopVa": stop,
                "startVaHex": hx(start),
                "stopVaHex": hx(stop),
                "lines": compact_lines(disasm, spec["needles"], context=spec.get("context", 0)),
            }
        )

    special_rows = [
        {
            "index": index,
            "tableEntryVaHex": hx(SPECIAL_TABLE_VA + index * 4),
            "functionVaHex": hx(function_va),
            "role": ROLE_BY_FUNCTION.get(function_va, "unclassified special handler"),
        }
        for index, function_va in enumerate(special_table)
    ]

    return {
        "title": "전투 result-family dispatch",
        "resultTableVa": RESULT_TABLE_VA,
        "resultTableVaHex": hx(RESULT_TABLE_VA),
        "resultTableEntryCount": RESULT_TABLE_ENTRY_COUNT,
        "specialTableVa": SPECIAL_TABLE_VA,
        "specialTableVaHex": hx(SPECIAL_TABLE_VA),
        "summary": [
            "8-byte hit unit의 byte[5]는 result-family id다. 0x00433f0e가 이를 target actor +0x6b에 복사한 뒤 0x00546970[id]를 호출한다.",
            f"byte[3]은 현재 추출된 {unit_usage['unitCount']}개 hit unit에서 전부 0x00이므로 reserved/unused로 둔다.",
            "byte[4]는 action payload의 target scope id다. 현재 값은 0x01 self/setup, 0x05 all allies, 0x06 all enemies, 0x09 one ally, 0x0a one enemy로만 갈라진다.",
            "byte[4]는 0x00433649에서 직접 읽히고 0x00433545/0x004335c6에서 actor +0x67 target selector로 복사된다.",
            "handler-table opcode 0x5c/0x0040fa6d는 byte[4] bit 0x08로 selected-target branch를 가르는 gate이고, 0x0040f57c action setup은 selected target latch 0x0059e347을 actor +0x61로 복사한다.",
            "byte[6]는 hit/avoid class index다. 0x004342e8..0x004342ee가 unit[6]을 읽어 target actor +0x36+index 보정 테이블을 참조한다.",
            "byte[6] 0..3은 내부 명중 판정 class로 승격한다. 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수 판정이다.",
            "따라서 out/enemy_stat_table의 exported +0x36 필드를 그대로 hit-class 테이블로 승격하지 않는다. byte[6] 소비자는 battle actor table의 +0x36 기반 값을 읽는다.",
            "family 1은 0x0043485e로 연결되어 actor +0x62 bit 0x04를 세우며, 현재 추출된 레코드에서는 도주만 사용한다.",
            "따라서 +0x62 bit 0x04는 타격/크리티컬/상태 플래그가 아니라 도주 계열 battle-flow fan-out 요청으로 승격한다.",
            "family 2(방어)와 family 3(몸감추기)은 현재 records에서 의도적인 no-op result family로 정리한다. 결과 적용은 없고, 의미는 command/action layer에서 발생한다.",
            "family 36..44는 아타호 취기 누적 class다. 0x434a4a가 family id 0x24..0x2c를 range/repeat 공식으로 바꿔 취기 actor +0x65와 주량 경험치 actor +0x1a를 누적하고 +0x2a 6/7/8/9로 승급시킨다. 주량 경험치가 100을 넘으면 주량 actor +0x18이 최대 100까지 증가한다.",
            "family 43/0x2b 취기 공식은 result table에 존재하지만 현재 grounded player/shared action records 281개에서는 참조되지 않는다. 구현 소비 대상에서는 table-only unused entry로 둔다.",
            "family 45는 스마슈 눈요기, family 46은 린샹 도발, family 47은 수경 전용 루틴으로 세분화했다. 이 셋은 +0x6c 상태이상 byte가 아니라 actor +0x2a 또는 fireGuard/waterIceGuard/windThunderGuard byte를 직접 만진다.",
            "family 47 수경은 +0x2f/+0x30/+0x31을 clear한다. damage formula의 actor+0x1c+family 가변 index와 action payload family 이름 대조로 +0x2f=화염, +0x30=수빙, +0x31=풍뢰 guard로 승격했다.",
            "0x00546970 뒤의 0x00546a38은 result-family 50번 이후가 아니라 actor +0x58 특수 경로에서 쓰는 별도 handler table이다.",
        ],
        "hitUnitSchema": [
            {"byte": 0, "meaning": "attacker +0x80 scaled into actor +0x70", "status": "grounded"},
            {"byte": 1, "meaning": "attacker +0x82 scaled into actor +0x72", "status": "grounded"},
            {"byte": 2, "meaning": "attacker +0x84 scaled into actor +0x74", "status": "grounded"},
            {"byte": 3, "meaning": "reserved/unused in extracted hit units; constant 0x00 across all current units", "status": "grounded"},
            {"byte": 4, "meaning": "target scope id: 0x01 self/setup, 0x05 all allies, 0x06 all enemies, 0x09 one ally, 0x0a one enemy; copied to actor +0x67 by 0x433545/0x4335c6; bit 0x08 branches through opcode 0x5c/0x40fa6d gate to stream +4 target-selection script", "status": "grounded"},
            {"byte": 5, "meaning": "result-family dispatch id; copied to target +0x6b", "status": "grounded"},
            {"byte": 6, "meaning": "hit/avoid class index; used to read target +0x36+index modifier table. 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수", "status": "grounded"},
            {"byte": 7, "meaning": "status effect value: 0 none, 1 넘어짐, 2 휙 날아감, 3 행동정지, 4 독, 5 마비, 6 졸림; copied to target +0x6c", "status": "grounded"},
        ],
        "unitUsage": unit_usage,
        "dispatchRows": dispatch_rows,
        "stateFamilyDetails": STATE_FAMILY_DETAILS,
        "specialRows": special_rows,
        "evidence": evidence,
        "openQuestions": [],
    }


def tag_class(status: str) -> str:
    return "good" if status == "grounded" else "warn"


def render_examples(examples: list[dict[str, Any]]) -> str:
    if not examples:
        return '<span class="muted">-</span>'
    return "<br>".join(
        f"<span class=\"mono\">#{esc(item.get('recordIndex'))}.{esc(item.get('unitIndex'))}</span> "
        f"{esc(item.get('name'))} <span class=\"mono\">{esc(item.get('unitHex'))}</span>"
        for item in examples[:5]
    )


def render_html(data: dict[str, Any]) -> str:
    summary_items = "\n".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    schema_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">byte[{row['byte']}]</td>
          <td>{esc(row['meaning'])}</td>
          <td><span class="tag {tag_class(row['status'])}">{esc(row['status'])}</span></td>
        </tr>"""
        for row in data["hitUnitSchema"]
    )
    dispatch_rows = "\n".join(
        f"""
        <tr class="{ 'unused' if not row['unitCount'] else '' }">
          <td class="mono">{esc(row['indexHex'])}</td>
          <td class="mono">{esc(row['tableEntryVaHex'])}</td>
          <td class="mono">{esc(row['functionVaHex'])}</td>
          <td>{esc(row['role'])}</td>
          <td>{esc(row['unitCount'])} / {esc(row['recordCount'])}</td>
          <td>{render_examples(row['examples'])}</td>
          <td>{esc(row['note'])}</td>
        </tr>"""
        for row in data["dispatchRows"]
    )
    family_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">{esc(row['familyHex'])}</td>
          <td>{esc(row['unitCount'])}</td>
          <td>{esc(row['recordCount'])}</td>
          <td>{esc(', '.join(row['recordNames'][:14]))}{' ...' if len(row['recordNames']) > 14 else ''}</td>
          <td>{esc(', '.join(row['statusEffectLabels']))}</td>
          <td>{esc(row['note'])}</td>
        </tr>"""
        for row in data["unitUsage"]["families"]
    )
    state_family_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">{esc(row['familyHex'])}</td>
          <td class="mono">{esc(row['routineVaHex'])}</td>
          <td>{esc(row['label'])}</td>
          <td>{esc(row['effect'])}</td>
          <td>{esc(row['detail'])}</td>
        </tr>"""
        for row in data["stateFamilyDetails"]
    )
    target_scope_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">{esc(row['valueHex'])}</td>
          <td>{esc(row['label'])}</td>
          <td>{esc(row['count'])}</td>
          <td>{render_examples(row['examples'])}</td>
        </tr>"""
        for row in data["unitUsage"]["targetScopeRows"]
    )
    hit_class_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">{esc(row['valueHex'])}</td>
          <td>{esc(row['label'])}</td>
          <td>{esc(row['count'])}</td>
          <td>{render_examples(row['examples'])}</td>
        </tr>"""
        for row in data["unitUsage"]["hitClassRows"]
    )
    special_rows = "\n".join(
        f"""
        <tr>
          <td class="mono">{esc(row['index'])}</td>
          <td class="mono">{esc(row['tableEntryVaHex'])}</td>
          <td class="mono">{esc(row['functionVaHex'])}</td>
          <td>{esc(row['role'])}</td>
        </tr>"""
        for row in data["specialRows"]
    )
    evidence_blocks = "\n".join(
        f"""
        <details>
          <summary>{esc(item['label'])} <span class="mono">{esc(item['startVaHex'])}..{esc(item['stopVaHex'])}</span></summary>
          <pre>{esc(chr(10).join(item['lines']) if item['lines'] else '; no compact evidence lines selected')}</pre>
        </details>"""
        for item in data["evidence"]
    )
    open_items = "\n".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    return f"""<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{esc(data['title'])}</title>
    <style>
      :root {{
        --bg:#f6f7f9;
        --panel:#fff;
        --line:#d8dee6;
        --head:#eef2f7;
        --text:#182230;
        --muted:#667085;
        --good:#0f766e;
        --warn:#a15c00;
      }}
      body {{ margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,sans-serif; line-height:1.45; }}
      main {{ max-width:1360px; margin:0 auto; padding:24px; }}
      header, section {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; margin-bottom:16px; }}
      header {{ padding:20px; }}
      section {{ padding:16px; overflow:auto; }}
      h1 {{ margin:0 0 8px; font-size:26px; }}
      h2 {{ margin:0 0 12px; font-size:18px; }}
      nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }}
      a {{ color:#2457a6; text-decoration:none; }}
      a:hover {{ text-decoration:underline; }}
      table {{ width:100%; border-collapse:collapse; font-size:13px; }}
      th, td {{ border:1px solid var(--line); padding:8px; vertical-align:top; }}
      th {{ background:var(--head); text-align:left; }}
      tr.unused {{ color:var(--muted); background:#fafafa; }}
      pre {{ margin:0; padding:12px; overflow:auto; background:#101827; color:#d1d5db; border-radius:0 0 8px 8px; font-size:12px; line-height:1.45; }}
      details {{ border:1px solid var(--line); border-radius:8px; margin:8px 0; background:white; }}
      summary {{ cursor:pointer; padding:10px 12px; font-weight:700; background:var(--head); }}
      .mono {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
      .muted {{ color:var(--muted); }}
      .tag {{ display:inline-block; border-radius:999px; padding:2px 8px; color:white; font-size:12px; }}
      .tag.good {{ background:var(--good); }}
      .tag.warn {{ background:var(--warn); }}
      .split {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }}
      h3 {{ margin:0 0 8px; font-size:15px; }}
      @media (max-width:760px) {{
        main {{ padding:12px; }}
        th:nth-child(7), td:nth-child(7) {{ display:none; }}
        .split {{ grid-template-columns:1fr; }}
      }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <h1>{esc(data['title'])}</h1>
        <p class="muted">result table <span class="mono">{esc(data['resultTableVaHex'])}</span>, special table <span class="mono">{esc(data['specialTableVaHex'])}</span>. 현재 전투 레코드의 hit-unit byte[5] 분포와 EXE dispatch 근거를 함께 본다.</p>
        <nav>
          <a href="../web/index.html">관리 홈</a>
          <a href="battle_result_family_dispatch_review.json">JSON</a>
          <a href="battle_result_family_dispatch_review.md">MD</a>
          <a href="battle_result_flag_lifecycle_review.html">결과 플래그</a>
          <a href="battle_skill_records.json">전투 레코드</a>
          <a href="../web/battle_simulator.html">전투 기술 실행</a>
        </nav>
      </header>
      <section>
        <h2>결론</h2>
        <ul>{summary_items}</ul>
      </section>
      <section>
        <h2>Hit Unit Schema</h2>
        <table>
          <thead><tr><th>byte</th><th>의미</th><th>상태</th></tr></thead>
          <tbody>{schema_rows}</tbody>
        </table>
      </section>
      <section>
        <h2>Target Scope / Hit Class</h2>
        <div class="split">
          <div>
            <h3>byte[4] Target Scope</h3>
            <table>
              <thead><tr><th>값</th><th>의미</th><th>unit</th><th>예시</th></tr></thead>
              <tbody>{target_scope_rows}</tbody>
            </table>
          </div>
          <div>
            <h3>byte[6] Hit/Avoid Class</h3>
            <table>
              <thead><tr><th>값</th><th>의미</th><th>unit</th><th>예시</th></tr></thead>
              <tbody>{hit_class_rows}</tbody>
            </table>
          </div>
        </div>
      </section>
      <section>
        <h2>Result-Family Dispatch Table</h2>
        <table>
          <thead><tr><th>id</th><th>entry VA</th><th>function</th><th>역할</th><th>unit/record</th><th>예시</th><th>메모</th></tr></thead>
          <tbody>{dispatch_rows}</tbody>
        </table>
      </section>
      <section>
        <h2>State / No-op Families 승격</h2>
        <p class="muted">이 표는 이전에 넓게 미확정으로 남아 있던 no-op family와 고유 상태 family를 EXE 루틴 기준으로 좁힌 것이다.</p>
        <table>
          <thead><tr><th>family</th><th>routine</th><th>레이블</th><th>동작</th><th>세부</th></tr></thead>
          <tbody>{state_family_rows}</tbody>
        </table>
      </section>
      <section>
        <h2>현재 레코드 사용 분포</h2>
        <p class="muted">총 {esc(data['unitUsage']['unitCount'])}개 hit unit, {esc(data['unitUsage']['familyCount'])}개 family가 현재 추출 레코드에서 사용된다.</p>
        <table>
          <thead><tr><th>family</th><th>unit</th><th>record</th><th>기술명</th><th>status effect byte[7]</th><th>메모</th></tr></thead>
          <tbody>{family_rows}</tbody>
        </table>
      </section>
      <section>
        <h2>Adjacent Special Handler Table</h2>
        <p class="muted"><span class="mono">0x00546a38</span>은 result-family 50번 이후가 아니라 actor +0x58 경로에서 actor +0x59로 호출되는 별도 테이블이다.</p>
        <table>
          <thead><tr><th>index</th><th>entry VA</th><th>function</th><th>역할</th></tr></thead>
          <tbody>{special_rows}</tbody>
        </table>
      </section>
      <section>
        <h2>근거 라인</h2>
        {evidence_blocks}
      </section>
      <section>
        <h2>미확정</h2>
        <ul>{open_items}</ul>
      </section>
    </main>
  </body>
</html>
"""


def render_md(data: dict[str, Any]) -> str:
    lines = [
        f"# {data['title']}",
        "",
        f"- result table: `{data['resultTableVaHex']}`",
        f"- adjacent special table: `{data['specialTableVaHex']}`",
        "",
        "## 결론",
    ]
    lines.extend(f"- {item}" for item in data["summary"])
    lines.extend(["", "## Hit Unit Schema", "", "| byte | 의미 | 상태 |", "|---|---|---|"])
    for row in data["hitUnitSchema"]:
        lines.append(f"| byte[{row['byte']}] | {row['meaning']} | {row['status']} |")
    lines.extend(["", "## Target Scope byte[4]", "", "| value | 의미 | unit | 예시 |", "|---|---|---:|---|"])
    for row in data["unitUsage"]["targetScopeRows"]:
        examples = "; ".join(f"#{item.get('recordIndex')}.{item.get('unitIndex')} {item.get('name')} `{item.get('unitHex')}`" for item in row["examples"][:4])
        lines.append(f"| {row['valueHex']} | {row['label']} | {row['count']} | {examples} |")
    lines.extend(["", "## Hit/Avoid Class byte[6]", "", "| value | 의미 | unit | 예시 |", "|---|---|---:|---|"])
    for row in data["unitUsage"]["hitClassRows"]:
        examples = "; ".join(f"#{item.get('recordIndex')}.{item.get('unitIndex')} {item.get('name')} `{item.get('unitHex')}`" for item in row["examples"][:4])
        lines.append(f"| {row['valueHex']} | {row['label']} | {row['count']} | {examples} |")
    lines.extend(["", "## Result-Family Dispatch Table", "", "| id | entry VA | function | 역할 | unit/record | 예시 | 메모 |", "|---|---|---|---|---|---|---|"])
    for row in data["dispatchRows"]:
        examples = "; ".join(f"#{item.get('recordIndex')}.{item.get('unitIndex')} {item.get('name')} `{item.get('unitHex')}`" for item in row["examples"][:4])
        lines.append(
            f"| {row['indexHex']} | {row['tableEntryVaHex']} | {row['functionVaHex']} | {row['role']} | {row['unitCount']} / {row['recordCount']} | {examples or '-'} | {row['note']} |"
        )
    lines.extend(["", "## State / No-op Families 승격", "", "| family | routine | 레이블 | 동작 | 세부 |", "|---|---|---|---|---|"])
    for row in data["stateFamilyDetails"]:
        lines.append(f"| {row['familyHex']} | {row['routineVaHex']} | {row['label']} | {row['effect']} | {row['detail']} |")
    lines.extend(["", "## 현재 레코드 사용 분포", "", "| family | unit | record | 기술명 | status effect byte[7] | 메모 |", "|---|---|---|---|---|---|"])
    for row in data["unitUsage"]["families"]:
        names = ", ".join(row["recordNames"][:20])
        if len(row["recordNames"]) > 20:
            names += " ..."
        statuses = ", ".join(row["statusEffectLabels"])
        lines.append(f"| {row['familyHex']} | {row['unitCount']} | {row['recordCount']} | {names} | {statuses} | {row['note']} |")
    lines.extend(["", "## Adjacent Special Handler Table", "", "| index | entry VA | function | 역할 |", "|---|---|---|---|"])
    for row in data["specialRows"]:
        lines.append(f"| {row['index']} | {row['tableEntryVaHex']} | {row['functionVaHex']} | {row['role']} |")
    lines.extend(["", "## 근거 라인"])
    for item in data["evidence"]:
        lines.extend(["", f"### {item['label']} ({item['startVaHex']}..{item['stopVaHex']})", "", "```asm"])
        lines.extend(item["lines"] or ["; no compact evidence lines selected"])
        lines.append("```")
    lines.extend(["", "## 미확정"])
    lines.extend(f"- {item}" for item in data["openQuestions"])
    lines.append("")
    return "\n".join(lines)


def main() -> None:
    OUT.mkdir(exist_ok=True)
    data = build_data()
    (OUT / "battle_result_family_dispatch_review.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    (OUT / "battle_result_family_dispatch_review.html").write_text(render_html(data), encoding="utf-8")
    (OUT / "battle_result_family_dispatch_review.md").write_text(render_md(data), encoding="utf-8")
    print("wrote battle_result_family_dispatch_review")


if __name__ == "__main__":
    main()
