#!/usr/bin/env python3
"""Build a focused review for consumable battle recovery effects.

This report follows the EXE-bound consumable effect dispatch table at
0x00546a38.  It specifically closes the open question from the actor +0x62
flag lifecycle report: knockout/inactive actors keep +0x62 bits 0x40/0x80
until revive-capable consumables overwrite the flag byte with 0x01.
"""
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"

IMAGE_BASE = 0x00400000
DISPATCH_TABLE_VA = 0x00546A38


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]
    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_u32(blob: bytes, sections: list[dict[str, int | str]], va: int) -> int:
    offset = va_to_offset(va, sections)
    if offset is None:
        raise ValueError(f"cannot map VA {hex32(va)}")
    return struct.unpack_from("<I", blob, offset)[0]


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


ITEM_ROWS = [
    {
        "index": 0,
        "name": "none / unused",
        "function": 0x004357DB,
        "effect": "no-op",
        "flagEffect": "-",
        "confidence": "확정",
    },
    {
        "index": 1,
        "name": "약초",
        "function": 0x004357E6,
        "effect": "HP +50",
        "flagEffect": "HP clamp helper만 호출",
        "confidence": "확정",
    },
    {
        "index": 2,
        "name": "해독초",
        "function": 0x00435810,
        "effect": "독/마비 해제. 상태가 없고 item context가 전투 사용이면 HP/MP를 소량 회복.",
        "flagEffect": "독/마비 해제 시 +0x62 bit 0x02를 지우고, 마비 해제는 bit 0x01을 세운다.",
        "confidence": "확정",
    },
    {
        "index": 3,
        "name": "리프레시 워터",
        "function": 0x0043583C,
        "effect": "기절/비활성 대상이면 상태를 되살린 뒤 HP +200.",
        "flagEffect": "+0x62 bit 0x80이 있으면 +0x66 prior mode 복원 후 +0x62=0x01로 덮어써 0x40/0x80을 동시에 해제.",
        "confidence": "확정",
    },
    {
        "index": 4,
        "name": "마법의 물약",
        "function": 0x004358AB,
        "effect": "MP +50",
        "flagEffect": "MP clamp helper만 호출",
        "confidence": "확정",
    },
    {
        "index": 5,
        "name": "고급한방약",
        "function": 0x004358D5,
        "effect": "HP +100, MP +100",
        "flagEffect": "HP/MP clamp helper만 호출",
        "confidence": "확정",
    },
    {
        "index": 6,
        "name": "마수석",
        "function": 0x0043590D,
        "effect": "기절/비활성 대상이면 상태를 되살린 뒤 HP/MP를 최대치까지 회복.",
        "flagEffect": "+0x62 bit 0x80이 있으면 +0x66 prior mode 복원 후 +0x62=0x01로 덮어써 0x40/0x80을 동시에 해제.",
        "confidence": "확정",
    },
]


EVIDENCE_SPECS = [
    {
        "label": "item/recovery dispatch through actor +0x59",
        "range": (0x00435711, 0x00435736),
        "needles": ["[eax+0x59]", "0x546a38", "call   DWORD PTR"],
        "context": 4,
    },
    {
        "label": "alternate dispatch from hit-unit loader when +0x58 is set",
        "range": (0x00433F40, 0x00433F78),
        "needles": ["[eax+0x58]", "[eax+0x59]", "0x546a38"],
        "context": 4,
    },
    {
        "label": "antidote clears poison/paralysis state",
        "range": (0x0043497D, 0x00434A45),
        "needles": ["[eax+0x2a]", "0x19", "0x1a", "and    cl,0xfd", "or     cl,0x1", "0x435295", "0x435356"],
        "context": 4,
    },
    {
        "label": "refresh water clears inactive/knockout flags and heals 200 HP",
        "range": (0x0043583C, 0x004358AA),
        "needles": ["test   cl,0x80", "[eax+0x66]", "[eax+0x2a]", "[eax+0x64]", "[eax+0x62],0x1", "0xc8", "0x435295"],
        "context": 4,
    },
    {
        "label": "beast stone clears inactive/knockout flags and fully heals HP/MP",
        "range": (0x0043590D, 0x00435992),
        "needles": ["test   cl,0x80", "[eax+0x66]", "[eax+0x2a]", "[eax+0x62],0x1", "[eax+0xa]", "[eax+0x10]", "0x435295", "0x435356"],
        "context": 4,
    },
    {
        "label": "HP recovery clamp helper records visible result unless target has 0x80",
        "range": (0x00435295, 0x00435356),
        "needles": ["[eax+0xa]", "[eax+0x8]", "[eax+0x62]", "test   cl,0x80", "[eax+0x6e]"],
        "context": 4,
    },
    {
        "label": "MP recovery clamp helper",
        "range": (0x00435356, 0x004353F8),
        "needles": ["[eax+0x10]", "[eax+0xe]", "[eax+0x6e]"],
        "context": 4,
    },
]


