#!/usr/bin/env python3
"""Summarize battle end, EXP/gold reward, and inactive item-drop handler flow."""

from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
WEB = ROOT / "web"
OUT = ROOT / "out"
CAPTURES = ROOT / "captures" / "runtime"
ENEMY_STAT_TABLE = OUT / "enemy_stat_table.json"
RUNTIME_TRACE_IDS = ["levelup-reward", "battle-exp-level-gold"]
PRIMARY_RUNTIME_TRACE_ID = "levelup-reward"
GLOBAL_MONEY_VA = 0x004576E0
GLOBAL_MONEY_CAP = 999999


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


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


def pe_sections() -> list[dict[str, Any]]:
    data = EXE.read_bytes()
    pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
    section_count = struct.unpack_from("<H", data, pe_offset + 6)[0]
    optional_header_size = struct.unpack_from("<H", data, pe_offset + 20)[0]
    image_base = struct.unpack_from("<I", data, pe_offset + 24 + 28)[0]
    section_offset = pe_offset + 24 + optional_header_size
    sections = []
    for index in range(section_count):
        offset = section_offset + index * 40
        name = data[offset : offset + 8].split(b"\0")[0].decode(errors="replace")
        virtual_size, virtual_address, raw_size, raw_offset = struct.unpack_from("<IIII", data, offset + 8)
        sections.append(
            {
                "name": name,
                "vaStart": image_base + virtual_address,
                "vaEnd": image_base + virtual_address + max(virtual_size, raw_size),
                "rawOffset": raw_offset,
                "rawSize": raw_size,
                "virtualSize": virtual_size,
            }
        )
    return sections


def va_to_file_offset(va: int) -> tuple[dict[str, Any], int]:
    for section in pe_sections():
        if section["vaStart"] <= va < section["vaEnd"]:
            return section, int(section["rawOffset"]) + (va - int(section["vaStart"]))
    raise ValueError(f"VA outside PE sections: {hx(va)}")


def build_money_global_evidence() -> dict[str, Any]:
    data = EXE.read_bytes()
    section, file_offset = va_to_file_offset(GLOBAL_MONEY_VA)
    initial_value = struct.unpack_from("<I", data, file_offset)[0]
    direct_ref_count = data.count(struct.pack("<I", GLOBAL_MONEY_VA))
    plus_two_ref_count = data.count(struct.pack("<I", GLOBAL_MONEY_VA + 2))
    return {
        "address": GLOBAL_MONEY_VA,
        "addressHex": hx(GLOBAL_MONEY_VA),
        "section": section["name"],
        "fileOffset": file_offset,
        "fileOffsetHex": hx(file_offset),
        "initialDword": initial_value,
        "cap": GLOBAL_MONEY_CAP,
        "directAddressRefCount": direct_ref_count,
        "plusTwoAddressRefCount": plus_two_ref_count,
        "initialBytes": data[file_offset : file_offset + 16].hex(" "),
        "conclusion": "global money dword; PE initial value is 200 and reward/shop handlers read or mutate the same address",
    }


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.rstrip())
    return result


FLOW_ROWS = [
    {
        "step": "defeated actor scan",
        "va": 0x0040DFB8,
        "meaning": "target range helper(0x433c99/0x433dc0)로 전투 대상 범위를 돌며 actor kind +0x04가 enemy 쪽이고 +0x62 bit 0x40(KO)인 actor만 보상 대상으로 처리한다.",
        "status": "확정",
    },
    {
        "step": "EXP fan-out to live players",
        "va": 0x0040E028,
        "meaning": "player actor slot 0x4576e8 범위를 돌며 +0x62 bit 0x80 비활성/기절 제외 actor에게만 0x42157b 경험치 공식을 적용하고 player +0x14 current EXP에 더한다.",
        "status": "확정",
    },
    {
        "step": "EXP formula",
        "va": 0x0042157B,
        "meaning": "player/enemy actor +0x06 level 차이를 최대 10으로 clamp하고, enemy actor +0x14 EXP base를 기준으로 보정한다. enemy actor +0x14는 enemy stat row actual +0x0c byte(HP-view +0x04)에서 온다.",
        "status": "확정",
    },
    {
        "step": "equipment EXP penalty",
        "va": 0x00421699,
        "meaning": "player/equipment class table 0x457798 == 0x1d 또는 0x457799 in (0x0b, 0x17)이면 EXP를 절반으로 줄이되 1 미만으로 내리지 않는다.",
        "status": "확정",
    },
    {
        "step": "gold reward",
        "va": 0x00435736,
        "meaning": "defeated actor +0x44 gold를 global money 0x4576e0에 더하고 0x000f423f(999999)로 cap한다.",
        "status": "확정",
    },
    {
        "step": "global money initial value",
        "va": GLOBAL_MONEY_VA,
        "meaning": "PE .data의 0x004576e0 dword 초기값이 200이다. 새 프로세스/처음부터 시작의 기본 소지금 200과 일치하며, 같은 주소가 보상/상점 흐름에서 읽고 쓰인다.",
        "status": "확정",
    },
    {
        "step": "level-up carryover",
        "va": 0x0042177F,
        "meaning": "레벨업 처리 후 player actor +0x14 current EXP는 (current EXP - required EXP actor +0x16) * 3 / 4로 축소되어 다음 레벨 잔여 경험치가 된다.",
        "status": "확정",
    },
    {
        "step": "item-drop handler",
        "va": 0x0043577C,
        "meaning": "defeated actor +0x40 item id가 0이 아니면 +0x41 확률/분모로 random(0x427730)을 돌린다. 성공 시 0x422093(item id, 1)로 소지품을 1개 추가한다.",
        "status": "확정",
    },
]


