#!/usr/bin/env python3
"""Build a focused review for battle stat offsets, result flags, and status gates.

This report sits on top of battle_damage_formula_trace_review.  The previous
report established the calculation path; this one names the actor fields only
as far as the EXE evidence allows and separates exact behavior from UI labels
that still need a controlled runtime sample.
"""
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"
PRIOR = OUT / "battle_damage_formula_trace_review.json"
ACTOR_LAYOUT = OUT / "battle_actor_stat_layout_review.json"


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


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


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):
            start = max(0, index - context)
            end = min(len(lines), index + context + 1)
            for i in range(start, end):
                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


FUNCTION_SPECS = [
    {
        "va": 0x0043414D,
        "end": 0x00434218,
        "label": "common physical family coordinator",
        "summary": "hit/avoid, alternate/critical, normal/alternate damage, status gate를 연결하고 attacker +0x6e result를 target +0x6e로 복사한다.",
        "needles": ["0x434218", "[eax+0x62]", "0x4343db", "0x434737", "0x43461f", "0x4344ab", "[ecx+0x6e]"],
    },
    {
        "va": 0x00434218,
        "end": 0x004343DB,
        "label": "hit/avoid branch flag test",
        "summary": "attacker +0x06/+0x22/+0x74와 target +0x06/+0x24/+0x7c, target table +0x36+unitByte6을 비교한다. 실패 폭에 따라 target +0x62 bit 0x20 또는 0x08을 세운다.",
        "needles": ["[eax+0x22]", "[eax+0x74]", "[eax+0x24]", "[eax+0x7c]", "+0x36", "or     cl,0x20", "or     cl,0x8"],
    },
    {
        "va": 0x004343DB,
        "end": 0x004344AB,
        "label": "alternate result / critical flag gate",
        "summary": "attacker +0x28/+0x72와 target +0x26/+0x7e를 비교하고, 추가 random 합산 조건이 통과되면 attacker +0x62 bit 0x10을 세운다.",
        "needles": ["[eax+0x28]", "[eax+0x72]", "[eax+0x26]", "[eax+0x7e]", "cmp    eax,0x96", "or     cl,0x10"],
    },
    {
        "va": 0x004344AB,
        "end": 0x0043461F,
        "label": "status success gate",
        "summary": "target +0x6c status 후보가 있으면 attacker/target 수치와 target status table +0x39+status로 성공 여부를 판정한다. 실패 시 target +0x6c를 0으로 지운다.",
        "needles": ["[eax+0x6c]", "[eax+0x26]", "[eax+0x28]", "[eax+0x74]", "[eax+0x22]", "+0x39", "c6 40 6c 00"],
    },
    {
        "va": 0x0043461F,
        "end": 0x00434737,
        "label": "normal damage formula",
        "summary": "attacker +0x70 * +0x1c, target +0x7a * +0x20, level random, target family table +0x1c+family로 result +0x6e를 계산한다.",
        "needles": ["[eax+0x70]", "[eax+0x1c]", "[eax+0x7a]", "[eax+0x20]", "[eax+0x6b]", "+0x1c", "[ecx+0x6e]"],
    },
    {
        "va": 0x00434737,
        "end": 0x0043485E,
        "label": "alternate/critical damage formula",
        "summary": "normal damage와 같은 입력 필드를 쓰지만 중심식이 /3이 아니라 /2에 가깝다. result가 너무 낮으면 attacker +0x62 bit 0x10을 해제한다.",
        "needles": ["[eax+0x70]", "[eax+0x1c]", "[eax+0x7a]", "[eax+0x20]", "[eax+0x6b]", "+0x1c", "and    cl,0xef"],
    },
    {
        "va": 0x004340AE,
        "end": 0x0043414D,
        "label": "target result mode dispatcher",
        "summary": "target +0x62 bit 0x20이면 결과 적용을 건너뛰고, target +0x6d가 0이면 HP damage, 2이면 HP recovery를 호출한다.",
        "needles": ["test   cl,0x20", "[eax+0x6d]", "0x4353f8", "0x434e84", "0x435295"],
    },
    {
        "va": 0x004353F8,
        "end": 0x004354EB,
        "label": "HP damage apply",
        "summary": "target +0x08 current HP에서 result를 차감한다. HP가 0이면 target +0x62 bit 0x40을 세우고, player actor는 max HP +0x0a도 일부 줄인다.",
        "needles": ["[eax+0x8]", "or     cl,0x40", "[eax+0xa]", "[eax+0x6]"],
    },
    {
        "va": 0x00435295,
        "end": 0x004353F8,
        "label": "HP/MP recovery apply",
        "summary": "HP recovery는 +0x08/+0x0a, MP recovery는 +0x0e/+0x10을 사용해 현재값을 증가시키고 최대값으로 clamp한다.",
        "needles": ["[eax+0x8]", "[eax+0xa]", "[eax+0xe]", "[eax+0x10]"],
    },
    {
        "va": 0x0040DFB8,
        "end": 0x0040E0C0,
        "label": "victory EXP fanout",
        "summary": "KO flag가 선 target마다 살아있는 player actor에 0x42157b 경험치를 더하고, 이후 0x435736으로 골드/드롭 보상을 처리한다.",
        "needles": ["[eax+0x62]", "test   cl,0x40", "test   cl,0x80", "0x42157b", "[ecx+0x14]", "0x435736"],
    },
    {
        "va": 0x0042157B,
        "end": 0x00421780,
        "label": "EXP reward formula",
        "summary": "defeated enemy +0x14 base EXP와 player/enemy level 차이로 보상 EXP를 계산하고, 특정 player/equipment class table 값에서는 절반 보정을 적용한다.",
        "needles": ["[eax+0x6]", "[eax+0x14]", "0x457798", "0x457799", "shr    eax,0x1"],
    },
    {
        "va": 0x00435736,
        "end": 0x004357D0,
        "label": "gold/drop reward handler",
        "summary": "defeated actor +0x44 gold를 global money 0x4576e0에 더하고 cap을 적용한 뒤, +0x40/+0x41 기반 드롭 후보를 처리한다.",
        "needles": ["[eax+0x44]", "0x4576e0", "0xf423f", "[eax+0x40]", "[eax+0x41]", "0x422093"],
    },
]


