#!/usr/bin/env python3
"""Summarize selected-target latch producers and consumers."""

from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

from probe_exe_scene_tables import read_sections


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

GLOBALS = {
    0x0059E347: "selected target latch / opcode24 mode2 source",
    0x0059E348: "opcode24 mode1 source byte",
    0x0059E33E: "current runtime actor/object index",
    0x0059E34A: "selection cursor index",
    0x0059E33F: "selection result class",
    0x0059E34D: "selection/runtime enabled flag",
}


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


def hx(value: int) -> str:
    return f"0x{value:08x}"


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 prefix_info(data: bytes, pos: int) -> tuple[str, str, int]:
    if pos >= 1 and data[pos - 1] == 0xA0:
        return "read", "mov al, ds:[addr]", 1
    if pos >= 1 and data[pos - 1] == 0xA2:
        return "write", "mov ds:[addr], al", 1
    if pos >= 2 and data[pos - 2:pos] == b"\x8a\x0d":
        return "read", "mov cl, byte ptr ds:[addr]", 2
    if pos >= 2 and data[pos - 2:pos] == b"\x88\x0d":
        return "write", "mov byte ptr ds:[addr], cl", 2
    if pos >= 2 and data[pos - 2:pos] == b"\x8a\x15":
        return "read", "mov dl, byte ptr ds:[addr]", 2
    if pos >= 2 and data[pos - 2:pos] == b"\x88\x15":
        return "write", "mov byte ptr ds:[addr], dl", 2
    if pos >= 2 and data[pos - 2:pos] == b"\xc6\x05":
        return "write", "mov byte ptr ds:[addr], imm8", 2
    return "unknown", "unclassified direct address reference", 0


def direct_refs(exe: bytes) -> list[dict[str, Any]]:
    sections = read_sections(exe)
    text = next(section for section in sections if section["name"] == ".text")
    data = exe[text["raw"]: text["raw"] + text["raw_size"]]
    rows: list[dict[str, Any]] = []
    for address, meaning in GLOBALS.items():
        needle = struct.pack("<I", address)
        pos = data.find(needle)
        while pos >= 0:
            access, instruction, prefix_len = prefix_info(data, pos)
            rows.append(
                {
                    "globalVaHex": hx(address),
                    "meaning": meaning,
                    "instructionVaHex": hx(text["va"] + pos - prefix_len),
                    "refVaHex": hx(text["va"] + pos),
                    "access": access,
                    "instruction": instruction,
                }
            )
            pos = data.find(needle, pos + 1)
    rows.sort(key=lambda row: (row["globalVaHex"], row["instructionVaHex"]))
    return rows