def build_data() -> dict[str, Any]:
    blob = EXE.read_bytes()
    image_base, sections = pe_sections(blob)
    table = []
    for index in range(17):
        ptr = read_u32(blob, sections, DISPATCH_TABLE_VA + index * 4)
        item = next((row for row in ITEM_ROWS if row["index"] == index), None)
        table.append(
            {
                "index": index,
                "entryVaHex": hex32(DISPATCH_TABLE_VA + index * 4),
                "functionVaHex": hex32(ptr),
                "knownName": item["name"] if item else "unused/no-op" if ptr == 0x004357DB else "unknown",
                "effect": item["effect"] if item else "no-op" if ptr == 0x004357DB else "unclassified",
                "flagEffect": item["flagEffect"] if item else "-",
                "confidence": item["confidence"] if item else "확정" if ptr == 0x004357DB else "후보",
            }
        )

    evidence = []
    for spec in EVIDENCE_SPECS:
        disasm = disassemble(*spec["range"])
        evidence.append(
            {
                "label": spec["label"],
                "rangeHex": f"{hex32(spec['range'][0])}..{hex32(spec['range'][1])}",
                "lines": compact_lines(disasm, spec["needles"], context=spec["context"]),
            }
        )

    return {
        "version": 1,
        "kind": "battle-recovery-item-effect-review",
        "source": "tools/build_battle_recovery_item_effect_review.py",
        "status": "confirmed consumable effect dispatch and revive flag clear",
        "dispatchTableVaHex": hex32(DISPATCH_TABLE_VA),
        "summary": [
            "소모품 전투 효과는 actor +0x59를 index로 삼아 0x00546a38 함수 포인터 테이블을 호출한다.",
            "index 1..6은 약초/해독초/리프레시 워터/마법의 물약/고급한방약/마수석과 일치한다.",
            "리프레시 워터와 마수석은 target +0x62 bit 0x80을 검사한 뒤 +0x62=0x01로 덮어써 bit 0x40/0x80을 동시에 해제한다.",
            "따라서 기절 HP0에서 세워진 0x40과 표시/비활성 전환에서 세워진 0x80은 부활 아이템 효과 함수에서 해제되는 것으로 볼 수 있다.",
            "부활 후 남는 +0x62=0x01은 battle_one_turn_flag_review에서 확인한 1턴 actor latch다. action-script gate mask 0x83에 걸리고, battle-order cleanup 0x0040d95b에서 +0x2a 복원/+0x64/+0x66 clear 후 bit 0x01이 정리된다.",
            "전투 밖 메뉴 사용 경로가 같은 table/helper를 재사용하는지는 map/menu event 범위다. 이 보고서는 전투 중 소모품 효과 dispatch와 actor flag 해제만 다룬다.",
        ],
        "table": table,
        "helpers": [
            {
                "functionVaHex": "0x00435295",
                "name": "HP recovery clamp",
                "meaning": "current HP + amount, max HP clamp. battle-result context(0x59e34d==1)에서는 +0x6e result도 갱신하지만 target 0x80이면 result를 0으로 둔다.",
            },
            {
                "functionVaHex": "0x00435356",
                "name": "MP recovery clamp",
                "meaning": "current MP + amount, max MP clamp. battle-result context에서는 +0x6e result도 갱신한다.",
            },
            {
                "functionVaHex": "0x0043497d",
                "name": "poison/paralysis cure",
                "meaning": "독(25) 또는 마비(26)면 +0x66 prior mode를 복원하고 +0x64 timer를 지운 뒤 +0x62 bit 0x02를 해제한다. 마비 해제는 bit 0x01도 세운다.",
            },
        ],
        "evidence": evidence,
        "openQuestions": [],
    }


