#!/usr/bin/env python3
"""Trace source/consumer evidence for Dan/rank score flags.

The Dan score producer is already grounded as generic Event VM writes to
global[0x64]. This report focuses on the scenario bit ids consumed by that
producer, so the labels can be moved from vague "candidate" names to named
event outcomes where static text proximity is strong enough.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections


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

TARGET_FLAGS = {
    0x00E8: {
        "label": "주작 시련 클리어",
        "rankCriterion": "백호 제외 3시련 통과",
        "confidence": "high",
        "note": "writer가 주작성령 전투 후 보상 대사 직전/직후에 있다.",
    },
    0x00E9: {
        "label": "창룡 시련 클리어",
        "rankCriterion": "백호 제외 3시련 통과",
        "confidence": "high",
        "note": "writer가 창룡성령 전투 후 보상 대사 직전/직후에 있다.",
    },
    0x00EA: {
        "label": "현무 시련 클리어",
        "rankCriterion": "백호 제외 3시련 통과",
        "confidence": "high",
        "note": "writer가 현무성령 전투 후 보상 대사 직전/직후에 있다.",
    },
    0x00EC: {
        "label": "진 호혈 지하 666층 나찰 클리어",
        "rankCriterion": "진 호혈 666층 돌파",
        "confidence": "high",
        "note": "writer가 나찰 격파 후 가면의 검사/나찰 대사 묶음에 있다.",
    },
    0x01C7: {
        "label": "무투대회 패배 플래그",
        "rankCriterion": "무투대회 무패 조건",
        "confidence": "high",
        "note": "writer 4개가 모두 무투대회 패배/분함 대사 근처다. 단수 가산은 이 플래그가 켜지지 않은 상태를 검사하는 것으로 해석한다.",
    },
    0x01F0: {
        "label": "마수 2마리/폭호 분기 하위 플래그",
        "rankCriterion": "마수 2마리 이상 해방",
        "confidence": "high",
        "note": (
            "set/clear가 마수 두놈/폭호 분기 대사 주변에 있고, global slot +0x43 마수 해방 카운터가 2일 때 "
            "이 route로 분기한다. f0 자체는 파생 route flag지만 source counter와 Dan score consumer가 함께 잡혔다."
        ),
    },
    0x01F1: {
        "label": "마수 3마리/폭호 분기 상위 플래그",
        "rankCriterion": "마수 3마리 해방 및 폭호 조건",
        "confidence": "high",
        "note": (
            "set/clear가 마수 세놈/폭호 분기 대사 주변에 있고, global slot +0x43 마수 해방 카운터가 3일 때 "
            "이 route로 분기한다. 0x01f0보다 높은 tier 조건으로 Dan score에서 먼저 소비된다."
        ),
    },
}

BEAST_COUNTER_INCREMENT_ROWS = [
    {
        "va": 0x004ADCA4,
        "bytes": "10 41 43 01",
        "slot": "+0x43",
        "delta": 1,
        "pairedFlagVa": 0x004ADCA8,
        "pairedFlagBytes": "31 01 94 01",
        "label": "마수 해방 #1 / 흉조 text cluster",
    },
    {
        "va": 0x004ADEC0,
        "bytes": "10 41 43 01",
        "slot": "+0x43",
        "delta": 1,
        "pairedFlagVa": 0x004ADEC4,
        "pairedFlagBytes": "31 01 97 01",
        "label": "마수 해방 #2 / 사룡 text cluster",
    },
    {
        "va": 0x004AE0DC,
        "bytes": "10 41 43 01",
        "slot": "+0x43",
        "delta": 1,
        "pairedFlagVa": 0x004AE0E0,
        "pairedFlagBytes": "31 01 9a 01",
        "label": "마수 해방 #3 / 업구 text cluster",
    },
]

BEAST_COUNTER_BRANCH_ROWS = [
    {
        "va": 0x0045A5A8,
        "bytes": "13 41 43 02 64 a6 45 00",
        "slot": "+0x43",
        "compareValue": 2,
        "targetVa": 0x0045A664,
        "label": "마수 2마리 route text로 분기",
    },
    {
        "va": 0x0045A5B0,
        "bytes": "13 41 43 03 a8 a6 45 00",
        "slot": "+0x43",
        "compareValue": 3,
        "targetVa": 0x0045A6A8,
        "label": "마수 3마리 route text로 분기",
    },
    {
        "va": 0x0045A928,
        "bytes": "13 41 43 03 60 a9 45 00",
        "slot": "+0x43",
        "compareValue": 3,
        "targetVa": 0x0045A960,
        "label": "폭호 후속 route에서 3마리 상위 분기 재검사",
    },
]

DAN_SCORE_CONSUMER_RANGES = [
    (0x0043CC88, 0x0043CCD0, "dan-score-producer"),
    (0x0043D840, 0x0043D894, "dan-condition-hint-branch"),
    (0x00446FF0, 0x00447008, "condition-text/route-branch"),
]


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, separators=(",", ":")), 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 = 220) -> 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 = 24) -> 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 = 3) -> 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 range_label(va: int) -> str:
    for start, end, label in DAN_SCORE_CONSUMER_RANGES:
        if start <= va <= end:
            return label
    return ""


def is_code_like_false_positive(opcode: int, mode: int, target: int) -> bool:
    # Common x86 bytes matching "32 c0 e9 00 00 00 00 5f" are xor al,al;
    # jmp ...; pop edi shape, not an Event VM bit-test row.
    return opcode == 0x32 and mode == 0xC0 and target >= 0x50000000


def scan_flag_rows(exe: bytes, sections: list[dict[str, Any]], entry_vas: list[int], entries: list[dict[str, Any]], flag_id: int) -> dict[str, Any]:
    lo = flag_id & 0xFF
    hi = (flag_id >> 8) & 0xFF
    meta = TARGET_FLAGS[flag_id]
    rows: list[dict[str, Any]] = []

    for off in range(0, len(exe) - 8):
        opcode = exe[off]
        if opcode not in (0x31, 0x32):
            continue
        if exe[off + 2] != lo or exe[off + 3] != hi:
            continue
        va = offset_to_va(sections, off)
        if va is None:
            continue
        mode = exe[off + 1]
        target = struct.unpack_from("<I", exe, off + 4)[0] if opcode == 0x32 else None
        op_len = 8 if opcode == 0x32 else 4
        nearby = nearby_entries(entry_vas, entries, va)
        sequence_like = bool(nearby and abs(nearby[min(3, len(nearby) - 1)]["deltaFromRef"]) < 0x5000)
        context = range_label(va)
        false_positive = is_code_like_false_positive(opcode, mode, target or 0)
        action = ""
        if opcode == 0x31:
            action = "set-flag" if mode == 1 else "clear-flag" if mode == 0 else f"write-mode-0x{mode:02x}"
        elif opcode == 0x32:
            action = f"test-bit-expect-{mode}"

        rows.append(
            {
                "flagId": flag_id,
                "flagIdHex": f"0x{flag_id:04x}",
                "flagLabel": meta["label"],
                "rankCriterion": meta["rankCriterion"],
                "opcode": f"0x{opcode:02x}",
                "opcodeName": "flag-write" if opcode == 0x31 else "flag-test-branch",
                "action": action,
                "mode": mode,
                "modeHex": f"0x{mode:02x}",
                "fileOffsetHex": f"0x{off:06x}",
                "va": va,
                "vaHex": f"0x{va:08x}",
                "bytes": hex_bytes(exe[off : off + op_len], op_len),
                "targetVaHex": f"0x{target:08x}" if target is not None else "",
                "consumerRange": context,
                "streamEvidence": "code-like-false-positive" if false_positive else "known-consumer-range" if context else "scene-proximity",
                "promoted": (not false_positive) and (opcode == 0x31 or bool(context)),
                "nearbyEntries": nearby,
            }
        )

    rows.sort(key=lambda row: (row["opcode"], row["va"]))
    writers = [row for row in rows if row["opcode"] == "0x31" and row["promoted"]]
    tests = [row for row in rows if row["opcode"] == "0x32" and row["promoted"]]
    false_hits = [row for row in rows if row["streamEvidence"] == "code-like-false-positive"]
    return {
        "flagId": flag_id,
        "flagIdHex": f"0x{flag_id:04x}",
        "label": meta["label"],
        "rankCriterion": meta["rankCriterion"],
        "confidence": meta["confidence"],
        "note": meta["note"],
        "writerCount": len(writers),
        "testCount": len(tests),
        "falsePositiveCount": len(false_hits),
        "rows": rows,
        "writerRows": writers,
        "testRows": tests,
    }


def build_beast_counter_evidence(entry_vas: list[int], entries: list[dict[str, Any]]) -> dict[str, Any]:
    increments: list[dict[str, Any]] = []
    for row in BEAST_COUNTER_INCREMENT_ROWS:
        enriched = dict(row)
        enriched["vaHex"] = f"0x{row['va']:08x}"
        enriched["pairedFlagVaHex"] = f"0x{row['pairedFlagVa']:08x}"
        enriched["nearbyEntries"] = nearby_entries(entry_vas, entries, row["va"])
        increments.append(enriched)

    branches: list[dict[str, Any]] = []
    for row in BEAST_COUNTER_BRANCH_ROWS:
        enriched = dict(row)
        enriched["vaHex"] = f"0x{row['va']:08x}"
        enriched["targetVaHex"] = f"0x{row['targetVa']:08x}"
        enriched["nearbyEntries"] = nearby_entries(entry_vas, entries, row["va"])
        branches.append(enriched)

    return {
        "slot": "+0x43",
        "slotMeaning": "마수 해방 카운터",
        "status": "grounded-derived-counter",
        "incrementCount": len(increments),
        "branchCount": len(branches),
        "increments": increments,
        "branches": branches,
        "interpretation": (
            "0x10 41 43 01 rows increment global slot +0x43 exactly in the three beast-release text clusters. "
            "The final 폭호 route checks +0x43 against 2 and 3, then sets the f0/f1 route flags that the Dan score block consumes."
        ),
    }


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    sections = read_sections(exe)
    scene_text = load_json(args.scene_text_sequence, {})
    entry_vas, entries = build_scene_entry_index(scene_text)
    flags = [scan_flag_rows(exe, sections, entry_vas, entries, flag_id) for flag_id in TARGET_FLAGS]
    beast_counter = build_beast_counter_evidence(entry_vas, entries)
    writer_total = sum(flag["writerCount"] for flag in flags)
    test_total = sum(flag["testCount"] for flag in flags)
    return {
        "scope": "dan-rank-flag-source-review",
        "sourceArtifacts": {
            "exe": str(args.exe.relative_to(ROOT)),
            "sceneTextSequence": str(args.scene_text_sequence.relative_to(ROOT)),
            "danRankSystem": "out/dan_rank_system_review.json",
        },
        "summary": {
            "targetFlagCount": len(flags),
            "promotedWriterRows": writer_total,
            "promotedConsumerRows": test_total,
            "status": "source-labels-promoted-for-main-dan-score-bits",
            "remainingCaveat": (
                "0x01f0/0x01f1 are still derived route flags rather than the raw counter, but the raw beast-release "
                "counter source is now grounded as global slot +0x43."
            ),
        },
        "beastCounterEvidence": beast_counter,
        "flags": flags,
    }


def render_rows_for_flag(flag: dict[str, Any]) -> str:
    out = [
        "<section class=\"flag-block\">",
        f"<h2><code>{h(flag['flagIdHex'])}</code> {h(flag['label'])}</h2>",
        f"<p><strong>단 조건:</strong> {h(flag['rankCriterion'])} · <strong>신뢰도:</strong> {h(flag['confidence'])}</p>",
        f"<p class=\"muted\">{h(flag['note'])}</p>",
        "<table><thead><tr><th>VA</th><th>op</th><th>동작</th><th>target/range</th><th>근처 대사</th></tr></thead><tbody>",
    ]
    for row in flag["rows"]:
        if row["streamEvidence"] == "code-like-false-positive":
            css = "muted-row"
        elif row["promoted"]:
            css = "promoted-row"
        else:
            css = ""
        samples = []
        for entry in row.get("nearbyEntries", [])[:7]:
            samples.append(
                f"<div><code>{h(entry.get('deltaFromRefHex'))}</code> "
                f"<span class=\"muted\">{h(entry.get('groupId'))}/{h(entry.get('sequenceId'))}</span> "
                f"{h(entry.get('sample'))}</div>"
            )
        range_text = row.get("consumerRange") or row.get("targetVaHex") or row.get("streamEvidence")
        out.append(
            f"<tr class=\"{css}\"><td><code>{h(row['vaHex'])}</code><br><span class=\"muted\">{h(row['fileOffsetHex'])}</span></td>"
            f"<td><code>{h(row['bytes'])}</code><br>{h(row['opcodeName'])}</td>"
            f"<td>{h(row['action'])}<br><span class=\"muted\">{h(row['streamEvidence'])}</span></td>"
            f"<td>{h(range_text)}</td><td>{''.join(samples)}</td></tr>"
        )
    out.append("</tbody></table></section>")
    return "\n".join(out)


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    beast = report["beastCounterEvidence"]
    beast_increment_rows = []
    for row in beast["increments"]:
        sample = ""
        if row.get("nearbyEntries"):
            sample = row["nearbyEntries"][min(3, len(row["nearbyEntries"]) - 1)].get("sample", "")
        beast_increment_rows.append(
            f"<tr><td><code>{h(row['vaHex'])}</code></td><td><code>{h(row['bytes'])}</code></td>"
            f"<td>{h(row['label'])}<br><span class=\"muted\">paired <code>{h(row['pairedFlagVaHex'])}</code> "
            f"{h(row['pairedFlagBytes'])}</span></td><td>{h(sample)}</td></tr>"
        )
    beast_branch_rows = []
    for row in beast["branches"]:
        sample = ""
        if row.get("nearbyEntries"):
            sample = row["nearbyEntries"][min(3, len(row["nearbyEntries"]) - 1)].get("sample", "")
        beast_branch_rows.append(
            f"<tr><td><code>{h(row['vaHex'])}</code></td><td><code>{h(row['bytes'])}</code></td>"
            f"<td>{h(row['slot'])} == {h(row['compareValue'])} → <code>{h(row['targetVaHex'])}</code><br>"
            f"<span class=\"muted\">{h(row['label'])}</span></td><td>{h(sample)}</td></tr>"
        )
    flag_cards = "\n".join(render_rows_for_flag(flag) for flag in report["flags"])
    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>단 평가 플래그 출처 리뷰</title>
    <style>
      :root {{
        --bg: #f6f7f9;
        --fg: #17202a;
        --muted: #657282;
        --line: #d8dee6;
        --head: #eef2f6;
        --good: #0f766e;
        --warn: #b45309;
      }}
      * {{ box-sizing: border-box; }}
      body {{ margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
      main {{ max-width: 1280px; margin: 0 auto; padding: 24px; }}
      h1 {{ margin: 0 0 8px; font-size: 24px; }}
      h2 {{ margin: 0 0 8px; font-size: 18px; }}
      p {{ margin: 6px 0 12px; }}
      code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
      table {{ width: 100%; border-collapse: collapse; margin: 12px 0 22px; background: #fff; border: 1px solid var(--line); }}
      th, td {{ border: 1px solid var(--line); padding: 8px 10px; vertical-align: top; text-align: left; }}
      th {{ background: var(--head); white-space: nowrap; }}
      .muted {{ color: var(--muted); }}
      .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin: 18px 0; }}
      .card {{ background: #fff; border: 1px solid var(--line); padding: 12px; }}
      .card strong {{ display: block; font-size: 18px; }}
      .flag-block {{ margin-top: 26px; }}
      .promoted-row td:first-child {{ border-left: 4px solid var(--good); }}
      .muted-row {{ opacity: .58; }}
      .marker {{ display: none; }}
      @media (max-width: 860px) {{
        main {{ padding: 14px; }}
        table {{ display: block; overflow-x: auto; white-space: nowrap; }}
        td:last-child {{ white-space: normal; min-width: 420px; }}
      }}
    </style>
  </head>
  <body>
    <main>
      <h1>단 평가 플래그 출처 리뷰</h1>
      <p class=\"muted\">단 점수 산출 블록에서 소비하는 주요 시나리오 비트를 writer/consumer와 근처 대사로 역추적한 화면이다.</p>
      <div class=\"summary\">
        <div class=\"card\"><span>대상 비트</span><strong>{h(summary['targetFlagCount'])}</strong></div>
        <div class=\"card\"><span>승격 writer</span><strong>{h(summary['promotedWriterRows'])}</strong></div>
        <div class=\"card\"><span>승격 consumer</span><strong>{h(summary['promotedConsumerRows'])}</strong></div>
        <div class=\"card\"><span>상태</span><strong>{h(summary['status'])}</strong></div>
      </div>
      <p><a href=\"dan_rank_system_review.html\">단 평가 공식 보기</a></p>
      <section class=\"flag-block\">
        <h2>마수 해방 카운터 <code>{h(beast['slot'])}</code></h2>
        <p><strong>상태:</strong> {h(beast['status'])} · <strong>해석:</strong> {h(beast['slotMeaning'])}</p>
        <p class=\"muted\">{h(beast['interpretation'])}</p>
        <h3>해방 시 증가</h3>
        <table><thead><tr><th>VA</th><th>op</th><th>의미</th><th>근처 대사</th></tr></thead><tbody>
          {''.join(beast_increment_rows)}
        </tbody></table>
        <h3>폭호 route 분기</h3>
        <table><thead><tr><th>VA</th><th>op</th><th>분기</th><th>근처 대사</th></tr></thead><tbody>
          {''.join(beast_branch_rows)}
        </tbody></table>
      </section>
      {flag_cards}
      <div class=\"marker\">HWANSE_DAN_RANK_FLAG_SOURCE_REVIEW_READY</div>
    </main>
  </body>
</html>
"""