FIELD_ROWS = [
    {
        "offset": "+0x06",
        "width": "word",
        "role": "레벨 또는 레벨성 기본값",
        "status": "고신뢰",
        "evidence": "데미지 random 폭에서 attacker/target +0x06 / 2로 쓰이고, HP 0 처리에서 player max HP clamp 기준으로 쓰인다.",
    },
    {
        "offset": "+0x08",
        "width": "word",
        "role": "현재 HP",
        "status": "확정",
        "evidence": "0x4353f8이 result만큼 차감하고, 0x435295가 회복한다.",
    },
    {
        "offset": "+0x0a",
        "width": "word",
        "role": "최대 HP",
        "status": "확정",
        "evidence": "HP 회복 clamp 및 player 기절 시 최대 HP 감소에 사용된다.",
    },
    {
        "offset": "+0x0e",
        "width": "word",
        "role": "현재 MP",
        "status": "확정",
        "evidence": "0x435356 MP recovery apply가 증가시키는 현재값이다.",
    },
    {
        "offset": "+0x10",
        "width": "word",
        "role": "최대 MP",
        "status": "확정",
        "evidence": "MP recovery clamp 기준이다.",
    },
    {
        "offset": "+0x14",
        "width": "word",
        "role": "플레이어 현재 EXP / 적 EXP reward base",
        "status": "확정",
        "evidence": "0x42157b는 defeated enemy +0x14를 base EXP로 읽고, 0x40e085..0x40e095는 산출 EXP를 live player actor +0x14에 더한다. player row +0x14도 전투 actor로 복사되는 현재 EXP 슬롯이다.",
    },
    {
        "offset": "+0x1c",
        "width": "word / byte table base",
        "role": "공격력 축 / family resistance table base",
        "status": "확정",
        "evidence": "플레이어 장비 재계산은 base attack + 장비 +0x10을 actor +0x1c에 기록한다. normal/alternate damage는 attacker +0x70 * attacker +0x1c로 공격값을 만들고, target + family + 0x1c byte를 속성/계열 보정으로 읽는다.",
    },
    {
        "offset": "+0x20",
        "width": "word",
        "role": "방어력 축",
        "status": "확정",
        "evidence": "플레이어 장비 재계산은 base defense + 장비 +0x11을 actor +0x20에 기록한다. normal/alternate damage는 target +0x7a * target +0x20로 방어값을 만든다.",
    },
    {
        "offset": "+0x22",
        "width": "word",
        "role": "기술력/명중 축",
        "status": "확정",
        "evidence": "플레이어 장비 재계산은 base technique + 장비 +0x12를 actor +0x22에 기록한다. hit/avoid에서 attacker +0x06 + +0x22 및 status gate에서 attacker +0x74 * +0x22로 쓰인다.",
    },
    {
        "offset": "+0x24",
        "width": "word",
        "role": "순발력/회피 축",
        "status": "확정",
        "evidence": "플레이어 장비 재계산은 base agility + 장비 +0x13을 actor +0x24에 기록한다. hit/avoid에서 target +0x06 + +0x24와 target +0x7c를 곱한 회피 점수에 쓰인다.",
    },
    {
        "offset": "+0x26",
        "width": "word",
        "role": "운 / 상태/크리티컬 저항 쪽 축",
        "status": "확정",
        "evidence": "플레이어 장비 재계산과 레벨업 random luck 공식에서 actor +0x26이 운으로 확정됐다. critical/status gate에서는 attacker/target 양쪽의 luck-side 축으로 쓰인다.",
    },
    {
        "offset": "+0x28",
        "width": "word",
        "role": "숨은 상태/크리티컬 발동률 계수",
        "status": "역할 확정 / UI 비표시",
        "evidence": "actor row actual +0x1a/exported HP view +0x12에서 복사된다. 0x4343db에서 attacker +0x28이 RNG range/base로 쓰이고, 통과 시 attacker +0x62 bit 0x10을 세운다. 0x4344ab 상태 성공 gate에서도 attacker-side 계수로 읽힌다. 상태창/장비/레벨업 운 경로와 분리되어 있고 몬스터 대부분이 100이라 상태창 운으로 단정하지 않는다.",
    },
    {
        "offset": "+0x2a",
        "width": "word",
        "role": "전투 자세/상태 id",
        "status": "확정",
        "evidence": "0x433940이 actor +0x2a를 0x48bcaa 자세/상태 계수 테이블 index로 사용한다. 0x434e84..0x43512e는 상태이상/술취함/눈요기/도발 상태 id를 +0x2a에 쓰고 +0x66에 이전값을 저장한다.",
    },
    {
        "offset": "+0x36 + unitByte6",
        "width": "byte table",
        "role": "공격 계열별 명중 보정 table",
        "status": "확정",
        "evidence": "hit/avoid에서 payload unit byte6으로 runtime target actor table을 인덱싱해 attacker hit score에 곱한다. 오프닝 샘플에서 데드드래곤 actor +0x36..+0x39 = 64/64/6e/46이고 actual actor row +0x28..+0x2b와 일치한다. class label은 0=상단/공중/상승, 1=일반, 2=하단/지면/다운, 3=잡기/밀착 특수로 승격한다.",
    },
    {
        "offset": "+0x39 + status",
        "width": "byte table",
        "role": "상태이상 저항/성공 보정 table",
        "status": "고신뢰",
        "evidence": "status gate에서 target +0x6c 상태값으로 인덱싱해 성공 2단계 판정에 곱한다.",
    },
    {
        "offset": "+0x59",
        "width": "byte",
        "role": "skill id",
        "status": "확정",
        "evidence": "payload table lookup index로 쓰인다.",
    },
    {
        "offset": "+0x62",
        "width": "byte flags",
        "role": "전투 결과/상태 플래그",
        "status": "확정",
        "evidence": "hit branch, critical branch, HP zero, result dispatcher가 모두 이 byte의 bit를 검사하거나 세운다.",
    },
    {
        "offset": "+0x6b",
        "width": "byte",
        "role": "hit family / damage family",
        "status": "확정",
        "evidence": "damage formula가 target family resistance table 인덱스로 사용한다.",
    },
    {
        "offset": "+0x6c",
        "width": "byte",
        "role": "상태이상 후보/결과",
        "status": "확정",
        "evidence": "payload status byte가 들어가고 status gate 실패 시 0으로 지워진다.",
    },
    {
        "offset": "+0x6d",
        "width": "byte",
        "role": "결과 적용 mode",
        "status": "확정",
        "evidence": "0 = HP damage, 1 = no apply, 2 = HP recovery dispatcher로 확인된다.",
    },
    {
        "offset": "+0x6e",
        "width": "word",
        "role": "결과값 damage/recovery",
        "status": "확정",
        "evidence": "damage/recovery formula가 쓰고 target result dispatcher가 적용한다.",
    },
    {
        "offset": "+0x70/+0x72/+0x74",
        "width": "word x3",
        "role": "payload power byte0..2와 actor modifier를 곱한 attacker-side 계수",
        "status": "고신뢰",
        "evidence": "0x433f0e가 hit unit byte0..2를 +0x80/+0x82/+0x84와 곱해 저장하고, damage/critical/hit/status gate가 사용한다.",
    },
    {
        "offset": "+0x7a/+0x7c/+0x7e",
        "width": "word x3",
        "role": "target-side defense/avoid/critical-status resistance 계수",
        "status": "확정",
        "evidence": "+0x7a는 defense damage, +0x7c는 avoid, +0x7e는 critical/status resistance 계열 판정에 쓰인다. +0x7e는 reset 시 100으로 초기화되고 현재 자세/상태 테이블에서는 별도로 변조되지 않는다.",
    },
]