def write_html(data: dict[str, Any]) -> None:
    rows = "\n".join(
        "<tr>"
        f"<td>{row['index']}</td>"
        f"<td><code>{esc(row['entryVaHex'])}</code></td>"
        f"<td><code>{esc(row['functionVaHex'])}</code></td>"
        f"<td>{esc(row['knownName'])}</td>"
        f"<td>{esc(row['effect'])}</td>"
        f"<td>{esc(row['flagEffect'])}</td>"
        f"<td><span class='tag'>{esc(row['confidence'])}</span></td>"
        "</tr>"
        for row in data["table"]
    )
    helper_rows = "\n".join(
        "<tr>"
        f"<td><code>{esc(row['functionVaHex'])}</code></td>"
        f"<td>{esc(row['name'])}</td>"
        f"<td>{esc(row['meaning'])}</td>"
        "</tr>"
        for row in data["helpers"]
    )
    evidence_blocks = "\n".join(
        f"<section class='evidence'><h2>{esc(block['label'])}</h2><div class='range'>{esc(block['rangeHex'])}</div>"
        f"<pre>{esc(chr(10).join(block['lines']) or '(no focused lines)')}</pre></section>"
        for block in data["evidence"]
    )
    summary_items = "\n".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    open_items = "\n".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    html_text = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Battle Recovery Item Effect Review</title>
  <style>
    :root {{ color-scheme: light; }}
    body {{ margin: 24px; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1f2933; background: #f7f8fa; }}
    h1 {{ margin: 0 0 8px; font-size: 24px; }}
    h2 {{ margin: 24px 0 8px; font-size: 18px; }}
    .meta, .range {{ color: #667085; font-size: 13px; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 22px; background: #fff; }}
    th, td {{ border: 1px solid #d7dde5; padding: 8px 10px; vertical-align: top; font-size: 13px; }}
    th {{ background: #edf1f5; text-align: left; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
    pre {{ white-space: pre-wrap; overflow: auto; background: #111827; color: #d1d5db; padding: 12px; border-radius: 6px; font-size: 12px; line-height: 1.45; }}
    .tag {{ display: inline-block; padding: 2px 6px; border-radius: 999px; background: #dbeafe; color: #1e40af; font-size: 12px; }}
    .panel {{ background: #fff; border: 1px solid #d7dde5; padding: 14px 18px; margin: 12px 0 20px; }}
  </style>
</head>
<body>
  <h1>Battle Recovery Item Effect Review</h1>
  <div class="meta">source: <code>{esc(data['source'])}</code> · dispatch table <code>{esc(data['dispatchTableVaHex'])}</code> · {esc(data['status'])}</div>
  <p><a href="../web/index.html">홈</a> · <a href="battle_one_turn_flag_review.html">one-turn actor flag</a> · <a href="battle_recovery_item_effect_review.json">JSON</a> · <a href="battle_recovery_item_effect_review.md">MD</a></p>
  <div class="panel"><ul>{summary_items}</ul></div>
  <h2>Dispatch Table</h2>
  <table>
    <thead><tr><th>Index</th><th>Entry VA</th><th>Function</th><th>Name</th><th>Effect</th><th>Flag Effect</th><th>Confidence</th></tr></thead>
    <tbody>{rows}</tbody>
  </table>
  <h2>Helpers</h2>
  <table>
    <thead><tr><th>Function</th><th>Name</th><th>Meaning</th></tr></thead>
    <tbody>{helper_rows}</tbody>
  </table>
  {evidence_blocks}
  <h2>Open Questions</h2>
  <div class="panel"><ul>{open_items}</ul></div>
</body>
</html>
"""
    (OUT / "battle_recovery_item_effect_review.html").write_text(html_text, encoding="utf-8")


def write_md(data: dict[str, Any]) -> None:
    lines = [
        "# Battle Recovery Item Effect Review",
        "",
        f"- source: `{data['source']}`",
        f"- dispatch table: `{data['dispatchTableVaHex']}`",
        f"- status: {data['status']}",
        "",
        "## Summary",
        "",
    ]
    lines.extend(f"- {item}" for item in data["summary"])
    lines.extend(
        [
            "",
            "## Dispatch Table",
            "",
            "| index | entry | function | name | effect | flag effect | confidence |",
            "|---:|---|---|---|---|---|---|",
        ]
    )
    for row in data["table"]:
        lines.append(
            f"| {row['index']} | `{row['entryVaHex']}` | `{row['functionVaHex']}` | {row['knownName']} | {row['effect']} | {row['flagEffect']} | {row['confidence']} |"
        )
    lines.extend(
        [
            "",
            "## Helpers",
            "",
            "| function | name | meaning |",
            "|---|---|---|",
        ]
    )
    for row in data["helpers"]:
        lines.append(f"| `{row['functionVaHex']}` | {row['name']} | {row['meaning']} |")
    lines.append("")
    lines.append("## Evidence")
    for block in data["evidence"]:
        lines.extend(
            [
                "",
                f"### {block['label']} ({block['rangeHex']})",
                "",
                "```asm",
                "\n".join(block["lines"]) or "(no focused lines)",
                "```",
            ]
        )
    lines.extend(["", "## Open Questions", ""])
    if data["openQuestions"]:
        lines.extend(f"- {item}" for item in data["openQuestions"])
    else:
        lines.append("- 없음")
    lines.append("")
    (OUT / "battle_recovery_item_effect_review.md").write_text("\n".join(lines), encoding="utf-8")


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    data = build_data()
    (OUT / "battle_recovery_item_effect_review.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    write_html(data)
    write_md(data)
    print("wrote battle_recovery_item_effect_review")


if __name__ == "__main__":
    main()