def render_md(report: dict[str, Any]) -> str:
    beast = report["beastCounterEvidence"]
    lines = [
        "# 단 평가 플래그 출처 리뷰",
        "",
        "단 점수 산출 블록에서 소비하는 주요 시나리오 비트를 writer/consumer와 근처 대사로 역추적한 결과다.",
        "",
        f"- 대상 비트: {report['summary']['targetFlagCount']}",
        f"- 승격 writer: {report['summary']['promotedWriterRows']}",
        f"- 승격 consumer: {report['summary']['promotedConsumerRows']}",
        f"- 주의: {report['summary']['remainingCaveat']}",
        "",
        "## 마수 해방 카운터",
        "",
        f"- 슬롯: `{beast['slot']}`",
        f"- 상태: {beast['status']}",
        f"- 해석: {beast['slotMeaning']}",
        f"- 근거: {beast['interpretation']}",
        "",
        "### 해방 시 증가",
        "",
    ]
    for row in beast["increments"]:
        sample = row["nearbyEntries"][min(3, len(row["nearbyEntries"]) - 1)]["sample"] if row["nearbyEntries"] else ""
        lines.append(
            f"- `{row['vaHex']}` `{row['bytes']}` {row['label']} "
            f"(paired `{row['pairedFlagVaHex']}` `{row['pairedFlagBytes']}`) :: {sample}"
        )
    lines.extend(["", "### 폭호 route 분기", ""])
    for row in beast["branches"]:
        sample = row["nearbyEntries"][min(3, len(row["nearbyEntries"]) - 1)]["sample"] if row["nearbyEntries"] else ""
        lines.append(
            f"- `{row['vaHex']}` `{row['bytes']}` `{row['slot']}` == {row['compareValue']} "
            f"→ `{row['targetVaHex']}` :: {row['label']} :: {sample}"
        )
    lines.extend(
        [
            "",
        ]
    )
    for flag in report["flags"]:
        lines.extend(
            [
                f"## {flag['flagIdHex']} {flag['label']}",
                "",
                f"- 단 조건: {flag['rankCriterion']}",
                f"- 신뢰도: {flag['confidence']}",
                f"- 근거: {flag['note']}",
                f"- writer/test: {flag['writerCount']} / {flag['testCount']}",
                "",
            ]
        )
        for row in flag["writerRows"][:6]:
            sample = row["nearbyEntries"][min(3, len(row["nearbyEntries"]) - 1)]["sample"] if row["nearbyEntries"] else ""
            lines.append(f"  - `{row['vaHex']}` `{row['bytes']}` {row['action']} :: {sample}")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--scene-text-sequence", type=Path, default=OUT / "scene_text_sequence_review.json")
    parser.add_argument("--out-json", type=Path, default=OUT / "dan_rank_flag_source_review.json")
    parser.add_argument("--out-html", type=Path, default=WEB / "dan_rank_flag_source_review.html")
    parser.add_argument("--out-md", type=Path, default=DOCS / "DAN_RANK_FLAG_SOURCE_REVIEW.md")
    args = parser.parse_args()

    report = build(args)
    write_json(args.out_json, report)
    write_text(args.out_html, render_html(report))
    write_text(args.out_md, render_md(report))


if __name__ == "__main__":
    main()
