#!/usr/bin/env python3
"""Trace static evidence between selector-root resource refs and file loaders.

This pass deliberately separates two things that are easy to over-promote:

* selector-root payloads contain many `.cns` resource string pointers,
* the executable has file/archive loader functions that call CreateFileA,
  ReadFile, SetFilePointer, etc.

The currently available static evidence does not show individual `.cns`
strings being referenced directly by `.text` code.  Therefore this report
records a grounded resource package surface and grounded loader candidates,
while keeping the route/resource-loader binding blocked until a data-flow or
runtime stack proof exists.
"""
from __future__ import annotations

import argparse
import bisect
import html
import json
import re
import struct
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

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

from probe_exe_scene_tables import (  # noqa: E402
    IMAGE_BASE,
    classify_cns,
    find_cns_strings,
    offset_to_va,
    read_sections,
    va_to_offset,
)


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
WEB = ROOT / "web"
PROMOTION_STATUS = "resource-loader-consumer-static-boundary-route-blocked"

FILE_API_NAMES = {
    "CreateFileA",
    "ReadFile",
    "SetFilePointer",
    "GetFileSize",
    "CloseHandle",
    "FindFirstFileA",
    "FindNextFileA",
    "FindClose",
}
STRING_API_NAMES = {"lstrcmpA", "lstrcpyA", "lstrcatA"}
MEMORY_API_NAMES = {"HeapAlloc", "HeapFree"}
INTERESTING_IMPORTS = FILE_API_NAMES | STRING_API_NAMES | MEMORY_API_NAMES


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


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


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


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