FUNCTIONS = [
    {
        "label": "selected-target gate / only direct e347 producer",
        "range": (0x0040FA6D, 0x0040FB32),
        "needles": ["0x59e33e", "0x433649", "test   cl,0x8", "0x59e347", "add    DWORD PTR [eax+0x40],0x8"],
        "meaning": "opcode 0x5c checks target-scope bit 0x08. If selection is not required, it copies current actor/object 0x0059e33e into 0x0059e347. If selection is required, it advances into the nested selection script and does not write e347.",
    },
    {
        "label": "manual/menu action setup consumes e347",
        "range": (0x0040F57C, 0x0040F6A0),
        "needles": ["0x433649", "[ecx+0x67]", "0x59e347", "[ecx+0x61]", "0x435538"],
        "meaning": "manual/menu action setup copies 0x0059e347 into actor +0x61, then passes the same target actor into target-side setup.",
    },
    {
        "label": "selection-mode mode1 consumes e348",
        "range": (0x0040C673, 0x0040C688),
        "needles": ["0x59e348", "add    eax,0x3", "[ecx+0x61]"],
        "meaning": "selection-mode opcode24 mode 1 writes byte(0x0059e348)+3 directly into actor +0x61. It bypasses 0x0059e347.",
    },
    {
        "label": "selection-mode mode2 consumes e347",
        "range": (0x0040C688, 0x0040C698),
        "needles": ["0x59e347", "[ecx+0x61]"],
        "meaning": "selection-mode opcode24 mode 2 writes byte(0x0059e347) into actor +0x61.",
    },
    {
        "label": "interactive confirm stores cursor entry, not e347",
        "range": (0x0041FBAA, 0x0041FC8A),
        "needles": ["0x59e34a", "0x59e2a8", "[eax+0x58]", "0x59e33f", "[eax+0xb0]"],
        "meaning": "interactive confirm reads the selected list entry from cursor 0x0059e34a, writes the entry id to object +0x58, and writes the entry class to 0x0059e33f. It does not write 0x0059e347.",
    },
]


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    refs = direct_refs(exe)
    refs_by_global: dict[str, list[dict[str, Any]]] = {}
    for row in refs:
        refs_by_global.setdefault(row["globalVaHex"], []).append(row)
    function_rows = []
    for spec in FUNCTIONS:
        start, stop = spec["range"]
        disasm = disassemble(start, stop)
        function_rows.append(
            {
                "label": spec["label"],
                "range": f"{hx(start)}..{hx(stop)}",
                "meaning": spec["meaning"],
                "keyLines": compact_lines(disasm, spec["needles"], context=1),
            }
        )
    e347_refs = refs_by_global.get("0x0059e347", [])
    e348_refs = refs_by_global.get("0x0059e348", [])
    return {
        "version": 1,
        "kind": "hwanse-battle-selected-target-latch-review",
        "source": str(Path(__file__).relative_to(ROOT)),
        "status": "battle-selected-target-latch-resolved",
        "summary": [
            "0x0059e347 자체의 직접 producer는 0x0040fb1c/0x0040fb21 한 곳으로 닫힌다.",
            "그 producer는 0x0059e33e current actor/object index를 0x0059e347에 복사한다.",
            "이 복사는 target-scope bit 0x08이 없는 경로, 즉 nested target-selection UI가 필요 없는 기본 대상 경로에서만 수행된다.",
            "target-scope bit 0x08이 있으면 0x0040fa6d는 e347을 쓰지 않고 stream +4 nested selection script로 들어간다.",
            "사용자 선택 UI confirm/helper는 선택 entry id를 script object +0x58에 쓰고 entry class를 0x0059e33f에 남긴다. e347 직접 write는 없다.",
            "실제 selection-mode apply에서 mode 1은 0x0059e348 + 3을 actor +0x61에 직접 쓰고, mode 2는 0x0059e347을 actor +0x61에 쓴다.",
            "따라서 'selected target latch e347 직접 producer'는 확정 완료다.",
            "0x0059e348은 이 전투 latch의 producer가 아니라 opcode24 mode1의 별도 source byte다. 기존 save/opcode24 보고서 기준으로 raw byte 없음, direct write 없음, save block 밖, 진단 런타임에서도 0 유지로 정리되어 있으므로 전투 내부 미확정이 아니라 공용 VM/selector 미확정으로 격리한다.",
        ],
        "globals": {
            "0x0059e347": {
                "role": "selected target latch / opcode24 mode2 source",
                "directReadCount": sum(1 for row in e347_refs if row["access"] == "read"),
                "directWriteCount": sum(1 for row in e347_refs if row["access"] == "write"),
                "directWriter": "0x0040fb21",
                "directProducer": "0x0040fb1c reads 0x0059e33e; 0x0040fb21 writes that byte to 0x0059e347",
            },
            "0x0059e348": {
                "role": "opcode24 mode1 source byte; consumed as target index base via +3",
                "directReadCount": sum(1 for row in e348_refs if row["access"] == "read"),
                "directWriteCount": sum(1 for row in e348_refs if row["access"] == "write"),
                "battleTargetLatchRole": "not the e347 producer; separate opcode24 mode1 source",
                "quarantine": "shared opcode24/save-selector reports track this as a non-battle-internal open item",
                "evidenceReports": [
                    "out/save_selector_opcode24_mode1_runtime_context.json",
                    "out/save_selector_opcode24_mode1_source_writes.json",
                    "out/save_selector_opcode24_mode1_indirect_context.json",
                ],
            },
        },
        "directRefs": refs,
        "functionRows": function_rows,
        "resolvedItems": [
            "전투 내부 selected-target latch 관점에서는 남은 e347 producer 미확정이 없다.",
            "0x0059e347 direct producer는 0x0040fb1c/0x0040fb21 하나로 닫혔다.",
            "target-scope bit 0x08이 없는 기본 경로는 0x0059e33e current actor/object index를 e347에 복사한다.",
            "target-scope bit 0x08이 있는 경로는 nested target-selection script로 들어가며 e347을 직접 쓰지 않는다.",
        ],
        "externalItems": [
            "0x0059e348 producer/meaning은 공용 opcode24 mode1/save-selector 추적 범위로 분리한다. 기존 보고서들은 no raw byte, no direct/static producer, no save-loader backing, diagnostic runtime zero를 기록한다.",
        ],
        "openQuestions": [],
    }