FUNCTION_SPECS = [
    {
        "label": "battle KO reward loop",
        "range": (0x0040DFB8, 0x0040E0C2),
        "needles": [
            "0x433c99",
            "0x433dc0",
            "[eax+0x4]",
            "test   cl,0x40",
            "0x4576e8",
            "test   cl,0x80",
            "call   0x42157b",
            "[ecx+0x14]",
            "call   0x435736",
        ],
        "context": 1,
    },
    {
        "label": "EXP reward formula",
        "range": (0x0042157B, 0x0042177F),
        "needles": [
            "[eax+0x6]",
            "[eax+0x14]",
            "cmp    eax,0xa",
            "mov    WORD PTR [ebp-0x4],0x1",
            "0x457798",
            "0x457799",
            "shr    eax,0x1",
        ],
        "context": 1,
    },
    {
        "label": "gold reward / item-drop handler",
        "range": (0x00435736, 0x004357D0),
        "needles": [
            "[eax+0x44]",
            "0x4576e0",
            "0xf423f",
            "[eax+0x40]",
            "[eax+0x41]",
            "call   0x427730",
            "call   0x422093",
        ],
        "context": 1,
    },
    {
        "label": "money add/subtract VM helper",
        "range": (0x00406A38, 0x00406AE8),
        "needles": [
            "0x4576e0",
            "0xf423f",
            "add    DWORD PTR ds:0x4576e0",
            "sub    DWORD PTR ds:0x4576e0",
        ],
        "context": 1,
    },
    {
        "label": "shop/inventory money checks",
        "range": (0x0040FD11, 0x0041004F),
        "needles": [
            "0x4576e0",
            "div    ebx",
            "cmp    eax,0xa",
            "sub    DWORD PTR ds:0x4576e0",
        ],
        "context": 1,
    },
    {
        "label": "level-up carryover",
        "range": (0x0042177F, 0x00421B33),
        "needles": [
            "inc    WORD PTR [eax+0x6]",
            "[eax+0x14]",
            "[eax+0x16]",
            "lea    eax,[ecx+ecx*2]",
            "sar    eax,0x2",
            "mov    WORD PTR [ecx+0x14],ax",
            "mov    al,0x1",
        ],
        "context": 1,
    },
]


def latest_runtime_trace(trace_id: str) -> tuple[Path | None, Path | None]:
    if not CAPTURES.exists():
        return None, None
    manifests = sorted(CAPTURES.glob(f"*-{trace_id}.manifest.json"), key=lambda path: path.stat().st_mtime)
    if not manifests:
        return None, None
    manifest = manifests[-1]
    jsonl = manifest.with_name(manifest.name.replace(".manifest.json", ".jsonl"))
    return manifest, jsonl if jsonl.exists() else None


def decode_cp949(raw: Any) -> str:
    if not isinstance(raw, str):
        return ""
    text = raw.strip()
    if not text:
        return ""
    parts = [part for part in re.split(r"[\s,]+", text) if part]
    try:
        return bytes(int(part, 16) for part in parts).decode("cp949", errors="replace")
    except ValueError:
        return ""