FLAG_ROWS = [
    {
        "bit": "target +0x62 bit 0x20",
        "exactBehavior": "target result dispatcher가 바로 반환하므로 HP 적용이 없다.",
        "label": "MISS / full miss",
        "status": "확정",
        "evidence": "0x434218에서 큰 실패 폭이면 설정, 0x4340ae 첫 test cl,0x20에서 적용 skip. 결과 helper label 리뷰에서 helper 0x05 / selector 0x00100051의 MISS 표시 분기도 확인됐다.",
    },
    {
        "bit": "target +0x62 bit 0x08",
        "exactBehavior": "normal damage를 건너뛰고 random 1..3 result를 만들며 status +0x6c를 0으로 지운다.",
        "label": "guard/glancing chip-damage hit",
        "status": "확정",
        "evidence": "0x43414d가 bit 0x08이면 result 1..3 설정 후 status 제거. target result script 0x004545b4가 WLK id 11/helper 0x16과 btl_efc F56..F59를 선택한다. 수동 관찰상 MISS 라벨 없이 normal frame 위에 1자리수 데미지와 약한 '팅' 계열 효과음만 뜬다.",
    },
    {
        "bit": "attacker +0x62 bit 0x10",
        "exactBehavior": "alternate damage formula 0x434737을 호출하고 0xc2 result sound handler의 alt WLK 선택 조건이 된다.",
        "label": "critical presentation / alternate damage result",
        "status": "정적 역할 확정 / 수동 관찰 정합",
        "evidence": "0x4343db가 stat/random gate로 설정, 0x43414d가 alternate formula로 분기, 0xc2 result handler가 alt WLK/helper를 선택한다. 낮은 result면 0x434737이 bit를 해제. critical은 텍스트 없이 섬광/전용 사운드/높은 데미지로 인지된다는 관찰과 맞다.",
    },
    {
        "bit": "target +0x62 bit 0x40",
        "exactBehavior": "current HP가 0이 될 때 세워진다.",
        "label": "기절/전투불능",
        "status": "확정",
        "evidence": "0x4353f8 HP damage apply.",
    },
    {
        "bit": "actor +0x62 bit 0x80",
        "exactBehavior": "target eligibility gate에서 대상 후보 제외 조건으로 쓰인다.",
        "label": "대상 제외/이미 처리됨",
        "status": "고신뢰",
        "evidence": "0x433a30 target eligibility gate.",
    },
]


