#!/usr/bin/env python3
"""Summarize battle command/skill records and their inline payload bytes."""
from __future__ import annotations

import argparse
import json
import struct
from collections import Counter
from pathlib import Path

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


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

RECORD_TABLE_VA = 0x004D2974
RECORD_STRIDE = 8
POINTER_MIN = 0x004D4000
POINTER_MAX = 0x004D6000
NAME_BYTES = 16

PLAYER_COMMAND_SOURCE_HINTS = {
    0: "플레이어 커맨드: 도주",
    1: "플레이어 커맨드: 방어",
    2: "플레이어 특수기: 눈요기",
    3: "플레이어 특수기: 인법·몸감추기",
    4: "플레이어 장비 특수기: 신격방어",
}


def hex32(value: int | None) -> str | None:
    return None if value is None else f"0x{value:08x}"


def read_name(data: bytes, sections: list[dict], va: int) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    raw = data[offset : offset + NAME_BYTES]
    return raw.decode("cp949", "ignore").replace("\u3000", " ").strip()


def read_record(data: bytes, sections: list[dict], index: int) -> dict | None:
    va = RECORD_TABLE_VA + index * RECORD_STRIDE
    offset = va_to_offset(sections, va)
    if offset is None or offset + 8 > len(data):
        return None
    ptr, meta = struct.unpack_from("<II", data, offset)
    if ptr == 0:
        return {
            "index": index,
            "recordVa": va,
            "recordVaHex": hex32(va),
            "payloadVa": 0,
            "payloadVaHex": "0x00000000",
            "meta": meta,
            "metaHex": hex32(meta),
            "name": "",
            "empty": True,
        }
    if not (POINTER_MIN <= ptr <= POINTER_MAX):
        return None
    name = read_name(data, sections, ptr)
    if not name:
        return None
    return {
        "index": index,
        "recordVa": va,
        "recordVaHex": hex32(va),
        "payloadVa": ptr,
        "payloadVaHex": hex32(ptr),
        "meta": meta,
        "metaHex": hex32(meta),
        "iconSheet": meta >> 16,
        "iconCell": meta & 0xFFFF,
        "name": name,
        "empty": False,
    }


def parse_payload(data: bytes, sections: list[dict], row: dict, next_payload_va: int | None) -> dict:
    if row.get("empty"):
        return row
    payload_va = int(row["payloadVa"])
    offset = va_to_offset(sections, payload_va)
    if offset is None:
        return row
    length = None
    if next_payload_va and next_payload_va > payload_va:
        length = next_payload_va - payload_va
    raw = data[offset : offset + (length or 96)]
    separator = raw[NAME_BYTES : NAME_BYTES + 2]
    count = raw[NAME_BYTES + 2] if len(raw) > NAME_BYTES + 2 else None
    tail = raw[NAME_BYTES + 2 : length] if length else raw[NAME_BYTES + 2 : NAME_BYTES + 2 + 40]
    post_count_bytes = raw[NAME_BYTES + 3 : length] if length else raw[NAME_BYTES + 3 : NAME_BYTES + 3 + 40]
    expected_length = 22 + 8 * count if isinstance(count, int) else None
    sequence_prefix = post_count_bytes[:3]
    sequence_body = post_count_bytes[3 : 3 + (count or 0) * 8]
    sequence_units = [
        list(sequence_body[index : index + 8])
        for index in range(0, len(sequence_body), 8)
        if len(sequence_body[index : index + 8]) == 8
    ]
    row.update(
        {
            "recordLength": length,
            "recordLengthHex": f"0x{length:x}" if length is not None else None,
            "nameFieldBytesHex": raw[:NAME_BYTES].hex(" "),
            "separatorHex": separator.hex(" "),
            "costByteCandidate": raw[NAME_BYTES + 1] if len(raw) > NAME_BYTES + 1 else None,
            "sequenceCount": count,
            "tailBytesHex": tail.hex(" "),
            "postCountBytesHex": post_count_bytes.hex(" "),
            "sequencePrefixBytes": list(sequence_prefix),
            "sequencePrefixBytesHex": sequence_prefix.hex(" "),
            "sequenceUnits": sequence_units,
            "sequenceUnitsHex": [" ".join(f"{byte:02x}" for byte in unit) for unit in sequence_units],
            "expectedLengthFromCount": expected_length,
            "expectedLengthMatches": length == expected_length if length is not None and expected_length is not None else None,
        }
    )
    return row


