#!/usr/bin/env python3
"""Build a focused review for the ending Dan/rank evaluation path.

The report is intentionally conservative:

* The Dan display selector is grounded: a single 13-branch cluster tests the
  same state slot and jumps into the rank text block.
* The score producer block is grounded under the generic event VM table:
  `0x10 41 64 xx` means `global[0x64] += xx` there. The same raw bytes are
  not executable writer proof under the save-selector slice table, so the
  report records that dispatch-table distinction explicitly.
"""
from __future__ import annotations

import argparse
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, va_to_offset


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

DAN_SELECTOR_ENTRY_VA = 0x0043D54C
DAN_SELECTOR_TEXT_VA = 0x0050737C
DAN_SCORE_SLOT = 0x64
DAN_SCORE_MODE = 0x41
PATTERN_BRANCH = bytes([0x13, DAN_SCORE_MODE, DAN_SCORE_SLOT])
PATTERN_WRITE = bytes([0x10, DAN_SCORE_MODE, DAN_SCORE_SLOT])
DAN_SCORE_BLOCK_START_VA = 0x0043CBE8
DAN_SCORE_BLOCK_END_VA = 0x0043CCD4
GENERIC_DISPATCH_TABLE_VA = 0x00440538
GENERIC_OPCODE10_HANDLER_VA = 0x00402D2E
SAVE_SELECTOR_DISPATCH_TABLE_VA = 0x00440720
SAVE_SELECTOR_OPCODE10_HANDLER_VA = 0x0040B49E

SCORE_RULE_HINTS = {
    0x0043CBF4: {
        "group": "skill-tier",
        "label": "아타호 기술 전부 신기",
        "guard": "e3 00 03",
        "confidence": "high",
        "note": "e3 skill-tier gate passes then jumps over the lower skill-tier checks.",
    },
    0x0043CC0C: {
        "group": "skill-tier",
        "label": "아타호 기술 전부 달인기 이상",
        "guard": "e3 00 02",
        "confidence": "high",
        "note": "Mutually exclusive with the +5/+2/+1 skill-tier paths.",
    },
    0x0043CC24: {
        "group": "skill-tier",
        "label": "아타호 기술 전부 장기 이상",
        "guard": "e3 00 01",
        "confidence": "high",
        "note": "Mutually exclusive with the +5/+3/+1 skill-tier paths.",
    },
    0x0043CC3C: {
        "group": "skill-tier",
        "label": "아타호 기술 전부 습득",
        "guard": "e3 00 00",
        "confidence": "high",
        "note": "Lowest skill completion tier.",
    },
    0x0043CC4C: {
        "group": "liquor/equipment",
        "label": "아타호 무기/술류 완비",
        "guard": "e2 00 01",
        "confidence": "high",
        "note": (
            "Generic Event VM opcode e2 checks equipment possession range 1..6. "
            "That range maps to Ataho weapons; bare hands is default-owned, so the practical rank condition is all liquor weapons."
        ),
    },
    0x0043CC64: {
        "group": "drunken-tier",
        "label": "주량 30 이상: 취권/대호 상위 tier",
        "guard": "14 c3 18 00 1e",
        "confidence": "high",
        "note": (
            "Generic Event VM opcode 0x14 is a word-width conditional branch. "
            "The 0xc3 source form reads word context+0xa8[+0x18], and battle status analysis promotes +0x18 as "
            "Ataho's persistent 주량. Passing this threshold jumps past the lower +1 drunken threshold."
        ),
    },
    0x0043CC80: {
        "group": "drunken-tier",
        "label": "주량 20 이상: 취권 사용 lower tier",
        "guard": "14 c3 18 00 14",
        "confidence": "high",
        "note": (
            "Generic Event VM opcode 0x14 compares word context+0xa8[+0x18] against immediate 0x14. "
            "+0x18 is the confirmed persistent 주량 slot, incremented from 술/취호권 result families via 주량 경험치 +0x1a."
        ),
    },
    0x0043CCA0: {
        "group": "scenario-bit",
        "label": "주작/창룡/현무 시련 클리어",
        "guard": "32 00 e8/e9/ea",
        "confidence": "high",
        "note": "Bits 0xe8/0xe9/0xea are written in the 주작/창룡/현무 trial-clear text groups.",
    },
    0x0043CCAC: {
        "group": "scenario-bit",
        "label": "진 호혈 666층 나찰 클리어",
        "guard": "32 00 ec",
        "confidence": "high",
        "note": "Bit 0xec is written in the 나찰/가면의 검사 clear text group.",
    },
    0x0043CCB8: {
        "group": "scenario-bit",
        "label": "무투대회 무패",
        "guard": "32 01 c7",
        "confidence": "high",
        "note": "Bit 0x1c7 is written by tournament-loss branches; the rank bonus is the no-loss condition.",
    },
    0x0043CCC4: {
        "group": "beast-tier",
        "label": "마수 3마리/폭호 조건",
        "guard": "32 00 f1",
        "confidence": "high",
        "note": (
            "Bit 0x1f1 is the upper beast route flag consumed before the lower tier. "
            "The source report grounds global +0x43 as the beast-release counter and shows +0x43 == 3 branching into this route."
        ),
    },
    0x0043CCD0: {
        "group": "beast-tier",
        "label": "마수 2마리 조건",
        "guard": "32 00 f0",
        "confidence": "high",
        "note": (
            "Bit 0x1f0 is the lower beast route flag. "
            "The source report grounds global +0x43 as the beast-release counter and shows +0x43 == 2 branching into this route."
        ),
    },
}

