#!/usr/bin/env python3
"""Build a focused review for Event VM skill/equipment gates.

The important distinction is that equipment and skill completion are not
ordinary 0x31/0x32 scenario bits.  The ending and perfect-clear scripts use two
generic Event VM opcodes:

* e2: check whether a character equipment range is owned.
* e3: check whether a character skill matrix satisfies a learned/tier gate.
"""
from __future__ import annotations

import argparse
import bisect
import html
import json
import struct
from collections import defaultdict
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


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

GENERIC_DISPATCH_TABLE_VA = 0x00440538
SAVE_SELECTOR_DISPATCH_TABLE_VA = 0x00440720
E2_HANDLER_VA = 0x004101EC
E3_HANDLER_VA = 0x00410302
E3_HANDLER_END_VA = 0x00410438
SKILL_MATRIX_VA = 0x004404F0
SKILL_MATRIX_SIZE = 72

ACTORS = {
    0: "아타호",
    1: "린샹",
    2: "스마슈",
}

SKILL_CATEGORIES = {
    0: "기본기/unused-row",
    1: "개인공격기",
    2: "전체공격기",
    3: "특수기",
}

SKILL_TIER_LABELS = {
    0: "습득",
    1: "장기 이상",
    2: "달인기 이상",
    3: "신기",
}