def add_source_hint(row: dict) -> dict:
    if row.get("empty"):
        return row
    index = int(row.get("index", -1))
    if index in PLAYER_COMMAND_SOURCE_HINTS:
        row["sourceClass"] = "player-command-confirmed"
        row["sourceLabel"] = PLAYER_COMMAND_SOURCE_HINTS[index]
        row["costByteMeaning"] = "player-command-cost-candidate"
    else:
        row["sourceClass"] = "shared-battle-action"
        row["sourceLabel"] = "공용 전투 액션: 적/복사 적/스토리 전투가 참조 가능"
        row["costByteMeaning"] = "shared-action-cost-byte-unpromoted"
    return row


def find_data_refs(data: bytes, sections: list[dict], start: int, end: int) -> list[dict]:
    refs = []
    text_values = []
    for section in sections:
        raw_start = section["raw"]
        raw_end = section["raw"] + section["raw_size"]
        raw = data[raw_start:raw_end]
        step = 1 if section["name"] == ".text" else 4
        for index in range(0, max(0, len(raw) - 3), step):
            value = struct.unpack_from("<I", raw, index)[0]
            if not (start <= value <= end):
                continue
            ref_va = offset_to_va(sections, raw_start + index)
            if ref_va is None:
                continue
            row = {
                "section": section["name"],
                "refVa": ref_va,
                "refVaHex": hex32(ref_va),
                "value": value,
                "valueHex": hex32(value),
            }
            refs.append(row)
            if section["name"] == ".text":
                text_values.append(row)
    return refs


def build_consumer_summary(data: bytes, sections: list[dict]) -> dict:
    pointer_base_refs = find_data_refs(data, sections, 0x004D2488, 0x004D2494)
    skill_table_refs = find_data_refs(data, sections, RECORD_TABLE_VA, RECORD_TABLE_VA + 130 * RECORD_STRIDE)
    payload_refs = find_data_refs(data, sections, POINTER_MIN, POINTER_MAX)
    text_pointer_base_refs = [row for row in pointer_base_refs if row["section"] == ".text"]
    text_ref_values = Counter(row["valueHex"] for row in text_pointer_base_refs)
    return {
        "status": "consumer-narrowed-pointer-base-indexed-skill-records",
        "pointerBaseRangeHex": "0x004d2488..0x004d2494",
        "skillRecordRangeHex": f"{hex32(RECORD_TABLE_VA)}..{hex32(RECORD_TABLE_VA + 130 * RECORD_STRIDE)}",
        "payloadRangeHex": f"{hex32(POINTER_MIN)}..{hex32(POINTER_MAX)}",
        "textPointerBaseRefCount": len(text_pointer_base_refs),
        "textPointerBaseRefValues": dict(sorted(text_ref_values.items())),
        "textPointerBaseRefs": text_pointer_base_refs[:80],
        "skillRecordTextRefCount": sum(1 for row in skill_table_refs if row["section"] == ".text"),
        "skillRecordDataRefCount": sum(1 for row in skill_table_refs if row["section"] == ".data"),
        "payloadTextRefCount": sum(1 for row in payload_refs if row["section"] == ".text"),
        "payloadDataRefCount": sum(1 for row in payload_refs if row["section"] == ".data"),
        "notes": [
            "No direct .text immediate points at 0x004d2974 or the skill-record payload range.",
            "Code indexes pointer bases at 0x004d2488/0x004d2494, then indexes 8-byte records by an action id byte.",
            "Handler 0x0041ff44 reads the same pointer base family and remains the narrow battle-action consumer candidate.",
            "This narrows the consumer path but still does not bind an enemy stat row to a skill list.",
        ],
    }


