#!/usr/bin/env python3
"""Scan cross-domain mode/state intersections for remaining producer roots.

Several fronts are now stuck at the same kind of boundary:

* HUD menu: UI payload/cursor consumers are grounded, opener pending.
* Scene/Event: text/choice/root consumers are grounded, route producer pending.
* Field encounter: formation/resource consumers are grounded, walking producer
  pending.
* Map animation: dirty/redraw consumers are grounded, visible-motion producer
  pending.

This pass looks at the EXE function graph from above those surfaces.  It asks:
do input, field tick, descriptor/menu state, scene selected-root state, battle
formation/resource setup, and map-animation buffers meet in the same x86
function?  Promotion is deliberately strict.  Save/load restore and already
known VM helpers are useful evidence, but they are not live gameplay roots.
"""
from __future__ import annotations

import html
import json
from collections import Counter
from pathlib import Path
from typing import Any

from build_hud_menu_opener_frontier_review import (
    ADD_DESCRIPTOR_ROUTINE_VA,
    REMOVE_DESCRIPTOR_ROUTINE_VA,
    REBUILD_DESCRIPTOR_ROUTINE_VA,
    TOP_MENU_OBJECT_SEQUENCE_VA,
    call_sites,
    find_function,
    function_ranges,
    hx,
    refs_in_function,
    text_refs,
)
from probe_exe_scene_tables import read_sections


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


SYMBOLS: dict[str, tuple[str, int, str]] = {
    # Input / field state
    "input.currentMask": ("ref", 0x0059E310, "input"),
    "input.edgeMask": ("ref", 0x0059E312, "input"),
    "input.pollDriver": ("call", 0x00422D74, "input"),
    # Menu / descriptor state
    "menu.descriptorCount": ("ref", 0x004576E8, "menu"),
    "menu.descriptorOrder": ("ref", 0x004576E9, "menu"),
    "menu.descriptorRows": ("ref", 0x00457750, "menu"),
    "menu.rootTable": ("ref", 0x00442D95, "menu"),
    "menu.topObjectSequence": ("ref", TOP_MENU_OBJECT_SEQUENCE_VA, "menu"),
    "menu.category": ("ref", 0x0059E340, "menu"),
    "menu.cursor": ("ref", 0x0059E34A, "menu"),
    "menu.enabledTable": ("ref", 0x0059E360, "menu"),
    "menu.dispatchLatch": ("ref", 0x0055B128, "menu"),
    "menu.addDescriptor": ("call", ADD_DESCRIPTOR_ROUTINE_VA, "menu"),
    "menu.removeDescriptor": ("call", REMOVE_DESCRIPTOR_ROUTINE_VA, "menu"),
    "menu.rebuildDescriptor": ("call", REBUILD_DESCRIPTOR_ROUTINE_VA, "menu"),
    # Scene/event selected-root and text/display state
    "scene.selectorA": ("ref", 0x004576DA, "scene"),
    "scene.selectorB": ("ref", 0x004576DB, "scene"),
    "scene.selectedRoot": ("ref", 0x0059DE30, "scene"),
    "scene.randomGateThreshold": ("ref", 0x0059DB1C, "scene"),
    "scene.randomGateCounter": ("ref", 0x0059DB1F, "scene"),
    "scene.textStatusConsumer": ("call", 0x0041B66D, "scene"),
    # Field/tick/update/map buffers
    "field.tickRoute": ("call", 0x00411476, "field"),
    "field.activeObjectScriptRoute": ("call", 0x00432FF0, "field"),
    "field.genericObjectScriptRunner": ("call", 0x00402321, "field"),
    "field.liveLayer0Grid": ("ref", 0x00595AF0, "field"),
    "field.liveLayer1Flags": ("ref", 0x0058D7D0, "field"),
    "field.dirtyGrid": ("ref", 0x005957D0, "field"),
    "field.mapWidth": ("ref", 0x00595ADA, "field"),
    "field.viewX": ("ref", 0x004576DC, "field"),
    "field.viewY": ("ref", 0x004576DE, "field"),
    # Encounter / battle setup primitives
    "encounter.sharedRng": ("call", 0x00427730, "encounter"),
    "encounter.rngSelectorHandler": ("call", 0x0040BCC9, "encounter"),
    "encounter.formationConsumer": ("call", 0x0040C084, "encounter"),
    "encounter.resourceRunner": ("call", 0x00423A2F, "encounter"),
    "encounter.mapResourceLoader": ("call", 0x0042449C, "encounter"),
}


