#!/usr/bin/env python3
"""Export the recovered monster HP-view stat table.

The table is grounded by the monster HP dword and a +0x34 boundary marker in
this exported HP-view.  The actor initializer uses an 8-byte wider row prefix;
see battle_actor_stat_layout_review for the actor struct mapping.  The monster
level lives in that prefix at actual row +0x06, which is HP-view -0x02 in this
export.
"""
from __future__ import annotations

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


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

IMAGE_BASE_DELTA = 0x00402000
STAT_TABLE_FILE_OFFSET = 0x00055D48
STAT_TABLE_VA = STAT_TABLE_FILE_OFFSET + IMAGE_BASE_DELTA
STAT_ROW_SIZE = 0x38
NAME_ID_BASE = 0x0205
NAME_POINTER_TABLE_VA = 0x004EC2C4
NAME_POINTER_MIN_VA = 0x004EC300
NAME_POINTER_MAX_VA = 0x004EC800

FRONT_NUMERIC_FIELD_OFFSETS = [0x04, 0x06, 0x08, 0x0A, 0x0C, 0x0E, 0x10, 0x12, 0x14]
RATE_BYTE_START = 0x16
RATE_BYTE_END = 0x28

FIELD_LAYOUT = {
    "confirmed": [
        {"offsetHex": "HP-view -0x02 / actual +0x06", "width": "u16", "label": "level"},
        {"offsetHex": "+0x00", "width": "u32", "label": "HP"},
        {"offsetHex": "+0x2e", "width": "u16", "label": "gold"},
        {"offsetHex": "+0x34", "width": "u16-byte-swapped", "label": "name id boundary marker"},
    ],
    "candidate": [
        {
            "offsetRangeHex": "+0x04..+0x14",
            "width": "u16 x9",
            "label": "front numeric battle-stat candidates",
        },
        {
            "offsetRangeHex": "+0x16..+0x27",
            "width": "u8 x18",
            "label": "rate/resistance-style byte candidates",
        },
        {
            "offsetHex": "+0x36",
            "width": "u16",
            "label": "next-row prefix level; not current-row data",
        },
    ],
    "reservedZero": [
        {"offsetHex": "+0x02", "width": "u16"},
        {"offsetRangeHex": "+0x28..+0x2c", "width": "u16 x3"},
        {"offsetRangeHex": "+0x30..+0x32", "width": "u16 x2"},
    ],
}


def va_to_file_offset(va: int) -> int:
    return va - IMAGE_BASE_DELTA


def read_u16(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset:offset + 2], "little")


def read_u32(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset:offset + 4], "little")


def swap_u16(value: int) -> int:
    return ((value & 0xFF) << 8) | (value >> 8)


def hex_va(value: int) -> str:
    return f"0x{value:08x}"


def hex_off(value: int) -> str:
    return f"0x{value:06x}"


def clean_name(value: str) -> str:
    return value.replace("\u3000", "").strip()


def read_marker_string(data: bytes, va: int) -> str:
    offset = va_to_file_offset(va)
    if offset < 0 or offset >= len(data):
        return ""
    raw = data[offset:offset + 64]
    stop = len(raw)
    for marker in (b"@", b"\0"):
        index = raw.find(marker)
        if index >= 0:
            stop = min(stop, index)
    try:
        return raw[:stop].strip().decode("cp949")
    except UnicodeDecodeError:
        return ""


def read_name_pointer_table(data: bytes) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    table_offset = va_to_file_offset(NAME_POINTER_TABLE_VA)
    index = 0
    while table_offset + index * 4 + 4 <= len(data):
        ptr = read_u32(data, table_offset + index * 4)
        if not (NAME_POINTER_MIN_VA <= ptr < NAME_POINTER_MAX_VA):
            break
        name = read_marker_string(data, ptr)
        if not name or not any("\uac00" <= char <= "\ud7a3" for char in name):
            break
        name_id = NAME_ID_BASE + index
        rows.append({
            "index": index,
            "nameId": name_id,
            "nameIdHex": f"0x{name_id:04x}",
            "pointerTableVa": NAME_POINTER_TABLE_VA + index * 4,
            "pointerTableVaHex": hex_va(NAME_POINTER_TABLE_VA + index * 4),
            "nameVa": ptr,
            "nameVaHex": hex_va(ptr),
            "name": name,
            "cleanName": clean_name(name),
        })
        index += 1
    return rows


