#!/usr/bin/env python3
"""Build a review for the battle actor stat row layout.

This report clarifies a subtle offset issue:

* out/enemy_stat_table.json is a useful monster review view anchored at the HP
  dword, but its trailing name id is a boundary marker from the next actor row.
* The battle actor initializer at 0x0040be38 copies a wider row whose prefix is
  used by the battle actor struct, then copies HP/MP and stat/rate fields into
  exact actor offsets.

The goal is to keep the user-facing monster table intact while making the actor
struct layout explicit for the battle formula work.
"""
from __future__ import annotations

import html
import json
import re
import subprocess
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
ENEMY_STAT_TABLE = OUT / "enemy_stat_table.json"
UI_GRID = OUT / "ui_cns_grid_mappings.json"

IMAGE_BASE_DELTA = 0x00402000
ACTOR_ROW_TABLE_VA = 0x00457C60
ACTOR_ROW_SIZE = 0x38
EXPORTED_MONSTER_TABLE_VA = 0x00457D48
EXPORTED_MONSTER_TABLE_FILE_OFFSET = EXPORTED_MONSTER_TABLE_VA - IMAGE_BASE_DELTA


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


def hex32(value: int) -> str:
    return f"0x{value:08x}"


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


def read_u16(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset : offset + 2], "little")


def read_u32(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset : offset + 4], "little")


def va_to_offset(va: int) -> int:
    return va - IMAGE_BASE_DELTA


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


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()
    result: list[str] = []
    for index, line in picked:
        if index not in seen:
            seen.add(index)
            result.append(line)
    return result


def signed8(value: int) -> int:
    return value - 256 if value >= 128 else value