def 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:
        body.append("<tr>" + "".join(f"<td>{esc(row.get(header, ''))}</td>" for header in headers) + "</tr>")
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def write_json(data: dict[str, Any], out_path: Path = OUT / "battle_selected_target_latch_review.json") -> Path:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(
        json.dumps(data, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    return out_path


def write_md(data: dict[str, Any], out_path: Path) -> Path:
    lines = ["# Battle Selected Target Latch Review", "", "## Summary", ""]
    lines.extend(f"- {item}" for item in data["summary"])
    lines.extend(["", "## Resolved Items", ""])
    lines.extend(f"- {item}" for item in data["resolvedItems"])
    lines.extend(["", "## External / Deferred Items", ""])
    lines.extend(f"- {item}" for item in data["externalItems"])
    lines.extend(["", "## Globals", ""])
    for key, row in data["globals"].items():
        lines.append(f"- `{key}`: {row}")
    lines.extend(["", "## Function Evidence", ""])
    for row in data["functionRows"]:
        lines.extend([f"### {row['label']} `{row['range']}`", "", row["meaning"], "", "```asm"])
        lines.extend(row["keyLines"] or ["-"])
        lines.extend(["```", ""])
    lines.extend(["## Direct References", "", "| global | access | instruction VA | instruction | meaning |", "| --- | --- | --- | --- | --- |"])
    for row in data["directRefs"]:
        lines.append(
            f"| `{row['globalVaHex']}` | {row['access']} | `{row['instructionVaHex']}` | `{row['instruction']}` | {row['meaning']} |"
        )
    lines.extend(["", "## Open Questions", ""])
    lines.extend(f"- {item}" for item in data["openQuestions"] or ["없음"])
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out_path


def write_html(data: dict[str, Any], out_path: Path) -> Path:
    function_rows = []
    for row in data["functionRows"]:
        function_rows.append(
            "<section>"
            f"<h2>{esc(row['label'])} <code>{esc(row['range'])}</code></h2>"
            f"<p>{esc(row['meaning'])}</p>"
            f"<pre>{esc(chr(10).join(row['keyLines']) or '-')}</pre>"
            "</section>"
        )
    refs = table(["globalVaHex", "access", "instructionVaHex", "instruction", "meaning"], data["directRefs"])
    html_text = "\n".join(
        [
            "<!doctype html><meta charset=\"utf-8\"><title>Battle Selected Target Latch Review</title>",
            "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;margin:24px}table{border-collapse:collapse;width:100%;font-size:13px}td,th{border:1px solid #444;padding:6px 8px;vertical-align:top}code{color:#9bd4ff}pre{white-space:pre-wrap;background:#191919;border:1px solid #333;padding:10px;overflow:auto}.tag{display:inline-block;border:1px solid #555;padding:2px 6px;border-radius:4px;color:#9fe29f}</style>",
            "<h1>Battle Selected Target Latch Review</h1>",
            f"<p><span class=\"tag\">{esc(data['status'])}</span></p>",
            "<h2>Summary</h2><ul>",
            *(f"<li>{esc(item)}</li>" for item in data["summary"]),
            "</ul>",
            "<h2>Resolved Items</h2><ul>",
            *(f"<li>{esc(item)}</li>" for item in data["resolvedItems"]),
            "</ul>",
            "<h2>External / Deferred Items</h2><ul>",
            *(f"<li>{esc(item)}</li>" for item in data["externalItems"]),
            "</ul>",
            "<h2>Global Summary</h2>",
            f"<pre>{esc(json.dumps(data['globals'], ensure_ascii=False, indent=2))}</pre>",
            "".join(function_rows),
            "<h2>Direct References</h2>",
            refs,
            "<h2>Open Questions</h2><ul>",
            *(f"<li>{esc(item)}</li>" for item in (data["openQuestions"] or ["없음"])),
            "</ul>",
        ]
    )
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(html_text, encoding="utf-8")
    return out_path


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--out-json", type=Path, default=OUT / "battle_selected_target_latch_review.json")
    parser.add_argument("--md-out", type=Path)
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    data = build()
    json_out = write_json(data, args.out_json)
    if args.md_out is not None:
        write_md(data, args.md_out)
    if args.html_out is not None:
        write_html(data, args.html_out)
    print(f"wrote battle selected target latch review -> {json_out}")


if __name__ == "__main__":
    main()