def stat_words(data: bytes, offset: int) -> list[dict[str, Any]]:
    words = []
    for word_offset in range(0, STAT_ROW_SIZE, 2):
        value = read_u16(data, offset + word_offset)
        words.append({
            "offset": word_offset,
            "offsetHex": f"+0x{word_offset:02x}",
            "value": value,
            "hex": f"0x{value:04x}",
        })
    return words


def field_breakdown(data: bytes, offset: int) -> dict[str, Any]:
    blob = data[offset:offset + STAT_ROW_SIZE]
    front_fields = [
        {
            "offset": field_offset,
            "offsetHex": f"+0x{field_offset:02x}",
            "key": f"u16_{field_offset:02x}",
            "label": f"u16 +0x{field_offset:02x}",
            "value": read_u16(data, offset + field_offset),
            "hex": f"0x{read_u16(data, offset + field_offset):04x}",
            "status": "candidate-front-numeric-field",
        }
        for field_offset in FRONT_NUMERIC_FIELD_OFFSETS
    ]
    rate_bytes = [
        {
            "offset": field_offset,
            "offsetHex": f"+0x{field_offset:02x}",
            "key": f"rate_{field_offset - RATE_BYTE_START:02d}",
            "label": f"rate byte {field_offset - RATE_BYTE_START:02d}",
            "value": blob[field_offset],
            "hex": f"0x{blob[field_offset]:02x}",
            "status": "candidate-rate-or-resistance-byte",
        }
        for field_offset in range(RATE_BYTE_START, RATE_BYTE_END)
    ]
    tail_value = read_u16(data, offset + 0x36)
    level = read_u16(data, offset - 2)
    return {
        "confirmedFields": [
            {
                "label": "level",
                "offsetHex": "HP-view -0x02 / actual +0x06",
                "width": "u16",
                "value": level,
            },
            {"label": "HP", "offsetHex": "+0x00", "width": "u32", "value": read_u32(data, offset)},
            {"label": "gold", "offsetHex": "+0x2e", "width": "u16", "value": read_u16(data, offset + 0x2E)},
            {
                "label": "name id",
                "offsetHex": "+0x34",
                "width": "u16-byte-swapped",
                "value": swap_u16(read_u16(data, offset + 0x34)),
                "rawHex": f"0x{read_u16(data, offset + 0x34):04x}",
            },
        ],
        "frontNumericFields": front_fields,
        "rateByteFields": rate_bytes,
        "tailCandidateField": {
            "offset": 0x36,
            "offsetHex": "+0x36",
            "key": "u16_36",
            "label": "next-row prefix level",
            "value": tail_value,
            "hex": f"0x{tail_value:04x}",
            "status": "next-row-boundary-field-not-current-row-level",
        },
        "frontNumericSummary": " ".join(f"+{row['offset']:02x}={row['value']}" for row in front_fields),
        "rateByteSummary": " ".join(f"{row['value']}" for row in rate_bytes),
        "candidateFieldStatus": "structured-candidate-fields-not-promoted-to-formula-labels",
    }