def summarize_runtime_trace(trace_id: str) -> dict[str, Any] | None:
    manifest_path, jsonl_path = latest_runtime_trace(trace_id)
    if not manifest_path or not jsonl_path:
        return None
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    counts: Counter[str] = Counter()
    text_draws: list[dict[str, Any]] = []
    resource_labels: Counter[str] = Counter()
    mem_write_names: Counter[str] = Counter()
    vm_tail: list[dict[str, Any]] = []
    object_tail: list[dict[str, Any]] = []
    probe_value_changes: list[dict[str, Any]] = []
    static_address_hits: list[dict[str, Any]] = []
    important_probe_addresses = {
        "0x00457756": "player level",
        "0x0045775A": "player max HP candidate",
        "0x0045775C": "player current HP candidate",
        "0x00457760": "player max MP candidate",
        "0x00457762": "player current MP candidate",
        "0x004870B2": "animated/display HP candidate",
        "0x004870B4": "animated/display level-or-state candidate",
        "0x004870CA": "animated/display EXP candidate",
    }
    important_pcs = {
        "0x00420674": "animated value increment",
        "0x00420678": "animated value wrap/reset",
        "0x00420685": "animated value increment",
        "0x00420690": "animated value increment",
        "0x00420693": "animated value increment",
        "0x00420695": "animated value increment",
        "0x00421802": "level-up threshold/application",
        "0x00427730": "rng helper",
        "0x00427763": "rng return/write observer",
    }
    important_callers = {
        "0x004217DF": "level-up HP increase RNG call",
        "0x0042183B": "level-up stat RNG call",
        "0x00421897": "level-up stat RNG call",
        "0x004218DF": "level-up stat RNG call",
        "0x00421927": "level-up stat RNG call",
        "0x0042196F": "level-up stat RNG call",
        "0x004219AD": "level-up luck/random branch call",
    }
    with jsonl_path.open("r", encoding="utf-8", errors="replace") as handle:
        for line in handle:
            try:
                event = json.loads(line)
            except json.JSONDecodeError:
                continue
            kind = str(event.get("event", ""))
            counts[kind] += 1
            if kind == "text-draw":
                text_draws.append(
                    {
                        "frame": event.get("frame"),
                        "pc": event.get("pc"),
                        "rect": event.get("rect") or event.get("bounds"),
                        "text": decode_cp949(event.get("raw") or event.get("rawBytes") or event.get("bytes")) or event.get("text"),
                    }
                )
            elif kind in {"resource-surface-label", "surface-snapshot", "surface-upload", "draw-surface"}:
                name = event.get("sourceResourceName") or event.get("sourceLabel") or event.get("resourceName")
                if isinstance(name, str) and (name.endswith(".cns") or name.endswith(".mid") or name.endswith(".wlk")):
                    resource_labels[name] += 1
            elif kind == "mem-write":
                name = event.get("watchName") or event.get("name") or event.get("address")
                if name:
                    mem_write_names[str(name)] += 1
            elif kind == "probe-value-change":
                address = str(event.get("address"))
                pc = str(event.get("pc"))
                if address in important_probe_addresses or pc in important_pcs:
                    state = event.get("state") or {}
                    probe_value_changes.append(
                        {
                            "frame": event.get("frame"),
                            "pc": pc,
                            "address": address,
                            "label": important_probe_addresses.get(address, ""),
                            "old": event.get("old"),
                            "new": event.get("new"),
                            "cpuEip": state.get("cpu_eip"),
                            "cpuEax": state.get("cpu_eax"),
                            "cpuEcx": state.get("cpu_ecx"),
                            "cpuEdx": state.get("cpu_edx"),
                        }
                    )
                    probe_value_changes = probe_value_changes[-80:]
            elif kind == "function-enter":
                pc = str(event.get("pc"))
                caller = str(event.get("caller"))
                if pc in important_pcs or caller in important_callers:
                    static_address_hits.append(
                        {
                            "frame": event.get("frame"),
                            "pc": pc,
                            "caller": caller,
                            "function": event.get("function"),
                            "meaning": important_callers.get(caller) or important_pcs.get(pc, ""),
                            "args": event.get("args"),
                        }
                    )
                    static_address_hits = static_address_hits[-40:]
            elif kind == "vm-op":
                vm_tail.append(
                    {
                        "frame": event.get("frame"),
                        "pc": event.get("pc"),
                        "stream": event.get("stream") or event.get("streamVa"),
                        "opcode": event.get("opcode"),
                        "raw": event.get("raw") or event.get("rawBytes"),
                    }
                )
                vm_tail = vm_tail[-12:]
            elif kind == "object-stream-change":
                object_tail.append(
                    {
                        "frame": event.get("frame"),
                        "object": event.get("object") or event.get("objectVa"),
                        "old": event.get("old") or event.get("oldStream"),
                        "new": event.get("new") or event.get("newStream"),
                    }
                )
                object_tail = object_tail[-12:]
    direct_levelup_captured = any(
        row.get("address") == "0x00457756" and row.get("old") == "1" and row.get("new") == "2"
        for row in probe_value_changes
    )
    exp_rollover_captured = any(
        row.get("address") == "0x004870CA" and row.get("old") == "100" and row.get("new") == "0"
        for row in probe_value_changes
    )
    stat_growth_captured = any(row.get("address") in {"0x0045775A", "0x00457760"} for row in probe_value_changes)
    return {
        "traceId": manifest.get("trace_id"),
        "manifest": manifest_path.name,
        "jsonl": jsonl_path.name,
        "profiles": manifest.get("profiles", []),
        "frameRange": [manifest.get("start_frame"), manifest.get("end_frame")],
        "eventCount": manifest.get("event_count"),
        "topEventCounts": counts.most_common(12),
        "textDraws": text_draws[-12:],
        "resourceLabels": resource_labels.most_common(20),
        "memWriteNames": mem_write_names.most_common(12),
        "probeValueChanges": probe_value_changes,
        "staticAddressHits": static_address_hits,
        "vmTail": vm_tail,
        "objectStreamTail": object_tail,
        "directRewardWriteCaptured": direct_levelup_captured or exp_rollover_captured or stat_growth_captured,
        "directLevelupCaptured": direct_levelup_captured,
        "expRolloverCaptured": exp_rollover_captured,
        "statGrowthCaptured": stat_growth_captured,
        "note": (
            "levelup-reward 캡처는 value-probe로 EXP 88→100→0→7, level 1→2, HP/MP 성장 후보를 정적 level-up 주소 근처에서 직접 잡는다."
            if trace_id == PRIMARY_RUNTIME_TRACE_ID
            else "이전 캡처는 보상 관찰치와 전투 종료 경계 bridge로 유지한다."
        ),
    }


def find_enemy_row(enemy_stat: dict[str, Any], clean_name: str) -> dict[str, Any] | None:
    for row in enemy_stat.get("rows", []):
        if row.get("cleanName") == clean_name or row.get("name") == clean_name:
            return row
    return None


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