KNOWN_CLASSIFICATIONS = {
    0x0043022D: {
        "classification": "field-movement-controller",
        "promotion": "excluded-known-consumer",
        "reason": "input/current mask, descriptor count, collision flags, viewport refs meet in the movement/collision controller. It is already excluded as the menu opener and not a battle/scene producer.",
    },
    0x00423319: {
        "classification": "save-load-state-restore",
        "promotion": "excluded-non-live-route",
        "reason": "loads/restores 0x4576d8 state block, 0x457750 descriptor block, 0x59db60 flags, then restores 0x59de30 from selector bytes. It proves save/load restoration, not live scene/menu/encounter routing.",
    },
    0x00431FE8: {
        "classification": "descriptor-add-helper",
        "promotion": "excluded-generic-stack-helper",
        "reason": "generic descriptor stack add helper; it may call text/status consumer but is below the root producer.",
    },
    0x00432323: {
        "classification": "descriptor-rebuild-helper",
        "promotion": "excluded-generic-stack-helper",
        "reason": "generic descriptor stack rebuild helper; useful support layer, not an opener/root selector.",
    },
    0x00432541: {
        "classification": "descriptor-remove-helper",
        "promotion": "excluded-generic-stack-helper",
        "reason": "generic descriptor stack remove helper; this is cancel/stack maintenance support.",
    },
    0x0040B13B: {
        "classification": "menu-direction-input-helper",
        "promotion": "excluded-menu-internal",
        "reason": "directional input dispatch to cursor helper after a menu/list object exists.",
    },
    0x0040C6CC: {
        "classification": "cancel-back-handler",
        "promotion": "excluded-cancel-back",
        "reason": "ESC/X edge handling that unwinds active descriptor state; this is cancel/back behavior, not normal opener or higher-level mode transition.",
    },
    0x00411476: {
        "classification": "field-tick-update-route",
        "promotion": "excluded-known-route-no-direct-producer",
        "reason": "per-frame field tick/update route; it calls update paths before redraw but has no direct live map-buffer mutation or encounter/menu/scene producer bridge.",
    },
    0x0041D61B: {
        "classification": "menu-internal-dispatcher",
        "promotion": "excluded-menu-internal",
        "reason": "confirmed cursor/confirm/cancel dispatcher after menu object construction.",
    },
    0x0041D89D: {
        "classification": "display-vm-base-binder",
        "promotion": "excluded-base-binder",
        "reason": "display/object VM base pointer binder, not descriptor attachment producer.",
    },
    0x0040BCC9: {
        "classification": "generic-vm-rng-selector",
        "promotion": "excluded-primitive",
        "reason": "generic random selector primitive using descriptor context; not field encounter routing by itself.",
    },
    0x004330E0: {
        "classification": "scene-random-gate",
        "promotion": "excluded-scene-random-gate",
        "reason": "scene/event random gate; previous field encounter scans exclude it as encounter RNG.",
    },
}


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


def collect_hits(exe: bytes, sections: list[dict[str, Any]]) -> tuple[dict[str, list[int]], dict[str, list[int]]]:
    refs: dict[str, list[int]] = {}
    calls: dict[str, list[int]] = {}
    for name, (kind, va, _category) in SYMBOLS.items():
        if kind == "ref":
            refs[name] = text_refs(exe, sections, va)
        else:
            calls[name] = call_sites(exe, sections, va)
    return refs, calls


