#!/usr/bin/env python3
"""Build a focused review for player battle actor stat rows.

This report complements ``battle_actor_stat_layout_review``.  The older report
anchors the monster/static actor row table.  This one follows the player path:

* saved/player row table at 0x00457750, stride 0xd8
* battle actor init copies the first 0x48 bytes from that row
* equipped weapon/armor bytes at +0x48/+0x49 select records through 0x48b1dc
* equipment stat bytes +0x10..+0x14 are added to base stats +0xbe..+0xc8
* level-up rewrites +0xc8/+0x26 with the random luck formula
* battle reward code adds EXP to actor/player row +0x14

The practical result is that actor +0x26 can now be promoted as the player luck
axis and actor +0x14 as current EXP.  Actor +0x28 is a hidden battle proc
coefficient, not a status-window stat.
"""
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"
UI_GRID = OUT / "ui_cns_grid_mappings.json"

IMAGE_BASE_DELTA = 0x00402000
PLAYER_ROW_TABLE_VA = 0x00457750
PLAYER_ROW_STRIDE = 0xD8
PLAYER_ROW_BATTLE_COPY_SIZE = 0x48
EQUIPMENT_POINTER_TABLE_VA = 0x0048B1DC


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 va_to_offset(va: int) -> int:
    return va - IMAGE_BASE_DELTA


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


def read_s8(data: bytes, offset: int) -> int:
    value = data[offset]
    return value - 256 if value >= 128 else value


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 equipment_records(ui_grid: dict[str, Any]) -> list[dict[str, Any]]:
    for table in ui_grid.get("tables", []):
        if isinstance(table, dict) and table.get("key") == "equipment":
            return [row for row in table.get("records", []) if isinstance(row, dict)]
    return []


def equipment_by_internal_id(ui_grid: dict[str, Any]) -> dict[int, dict[str, Any]]:
    records = equipment_records(ui_grid)
    return {int(row.get("index", 0)) + 1: row for row in records}


def stat_fields_from_equipment(row: dict[str, Any]) -> dict[str, int]:
    detail = row.get("detailRecord") if isinstance(row.get("detailRecord"), dict) else {}
    raw_hex = str(detail.get("rawHex") or "")
    raw = bytes.fromhex(raw_hex) if raw_hex else b""
    labels = ["attack", "defense", "technique", "agility", "luck"]
    return {
        label: read_s8(raw, index) if index < len(raw) else 0
        for index, label in enumerate(labels)
    }


