#!/usr/bin/env python3
"""Trace the shared battle result layer around hit sound/damage display.

This report deliberately separates three layers that were easy to mix up:

* attacker action VM rows (`0xc2`, `0xad`, frame gates)
* target result object script around `0x00454560`

The output is not a full damage formula.  It is a grounded map of what the
current EXE/static evidence proves and what still needs a real battle runtime
sample such as miss/critical/status-success.
"""
from __future__ import annotations

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


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

IMAGE_BASE = 0x00400000
TARGET_SCRIPT_START = 0x00454558
TARGET_SCRIPT_END = 0x00454634

OPCODE_NAMES = {
    0x01: "yield/end marker",
    0x03: "jump",
    0x12: "arith/write",
    0x13: "conditional branch",
    0x21: "sprite/frame write",
    0x24: "effect/cast cue",
    0xAD: "actor flag set/clear",
    0xBD: "helper dispatch",
}


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 wlk_label(value: Any) -> str:
    if value in (None, ""):
        return "-"
    return f"WLK id {int(value):02d}"


def wlk_list_label(values: list[Any] | set[Any] | tuple[Any, ...]) -> str:
    if not values:
        return "-"
    return ", ".join(wlk_label(value) for value in values)


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


def pe_sections(blob: bytes) -> tuple[int, list[dict[str, int | str]]]:
    pe_offset = struct.unpack_from("<I", blob, 0x3C)[0]
    opt_size = struct.unpack_from("<H", blob, pe_offset + 20)[0]
    image_base = struct.unpack_from("<I", blob, pe_offset + 52)[0]
    section_count = struct.unpack_from("<H", blob, pe_offset + 6)[0]
    section_base = pe_offset + 24 + opt_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 = IMAGE_BASE) -> 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_bytes_by_va(blob: bytes, sections: list[dict[str, int | str]], va: int, length: int) -> bytes:
    offset = va_to_offset(va, sections)
    if offset is None:
        raise ValueError(f"cannot map VA {hex32(va)}")
    return blob[offset : offset + length]


def opcode_length(raw: bytes, offset: int) -> int:
    op = raw[offset]
    if op in {0x03, 0x12, 0x13, 0x21, 0xAD}:
        return 8
    if op in {0x01, 0x24, 0xBD}:
        return 4
    return 4