RANK_TITLES = {
    13: "13단 전설의 맹호",
    12: "12단 권신",
    11: "11단 권성",
    10: "10단 지고의 권사",
    9: "9단 권성(정발 표기, 원문 拳皇)",
    8: "8단 권제",
    7: "7단 권호",
    6: "6단 사범",
    5: "5단 사범대리",
    4: "4단 유명권사",
    3: "3단 강권사",
    2: "2단 난폭권사",
    1: "1단 보통권사",
    0: "단외 보통호랑이",
}

CONDITION_PROMPT_IDS = [
    "story-prompt-3504",
    "story-prompt-3505",
    "story-prompt-3506",
    "story-prompt-3507",
    "story-prompt-3508",
    "story-prompt-3510",
    "story-prompt-3511",
    "story-prompt-3512",
    "story-prompt-3513",
    "story-prompt-3514",
    "story-prompt-3515",
    "story-prompt-3516",
    "story-prompt-3517",
    "story-prompt-3518",
    "story-prompt-3519",
]

RANK_PROMPT_IDS = [f"story-prompt-{idx}" for idx in range(3475, 3502)]
PERFECT_PROMPT_IDS = ["story-prompt-4503", "story-prompt-4504"]


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 = 180) -> str:
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, sort_keys=True)
    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 find_all(data: bytes, pattern: bytes) -> list[int]:
    hits: list[int] = []
    start = 0
    while True:
        off = data.find(pattern, start)
        if off < 0:
            return hits
        hits.append(off)
        start = off + 1