LAYOUT_ROWS: list[dict[str, Any]] = [
    {
        "actualOffset": 0x00,
        "exportedOffset": None,
        "actorOffset": 0x00,
        "width": "dword",
        "role": "actor prefix zero/reserved",
        "status": "구조 확인, 의미 후보",
        "evidence": "0x40bec8에서 row prefix 8바이트를 actor +0x00으로 memcpy한다.",
    },
    {
        "actualOffset": 0x04,
        "exportedOffset": None,
        "actorOffset": 0x04,
        "width": "byte x2",
        "role": "battle action source/type prefix",
        "status": "고신뢰",
        "evidence": "actor +0x04는 0x434218에서 action table branch(1/2)를 고르고, +0x05는 type 1 branch의 table index로 쓰인다.",
    },
    {
        "actualOffset": 0x06,
        "exportedOffset": None,
        "actorOffset": 0x06,
        "width": "u16",
        "role": "level / level-like battle value",
        "status": "고신뢰",
        "evidence": "actor +0x06은 hit/avoid와 damage random 폭에서 attacker/target level 값처럼 쓰인다. 원숭이 actual row 값은 2.",
    },
    {
        "actualOffset": 0x08,
        "exportedOffset": 0x00,
        "actorOffset": 0x08,
        "width": "u32 source -> actor word",
        "role": "HP source, actor current/max/base HP",
        "status": "확정",
        "evidence": "0x40bef4가 row +0x08을 actor +0x08에 쓰고 +0x0a/+0x0c로 복사한다.",
    },
    {
        "actualOffset": 0x0A,
        "exportedOffset": 0x02,
        "actorOffset": 0x0E,
        "width": "u16",
        "role": "MP source, actor current/max/base MP",
        "status": "확정",
        "evidence": "0x40bf2a가 row +0x0a를 actor +0x0e에 쓰고 +0x10/+0x12로 복사한다.",
    },
    {
        "actualOffset": 0x0C,
        "exportedOffset": 0x04,
        "actorOffset": 0x14,
        "width": "u8 -> word",
        "role": "EXP base/current EXP",
        "status": "확정",
        "evidence": "0x40bf60이 enemy row +0x0c를 actor +0x14로 복사한다. 0x42157b 경험치 공식은 defeated enemy +0x14를 base EXP로 읽고, 0x40e085..0x40e095는 산출 EXP를 live player actor +0x14에 더한다.",
    },
    {
        "actualOffset": None,
        "exportedOffset": None,
        "actorOffset": 0x16,
        "width": "word",
        "role": "fixed 100 coefficient",
        "status": "확정",
        "evidence": "0x40bf70이 actor +0x16 = 100으로 직접 초기화한다.",
    },
    {
        "actualOffset": 0x0E,
        "exportedOffset": 0x06,
        "actorOffset": 0x1C,
        "width": "u16",
        "role": "attack axis",
        "status": "고신뢰",
        "evidence": "0x40bf79 memcpy source row +0x0e -> actor +0x1c. 0x43461f/0x434737 damage formula에서 attacker +0x70 * actor +0x1c로 쓰인다.",
    },
    {
        "actualOffset": 0x10,
        "exportedOffset": 0x08,
        "actorOffset": 0x1E,
        "width": "u16",
        "role": "저장된 튜닝 축 / 현 전투 공식 미사용",
        "status": "공식 미사용 확인 / 이름 보류",
        "evidence": "row +0x0e..+0x37 block 안에서 actor +0x1e로 복사되고 전체 몬스터 80개에 20..200 범위 값이 있다. 다만 .text 직접 접근 스캔에서 전투 actor word/byte read/write [actor+0x1e]는 없고, 보이는 +0x1e 접근은 display/helper object callback pointer 구조다. damage family byte5가 0x10..0x19인 물리/속성 공식은 target +0x1c+family로 actor +0x2c 이후를 읽으므로 +0x1e에 닿지 않는다.",
    },
    {
        "actualOffset": 0x12,
        "exportedOffset": 0x0A,
        "actorOffset": 0x20,
        "width": "u16",
        "role": "defense axis",
        "status": "고신뢰",
        "evidence": "0x43461f/0x434737 damage formula에서 target +0x7a * actor +0x20로 쓰인다.",
    },
    {
        "actualOffset": 0x14,
        "exportedOffset": 0x0C,
        "actorOffset": 0x22,
        "width": "u16",
        "role": "technique / hit axis",
        "status": "고신뢰",
        "evidence": "0x434218 hit/avoid에서 attacker +0x06 + +0x22, 0x4344ab status gate에서 attacker +0x74 * +0x22로 쓰인다.",
    },
    {
        "actualOffset": 0x16,
        "exportedOffset": 0x0E,
        "actorOffset": 0x24,
        "width": "u16",
        "role": "agility / avoid axis",
        "status": "고신뢰",
        "evidence": "0x434218 hit/avoid에서 target +0x06 + +0x24와 target +0x7c 계수로 회피 점수를 만든다.",
    },
    {
        "actualOffset": 0x18,
        "exportedOffset": 0x10,
        "actorOffset": 0x26,
        "width": "u16",
        "role": "luck axis / status-critical resistance-side axis",
        "status": "고신뢰",
        "evidence": "플레이어 row의 장비/레벨업 루틴에서 actor +0x26이 운으로 확정됐다. 전투 공식에서는 critical/status gate의 attacker/target luck-side 축으로 쓰인다.",
    },
    {
        "actualOffset": 0x1A,
        "exportedOffset": 0x12,
        "actorOffset": 0x28,
        "width": "u16",
        "role": "숨은 상태/크리티컬 발동률 계수",
        "status": "역할 확정 / UI 비표시",
        "evidence": "0x4343db에서 attacker +0x28이 RNG range/base로 쓰이고, 통과 시 attacker +0x62 bit 0x10을 세운다. 0x4344ab 상태 성공 gate에서도 attacker-side 계수로 읽힌다. 플레이어 상태창/장비/레벨업 운 경로와 분리되어 있고 몬스터 대부분이 100이라 상태창 운으로 단정하면 안 된다.",
    },
    {
        "actualOffset": 0x1C,
        "exportedOffset": 0x14,
        "actorOffset": 0x2A,
        "width": "u16",
        "role": "initial battle mode/status id",
        "status": "확정",
        "evidence": "actor +0x2a로 복사된 뒤 0x433940이 0x48bcaa 자세/상태 계수 테이블 index로 읽는다. 0x434e84..0x43512e 상태/버프 루틴도 +0x2a에 상태 id를 쓰고 +0x66에 이전값을 저장한다. 몬스터 row 대부분의 1은 초기 보통 상태로 해석된다.",
    },
    {
        "actualOffset": 0x1E,
        "exportedOffset": 0x16,
        "actorOffset": 0x2C,
        "width": "u8 table",
        "role": "family/rate table start",
        "status": "고신뢰",
        "evidence": "target +0x1c+family damage 보정은 family 0x10부터 actor +0x2c table을 읽고, hit/avoid는 actor +0x36+unitByte6을 읽는다. enemy_stat_table의 actual row +0x28..+0x2b가 actor +0x36..+0x39 hit-class table로 복사된다. hit class는 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수로 본다.",
    },
    {
        "actualOffset": 0x32,
        "exportedOffset": 0x2A,
        "actorOffset": 0x40,
        "width": "u8 item + u8 chance/range",
        "role": "item drop id / item drop probability divisor",
        "status": "루틴 확정 / 현재 연속 몬스터 테이블 전부 0",
        "evidence": "보상 핸들러 0x43577c가 defeated actor +0x40 item id와 +0x41 확률/분모를 읽는다. actor row copy상 source는 actual +0x32/+0x33, HP-view +0x2a/+0x2b다. 현재 80개 연속 몬스터 row에서는 모두 0이라 일반 몬스터 보상은 EXP/골드만 있고 아이템 드롭은 비활성으로 본다.",
    },
    {
        "actualOffset": 0x36,
        "exportedOffset": 0x2E,
        "actorOffset": 0x44,
        "width": "u16",
        "role": "gold/reward source",
        "status": "고신뢰",
        "evidence": "exported monster table의 gold 값과 일치하고, row +0x0e..+0x37 memcpy에 포함되어 actor +0x44로 복사된다.",
    },
    {
        "actualOffset": "next +0x04",
        "exportedOffset": 0x34,
        "actorOffset": None,
        "width": "u16 boundary marker",
        "role": "next row prefix marker used as exported monster name id",
        "status": "구조 확인",
        "evidence": "exported +0x34는 actual current row 내부가 아니라 다음 actual row +0x04다. 이름 anchor로는 유효하지만 current actor에는 복사되지 않는다.",
    },
]