def decode_target_script(blob: bytes, sections: list[dict[str, int | str]]) -> list[dict[str, Any]]:
    raw = read_bytes_by_va(blob, sections, TARGET_SCRIPT_START, TARGET_SCRIPT_END - TARGET_SCRIPT_START)
    rows: list[dict[str, Any]] = []
    offset = 0
    while offset < len(raw):
        va = TARGET_SCRIPT_START + offset
        op = raw[offset]
        length = min(opcode_length(raw, offset), len(raw) - offset)
        chunk = raw[offset : offset + length]
        summary = OPCODE_NAMES.get(op, "unknown/control")
        target = None
        wlk_no = None
        helper_id = None
        frame = None
        gate = None
        branch_mode = None
        flag_mask = None
        if op == 0x03 and len(chunk) >= 8:
            target = struct.unpack_from("<I", chunk, 4)[0]
            summary = f"jump -> {hex32(target)}"
        elif op == 0x13 and len(chunk) >= 8:
            branch_mode = chunk[1]
            flag_mask = chunk[3]
            target = struct.unpack_from("<I", chunk, 4)[0]
            summary = f"if mode=0x{branch_mode:02x} field=0x{chunk[2]:02x} mask/value=0x{flag_mask:02x} -> {hex32(target)}"
        elif op == 0x21 and len(chunk) >= 8:
            mode = chunk[1]
            gate = struct.unpack_from("<H", chunk, 2)[0]
            sprite_frame = struct.unpack_from("<I", chunk, 4)[0]
            if mode == 0x01:
                # Target result objects use mode 1 as an actor-owned frame write:
                # `21 01 08 00 00 00 04 00` is observed at runtime as target frame 4.
                frame = sprite_frame >> 16
                summary = f"set target frame={frame}, gate={gate}, mode=1"
            else:
                frame = sprite_frame & 0xFFFF
                sprite = sprite_frame >> 16
                summary = f"set sprite=0x{sprite:04x}, frame={frame}, gate={gate}, mode=0x{mode:02x}"
        elif op == 0x24 and len(chunk) >= 4:
            wlk_no = chunk[3]
            summary = f"effect/cast cue WLK id {wlk_no:02d}"
        elif op == 0xBD and len(chunk) >= 4:
            helper_id = chunk[1]
            summary = f"helper id 0x{helper_id:02x}"
        elif op == 0xAD and len(chunk) >= 8:
            flag_mask = struct.unpack_from("<I", chunk, 4)[0]
            summary = f"actor flag mode=0x{chunk[1]:02x}, mask=0x{flag_mask:08x}"
        elif op == 0x12 and len(chunk) >= 8:
            imm = struct.unpack_from("<i", chunk, 4)[0]
            summary = f"arith/write mode=0x{chunk[1]:02x}, field=0x{chunk[2]:02x}, imm={imm}"
        rows.append(
            {
                "va": va,
                "vaHex": hex32(va),
                "opcode": f"0x{op:02x}",
                "name": OPCODE_NAMES.get(op, "unknown/control"),
                "length": length,
                "bytes": chunk.hex(" "),
                "summary": summary,
                "targetVa": target,
                "targetVaHex": hex32(target) if target else "",
                "wlkNo": wlk_no,
                "helperId": helper_id,
                "frame": frame,
                "gate": gate,
                "branchMode": f"0x{branch_mode:02x}" if branch_mode is not None else "",
                "flagMask": f"0x{flag_mask:08x}" if isinstance(flag_mask, int) and flag_mask > 0xFF else (f"0x{flag_mask:02x}" if isinstance(flag_mask, int) else ""),
            }
        )
        offset += length
    return rows