def player_rows(exe: bytes, ui_grid: dict[str, Any]) -> list[dict[str, Any]]:
    equipment = equipment_by_internal_id(ui_grid)
    characters = [
        {"slot": 0, "name": "아타호", "nameEn": "Ataho"},
        {"slot": 1, "name": "린샹", "nameEn": "Rinshan"},
        {"slot": 2, "name": "스마슈", "nameEn": "Smash"},
    ]
    rows: list[dict[str, Any]] = []
    for character in characters:
        va = PLAYER_ROW_TABLE_VA + character["slot"] * PLAYER_ROW_STRIDE
        offset = va_to_offset(va)
        weapon_id = exe[offset + 0x48]
        armor_id = exe[offset + 0x49]
        weapon = equipment.get(weapon_id, {})
        armor = equipment.get(armor_id, {})
        weapon_stats = stat_fields_from_equipment(weapon)
        armor_stats = stat_fields_from_equipment(armor)
        base = {
            "attack": read_u16(exe, offset + 0xBE),
            "defense": read_u16(exe, offset + 0xC2),
            "technique": read_u16(exe, offset + 0xC4),
            "agility": read_u16(exe, offset + 0xC6),
            "luck": read_u16(exe, offset + 0xC8),
        }
        actor = {
            "attack": read_u16(exe, offset + 0x1C),
            "defense": read_u16(exe, offset + 0x20),
            "technique": read_u16(exe, offset + 0x22),
            "agility": read_u16(exe, offset + 0x24),
            "luck": read_u16(exe, offset + 0x26),
            "coeff28": read_u16(exe, offset + 0x28),
        }
        calculated = {
            stat: max(0, base[stat] + weapon_stats[stat] + armor_stats[stat])
            for stat in base
        }
        rows.append(
            {
                **character,
                "rowVa": va,
                "rowVaHex": hex32(va),
                "battleCopyHex": exe[offset : offset + PLAYER_ROW_BATTLE_COPY_SIZE].hex(" "),
                "level": read_u16(exe, offset + 0x06),
                "hp": read_u16(exe, offset + 0x08),
                "maxHp": read_u16(exe, offset + 0x0A),
                "baseHp": read_u16(exe, offset + 0x0C),
                "mp": read_u16(exe, offset + 0x0E),
                "maxMp": read_u16(exe, offset + 0x10),
                "baseMp": read_u16(exe, offset + 0x12),
                "exp": read_u16(exe, offset + 0x14),
                "weaponId": weapon_id,
                "weaponName": weapon.get("name", ""),
                "armorId": armor_id,
                "armorName": armor.get("name", ""),
                "weaponStatSummary": (weapon.get("detailRecord") or {}).get("statSummary", ""),
                "armorStatSummary": (armor.get("detailRecord") or {}).get("statSummary", ""),
                "baseAttack": base["attack"],
                "weaponAttack": weapon_stats["attack"],
                "armorAttack": armor_stats["attack"],
                "actorAttack": actor["attack"],
                "calcAttack": calculated["attack"],
                "baseDefense": base["defense"],
                "weaponDefense": weapon_stats["defense"],
                "armorDefense": armor_stats["defense"],
                "actorDefense": actor["defense"],
                "calcDefense": calculated["defense"],
                "baseTechnique": base["technique"],
                "weaponTechnique": weapon_stats["technique"],
                "armorTechnique": armor_stats["technique"],
                "actorTechnique": actor["technique"],
                "calcTechnique": calculated["technique"],
                "baseAgility": base["agility"],
                "weaponAgility": weapon_stats["agility"],
                "armorAgility": armor_stats["agility"],
                "actorAgility": actor["agility"],
                "calcAgility": calculated["agility"],
                "baseLuck": base["luck"],
                "weaponLuck": weapon_stats["luck"],
                "armorLuck": armor_stats["luck"],
                "actorLuck": actor["luck"],
                "calcLuck": calculated["luck"],
                "actorCoeff28": actor["coeff28"],
                "matchesEquipmentFormula": calculated == {
                    "attack": actor["attack"],
                    "defense": actor["defense"],
                    "technique": actor["technique"],
                    "agility": actor["agility"],
                    "luck": actor["luck"],
                },
            }
        )
    return rows


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    ui_grid = read_json(UI_GRID)
    init_disasm = disassemble(0x0040BE38, 0x0040BFA0)
    save_disasm = disassemble(0x00423319, 0x004233B0)
    recalc_disasm = disassemble(0x00422B95, 0x00422D74)
    level_disasm = disassemble(0x00421800, 0x00421B33)
    display_disasm = disassemble(0x0042005B, 0x00420380)
    exp_fanout_disasm = disassemble(0x0040DFB8, 0x0040E0C0)
    exp_formula_disasm = disassemble(0x0042157B, 0x00421780)

    return {
        "version": 1,
        "kind": "hwanse-battle-player-actor-stat-layout-review",
        "source": {
            "script": str(Path(__file__).relative_to(ROOT)),
            "exe": "Hwanse2.exe",
            "uiGridMappings": str(UI_GRID.relative_to(ROOT)),
        },
        "status": "player-actor-exp-luck-grounded",
        "tables": {
            "playerRowTableVa": PLAYER_ROW_TABLE_VA,
            "playerRowTableVaHex": hex32(PLAYER_ROW_TABLE_VA),
            "playerRowStride": PLAYER_ROW_STRIDE,
            "playerRowStrideHex": f"0x{PLAYER_ROW_STRIDE:02x}",
            "battleCopySize": PLAYER_ROW_BATTLE_COPY_SIZE,
            "battleCopySizeHex": f"0x{PLAYER_ROW_BATTLE_COPY_SIZE:02x}",
            "equipmentPointerTableVa": EQUIPMENT_POINTER_TABLE_VA,
            "equipmentPointerTableVaHex": hex32(EQUIPMENT_POINTER_TABLE_VA),
        },
        "summary": [
            "플레이어 전투 actor 초기화는 파티 slot -> character index -> 0x00457750 + index*0xd8 row를 찾아 첫 0x48바이트를 actor로 복사한다.",
            "player row/actor +0x14는 현재 EXP다. 승리 보상 루프가 0x42157b 산출값을 살아있는 player actor +0x14에 더한다.",
            "row +0x48/+0x49는 현재 무기/방어구 내부 id이고, 장비 포인터 테이블 0x0048b1dc[id]를 통해 장비 상세 레코드를 읽는다.",
            "장비 상세 레코드 +0x10..+0x14는 공격/방어/기술/순발/운 보정이며, player base +0xbe/+0xc2/+0xc4/+0xc6/+0xc8에 더해 actor +0x1c/+0x20/+0x22/+0x24/+0x26에 기록된다.",
            "레벨업 루틴은 +0xc8과 +0x26에 rand(100) + level/2 + 1을 써서 운을 재설정한다. 따라서 플레이어 actor +0x26은 운으로 확정할 수 있다.",
            "actor +0x28은 플레이어도 100/100/40처럼 별도 값이며 상태창 표시/장비 운 보정/레벨업 운 공식 대상이 아니다. 0x4343db의 critical/alternate 판정과 0x4344ab의 상태 성공 gate에서 attacker-side random range/base로 쓰이는 숨은 전투 발동률 계수다.",
            "몬스터 actor +0x26도 같은 공식 축으로 쓰지만, 몬스터에는 플레이어 장비/레벨업 루틴이 없으므로 UI 이름은 전투 공식 축으로 보존한다.",
        ],
        "playerRows": player_rows(exe, ui_grid),
        "layoutRows": [
            {
                "actorOffset": "+0x14",
                "baseOffset": "+0x14",
                "equipmentOffset": "",
                "role": "현재 경험치 EXP",
                "status": "확정",
                "evidence": "0x40e085..0x40e095가 0x42157b에서 계산한 경험치를 live player actor +0x14에 더한다. player row의 첫 0x48바이트가 actor로 복사되므로 row +0x14와 같은 슬롯이다.",
            },
            {
                "actorOffset": "+0x1c",
                "baseOffset": "+0xbe",
                "equipmentOffset": "+0x10",
                "role": "공격력",
                "status": "확정",
                "evidence": "0x422b95가 base +0xbe + weapon/armor +0x10을 actor +0x1c에 기록한다.",
            },
            {
                "actorOffset": "+0x20",
                "baseOffset": "+0xc2",
                "equipmentOffset": "+0x11",
                "role": "방어력",
                "status": "확정",
                "evidence": "0x422b95가 base +0xc2 + weapon/armor +0x11을 actor +0x20에 기록한다.",
            },
            {
                "actorOffset": "+0x22",
                "baseOffset": "+0xc4",
                "equipmentOffset": "+0x12",
                "role": "기술력",
                "status": "확정",
                "evidence": "0x422b95가 base +0xc4 + weapon/armor +0x12를 actor +0x22에 기록한다.",
            },
            {
                "actorOffset": "+0x24",
                "baseOffset": "+0xc6",
                "equipmentOffset": "+0x13",
                "role": "순발력",
                "status": "확정",
                "evidence": "0x422b95가 base +0xc6 + weapon/armor +0x13을 actor +0x24에 기록한다.",
            },
            {
                "actorOffset": "+0x26",
                "baseOffset": "+0xc8",
                "equipmentOffset": "+0x14",
                "role": "운",
                "status": "확정",
                "evidence": "0x422b95 장비 재계산과 0x4219a6 레벨업 random luck 공식이 같은 offset을 사용한다.",
            },
            {
                "actorOffset": "+0x28",
                "baseOffset": "",
                "equipmentOffset": "",
                "role": "숨은 상태/크리티컬 발동률 계수",
                "status": "역할 확정 / UI 비표시",
                "evidence": "상태창 표시 루틴, 장비 운 보정, 레벨업 운 공식의 대상이 아니다. 상태창 쪽은 base +0xbe/+0xc2/+0xc4/+0xc6/+0xc8 및 장비 +0x10..+0x14를 visible stat으로 쓰지만 +0x28은 표시하지 않는다. 0x4343db에서 attacker +0x28이 RNG range/base로 쓰이고, 통과 시 attacker +0x62 bit 0x10을 세운다. 0x4344ab 상태 성공 gate에서도 attacker-side 계수로 읽힌다.",
            },
        ],
        "disassemblyEvidence": [
            {
                "label": "player actor initialization path",
                "vaHex": "0x0040be38",
                "keyLines": compact_lines(
                    init_disasm,
                    ["0x4576e9", "0x457750", "push   0x48", "push   0x58", "add    eax,0x48"],
                    context=1,
                ),
            },
            {
                "label": "save/load row ranges",
                "vaHex": "0x00423319",
                "keyLines": compact_lines(
                    save_disasm,
                    ["0x4576d8", "0x72", "0x457750", "0x288", "0x59db60", "0x200"],
                    context=1,
                ),
            },
            {
                "label": "victory EXP fanout",
                "vaHex": "0x0040dfb8",
                "keyLines": compact_lines(
                    exp_fanout_disasm,
                    ["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": "equipment stat recalculation",
                "vaHex": "0x00422b95",
                "keyLines": compact_lines(
                    recalc_disasm,
                    [
                        "[eax+0xbe]",
                        "[eax+0xc2]",
                        "[eax+0xc4]",
                        "[eax+0xc6]",
                        "[eax+0xc8]",
                        "[eax+0x48]",
                        "[eax+0x49]",
                        "0x48b1dc",
                        "[eax+0x10]",
                        "[eax+0x11]",
                        "[eax+0x12]",
                        "[eax+0x13]",
                        "[eax+0x14]",
                        "[ecx+0x1c]",
                        "[ecx+0x20]",
                        "[ecx+0x22]",
                        "[ecx+0x24]",
                        "[ecx+0x26]",
                    ],
                    context=0,
                ),
            },
            {
                "label": "level-up luck formula",
                "vaHex": "0x00421800",
                "keyLines": compact_lines(
                    level_disasm,
                    [
                        "push   0x64",
                        "0x427730",
                        "[ecx+0x6]",
                        "sar    edx,0x1",
                        "[ecx+0xc8]",
                        "[ecx+0x26]",
                        "0x422b95",
                    ],
                    context=0,
                ),
            },
            {
                "label": "status window equipment comparison",
                "vaHex": "0x0042005b",
                "keyLines": compact_lines(
                    display_disasm,
                    [
                        "[eax+0xbe]",
                        "[eax+0xc2]",
                        "[eax+0xc4]",
                        "[eax+0xc6]",
                        "[eax+0xc8]",
                        "0x48b1dc",
                        "[eax+0x10]",
                        "[eax+0x11]",
                        "[eax+0x12]",
                        "[eax+0x13]",
                        "[eax+0x14]",
                    ],
                    context=0,
                ),
            },
        ],
        "openQuestions": [],
    }


def tag_class(status: str) -> str:
    if "확정" in status or "grounded" 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(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>')
            elif key == "matchesEquipmentFormula":
                cells.append(f'<td><span class="tag {"good" if value else "warn"}">{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 스탯 레이아웃</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 스탯 레이아웃</h1>
          <p class="sub">플레이어 row, 현재 장비, 레벨업 루틴을 기준으로 전투 actor 스탯 offset을 확정한 자료.</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="../web/battle_simulator.html">전투 기술 실행</a>
          <a href="battle_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_player_actor_stat_layout_review.json">JSON</a>
          <a href="battle_player_actor_stat_layout_review.md">MD</a>
        </nav>
      </header>

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

      <h2>확정 Offset</h2>
      {table(['actor', 'base row', 'equipment', 'role', 'status', 'evidence'], data['layoutRows'], ['actorOffset', 'baseOffset', 'equipmentOffset', 'role', 'status', 'evidence'])}

      <h2>기본 Player Row 검증</h2>
      {table(['slot', 'name', 'row', 'level', 'EXP', 'HP', 'MP', 'weapon', 'armor', 'base atk', 'weapon atk', 'armor atk', 'actor atk', 'base def', 'armor def', 'actor def', 'base tech', 'weapon tech', 'actor tech', 'base agi', 'armor agi', 'actor agi', 'base luck', 'weapon luck', 'armor luck', 'actor luck', '+0x28', 'formula'], data['playerRows'], ['slot', 'name', 'rowVaHex', 'level', 'exp', 'hp', 'mp', 'weaponName', 'armorName', 'baseAttack', 'weaponAttack', 'armorAttack', 'actorAttack', 'baseDefense', 'armorDefense', 'actorDefense', 'baseTechnique', 'weaponTechnique', 'actorTechnique', 'baseAgility', 'armorAgility', 'actorAgility', 'baseLuck', 'weaponLuck', 'armorLuck', 'actorLuck', 'actorCoeff28', 'matchesEquipmentFormula'])}

      <h2>디스어셈블 근거</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 스탯 레이아웃",
        "",
        "## 요약",
        "",
        *[f"- {item}" for item in data["summary"]],
        "",
        "## 확정 Offset",
        "",
        "| actor | base row | equipment | role | status |",
        "| --- | --- | --- | --- | --- |",
    ]
    for row in data["layoutRows"]:
        lines.append(
            f"| `{row['actorOffset']}` | `{row['baseOffset']}` | `{row['equipmentOffset']}` | {row['role']} | {row['status']} |"
        )
    lines.extend(
        [
            "",
            "## 기본 Player Row 검증",
            "",
            "| name | EXP | weapon | armor | atk | def | tech | agi | luck | +0x28 | formula |",
            "| --- | ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
        ]
    )
    for row in data["playerRows"]:
        lines.append(
            f"| {row['name']} | {row['exp']} | {row['weaponName']} | {row['armorName']} | {row['actorAttack']} | {row['actorDefense']} | "
            f"{row['actorTechnique']} | {row['actorAgility']} | {row['actorLuck']} | {row['actorCoeff28']} | {row['matchesEquipmentFormula']} |"
        )
    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_player_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_player_actor_stat_layout_review.html").write_text(html_text, encoding="utf-8")
    (OUT / "battle_player_actor_stat_layout_review.md").write_text(render_md(data), encoding="utf-8")


if __name__ == "__main__":
    main()