def parse_imports(data: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    pe_off = struct.unpack_from("<I", data, 0x3C)[0]
    optional_off = pe_off + 24
    import_rva, _import_size = struct.unpack_from("<II", data, optional_off + 96 + 8)
    import_off = va_to_offset(sections, IMAGE_BASE + import_rva)
    if import_off is None:
        return []

    rows: list[dict[str, Any]] = []
    desc_off = import_off
    while desc_off + 20 <= len(data):
        original_first_thunk, _time, _forwarder, name_rva, first_thunk = struct.unpack_from(
            "<IIIII", data, desc_off
        )
        if not any([original_first_thunk, name_rva, first_thunk]):
            break
        dll_off = va_to_offset(sections, IMAGE_BASE + name_rva)
        dll = ""
        if dll_off is not None:
            dll = data[dll_off : data.find(b"\0", dll_off)].decode("ascii", "replace")
        thunk_rva = original_first_thunk or first_thunk
        thunk_off = va_to_offset(sections, IMAGE_BASE + thunk_rva)
        if thunk_off is None:
            desc_off += 20
            continue
        index = 0
        while thunk_off + index * 4 + 4 <= len(data):
            thunk = struct.unpack_from("<I", data, thunk_off + index * 4)[0]
            if thunk == 0:
                break
            iat_va = IMAGE_BASE + first_thunk + index * 4
            if thunk & 0x80000000:
                name = f"ordinal_{thunk & 0xffff}"
            else:
                import_name_off = va_to_offset(sections, IMAGE_BASE + thunk)
                if import_name_off is None:
                    name = ""
                else:
                    name = data[
                        import_name_off + 2 : data.find(b"\0", import_name_off + 2)
                    ].decode("ascii", "replace")
            rows.append({"dll": dll, "name": name, "iatVa": iat_va, "iatVaHex": hx(iat_va)})
            index += 1
        desc_off += 20
    return rows


def text_bytes(data: bytes, sections: list[dict[str, Any]]) -> tuple[dict[str, Any], bytes]:
    text = next(section for section in sections if section["name"] == ".text")
    return text, data[text["raw"] : text["raw"] + text["raw_size"]]


def function_starts(text: dict[str, Any], blob: bytes) -> list[int]:
    starts = [text["va"] + match.start() for match in re.finditer(rb"\x55\x8b\xec", blob)]
    if text["va"] not in starts:
        starts.insert(0, text["va"])
    return sorted(set(starts))


def function_range_for(starts: list[int], text: dict[str, Any], va: int) -> tuple[int, int]:
    index = bisect.bisect_right(starts, va) - 1
    start = starts[index] if index >= 0 else text["va"]
    end = starts[index + 1] if index + 1 < len(starts) else text["va"] + text["raw_size"]
    return start, end


def relative_call_refs(
    data: bytes,
    sections: list[dict[str, Any]],
    target_va: int,
) -> list[dict[str, Any]]:
    text, blob = text_bytes(data, sections)
    starts = function_starts(text, blob)
    rows: list[dict[str, Any]] = []
    offset = 0
    while offset + 5 <= len(blob):
        if blob[offset] == 0xE8:
            source_va = text["va"] + offset
            rel = struct.unpack_from("<i", blob, offset + 1)[0]
            destination = source_va + 5 + rel
            if destination == target_va:
                function_start, function_end = function_range_for(starts, text, source_va)
                rows.append(
                    {
                        "callVa": source_va,
                        "callVaHex": hx(source_va),
                        "callerFunctionStartVa": function_start,
                        "callerFunctionStartVaHex": hx(function_start),
                        "callerFunctionEndVa": function_end,
                        "callerFunctionEndVaHex": hx(function_end),
                    }
                )
        offset += 1
    return rows


def dword_text_ref_count(data: bytes, sections: list[dict[str, Any]], value: int) -> int:
    text, blob = text_bytes(data, sections)
    pattern = struct.pack("<I", value)
    count = 0
    offset = 0
    while True:
        hit = blob.find(pattern, offset)
        if hit < 0:
            break
        count += 1
        offset = hit + 1
    return count


def text_bridge_counts(data: bytes, sections: list[dict[str, Any]], selector_review: dict[str, Any]) -> dict[str, Any]:
    roots = selector_root_ranges(selector_review)
    root_text_ref_count = 0
    range_end_text_ref_count = 0
    row_pointer_text_ref_count = 0
    map1_root_text_ref_count = 0
    map1_row_pointer_text_ref_count = 0
    for root in roots:
        root_va = root.get("rootVa")
        range_end = root.get("rangeEndVa")
        if isinstance(root_va, int):
            count = dword_text_ref_count(data, sections, root_va)
            root_text_ref_count += count
            if root.get("rootVaHex") == "0x00501808":
                map1_root_text_ref_count += count
        if isinstance(range_end, int):
            range_end_text_ref_count += dword_text_ref_count(data, sections, range_end)
        for row_hex in root.get("rowPointerHexes") or []:
            row_va = int(row_hex, 16)
            count = dword_text_ref_count(data, sections, row_va)
            row_pointer_text_ref_count += count
            if root.get("rootVaHex") == "0x00501808":
                map1_row_pointer_text_ref_count += count
    return {
        "selectorRootStartTextRefCount": root_text_ref_count,
        "selectorRootRangeEndTextRefCount": range_end_text_ref_count,
        "selectorRootRowPointerTextRefCount": row_pointer_text_ref_count,
        "map1RootTextRefCount": map1_root_text_ref_count,
        "map1RowPointerTextRefCount": map1_row_pointer_text_ref_count,
    }


def find_import_call_sites(
    data: bytes,
    sections: list[dict[str, Any]],
    imports: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    text, blob = text_bytes(data, sections)
    starts = function_starts(text, blob)
    rows: list[dict[str, Any]] = []
    functions: dict[int, dict[str, Any]] = {}
    for row in imports:
        name = row["name"]
        if name not in INTERESTING_IMPORTS:
            continue
        pattern = b"\xff\x15" + struct.pack("<I", row["iatVa"])
        search = 0
        while True:
            hit = blob.find(pattern, search)
            if hit < 0:
                break
            va = text["va"] + hit
            start, end = function_range_for(starts, text, va)
            site = {
                "callVa": va,
                "callVaHex": hx(va),
                "functionStartVa": start,
                "functionStartVaHex": hx(start),
                "functionEndVa": end,
                "functionEndVaHex": hx(end),
                "dll": row["dll"],
                "api": name,
                "iatVaHex": row["iatVaHex"],
            }
            rows.append(site)
            func = functions.setdefault(
                start,
                {
                    "functionStartVa": start,
                    "functionStartVaHex": hx(start),
                    "functionEndVa": end,
                    "functionEndVaHex": hx(end),
                    "apiCounts": Counter(),
                    "callSites": [],
                    "inlineMagic": [],
                },
            )
            func["apiCounts"][name] += 1
            func["callSites"].append(site)
            search = hit + 1

    for magic in (b"FLDF",):
        search = 0
        while True:
            hit = data.find(magic, search)
            if hit < 0:
                break
            va = offset_to_va(sections, hit)
            section = section_for_va(sections, va)
            if section and section["name"] == ".text" and va is not None:
                start, end = function_range_for(starts, text, va)
                func = functions.setdefault(
                    start,
                    {
                        "functionStartVa": start,
                        "functionStartVaHex": hx(start),
                        "functionEndVa": end,
                        "functionEndVaHex": hx(end),
                        "apiCounts": Counter(),
                        "callSites": [],
                        "inlineMagic": [],
                    },
                )
                func["inlineMagic"].append({"magic": magic.decode("ascii"), "va": va, "vaHex": hx(va)})
            search = hit + 1

    function_rows = []
    for func in functions.values():
        api_counts = dict(sorted(func["apiCounts"].items()))
        file_score = sum(1 for api in FILE_API_NAMES if api in api_counts)
        string_score = sum(1 for api in STRING_API_NAMES if api in api_counts)
        memory_score = sum(1 for api in MEMORY_API_NAMES if api in api_counts)
        class_name = "support-call-cluster"
        if func["inlineMagic"]:
            class_name = "fld-archive-header-consumer-candidate"
        elif {"CreateFileA", "ReadFile", "CloseHandle"} <= set(api_counts):
            class_name = "file-open-read-loader-candidate"
        elif {"SetFilePointer", "ReadFile"} <= set(api_counts):
            class_name = "seek-read-subresource-loader-candidate"
        elif "FindFirstFileA" in api_counts:
            class_name = "asset-path-discovery-candidate"
        elif string_score and not file_score:
            class_name = "path-string-builder-candidate"
        score = file_score * 10 + string_score * 3 + memory_score * 2 + len(func["inlineMagic"]) * 15
        function_rows.append(
            {
                **{k: v for k, v in func.items() if k not in {"apiCounts"}},
                "apiCounts": api_counts,
                "apiUniqueCount": len(api_counts),
                "callCount": sum(api_counts.values()),
                "fileApiUniqueCount": file_score,
                "loaderClass": class_name,
                "loaderScore": score,
            }
        )
    function_rows.sort(
        key=lambda row: (
            row["loaderClass"] == "support-call-cluster",
            -row["loaderScore"],
            row["functionStartVa"],
        )
    )
    return rows, function_rows


def selector_root_ranges(selector_review: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for root in selector_review.get("roots") or []:
        start = root.get("rootVa")
        end = root.get("rangeEndVa")
        if isinstance(start, int) and isinstance(end, int) and start < end:
            rows.append(root)
    return sorted(rows, key=lambda row: row["rootVa"])


def root_for_va(roots: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    # Roots are few; linear scan keeps the code obvious.
    for root in roots:
        if root["rootVa"] <= va < root["rangeEndVa"]:
            return root
    return None


def find_cns_pointer_refs(
    data: bytes,
    sections: list[dict[str, Any]],
    selector_review: dict[str, Any],
) -> dict[str, Any]:
    cns_strings = find_cns_strings(data, sections)
    roots = selector_root_ranges(selector_review)
    by_section: Counter[str] = Counter()
    by_cns_kind: Counter[str] = Counter()
    by_root: Counter[str] = Counter()
    direct_code_refs: list[dict[str, Any]] = []
    selector_root_ref_count = 0
    non_selector_data_refs: list[dict[str, Any]] = []
    cns_samples: list[dict[str, Any]] = []
    unique_selector_cns: set[str] = set()

    for cns_va, name in sorted(cns_strings.items()):
        pattern = struct.pack("<I", cns_va)
        search = 0
        ref_count_for_name = 0
        while True:
            hit = data.find(pattern, search)
            if hit < 0:
                break
            ref_va = offset_to_va(sections, hit)
            section = section_for_va(sections, ref_va)
            section_name = section["name"] if section else "unknown"
            by_section[section_name] += 1
            by_cns_kind[classify_cns(name)] += 1
            root = root_for_va(roots, ref_va) if ref_va is not None else None
            if root:
                selector_root_ref_count += 1
                unique_selector_cns.add(name)
                by_root[root["rootVaHex"]] += 1
            elif section_name == ".data" and len(non_selector_data_refs) < 20:
                non_selector_data_refs.append(
                    {
                        "refVaHex": hx(ref_va),
                        "cnsVaHex": hx(cns_va),
                        "name": name,
                        "section": section_name,
                    }
                )
            if section_name == ".text" and ref_va is not None:
                direct_code_refs.append(
                    {"refVaHex": hx(ref_va), "cnsVaHex": hx(cns_va), "name": name}
                )
            ref_count_for_name += 1
            search = hit + 1
        if len(cns_samples) < 24 and ref_count_for_name:
            cns_samples.append(
                {
                    "name": name,
                    "kind": classify_cns(name),
                    "cnsVaHex": hx(cns_va),
                    "pointerRefCount": ref_count_for_name,
                }
            )

    top_roots = [
        {
            "rootVaHex": root_hex,
            "resourcePointerRefCount": count,
            "rootClass": next((r.get("rootClass", "") for r in roots if r["rootVaHex"] == root_hex), ""),
            "sequenceGroupIds": next((r.get("sequenceGroupIds", []) for r in roots if r["rootVaHex"] == root_hex), []),
            "linkedCns": next((r.get("linkedCns", [])[:8] for r in roots if r["rootVaHex"] == root_hex), []),
        }
        for root_hex, count in by_root.most_common(16)
    ]

    return {
        "cnsStringCount": len(cns_strings),
        "cnsPointerRefCount": sum(by_section.values()),
        "cnsPointerRefsBySection": dict(sorted(by_section.items())),
        "cnsPointerRefsByKind": dict(sorted(by_cns_kind.items())),
        "selectorRootCnsPointerRefCount": selector_root_ref_count,
        "selectorRootUniqueCnsNameCount": len(unique_selector_cns),
        "directCnsStringCodeRefCount": len(direct_code_refs),
        "directCnsStringCodeRefs": direct_code_refs[:20],
        "nonSelectorDataRefsSample": non_selector_data_refs,
        "topSelectorRootsByResourceRefs": top_roots,
        "cnsSamples": cns_samples,
    }


def build_payload(data: bytes, sections: list[dict[str, Any]], selector_review: dict[str, Any]) -> dict[str, Any]:
    imports = parse_imports(data, sections)
    import_call_sites, loader_functions = find_import_call_sites(data, sections, imports)
    cns_refs = find_cns_pointer_refs(data, sections, selector_review)
    bridge_counts = text_bridge_counts(data, sections, selector_review)
    loader_candidate_rows = [
        row
        for row in loader_functions
        if row["loaderClass"] != "support-call-cluster"
    ][:24]
    loader_call_graph_rows = []
    total_loader_direct_call_refs = 0
    loader_candidates_with_callers = 0
    for row in loader_candidate_rows:
        call_refs = relative_call_refs(data, sections, row["functionStartVa"])
        total_loader_direct_call_refs += len(call_refs)
        if call_refs:
            loader_candidates_with_callers += 1
        loader_call_graph_rows.append(
            {
                "functionStartVaHex": row["functionStartVaHex"],
                "functionEndVaHex": row["functionEndVaHex"],
                "loaderClass": row["loaderClass"],
                "directCallRefCount": len(call_refs),
                "callRefs": call_refs[:16],
                "selectorRootBridgeTextRefCountInKnownDirectPath": 0,
                "cnsStringBridgeTextRefCountInKnownDirectPath": 0,
                "bridgeProofFound": False,
            }
        )
    file_api_call_count = sum(1 for row in import_call_sites if row["api"] in FILE_API_NAMES)
    summary = {
        "importCount": len(imports),
        "interestingImportCallSiteCount": len(import_call_sites),
        "fileApiCallSiteCount": file_api_call_count,
        "loaderFunctionCandidateCount": len(loader_candidate_rows),
        "cnsStringCount": cns_refs["cnsStringCount"],
        "cnsPointerRefCount": cns_refs["cnsPointerRefCount"],
        "selectorRootCnsPointerRefCount": cns_refs["selectorRootCnsPointerRefCount"],
        "selectorRootUniqueCnsNameCount": cns_refs["selectorRootUniqueCnsNameCount"],
        "directCnsStringCodeRefCount": cns_refs["directCnsStringCodeRefCount"],
        "selectorRootStartTextRefCount": bridge_counts["selectorRootStartTextRefCount"],
        "selectorRootRangeEndTextRefCount": bridge_counts["selectorRootRangeEndTextRefCount"],
        "selectorRootRowPointerTextRefCount": bridge_counts["selectorRootRowPointerTextRefCount"],
        "map1RootTextRefCount": bridge_counts["map1RootTextRefCount"],
        "map1RowPointerTextRefCount": bridge_counts["map1RowPointerTextRefCount"],
        "loaderCandidateDirectCallRefCount": total_loader_direct_call_refs,
        "loaderCandidateWithCallerCount": loader_candidates_with_callers,
        "resourcePackageToLoaderBridgeProofFound": False,
        "routeResourceLoaderBindingProofFound": False,
        "selectorRootResourcePackageProofFound": cns_refs["selectorRootCnsPointerRefCount"] > 0,
        "fileLoaderBoundaryProofFound": bool(loader_candidate_rows),
        "promotionStatus": PROMOTION_STATUS,
    }
    return {
        "kind": "hwanse-resource-loader-consumer-trace-review",
        "promotionStatus": PROMOTION_STATUS,
        "summary": summary,
        "cnsRefs": cns_refs,
        "loaderCandidates": loader_candidate_rows,
        "loaderCallGraph": loader_call_graph_rows,
        "textBridgeCounts": bridge_counts,
        "importCallSitesSample": import_call_sites[:120],
        "decisions": [
            {
                "id": "selector-root-resource-package",
                "status": "grounded",
                "decision": "selector-root ranges contain `.cns` resource pointer refs.",
                "evidence": f"{cns_refs['selectorRootCnsPointerRefCount']} refs under selector-root ranges",
            },
            {
                "id": "file-loader-boundary",
                "status": "grounded",
                "decision": "file/archive loader function candidates exist in `.text`.",
                "evidence": "CreateFileA/ReadFile/SetFilePointer/GetFileSize/CloseHandle call clusters plus inline FLDF magic",
            },
            {
                "id": "resource-loader-consumer-binding",
                "status": "blocked",
                "decision": "do not claim selector-root resource refs are consumed by the loader yet.",
                "evidence": "direct `.text` refs to individual `.cns` string pointers are 0; data-flow path remains unproven",
            },
            {
                "id": "package-to-loader-call-bridge",
                "status": "blocked",
                "decision": "loader caller graph exists, but no caller path carries selector-root or CNS pointer refs in static text.",
                "evidence": f"{total_loader_direct_call_refs} direct loader call refs; selector root start/range/row pointer text refs are {bridge_counts['selectorRootStartTextRefCount']}/{bridge_counts['selectorRootRangeEndTextRefCount']}/{bridge_counts['selectorRootRowPointerTextRefCount']}",
            },
        ],
    }


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    loader_rows = []
    for row in payload["loaderCandidates"]:
        apis = " ".join(
            f"<span class=\"tag\">{esc(name)} x{count}</span>"
            for name, count in row["apiCounts"].items()
        )
        sites = "<br>".join(
            f"<code>{esc(site['callVaHex'])}</code> {esc(site['api'])}"
            for site in row["callSites"][:12]
        )
        magic = " ".join(
            f"<span class=\"tag good\">{esc(item['magic'])}@{esc(item['vaHex'])}</span>"
            for item in row.get("inlineMagic", [])
        )
        tag_class = "good" if row["loaderClass"] != "support-call-cluster" else "muted"
        loader_rows.append(
            "<tr>"
            f"<td><code>{esc(row['functionStartVaHex'])}</code><br><span class=\"muted\">..{esc(row['functionEndVaHex'])}</span></td>"
            f"<td><span class=\"tag {tag_class}\">{esc(row['loaderClass'])}</span></td>"
            f"<td>{row['callCount']}</td>"
            f"<td>{apis}</td>"
            f"<td>{magic or '<span class=\"muted\">-</span>'}</td>"
            f"<td>{sites}</td>"
            "</tr>"
        )

    root_rows = []
    for row in payload["cnsRefs"]["topSelectorRootsByResourceRefs"]:
        resources = " ".join(f"<span class=\"tag\">{esc(name)}</span>" for name in row["linkedCns"])
        groups = ", ".join(map(str, row["sequenceGroupIds"])) or "-"
        root_rows.append(
            "<tr>"
            f"<td><code>{esc(row['rootVaHex'])}</code></td>"
            f"<td>{row['resourcePointerRefCount']}</td>"
            f"<td>{esc(row['rootClass'])}</td>"
            f"<td>{esc(groups)}</td>"
            f"<td>{resources}</td>"
            "</tr>"
        )

    sample_rows = []
    for row in payload["cnsRefs"]["cnsSamples"]:
        sample_rows.append(
            "<tr>"
            f"<td>{esc(row['name'])}</td>"
            f"<td>{esc(row['kind'])}</td>"
            f"<td><code>{esc(row['cnsVaHex'])}</code></td>"
            f"<td>{row['pointerRefCount']}</td>"
            "</tr>"
        )

    call_graph_rows = []
    for row in payload["loaderCallGraph"]:
        callers = "<br>".join(
            f"<code>{esc(call['callVaHex'])}</code> in <code>{esc(call['callerFunctionStartVaHex'])}</code>"
            for call in row["callRefs"][:12]
        ) or '<span class="muted">-</span>'
        call_graph_rows.append(
            "<tr>"
            f"<td><code>{esc(row['functionStartVaHex'])}</code></td>"
            f"<td><span class=\"tag\">{esc(row['loaderClass'])}</span></td>"
            f"<td>{row['directCallRefCount']}</td>"
            f"<td>{callers}</td>"
            f"<td><span class=\"tag warn\">{esc(row['bridgeProofFound'])}</span></td>"
            "</tr>"
        )

    decisions = []
    for row in payload["decisions"]:
        cls = "good" if row["status"] == "grounded" else "warn"
        decisions.append(
            "<tr>"
            f"<td>{esc(row['id'])}</td>"
            f"<td><span class=\"tag {cls}\">{esc(row['status'])}</span></td>"
            f"<td>{esc(row['decision'])}</td>"
            f"<td>{esc(row['evidence'])}</td>"
            "</tr>"
        )

    summary_json = json.dumps(summary, ensure_ascii=False, sort_keys=True)
    refs_by_section = esc(payload["cnsRefs"]["cnsPointerRefsBySection"])
    refs_by_kind = esc(payload["cnsRefs"]["cnsPointerRefsByKind"])
    return 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>resource loader consumer trace</title>
  <style>
    :root {{ --bg:#f6f7f9; --panel:#fff; --head:#eef2f6; --border:#d8dee6; --ink:#17202a; --muted:#647384; }}
    * {{ 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:1600px; 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; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    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-wrap {{ overflow:auto; }}
    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; position:sticky; top:0; z-index:1; }}
    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; margin:1px; }}
    .tag.good {{ color:#0f6a38; background:#e7f6ec; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .tag.muted {{ color:#607080; background:#edf2f7; }}
    .muted {{ color:var(--muted); }}
    @media (max-width:760px) {{ header {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main data-page="resource-loader-consumer-trace-review">
  <header>
    <div>
      <h1>resource loader consumer trace</h1>
      <p class="muted">selector-root의 `.cns` resource refs와 EXE file/archive loader 후보를 분리한다. 직접 route binding은 아직 승격하지 않는다.</p>
    </div>
    <nav aria-label="review links">
      <a href="index.html">관리 홈</a>
      <a href="selector_root_structure_review.html">selector-root 구조</a>
      <a href="scene_seq_resource_record_link_review.html">seq/resource 연결</a>
      <a href="scene_event_vm_review.html">Scene/Event VM</a>
      <a href="scene_event_text_consumer_trace_review.html">text consumer</a>
      <a href="../out/resource_loader_consumer_trace_review.json">JSON</a>
    </nav>
  </header>

  <section>
    <div class="head"><h2>요약</h2><span class="muted">{esc(payload['promotionStatus'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{summary['cnsStringCount']}</strong><span>CNS strings</span></div>
      <div class="metric"><strong>{summary['cnsPointerRefCount']}</strong><span>CNS pointer refs</span></div>
      <div class="metric"><strong>{summary['selectorRootCnsPointerRefCount']}</strong><span>selector-root refs</span></div>
      <div class="metric"><strong>{summary['directCnsStringCodeRefCount']}</strong><span>direct code refs</span></div>
      <div class="metric"><strong>{summary['selectorRootStartTextRefCount']}</strong><span>selector root text refs</span></div>
      <div class="metric"><strong>{summary['selectorRootRowPointerTextRefCount']}</strong><span>root row ptr text refs</span></div>
      <div class="metric"><strong>{summary['fileApiCallSiteCount']}</strong><span>file API call sites</span></div>
      <div class="metric"><strong>{summary['loaderFunctionCandidateCount']}</strong><span>loader candidates</span></div>
      <div class="metric"><strong>{summary['loaderCandidateDirectCallRefCount']}</strong><span>loader direct calls</span></div>
    </div>
    <div class="body">
      <p><strong>section refs:</strong> <code>{refs_by_section}</code></p>
      <p><strong>kind refs:</strong> <code>{refs_by_kind}</code></p>
    </div>
  </section>

  <section>
    <div class="head"><h2>판정</h2><span>resource package와 loader boundary는 분리</span></div>
    <div class="table-wrap">
      <table>
        <thead><tr><th>id</th><th>status</th><th>decision</th><th>evidence</th></tr></thead>
        <tbody>{''.join(decisions)}</tbody>
      </table>
    </div>
  </section>

  <section>
    <div class="head"><h2>File/Archive Loader Candidates</h2><span>CreateFile/ReadFile/SetFilePointer clusters</span></div>
    <div class="table-wrap">
      <table>
        <thead><tr><th>function</th><th>class</th><th>calls</th><th>APIs</th><th>inline magic</th><th>sample call sites</th></tr></thead>
        <tbody>{''.join(loader_rows)}</tbody>
      </table>
    </div>
  </section>

  <section>
    <div class="head"><h2>Loader Caller Graph / Bridge Check</h2><span>package pointer -> loader 경로 미승격 이유</span></div>
    <div class="body">
      <p>loader 후보 함수로 들어오는 direct call은 보이지만, 그 caller path 안에서 selector-root start/range/row pointer 또는 개별 CNS string pointer를 같이 잡지는 못했다. 따라서 resource package consumption은 아직 route-bound proof가 아니다.</p>
    </div>
    <div class="table-wrap">
      <table>
        <thead><tr><th>loader</th><th>class</th><th>direct calls</th><th>sample callers</th><th>bridge proof</th></tr></thead>
        <tbody>{''.join(call_graph_rows)}</tbody>
      </table>
    </div>
  </section>

  <section>
    <div class="head"><h2>Top Selector Roots By Resource Refs</h2><span>resource payload surface</span></div>
    <div class="table-wrap">
      <table>
        <thead><tr><th>root</th><th>refs</th><th>class</th><th>scene groups</th><th>linked CNS sample</th></tr></thead>
        <tbody>{''.join(root_rows)}</tbody>
      </table>
    </div>
  </section>

  <section>
    <div class="head"><h2>CNS String Samples</h2><span>all code refs are currently 0</span></div>
    <div class="table-wrap">
      <table>
        <thead><tr><th>name</th><th>kind</th><th>string VA</th><th>pointer refs</th></tr></thead>
        <tbody>{''.join(sample_rows)}</tbody>
      </table>
    </div>
  </section>
</main>
<script>
window.HWANSE_RESOURCE_LOADER_CONSUMER_TRACE_REVIEW_READY = {{
  resourceLoaderConsumerTraceImplemented: true,
  promotionStatus: "{PROMOTION_STATUS}",
  cnsStringCount: {summary['cnsStringCount']},
  cnsPointerRefCount: {summary['cnsPointerRefCount']},
  selectorRootCnsPointerRefCount: {summary['selectorRootCnsPointerRefCount']},
  directCnsStringCodeRefCount: {summary['directCnsStringCodeRefCount']},
  selectorRootStartTextRefCount: {summary['selectorRootStartTextRefCount']},
  selectorRootRangeEndTextRefCount: {summary['selectorRootRangeEndTextRefCount']},
  selectorRootRowPointerTextRefCount: {summary['selectorRootRowPointerTextRefCount']},
  map1RootTextRefCount: {summary['map1RootTextRefCount']},
  map1RowPointerTextRefCount: {summary['map1RowPointerTextRefCount']},
  fileApiCallSiteCount: {summary['fileApiCallSiteCount']},
  loaderFunctionCandidateCount: {summary['loaderFunctionCandidateCount']},
  loaderCandidateDirectCallRefCount: {summary['loaderCandidateDirectCallRefCount']},
  loaderCandidateWithCallerCount: {summary['loaderCandidateWithCallerCount']},
  selectorRootResourcePackageProofFound: true,
  fileLoaderBoundaryProofFound: true,
  resourcePackageToLoaderBridgeProofFound: false,
  routeResourceLoaderBindingProofFound: false,
  summary: {summary_json}
}};
</script>
</body>
</html>
"""


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--selector-root-review", type=Path, default=OUT / "selector_root_structure_review.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--web-dir", type=Path, default=WEB)
    args = parser.parse_args()

    data = args.exe.read_bytes()
    sections = read_sections(data)
    selector_review = load_json(args.selector_root_review, {})
    payload = build_payload(data, sections, selector_review)

    args.out_dir.mkdir(parents=True, exist_ok=True)
    args.web_dir.mkdir(parents=True, exist_ok=True)
    json_path = args.out_dir / "resource_loader_consumer_trace_review.json"
    html_path = args.out_dir / "resource_loader_consumer_trace_review.html"
    web_path = args.web_dir / "resource_loader_consumer_trace_review.html"
    html_text = render_html(payload)
    json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_path.write_text(html_text, encoding="utf-8")
    web_path.write_text(html_text, encoding="utf-8")
    print(f"wrote {json_path}")
    return 0


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