def prompt_map(story_prompts: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {row.get("id"): row for row in story_prompts.get("prompts", []) if row.get("id")}


def prompt_subset(prompts: dict[str, dict[str, Any]], ids: list[str]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for pid in ids:
        row = prompts.get(pid)
        if not row:
            continue
        rows.append(
            {
                "id": pid,
                "startVaHex": row.get("startVaHex"),
                "endVaHex": row.get("endVaHex"),
                "lineCount": row.get("lineCount"),
                "text": row.get("displayText") or row.get("text"),
                "sample": short(row.get("displayText") or row.get("text"), 260),
            }
        )
    return rows


def build_entry_lookup(scene_text: dict[str, Any]) -> dict[str, dict[str, Any]]:
    lookup: dict[str, dict[str, Any]] = {}
    for group in scene_text.get("groups", []):
        for sequence in group.get("sequences", []):
            for entry in sequence.get("entries", []):
                if entry.get("entryVaHex"):
                    item = dict(entry)
                    item["_groupId"] = group.get("id")
                    item["_sequenceId"] = sequence.get("id")
                    item["_groupEvidenceStatus"] = group.get("evidenceStatus")
                    item["_contextLabel"] = group.get("contextLabel")
                    lookup[entry["entryVaHex"].lower()] = item
    return lookup


def find_entry(scene_text: dict[str, Any], entry_va_hex: str) -> dict[str, Any] | None:
    return build_entry_lookup(scene_text).get(entry_va_hex.lower())


def parse_selector(exe: bytes, sections: list[dict[str, Any]], scene_text: dict[str, Any]) -> dict[str, Any]:
    entry = find_entry(scene_text, f"0x{DAN_SELECTOR_ENTRY_VA:08x}")
    entry_off = va_to_offset(sections, DAN_SELECTOR_ENTRY_VA)
    raw_window = exe[entry_off : entry_off + 0x80] if entry_off is not None else b""

    branch_hits = find_all(exe, PATTERN_BRANCH)
    exact_cluster = []
    entries = build_entry_lookup(scene_text)
    for hit in branch_hits:
        va = offset_to_va(sections, hit)
        if va is None:
            continue
        value = exe[hit + 3]
        target = struct.unpack_from("<I", exe, hit + 4)[0] if hit + 8 <= len(exe) else 0
        target_entry = entries.get(f"0x{target:08x}")
        display_rank = value if value > 0 else 0
        exact_cluster.append(
            {
                "fileOffsetHex": f"0x{hit:06x}",
                "opcodeVaHex": f"0x{va:08x}",
                "opcodeBytes": hex_bytes(exe[hit : hit + 8], 8),
                "modeHex": f"0x{DAN_SCORE_MODE:02x}",
                "slotHex": f"0x{DAN_SCORE_SLOT:02x}",
                "compareValue": value,
                "compareValueHex": f"0x{value:02x}",
                "targetVaHex": f"0x{target:08x}",
                "targetTextVaHex": target_entry.get("textVaHex") if target_entry else "",
                "targetSample": short(target_entry.get("sample"), 220) if target_entry else "",
                "displayRank": display_rank,
                "displayTitle": RANK_TITLES.get(display_rank, ""),
                "evidence": "0x13 0x41 0x64 value target branch row",
            }
        )

    exact_cluster.sort(key=lambda row: row["compareValue"], reverse=True)
    return {
        "entryVaHex": f"0x{DAN_SELECTOR_ENTRY_VA:08x}",
        "entryFileOffsetHex": f"0x{entry_off:06x}" if entry_off is not None else "",
        "entryTextVaHex": f"0x{DAN_SELECTOR_TEXT_VA:08x}",
        "entrySample": short(entry.get("sample"), 300) if entry else "",
        "sceneTextGroup": entry.get("_groupId") if entry else "",
        "sceneTextSequence": entry.get("_sequenceId") if entry else "",
        "sceneProximityEvidenceStatus": entry.get("_groupEvidenceStatus") if entry else "",
        "contextLabel": entry.get("_contextLabel") if entry else "",
        "rawEntryBytes": hex_bytes(raw_window, 96),
        "branchPatternHex": PATTERN_BRANCH.hex(" "),
        "branchHitCount": len(branch_hits),
        "branchRows": exact_cluster,
        "fallthrough": {
            "scoreValueHypothesis": 13,
            "displayTitle": RANK_TITLES[13],
            "evidence": (
                "branch cluster tests only 0x0c..0x00. The 13단 text is the first rank line before "
                "the branch targets, so the max rank path is treated as fallthrough/no-branch."
            ),
            "confidence": "strong-static-structure",
        },
        "interpretation": {
            "slot": "+0x64",
            "mode": "0x41",
            "slotMeaning": "ending Dan/rank score selector",
            "status": "grounded-consumer",
            "gap": "the exact producer formula and each global flag/counter source are still separate work",
        },
    }


def parse_score_pattern_hits(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    hits = find_all(exe, PATTERN_WRITE)
    rows: list[dict[str, Any]] = []
    for hit in hits:
        va = offset_to_va(sections, hit)
        if va is None:
            continue
        in_score_block = DAN_SCORE_BLOCK_START_VA <= va <= DAN_SCORE_BLOCK_END_VA
        trailing = exe[hit + 3]
        packed = struct.unpack_from("<I", exe, hit)[0]
        row_start = max(0, hit - 8)
        row_bytes = exe[row_start : hit + 4]
        row_dwords = [
            f"0x{struct.unpack_from('<I', row_bytes, index)[0]:08x}"
            for index in range(0, len(row_bytes) - 3, 4)
        ]
        rule = SCORE_RULE_HINTS.get(va, {})
        rows.append(
            {
                "fileOffsetHex": f"0x{hit:06x}",
                "vaHex": f"0x{va:08x}",
                "rawBytes": hex_bytes(exe[hit : hit + 4], 4),
                "packedDwordHex": f"0x{packed:08x}",
                "modeHex": f"0x{DAN_SCORE_MODE:02x}",
                "slotHex": f"0x{DAN_SCORE_SLOT:02x}",
                "scoreDelta": trailing,
                "trailingByte": trailing,
                "trailingByteHex": f"0x{trailing:02x}",
                "candidatePackedRowStartFileOffsetHex": f"0x{row_start:06x}",
                "candidatePackedRowDwords": row_dwords,
                "beforeBytes": hex_bytes(exe[max(0, hit - 20) : hit], 20),
                "afterBytes": hex_bytes(exe[hit + 4 : hit + 24], 20),
                "inScoreProducerBlock": in_score_block,
                "promotion": "generic-vm-score-writer-grounded" if in_score_block else "raw-pattern-outside-score-block",
                "operation": "global[0x64] += trailingByte" if in_score_block else "",
                "dispatchTableVaHex": f"0x{GENERIC_DISPATCH_TABLE_VA:08x}" if in_score_block else "",
                "handlerVaHex": f"0x{GENERIC_OPCODE10_HANDLER_VA:08x}" if in_score_block else "",
                "exclusiveGroup": rule.get("group", ""),
                "ruleLabel": rule.get("label", ""),
                "guard": rule.get("guard", ""),
                "confidence": rule.get("confidence", "low" if not in_score_block else "medium"),
                "note": rule.get("note", ""),
                "dispatchCaveat": (
                    "The same raw bytes are not a writer under the save-selector slice table "
                    f"0x{SAVE_SELECTOR_DISPATCH_TABLE_VA:08x}->0x{SAVE_SELECTOR_OPCODE10_HANDLER_VA:08x}; "
                    "this row is promoted because this endgame block is decoded by the generic VM table."
                )
                if in_score_block
                else "",
            }
        )
    trailing_counts: dict[str, int] = {}
    for row in rows:
        trailing_counts[str(row["trailingByte"])] = trailing_counts.get(str(row["trailingByte"]), 0) + 1

    promoted_rows = [row for row in rows if row["promotion"] == "generic-vm-score-writer-grounded"]
    max_score_components = {
        "skillTierMax": 5,
        "liquorCondition": 1,
        "drunkenTierMax": 2,
        "threeTrialCondition": 1,
        "floor666Condition": 1,
        "tournamentCondition": 1,
        "beastTierMax": 2,
        "total": 13,
    }

    return {
        "rawPatternHex": PATTERN_WRITE.hex(" "),
        "rawPatternHitCount": len(hits),
        "scoreProducerBlockVaRange": f"0x{DAN_SCORE_BLOCK_START_VA:08x}..0x{DAN_SCORE_BLOCK_END_VA:08x}",
        "scoreWriterRowCount": len(promoted_rows),
        "slotHex": f"0x{DAN_SCORE_SLOT:02x}",
        "modeHex": f"0x{DAN_SCORE_MODE:02x}",
        "trailingByteCounts": trailing_counts,
        "genericOpcode10Handler": {
            "dispatchTableVaHex": f"0x{GENERIC_DISPATCH_TABLE_VA:08x}",
            "handlerVaHex": f"0x{GENERIC_OPCODE10_HANDLER_VA:08x}",
            "streamAdvanceBytes": 4,
            "operation": "byte arithmetic/write",
            "decodedSemantics": "low nibble 1 = add; high bits 0x40 select global pointer table; stream[2] selects slot; stream[3] is immediate delta",
            "decodedDanOperation": "global[0x64] += stream[3]",
            "conclusion": "0x10 41 64 xx rows inside the Dan block are score writers under the generic event VM table",
        },
        "saveSelectorOpcode10Caveat": {
            "dispatchTableVaHex": f"0x{SAVE_SELECTOR_DISPATCH_TABLE_VA:08x}",
            "handlerVaHex": f"0x{SAVE_SELECTOR_OPCODE10_HANDLER_VA:08x}",
            "helperVaHex": "0x00410c90",
            "validHelperSubcommandRange": "0..11",
            "observedStream2Hex": f"0x{DAN_SCORE_SLOT:02x}",
            "observedStream2Executable": False,
            "conclusion": "the save-selector slice handler is not the active decoder for this Dan score producer block",
        },
        "maxScoreComponents": max_score_components,
        "rows": rows,
        "interpretation": {
            "status": "score-producer-grounded-with-conservative-labels",
            "whyUseful": (
                "These exact 0x10/0x41/+0x64 byte patterns form the endgame Dan score producer block. "
                "The generic VM opcode 0x10 handler decodes them as additive writes to global slot +0x64."
            ),
            "gap": (
                "The additive formula and max score are grounded. Main source bits and e2/e3 skill/equipment gates "
                "are tied to named handlers; remaining gaps are perfect-clear/password flow separation, not the Dan score formula."
            ),
        },
    }


def raw_text_hits(exe: bytes, sections: list[dict[str, Any]], terms: list[str]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for term in terms:
        enc = term.encode("cp949", "ignore")
        start = 0
        while True:
            off = exe.find(enc, start)
            if off < 0:
                break
            va = offset_to_va(sections, off)
            rows.append(
                {
                    "term": term,
                    "fileOffsetHex": f"0x{off:06x}",
                    "vaHex": f"0x{va:08x}" if va is not None else "",
                    "contextBytes": hex_bytes(exe[max(0, off - 12) : off + len(enc) + 24], 64),
                }
            )
            start = off + 1
    return rows


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    sections = read_sections(exe)
    story_prompts = load_json(args.story_prompts, {})
    scene_text = load_json(args.scene_text_sequence, {})
    prompts = prompt_map(story_prompts)

    selector = parse_selector(exe, sections, scene_text)
    pattern_hits = parse_score_pattern_hits(exe, sections)

    return {
        "scope": "dan-rank-system-review",
        "sourceArtifacts": {
            "exe": str(args.exe.relative_to(ROOT)),
            "storyPrompts": str(args.story_prompts.relative_to(ROOT)),
            "sceneTextSequence": str(args.scene_text_sequence.relative_to(ROOT)),
            "danFlagSource": "out/dan_rank_flag_source_review.json",
        },
        "summary": {
            "danSelectorConsumerStatus": "grounded",
            "danScoreSlot": "+0x64",
            "danScoreMode": "0x41",
            "danBranchRows": len(selector["branchRows"]),
            "danBranchPatternHits": selector["branchHitCount"],
            "danRawPatternHitsReclassified": 0,
            "danScoreWriterCandidates": pattern_hits["scoreWriterRowCount"],
            "danScoreProducerStatus": "grounded-generic-vm",
            "danScoreProducerBlock": pattern_hits["scoreProducerBlockVaRange"],
            "danMaxScore": pattern_hits["maxScoreComponents"]["total"],
            "fallthroughRank": RANK_TITLES[13],
            "formulaStatus": "consumer-and-producer-grounded; source flags and e2/e3 skill/equipment gates promoted",
            "perfectClearTextFound": bool(prompt_subset(prompts, PERFECT_PROMPT_IDS)),
            "nextReverseTraceTarget": "separate perfect-clear/password logic from Dan rank score logic",
        },
        "danSelector": selector,
        "danScorePatternHits": pattern_hits,
        "danScoreWriters": pattern_hits,
        "rankTextPrompts": prompt_subset(prompts, RANK_PROMPT_IDS),
        "conditionHintPrompts": prompt_subset(prompts, CONDITION_PROMPT_IDS),
        "perfectClearPrompts": prompt_subset(prompts, PERFECT_PROMPT_IDS),
        "rawTextHits": raw_text_hits(
            exe,
            sections,
            ["단외", "전설의 맹호", "권신", "퍼펙트 클리어", "나는야 완벽한 호랑이"],
        ),
        "reverseTracePlan": [
            {
                "step": "promote 0x10 41 64 rows in the Dan block as generic-VM score writers",
                "reason": "generic table 0x00440538 maps opcode 0x10 to byte arithmetic/write handler 0x00402d2e",
                "status": "done",
            },
            {
                "step": "keep save-selector 0x10 handler distinction",
                "reason": "the same raw bytes are not writer proof under save-selector table 0x00440720; dispatch context matters",
                "status": "done",
            },
            {
                "step": "tie remaining bit ids to named scenario flags",
                "reason": "score increments are grounded, and e8/e9/ea/ec/c7/f0/f1 are now tied to trial, tournament, 666F, and beast-route text groups",
                "status": "done",
            },
            {
                "step": "separate Dan evaluation from perfect-clear evaluation",
                "reason": "perfect clear text/password is nearby endgame content but not necessarily the same rank score slot",
                "status": "pending",
            },
        ],
    }


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:
    selector = report["danSelector"]
    pattern_hits = report["danScorePatternHits"]
    summary = report["summary"]

    branch_rows = []
    branch_rows.append(
        [
            "<strong>fallthrough</strong>",
            "<code>no 0x13 match</code>",
            h(selector["fallthrough"]["scoreValueHypothesis"]),
            h(selector["fallthrough"]["displayTitle"]),
            h(selector["fallthrough"]["evidence"]),
        ]
    )
    for row in selector["branchRows"]:
        branch_rows.append(
            [
                f"<code>{h(row['opcodeVaHex'])}</code>",
                f"<code>{h(row['opcodeBytes'])}</code>",
                h(row["compareValue"]),
                h(row["displayTitle"]),
                f"<code>{h(row['targetVaHex'])}</code><br><span class=\"muted\">text {h(row['targetTextVaHex'])}</span>",
            ]
        )

    pattern_rows = [
        [
            f"<code>{h(row['vaHex'])}</code>",
            f"<code>{h(row['rawBytes'])}</code><br><span class=\"muted\">dword {h(row['packedDwordHex'])}</span>",
            f"+{h(row['scoreDelta'])}",
            h(row["ruleLabel"]),
            h(row["guard"]),
            h(row["exclusiveGroup"]),
            f"<span class=\"tag {'good' if row['confidence'] == 'high' else 'warn'}\">{h(row['confidence'])}</span>",
        ]
        for row in pattern_hits["rows"]
    ]

    hint_rows = [
        [
            f"<code>{h(row['id'])}</code>",
            f"<code>{h(row['startVaHex'])}</code>",
            h(row["sample"]),
        ]
        for row in report["conditionHintPrompts"]
    ]

    perfect_rows = [
        [
            f"<code>{h(row['id'])}</code>",
            f"<code>{h(row['startVaHex'])}</code>",
            h(row["sample"]),
        ]
        for row in report["perfectClearPrompts"]
    ]

    text_hit_rows = [
        [
            h(row["term"]),
            f"<code>{h(row['vaHex'])}</code>",
            f"<code>{h(row['fileOffsetHex'])}</code>",
        ]
        for row in report["rawTextHits"]
    ]

    plan_rows = [
        [h(row["step"]), h(row["reason"]), f"<span class=\"tag warn\">{h(row['status'])}</span>"]
        for row in report["reverseTracePlan"]
    ]

    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 {{
        color-scheme: light;
        --bg: #f6f7f9;
        --fg: #17202a;
        --muted: #657282;
        --line: #d8dee6;
        --head: #eef2f6;
        --good: #0f766e;
        --warn: #a15c00;
      }}
      * {{ box-sizing: border-box; }}
      body {{
        margin: 0;
        background: var(--bg);
        color: var(--fg);
        font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;
        line-height: 1.45;
      }}
      main {{ max-width: 1440px; margin: 0 auto; padding: 18px; }}
      header {{ display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }}
      h1 {{ margin: 0 0 6px; font-size: 24px; }}
      h2 {{ margin: 0; font-size: 18px; }}
      a {{ color: #185abc; text-decoration: none; }}
      a:hover {{ text-decoration: underline; }}
      .sub, .muted {{ color: var(--muted); }}
      nav {{ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }}
      section {{
        background: white;
        border: 1px solid var(--line);
        border-radius: 8px;
        margin: 14px 0;
        overflow: hidden;
      }}
      .section-head {{
        display: flex;
        justify-content: space-between;
        gap: 12px;
        padding: 11px 12px;
        border-bottom: 1px solid var(--line);
        background: var(--head);
      }}
      .summary-grid {{
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
        gap: 10px;
        padding: 12px;
      }}
      .metric {{
        border: 1px solid var(--line);
        border-radius: 6px;
        padding: 10px;
        background: #fbfcfd;
      }}
      .metric strong {{ display: block; font-size: 18px; }}
      table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
      th, td {{ border-bottom: 1px solid var(--line); padding: 8px 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; word-break: break-all; }}
      .table-wrap {{ overflow-x: auto; }}
      .tag {{ display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; background: #fff; font-size: 12px; color: var(--muted); }}
      .tag.good {{ color: var(--good); border-color: #8ed5ca; background: #ecfdf9; }}
      .tag.warn {{ color: var(--warn); border-color: #f2ca8a; background: #fff8eb; }}
      .note {{ padding: 12px; margin: 0; }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <div>
          <h1>단 시스템 역추적 리뷰</h1>
          <p class=\"sub\">엔딩 단수 표시 소비자와 점수 writer 후보를 분리한 정적 EXE 근거.</p>
        </div>
        <nav>
          <a href=\"index.html\">관리 홈</a>
          <a href=\"scene_event_vm_review.html\">Scene/Event VM</a>
          <a href=\"skill_equipment_gate_review.html\">기술/장비 Gate</a>
          <a href=\"dan_rank_flag_source_review.html\">단 조건 flag 출처</a>
          <a href=\"../out/scene_text_sequence_review.html\">대사 시퀀스</a>
          <a href=\"../docs/DAN_RANK_SYSTEM_REVIEW.md\">요약 문서</a>
        </nav>
      </header>

      <section>
        <div class=\"section-head\"><h2>결론</h2><span class=\"tag good\">{h(summary['danSelectorConsumerStatus'])}</span></div>
        <div class=\"summary-grid\">
          <div class=\"metric\"><strong>{h(summary['danScoreSlot'])}</strong><span class=\"muted\">단수 selector slot</span></div>
          <div class=\"metric\"><strong>{h(summary['danBranchRows'])}</strong><span class=\"muted\">하위 단수 branch rows</span></div>
          <div class=\"metric\"><strong>{h(summary['danScoreWriterCandidates'])}</strong><span class=\"muted\">score writer rows</span></div>
          <div class=\"metric\"><strong>{h(summary['danMaxScore'])}</strong><span class=\"muted\">최대 점수</span></div>
          <div class=\"metric\"><strong>{h(summary['fallthroughRank'])}</strong><span class=\"muted\">branch miss/fallthrough 후보</span></div>
        </div>
        <p class=\"note\">단수 출력 소비자와 점수 산출 블록은 모두 확정 수준이다. <code>0x0043cbe8..0x0043ccd4</code> 권역은 일반 Event VM dispatch table <code>0x00440538</code>로 해석되며, 이 안의 <code>0x10 41 64 xx</code>는 <code>global[0x64] += xx</code> 가산 명령이다. 단, 동일 raw bytes는 save-selector slice table에서는 writer proof가 아니므로 dispatch context를 반드시 같이 봐야 한다.</p>
      </section>

      <section>
        <div class=\"section-head\"><h2>단수 선택 분기</h2><span class=\"tag good\">0x13 41 64</span></div>
        <div class=\"table-wrap\">{render_table(['opcode VA', 'bytes', '비교값', '표시 단수', 'target'], branch_rows)}</div>
      </section>

      <section>
        <div class=\"section-head\"><h2>점수 산출 블록</h2><span class=\"tag good\">0x10 41 64 writer grounded</span></div>
        <p class=\"note\">일반 VM opcode <code>0x10</code> handler(<code>{h(pattern_hits['genericOpcode10Handler']['handlerVaHex'])}</code>) 기준으로 low nibble <code>1</code>은 add, <code>0x40</code> 계열은 global slot 선택, <code>stream[2]=0x64</code>, <code>stream[3]=delta</code>이다. 따라서 아래 행은 단수 점수 <code>global[0x64]</code>에 가산된다. save-selector 전용 opcode 0x10 wrapper와 혼동하지 않도록 별도 caveat를 JSON에 남겼다.</p>
        <div class=\"table-wrap\">{render_table(['VA', 'raw bytes', '가산', '해석 라벨', 'guard', '배타 그룹', '신뢰도'], pattern_rows)}</div>
      </section>

      <section>
        <div class=\"section-head\"><h2>조건 힌트 대사</h2><span class=\"tag warn\">text-backed criteria</span></div>
        <div class=\"table-wrap\">{render_table(['prompt', 'VA', 'sample'], hint_rows)}</div>
      </section>

      <section>
        <div class=\"section-head\"><h2>퍼펙트 클리어 텍스트</h2><span class=\"tag warn\">separate consumer candidate</span></div>
        <p class=\"note\">퍼펙트 클리어/패스워드는 같은 엔딩 권역에 있지만, 현재 단수 selector slot과 동일 공식이라고 보지는 않는다. 별도 all-content completion consumer로 추적한다.</p>
        <div class=\"table-wrap\">{render_table(['prompt', 'VA', 'sample'], perfect_rows)}</div>
      </section>

      <section>
        <div class=\"section-head\"><h2>원문 텍스트 히트</h2><span class=\"tag good\">CP949 in EXE</span></div>
        <div class=\"table-wrap\">{render_table(['검색어', 'VA', 'file offset'], text_hit_rows)}</div>
      </section>

      <section>
        <div class=\"section-head\"><h2>다음 역추적</h2><span class=\"tag warn\">perfect clear pending</span></div>
        <div class=\"table-wrap\">{render_table(['작업', '이유', '상태'], plan_rows)}</div>
      </section>
    </main>
  </body>
</html>
"""


def render_markdown(report: dict[str, Any]) -> str:
    summary = report["summary"]
    selector = report["danSelector"]
    pattern_hits = report["danScorePatternHits"]
    lines = [
        "# Dan Rank System Review",
        "",
        "## Conclusion",
        "",
        f"- Dan selector consumer: **{summary['danSelectorConsumerStatus']}**",
        f"- Selector slot: `{summary['danScoreSlot']}` with mode `{summary['danScoreMode']}`",
        f"- Branch selector pattern: `{selector['branchPatternHex']}`",
        f"- Branch rows: `{summary['danBranchRows']}` for values `12..0`",
        f"- Max rank path: `{summary['fallthroughRank']}` is treated as branch miss/fallthrough.",
        f"- Score producer block: `{summary['danScoreProducerBlock']}`",
        f"- Score writer rows: `{summary['danScoreWriterCandidates']}` rows of `{pattern_hits['rawPatternHex']}`",
        f"- Max score: `{summary['danMaxScore']}`",
        "",
        "The display consumer and the score producer block are both grounded.",
        "Inside the generic Event VM table (`0x00440538`), `0x10 41 64 xx` decodes as `global[0x64] += xx` via handler `0x00402d2e`.",
        "The same raw bytes are not writer proof under the save-selector slice table, so dispatch-table context remains mandatory.",
        "The main source flags behind the grounded score gates are promoted in `dan_rank_flag_source_review`; the remaining split is perfect-clear/password logic versus Dan score logic.",
        "",
        "## Branch Rows",
        "",
        "| Compare | Display | Opcode VA | Target |",
        "|---:|---|---|---|",
        f"| fallthrough | {RANK_TITLES[13]} | n/a | first rank line |",
    ]
    for row in selector["branchRows"]:
        lines.append(
            f"| {row['compareValue']} | {row['displayTitle']} | `{row['opcodeVaHex']}` | `{row['targetVaHex']}` |"
        )
    lines.extend(
        [
            "",
            "## Score Producer Rows",
            "",
            "| VA | Delta | Rule label | Guard | Group | Confidence |",
            "|---|---:|---|---|---|---|",
        ]
    )
    for row in pattern_hits["rows"]:
        lines.append(
            f"| `{row['vaHex']}` | +{row['scoreDelta']} | {row['ruleLabel']} | `{row['guard']}` | {row['exclusiveGroup']} | {row['confidence']} |"
        )
    lines.extend(
        [
            "",
            "## Criteria Text Hints",
            "",
        ]
    )
    for row in report["conditionHintPrompts"]:
        lines.append(f"- `{row['id']}` `{row['startVaHex']}`: {short(row['sample'], 220)}")
    lines.extend(
        [
            "",
            "## Perfect Clear",
            "",
            "Perfect clear text/password is nearby endgame content, but remains separate from the Dan selector until a shared producer is proven.",
        ]
    )
    for row in report["perfectClearPrompts"]:
        lines.append(f"- `{row['id']}` `{row['startVaHex']}`: {short(row['sample'], 220)}")
    lines.extend(
        [
            "",
            "## Next Steps",
            "",
        ]
    )
    for row in report["reverseTracePlan"]:
        lines.append(f"- {row['step']}: {row['reason']} ({row['status']})")
    lines.append("")
    return "\n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--story-prompts", type=Path, default=OUT / "story_prompts.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 / "dan_rank_system_review.json")
    parser.add_argument("--html-out", type=Path, default=WEB / "dan_rank_system_review.html")
    parser.add_argument("--web-html-out", type=Path, default=WEB / "dan_rank_system_review.html")
    parser.add_argument("--md-out", type=Path, default=DOCS / "DAN_RANK_SYSTEM_REVIEW.md")
    args = parser.parse_args()

    report = build(args)
    write_json(args.json_out, report)
    html_text = render_html(report)
    write_text(args.html_out, html_text)
    write_text(args.web_html_out, html_text)
    write_text(args.md_out, render_markdown(report))
    print(
        f"wrote {args.json_out.relative_to(ROOT)}, {args.html_out.relative_to(ROOT)}, "
        f"{args.web_html_out.relative_to(ROOT)}, {args.md_out.relative_to(ROOT)}"
    )


if __name__ == "__main__":
    main()