def payload_field_analysis(rows: list[dict]) -> dict:
    units: list[list[int]] = []
    prefixes: list[list[int]] = []
    for row in rows:
        if row.get("empty"):
            continue
        prefix = row.get("sequencePrefixBytes") or []
        if len(prefix) == 3:
            prefixes.append(prefix)
        for unit in row.get("sequenceUnits") or []:
            if len(unit) == 8:
                units.append(unit)

    def distribution(values: list[int]) -> dict:
        counts = Counter(values)
        return {
            "uniqueCount": len(counts),
            "values": sorted(counts),
            "top": [
                {"value": value, "hex": f"0x{value:02x}", "count": count}
                for value, count in counts.most_common(20)
            ],
        }

    unit_fields = [
        ("coefA", "numeric-candidate", "첫 번째 계수 후보. 대체로 35~125, 비공격기는 0도 많다."),
        ("coefB", "numeric-candidate", "두 번째 계수 후보. 다단 unit의 후속 unit이나 보조 액션에서 0이 자주 나온다."),
        ("coefC", "numeric-candidate", "세 번째 계수 후보. 100/125/200/225/250 같은 백분율형 값이 많다."),
        ("reserved0", "reserved/zero", "현재 확인한 모든 unit에서 0이다."),
        ("targetScope", "enum", "0x01=자기/보조, 0x06=전체, 0x0a=개인으로 반복된다."),
        ("effectFamily", "enum", "물리/속성/회복/상태 계열을 나타내는 enum 후보이다."),
        ("auxMode", "small-enum-candidate", "0~3만 나온다. 일반/강타/상태부여 같은 보조 모드 후보이나 의미는 미확정이다."),
        ("status", "enum", "0=없음, 1=넘어짐, 2=휙날아감, 3=행동정지, 4=독, 5=마비로 보인다."),
    ]
    prefix_fields = [
        ("prefixA", "numeric-candidate", "액션 공통 계수/우선도 후보."),
        ("prefixB", "numeric-candidate", "액션 공통 계수/명중·속도 후보."),
        ("prefixC", "numeric-candidate", "액션 공통 계수/연출·후딜 후보."),
    ]
    return {
        "status": "field-split-hypothesis",
        "unitCount": len(units),
        "prefixCount": len(prefixes),
        "costByteDistribution": distribution([
            int(row.get("costByteCandidate") or 0)
            for row in rows
            if not row.get("empty") and row.get("costByteCandidate") is not None
        ]),
        "prefixFields": [
            {
                "offset": index,
                "name": name,
                "kind": kind,
                "note": note,
                "distribution": distribution([prefix[index] for prefix in prefixes]),
            }
            for index, (name, kind, note) in enumerate(prefix_fields)
        ],
        "unitFields": [
            {
                "offset": index,
                "name": name,
                "kind": kind,
                "note": note,
                "distribution": distribution([unit[index] for unit in units]),
            }
            for index, (name, kind, note) in enumerate(unit_fields)
        ],
        "scopeDistribution": distribution([unit[4] for unit in units]),
        "familyDistribution": distribution([unit[5] for unit in units]),
        "auxDistribution": distribution([unit[6] for unit in units]),
        "statusDistribution": distribution([unit[7] for unit in units]),
        "notes": [
            "The payload appears to mix numeric coefficient candidates and enum-like fields.",
            "The table mixes confirmed player command rows and shared battle action rows. The byte after the 0xff name terminator is a cost-like byte, but it is not promoted as global monster MP.",
            "Rows 0..4 are confirmed player-side command/skill actions: 도주, 방어, 눈요기, 인법·몸감추기, 신격방어.",
            "sequenceCount is the attack/effect count field. For offensive actions it is the attack count; support/system commands use the same count field for their effect step.",
            "Target scope is the strongest split: 0x01 self/support, 0x06 all-target, 0x0a single-target.",
            "Effect family and status bytes are enum-like rather than bitmask-like in the observed corpus.",
            "The first three prefix bytes and the first three unit bytes look like percentage/timing/power coefficients, but exact formulas remain unpromoted.",
            "Unit byte 3 is always zero in the current 182 decoded units and should be treated as reserved until runtime evidence says otherwise.",
        ],
    }