STATUS_ROWS = [
    {"value": "0x00", "label": "none", "status": "확정", "evidence": "payload status 없음. status gate가 바로 종료."},
    {"value": "0x01", "label": "넘어짐", "status": "고신뢰", "evidence": "다리후리기/대폭진 등 사용자 검증 기술군과 payload 통계가 맞는다."},
    {"value": "0x02", "label": "휙날아감", "status": "고신뢰", "evidence": "던지기/맹호비상각/쾌진격 계열 기술 설명과 payload 통계가 맞는다."},
    {"value": "0x03", "label": "행동정지", "status": "고신뢰", "evidence": "고양이달래기/춤/행동정지 계열 기술군과 맞는다."},
    {"value": "0x04", "label": "독", "status": "고신뢰", "evidence": "독 부여 기술/몬스터 기술 payload에서 드물게 나타난다."},
    {"value": "0x05", "label": "마비", "status": "고신뢰", "evidence": "흡혈/장궁/기공독치료 관련 상태 설명과 맞는다."},
    {"value": "0x06", "label": "졸림", "status": "확정", "evidence": "0x434fc4가 payload status 6을 +0x2a=0x15, timer current+rand(3)+4, +0x62 bit 0x02로 변환한다."},
]


FORMULA_ROWS = [
    {
        "name": "hit/avoid score",
        "pseudo": "hitBase = attacker.level(+0x06) + attacker.hitAxis(+0x22)\nhit = floor(floor(hitBase * attacker.hitCoef(+0x74) / 100) * target.hitClassMod(+0x36+unitByte6) / 100) + rand(hitBase)\navoidBase = target.level(+0x06) + target.avoidAxis(+0x24)\navoid = rand(avoidBase) + floor(target.avoidCoef(+0x7c) * avoidBase / 100)",
        "result": "hit > avoid이면 정상. 아니고 avoid - 3 > hit이면 bit 0x20, 그 외에는 bit 0x08.",
    },
    {
        "name": "normal damage",
        "pseudo": "atk = floor(attacker.power0(+0x70) * attacker.attack(+0x1c) / 100)\ndef = floor(target.defCoef(+0x7a) * target.defense(+0x20) / 100)\nfamily = target.familyTable[target.resultFamily(+0x6b)]\ndamage = floor(((atk * 2 - def) / 3 + rand(attacker.level/2) - rand(target.level/2)) * family / 100)",
        "result": "damage < 3이면 rand(3)+1로 최소 보정.",
    },
    {
        "name": "alternate/critical damage",
        "pseudo": "입력 필드는 normal damage와 같지만 중심식이 floor((atk * 2 - def) / 2)에 가깝다.",
        "result": "damage < 3이면 rand(5)+1로 최소 보정하고 attacker bit 0x10을 해제한다.",
    },
    {
        "name": "status gate",
        "pseudo": "status가 0이 아니면 attacker/target +0x26/+0x28/+0x22와 +0x72/+0x74/+0x7e, target status table(+0x39+status)을 두 단계 random 비교에 사용한다.",
        "result": "실패하면 target +0x6c = 0. 성공하면 +0x6c가 유지되어 0x434e84 상태/피격 처리로 넘어간다.",
    },
    {
        "name": "experience reward",
        "pseudo": "diff = min(abs(enemy.level(+0x06) - player.level(+0x06)), 10)\nif enemy.level < player.level:\n    exp = enemy.expBase(+0x14) - enemy.expBase * diff * 12 / 100\n    exp = max(exp, 1)\nelse:\n    exp = enemy.expBase(+0x14) + enemy.expBase * diff * diff / 100\nif player/equipment class table 0x457798 == 0x1d:\n    exp = max(exp >> 1, 1)\nif player/equipment class table 0x457799 in (0x0b, 0x17):\n    exp = max(exp >> 1, 1)",
        "result": "KO target마다 살아있는 player actor +0x14 current EXP에 더한다.",
    },
]