def load_json(path: Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return default


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def write_text(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


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


def short(value: Any, limit: int = 180) -> str:
    text = " ".join((str(value) if value is not None else "").split())
    return text if len(text) <= limit else text[: limit - 1] + "..."


def hex_bytes(data: bytes, limit: int = 32) -> str:
    text = data[:limit].hex(" ")
    return text if len(data) <= limit else text + " ..."


def build_scene_entry_index(scene_text: dict[str, Any]) -> tuple[list[int], list[dict[str, Any]]]:
    entries: list[dict[str, Any]] = []
    for group in scene_text.get("groups", []):
        for sequence in group.get("sequences", []):
            for entry in sequence.get("entries", []):
                entry_va = entry.get("entryVa")
                if entry_va is None:
                    continue
                entries.append(
                    {
                        "entryVa": entry_va,
                        "entryVaHex": entry.get("entryVaHex"),
                        "textVaHex": entry.get("textVaHex"),
                        "groupId": group.get("id"),
                        "sequenceId": sequence.get("id"),
                        "contextLabel": group.get("contextLabel"),
                        "evidenceStatus": group.get("evidenceStatus"),
                        "sample": short(entry.get("sample") or entry.get("displayText") or entry.get("text")),
                    }
                )
    entries.sort(key=lambda row: row["entryVa"])
    return [row["entryVa"] for row in entries], entries


def nearby_entries(entry_vas: list[int], entries: list[dict[str, Any]], va: int, radius: int = 2) -> list[dict[str, Any]]:
    index = bisect.bisect_right(entry_vas, va)
    rows: list[dict[str, Any]] = []
    for cursor in range(max(0, index - radius), min(len(entries), index + radius + 1)):
        row = dict(entries[cursor])
        row["deltaFromRef"] = row["entryVa"] - va
        row["deltaFromRefHex"] = f"{row['deltaFromRef']:+#x}"
        rows.append(row)
    return rows


def ui_equipment_records(ui: dict[str, Any]) -> list[dict[str, Any]]:
    for table in ui.get("tables") or []:
        if table.get("key") == "equipment":
            return list(table.get("records") or [])
    return []


def equipment_ranges(equipment: list[dict[str, Any]]) -> list[dict[str, Any]]:
    by_id = {row.get("index1Based"): row for row in equipment}
    rows: list[dict[str, Any]] = []
    for actor_index, actor_name in ACTORS.items():
        start = 1 + actor_index * 12
        weapon_end = start + 5
        all_end = start + 11
        rows.append(
            {
                "actorIndex": actor_index,
                "actor": actor_name,
                "selector": 1,
                "selectorMeaning": "무기/술류만",
                "idStart": start,
                "idEnd": weapon_end,
                "ids": list(range(start, weapon_end + 1)),
                "names": [by_id.get(item_id, {}).get("name", f"#{item_id}") for item_id in range(start, weapon_end + 1)],
            }
        )
        rows.append(
            {
                "actorIndex": actor_index,
                "actor": actor_name,
                "selector": 0,
                "selectorMeaning": "장비 전체",
                "idStart": start,
                "idEnd": all_end,
                "ids": list(range(start, all_end + 1)),
                "names": [by_id.get(item_id, {}).get("name", f"#{item_id}") for item_id in range(start, all_end + 1)],
            }
        )
    return rows


def e2_checked_range(actor_index: int, selector: int, equipment: list[dict[str, Any]]) -> dict[str, Any]:
    start = 1 + actor_index * 12
    end = start + 5 if selector != 0 else start + 11
    by_id = {row.get("index1Based"): row for row in equipment}
    rows = [by_id.get(item_id, {"index1Based": item_id, "name": f"#{item_id}"}) for item_id in range(start, end + 1)]
    return {
        "actorIndex": actor_index,
        "actor": ACTORS.get(actor_index, f"actor-{actor_index}"),
        "selector": selector,
        "selectorMeaning": "무기/술류만" if selector != 0 else "장비 전체",
        "idStart": start,
        "idEnd": end,
        "ids": list(range(start, end + 1)),
        "names": [row.get("name") for row in rows],
        "records": [
            {
                "index1Based": row.get("index1Based"),
                "name": row.get("name"),
                "recordVaHex": row.get("recordVaHex"),
                "metaHex": row.get("metaHex"),
            }
            for row in rows
        ],
    }


def skill_references_by_actor_category(ui: dict[str, Any]) -> dict[tuple[str, str], list[str]]:
    grouped: dict[tuple[str, str], list[str]] = defaultdict(list)
    for row in ui.get("skillReferences") or []:
        grouped[(row.get("character") or "", row.get("category") or "")].append(row.get("name") or "")
    return grouped


def extract_skill_matrix(exe: bytes, sections: list[dict[str, Any]], ui: dict[str, Any]) -> dict[str, Any]:
    off = va_to_offset(sections, SKILL_MATRIX_VA)
    raw = exe[off : off + SKILL_MATRIX_SIZE] if off is not None else b""
    refs = skill_references_by_actor_category(ui)
    rows: list[dict[str, Any]] = []
    for actor_index, actor_name in ACTORS.items():
        for category_index in range(4):
            start = actor_index * 24 + category_index * 6
            values = list(raw[start : start + 6])
            category = SKILL_CATEGORIES[category_index]
            candidate_names = refs.get((actor_name, category), [])
            active_values = [value for value in values if value]
            rows.append(
                {
                    "actorIndex": actor_index,
                    "actor": actor_name,
                    "categoryIndex": category_index,
                    "category": category,
                    "rawValues": values,
                    "activeValues": active_values,
                    "candidateNamesByReferenceOrder": candidate_names,
                    "note": (
                        "e3 tier checks use categories 1 and 2 only when stream[2] != 0; "
                        "stream[2] == 0 includes category 3 learned checks."
                    )
                    if category_index in (1, 2, 3)
                    else "empty row in the observed matrix",
                }
            )
    return {
        "tableVaHex": f"0x{SKILL_MATRIX_VA:08x}",
        "tableFileOffsetHex": f"0x{off:06x}" if off is not None else "",
        "size": SKILL_MATRIX_SIZE,
        "rawBytes": hex_bytes(raw, SKILL_MATRIX_SIZE),
        "rows": rows,
        "interpretation": {
            "status": "grounded-e3-skill-matrix",
            "actorStride": 24,
            "categoryStride": 6,
            "categoryCountPerActor": 4,
            "bytesPerCategory": 6,
        },
    }


def classify_stream_context(va: int, nearby: list[dict[str, Any]]) -> str:
    if 0x0043CBE0 <= va <= 0x0043CCD8:
        return "dan-rank-score"
    if 0x0046C360 <= va <= 0x0046C4E0:
        return "perfect-clear-audit"
    labels = " ".join(str(row.get("sample") or "") for row in nearby)
    if "퍼펙트" in labels or "장비" in labels or "기술" in labels:
        return "endgame-audit-proximity"
    return "stream-like"


def scan_gate_streams(exe: bytes, sections: list[dict[str, Any]], scene_text: dict[str, Any], equipment: list[dict[str, Any]]) -> list[dict[str, Any]]:
    entry_vas, entries = build_scene_entry_index(scene_text)
    rows: list[dict[str, Any]] = []
    for off in range(0, len(exe) - 8):
        opcode = exe[off]
        if opcode not in (0xE2, 0xE3):
            continue
        actor_index = exe[off + 1]
        selector = exe[off + 2]
        zero = exe[off + 3]
        target = struct.unpack_from("<I", exe, off + 4)[0]
        if actor_index not in ACTORS or zero != 0 or not (0x00400000 <= target <= 0x00600000):
            continue
        if opcode == 0xE2 and selector not in (0, 1):
            continue
        if opcode == 0xE3 and selector not in (0, 1, 2, 3):
            continue
        va = offset_to_va(sections, off)
        if va is None:
            continue
        nearby = nearby_entries(entry_vas, entries, va)
        row: dict[str, Any] = {
            "opcode": f"0x{opcode:02x}",
            "opcodeName": "equipment-possession-gate" if opcode == 0xE2 else "skill-learned-tier-gate",
            "fileOffsetHex": f"0x{off:06x}",
            "va": va,
            "vaHex": f"0x{va:08x}",
            "bytes": hex_bytes(exe[off : off + 8], 8),
            "actorIndex": actor_index,
            "actor": ACTORS[actor_index],
            "selector": selector,
            "targetVaHex": f"0x{target:08x}",
            "context": classify_stream_context(va, nearby),
            "nearbyEntries": nearby,
        }
        if opcode == 0xE2:
            checked = e2_checked_range(actor_index, selector, equipment)
            row.update(
                {
                    "selectorMeaning": checked["selectorMeaning"],
                    "checkedRange": checked,
                    "passSemantics": "all checked equipment IDs are owned -> advance 8 bytes",
                    "failSemantics": "first missing equipment ID -> branch target",
                }
            )
        else:
            row.update(
                {
                    "selectorMeaning": SKILL_TIER_LABELS[selector],
                    "skillTier": selector,
                    "skillTierLabel": SKILL_TIER_LABELS[selector],
                    "passSemantics": "all required skill matrix checks pass -> advance 8 bytes",
                    "failSemantics": "first missing/under-tier skill check -> branch target",
                    "categorySemantics": (
                        "stream[2] 0 checks learned state including category 3; "
                        "stream[2] 1..3 checks mastery tier for attack categories 1 and 2."
                    ),
                }
            )
        rows.append(row)
    rows.sort(key=lambda row: row["va"])
    return rows


def handler_summary() -> dict[str, Any]:
    return {
        "genericDispatchTableVaHex": f"0x{GENERIC_DISPATCH_TABLE_VA:08x}",
        "saveSelectorDispatchTableVaHex": f"0x{SAVE_SELECTOR_DISPATCH_TABLE_VA:08x}",
        "e2": {
            "handlerVaHex": f"0x{E2_HANDLER_VA:08x}",
            "handlerEndVaHex": f"0x{E3_HANDLER_VA:08x}",
            "streamLength": 8,
            "summary": "stream[1] selects actor/equipment group, stream[2] selects weapon-only vs full equipment range, stream[4..7] is failure target.",
            "rangeRule": "actor 0 IDs 1..6 or 1..12, actor 1 IDs 13..18 or 13..24, actor 2 IDs 25..30 or 25..36.",
            "callee": "0x00421d3d(id) possession predicate",
        },
        "e3": {
            "handlerVaHex": f"0x{E3_HANDLER_VA:08x}",
            "handlerEndVaHex": f"0x{E3_HANDLER_END_VA:08x}",
            "streamLength": 8,
            "summary": "stream[1] selects actor, stream[2] selects learned/tier threshold, stream[4..7] is failure target.",
            "matrixVaHex": f"0x{SKILL_MATRIX_VA:08x}",
            "callee": "0x00421bdf(actorSkillBase, category, matrixByte + stream[2]) skill predicate",
            "tierRule": "0=learned; 1=장기 이상; 2=달인기 이상; 3=신기.",
        },
    }


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    sections = read_sections(exe)
    ui = load_json(args.ui_mappings, {})
    scene_text = load_json(args.scene_text_sequence, {})
    equipment = ui_equipment_records(ui)
    streams = scan_gate_streams(exe, sections, scene_text, equipment)
    e2_rows = [row for row in streams if row["opcode"] == "0xe2"]
    e3_rows = [row for row in streams if row["opcode"] == "0xe3"]
    return {
        "scope": "skill-equipment-gate-review",
        "sourceArtifacts": {
            "exe": str(args.exe.relative_to(ROOT)),
            "uiCnsGridMappings": str(args.ui_mappings.relative_to(ROOT)),
            "sceneTextSequence": str(args.scene_text_sequence.relative_to(ROOT)),
        },
        "summary": {
            "status": "e2-equipment-and-e3-skill-gates-grounded",
            "e2StreamRowCount": len(e2_rows),
            "e3StreamRowCount": len(e3_rows),
            "danRankRows": sum(1 for row in streams if row["context"] == "dan-rank-score"),
            "perfectClearRows": sum(1 for row in streams if row["context"] == "perfect-clear-audit"),
            "conclusion": (
                "기술/장비 완료 조건은 개별 0x31/0x32 bit가 아니라 generic Event VM e2/e3 gate로 검사된다. "
                "단 평가와 퍼펙트 클리어 감사 양쪽에서 같은 handler가 재사용된다."
            ),
        },
        "handlerSummary": handler_summary(),
        "equipmentRanges": equipment_ranges(equipment),
        "skillMatrix": extract_skill_matrix(exe, sections, ui),
        "streamRows": streams,
        "e2Rows": e2_rows,
        "e3Rows": e3_rows,
    }


def render_table(headers: list[str], rows: list[list[Any]]) -> str:
    out = ["<table>", "<thead><tr>"]
    out.extend(f"<th>{h(head)}</th>" for head in headers)
    out.append("</tr></thead><tbody>")
    for row in rows:
        out.append("<tr>")
        out.extend(f"<td>{cell}</td>" for cell in row)
        out.append("</tr>")
    out.append("</tbody></table>")
    return "\n".join(out)


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    handler = report["handlerSummary"]
    stream_rows = [
        [
            f"<code>{h(row['vaHex'])}</code>",
            f"<code>{h(row['bytes'])}</code>",
            h(row["opcodeName"]),
            h(row["actor"]),
            h(row["selectorMeaning"]),
            h(row["context"]),
            f"<code>{h(row['targetVaHex'])}</code>",
            h(row.get("nearbyEntries", [{}])[0].get("sample", "") if row.get("nearbyEntries") else ""),
        ]
        for row in report["streamRows"]
    ]
    equipment_rows = [
        [
            h(row["actor"]),
            h(row["selectorMeaning"]),
            f"{h(row['idStart'])}..{h(row['idEnd'])}",
            h(", ".join(row["names"])),
        ]
        for row in report["equipmentRanges"]
    ]
    matrix_rows = [
        [
            h(row["actor"]),
            h(row["category"]),
            f"<code>{h(row['rawValues'])}</code>",
            h(", ".join(row["candidateNamesByReferenceOrder"])),
        ]
        for row in report["skillMatrix"]["rows"]
    ]

    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>기술/장비 Gate 리뷰</title>
    <style>
      :root {{ color-scheme: light; --bg:#f6f7f9; --fg:#17202a; --line:#d8dee6; --muted:#657282; --head:#eef2f6; --good:#0f766e; --warn:#a16207; }}
      * {{ box-sizing:border-box; }}
      body {{ margin:0; padding:24px; background:var(--bg); color:var(--fg); font:14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
      h1, h2 {{ margin:0 0 12px; }}
      h2 {{ margin-top:26px; }}
      a {{ color:#155e75; }}
      .card {{ background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px; margin:0 0 16px; box-shadow:0 1px 2px rgba(15,23,42,.04); }}
      .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
      .metric {{ border:1px solid var(--line); border-radius:8px; padding:12px; background:#fafbfc; }}
      .metric strong {{ display:block; font-size:20px; }}
      .metric span, .muted {{ color:var(--muted); }}
      .tag {{ display:inline-flex; align-items:center; border-radius:999px; padding:2px 8px; background:#eef2f6; }}
      .tag.good {{ background:#ccfbf1; color:var(--good); }}
      .table-wrap {{ overflow-x:auto; }}
      table {{ width:100%; border-collapse:collapse; background:#fff; }}
      th, td {{ border:1px solid var(--line); padding:7px 9px; vertical-align:top; text-align:left; }}
      th {{ background:var(--head); white-space:nowrap; }}
      code {{ font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; }}
      pre {{ white-space:pre-wrap; background:#0f172a; color:#dbeafe; padding:12px; border-radius:8px; overflow:auto; }}
    </style>
  </head>
  <body>
    <main>
      <h1>기술/장비 Gate 리뷰</h1>
      <section class="card">
        <p><span class="tag good">HWANSE_SKILL_EQUIPMENT_GATE_REVIEW_READY</span></p>
        <p>{h(summary["conclusion"])}</p>
        <div class="metrics">
          <div class="metric"><strong>{h(summary["e2StreamRowCount"])}</strong><span>e2 장비 gate stream rows</span></div>
          <div class="metric"><strong>{h(summary["e3StreamRowCount"])}</strong><span>e3 기술 gate stream rows</span></div>
          <div class="metric"><strong>{h(summary["danRankRows"])}</strong><span>단 평가 block rows</span></div>
          <div class="metric"><strong>{h(summary["perfectClearRows"])}</strong><span>퍼펙트 클리어 감사 rows</span></div>
        </div>
      </section>

      <section class="card">
        <h2>Handler Semantics</h2>
        <pre>e2 {h(json.dumps(handler["e2"], ensure_ascii=False, indent=2))}

e3 {h(json.dumps(handler["e3"], ensure_ascii=False, indent=2))}</pre>
      </section>

      <section class="card">
        <h2>Gate Stream Rows</h2>
        <div class="table-wrap">
          {render_table(["VA", "bytes", "opcode", "actor", "selector", "context", "failure target", "near text"], stream_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Equipment Ranges</h2>
        <div class="table-wrap">
          {render_table(["actor", "selector", "ID range", "names"], equipment_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Skill Matrix</h2>
        <p class="muted">matrix byte 자체가 EXE 근거다. 이름은 현재 정리된 기술 reference 순서를 나란히 붙인 검토 보조값이다.</p>
        <div class="table-wrap">
          {render_table(["actor", "category", "raw values", "candidate names by reference order"], matrix_rows)}
        </div>
      </section>
    </main>
  </body>
</html>
"""


def render_docs(report: dict[str, Any]) -> str:
    lines = [
        "# 기술/장비 Gate 리뷰",
        "",
        "기술과 장비 완료 조건은 개별 scenario bit가 아니라 generic Event VM opcode로 처리된다.",
        "",
        "## 결론",
        "",
        f"- e2 장비 gate stream rows: {report['summary']['e2StreamRowCount']}",
        f"- e3 기술 gate stream rows: {report['summary']['e3StreamRowCount']}",
        f"- 단 평가 rows: {report['summary']['danRankRows']}",
        f"- 퍼펙트 클리어 감사 rows: {report['summary']['perfectClearRows']}",
        "",
        "## e2 장비 Gate",
        "",
        "- handler: `0x004101ec`",
        "- `stream[1]`: 캐릭터/장비 그룹 (`0` 아타호, `1` 린샹, `2` 스마슈)",
        "- `stream[2]`: `1`이면 무기/술류만, `0`이면 무기+방어구 전체",
        "- `stream[4..7]`: 실패 시 branch target",
        "- 내부적으로 장비 ID를 순회하며 `0x00421d3d(id)`를 호출한다.",
        "",
        "## e3 기술 Gate",
        "",
        "- handler: `0x00410302`",
        "- skill matrix: `0x004404f0`, 72 bytes",
        "- `stream[1]`: 캐릭터",
        "- `stream[2]`: `0` 습득, `1` 장기 이상, `2` 달인기 이상, `3` 신기",
        "- 내부적으로 `0x00421bdf(actorSkillBase, category, matrixByte + stream[2])`를 호출한다.",
        "- `stream[2] != 0`일 때는 공격 기술 category 1/2만 tier 검사한다. `stream[2] == 0`은 습득 검사로 category 3도 포함한다.",
        "",
        "## 주요 Rows",
        "",
        "| VA | opcode | actor | selector | context | target |",
        "|---|---|---|---|---|---|",
    ]
    for row in report["streamRows"]:
        lines.append(
            f"| `{row['vaHex']}` | `{row['bytes']}` | {row['actor']} | {row['selectorMeaning']} | {row['context']} | `{row['targetVaHex']}` |"
        )
    lines.extend(
        [
            "",
            "## 주의",
            "",
            "- `e2/e3`는 `0x31/0x32` scenario flag와 다르다.",
            "- 단 평가에서 `e2 00 01`은 장비 ID `1..6`을 확인한다. `맨주먹`은 기본 보유이므로 실질적으로 아타호 술류 완비 조건으로 해석된다.",
            "- 퍼펙트 클리어 감사 대사에서는 `e2`가 세 캐릭터 장비 전체를, `e3`가 세 캐릭터 기술 습득을 검사한다.",
        ]
    )
    return "\n".join(lines) + "\n"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--ui-mappings", type=Path, default=OUT / "ui_cns_grid_mappings.json")
    parser.add_argument("--scene-text-sequence", type=Path, default=OUT / "scene_text_sequence_review.json")
    parser.add_argument("--json-out", type=Path, default=OUT / "skill_equipment_gate_review.json")
    parser.add_argument("--web-out", type=Path, default=WEB / "skill_equipment_gate_review.html")
    parser.add_argument("--docs-out", type=Path, default=DOCS / "SKILL_EQUIPMENT_GATE_REVIEW.md")
    args = parser.parse_args()

    report = build(args)
    write_json(args.json_out, report)
    write_text(args.web_out, render_html(report))
    write_text(args.docs_out, render_docs(report))
    print(
        f"wrote {args.json_out} / {args.web_out} / {args.docs_out} "
        f"(e2={report['summary']['e2StreamRowCount']} e3={report['summary']['e3StreamRowCount']})"
    )


if __name__ == "__main__":
    main()