def row_refs(function: dict[str, Any], refs_by_name: dict[str, list[int]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for name, refs in refs_by_name.items():
        for ref in refs_in_function(function, refs):
            rows.append({"name": name, "vaHex": hx(ref), "category": SYMBOLS[name][2]})
    return rows


def row_calls(function: dict[str, Any], calls_by_name: dict[str, list[int]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for name, calls in calls_by_name.items():
        for call in refs_in_function(function, calls):
            rows.append({"name": name, "vaHex": hx(call), "category": SYMBOLS[name][2]})
    return rows


def classify_function(va: int, categories: set[str], refs: list[dict[str, Any]], calls: list[dict[str, Any]]) -> dict[str, str]:
    if va in KNOWN_CLASSIFICATIONS:
        return KNOWN_CLASSIFICATIONS[va]
    names = {row["name"] for row in refs + calls}
    if {"input", "menu", "field"} <= categories and not ({"scene", "encounter"} & categories):
        return {
            "classification": "field-or-render-descriptor-consumer",
            "promotion": "excluded-consumer-intersection",
            "reason": "input/menu/field refs meet without scene/encounter/root evidence; this matches already known movement/render descriptor layers.",
        }
    if "scene" in categories and "menu" in categories and "scene.selectedRoot" in names:
        return {
            "classification": "scene-menu-state-bridge-review",
            "promotion": "review-required",
            "reason": "scene selected-root and descriptor/menu state meet. Promote only if this is live route execution, not save/load or generic stack helper.",
        }
    if "encounter" in categories and "field" in categories:
        return {
            "classification": "field-encounter-bridge-review",
            "promotion": "review-required",
            "reason": "field/tick and encounter primitives meet. This would be promotable only if resource/formation setup is selected from walking/map state.",
        }
    if "encounter" in categories and "menu" in categories:
        return {
            "classification": "rng-descriptor-context-review",
            "promotion": "context-only",
            "reason": "RNG and descriptor state meet; previous scans usually classify these as generic VM/battle/status helpers unless field/map state is also present.",
        }
    return {
        "classification": "cross-domain-context",
        "promotion": "context-only",
        "reason": "Multiple categories meet, but strict producer/root conditions are not satisfied.",
    }


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    functions = function_ranges(exe, sections)
    refs_by_name, calls_by_name = collect_hits(exe, sections)

    touched: dict[int, dict[str, Any]] = {}
    for hits in list(refs_by_name.values()) + list(calls_by_name.values()):
        for hit in hits:
            function = find_function(functions, hit)
            if function:
                touched[int(function["startVa"])] = function

    rows: list[dict[str, Any]] = []
    for function in sorted(touched.values(), key=lambda item: int(item["startVa"])):
        refs = row_refs(function, refs_by_name)
        calls = row_calls(function, calls_by_name)
        categories = sorted({row["category"] for row in refs + calls})
        if len(categories) < 2:
            continue
        va = int(function["startVa"])
        classification = classify_function(va, set(categories), refs, calls)
        rows.append(
            {
                "functionVa": va,
                "functionVaHex": hx(va),
                "endVaHex": hx(int(function["endVa"])),
                "size": int(function["size"]),
                "categories": categories,
                "refs": refs,
                "calls": calls,
                **classification,
            }
        )

    promotion_counts = Counter(row["promotion"] for row in rows)
    category_combo_counts = Counter("+".join(row["categories"]) for row in rows)
    review_required = [row for row in rows if row["promotion"] == "review-required"]
    promoted = [
        row for row in rows
        if row["promotion"].startswith("promoted")
    ]
    save_restore = next((row for row in rows if row["functionVa"] == 0x00423319), None)

    summary = {
        "touchedFunctionCount": len(touched),
        "crossDomainFunctionCount": len(rows),
        "reviewRequiredCount": len(review_required),
        "promotedProducerCount": len(promoted),
        "saveLoadRestoreBridgeFound": save_restore is not None,
        "saveLoadRestoreBridgeVaHex": save_restore["functionVaHex"] if save_restore else None,
        "menuInternalDispatcherExcluded": any(row["functionVa"] == 0x0041D61B for row in rows),
        "fieldMovementControllerExcluded": any(row["functionVa"] == 0x0043022D for row in rows),
        "globalModeStateProducerPromoted": False,
        "promotionCounts": dict(sorted(promotion_counts.items())),
        "categoryComboCounts": dict(sorted(category_combo_counts.items())),
        "decision": (
            "통합 교집합 스캔에서 새 live gameplay mode/state producer는 승격되지 않았다. "
            "가장 강한 scene+menu 교집합인 0x00423319는 save/load restore 경로로 분리됐고, "
            "0x0041d61b/0x0043022d/descriptor helpers/RNG primitive도 각각 내부 소비자 또는 "
            "generic helper로 배제된다."
        ),
    }

    return {
        "kind": "hwanse-global-mode-state-frontier-review",
        "source": "tools/build_global_mode_state_frontier_review.py",
        "status": "cross-domain-frontier-scanned-no-producer-promoted",
        "summary": summary,
        "strictPromotionCriteria": [
            "A candidate must connect a live input/field/scene state transition to exact descriptor attachment, selected-root execution, battle formation/resource setup, or visible map-animation mutation.",
            "Save/load restoration, generic descriptor stack add/remove/rebuild, cursor movement, text/status rendering, and RNG primitives are support layers and do not promote by themselves.",
            "A single shared global such as descriptor count or selected-root is insufficient unless the caller/root shows live execution context.",
        ],
        "crossDomainRows": rows,
        "reviewRequiredRows": review_required,
        "negativePromotions": [
            {
                "functionVaHex": "0x00423319",
                "reason": "save/load restore writes selected-root from selector bytes after reading persistent blocks; useful boundary, not live route producer.",
            },
            {
                "functionVaHex": "0x0043022d",
                "reason": "field movement/collision controller has input+field+descriptor refs but no scene/encounter/top-menu materialization evidence.",
            },
            {
                "functionVaHex": "0x0041d61b",
                "reason": "menu-internal dispatcher after menu object exists.",
            },
            {
                "functionVaHex": "0x00431fe8/0x00432323/0x00432541",
                "reason": "generic descriptor stack helper layer; not the producer that chooses which root to attach.",
            },
        ],
        "nextFrontier": [
            "Trace callers above the generic object/display VM handlers that choose descriptor roots before add/rebuild helpers run.",
            "Search for live writers of 0x004576da/0x004576db/0x0059de30 outside save/load restore and generic selector opcodes.",
            "For encounter, require one function/root to bridge field step/tick state with formation/resource setup, not merely shared RNG.",
            "For map animation, require a live animated-map object/root that reaches tile-write handlers before redraw.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("cross-domain funcs", summary["crossDomainFunctionCount"]),
        ("review required", summary["reviewRequiredCount"]),
        ("promoted producers", summary["promotedProducerCount"]),
        ("save/load bridge", summary["saveLoadRestoreBridgeFound"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    rows = "".join(
        "<tr>"
        f"<td><code>{h(row['functionVaHex'])}</code><br>{h(row['classification'])}</td>"
        f"<td>{h(' + '.join(row['categories']))}</td>"
        f"<td>{h(row['promotion'])}<br><span class='note'>{h(row['reason'])}</span></td>"
        f"<td>{h(json.dumps(row['refs'], ensure_ascii=False))}</td>"
        f"<td>{h(json.dumps(row['calls'], ensure_ascii=False))}</td>"
        "</tr>"
        for row in report["crossDomainRows"]
    )
    criteria = "".join(f"<li>{h(item)}</li>" for item in report["strictPromotionCriteria"])
    negatives = "".join(
        f"<li><code>{h(item['functionVaHex'])}</code> {h(item['reason'])}</li>"
        for item in report["negativePromotions"]
    )
    frontier = "".join(f"<li>{h(item)}</li>" for item in report["nextFrontier"])
    payload = json.dumps(report, ensure_ascii=False)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Global Mode/State Frontier Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1320px; margin:0 auto; padding:24px; }}
    a {{ color:#8ecbff; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }}
    .chip {{ border:1px solid #334155; border-radius:999px; padding:6px 10px; text-decoration:none; background:#161b22; }}
    .summary {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; margin:16px 0; }}
    .card {{ border:1px solid #2b3544; border-radius:8px; padding:12px; background:#161b22; }}
    .card b {{ display:block; color:#9fb1c9; font-size:12px; text-transform:uppercase; }}
    .card span {{ display:block; margin-top:8px; font-size:18px; overflow-wrap:anywhere; }}
    section {{ border:1px solid #273244; border-radius:10px; padding:16px; margin:16px 0; background:#141922; overflow:auto; }}
    table {{ border-collapse:collapse; width:100%; min-width:1180px; font-size:13px; }}
    th,td {{ border-bottom:1px solid #283342; padding:8px; text-align:left; vertical-align:top; }}
    th {{ color:#b9c7dc; background:#111722; }}
    code {{ color:#dbeafe; overflow-wrap:anywhere; }}
    .note {{ display:block; margin-top:6px; color:#9fb1c9; line-height:1.35; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:420px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="static_analysis_remaining_work.html">remaining work</a>
    <a class="chip" href="hud_menu_mode_state_dispatcher_review.html">HUD mode/state</a>
    <a class="chip" href="../out/scene_event_vm_route_boundary_review.json">scene route JSON</a>
    <a class="chip" href="field_encounter_producer_boundary_review.html">encounter producer</a>
    <a class="chip" href="../out/map_animation_tick_route_review.json">map animation tick JSON</a>
    <a class="chip" href="../out/global_mode_state_frontier_review.json">JSON</a>
  </div>
  <h1>Global Mode/State Frontier Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Strict Promotion Criteria</h2>
    <ul>{criteria}</ul>
  </section>
  <section>
    <h2>Cross-Domain Functions</h2>
    <table>
      <thead><tr><th>function</th><th>categories</th><th>promotion</th><th>refs</th><th>calls</th></tr></thead>
      <tbody>{rows or "<tr><td colspan='5'>No cross-domain rows.</td></tr>"}</tbody>
    </table>
  </section>
  <section>
    <h2>Important Negative Promotions</h2>
    <ul>{negatives}</ul>
  </section>
  <section>
    <h2>Next Frontier</h2>
    <ul>{frontier}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_GLOBAL_MODE_STATE_FRONTIER_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_GLOBAL_MODE_STATE_FRONTIER_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "global_mode_state_frontier_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (WEB / "global_mode_state_frontier_review.html").write_text(render_html(report), encoding="utf-8")
    print("global_mode_state_frontier_review ok")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