def read_stat_rows(data: bytes, names: list[dict[str, Any]]) -> list[dict[str, Any]]:
    name_by_id = {int(row["nameId"]): row for row in names}
    rows: list[dict[str, Any]] = []
    index = 0
    while STAT_TABLE_FILE_OFFSET + (index + 1) * STAT_ROW_SIZE <= len(data):
        offset = STAT_TABLE_FILE_OFFSET + index * STAT_ROW_SIZE
        actor_prefix_offset = offset - 8
        raw_name_id = read_u16(data, offset + 0x34)
        name_id = swap_u16(raw_name_id)
        expected_id = NAME_ID_BASE + index
        if name_id != expected_id:
            break
        name_row = name_by_id.get(name_id)
        if not name_row:
            break
        hp = read_u32(data, offset)
        gold = read_u32(data, offset + 0x2E)
        level = read_u16(data, offset - 2)
        actor_kind_byte = data[offset - 4]
        actor_subtype_byte = data[offset - 3]
        words = stat_words(data, offset)
        breakdown = field_breakdown(data, offset)
        rows.append({
            "index": index,
            "actorPrefixFileOffset": actor_prefix_offset,
            "actorPrefixFileOffsetHex": hex_off(actor_prefix_offset),
            "actorPrefixVa": actor_prefix_offset + IMAGE_BASE_DELTA,
            "actorPrefixVaHex": hex_va(actor_prefix_offset + IMAGE_BASE_DELTA),
            "actorKindByte": actor_kind_byte,
            "actorKindByteHex": f"0x{actor_kind_byte:02x}",
            "actorSubtypeByte": actor_subtype_byte,
            "actorSubtypeByteHex": f"0x{actor_subtype_byte:02x}",
            "fileOffset": offset,
            "fileOffsetHex": hex_off(offset),
            "va": offset + IMAGE_BASE_DELTA,
            "vaHex": hex_va(offset + IMAGE_BASE_DELTA),
            "rowSize": STAT_ROW_SIZE,
            "rowSizeHex": f"0x{STAT_ROW_SIZE:02x}",
            "name": name_row["name"],
            "cleanName": name_row["cleanName"],
            "nameId": name_id,
            "nameIdHex": f"0x{name_id:04x}",
            "rawNameId": raw_name_id,
            "rawNameIdHex": f"0x{raw_name_id:04x}",
            "nameVa": name_row["nameVa"],
            "nameVaHex": name_row["nameVaHex"],
            "namePointerTableVa": name_row["pointerTableVa"],
            "namePointerTableVaHex": name_row["pointerTableVaHex"],
            "hp": hp,
            "gold": gold,
            "level": level,
            "levelHex": f"0x{level:04x}",
            "levelSource": "actual stat row +0x06 / exported HP-view -0x02; copied to actor +0x06 and consumed by EXP formula 0x0042157b",
            "field2Hex": f"0x{read_u16(data, offset + 0x36):04x}",
            "hpViewPlus36NextRowLevelCandidate": read_u16(data, offset + 0x36),
            "hpViewPlus36NextRowLevelCandidateHex": f"0x{read_u16(data, offset + 0x36):04x}",
            "hpViewPlus36Meaning": "next actual row prefix +0x06 level; shifted by one row, not this monster level",
            "words": words,
            "wordHex": " ".join(word["hex"][2:] for word in words),
            **breakdown,
        })
        index += 1
    occurrence_counts: dict[str, int] = {}
    for row in rows:
        key = str(row["cleanName"])
        occurrence_counts[key] = occurrence_counts.get(key, 0) + 1
        row["nameOccurrence"] = occurrence_counts[key]
    total_by_name: dict[str, int] = {}
    for row in rows:
        key = str(row["cleanName"])
        total_by_name[key] = total_by_name.get(key, 0) + 1
    for row in rows:
        row["nameDuplicateCount"] = total_by_name[str(row["cleanName"])]
    return rows


def trailing_candidate_block(data: bytes, index: int, name_row: dict[str, Any]) -> dict[str, Any] | None:
    offset = STAT_TABLE_FILE_OFFSET + index * STAT_ROW_SIZE
    if offset + STAT_ROW_SIZE > len(data):
        return None
    hp = read_u32(data, offset)
    if not (1 <= hp <= 10000):
        return None
    raw_name_id = read_u16(data, offset + 0x34)
    swapped_name_id = swap_u16(raw_name_id)
    words = stat_words(data, offset)
    return {
        "status": "trailing-raw-block-candidate",
        "reason": "The block starts where the next 0x38 stat row would start and has a plausible HP-like first value, but it lacks the row-local byte-swapped name id at +0x34.",
        "index": index,
        "fileOffset": offset,
        "fileOffsetHex": hex_off(offset),
        "va": offset + IMAGE_BASE_DELTA,
        "vaHex": hex_va(offset + IMAGE_BASE_DELTA),
        "name": name_row["name"],
        "cleanName": name_row["cleanName"],
        "nameId": name_row["nameId"],
        "nameIdHex": name_row["nameIdHex"],
        "hpCandidate": hp,
        "hpCandidateHex": f"0x{hp:08x}",
        "u16At2e": read_u16(data, offset + 0x2E),
        "u16At2eHex": f"0x{read_u16(data, offset + 0x2E):04x}",
        "rawNameIdAt34": raw_name_id,
        "rawNameIdAt34Hex": f"0x{raw_name_id:04x}",
        "swappedNameIdAt34": swapped_name_id,
        "swappedNameIdAt34Hex": f"0x{swapped_name_id:04x}",
        "field36Hex": f"0x{read_u16(data, offset + 0x36):04x}",
        "words": words,
        "wordHex": " ".join(word["hex"][2:] for word in words),
    }


def attach_name_only_candidates(data: bytes, rows: list[dict[str, Any]], name_only: list[dict[str, Any]]) -> None:
    for offset_index, row in enumerate(name_only):
        candidate = trailing_candidate_block(data, len(rows) + offset_index, row)
        if candidate:
            row["trailingCandidateBlock"] = candidate


