#!/usr/bin/env python3
"""Build a focused review for battle stance/status coefficient tables.

The damage reports established the consumer side:

* +0x70/+0x72/+0x74 are attacker-side action/hit coefficients.
* +0x7a/+0x7c/+0x7e are target-side defense/avoid/resist coefficients.
* +0x80/+0x82/+0x84 cache the base attacker coefficients before each hit unit.

This report identifies the producer side for those values.  The EXE stores
the battle mode/status names and six percentage bytes in one table at
0x0048bcaa.  Function 0x00433932 selects the record by actor +0x2a and
multiplies the active battle coefficients by those bytes.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"

MODE_TABLE_VA = 0x0048BCAA
MODE_TABLE_COUNT = 28


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


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


def pe_sections(blob: bytes) -> tuple[int, list[dict[str, int | str]]]:
    pe_offset = struct.unpack_from("<I", blob, 0x3C)[0]
    section_count = struct.unpack_from("<H", blob, pe_offset + 6)[0]
    optional_header_size = struct.unpack_from("<H", blob, pe_offset + 20)[0]
    image_base = struct.unpack_from("<I", blob, pe_offset + 52)[0]
    section_base = pe_offset + 24 + optional_header_size
    sections: list[dict[str, int | str]] = []
    for index in range(section_count):
        offset = section_base + index * 40
        name = blob[offset : offset + 8].rstrip(b"\0").decode("ascii", "replace")
        virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from("<IIII", blob, offset + 8)
        sections.append(
            {
                "name": name,
                "virtualAddress": virtual_address,
                "virtualSize": virtual_size,
                "rawSize": raw_size,
                "rawPtr": raw_ptr,
            }
        )
    return image_base, sections


def va_to_offset(va: int, sections: list[dict[str, int | str]], image_base: int) -> int | None:
    rva = va - image_base
    for section in sections:
        start = int(section["virtualAddress"])
        span = max(int(section["virtualSize"]), int(section["rawSize"]))
        if start <= rva < start + span:
            return int(section["rawPtr"]) + (rva - start)
    return None


def read_c_string_cp949(data: bytes) -> str:
    raw = data.split(b"\0", 1)[0]
    return raw.decode("cp949", "replace").replace("\u3000", " ").rstrip()


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


def category_for_index(index: int) -> str:
    if 1 <= index <= 5:
        return "battle stance"
    if 6 <= index <= 10:
        return "아타호 술취함"
    if 11 <= index <= 15:
        return "스마슈 눈요기"
    if 16 <= index <= 20:
        return "린샹 도발"
    if 21 <= index <= 27:
        return "상태이상/행동불가"
    return "unused/null"


def decode_mode_table(exe: bytes, sections: list[dict[str, int | str]], image_base: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for index in range(MODE_TABLE_COUNT):
        entry_va = MODE_TABLE_VA + index * 8
        entry_offset = va_to_offset(entry_va, sections, image_base)
        if entry_offset is None:
            continue
        record_va, meta = struct.unpack_from("<II", exe, entry_offset)
        record_offset = va_to_offset(record_va, sections, image_base) if record_va else None
        if record_offset is None:
            rows.append(
                {
                    "index": index,
                    "entryVa": entry_va,
                    "entryVaHex": hex32(entry_va),
                    "recordVa": record_va,
                    "recordVaHex": hex32(record_va) if record_va else "-",
                    "metaHex": hex32(meta),
                    "name": "(null)",
                    "category": "unused/null",
                    "attackCoef": None,
                    "defenseCoef": None,
                    "hitStatusCoef": None,
                    "avoidCoef": None,
                    "actionSpeedCoef": None,
                    "criticalCoef": None,
                    "flag0x16": None,
                    "flag0x17": None,
                    "rawHex": "",
                }
            )
            continue

        record = exe[record_offset : record_offset + 0x18]
        rows.append(
            {
                "index": index,
                "entryVa": entry_va,
                "entryVaHex": hex32(entry_va),
                "recordVa": record_va,
                "recordVaHex": hex32(record_va),
                "metaHex": hex32(meta),
                "name": read_c_string_cp949(record[:0x10]),
                "category": category_for_index(index),
                "attackCoef": record[0x10],
                "defenseCoef": record[0x11],
                "hitStatusCoef": record[0x12],
                "avoidCoef": record[0x13],
                "actionSpeedCoef": record[0x14],
                "criticalCoef": record[0x15],
                "flag0x16": record[0x16],
                "flag0x17": record[0x17],
                "rawHex": record.hex(" "),
            }
        )
    return rows


FUNCTIONS = [
    {
        "va": 0x004336F1,
        "end": 0x0043376E,
        "label": "coefficient reset",
        "summary": "actor +0x70/+0x72/+0x74/+0x78/+0x7a/+0x7c/+0x7e를 모두 100으로 초기화하고 +0x70/+0x72/+0x74를 +0x80/+0x82/+0x84에 캐시한다.",
        "needles": ["[eax+0x70]", "[eax+0x72]", "[eax+0x74]", "[eax+0x78]", "[eax+0x7a]", "[eax+0x7c]", "[eax+0x7e]", "[ecx+0x80]", "[ecx+0x82]", "[ecx+0x84]"],
    },
    {
        "va": 0x0043376E,
        "end": 0x0043385D,
        "label": "action payload speed/cache applier",
        "summary": "선택된 액션 payload record +0x13을 actor +0x78에 곱하고, payload +0x12를 actor +0x63에 복사한 뒤 +0x70/+0x72/+0x74를 +0x80/+0x82/+0x84에 캐시한다.",
        "needles": ["[eax+0x13]", "[eax+0x78]", "[ecx+0x78]", "[eax+0x12]", "[ecx+0x63]", "[ecx+0x80]", "[ecx+0x82]", "[ecx+0x84]"],
    },
    {
        "va": 0x0043385D,
        "end": 0x00433932,
        "label": "action payload defense/avoid applier",
        "summary": "선택된 액션 payload record +0x14/+0x15를 actor +0x7a/+0x7c에 곱한다. 공격 중 방어/회피 쪽 보정이 여기서 한 번 더 들어간다.",
        "needles": ["[eax+0x14]", "[eax+0x7a]", "[ecx+0x7a]", "[eax+0x15]", "[eax+0x7c]", "[ecx+0x7c]"],
    },
    {
        "va": 0x00433932,
        "end": 0x00433A30,
        "label": "mode/status coefficient applier",
        "summary": "actor +0x2a로 0x0048bcaa 테이블을 고르고 record +0x10/+0x15/+0x12/+0x14/+0x11/+0x13을 각각 +0x70/+0x72/+0x74/+0x78/+0x7a/+0x7c에 곱한다.",
        "needles": ["[eax+0x2a]", "0x48bcaa", "[eax+0x10]", "[eax+0x15]", "[eax+0x12]", "[eax+0x14]", "[eax+0x11]", "[eax+0x13]", "[ecx+0x70]", "[ecx+0x72]", "[ecx+0x74]", "[ecx+0x78]", "[ecx+0x7a]", "[ecx+0x7c]"],
    },
    {
        "va": 0x00433F0E,
        "end": 0x004340AE,
        "label": "hit unit coefficient applier",
        "summary": "hit unit byte0..2를 +0x80/+0x82/+0x84와 곱해 최종 +0x70/+0x72/+0x74를 만든다. 즉 자세/상태 보정은 hit unit 직전의 base coefficient로 작용한다.",
        "needles": ["0x59e2a4", "[eax+0x80]", "[eax+0x82]", "[eax+0x84]", "[ecx+0x70]", "[ecx+0x72]", "[ecx+0x74]", "[ecx+0x6b]", "[ecx+0x6c]"],
    },
]


FIELD_MAP = [
    {
        "recordByte": "+0x10",
        "coefficient": "attackCoef",
        "actorField": "+0x70",
        "meaning": "공격력 계수",
        "evidence": "0x433932에서 +0x70에 곱해지고, damage formula에서 +0x70 * 공격력(+0x1c)로 쓰인다.",
    },
    {
        "recordByte": "+0x11",
        "coefficient": "defenseCoef",
        "actorField": "+0x7a",
        "meaning": "방어력 계수",
        "evidence": "0x433932에서 +0x7a에 곱해지고, damage formula에서 +0x7a * 방어력(+0x20)로 쓰인다.",
    },
    {
        "recordByte": "+0x12",
        "coefficient": "hitStatusCoef",
        "actorField": "+0x74",
        "meaning": "명중/상태 성공 계수",
        "evidence": "0x433932와 hit unit이 +0x74를 만들고, hit/status gate에서 기술력(+0x22)과 함께 쓰인다.",
    },
    {
        "recordByte": "+0x13",
        "coefficient": "avoidCoef",
        "actorField": "+0x7c",
        "meaning": "회피 계수",
        "evidence": "0x433932에서 +0x7c에 곱해지고, hit/avoid gate에서 순발력(+0x24)과 함께 쓰인다.",
    },
    {
        "recordByte": "+0x14",
        "coefficient": "actionSpeedCoef",
        "actorField": "+0x78",
        "meaning": "행동순서/속도 계수",
        "evidence": "0x43376e와 0x433932가 +0x78을 누적한다. 선제 120, 반격 10이라 사용자 관찰의 행동순서와 맞는다.",
    },
    {
        "recordByte": "+0x15",
        "coefficient": "criticalCoef",
        "actorField": "+0x72",
        "meaning": "크리티컬 결과 계수",
        "evidence": "0x433932에서 +0x72에 곱해지고, actor +0x62 bit 0x10 critical presentation branch가 +0x72를 사용한다.",
    },
    {
        "recordByte": "+0x16/+0x17",
        "coefficient": "flags",
        "actorField": "(not coefficient)",
        "meaning": "비계수 tail metadata",
        "evidence": "0x48bcaa code ref는 상태명 표시(0x411c0c)와 계수 적용(0x433943)뿐이고, 계수 적용은 +0x10..+0x15까지만 읽는다. 각 record VA 직접 참조도 0건이므로 전투 계산 소비자는 없다.",
    },
]


TAIL_FLAG_INTERPRETATION = [
    {
        "recordByte": "+0x16",
        "mask": "0x01",
        "meaning": "전투 자세 레코드 표식",
        "matchingIds": "1..5",
        "confidence": "패턴 확정 / 전투 계산 소비자 없음",
        "evidence": "보통/돌격/방어/선제/반격 5개만 +0x16=1이고 나머지 상태/버프/상태이상은 모두 0이다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x01",
        "meaning": "비자세 상태 레코드 표식",
        "matchingIds": "6..27",
        "confidence": "패턴 확정 / 전투 계산 소비자 없음",
        "evidence": "전투 자세 1..5는 0이고, 아타호 술취함부터 기절까지 모든 상태 id에는 bit 0x01이 켜져 있다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x02",
        "meaning": "지속/누적 계열 상태군 표식 후보",
        "matchingIds": "6..9, 21, 25, 26",
        "confidence": "고신뢰 패턴 / 정적 분류 메타데이터",
        "evidence": "아타호 술취함 진행 상태와 졸림/독/마비처럼 timer나 누적 경로를 갖는 상태에 공통으로 켜진다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x04",
        "meaning": "캐릭터 고유 부작용/불리 상태 표식 후보",
        "matchingIds": "10, 15, 20",
        "confidence": "고신뢰 패턴 / 정적 분류 메타데이터",
        "evidence": "곤드레만드레, 푸쉬, 피곤함처럼 아타호/스마슈/린샹 고유 루틴의 나쁜 종착 상태에만 켜진다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x08",
        "meaning": "1턴 행동불가 결과 상태 표식 후보",
        "matchingIds": "22, 23, 24",
        "confidence": "고신뢰 패턴 / +0x62 bit 0x01 경로와 정합 / 전투 계산 소비자 없음",
        "evidence": "행동정지/넘어짐/휙 날아감은 0x434e84에서 +0x62 bit 0x01을 세우는 1턴 행동불가 계열이다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x10",
        "meaning": "졸림 전용 표식 후보",
        "matchingIds": "21",
        "confidence": "패턴 확정 / 전투 계산 소비자 없음",
        "evidence": "졸림만 0x13(0x10|0x02|0x01)이고 같은 timed 계열인 독/마비와 구분된다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x20",
        "meaning": "독/마비 지속 상태 표식 후보",
        "matchingIds": "25, 26",
        "confidence": "패턴 확정 / 전투 계산 소비자 없음",
        "evidence": "독과 마비만 0x23(0x20|0x02|0x01)으로 묶인다.",
    },
    {
        "recordByte": "+0x17",
        "mask": "0x40",
        "meaning": "기절/전투불능 표식 후보",
        "matchingIds": "27",
        "confidence": "패턴 확정 / +0x62 bit 0x40 경로와 정합 / 전투 계산 소비자 없음",
        "evidence": "기절만 0x41(0x40|0x01)이고 HP 0 처리에서 +0x2a=27과 +0x62 bit 0x40이 함께 쓰인다.",
    },
]


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    image_base, sections = pe_sections(exe)
    mode_rows = decode_mode_table(exe, sections, image_base)
    function_rows = []
    for spec in FUNCTIONS:
        disasm = disassemble(spec["va"], spec["end"])
        function_rows.append(
            {
                **spec,
                "vaHex": hex32(spec["va"]),
                "endHex": hex32(spec["end"]),
                "keyLines": compact_lines(disasm, spec["needles"], context=0),
            }
        )
    return {
        "sourceExe": str(EXE.relative_to(ROOT)),
        "modeTableVaHex": hex32(MODE_TABLE_VA),
        "modeTableCount": MODE_TABLE_COUNT,
        "status": "mode-coefficients-grounded-tail-metadata",
        "summary": [
            "전투 자세/상태 이름과 6개 계수 byte는 0x0048bcaa 포인터 테이블에서 확정적으로 읽힌다.",
            "기본 자세 5개는 보통/돌격/방어/선제/반격이며, 사용자 설명과 EXE 수치가 일치한다.",
            "돌격은 공격 115, 방어 90, 명중 110, 회피 80이다. 방어는 공격 80, 방어 150이다.",
            "선제는 행동순서 계수 120, 반격은 행동순서 계수 10이라 turn order 설명과 맞는다.",
            "+0x7e는 target-side critical/status resistance 계수로 확정됐다. 이 자세/상태 테이블은 +0x7e를 바꾸지 않아 reset 기본값 100이 유지된다.",
            "+0x72는 attacker 쪽 critical result gate 계수로 승격한다. critical은 텍스트가 아니라 섬광/전용 사운드/높은 데미지로 표시된다.",
            "record +0x16/+0x17은 전투 계산 계수가 아니다. 0x48bcaa를 쓰는 code path는 상태명 표시와 +0x10..+0x15 계수 적용뿐이며, record VA 직접 참조도 없다.",
            "+0x16/+0x17은 전투 런타임 계산 미확정이 아니라 비소비 정적 분류 메타데이터로 닫는다. +0x16은 전투 자세 1..5 표식, +0x17은 비자세 상태 그룹 마스크다.",
        ],
        "fieldMap": FIELD_MAP,
        "tailFlagInterpretation": TAIL_FLAG_INTERPRETATION,
        "modeRows": mode_rows,
        "functionRows": function_rows,
        "openQuestions": [],
    }


def render_table(headers: list[str], rows: list[dict[str, Any]]) -> str:
    head = "".join(f"<th>{esc(header)}</th>" for header in headers)
    body = []
    for row in rows:
        cells = []
        for header in headers:
            value = row.get(header, "")
            if header.endswith("Coef") and isinstance(value, int):
                cls = "good" if value > 100 else "bad" if value < 100 else ""
                cells.append(f'<td><span class="coef {cls}">{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:
    mode_headers = [
        "index",
        "name",
        "category",
        "recordVaHex",
        "attackCoef",
        "defenseCoef",
        "hitStatusCoef",
        "avoidCoef",
        "actionSpeedCoef",
        "criticalCoef",
        "flag0x16",
        "flag0x17",
    ]
    field_headers = ["recordByte", "coefficient", "actorField", "meaning", "evidence"]
    tail_headers = ["recordByte", "mask", "meaning", "matchingIds", "confidence", "evidence"]
    summary = "".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    open_questions = "".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    functions = []
    for row in data["functionRows"]:
        functions.append(
            f"""
            <details>
              <summary>{esc(row['vaHex'])} {esc(row['label'])}</summary>
              <p>{esc(row['summary'])}</p>
              <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>전투 자세/상태 계수 검토</title>
    <style>
      :root {{
        color-scheme: light;
        --bg: #f6f7f9;
        --fg: #17202a;
        --muted: #607080;
        --line: #d8dee6;
        --head: #eef2f6;
        --link: #185abc;
        --good: #0f766e;
        --bad: #b42318;
      }}
      * {{ 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; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }}
      h1 {{ margin: 0 0 6px; font-size: 24px; }}
      h2 {{ margin: 22px 0 8px; font-size: 18px; }}
      a {{ color: var(--link); text-decoration: none; font-weight: 650; }}
      a:hover {{ text-decoration: underline; }}
      .sub {{ color: var(--muted); }}
      nav {{ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }}
      .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; }}
      .wide {{ overflow: auto; border: 1px solid var(--line); }}
      .coef {{
        display: inline-flex;
        min-width: 42px;
        justify-content: center;
        border-radius: 4px;
        padding: 2px 6px;
        background: #f1f5f9;
        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      }}
      .coef.good {{ background: #dff7ef; color: var(--good); }}
      .coef.bad {{ background: #ffe4e6; color: var(--bad); }}
      pre {{
        white-space: pre-wrap;
        overflow: auto;
        background: #111827;
        color: #e5e7eb;
        border-radius: 6px;
        padding: 10px;
        font-size: 12px;
      }}
      details {{ background: white; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 8px 0; }}
      summary {{ cursor: pointer; font-weight: 700; }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <div>
          <h1>전투 자세/상태 계수 검토</h1>
          <p class="sub">mode table {esc(data['modeTableVaHex'])} · actor +0x2a indexed coefficient records</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="battle_damage_formula_trace_review.html">데미지 공식</a>
          <a href="battle_stat_flag_status_semantics_review.html">스탯/플래그</a>
          <a href="battle_player_actor_stat_layout_review.html">플레이어 Actor</a>
        </nav>
      </header>

      <section class="panel">
        <h2>결론</h2>
        <ul>{summary}</ul>
      </section>

      <h2>레코드 필드 의미</h2>
      <div class="wide">{render_table(field_headers, data["fieldMap"])}</div>

      <h2>Tail Flag 해석 후보</h2>
      <div class="wide">{render_table(tail_headers, data["tailFlagInterpretation"])}</div>

      <h2>자세/상태 계수 테이블</h2>
      <div class="wide">{render_table(mode_headers, data["modeRows"])}</div>

      <section class="panel">
        <h2>남은 부분</h2>
        <ul>{open_questions}</ul>
      </section>

      <h2>근거 함수</h2>
      {''.join(functions)}
    </main>
  </body>
</html>
"""


def render_md(data: dict[str, Any]) -> str:
    lines = [
        "# 전투 자세/상태 계수 검토",
        "",
        f"- table: `{data['modeTableVaHex']}`",
        f"- records: `{data['modeTableCount']}`",
        "",
        "## 결론",
        "",
    ]
    lines.extend(f"- {item}" for item in data["summary"])
    lines.extend(["", "## 자세 5종", ""])
    lines.append("| index | name | attack | defense | hit/status | avoid | order | critical |")
    lines.append("|---:|---|---:|---:|---:|---:|---:|---:|")
    for row in data["modeRows"]:
        if row["category"] == "battle stance":
            lines.append(
                f"| {row['index']} | {row['name']} | {row['attackCoef']} | {row['defenseCoef']} | "
                f"{row['hitStatusCoef']} | {row['avoidCoef']} | {row['actionSpeedCoef']} | {row['criticalCoef']} |"
            )
    lines.extend(["", "## 전체 상태 테이블", ""])
    lines.append("| index | name | category | attack | defense | hit/status | avoid | order | critical | flags |")
    lines.append("|---:|---|---|---:|---:|---:|---:|---:|---:|---|")
    for row in data["modeRows"]:
        if row["index"] == 0:
            continue
        lines.append(
            f"| {row['index']} | {row['name']} | {row['category']} | {row['attackCoef']} | {row['defenseCoef']} | "
            f"{row['hitStatusCoef']} | {row['avoidCoef']} | {row['actionSpeedCoef']} | {row['criticalCoef']} | "
            f"{row['flag0x16']:02x} {row['flag0x17']:02x} |"
        )
    lines.extend(["", "## Tail Flag 해석 후보", ""])
    lines.append("| record byte | mask | meaning | matching ids | confidence |")
    lines.append("|---|---|---|---|---|")
    for row in data["tailFlagInterpretation"]:
        lines.append(
            f"| {row['recordByte']} | {row['mask']} | {row['meaning']} | {row['matchingIds']} | {row['confidence']} |"
        )
    lines.extend(["", "## 남은 부분", ""])
    if data["openQuestions"]:
        lines.extend(f"- {item}" for item in data["openQuestions"])
    else:
        lines.append("- 없음")
    lines.append("")
    return "\n".join(lines)


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


if __name__ == "__main__":
    main()