def enemy_actor_row(exe: bytes, row: dict[str, Any]) -> dict[str, Any]:
    exported_va = int(row["va"])
    actual_va = exported_va - 8
    actual_offset = va_to_offset(actual_va)
    prefix = exe[actual_offset : actual_offset + 8]
    copied = exe[actual_offset + 0x0E : actual_offset + 0x38]
    return {
        "name": row.get("cleanName") or row.get("name") or "",
        "rawName": row.get("name") or "",
        "enemyIndex": row["index"],
        "actualRowVa": actual_va,
        "actualRowVaHex": hex32(actual_va),
        "exportedHpViewVa": exported_va,
        "exportedHpViewVaHex": row["vaHex"],
        "prefixHex": prefix.hex(" "),
        "actorTypeByte": prefix[4],
        "actorSubByte": prefix[5],
        "level": read_u16(exe, actual_offset + 0x06),
        "hp": row["hp"],
        "mp": read_u16(exe, actual_offset + 0x0A),
        "expBase": read_u16(exe, actual_offset + 0x0C),
        "attack": read_u16(exe, actual_offset + 0x0E),
        "axis1e": read_u16(exe, actual_offset + 0x10),
        "defense": read_u16(exe, actual_offset + 0x12),
        "technique": read_u16(exe, actual_offset + 0x14),
        "agility": read_u16(exe, actual_offset + 0x16),
        "luckResistAxis": read_u16(exe, actual_offset + 0x18),
        "baseCoeff": read_u16(exe, actual_offset + 0x1A),
        "initialModeStatusId": read_u16(exe, actual_offset + 0x1C),
        "dropItemId": exe[actual_offset + 0x32],
        "dropRate": exe[actual_offset + 0x33],
        "rateBytes": " ".join(str(value) for value in copied[0x10:0x22]),
        "hitAvoidClassTable": " ".join(f"0x{value:02x}" for value in exe[actual_offset + 0x28 : actual_offset + 0x2C]),
        "gold": row["gold"],
        "boundaryNameIdHex": row["nameIdHex"],
        "nextPrefixMarkerVaHex": hex32(actual_va + ACTOR_ROW_SIZE + 0x04),
    }