def build_summary(data: bytes) -> dict[str, Any]:
    names = read_name_pointer_table(data)
    rows = read_stat_rows(data, names)
    name_only = names[len(rows):]
    attach_name_only_candidates(data, rows, name_only)
    duplicate_names = sorted({
        row["cleanName"]
        for row in rows
        if row.get("nameDuplicateCount", 0) > 1
    })
    return {
        "scope": "Monster stat HP-view rows recovered from Hwanse2.exe by HP/gold fields and the +0x34 name-id boundary marker.",
        "source": ["Hwanse2.exe"],
        "promotionStatus": "enemy-stat-table-name-hp-gold-grounded",
        "table": {
            "statTableFileOffset": STAT_TABLE_FILE_OFFSET,
            "statTableFileOffsetHex": hex_off(STAT_TABLE_FILE_OFFSET),
            "statTableVa": STAT_TABLE_VA,
            "statTableVaHex": hex_va(STAT_TABLE_VA),
            "rowSize": STAT_ROW_SIZE,
            "rowSizeHex": f"0x{STAT_ROW_SIZE:02x}",
            "nameIdBase": NAME_ID_BASE,
            "nameIdBaseHex": f"0x{NAME_ID_BASE:04x}",
            "namePointerTableVa": NAME_POINTER_TABLE_VA,
            "namePointerTableVaHex": hex_va(NAME_POINTER_TABLE_VA),
        },
        "rowCount": len(rows),
        "namePointerCount": len(names),
        "nameOnlyCount": len(name_only),
        "duplicateNames": duplicate_names,
        "fieldLayout": FIELD_LAYOUT,
        "confirmedFieldCountPerRow": 4,
        "candidateFrontNumericFieldCountPerRow": len(FRONT_NUMERIC_FIELD_OFFSETS),
        "candidateRateByteFieldCountPerRow": RATE_BYTE_END - RATE_BYTE_START,
        "rows": rows,
        "namePointers": names,
        "nameOnlyEntries": name_only,
        "checks": {
            "continuousStatRowsRecovered": len(rows) == 80,
            "namePointersRecovered": len(names) == 81,
            "rowStrideIs0x38": STAT_ROW_SIZE == 0x38,
            "hpOffsetHex": "+0x00",
            "levelOffsetHex": "HP-view -0x02 / actual +0x06 / actor +0x06",
            "goldOffsetHex": "+0x2e",
            "nameIdOffsetHex": "+0x34",
            "hpViewPlus36IsNextRowPrefixLevel": True,
            "candidateFrontNumericRangeHex": "+0x04..+0x14",
            "candidateRateByteRangeHex": "+0x16..+0x27",
            "firstRowName": rows[0]["cleanName"] if rows else "",
            "lastContinuousRowName": rows[-1]["cleanName"] if rows else "",
            "trailingNameOnlyEntries": [row["cleanName"] for row in name_only],
            "trailingCandidateBlocks": [
                {
                    "name": row["cleanName"],
                    "nameIdHex": row["nameIdHex"],
                    "vaHex": row["trailingCandidateBlock"]["vaHex"],
                    "hpCandidate": row["trailingCandidateBlock"]["hpCandidate"],
                }
                for row in name_only
                if row.get("trailingCandidateBlock")
            ],
        },
        "notes": [
            "HP and gold are grounded from the recovered stat row layout.",
            "In this exported HP-view, the name id boundary marker is stored byte-swapped at +0x34, e.g. bytes 26 02 mean logical id 0x0226. The actor initializer uses an 8-byte wider row prefix, so +0x34 is not a current actor struct field.",
            "Monster level is recovered from the actor row prefix at actual row +0x06. Because this export starts at actual row +0x08(HP), that is HP-view -0x02.",
            "HP-view +0x36 crosses into the next actual row prefix and reads the next monster's level. It is kept only as a boundary diagnostic and must not be used as the current monster level.",
            "One trailing name pointer, 폭호 id 0x0255, is present after the continuous stat rows. The next raw 0x38-sized block starts with 2000, matching the known special 폭호 HP variant, but it is kept as a candidate because +0x34 is not the expected byte-swapped name id.",
            "Sprite binding, formation, encounter, reward routing, and combat formulas are separate tables and are not promoted by this report.",
            "+0x04..+0x14 and +0x16..+0x27 are structured as candidate numeric/rate fields in this HP-view. Use battle_actor_stat_layout_review to map them to actor offsets.",
        ],
    }


