#!/usr/bin/env python3
"""Build static global flag candidate review for scene/event VM work.

This intentionally stays conservative. It scans x86 absolute-memory
instructions in .text and aggregates .data/.rdata targets that look like
global reads, compares, or writes. Candidates are then scored against already
grounded scene/event artifacts, but are not promoted to route proof.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections


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

JSON_OUT = OUT / "scene_event_global_flag_candidate_review.json"
HTML_OUT = WEB / "scene_event_global_flag_candidate_review.html"
WEB_HTML_OUT = WEB / "scene_event_global_flag_candidate_review.html"


def hx(value: int | None, width: int = 8) -> str:
    if value is None:
        return "-"
    return f"0x{value:0{width}x}"


def u32(data: bytes, off: int) -> int:
    return struct.unpack_from("<I", data, off)[0]


def s8(value: int) -> int:
    return value - 0x100 if value & 0x80 else value


def section_for_va(sections: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    for section in sections:
        start = section["va"]
        end = start + section["size"]
        if start <= va < end:
            return section
    return None


def section_bytes(exe: bytes, section: dict[str, Any]) -> bytes:
    start = section["raw"]
    return exe[start : start + section["raw_size"]]


def code_at(exe: bytes, sections: list[dict[str, Any]], va: int, size: int = 16) -> str:
    section = section_for_va(sections, va)
    if not section:
        return ""
    off = section["raw"] + va - section["va"]
    return exe[off : off + size].hex(" ")


def load_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))


def collect_known_anchors() -> dict[int, dict[str, Any]]:
    anchors: dict[int, dict[str, Any]] = {}

    branch = load_json(OUT / "scene_event_vm_branch_flag_review.json", {})
    summary = branch.get("summary") or {}
    for key, label in [
        ("activeFlagVaHex", "active scene/route flag"),
        ("frontierReaderVaHex", "frontier reader stream slot"),
    ]:
        value = summary.get(key)
        if isinstance(value, str) and value.startswith("0x"):
            va = int(value, 16)
            anchors.setdefault(va, {"va": va, "labels": []})["labels"].append(label)

    for cluster in branch.get("writerClusters") or []:
        for item in cluster.get("sourceGlobals") or []:
            value = item.get("valueHex")
            if isinstance(value, str) and value.startswith("0x"):
                va = int(value, 16)
                anchors.setdefault(va, {"va": va, "labels": []})["labels"].append(
                    item.get("name") or cluster.get("name") or "branch writer source"
                )

    for path in [
        OUT / "save_selector_active_flag_sources.json",
        OUT / "runtime_opcode24_flag_context.json",
        OUT / "save_selector_active_flag_effect.json",
    ]:
        data = load_json(path, {})
        text = json.dumps(data, ensure_ascii=False)
        for token in sorted(set(part.strip('",:[]{} ') for part in text.split())):
            if token.startswith("0x") and len(token) == 10:
                try:
                    va = int(token, 16)
                except ValueError:
                    continue
                if 0x00400000 <= va < 0x00610000:
                    anchors.setdefault(va, {"va": va, "labels": []})["labels"].append(path.name)

    return anchors


def collect_context_ranges() -> list[dict[str, Any]]:
    ranges: list[dict[str, Any]] = []

    event_handlers = load_json(OUT / "event_handler_text_refs.json", {})
    for row in event_handlers.get("rows") or []:
        start = row.get("handlerVa")
        end = row.get("endVa")
        if isinstance(start, int) and isinstance(end, int) and end > start:
            ranges.append(
                {
                    "start": start,
                    "end": end,
                    "kind": "event-object-handler",
                    "label": f"event handler {row.get('opcodes')}",
                }
            )

    script_handlers = load_json(OUT / "script_handler_table.json", {})
    for row in script_handlers.get("entries") or []:
        start = row.get("handlerVa")
        if not isinstance(start, int) or row.get("isDefaultHandler"):
            continue
        scan = ((row.get("streamEffect") or {}).get("scanBytes") or 0)
        end = start + max(80, min(512, int(scan) + 32))
        ranges.append(
            {
                "start": start,
                "end": end,
                "kind": "script-handler",
                "label": f"script opcode {row.get('opcodeHex')}",
            }
        )

    branch = load_json(OUT / "scene_event_vm_branch_flag_review.json", {})
    for cluster in branch.get("writerClusters") or []:
        range_hex = cluster.get("rangeHex") or ""
        if ".." not in range_hex:
            continue
        left, right = range_hex.split("..", 1)
        try:
            start = int(left, 16)
            end = int(right, 16)
        except ValueError:
            continue
        ranges.append(
            {
                "start": start,
                "end": end,
                "kind": "branch-writer-cluster",
                "label": cluster.get("name") or "branch writer cluster",
            }
        )

    return ranges


def nearest_context(va: int, ranges: list[dict[str, Any]]) -> dict[str, Any] | None:
    best: tuple[int, dict[str, Any]] | None = None
    for item in ranges:
        if item["start"] <= va < item["end"]:
            return {**item, "distance": 0}
        if va < item["start"]:
            distance = item["start"] - va
        else:
            distance = va - item["end"]
        if distance <= 256 and (best is None or distance < best[0]):
            best = (distance, item)
    if best is None:
        return None
    return {**best[1], "distance": best[0]}


def collect_stream_global_refs() -> Counter[int]:
    refs: Counter[int] = Counter()
    for path in [OUT / "scene_events.json", OUT / "event_record_structure_review.json"]:
        data = load_json(path, [])
        text = json.dumps(data, ensure_ascii=False)
        for token in text.replace(",", " ").replace("[", " ").replace("]", " ").split():
            token = token.strip('":{}')
            if not token.startswith("0x"):
                continue
            try:
                value = int(token, 16)
            except ValueError:
                continue
            if 0x00400000 <= value < 0x00610000:
                refs[value] += 1
    return refs


def scan_absolute_global_ops(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = next(section for section in sections if section["name"] == ".text")
    code = section_bytes(exe, text)
    base = text["va"]
    rows: list[dict[str, Any]] = []

    def add(i: int, length: int, kind: str, mnemonic: str, target: int, imm: int | None = None) -> None:
        sec = section_for_va(sections, target)
        if sec is None or sec["name"] not in {".data", ".rdata"}:
            return
        va = base + i
        rows.append(
            {
                "codeVa": va,
                "codeVaHex": hx(va),
                "targetVa": target,
                "targetVaHex": hx(target),
                "targetSection": sec["name"],
                "kind": kind,
                "mnemonic": mnemonic,
                "immediate": imm,
                "immediateHex": hx(imm, 2) if imm is not None and 0 <= imm <= 0xFF else (hx(imm) if imm is not None else None),
                "bytes": code[i : i + length].hex(" "),
            }
        )

    n = len(code)
    for i in range(n - 10):
        b0 = code[i]
        b1 = code[i + 1]
        b2 = code[i + 2]

        if b0 == 0x80 and b1 == 0x3D:
            add(i, 7, "compare", "cmp byte ptr [abs], imm8", u32(code, i + 2), code[i + 6])
        elif b0 == 0x83 and b1 == 0x3D:
            add(i, 7, "compare", "cmp dword ptr [abs], imm8", u32(code, i + 2), s8(code[i + 6]))
        elif b0 == 0x81 and b1 == 0x3D:
            add(i, 10, "compare", "cmp dword ptr [abs], imm32", u32(code, i + 2), u32(code, i + 6))
        elif b0 == 0xA0:
            add(i, 5, "read", "mov al, [abs]", u32(code, i + 1))
        elif b0 == 0xA1:
            add(i, 5, "read", "mov eax, [abs]", u32(code, i + 1))
        elif b0 == 0xA2:
            add(i, 5, "write", "mov [abs], al", u32(code, i + 1))
        elif b0 == 0xA3:
            add(i, 5, "write", "mov [abs], eax", u32(code, i + 1))
        elif b0 == 0x8A and b1 == 0x05:
            add(i, 6, "read", "mov al, [abs]", u32(code, i + 2))
        elif b0 == 0x8B and b1 == 0x05:
            add(i, 6, "read", "mov eax, [abs]", u32(code, i + 2))
        elif b0 == 0x0F and b1 == 0xB6 and b2 == 0x05:
            add(i, 7, "read", "movzx eax, byte ptr [abs]", u32(code, i + 3))
        elif b0 == 0x0F and b1 == 0xB7 and b2 == 0x05:
            add(i, 7, "read", "movzx eax, word ptr [abs]", u32(code, i + 3))
        elif b0 == 0x88 and b1 == 0x05:
            add(i, 6, "write", "mov [abs], al", u32(code, i + 2))
        elif b0 == 0x89 and b1 == 0x05:
            add(i, 6, "write", "mov [abs], eax", u32(code, i + 2))
        elif b0 == 0xC6 and b1 == 0x05:
            add(i, 7, "write-imm", "mov byte ptr [abs], imm8", u32(code, i + 2), code[i + 6])
        elif b0 == 0xC7 and b1 == 0x05:
            add(i, 10, "write-imm", "mov dword ptr [abs], imm32", u32(code, i + 2), u32(code, i + 6))
        elif b0 == 0xFE and b1 == 0x05:
            add(i, 6, "read-write", "inc byte ptr [abs]", u32(code, i + 2))
        elif b0 == 0xFF and b1 in {0x05, 0x0D}:
            add(i, 6, "read-write", "inc/dec dword ptr [abs]", u32(code, i + 2))
        elif b0 == 0x66 and b1 == 0x81 and b2 == 0x3D:
            add(i, 9, "compare", "cmp word ptr [abs], imm16", u32(code, i + 3), struct.unpack_from("<H", code, i + 7)[0])
        elif b0 == 0x66 and b1 == 0x83 and b2 == 0x3D:
            add(i, 8, "compare", "cmp word ptr [abs], imm8", u32(code, i + 3), s8(code[i + 7]))
        elif b0 == 0x66 and b1 == 0xC7 and b2 == 0x05:
            add(i, 9, "write-imm", "mov word ptr [abs], imm16", u32(code, i + 3), struct.unpack_from("<H", code, i + 7)[0])
        elif b0 == 0x66 and b1 == 0xA3:
            add(i, 6, "write", "mov [abs], ax", u32(code, i + 2))
        elif b0 == 0x66 and b1 == 0xFF and b2 in {0x05, 0x0D}:
            add(i, 7, "read-write", "inc/dec word ptr [abs]", u32(code, i + 3))

    return rows


def aggregate_candidates(
    rows: list[dict[str, Any]],
    sections: list[dict[str, Any]],
    anchors: dict[int, dict[str, Any]],
    ranges: list[dict[str, Any]],
    stream_refs: Counter[int],
) -> list[dict[str, Any]]:
    grouped: dict[int, dict[str, Any]] = {}
    for row in rows:
        target = row["targetVa"]
        item = grouped.setdefault(
            target,
            {
                "targetVa": target,
                "targetVaHex": hx(target),
                "targetSection": row["targetSection"],
                "counts": Counter(),
                "immediates": Counter(),
                "mnemonics": Counter(),
                "contexts": Counter(),
                "evidence": [],
                "anchorLabels": anchors.get(target, {}).get("labels", []),
                "streamRefCount": stream_refs.get(target, 0),
            },
        )
        item["counts"][row["kind"]] += 1
        item["mnemonics"][row["mnemonic"]] += 1
        if row.get("immediate") is not None:
            item["immediates"][row["immediate"]] += 1
        context = nearest_context(row["codeVa"], ranges)
        if context:
            row["contextKind"] = context["kind"]
            row["contextLabel"] = context["label"]
            row["contextDistance"] = context["distance"]
            item["contexts"][context["kind"]] += 1
        if len(item["evidence"]) < 16:
            item["evidence"].append(row)

    candidates: list[dict[str, Any]] = []
    for item in grouped.values():
        counts = item["counts"]
        read_count = counts["read"] + counts["compare"] + counts["read-write"]
        write_count = counts["write"] + counts["write-imm"] + counts["read-write"]
        compare_count = counts["compare"]
        score = 0
        reasons: list[str] = []

        if item["targetSection"] == ".data":
            score += 10
            reasons.append(".data global")
        if read_count and write_count:
            score += 35
            reasons.append("read/write pair")
        elif compare_count and write_count:
            score += 30
            reasons.append("compare/write pair")
        elif compare_count:
            score += 12
            reasons.append("compare-only")
        elif write_count:
            score += 8
            reasons.append("write-only")

        small_imms = [value for value in item["immediates"] if -1 <= int(value) <= 8]
        if small_imms:
            score += min(15, len(small_imms) * 3)
            reasons.append("small immediate values")

        if item["anchorLabels"]:
            score += 35
            reasons.append("known flag artifact anchor")
        if item["streamRefCount"]:
            score += 18
            reasons.append("appears in event/scene artifact text")
        if item["contexts"]:
            score += min(24, 10 + sum(item["contexts"].values()) * 2)
            reasons.append("near event/script handler context")

        if score >= 70:
            confidence = "high"
        elif score >= 45:
            confidence = "medium"
        elif score >= 25:
            confidence = "low"
        else:
            confidence = "noise"

        item["score"] = score
        item["confidence"] = confidence
        item["reasons"] = reasons
        item["counts"] = dict(counts)
        item["immediates"] = {hx(k, 2) if 0 <= int(k) <= 0xFF else hx(int(k)): v for k, v in item["immediates"].items()}
        item["mnemonics"] = dict(item["mnemonics"])
        item["contexts"] = dict(item["contexts"])
        candidates.append(item)

    candidates.sort(key=lambda row: (row["score"], sum(row["counts"].values())), reverse=True)
    return candidates


def trim_for_json(candidates: list[dict[str, Any]], rows: list[dict[str, Any]]) -> dict[str, Any]:
    counts = Counter(c["confidence"] for c in candidates)
    summary = {
        "absoluteMemoryOpCount": len(rows),
        "candidateGlobalCount": len(candidates),
        "highConfidenceCount": counts["high"],
        "mediumConfidenceCount": counts["medium"],
        "lowConfidenceCount": counts["low"],
        "noiseCount": counts["noise"],
        "dataCandidateCount": sum(1 for c in candidates if c["targetSection"] == ".data"),
        "rdataCandidateCount": sum(1 for c in candidates if c["targetSection"] == ".rdata"),
        "promotionStatus": "global-flag-candidates-static-only",
    }
    return {
        "scope": "scene/event global flag candidate tracking",
        "summary": summary,
        "decisions": [
            "x86 absolute-memory .text scan only; this is not full disassembly.",
            "Candidates are scored by read/write pairing, small immediates, known branch artifacts, and event/script handler proximity.",
            "No candidate is promoted to map transition proof without a consumer path from an executed scene/event record.",
        ],
        "topCandidates": candidates[:120],
        "allCandidateCount": len(candidates),
        "evidenceRows": rows[:400],
    }


def render_html(payload: dict[str, Any]) -> str:
    s = payload["summary"]

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

    def tag(value: str) -> str:
        cls = {"high": "good", "medium": "warn", "low": "muted", "noise": "bad"}.get(value, "muted")
        return f'<span class="tag {cls}">{esc(value)}</span>'

    rows = []
    for row in payload["topCandidates"]:
        evidence = "<br>".join(
            f"<code>{esc(ev['codeVaHex'])}</code> {esc(ev['kind'])} {esc(ev['mnemonic'])}"
            + (f" imm={esc(ev.get('immediateHex'))}" if ev.get("immediateHex") else "")
            + (f" <span class='muted'>{esc(ev.get('contextLabel'))}</span>" if ev.get("contextLabel") else "")
            for ev in row["evidence"][:5]
        )
        rows.append(
            "<tr>"
            f"<td>{tag(row['confidence'])}</td>"
            f"<td>{esc(row['score'])}</td>"
            f"<td><code>{esc(row['targetVaHex'])}</code><br><span class='muted'>{esc(', '.join(row.get('anchorLabels') or []))}</span></td>"
            f"<td>{esc(row['targetSection'])}</td>"
            f"<td>{esc(row['counts'])}</td>"
            f"<td>{esc(row['immediates'])}</td>"
            f"<td>{esc(', '.join(row['reasons']))}</td>"
            f"<td>{evidence}</td>"
            "</tr>"
        )
    html_text = 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>Scene/Event Global Flag 후보</title>
  <style>
    :root {{ color-scheme: light; --border:#d8dee6; --ink:#17202a; --muted:#607080; --panel:#fff; --head:#eef2f6; --bg:#f6f7f9; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    section {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; }}
    .tag.good {{ color:#0f766e; background:#e6f4f1; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .tag.muted {{ color:#607080; background:#edf2f7; }}
    .muted {{ color:var(--muted); }}
  </style>
</head>
<body>
<main data-page="scene-event-global-flag-candidate-review">
  <header>
    <div>
      <h1>Scene/Event Global Flag 후보</h1>
      <p class="muted">전역 flag compare/set 후보를 정적으로 모은다. route proof가 아니라 다음 추적 대상 목록이다.</p>
    </div>
    <nav>
      <a href="index.html">관리 홈</a>
      <a href="scene_event_vm_review.html">Scene/Event VM</a>
      <a href="scene_event_vm_command_stream_candidates.html">command stream</a>
      <a href="scene_event_vm_branch_flag_review.html">branch/flag</a>
      <a href="../out/scene_event_global_flag_candidate_review.json">JSON</a>
    </nav>
  </header>
  <section>
    <div class="head"><h2>요약</h2><span class="tag warn">{esc(s['promotionStatus'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{esc(s['absoluteMemoryOpCount'])}</strong><span>absolute memory ops</span></div>
      <div class="metric"><strong>{esc(s['candidateGlobalCount'])}</strong><span>candidate globals</span></div>
      <div class="metric"><strong>{esc(s['highConfidenceCount'])}</strong><span>high</span></div>
      <div class="metric"><strong>{esc(s['mediumConfidenceCount'])}</strong><span>medium</span></div>
      <div class="metric"><strong>{esc(s['lowConfidenceCount'])}</strong><span>low</span></div>
    </div>
    <div class="body muted">높은 점수는 read/write와 handler 근접도가 있다는 뜻이며, 같은 입구의 조건 분기 확정은 아니다.</div>
  </section>
  <section>
    <div class="head"><h2>후보</h2><span>score순</span></div>
    <table>
      <thead><tr><th>confidence</th><th>score</th><th>target</th><th>section</th><th>counts</th><th>immediates</th><th>reasons</th><th>evidence</th></tr></thead>
      <tbody>{''.join(rows)}</tbody>
    </table>
  </section>
</main>
<script>
window.HWANSE_SCENE_EVENT_GLOBAL_FLAG_CANDIDATE_REVIEW_READY = {{
  loaded: true,
  globalFlagCandidateReviewImplemented: true,
  promotionStatus: "global-flag-candidates-static-only",
  highConfidenceCount: {s['highConfidenceCount']},
  mediumConfidenceCount: {s['mediumConfidenceCount']},
  routeProofFound: false
}};
</script>
</body>
</html>
"""
    return html_text


def main() -> None:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    anchors = collect_known_anchors()
    ranges = collect_context_ranges()
    stream_refs = collect_stream_global_refs()
    rows = scan_absolute_global_ops(exe, sections)
    for row in rows:
        row["codeBytes"] = code_at(exe, sections, row["codeVa"], 16)
    candidates = aggregate_candidates(rows, sections, anchors, ranges, stream_refs)
    payload = trim_for_json(candidates, rows)

    JSON_OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    html_text = render_html(payload)
    HTML_OUT.write_text(html_text, encoding="utf-8")
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT}")
    print(f"wrote {WEB_HTML_OUT}")
    print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