def target_blocks(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    labels = {
        0x00454558: ("idle-frame0-loop", "대기 프레임 0을 유지하는 루프"),
        0x00454560: ("idle-jump-entry", "런타임 첫 피격 전/단발 피격에서 cursor가 머무는 진입점"),
        0x00454578: ("result-branch", "피격 결과 플래그 0x20/0x08 분기. 0x20은 full miss, 0x08은 guard/glancing chip result로 승격"),
        0x00454594: ("branch-0x20-full-miss", "0x20 full-miss 표시 경로: effect WLK id 12 + helper 0x05"),
        0x004545AC: ("branch-0x08-guard-glancing", "0x08 guard/glancing chip-result 표시 경로: effect WLK id 11 + helper 0x16"),
        0x004545C4: ("common-result-prelude", "공통 피격 반응 전처리/플래그 조작"),
        0x004545D0: ("hit-frame4-loop", "피격 프레임 4 유지/흔들림 루프. 반복타 런타임 결과가 주로 여기에 찍힘"),
    }
    out = []
    by_va = {row["va"]: row for row in rows}
    sorted_starts = sorted(labels)
    for index, va in enumerate(sorted_starts):
        stop = sorted_starts[index + 1] if index + 1 < len(sorted_starts) else TARGET_SCRIPT_END
        block_rows = [row for row in rows if va <= row["va"] < stop]
        out.append(
            {
                "startVaHex": hex32(va),
                "label": labels[va][0],
                "meaning": labels[va][1],
                "firstOpcode": by_va.get(va, {}).get("opcode", ""),
                "rows": block_rows,
                "wlkNos": sorted({row["wlkNo"] for row in block_rows if row.get("wlkNo")}),
                "helperIds": sorted({row["helperId"] for row in block_rows if row.get("helperId") is not None}),
                "frames": sorted({row["frame"] for row in block_rows if row.get("frame") is not None}),
            }
        )
    return out


def sound_role_summary(sound_role: dict[str, Any]) -> dict[str, Any]:
    rows = {int(row["wlkNo"]): row for row in sound_role.get("wlkRoleRows") or []}
    keep = [1, 4, 5, 6, 12, 13, 15, 25, 36]
    return {
        "rows": [
            {
                "wlkNo": wlk,
                "roleHint": rows.get(wlk, {}).get("roleHint", ""),
                "effectCueCount": rows.get(wlk, {}).get("effectCueCount", 0),
                "resultNormalCount": rows.get(wlk, {}).get("resultNormalCount", 0),
                "resultAltCount": rows.get(wlk, {}).get("resultAltCount", 0),
                "resultDisplayBranchCount": rows.get(wlk, {}).get("resultDisplayBranchCount", 0),
                "runtimeObservedCount": rows.get(wlk, {}).get("runtimeObservedCount", 0),
            }
            for wlk in keep
        ],
        "resolvedPresentation": sound_role.get("resolvedPresentation") or {},
        "unresolved": sound_role.get("unresolved") or {},
    }


def build() -> dict[str, Any]:
    blob = EXE.read_bytes()
    image_base, sections = pe_sections(blob)
    target_script_rows = decode_target_script(blob, sections)
    blocks = target_blocks(target_script_rows)
    sound_role = read_json(SOUND_ROLE)
    target_branch_blocks = [
        block for block in blocks
        if block["startVaHex"] in {"0x00454578", "0x00454594", "0x004545ac"}
    ]
    conclusions = [
        "`a6 -> 0x433f0e`가 payload hit unit을 고르고 target+0x6d/+0x6e에 결과를 만드는 정적 경로와 맞물린다.",
        "`0xc2`는 공격자 action VM 안에 있지만, 실제 데미지 표시와 피격 프레임 유지는 target result object script에서 별도로 진행된다.",
        "`0x00454578`에는 actor sideFlags(+0x62) bit 0x20 / 0x08 조건분기가 있다. 후속 result display/helper 분석에서 0x20은 full miss, 0x08은 guard/glancing chip-result 표시 경로로 승격됐다.",
        "0xc2 consumer의 +0x10 alt operand 경로와 palette flash/fade child script가 critical presentation으로 승격된다.",
    ]
    next_steps = [
        "critical actor+0x62 bit 0x10 -> `0xc2` byte+3 -> WLK id 14 -> palette flash/fade child script 경로는 확정됐다. 남은 것은 브라우저 runner에서 VM tick을 wall-clock ms로 환산하는 미세 타이밍이다.",
        "상태이상 성공 샘플이 있으면 payload unit byte7(status) -> target+0x6c -> target result branch/WLK 여부를 분리할 수 있다.",
        "브라우저 runner는 full miss/guard-glancing-chip/critical result presentation을 분리해도 된다. MISS는 WLK id 12/helper 0x05, guard/glancing은 WLK id 11/helper 0x16, critical은 WLK id 14/palette flash로 분리한다.",
    ]
    return {
        "version": 1,
        "kind": "hwanse-battle-result-layer-trace-review",
        "source": [
            "Hwanse2.exe",
            str(SOUND_ROLE.relative_to(ROOT)),
        ],
        "status": "target-result-layer-static-grounded",
        "exe": {
            "imageBaseHex": hex32(image_base),
            "targetScriptRangeHex": f"{hex32(TARGET_SCRIPT_START)}..{hex32(TARGET_SCRIPT_END)}",
        },
        "summary": {
            "targetScriptRows": len(target_script_rows),
            "targetBlocks": len(blocks),
            "runtimeResultRows": 0,
            "targetDamageRows": 0,
            "runtimeWlkCounts": {},
            "targetTailUsage": [],
        },
        "pipelineEvidence": [],
        "conclusions": conclusions,
        "nextSteps": next_steps,
        "targetScriptRows": target_script_rows,
        "targetBlocks": blocks,
        "targetBranchBlocks": target_branch_blocks,
        "runtimeHitRows": [],
        "flowTargetRows": [],
        "soundRoleSummary": sound_role_summary(sound_role),
    }


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    def display_value(key: str, value: Any) -> Any:
        if key == "wlkNo":
            return wlk_label(value)
        if key == "wlkNos":
            return wlk_list_label(value)
        return value

    body = []
    for row in rows:
        body.append(
            "<tr>"
            + "".join(f"<td>{esc(display_value(key, row.get(key, '')))}</td>" for key, _ in columns)
            + "</tr>"
        )
    return (
        "<table><thead><tr>"
        + "".join(f"<th>{esc(title)}</th>" for _, title in columns)
        + "</tr></thead><tbody>"
        + "".join(body)
        + "</tbody></table>"
    )


def render_md(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Result Layer Trace Review",
        "",
        f"- status: `{report['status']}`",
        f"- target script: `{report['exe']['targetScriptRangeHex']}`",
        f"- runtime result rows: `{report['summary']['runtimeResultRows']}`",
        "",
        "## Conclusions",
        "",
    ]
    lines.extend(f"- {item}" for item in report["conclusions"])
    lines += [
        "",
        "## Target Tail Usage",
        "",
        "| tail VA | count | frames | families | statuses | damage |",
        "| --- | ---: | --- | --- | --- | --- |",
    ]
    for row in report["summary"]["targetTailUsage"]:
        lines.append(
            f"| `{row['tailVaHex']}` | {row['count']} | `{row['frames']}` | `{row['families']}` | `{row['statuses']}` | {row['damageMin']}..{row['damageMax']} |"
        )
    lines += [
        "",
        "## Target Script Blocks",
        "",
        "| start | label | frames | WLK | helpers | meaning |",
        "| --- | --- | --- | --- | --- | --- |",
    ]
    for block in report["targetBlocks"]:
        lines.append(
            f"| `{block['startVaHex']}` | {block['label']} | `{block['frames']}` | {wlk_list_label(block['wlkNos'])} | `{block['helperIds']}` | {block['meaning']} |"
        )
    lines += [
        "",
        "## Runtime Hits",
        "",
        "| phase | hit | damage ms | damage | target frame | target VA | WLK | sound delta |",
        "| --- | ---: | ---: | ---: | ---: | --- | --- | ---: |",
    ]
    for row in report["runtimeHitRows"]:
        lines.append(
            f"| {row['phase']} | {row['hitOrdinal']} | {row['damageAtMs']} | {row['damage']} | {row['targetFrame']} | `{row['targetTailVaHex']}` | {wlk_label(row['wlkNo'])} | {row['soundDeltaMs']} |"
        )
    lines += [
        "",
        "## Next Steps",
        "",
    ]
    lines.extend(f"- {item}" for item in report["nextSteps"])
    return "\n".join(lines) + "\n"


def render_html(report: dict[str, Any]) -> str:
    tail_rows = report["summary"]["targetTailUsage"]
    branch_rows = []
    for block in report["targetBranchBlocks"]:
        for row in block["rows"]:
            branch_rows.append(
                {
                    "block": block["label"],
                    "vaHex": row["vaHex"],
                    "opcode": row["opcode"],
                    "summary": row["summary"],
                    "bytes": row["bytes"],
                }
            )
    sound_rows = report["soundRoleSummary"]["rows"]
    html_doc = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Battle Result Layer Trace Review</title>
  <style>
    :root {{ color-scheme: light; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    body {{ margin: 0; background: #f5f6f8; color: #171a20; }}
    main {{ max-width: 1440px; margin: 0 auto; padding: 28px; }}
    h1 {{ margin: 0 0 8px; font-size: 28px; }}
    h2 {{ margin: 28px 0 10px; font-size: 18px; }}
    .sub {{ color: #5e6673; margin: 0 0 20px; }}
    .grid {{ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }}
    .card {{ background: #fff; border: 1px solid #d8dde6; border-radius: 8px; padding: 14px; }}
    .card strong {{ display: block; font-size: 13px; color: #5e6673; }}
    .card span {{ display: block; margin-top: 4px; font-size: 22px; font-weight: 700; }}
    table {{ width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d8dde6; }}
    th, td {{ border-bottom: 1px solid #e4e8ef; padding: 8px 10px; text-align: left; vertical-align: top; font-size: 13px; }}
    th {{ background: #eef1f6; color: #3e4652; position: sticky; top: 0; z-index: 1; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }}
    .wide {{ overflow: auto; max-height: 620px; border-radius: 8px; }}
    .tag {{ display: inline-block; padding: 2px 7px; border-radius: 999px; background: #fff1cf; border: 1px solid #e3bd57; font-size: 12px; }}
    .ok {{ background: #dff4e7; border-color: #8ccaa2; }}
    .bad {{ background: #ffe3e0; border-color: #df9a91; }}
    ul {{ background: #fff; border: 1px solid #d8dde6; border-radius: 8px; padding: 14px 20px 14px 28px; }}
    li {{ margin: 6px 0; }}
  </style>
</head>
<body>
<main>
  <h1>Battle Result Layer Trace Review</h1>
  <p class="sub">공격자 action VM의 <code>0xc2</code> 결과음과 피격 대상 result object script, target+0x6e 데미지 샘플을 분리해 추적한 결과.</p>
  <div class="grid">
    <div class="card"><strong>status</strong><span>{esc(report['status'])}</span></div>
    <div class="card"><strong>target script rows</strong><span>{report['summary']['targetScriptRows']}</span></div>
    <div class="card"><strong>runtime result rows</strong><span>{report['summary']['runtimeResultRows']}</span></div>
    <div class="card"><strong>damage rows</strong><span>{report['summary']['targetDamageRows']}</span></div>
  </div>
  <h2>Conclusions</h2>
  <ul>{''.join(f'<li>{esc(item)}</li>' for item in report['conclusions'])}</ul>
  <h2>Target Tail Usage</h2>
  <div class="wide">{render_table(tail_rows, [
      ('tailVaHex', 'tail VA'), ('count', 'count'), ('frames', 'frames'),
      ('families', 'families'), ('statuses', 'statuses'), ('damageValues', 'damage values'), ('timeMs', 'time ms')
  ])}</div>
  <h2>Target Script Blocks</h2>
  <div class="wide">{render_table(report['targetBlocks'], [
      ('startVaHex', 'start'), ('label', 'label'), ('meaning', 'meaning'),
      ('frames', 'frames'), ('wlkNos', 'WLK'), ('helperIds', 'helpers')
  ])}</div>
  <h2>Result Branch Paths</h2>
  <p class="sub">0x20/0x08 분기는 후속 display/helper 분석으로 full miss / guard-glancing chip result 표시 경로까지 승격됐다. 이 페이지는 정적 EXE 분기와 결과 스크립트만 근거로 삼는다.</p>
  <div class="wide">{render_table(branch_rows, [
      ('block', 'block'), ('vaHex', 'VA'), ('opcode', 'op'), ('summary', 'summary'), ('bytes', 'bytes')
  ])}</div>
  <h2>Runtime Hit Pairing</h2>
  <div class="wide">{render_table(report['runtimeHitRows'], [
      ('phase', 'phase'), ('hitOrdinal', 'hit'), ('damageAtMs', 'damage ms'), ('damage', 'damage'),
      ('targetFrame', 'target frame'), ('targetTailVaHex', 'target VA'), ('wlkNo', 'WLK'), ('soundDeltaMs', 'sound delta'), ('scriptVa', 'sound script')
  ])}</div>
  <h2>Sound Role Cross-check</h2>
  <div class="wide">{render_table(sound_rows, [
      ('wlkNo', 'WLK'), ('effectCueCount', '0x24 effect'), ('resultNormalCount', '0xc2 normal'),
      ('resultAltCount', '0xc2 alt'), ('resultDisplayBranchCount', 'display branch'),
      ('runtimeObservedCount', 'runtime'), ('roleHint', 'role')
  ])}</div>
  <h2>Target Script Decode</h2>
  <div class="wide">{render_table(report['targetScriptRows'], [
      ('vaHex', 'VA'), ('opcode', 'op'), ('name', 'name'), ('length', 'len'), ('summary', 'summary'), ('bytes', 'bytes')
  ])}</div>
  <h2>Next Steps</h2>
  <ul>{''.join(f'<li>{esc(item)}</li>' for item in report['nextSteps'])}</ul>
</main>
</body>
</html>
"""
    return html_doc


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


if __name__ == "__main__":
    main()