def enemy_actor_rows(exe: bytes, enemy_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [enemy_actor_row(exe, row) for row in enemy_rows]


def actor_row_samples(all_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    wanted = {
        "원숭이",
        "멧돼지",
        "박쥐",
        "슬라임",
        "파워",
        "스피드",
        "숙련주작권사",
        "숙련창룡권사",
        "숙련현무권사",
        "주작권사",
        "창룡권사",
        "현무권사",
        "주작성령",
        "창룡성령",
        "현무성령",
        "데드드래곤",
        "폭호",
    }
    return [row for row in all_rows if row["name"] in wanted]


def enemy_word(row: dict[str, Any], offset: int) -> int | None:
    for word in row.get("words", []):
        if word.get("offset") == offset:
            return int(word.get("value", 0))
    return None


def axis1e_distribution(enemy_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    grouped: dict[int, list[str]] = {}
    for row in enemy_rows:
        value = enemy_word(row, 0x08)
        if value is None:
            continue
        grouped.setdefault(value, []).append(str(row.get("cleanName") or row.get("name") or ""))
    return [
        {
            "value": value,
            "count": len(names),
            "examples": ", ".join(names[:10]),
        }
        for value, names in sorted(grouped.items())
    ]


def extract_equipment_examples(ui_grid: dict[str, Any]) -> list[dict[str, Any]]:
    examples: list[dict[str, Any]] = []

    for table in ui_grid.get("tables", []):
        if not isinstance(table, dict) or table.get("key") != "equipment":
            continue
        for record in table.get("records", []):
            if not isinstance(record, dict):
                continue
            detail = record.get("detailRecord")
            if not isinstance(detail, dict) or detail.get("kind") != "equipment-detail-record":
                continue
            stats = detail.get("statFields") or []
            if stats:
                examples.append(
                    {
                        "name": record.get("name"),
                        "recordVaHex": detail.get("recordVaHex"),
                        "statSummary": detail.get("statSummary"),
                        "battleModifierSummary": detail.get("battleModifierSummary"),
                        "equipmentSkillSummary": detail.get("equipmentSkillSummary"),
                        "rawHex": detail.get("rawHex"),
                        "classificationNote": detail.get("classificationNote"),
                    }
                )

    if not examples:
        def walk(value: Any) -> None:
            if isinstance(value, dict):
                if value.get("kind") == "equipment-detail-record":
                    stats = value.get("statFields") or []
                    if stats:
                        examples.append(
                            {
                                "name": "",
                                "recordVaHex": value.get("recordVaHex"),
                                "statSummary": value.get("statSummary"),
                                "battleModifierSummary": value.get("battleModifierSummary"),
                                "equipmentSkillSummary": value.get("equipmentSkillSummary"),
                                "rawHex": value.get("rawHex"),
                                "classificationNote": value.get("classificationNote"),
                            }
                        )
                for child in value.values():
                    walk(child)
            elif isinstance(value, list):
                for child in value:
                    walk(child)

        walk(ui_grid)

    preferred = []
    for row in examples:
        summary = " ".join(
            str(row.get(key) or "")
            for key in ("name", "statSummary", "equipmentSkillSummary", "rawHex")
        )
        if any(token in summary for token in ("명주", "호랑이", "마인아수라", "투신", "백호")):
            preferred.append(row)
    return (preferred or examples)[:8]


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    enemy = read_json(ENEMY_STAT_TABLE)
    ui_grid = read_json(UI_GRID)
    all_enemy_actor_rows = enemy_actor_rows(exe, enemy.get("rows", []))
    init_disasm = disassemble(0x0040BE38, 0x0040BFA0)
    hit_disasm = disassemble(0x00434218, 0x004343DB)
    critical_disasm = disassemble(0x004343DB, 0x004344AB)
    status_disasm = disassemble(0x004344AB, 0x0043461F)
    damage_disasm = disassemble(0x0043461F, 0x0043485E)
    exp_fanout_disasm = disassemble(0x0040DFB8, 0x0040E0C0)
    exp_formula_disasm = disassemble(0x0042157B, 0x00421780)
    gold_drop_disasm = disassemble(0x00435736, 0x004357D0)
    return {
        "version": 1,
        "kind": "hwanse-battle-actor-stat-layout-review",
        "source": {
            "script": str(Path(__file__).relative_to(ROOT)),
            "exe": "Hwanse2.exe",
            "enemyStatTable": str(ENEMY_STAT_TABLE.relative_to(ROOT)),
            "uiGridMappings": str(UI_GRID.relative_to(ROOT)),
        },
        "status": "actor-stat-layout-grounded",
        "tables": {
            "actorRowTableVa": ACTOR_ROW_TABLE_VA,
            "actorRowTableVaHex": hex32(ACTOR_ROW_TABLE_VA),
            "actorRowSize": ACTOR_ROW_SIZE,
            "actorRowSizeHex": f"0x{ACTOR_ROW_SIZE:02x}",
            "exportedMonsterHpViewVa": EXPORTED_MONSTER_TABLE_VA,
            "exportedMonsterHpViewVaHex": hex32(EXPORTED_MONSTER_TABLE_VA),
            "exportedMonsterHpViewFileOffset": EXPORTED_MONSTER_TABLE_FILE_OFFSET,
            "exportedMonsterHpViewFileOffsetHex": f"0x{EXPORTED_MONSTER_TABLE_FILE_OFFSET:06x}",
        },
        "summary": [
            "전투 actor 초기화 루틴은 0x00457c60 기반 0x38-byte row prefix 8바이트를 actor +0x00으로 복사한다.",
            "HP view로 내보낸 out/enemy_stat_table row는 actual actor row +0x08에서 시작한다. 따라서 exported +0x06은 actor +0x1c attack, exported +0x0a는 actor +0x20 defense처럼 8바이트 보정이 필요하다.",
            "exported +0x34 name id는 current actor row 내부가 아니라 다음 actual row prefix의 +0x04 marker다. 이름 anchor로는 유효하지만 current actor struct 필드는 아니다.",
            "플레이어 row 장비/레벨업 루틴 대조로 actor +0x26은 운 축으로 승격됐다. +0x28은 상태창/장비 운 보정 대상이 아니며 0x4343db critical/alternate 판정과 0x4344ab 상태 성공 gate의 attacker-side 숨은 발동률 계수로 분리한다.",
            "actor +0x2a는 숫자 계수가 아니라 0x48bcaa 자세/상태 테이블 index다. actual row +0x1c/exported +0x14는 초기 mode/status id이며, 몬스터 대부분의 값 1은 보통 상태다.",
            "actor +0x14는 몬스터에서는 EXP base, 플레이어에서는 current EXP다. 전투 보상 루프가 defeated enemy +0x14로 보상 EXP를 계산한 뒤 live player actor +0x14에 직접 더한다.",
            "actor +0x40/+0x41은 전투 종료 아이템 드롭 item id/probability로 확정됐다. 단 현재 80개 연속 몬스터 row의 source HP-view +0x2a/+0x2b가 전부 0이라 일반 몬스터 보상은 EXP/골드만 있고 아이템 드롭은 비활성이다.",
            "actor +0x1e/exported +0x08은 전체 80개 몬스터 row에 값이 있지만 전투 damage/hit/status 공식의 직접 소비처가 확인되지 않는다. 현재 전투 엔진 구현에서는 저장된 튜닝 축으로 보존하고 공식 필드로는 사용하지 않는다.",
            "몬스터 HP view exporter의 설명은 이름 anchor 용도로만 유지한다. 실제 actor row 내부 구조는 이 보고서의 actual row/actor offset mapping을 기준으로 본다.",
        ],
        "layoutRows": [
            {
                **row,
                "actualOffsetHex": hex_off(row["actualOffset"]) if isinstance(row["actualOffset"], int) else str(row["actualOffset"] or ""),
                "exportedOffsetHex": hex_off(row["exportedOffset"]) if isinstance(row["exportedOffset"], int) else "",
                "actorOffsetHex": hex_off(row["actorOffset"]) if isinstance(row["actorOffset"], int) else "",
            }
            for row in LAYOUT_ROWS
        ],
        "enemyActorRows": all_enemy_actor_rows,
        "actorRowSamples": actor_row_samples(all_enemy_actor_rows),
        "axis1eDistribution": axis1e_distribution(enemy.get("rows", [])),
        "equipmentExamples": extract_equipment_examples(ui_grid),
        "disassemblyEvidence": [
            {
                "label": "actor initializer row copy",
                "vaHex": "0x0040be38",
                "keyLines": compact_lines(
                    init_disasm,
                    [
                        "push   0x8",
                        "0x457c60",
                        "[eax*8+0x457c68]",
                        "[ecx+0x8]",
                        "[eax*8+0x457c6a]",
                        "[ecx+0xe]",
                        "[eax*8+0x457c6c]",
                        "[ecx+0x14]",
                        "[eax+0x16]",
                        "push   0x2a",
                        "add    eax,0xe",
                        "add    eax,0x1c",
                    ],
                    context=1,
                ),
            },
            {
                "label": "hit/avoid formula fields",
                "vaHex": "0x00434218",
                "keyLines": compact_lines(hit_disasm, ["[eax+0x6]", "[eax+0x22]", "[eax+0x74]", "[eax+0x24]", "[eax+0x7c]", "+0x36"], context=0),
            },
            {
                "label": "critical/alternate candidate fields",
                "vaHex": "0x004343db",
                "keyLines": compact_lines(critical_disasm, ["[eax+0x28]", "[eax+0x72]", "[eax+0x26]", "[eax+0x7e]", "or     cl,0x10"], context=0),
            },
            {
                "label": "status gate fields",
                "vaHex": "0x004344ab",
                "keyLines": compact_lines(status_disasm, ["[eax+0x6c]", "[eax+0x26]", "[eax+0x28]", "[eax+0x22]", "+0x39", "c6 40 6c 00"], context=0),
            },
            {
                "label": "damage formula fields",
                "vaHex": "0x0043461f",
                "keyLines": compact_lines(damage_disasm, ["[eax+0x70]", "[eax+0x1c]", "[eax+0x7a]", "[eax+0x20]", "[eax+0x6b]"], context=0),
            },
            {
                "label": "victory EXP fanout",
                "vaHex": "0x0040dfb8",
                "keyLines": compact_lines(
                    exp_fanout_disasm,
                    ["[eax+0x62]", "test   cl,0x40", "test   cl,0x80", "0x42157b", "[ecx+0x14]", "0x435736"],
                    context=0,
                ),
            },
            {
                "label": "EXP reward formula",
                "vaHex": "0x0042157b",
                "keyLines": compact_lines(
                    exp_formula_disasm,
                    ["[eax+0x6]", "[eax+0x14]", "0x457798", "0x457799", "shr    eax,0x1"],
                    context=0,
                ),
            },
            {
                "label": "gold reward / inactive item-drop handler",
                "vaHex": "0x00435736",
                "keyLines": compact_lines(gold_drop_disasm, ["[eax+0x44]", "0x4576e0", "0xf423f", "[eax+0x40]", "[eax+0x41]", "0x422093"], context=0),
            },
        ],
        "openQuestions": [],
    }


def tag_class(status: str) -> str:
    if "확정" in status or "고신뢰" in status or "grounded" in status:
        return "good"
    if "후보" in status or "의미" in status:
        return "warn"
    return ""


def render_table(headers: list[str], rows: list[dict[str, Any]], keys: list[str]) -> str:
    head = "".join(f"<th>{esc(header)}</th>" for header in headers)
    body = []
    for row in rows:
        cells = []
        for key in keys:
            value = row.get(key, "")
            if key == "status":
                cells.append(f'<td><span class="tag {tag_class(str(value))}">{esc(value)}</span></td>')
            else:
                cells.append(f"<td>{esc(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_html(data: dict[str, Any]) -> str:
    summary_items = "".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    open_items = "".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    evidence_cards = []
    for row in data["disassemblyEvidence"]:
        evidence_cards.append(
            f"""
            <details>
              <summary>{esc(row['vaHex'])} {esc(row['label'])}</summary>
              <pre>{esc(chr(10).join(row['keyLines']) or '(no key lines)')}</pre>
            </details>
            """
        )
    return f"""<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" href="../favicon.ico" />
    <title>전투 Actor 스탯 Row 레이아웃</title>
    <style>
      :root {{
        color-scheme: light;
        --bg: #f6f7f9;
        --fg: #17202a;
        --muted: #607080;
        --line: #d8dee6;
        --head: #eef2f6;
        --link: #185abc;
        --good: #0f766e;
        --warn: #a15c00;
      }}
      * {{ box-sizing: border-box; }}
      body {{
        margin: 0;
        background: var(--bg);
        color: var(--fg);
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        line-height: 1.45;
      }}
      main {{ max-width: 1500px; margin: 0 auto; padding: 18px; }}
      header {{ display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }}
      h1 {{ margin: 0 0 6px; font-size: 24px; }}
      h2 {{ margin: 22px 0 8px; font-size: 18px; }}
      .sub, .muted {{ color: var(--muted); }}
      nav {{ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }}
      a {{ color: var(--link); text-decoration: none; font-weight: 650; }}
      a:hover {{ text-decoration: underline; }}
      .panel {{
        background: white;
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: 14px;
        margin: 12px 0;
      }}
      table {{ width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--line); }}
      th, td {{ border-bottom: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; font-size: 13px; }}
      th {{ background: var(--head); position: sticky; top: 0; z-index: 1; }}
      code, pre {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
      pre {{ white-space: pre-wrap; overflow-x: auto; background: #101418; color: #e8edf2; border-radius: 6px; padding: 10px; font-size: 12px; }}
      details {{ background: white; border: 1px solid var(--line); border-radius: 8px; margin: 10px 0; }}
      summary {{ cursor: pointer; padding: 10px 12px; background: var(--head); font-weight: 700; }}
      details pre {{ margin: 10px 12px 12px; }}
      .tag {{ display: inline-block; padding: 2px 7px; border-radius: 999px; background: #edf2f7; color: #334155; font-size: 12px; white-space: nowrap; }}
      .tag.good {{ color: var(--good); background: #e6f4f1; }}
      .tag.warn {{ color: var(--warn); background: #fff4df; }}
      @media (max-width: 760px) {{
        header {{ display: block; }}
        nav {{ justify-content: flex-start; margin-top: 10px; }}
        th, td {{ min-width: 150px; }}
        table {{ display: block; overflow-x: auto; }}
      }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <div>
          <h1>전투 Actor 스탯 Row 레이아웃</h1>
          <p class="sub">EXE actor 초기화 루틴 기준으로 monster HP view와 실제 actor struct offset의 8바이트 차이를 정리한 자료.</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="../web/battle_simulator.html">전투 기술 실행</a>
          <a href="enemy_stat_table.html">몬스터 원문 표</a>
          <a href="battle_player_actor_stat_layout_review.html">플레이어 Actor 레이아웃</a>
          <a href="battle_stat_flag_status_semantics_review.html">스탯/플래그/상태</a>
          <a href="battle_damage_formula_trace_review.html">계산 추적</a>
          <a href="battle_actor_stat_layout_review.json">JSON</a>
          <a href="battle_actor_stat_layout_review.md">MD</a>
        </nav>
      </header>

      <section class="panel">
        <h2>요약</h2>
        <ul>{summary_items}</ul>
      </section>

      <h2>Offset Mapping</h2>
      {render_table(['actual row', 'exported HP view', 'actor', 'width', 'role', 'status', 'evidence'], data['layoutRows'], ['actualOffsetHex', 'exportedOffsetHex', 'actorOffsetHex', 'width', 'role', 'status', 'evidence'])}

      <h2>Sample Rows</h2>
      {render_table(['name', 'actual row', 'HP view', 'prefix', 'level', 'HP', 'MP', 'EXP base', '+1c atk', '+1e', '+20 def', '+22 tech', '+24 agi', '+26', '+28', '+2a init state', '+36..+39 hit class', 'gold', 'boundary name id'], data['actorRowSamples'], ['name', 'actualRowVaHex', 'exportedHpViewVaHex', 'prefixHex', 'level', 'hp', 'mp', 'expBase', 'attack', 'axis1e', 'defense', 'technique', 'agility', 'luckResistAxis', 'baseCoeff', 'initialModeStatusId', 'hitAvoidClassTable', 'gold', 'boundaryNameIdHex'])}

      <h2>+1e Distribution</h2>
      <p class="muted">전체 80개 몬스터 row의 exported +0x08 / actor +0x1e 값 분포. 값은 존재하지만 현재 확인된 전투 공식에서는 소비되지 않는다.</p>
      {render_table(['value', 'count', 'examples'], data['axis1eDistribution'], ['value', 'count', 'examples'])}

      <h2>Equipment Stat Contrast</h2>
      <p class="muted">장비 f00~f04는 상태창 스탯으로 이미 별도 디코드되어 있다. 몬스터 actor +0x26/+0x28과 같은 전투 공식 축을 이 장비 필드명에 그대로 덮어쓰지 않는다.</p>
      {render_table(['name', 'record', 'stat summary', 'battle modifiers', 'equipment skills', 'raw'], data['equipmentExamples'], ['name', 'recordVaHex', 'statSummary', 'battleModifierSummary', 'equipmentSkillSummary', 'rawHex'])}

      <h2>Disassembly Evidence</h2>
      {''.join(evidence_cards)}

      <section class="panel">
        <h2>남은 미확정</h2>
        <ul>{open_items}</ul>
      </section>
    </main>
  </body>
</html>
"""


def render_md(data: dict[str, Any]) -> str:
    lines = [
        "# 전투 Actor 스탯 Row 레이아웃",
        "",
        "## 요약",
        "",
        *[f"- {item}" for item in data["summary"]],
        "",
        "## Offset Mapping",
        "",
        "| actual row | exported HP view | actor | width | role | status |",
        "| --- | --- | --- | --- | --- | --- |",
    ]
    for row in data["layoutRows"]:
        lines.append(
            f"| `{row['actualOffsetHex']}` | `{row['exportedOffsetHex']}` | `{row['actorOffsetHex']}` | "
            f"{row['width']} | {row['role']} | {row['status']} |"
        )
    lines.extend(
        [
            "",
            "## Sample Rows",
            "",
            "| name | actual row | HP view | level | HP | EXP base | atk | def | tech | agi | +26 | +28 | +2a init | +36..+39 hit class | gold |",
            "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |",
        ]
    )
    for row in data["actorRowSamples"]:
        lines.append(
            f"| {row['name']} | `{row['actualRowVaHex']}` | `{row['exportedHpViewVaHex']}` | {row['level']} | "
            f"{row['hp']} | {row['expBase']} | {row['attack']} | {row['defense']} | {row['technique']} | {row['agility']} | "
            f"{row['luckResistAxis']} | {row['baseCoeff']} | {row['initialModeStatusId']} | `{row['hitAvoidClassTable']}` | {row['gold']} |"
        )
    lines.extend(
        [
            "",
            "## +1e Distribution",
            "",
            "| value | count | examples |",
            "| ---: | ---: | --- |",
        ]
    )
    for row in data["axis1eDistribution"]:
        lines.append(f"| {row['value']} | {row['count']} | {row['examples']} |")
    lines.extend(["", "## 남은 미확정", ""])
    if data["openQuestions"]:
        lines.extend(f"- {item}" for item in data["openQuestions"])
    else:
        lines.append("- 없음")
    return "\n".join(lines) + "\n"


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    data = build()
    (OUT / "battle_actor_stat_layout_review.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    html_text = "\n".join(line.rstrip() for line in render_html(data).splitlines()) + "\n"
    (OUT / "battle_actor_stat_layout_review.html").write_text(html_text, encoding="utf-8")
    (OUT / "battle_actor_stat_layout_review.md").write_text(render_md(data), encoding="utf-8")


if __name__ == "__main__":
    main()