def js_json(value: Any) -> str:
    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


def html_page(summary: dict[str, Any]) -> str:
    table = summary["table"]
    check_rows = "".join(
        "<tr>"
        f"<td>{html.escape(key)}</td>"
        f"<td><code>{html.escape(str(value))}</code></td>"
        "</tr>"
        for key, value in summary["checks"].items()
    )
    row_html = "".join(
        "<tr>"
        f"<td>{row['index']}</td>"
        f"<td>{html.escape(row['cleanName'])}</td>"
        f"<td>{row['level']}</td>"
        f"<td>{row['hp']}</td>"
        f"<td>{row['gold']}</td>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td><code>{html.escape(row['nameIdHex'])}</code></td>"
        f"<td><code>{html.escape(row['frontNumericSummary'])}</code></td>"
        f"<td><code>{html.escape(row['rateByteSummary'])}</code></td>"
        f"<td><code>{html.escape(row['field2Hex'])}</code></td>"
        f"<td><code>{html.escape(row['wordHex'])}</code></td>"
        "</tr>"
        for row in summary["rows"]
    )
    name_only_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row['nameIdHex'])}</code></td>"
        f"<td>{html.escape(row['cleanName'])}</td>"
        f"<td><code>{html.escape(row['nameVaHex'])}</code></td>"
        f"<td>{html.escape('HP candidate ' + str(row.get('trailingCandidateBlock', {}).get('hpCandidate')) if row.get('trailingCandidateBlock') else '')}</td>"
        "</tr>"
        for row in summary["nameOnlyEntries"]
    )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Enemy Stat Table</title>",
        "  <style>body{font-family:system-ui,sans-serif;margin:24px;line-height:1.45;max-width:1280px}table{border-collapse:collapse;width:100%;margin:16px 0}td,th{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f5f5f5}code{white-space:nowrap}td:last-child code{white-space:normal}</style>",
        "</head>",
        "<body>",
        "  <h1>Enemy Stat Table</h1>",
        f"  <p>{html.escape(summary['scope'])}</p>",
        f"  <p>Table <code>{html.escape(table['statTableVaHex'])}</code>, row size <code>{html.escape(table['rowSizeHex'])}</code>, rows {summary['rowCount']}.</p>",
        "  <h2>Checks</h2>",
        f"  <table><thead><tr><th>check</th><th>value</th></tr></thead><tbody>{check_rows}</tbody></table>",
        "  <h2>Rows</h2>",
        f"  <table><thead><tr><th>#</th><th>name</th><th>Lv</th><th>HP</th><th>gold</th><th>row VA</th><th>name id</th><th>candidate front fields</th><th>candidate rate bytes</th><th>+0x36 next Lv</th><th>raw words</th></tr></thead><tbody>{row_html}</tbody></table>",
        "  <h2>Name-Only Entries</h2>",
        f"  <table><thead><tr><th>name id</th><th>name</th><th>name VA</th><th>candidate</th></tr></thead><tbody>{name_only_rows}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict[str, Any], out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "enemy_stat_table.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "enemy_stat_table.html").write_text(html_page(summary), encoding="utf-8")
    (out_dir / "enemy_stat_table.js").write_text(
        "window.HWANSE_ENEMY_STAT_TABLE = "
        + js_json({
            "promotionStatus": summary["promotionStatus"],
            "scope": summary["scope"],
            "table": summary["table"],
            "rowCount": summary["rowCount"],
            "namePointerCount": summary["namePointerCount"],
            "nameOnlyCount": summary["nameOnlyCount"],
            "duplicateNames": summary["duplicateNames"],
            "fieldLayout": summary["fieldLayout"],
            "confirmedFieldCountPerRow": summary["confirmedFieldCountPerRow"],
            "candidateFrontNumericFieldCountPerRow": summary["candidateFrontNumericFieldCountPerRow"],
            "candidateRateByteFieldCountPerRow": summary["candidateRateByteFieldCountPerRow"],
            "checks": summary["checks"],
            "rows": summary["rows"],
            "nameOnlyEntries": summary["nameOnlyEntries"],
            "notes": summary["notes"],
        })
        + ";\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    data = args.exe.read_bytes()
    summary = build_summary(data)
    write_outputs(summary, args.out_dir)
    print(f"wrote enemy stat table -> {args.out_dir / 'enemy_stat_table.html'}")


if __name__ == "__main__":
    main()