def build() -> dict[str, Any]:
    prior = read_json(PRIOR)
    function_rows: list[dict[str, Any]] = []
    for spec in FUNCTION_SPECS:
        disasm = disassemble(spec["va"], spec["end"])
        function_rows.append(
            {
                "vaHex": f"0x{spec['va']:08x}",
                "endVaHex": f"0x{spec['end']:08x}",
                "label": spec["label"],
                "summary": spec["summary"],
                "keyLines": compact_lines(disasm, spec["needles"], context=0),
            }
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-stat-flag-status-semantics-review",
        "source": {
            "script": str(Path(__file__).relative_to(ROOT)),
            "exe": "Hwanse2.exe",
            "prior": str(PRIOR.relative_to(ROOT)),
            "actorLayout": str(ACTOR_LAYOUT.relative_to(ROOT)),
        },
        "status": "stat-flag-semantics-grounded-critical-presentation-supported",
        "summary": [
            "+0x08/+0x0a/+0x0e/+0x10/+0x59/+0x62/+0x6b/+0x6c/+0x6d/+0x6e는 동작 기준으로 확정.",
            "+0x14는 플레이어 현재 EXP / 적 EXP reward base로 확정. 승리 루프는 KO 적의 +0x14를 공식에 넣고 살아있는 플레이어 +0x14에 결과를 더한다.",
            "+0x1c/+0x20/+0x22/+0x24/+0x26은 플레이어 장비/레벨업 루틴과 전투 공식 사용처가 맞물려 공격력/방어력/기술력/순발력/운으로 확정.",
            "+0x28은 대부분 100인 별도 기본 계수라 운으로 단정하지 않는다. 상태창에 표시되지 않는 숨은 attacker-side 상태/크리티컬 발동률 계수로 확정한다.",
            "+0x2a는 전투 자세/상태 id다. 상태별 공격/방어/명중/회피/행동순서/크리티컬 계수는 0x48bcaa 테이블에서 읽힌다.",
            "+0x7e는 target-side critical/status resistance 계수로 확정한다. 현재 확인된 자세/상태 테이블은 이 값을 바꾸지 않아 기본 100으로 남는다.",
            "target bit 0x20은 MISS / HP 적용 skip, bit 0x08은 guard/glancing chip-damage branch, attacker bit 0x10은 critical presentation / alternate damage result 선택 플래그.",
            "actor +0x62 bit 0x10의 전용 result 경로는 stream +3 WLK, helper 0x402321, opening display VM runtime WLK id 14 선택, palette flash/fade child script까지 확인됐다. 남은 wall-clock ms 환산은 전투 의미가 아니라 browser runner timing 보정이다.",
        ],
        "priorUnitSummary": prior.get("unitSummary", {}),
        "fieldRows": FIELD_ROWS,
        "flagRows": FLAG_ROWS,
        "statusRows": STATUS_ROWS,
        "formulaRows": FORMULA_ROWS,
        "functionRows": function_rows,
        "openQuestions": [],
    }


