#!/usr/bin/env python3
"""Scan x86 absolute references into the status-comment backing block.

The short status-window comment selector is read through object+0xa8+0, with
object+0xa8 bound to 0x004576d8.  Prior reports proved the consumer but left the
writer for backing offset +0 unresolved.  This pass performs a narrow static
scan over .text for absolute memory operands that point anywhere inside the
0x004576d8 backing block, then classifies likely reads/writes.
"""
from __future__ import annotations

import html
import json
import struct
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

from build_hud_menu_opener_frontier_review import find_function, function_ranges, hx
from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


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

BACKING_BASE = 0x004576D8
BACKING_SIZE = 0x72


READ_OPS = {
    0xA0: "mov-al-from-moffs8",
    0xA1: "mov-eax-from-moffs32",
    0x8A: "mov-r8-from-mem",
    0x8B: "mov-r32-from-mem",
    0x38: "cmp-r8-mem",
    0x39: "cmp-r32-mem",
    0x3A: "cmp-mem-r8",
    0x3B: "cmp-mem-r32",
}
WRITE_OPS = {
    0xA2: "mov-moffs8-from-al",
    0xA3: "mov-moffs32-from-eax",
    0x88: "mov-mem-from-r8",
    0x89: "mov-mem-from-r32",
    0xC6: "mov-mem8-imm8",
    0xC7: "mov-mem32-imm32",
}
RMW_OPS = {
    0x00: "add-mem-r8",
    0x01: "add-mem-r32",
    0x08: "or-mem-r8",
    0x09: "or-mem-r32",
    0x10: "adc-mem-r8",
    0x11: "adc-mem-r32",
    0x18: "sbb-mem-r8",
    0x19: "sbb-mem-r32",
    0x20: "and-mem-r8",
    0x21: "and-mem-r32",
    0x28: "sub-mem-r8",
    0x29: "sub-mem-r32",
    0x30: "xor-mem-r8",
    0x31: "xor-mem-r32",
    0x80: "grp1-mem8-imm8",
    0x81: "grp1-mem32-imm32",
    0x83: "grp1-mem32-imm8",
    0xFE: "inc-dec-mem8",
    0xFF: "grp5-mem32",
}


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def section_by_name(sections: list[dict[str, Any]], name: str) -> dict[str, Any]:
    for section in sections:
        if section.get("name") == name:
            return section
    raise RuntimeError(f"missing section {name}")


def classify_at(exe: bytes, file_pos: int) -> dict[str, Any]:
    """Classify a possible instruction whose disp32 begins at file_pos."""
    # moffs opcodes: A0/A1/A2/A3 disp32.
    if file_pos >= 1:
        op = exe[file_pos - 1]
        if op in READ_OPS:
            return {
                "classification": "read",
                "mnemonic": READ_OPS[op],
                "instructionFileOffset": file_pos - 1,
                "confidence": "high",
            }
        if op in WRITE_OPS:
            return {
                "classification": "write",
                "mnemonic": WRITE_OPS[op],
                "instructionFileOffset": file_pos - 1,
                "confidence": "high",
            }
    # Optional 0x66 prefix before moffs.
    if file_pos >= 2 and exe[file_pos - 2] == 0x66:
        op = exe[file_pos - 1]
        if op in READ_OPS:
            return {
                "classification": "read",
                "mnemonic": "prefix66-" + READ_OPS[op],
                "instructionFileOffset": file_pos - 2,
                "confidence": "high",
            }
        if op in WRITE_OPS:
            return {
                "classification": "write",
                "mnemonic": "prefix66-" + WRITE_OPS[op],
                "instructionFileOffset": file_pos - 2,
                "confidence": "high",
            }
    # modrm absolute disp32 form: opcode modrm disp32 with mod=00 rm=101.
    if file_pos >= 2:
        op = exe[file_pos - 2]
        modrm = exe[file_pos - 1]
        if (modrm & 0xC7) == 0x05:
            if op in READ_OPS:
                return {
                    "classification": "read",
                    "mnemonic": READ_OPS[op],
                    "instructionFileOffset": file_pos - 2,
                    "modrmHex": f"0x{modrm:02x}",
                    "confidence": "high",
                }
            if op in WRITE_OPS:
                return {
                    "classification": "write",
                    "mnemonic": WRITE_OPS[op],
                    "instructionFileOffset": file_pos - 2,
                    "modrmHex": f"0x{modrm:02x}",
                    "confidence": "high",
                }
            if op in RMW_OPS:
                reg = (modrm >> 3) & 7
                classification = "read"
                if op in {0xFE, 0xFF} and reg in {2, 3, 4, 5}:
                    classification = "indirect-control-read"
                else:
                    classification = "read-write"
                return {
                    "classification": classification,
                    "mnemonic": RMW_OPS[op],
                    "instructionFileOffset": file_pos - 2,
                    "modrmHex": f"0x{modrm:02x}",
                    "groupReg": reg,
                    "confidence": "medium",
                }
    # Prefix before modrm form.
    if file_pos >= 3 and exe[file_pos - 3] in {0x66, 0xF3, 0xF2}:
        op = exe[file_pos - 2]
        modrm = exe[file_pos - 1]
        if (modrm & 0xC7) == 0x05:
            base = classify_at(exe, file_pos)
            if base["classification"] != "unclassified":
                base["instructionFileOffset"] = file_pos - 3
                base["mnemonic"] = f"prefix{exe[file_pos - 3]:02x}-" + str(base.get("mnemonic", ""))
                return base
    return {
        "classification": "unclassified",
        "mnemonic": "",
        "instructionFileOffset": None,
        "confidence": "low",
    }