def exp_reward(exp_base: int, player_level: int, enemy_level: int, *, penalty: bool = False) -> int:
    diff = min(abs(int(enemy_level) - int(player_level)), 10)
    base = int(exp_base)
    if int(enemy_level) < int(player_level):
        reward = max(base - (base * diff * 12) // 100, 1)
    else:
        reward = base + (base * diff * diff) // 100
    if penalty:
        reward = max(reward // 2, 1)
    return reward


def exp_base_for_row(row: dict[str, Any]) -> int | None:
    return word_by_offset(row, "+0x04")


def monster_exp_options(enemy_stat: dict[str, Any]) -> list[dict[str, Any]]:
    options = []
    for row in enemy_stat.get("rows", []):
        exp_base = exp_base_for_row(row)
        if exp_base is None:
            continue
        next_row_level = word_by_offset(row, "+0x36")
        level = row.get("level")
        options.append(
            {
                "index": row.get("index"),
                "name": row.get("cleanName") or row.get("name"),
                "expBase": exp_base,
                "hp": row.get("hp"),
                "gold": row.get("gold"),
                "enemyLevel": level,
                "nextRowLevelAtHpView36": next_row_level,
                "levelSource": row.get("levelSource") or "actual stat row +0x06 / exported HP-view -0x02; copied to actor +0x06",
            }
        )
    return options


def build_exp_readable(enemy_stat: dict[str, Any]) -> dict[str, Any]:
    monkey = find_enemy_row(enemy_stat, "원숭이")
    monkey_exp = exp_base_for_row(monkey) if monkey else 22
    monkey_level = int(monkey.get("level", 2)) if monkey else 2
    example_rows = []
    for player_level in range(1, 13):
        reward = exp_reward(int(monkey_exp or 22), player_level, monkey_level)
        example_rows.append(
            {
                "playerLevel": player_level,
                "enemyLevel": monkey_level,
                "diff": min(abs(monkey_level - player_level), 10),
                "reward": reward,
            }
        )
    return {
        "title": "EXP 획득 계산",
        "formulaSummary": [
            "몬스터별 EXP 기준값은 enemy stat HP-view +0x04에서 온다. 전투 actor에서는 enemy actor +0x14로 materialize된다.",
            "몬스터 레벨은 실제 stat row prefix +0x06에서 온다. HP-view export 기준으로는 -0x02이며, 전투 actor +0x06으로 복사되어 EXP 공식이 소비한다.",
            "HP-view +0x36은 다음 실제 row prefix +0x06을 읽는 값이므로 한 행 밀린 다음 몬스터 레벨이다.",
            "레벨 차이는 최대 10까지만 반영된다.",
            "몬스터 레벨이 주인공보다 낮으면 EXP가 감소하고, 그래도 최소 1은 획득한다.",
            "몬스터 레벨이 같거나 높으면 EXP 기준값 이상을 획득한다.",
            "일부 장비/상태 페널티가 걸리면 계산된 EXP를 절반으로 줄이되 최소 1은 유지한다.",
        ],
        "steps": [
            "enemyExpBase = 몬스터 EXP 기준값(+0x04)",
            "diff = min(abs(enemyLevel - playerLevel), 10)",
            "enemyLevel < playerLevel 이면 max(enemyExpBase - enemyExpBase * diff * 12 / 100, 1)",
            "enemyLevel >= playerLevel 이면 enemyExpBase + enemyExpBase * diff * diff / 100",
            "EXP 페널티 장비/상태가 있으면 max(result / 2, 1)",
        ],
        "example": {
            "monster": "원숭이",
            "enemyExpBase": monkey_exp,
            "enemyLevel": monkey_level,
            "enemyLevelSource": "actual row +0x06 / HP-view -0x02",
            "rows": example_rows,
        },
        "monsterOptions": monster_exp_options(enemy_stat),
    }


def build_runtime_bridge(enemy_stat: dict[str, Any]) -> dict[str, Any]:
    monkey = find_enemy_row(enemy_stat, "원숭이")
    exp_base = word_by_offset(monkey, "+0x04") if monkey else None
    gold = None
    if monkey:
        for field in monkey.get("confirmedFields", []):
            if field.get("label") == "gold":
                gold = field.get("value")
                break
    runtimes = [runtime for trace_id in RUNTIME_TRACE_IDS if (runtime := summarize_runtime_trace(trace_id))]
    runtime = next((item for item in runtimes if item.get("traceId") == PRIMARY_RUNTIME_TRACE_ID), None)
    if runtime is None and runtimes:
        runtime = runtimes[0]
    player_exp_before = 88
    exp_base_int = int(exp_base or 0)
    required = 100
    before_levelup = player_exp_before + exp_base_int
    carry_before_scale = max(0, before_levelup - required)
    return {
        "observation": "levelup-reward runtime: 전투 중 캡처에서 EXP 88→100→0→7, level 1→2, HP 30→35, MP 18→21이 정적 레벨업 구간 근처에서 잡힘. battle-exp-level-gold 관찰치의 gold 212→215도 원숭이 gold 3과 일치.",
        "staticRewardCheck": {
            "enemy": "원숭이",
            "enemyHpViewOffset04ExpBase": exp_base,
            "enemyActorExpBase": exp_base,
            "enemyGold": gold,
            "playerLevelBefore": 1,
            "playerExpBefore": player_exp_before,
            "levelRequirement": required,
            "expAfterRewardBeforeLevelUp": before_levelup,
            "carryBeforeScale": carry_before_scale,
            "carryoverFormula": "(currentExp - requiredExp) * 3 / 4",
            "playerExpAfterLevelUp": (carry_before_scale * 3) // 4,
            "playerLevelAfter": 2,
            "goldBefore": 212,
            "goldAfter": 212 + int(gold or 0),
            "matchesUserObservation": exp_base == 22 and gold == 3 and (carry_before_scale * 3) // 4 == 7,
            "statSourceNote": "enemy_stat_table rows are HP-view rows. EXE materializer uses actual stat row +0x0c byte for enemy actor +0x14 EXP base; in HP-view export this is +0x04.",
        },
        "runtimeTrace": runtime,
        "runtimeTraces": runtimes,
    }


def build() -> dict[str, Any]:
    evidence = []
    for spec in FUNCTION_SPECS:
        start, stop = spec["range"]
        disasm = disassemble(start, stop)
        evidence.append(
            {
                "label": spec["label"],
                "range": f"{hx(start)}..{hx(stop)}",
                "keyLines": compact_lines(disasm, spec["needles"], context=spec["context"]),
            }
        )
    enemy_stat = json.loads(ENEMY_STAT_TABLE.read_text(encoding="utf-8"))
    drop_source_rows = []
    for row in enemy_stat.get("rows", []):
        words = {word.get("offsetHex"): int(word.get("value", 0)) for word in row.get("words", [])}
        packed = words.get("+0x2a", 0)
        item_id = packed & 0xFF
        drop_rate = (packed >> 8) & 0xFF
        if item_id or drop_rate:
            drop_source_rows.append(
                {
                    "index": row.get("index"),
                    "name": row.get("cleanName") or row.get("name"),
                    "itemId": item_id,
                    "dropRate": drop_rate,
                }
            )
    money_global = build_money_global_evidence()
    return {
        "version": 1,
        "kind": "hwanse-battle-end-reward-review",
        "source": str(Path(__file__).relative_to(ROOT)),
        "status": "battle-end-exp-gold-grounded-item-drop-handler-grounded-inactive",
        "summary": [
            "전투 종료 보상은 KO된 enemy actor 기준으로 돌아간다. +0x62 bit 0x40이 KO 플래그다.",
            "경험치는 KO enemy마다 살아있는 player actor에게 fan-out된다. +0x62 bit 0x80 actor는 보상 fan-out에서 제외된다.",
            "경험치 공식은 enemy actor EXP base(+0x14), player/enemy actor level(+0x06) 차이, 장비/아이템 class penalty를 사용하며 최종 보상은 1 미만으로 떨어지지 않는다. enemy actor +0x14는 enemy stat actual +0x0c byte(HP-view +0x04)에서 materialize된다.",
            "레벨업 후 잔여 EXP는 단순 carry가 아니라 (current EXP - required EXP) * 3 / 4로 축소된다. battle-exp-level-gold 캡처의 88 + 원숭이 22 = 110, 요구치 100, 잔여 10 → 7과 일치한다.",
            "levelup-reward 캡처에서 EXP 표시/후보 주소 0x004870ca가 88→100→0→7로 진행되고, player level 후보 0x00457756이 1→2로 바뀌는 것을 확인했다. 같은 프레임에서 0x004217df/0x0042183b/... → 0x00427730 RNG 호출도 잡혀 레벨업 스탯 증가 루틴과 연결된다.",
            "골드는 enemy actor +0x44에서 읽어 global money 0x4576e0에 더하고 999999로 cap한다.",
            f"global money 0x4576e0은 PE .data 파일 오프셋 {money_global['fileOffsetHex']}의 dword이며 초기값은 {money_global['initialDword']}이다. 주소 직접 참조는 {money_global['directAddressRefCount']}개이고 0x4576e2 직접 참조는 {money_global['plusTwoAddressRefCount']}개라 32-bit money dword로 본다.",
            "아이템 드롭 핸들러는 enemy actor +0x40 item id와 +0x41 확률/분모를 사용해 성공 시 0x422093으로 1개 추가한다.",
            "다만 현재 80개 연속 몬스터 stat row의 source HP-view +0x2a/+0x2b는 모두 0이다. 따라서 일반 몬스터 보상은 EXP/골드만 있고 아이템 드롭은 비활성으로 본다.",
            "회복/부활은 별도 흐름이다. 리프레시 워터/마수석은 기절 actor의 0x40/0x80을 해제하고, 다른 회복류는 기절하지 않은 actor의 HP/MP 회복으로 분리된다.",
            "레벨업/스탯 상승/승리 UI 연출은 이 보상 루프 뒤쪽의 별도 화면 흐름이다. 여관/세이브포인트/전투 밖 메뉴 회복도 map/menu event 쪽 흐름이므로 이 보고서 범위에서 제외한다.",
        ],
        "flowRows": [{**row, "vaHex": hx(row["va"])} for row in FLOW_ROWS],
        "formula": {
            "exp": "enemyExpBase = enemy actor +0x14 = enemy stat actual +0x0c byte(HP-view +0x04). diff = min(abs(enemy.level - player.level), 10). enemy lower: max(enemyExpBase - enemyExpBase*diff*12/100, 1). enemy same/higher: enemyExpBase + enemyExpBase*diff*diff/100. Equipment class penalties halve with min 1.",
            "levelUpCarryover": "if currentExp(+0x14) reaches requiredExp(+0x16): level++, then currentExp = (currentExp - requiredExp) * 3 / 4. Example: 88 + 22 = 110, required 100, carry 10*3/4 = 7.",
            "gold": "money global dword = 0x004576e0. PE initial value = 200. Reward: money = min(999999, money + defeatedActor.gold(+0x44)). Shop/VM helpers also add/subtract this same address.",
            "itemDrop": "if itemId(+0x40) != 0 and random(dropRate +0x41) == 0: add itemId x1 via 0x422093. Current continuous enemy stat table sets all itemId/dropRate pairs to 0.",
        },
        "moneyGlobal": money_global,
        "expReadable": build_exp_readable(enemy_stat),
        "itemDropSourceRows": drop_source_rows,
        "itemDropSourceConclusion": {
            "source": "enemy_stat_table HP-view +0x2a/+0x2b -> actor +0x40/+0x41",
            "nonZeroRows": len(drop_source_rows),
            "conclusion": "continuous enemy stat table has no active item drops; monster rewards are EXP/gold only" if not drop_source_rows else "some enemy rows define item drops",
        },
        "runtimeBridge": build_runtime_bridge(enemy_stat),
        "evidence": evidence,
        "openQuestions": [],
    }


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    head = "".join(f"<th>{esc(label)}</th>" for _, label in columns)
    body = []
    for row in rows:
        cells = []
        for key, _ in columns:
            value = row.get(key, "")
            if key.endswith("Hex") or key == "range":
                cells.append(f"<td><code>{esc(value)}</code></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_exp_readable_section(exp_data: dict[str, Any]) -> str:
    summary = "".join(f"<li>{esc(item)}</li>" for item in exp_data.get("formulaSummary", []))
    steps = "".join(f"<li>{esc(item)}</li>" for item in exp_data.get("steps", []))
    example = exp_data.get("example", {})
    example_rows = render_table(
        example.get("rows", []),
        [("playerLevel", "주인공 Lv"), ("enemyLevel", "몬스터 Lv"), ("diff", "차이"), ("reward", "획득 EXP")],
    )
    options_json = json.dumps(exp_data.get("monsterOptions", []), ensure_ascii=False)
    return f"""  <section class="exp-calc" id="expCalc">
    <h2>EXP 획득 계산</h2>
    <p class="sub">몬스터별 EXP 기준값과 Lv를 EXE stat row에서 자동으로 채운다. Lv는 actual row +0x06, 즉 HP-view -0x02다.</p>
    <div class="formula-grid">
      <article class="formula-card">
        <h3>계산 순서</h3>
        <ol>{steps}</ol>
      </article>
      <article class="formula-card">
        <h3>핵심 규칙</h3>
        <ul>{summary}</ul>
      </article>
    </div>
    <div class="calculator-panel">
      <div class="controls">
        <label>몬스터
          <select id="expMonsterSelect"></select>
        </label>
        <label>EXP 기준값
          <input id="expBaseInput" type="number" min="1" step="1">
        </label>
        <label>주인공 Lv
          <input id="playerLevelInput" type="number" min="1" max="99" step="1" value="1">
        </label>
        <label>몬스터 Lv
          <input id="enemyLevelInput" type="number" min="1" max="99" step="1" value="1">
        </label>
        <label class="check">
          <input id="expPenaltyInput" type="checkbox"> EXP 절반 페널티
        </label>
      </div>
      <div class="result-card">
        <div class="result-label">획득 EXP</div>
        <div id="expRewardResult" class="result-value">-</div>
        <div id="expRewardFormula" class="formula-line"></div>
        <div id="expMonsterMeta" class="meta-line"></div>
      </div>
    </div>
    <h3>예시: {esc(example.get('monster'))} EXP 기준값 {esc(example.get('enemyExpBase'))}, 몬스터 Lv {esc(example.get('enemyLevel'))}</h3>
    {example_rows}
  </section>
  <script>
    (() => {{
      const monsters = {options_json};
      const select = document.getElementById("expMonsterSelect");
      const expBaseInput = document.getElementById("expBaseInput");
      const playerLevelInput = document.getElementById("playerLevelInput");
      const enemyLevelInput = document.getElementById("enemyLevelInput");
      const penaltyInput = document.getElementById("expPenaltyInput");
      const result = document.getElementById("expRewardResult");
      const formula = document.getElementById("expRewardFormula");
      const meta = document.getElementById("expMonsterMeta");

      function clampInt(value, min, max, fallback) {{
        const parsed = Number.parseInt(value, 10);
        if (!Number.isFinite(parsed)) return fallback;
        return Math.max(min, Math.min(max, parsed));
      }}

      function calcExp(expBase, playerLevel, enemyLevel, penalty) {{
        const diff = Math.min(Math.abs(enemyLevel - playerLevel), 10);
        let value;
        let branch;
        if (enemyLevel < playerLevel) {{
          value = Math.max(expBase - Math.trunc((expBase * diff * 12) / 100), 1);
          branch = "몬스터 레벨이 낮음";
        }} else {{
          value = expBase + Math.trunc((expBase * diff * diff) / 100);
          branch = "몬스터 레벨이 같거나 높음";
        }}
        if (penalty) value = Math.max(Math.trunc(value / 2), 1);
        return {{ value, diff, branch }};
      }}

      function selectedMonster() {{
        return monsters[Number.parseInt(select.value, 10)] || monsters[0] || null;
      }}

      function syncMonster() {{
        const monster = selectedMonster();
        if (!monster) return;
        expBaseInput.value = String(monster.expBase || 1);
        if (monster.enemyLevel) enemyLevelInput.value = String(monster.enemyLevel);
        meta.textContent = `HP ${{monster.hp ?? "-"}} · gold ${{monster.gold ?? "-"}} · row #${{monster.index ?? "-"}} · Lv ${{monster.enemyLevel ?? "-"}} · HP-view +0x36=${{monster.nextRowLevelAtHpView36 ?? "-"}}(다음 행 Lv)`;
        update();
      }}

      function update() {{
        const expBase = clampInt(expBaseInput.value, 1, 9999, 1);
        const playerLevel = clampInt(playerLevelInput.value, 1, 99, 1);
        const enemyLevel = clampInt(enemyLevelInput.value, 1, 99, 1);
        const penalty = penaltyInput.checked;
        const computed = calcExp(expBase, playerLevel, enemyLevel, penalty);
        result.textContent = String(computed.value);
        const penaltyText = penalty ? " → 절반 페널티 적용" : "";
        if (enemyLevel < playerLevel) {{
          formula.textContent = `diff=${{computed.diff}} · ${{computed.branch}} · max(${{expBase}} - floor(${{expBase}}*${{computed.diff}}*12/100), 1)${{penaltyText}}`;
        }} else {{
          formula.textContent = `diff=${{computed.diff}} · ${{computed.branch}} · ${{expBase}} + floor(${{expBase}}*${{computed.diff}}*${{computed.diff}}/100)${{penaltyText}}`;
        }}
      }}

      if (select) {{
        select.innerHTML = monsters.map((monster, index) =>
          `<option value="${{index}}">${{monster.index}} · ${{monster.name}} · Lv ${{monster.enemyLevel ?? "-"}} · EXP ${{monster.expBase}}</option>`
        ).join("");
        select.addEventListener("change", syncMonster);
        for (const input of [expBaseInput, playerLevelInput, enemyLevelInput, penaltyInput]) {{
          input.addEventListener("input", update);
          input.addEventListener("change", update);
        }}
        syncMonster();
      }}
    }})();
  </script>
"""


def write_json(data: dict[str, Any]) -> None:
    (OUT / "battle_end_reward_review.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def write_md(data: dict[str, Any]) -> None:
    lines = [
        "# Battle End Reward Review",
        "",
        "## Summary",
        "",
    ]
    lines += [f"- {item}" for item in data["summary"]]
    lines += ["", "## Flow", ""]
    lines += ["| step | VA | status | meaning |", "|---|---:|---|---|"]
    for row in data["flowRows"]:
        lines.append(f"| {row['step']} | `{row['vaHex']}` | {row['status']} | {row['meaning']} |")
    lines += ["", "## Formula", ""]
    for key, value in data["formula"].items():
        lines.append(f"- `{key}`: {value}")
    money = data.get("moneyGlobal", {})
    lines += ["", "## Global Money", ""]
    if money:
        lines += [
            f"- address: `{money.get('addressHex')}`",
            f"- section/file offset: `{money.get('section')}` / `{money.get('fileOffsetHex')}`",
            f"- PE initial dword: `{money.get('initialDword')}`",
            f"- cap: `{money.get('cap')}`",
            f"- direct address refs: `{money.get('directAddressRefCount')}`",
            f"- `0x004576e2` direct refs: `{money.get('plusTwoAddressRefCount')}`",
            f"- initial bytes: `{money.get('initialBytes')}`",
            f"- conclusion: {money.get('conclusion')}",
        ]
    exp_data = data.get("expReadable", {})
    example = exp_data.get("example", {})
    lines += ["", "## EXP Gain Calculator Notes", ""]
    lines += [f"- {item}" for item in exp_data.get("formulaSummary", [])]
    lines += ["", "### Steps", ""]
    for item in exp_data.get("steps", []):
        lines.append(f"1. {item}")
    lines += [
        "",
        f"### Example: {example.get('monster')} EXP base {example.get('enemyExpBase')}, enemy level {example.get('enemyLevel')}",
        "",
        "| player level | diff | EXP |",
        "|---:|---:|---:|",
    ]
    for row in example.get("rows", []):
        lines.append(f"| {row.get('playerLevel')} | {row.get('diff')} | {row.get('reward')} |")
    bridge = data.get("runtimeBridge", {})
    static = bridge.get("staticRewardCheck", {})
    lines += ["", "## Runtime Bridge", ""]
    if bridge:
        lines.append(f"- observation: {bridge.get('observation')}")
        lines.append(f"- enemy: {static.get('enemy')}")
        lines.append(f"- EXP base: {static.get('enemyActorExpBase')} (`enemy stat HP-view +0x04`)")
        lines.append(f"- level-up carryover: {static.get('playerExpBefore')} + {static.get('enemyActorExpBase')} = {static.get('expAfterRewardBeforeLevelUp')}; carry {static.get('carryBeforeScale')} * 3 / 4 = {static.get('playerExpAfterLevelUp')}")
        lines.append(f"- gold: {static.get('goldBefore')} + {static.get('enemyGold')} = {static.get('goldAfter')}")
        lines.append(f"- matches observation: {static.get('matchesUserObservation')}")
        runtime = bridge.get("runtimeTrace")
        if runtime:
            lines.append(f"- runtime trace: `{runtime.get('manifest')}` frames {runtime.get('frameRange')}")
            lines.append(f"- direct reward write captured: {runtime.get('directRewardWriteCaptured')} ({runtime.get('note')})")
            if runtime.get("probeValueChanges"):
                lines += ["", "### Key Runtime Value Changes", ""]
                lines += ["| frame | PC | address | label | old | new |", "|---:|---:|---:|---|---:|---:|"]
                for row in runtime.get("probeValueChanges", [])[-24:]:
                    lines.append(
                        f"| {row.get('frame')} | `{row.get('pc')}` | `{row.get('address')}` | {row.get('label')} | {row.get('old')} | {row.get('new')} |"
                    )
            if runtime.get("staticAddressHits"):
                lines += ["", "### Runtime Static Address Hits", ""]
                lines += ["| frame | caller | PC | function | meaning |", "|---:|---:|---:|---|---|"]
                for row in runtime.get("staticAddressHits", []):
                    lines.append(
                        f"| {row.get('frame')} | `{row.get('caller')}` | `{row.get('pc')}` | {row.get('function')} | {row.get('meaning')} |"
                    )
    conclusion = data["itemDropSourceConclusion"]
    lines += [
        "",
        "## Item Drop Source",
        "",
        f"- source: `{conclusion['source']}`",
        f"- non-zero rows: {conclusion['nonZeroRows']}",
        f"- conclusion: {conclusion['conclusion']}",
    ]
    lines += ["", "## Evidence", ""]
    for ev in data["evidence"]:
        lines += [f"### {ev['label']} `{ev['range']}`", "", "```asm"]
        lines += ev["keyLines"]
        lines += ["```", ""]
    lines += ["## Open Questions", ""]
    if data["openQuestions"]:
        lines += [f"- {item}" for item in data["openQuestions"]]
    else:
        lines.append("- 없음")
    (OUT / "battle_end_reward_review.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")


def write_html(data: dict[str, Any]) -> None:
    summary = "".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    open_items = "".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    bridge = data.get("runtimeBridge", {})
    static = bridge.get("staticRewardCheck", {})
    runtime = bridge.get("runtimeTrace") or {}
    runtime_text_draws = ""
    if runtime.get("textDraws"):
        runtime_text_draws = render_table(
            runtime["textDraws"],
            [("frame", "frame"), ("pc", "PC"), ("rect", "rect"), ("text", "text")],
        )
    runtime_counts = "".join(f"<li><code>{esc(name)}</code>: {esc(count)}</li>" for name, count in runtime.get("topEventCounts", []))
    runtime_resources = "".join(f"<li><code>{esc(name)}</code>: {esc(count)}</li>" for name, count in runtime.get("resourceLabels", [])[:12])
    runtime_probe_changes = ""
    if runtime.get("probeValueChanges"):
        runtime_probe_changes = render_table(
            runtime["probeValueChanges"][-32:],
            [
                ("frame", "frame"),
                ("pc", "PC"),
                ("address", "address"),
                ("label", "label"),
                ("old", "old"),
                ("new", "new"),
                ("cpuEip", "cpu eip"),
            ],
        )
    runtime_static_hits = ""
    if runtime.get("staticAddressHits"):
        runtime_static_hits = render_table(
            runtime["staticAddressHits"],
            [
                ("frame", "frame"),
                ("caller", "caller"),
                ("pc", "PC"),
                ("function", "function"),
                ("meaning", "meaning"),
            ],
        )
    runtime_traces_summary = ""
    if bridge.get("runtimeTraces"):
        runtime_traces_summary = render_table(
            [
                {
                    "traceId": item.get("traceId"),
                    "manifest": item.get("manifest"),
                    "frames": item.get("frameRange"),
                    "direct": item.get("directRewardWriteCaptured"),
                    "note": item.get("note"),
                }
                for item in bridge.get("runtimeTraces", [])
            ],
            [("traceId", "trace"), ("manifest", "manifest"), ("frames", "frames"), ("direct", "direct"), ("note", "note")],
        )
    money = data.get("moneyGlobal", {})
    money_table = ""
    if money:
        money_table = render_table(
            [
                {
                    "addressHex": money.get("addressHex"),
                    "section": money.get("section"),
                    "fileOffsetHex": money.get("fileOffsetHex"),
                    "initialDword": money.get("initialDword"),
                    "cap": money.get("cap"),
                    "directAddressRefCount": money.get("directAddressRefCount"),
                    "plusTwoAddressRefCount": money.get("plusTwoAddressRefCount"),
                    "initialBytes": money.get("initialBytes"),
                    "conclusion": money.get("conclusion"),
                }
            ],
            [
                ("addressHex", "VA"),
                ("section", "section"),
                ("fileOffsetHex", "file offset"),
                ("initialDword", "initial"),
                ("cap", "cap"),
                ("directAddressRefCount", "refs"),
                ("plusTwoAddressRefCount", "0x+2 refs"),
                ("initialBytes", "initial bytes"),
                ("conclusion", "conclusion"),
            ],
        )
    evidence = []
    for ev in data["evidence"]:
        evidence.append(
            f"<details><summary>{esc(ev['label'])} <code>{esc(ev['range'])}</code></summary>"
            f"<pre>{esc(chr(10).join(ev['keyLines']))}</pre></details>"
        )
    html_text = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Battle End Reward Review</title>
  <style>
    body {{ margin: 24px; font-family: system-ui, sans-serif; background: #f7f7f4; color: #202124; }}
    h1 {{ margin-bottom: 4px; }}
    .sub {{ color: #5f6368; margin-top: 0; }}
    table {{ border-collapse: collapse; width: 100%; background: white; }}
    th, td {{ border: 1px solid #ddd; padding: 8px; vertical-align: top; }}
    th {{ background: #eeeeea; text-align: left; }}
    code, pre {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
    pre {{ white-space: pre-wrap; background: #111; color: #eee; padding: 12px; overflow-x: auto; }}
    section {{ margin: 20px 0; }}
    details {{ margin: 10px 0; }}
    .formula-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }}
    .formula-card, .calculator-panel, .result-card {{ background: #fff; border: 1px solid #d9d9d0; border-radius: 8px; padding: 14px; }}
    .formula-card h3 {{ margin-top: 0; }}
    .calculator-panel {{ display: grid; grid-template-columns: minmax(280px, 2fr) minmax(220px, 1fr); gap: 14px; margin: 14px 0; }}
    .controls {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; align-items: end; }}
    .controls label {{ display: grid; gap: 4px; font-size: 13px; color: #4b4c4d; }}
    .controls input, .controls select {{ min-height: 34px; border: 1px solid #cfcfc8; border-radius: 6px; padding: 6px 8px; font: inherit; background: #fff; }}
    .controls .check {{ display: flex; align-items: center; gap: 8px; min-height: 34px; }}
    .result-card {{ border-color: #b9c2a7; background: #fbfff5; }}
    .result-label {{ color: #667057; font-size: 13px; }}
    .result-value {{ font-size: 44px; line-height: 1; font-weight: 800; color: #26320f; margin: 6px 0 10px; }}
    .formula-line, .meta-line {{ color: #4d5543; font-size: 13px; line-height: 1.45; }}
    @media (max-width: 760px) {{ .calculator-panel {{ grid-template-columns: 1fr; }} }}
  </style>
</head>
<body>
  <h1>Battle End Reward Review</h1>
  <p class="sub">KO enemy actor → live player EXP fan-out → gold reward and inactive item-drop handler.</p>
  {render_exp_readable_section(data['expReadable'])}
  <section><h2>Summary</h2><ul>{summary}</ul></section>
  <section><h2>Flow</h2>{render_table(data['flowRows'], [('step', 'step'), ('vaHex', 'VA'), ('status', 'status'), ('meaning', 'meaning')])}</section>
  <section><h2>Formula</h2><ul>
    <li><code>exp</code>: {esc(data['formula']['exp'])}</li>
    <li><code>levelUpCarryover</code>: {esc(data['formula']['levelUpCarryover'])}</li>
    <li><code>gold</code>: {esc(data['formula']['gold'])}</li>
    <li><code>itemDrop</code>: {esc(data['formula']['itemDrop'])}</li>
  </ul></section>
  <section><h2>Global Money</h2>{money_table}</section>
  <section><h2>Runtime Bridge</h2>
    <p>{esc(bridge.get('observation'))}</p>
    <table><tbody>
      <tr><th>enemy</th><td>{esc(static.get('enemy'))}</td></tr>
      <tr><th>enemy EXP base</th><td>{esc(static.get('enemyActorExpBase'))} <code>enemy stat HP-view +0x04</code></td></tr>
      <tr><th>level-up carryover</th><td>{esc(static.get('playerExpBefore'))} + {esc(static.get('enemyActorExpBase'))} = {esc(static.get('expAfterRewardBeforeLevelUp'))}; carry {esc(static.get('carryBeforeScale'))} * 3 / 4 = {esc(static.get('playerExpAfterLevelUp'))}</td></tr>
      <tr><th>gold</th><td>{esc(static.get('goldBefore'))} + {esc(static.get('enemyGold'))} = {esc(static.get('goldAfter'))}</td></tr>
      <tr><th>matches observation</th><td>{esc(static.get('matchesUserObservation'))}</td></tr>
      <tr><th>runtime trace</th><td><code>{esc(runtime.get('manifest'))}</code> frames {esc(runtime.get('frameRange'))}</td></tr>
      <tr><th>direct reward write captured</th><td>{esc(runtime.get('directRewardWriteCaptured'))}. {esc(runtime.get('note'))}</td></tr>
    </tbody></table>
    <h3>Runtime Event Counts</h3><ul>{runtime_counts}</ul>
    <h3>Runtime Resource Labels</h3><ul>{runtime_resources}</ul>
    <h3>Runtime Trace Summary</h3>{runtime_traces_summary}
    <h3>Key Runtime Value Changes</h3>{runtime_probe_changes}
    <h3>Runtime Static Address Hits</h3>{runtime_static_hits}
    <h3>Runtime Text Draws</h3>{runtime_text_draws}
  </section>
  <section><h2>Item Drop Source</h2><ul>
    <li><code>source</code>: {esc(data['itemDropSourceConclusion']['source'])}</li>
    <li><code>nonZeroRows</code>: {esc(data['itemDropSourceConclusion']['nonZeroRows'])}</li>
    <li><code>conclusion</code>: {esc(data['itemDropSourceConclusion']['conclusion'])}</li>
  </ul></section>
  <section><h2>Evidence</h2>{''.join(evidence)}</section>
  <section><h2>Open Questions</h2><ul>{open_items}</ul></section>
</body>
</html>
"""
    (OUT / "battle_end_reward_review.html").write_text(html_text, encoding="utf-8")
    (WEB / "battle_end_reward_review.html").write_text(html_text, encoding="utf-8")


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    data = build()
    write_json(data)
    write_md(data)
    write_html(data)


if __name__ == "__main__":
    main()