def tag_class(status: str) -> str:
    if "확정" in status:
        return "good"
    if "고신뢰" in status:
        return "warn"
    return ""


def table(headers: list[str], rows: list[dict[str, Any]], keys: list[str]) -> str:
    head = "".join(f"<th>{esc(h)}</th>" for h 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:
    function_cards = []
    for row in data["functionRows"]:
        key_lines = "\n".join(row["keyLines"]) or "(no key lines)"
        function_cards.append(
            f"""
            <details>
              <summary>{esc(row['vaHex'])} {esc(row['label'])}</summary>
              <p>{esc(row['summary'])}</p>
              <pre>{esc(key_lines)}</pre>
            </details>
            """
        )

    formula_cards = []
    for row in data["formulaRows"]:
        formula_cards.append(
            f"""
            <section class="formula">
              <h3>{esc(row['name'])}</h3>
              <pre>{esc(row['pseudo'])}</pre>
              <p>{esc(row['result'])}</p>
            </section>
            """
        )

    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"])

    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>전투 스탯/플래그/상태 의미 검토</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: 1440px;
        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; }}
      h3 {{ margin: 0 0 8px; font-size: 15px; }}
      .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, .formula {{
        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 p {{ margin: 10px 12px; }}
      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>전투 스탯/플래그/상태 의미 검토</h1>
          <p class="sub">EXE의 데미지/명중/상태 판정 루틴에서 actor offset과 result flag의 의미를 분리한 검토 자료.</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="../web/battle_simulator.html">전투 기술 실행</a>
          <a href="battle_damage_formula_trace_review.html">전투 계산 추적</a>
          <a href="battle_actor_stat_layout_review.html">Actor 스탯 레이아웃</a>
          <a href="battle_player_actor_stat_layout_review.html">플레이어 Actor 레이아웃</a>
          <a href="battle_result_layer_trace_review.html">결과 레이어</a>
          <a href="battle_sound_role_review.html">사운드 역할</a>
          <a href="battle_stat_flag_status_semantics_review.json">JSON</a>
          <a href="battle_stat_flag_status_semantics_review.md">MD</a>
        </nav>
      </header>

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

      <h2>Actor Offset 의미</h2>
      {table(['offset', 'width', 'role', 'status', 'evidence'], data['fieldRows'], ['offset', 'width', 'role', 'status', 'evidence'])}

      <h2>Result Flag 의미</h2>
      {table(['bit', 'exact behavior', 'label', 'status', 'evidence'], data['flagRows'], ['bit', 'exactBehavior', 'label', 'status', 'evidence'])}

      <h2>상태이상 값</h2>
      {table(['value', 'label', 'status', 'evidence'], data['statusRows'], ['value', 'label', 'status', 'evidence'])}

      <h2>수식 요약</h2>
      {''.join(formula_cards)}

      <h2>핵심 디스어셈블 근거</h2>
      {''.join(function_cards)}

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


def render_md(data: dict[str, Any]) -> str:
    lines = [
        "# 전투 스탯/플래그/상태 의미 검토",
        "",
        "## 요약",
    ]
    lines += [f"- {item}" for item in data["summary"]]
    lines += ["", "## Actor Offset"]
    for row in data["fieldRows"]:
        lines.append(f"- `{row['offset']}` `{row['width']}`: {row['role']} ({row['status']}) - {row['evidence']}")
    lines += ["", "## Result Flag"]
    for row in data["flagRows"]:
        lines.append(f"- `{row['bit']}`: {row['exactBehavior']} / {row['label']} ({row['status']})")
    lines += ["", "## 상태이상 값"]
    for row in data["statusRows"]:
        lines.append(f"- `{row['value']}`: {row['label']} ({row['status']}) - {row['evidence']}")
    lines += ["", "## 수식 요약"]
    for row in data["formulaRows"]:
        lines += [f"### {row['name']}", "```text", row["pseudo"], "```", row["result"], ""]
    lines += ["## 남은 미확정"]
    if data["openQuestions"]:
        lines += [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_stat_flag_status_semantics_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_stat_flag_status_semantics_review.html").write_text(html_text, encoding="utf-8")
    (OUT / "battle_stat_flag_status_semantics_review.md").write_text(render_md(data), encoding="utf-8")


if __name__ == "__main__":
    main()