def scan_refs(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = section_by_name(sections, ".text")
    text_start = int(text["raw"])
    text_end = text_start + int(text["raw_size"])
    functions = function_ranges(exe, sections)
    rows: list[dict[str, Any]] = []
    for offset in range(BACKING_SIZE):
        addr = BACKING_BASE + offset
        needle = struct.pack("<I", addr)
        pos = exe.find(needle, text_start, text_end)
        while pos != -1:
            ref_va = offset_to_va(sections, pos)
            cls = classify_at(exe, pos)
            inst_off = cls.get("instructionFileOffset")
            inst_va = offset_to_va(sections, inst_off) if isinstance(inst_off, int) else None
            function = find_function(functions, inst_va or ref_va or 0)
            rows.append(
                {
                    "offset": offset,
                    "offsetHex": hx(offset, 2),
                    "addressHex": hx(addr),
                    "refVaHex": hx(ref_va),
                    "instructionVaHex": hx(inst_va),
                    "functionVaHex": hx(int(function["startVa"])) if function else "",
                    "functionEndVaHex": hx(int(function["endVa"])) if function else "",
                    "classification": cls["classification"],
                    "mnemonic": cls.get("mnemonic", ""),
                    "confidence": cls.get("confidence", "low"),
                    "modrmHex": cls.get("modrmHex", ""),
                    "groupReg": cls.get("groupReg"),
                    "contextHex": exe[max(text_start, pos - 8) : min(text_end, pos + 12)].hex(" "),
                }
            )
            pos = exe.find(needle, pos + 1, text_end)
    return rows


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    rows = scan_refs(exe, sections)
    by_offset: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        by_offset[row["offsetHex"]].append(row)
    offset_summaries = []
    for offset_hex, refs in sorted(by_offset.items(), key=lambda item: int(item[0], 16)):
        counts = Counter(row["classification"] for row in refs)
        offset_summaries.append(
            {
                "offsetHex": offset_hex,
                "addressHex": refs[0]["addressHex"],
                "refCount": len(refs),
                "classificationCounts": dict(counts),
                "writeLikeCount": sum(counts.get(kind, 0) for kind in ("write", "read-write")),
                "functions": sorted({row["functionVaHex"] for row in refs if row["functionVaHex"]}),
            }
        )
    offset0_refs = by_offset.get("0x00", [])
    offset0_write_like = [
        row for row in offset0_refs if row["classification"] in {"write", "read-write"}
    ]
    writer_offsets = [
        row for row in offset_summaries if row["writeLikeCount"] > 0
    ]
    summary = {
        "backingBlockVaHex": hx(BACKING_BASE),
        "backingBlockSizeBytes": BACKING_SIZE,
        "absoluteTextRefCount": len(rows),
        "referencedOffsetCount": len(offset_summaries),
        "writeLikeOffsetCount": len(writer_offsets),
        "offset0RefCount": len(offset0_refs),
        "offset0WriteLikeCount": len(offset0_write_like),
        "offset0WriterProven": len(offset0_write_like) > 0,
        "decision": (
            "No direct absolute write to backing offset +0 was found. "
            "The exact selector byte still appears to be supplied through a VM/object base path rather than a simple x86 absolute store."
        ),
    }
    return {
        "version": 1,
        "kind": "hwanse-status-comment-backing-writer-scan",
        "source": "tools/build_status_comment_backing_writer_scan.py",
        "summary": summary,
        "offsetSummaries": offset_summaries,
        "writerOffsetSummaries": writer_offsets,
        "offset0Refs": offset0_refs,
        "refs": rows,
    }


def render_html(data: dict[str, Any]) -> str:
    s = data["summary"]
    cards = "".join(
        f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>"
        for k, v in s.items()
        if k != "decision"
    )
    offset_rows = "".join(
        "<tr>"
        f"<td>{h(row['offsetHex'])}</td>"
        f"<td><code>{h(row['addressHex'])}</code></td>"
        f"<td>{h(row['refCount'])}</td>"
        f"<td>{h(row['writeLikeCount'])}</td>"
        f"<td>{h(row['classificationCounts'])}</td>"
        f"<td>{h(', '.join(row['functions'][:8]))}</td>"
        "</tr>"
        for row in data["offsetSummaries"]
    )
    writer_rows = "".join(
        "<tr>"
        f"<td>{h(row['offsetHex'])}</td>"
        f"<td><code>{h(row['addressHex'])}</code></td>"
        f"<td>{h(row['writeLikeCount'])}</td>"
        f"<td>{h(row['classificationCounts'])}</td>"
        f"<td>{h(', '.join(row['functions']))}</td>"
        "</tr>"
        for row in data["writerOffsetSummaries"]
    )
    offset0_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['refVaHex'])}</code></td>"
        f"<td><code>{h(row['instructionVaHex'])}</code></td>"
        f"<td><code>{h(row['functionVaHex'])}</code></td>"
        f"<td>{h(row['classification'])}</td>"
        f"<td>{h(row['mnemonic'])}</td>"
        f"<td><code>{h(row['contextHex'])}</code></td>"
        "</tr>"
        for row in data["offset0Refs"]
    )
    return f"""<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status Comment Backing Writer Scan</title>
<style>
  :root {{ --bg:#f6f7f9; --panel:#fff; --line:#d8dee8; --text:#20242b; --muted:#687080; --bad:#a33b2d; }}
  * {{ box-sizing:border-box; }}
  body {{ margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
  main {{ width:min(1180px, calc(100vw - 28px)); margin:0 auto; padding:24px 0 40px; }}
  h1 {{ margin:0 0 6px; font-size:28px; }}
  h2 {{ margin:22px 0 8px; font-size:18px; }}
  p {{ margin:0; line-height:1.45; }}
  .lead {{ color:var(--muted); }}
  .decision {{ margin:14px 0; padding:12px; border:1px solid #efc3bb; background:#fff5f3; border-radius:8px; }}
  .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin:16px 0; }}
  .card,section {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; }}
  .card b {{ display:block; color:var(--muted); font-size:12px; }}
  .card span {{ display:block; margin-top:4px; font-weight:700; overflow-wrap:anywhere; }}
  table {{ width:100%; border-collapse:collapse; font-size:13px; }}
  th,td {{ border-top:1px solid var(--line); padding:7px; text-align:left; vertical-align:top; }}
  th {{ color:var(--muted); font-size:12px; }}
  code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; }}
  @media (max-width:760px) {{ main {{ width:min(100vw - 18px,1180px); padding-top:16px; }} table {{ display:block; overflow-x:auto; white-space:nowrap; }} }}
</style>
<main>
  <h1>Status Comment Backing Writer Scan</h1>
  <p class="lead">0x004576d8 상태창 문구 backing block 전체에 대한 .text 절대주소 read/write 스캔입니다.</p>
  <div class="decision"><b>결론:</b> {h(s['decision'])}</div>
  <div class="cards">{cards}</div>
  <section>
    <h2>Writer-Like Offsets</h2>
    <table><thead><tr><th>offset</th><th>address</th><th>write-like refs</th><th>classifications</th><th>functions</th></tr></thead><tbody>{writer_rows}</tbody></table>
  </section>
  <section>
    <h2>Offset +0 References</h2>
    <table><thead><tr><th>ref</th><th>instruction</th><th>function</th><th>class</th><th>mnemonic</th><th>context</th></tr></thead><tbody>{offset0_rows}</tbody></table>
  </section>
  <section>
    <h2>All Referenced Offsets</h2>
    <table><thead><tr><th>offset</th><th>address</th><th>refs</th><th>write-like</th><th>classes</th><th>functions</th></tr></thead><tbody>{offset_rows}</tbody></table>
  </section>
</main>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    data = build()
    (OUT / "status_comment_backing_writer_scan.json").write_text(
        json.dumps(data, ensure_ascii=False, separators=(",", ":")),
        encoding="utf-8",
    )
    (WEB / "status_comment_backing_writer_scan.html").write_text(
        render_html(data),
        encoding="utf-8",
    )
    s = data["summary"]
    print(
        "wrote status comment backing writer scan "
        f"(refs={s['absoluteTextRefCount']}, offset0_write={s['offset0WriteLikeCount']})"
    )


if __name__ == "__main__":
    main()