def build_summary(data: bytes) -> dict:
    sections = read_sections(data)
    rows = []
    for index in range(160):
        row = read_record(data, sections, index)
        if row is None:
            if index > 90:
                break
            continue
        rows.append(row)
    payload_vas = sorted(row["payloadVa"] for row in rows if not row.get("empty") and row.get("payloadVa"))
    for row in rows:
        if row.get("empty"):
            continue
        next_vas = [va for va in payload_vas if va > row["payloadVa"]]
        parse_payload(data, sections, row, next_vas[0] if next_vas else None)
        add_source_hint(row)

    non_empty = [row for row in rows if not row.get("empty")]
    count_distribution = Counter(row.get("sequenceCount") for row in non_empty)
    length_check_rows = [row for row in non_empty if row.get("recordLength") is not None]
    return {
        "scope": "Battle action payload records from Hwanse2.exe. This is a shared battle-action table: rows 0..4 are confirmed player command/skill actions, while later rows include enemy actions and player-party skill payloads reused by copy/story enemies such as 바니 or enemy 린샹.",
        "status": "battle-action-payload-grounded-monster-binding-resolved-by-action-catalog",
        "recordTableVa": RECORD_TABLE_VA,
        "recordTableVaHex": hex32(RECORD_TABLE_VA),
        "recordStride": RECORD_STRIDE,
        "rowCount": len(rows),
        "nonEmptyRowCount": len(non_empty),
        "emptyRowCount": sum(1 for row in rows if row.get("empty")),
        "sequenceCountDistribution": {str(key): value for key, value in sorted(count_distribution.items())},
        "expectedLengthMatchCount": sum(1 for row in length_check_rows if row.get("expectedLengthMatches")),
        "expectedLengthCheckedCount": len(length_check_rows),
        "fieldAnalysis": payload_field_analysis(rows),
        "consumerSummary": build_consumer_summary(data, sections),
        "notes": [
            "The byte after the 16-byte name and ff/cost bytes behaves like a variable attack/effect count.",
            "For checked records, length follows 22 + 8 * sequenceCount, e.g. attack/effect count 1 -> 0x1e, count 3 -> 0x2e, count 8 -> 0x56.",
            "This table grounds shared battle-action effect payloads. Monster-specific action selection is resolved separately by battle_monster_action_catalog.json through descriptor script3 VM -> actor +0x59 shared action id and actor +0x5a visible slot.",
            "Rows 0..4 are player-side command/skill actions. Player-party skill names in later rows are also expected because copy enemies such as 바니 and story enemy forms such as 린샹 can reuse player action payloads.",
            "Enemy stat rows contain many percentage-like bytes such as 100, 125, 75, 200; those look like coefficients/resistances rather than direct skill IDs.",
        ],
        "rows": rows,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Battle Skill Records",
        "",
        summary["scope"],
        "",
        f"- status: `{summary['status']}`",
        f"- record table: `{summary['recordTableVaHex']}`",
        f"- rows: {summary['rowCount']} ({summary['nonEmptyRowCount']} non-empty)",
        f"- sequence-count length checks: {summary['expectedLengthMatchCount']}/{summary['expectedLengthCheckedCount']}",
        "",
        "## Attack/Effect Count Distribution",
        "",
        "| attack/effect count | records |",
        "| ---: | ---: |",
    ]
    for key, value in summary["sequenceCountDistribution"].items():
        lines.append(f"| {key} | {value} |")
    field_analysis = summary.get("fieldAnalysis") or {}
    lines += [
        "",
        "## Payload Field Split",
        "",
        f"- status: `{field_analysis.get('status')}`",
        f"- decoded units: {field_analysis.get('unitCount')}",
        f"- decoded prefixes: {field_analysis.get('prefixCount')}",
        "",
        "### Cost Byte Candidate",
        "",
        "| value | records |",
        "| ---: | ---: |",
    ]
    for item in ((field_analysis.get("costByteDistribution") or {}).get("top") or []):
        lines.append(f"| `{item.get('hex')}` | {item.get('count')} |")
    lines += [
        "",
        "### Prefix/Unit Fields",
        "",
        "| field | kind | unique | common values | note |",
        "| --- | --- | ---: | --- | --- |",
    ]
    for field in (field_analysis.get("prefixFields") or []) + (field_analysis.get("unitFields") or []):
        dist = field.get("distribution") or {}
        top = ", ".join(
            f"{item.get('hex')}({item.get('count')})"
            for item in (dist.get("top") or [])[:8]
        )
        lines.append(
            f"| {field.get('name')} | {field.get('kind')} | {dist.get('uniqueCount')} | "
            f"{top} | {field.get('note')} |"
        )
    lines += ["", "### Field Split Notes", ""]
    lines += [f"- {note}" for note in field_analysis.get("notes") or []]
    lines += [
        "",
        "## Records",
        "",
        "| # | name | source | record | payload | len | units | cost? | meta | tail bytes |",
        "| ---: | --- | --- | --- | --- | ---: | ---: | ---: | --- | --- |",
    ]
    for row in summary["rows"]:
        if row.get("empty"):
            lines.append(
                f"| {row['index']} | `(empty)` | - | `{row['recordVaHex']}` | - | - | - | - | `{row['metaHex']}` | - |"
            )
            continue
        tail = row.get("tailBytesHex") or ""
        if len(tail) > 80:
            tail = tail[:80] + " ..."
        length = row.get("recordLength")
        lines.append(
            f"| {row['index']} | {row['name']} | {row.get('sourceLabel', '-')} | "
            f"`{row['recordVaHex']}` | `{row['payloadVaHex']}` | "
            f"{length if length is not None else '-'} | {row.get('sequenceCount')} | "
            f"{row.get('costByteCandidate', '-')} | `{row['metaHex']}` | `{tail}` |"
        )
    lines += ["", "## Notes", ""]
    lines += [f"- {note}" for note in summary["notes"]]
    consumer = summary.get("consumerSummary") or {}
    lines += [
        "",
        "## Consumer Trace",
        "",
        f"- status: `{consumer.get('status')}`",
        f"- pointer base refs in .text: `{consumer.get('textPointerBaseRefCount')}` / {consumer.get('textPointerBaseRefValues')}",
        f"- skill record direct .text refs: `{consumer.get('skillRecordTextRefCount')}`",
        f"- payload direct .text refs: `{consumer.get('payloadTextRefCount')}`",
        "",
        "| ref | value | section |",
        "| --- | --- | --- |",
    ]
    for row in (consumer.get("textPointerBaseRefs") or [])[:24]:
        lines.append(f"| `{row.get('refVaHex')}` | `{row.get('valueHex')}` | {row.get('section')} |")
    lines += ["", "### Consumer Notes", ""]
    lines += [f"- {note}" for note in consumer.get("notes") or []]
    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("--out", type=Path, default=OUT)
    parser.add_argument("--md-out", type=Path, help="Optional path for a human-readable markdown export.")
    args = parser.parse_args()
    data = args.exe.read_bytes()
    summary = build_summary(data)
    args.out.mkdir(parents=True, exist_ok=True)
    (args.out / "battle_skill_records.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (args.out / "battle_skill_records.js").write_text(
        "window.HWANSE_BATTLE_SKILL_RECORDS = "
        + json.dumps(summary, ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )
    if args.md_out:
        args.md_out.write_text(markdown(summary), encoding="utf-8")
    print("wrote out/battle_skill_records.{json,js}")


if __name__ == "__main__":
    main()
