#!/usr/bin/env python3
"""Parse known fields from Hwanse savedat*.dat files."""
from __future__ import annotations

import argparse
import json
import re
import struct
import sys
from pathlib import Path
from urllib.parse import urlencode

sys.path.insert(0, str(Path(__file__).resolve().parent))

from probe_exe_scene_tables import c_string, find_cns_strings, read_sections, va_to_offset


ITEM_FIELDS = [
    ("herb", "약초", 0x0015),
    ("item_2", "해독초", 0x0017),
    ("refresh_water", "리프레시 워터", 0x0019),
    ("mp_recovery", "마법의 물약", 0x001B),
    ("item_5", "고급한방약", 0x001D),
    ("item_6", "마수석", 0x001F),
]

EQUIPMENT_EVIDENCE = {
    "status": "offsets-unmapped",
    "source": "HDNua/HandyHwanseEditor",
    "notes": [
        "MainForm.cs only defines and reads/writes money, six item counts, character stat words, and packed skill bytes.",
        "MainForm.Designer.cs contains disabled equipment UI groups, so those labels are useful name candidates but not save-offset evidence.",
        "out/text_tables.* exports an EXE equipment-name candidate table, but savedat ownership/equipped offsets are still unknown.",
    ],
}

CHARACTERS = [
    {
        "key": "ataho",
        "name": "Ataho",
        "level": 0x0078,
        "hp": {"current": 0x007A, "recover": 0x007C, "maximum": 0x007E},
        "mp": {"current": 0x0080, "recover": 0x0082, "maximum": 0x0084},
        "experience": {"current": 0x0086, "maximum": 0x0088},
        "baseStats": {"atk": 0x0130, "def": 0x0134, "tech": 0x0136, "quick": 0x0138, "luck": 0x013A},
        "enhanceStats": {"atk": 0x008E, "def": 0x0092, "tech": 0x0094, "quick": 0x0096, "luck": 0x0098},
        "skills": {
            "individual": {"base": 0x00CE, "valueBases": [0x0A, 0x0E, 0x12, 0x16, 0x1A]},
            "group": {"base": 0x00D4, "valueBases": [0x23, 0x27, 0x2B, 0x2F, 0x33]},
        },
    },
    {
        "key": "rinshan",
        "name": "Rinshan",
        "level": 0x0150,
        "hp": {"current": 0x0152, "recover": 0x0154, "maximum": 0x0156},
        "mp": {"current": 0x0158, "recover": 0x015A, "maximum": 0x015C},
        "experience": {"current": 0x015E, "maximum": 0x0160},
        "baseStats": {"atk": 0x0208, "def": 0x020C, "tech": 0x020E, "quick": 0x0210, "luck": 0x0212},
        "enhanceStats": {"atk": 0x0166, "def": 0x016A, "tech": 0x016C, "quick": 0x016E, "luck": 0x0170},
        "skills": {
            "individual": {"base": 0x01A6, "valueBases": [0x09, 0x0D, 0x11, 0x15]},
            "group": {"base": 0x01AC, "valueBases": [0x1C, 0x20, 0x24, 0x28]},
        },
    },
    {
        "key": "smashu",
        "name": "Smashu",
        "level": 0x0228,
        "hp": {"current": 0x022A, "recover": 0x022C, "maximum": 0x022E},
        "mp": {"current": 0x0230, "recover": 0x0232, "maximum": 0x0234},
        "experience": {"current": 0x0236, "maximum": 0x0238},
        "baseStats": {"atk": 0x02E0, "def": 0x02E4, "tech": 0x02E6, "quick": 0x02E8, "luck": 0x02EA},
        "enhanceStats": {"atk": 0x023E, "def": 0x0242, "tech": 0x0244, "quick": 0x0246, "luck": 0x0248},
        "skills": {
            "individual": {"base": 0x027E, "valueBases": [0x05, 0x09, 0x0D]},
            "group": {"base": 0x0284, "valueBases": [0x14, 0x18, 0x1C]},
        },
    },
]

SAVE_BLOCKS = [
    {"name": "sceneAndInventory", "saveOffset": 0x0000, "size": 0x0072, "runtimeVa": 0x004576D8},
    {"name": "partyAndStats", "saveOffset": 0x0072, "size": 0x0288, "runtimeVa": 0x00457750},
    {"name": "tailState", "saveOffset": 0x02FA, "size": 0x0200, "runtimeVa": 0x0059DB60},
]

SCENE_SELECTOR_GROUP_OFFSET = 0x0002
SCENE_SELECTOR_SLOT_OFFSET = 0x0003
SCENE_POSITION_X_OFFSET = 0x0004
SCENE_POSITION_Y_OFFSET = 0x0006
ACTIVE_DESCRIPTOR_COUNT_OFFSET = 0x0010
ACTIVE_DESCRIPTOR_ORDER_OFFSET = 0x0011
ACTIVE_DESCRIPTOR_ORDER_SCAN_BYTES = 8
SCENE_SELECTOR_TABLE_VA = 0x00442D35
FIELD_MAP_RE = re.compile(r"map\d+_\d+[a-z]\.cns$")
MAP_INDEX_RE = re.compile(r"window\.HWANSE_MAP_INDEX = (.*?);\s*$", re.S)


def read_u8(data: bytes, offset: int) -> int:
    require_size(data, offset, 1)
    return data[offset]


def read_u16(data: bytes, offset: int) -> int:
    require_size(data, offset, 2)
    return data[offset] | (data[offset + 1] << 8)


def read_u24(data: bytes, offset: int) -> int:
    require_size(data, offset, 3)
    return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16)


def require_size(data: bytes, offset: int, size: int) -> None:
    if offset + size > len(data):
        raise ValueError(f"save data is too short for offset 0x{offset:04x}+{size}")


def hex_offset(offset: int) -> str:
    return f"0x{offset:04x}"


def value_record(data: bytes, offset: int, size: int, value: int) -> dict:
    return {
        "offset": offset,
        "offsetHex": hex_offset(offset),
        "size": size,
        "value": value,
    }


def load_map_index(path: Path | None) -> dict:
    if not path or not path.exists():
        return {}
    text = path.read_text(encoding="utf-8")
    match = MAP_INDEX_RE.search(text)
    if not match:
        raise ValueError(f"{path} does not contain window.HWANSE_MAP_INDEX")
    data = json.loads(match.group(1))
    if not isinstance(data, dict):
        raise ValueError(f"{path} map index must be an object")
    return data


def map_contains_tile(map_index: dict, name: str, x: int, y: int) -> bool:
    info = map_index.get(name)
    return (
        isinstance(info, dict)
        and isinstance(info.get("width"), int)
        and isinstance(info.get("height"), int)
        and 0 <= x < info["width"]
        and 0 <= y < info["height"]
    )


def choose_web_start(selector: dict, position: dict, map_index: dict) -> dict | None:
    resources = selector.get("linkedResources") or {}
    field_maps = resources.get("fieldMaps") or []
    if not field_maps:
        return None
    x = position["x"]["value"]
    y = position["y"]["value"]
    available = [name for name in field_maps if name in map_index]
    if not available:
        return None
    selected = next((name for name in available if map_contains_tile(map_index, name, x, y)), available[0])
    reason = "coordinate-in-bounds" if map_contains_tile(map_index, selected, x, y) else "first-available"
    query = {
        "saveGroup": str(selector["group"]["value"]),
        "saveSlot": str(selector["slot"]["value"]),
        "saveX": str(x),
        "saveY": str(y),
    }
    return {
        "map": selected,
        "reason": reason,
        "x": x,
        "y": y,
        "fieldMaps": field_maps,
        "availableFieldMaps": available,
        "url": f"/web/game.html?{urlencode(query)}",
        "explicitMapUrl": f"/web/game.html?{urlencode({'map': selected, 'startTile': f'{x},{y}'})}",
    }


def decode_skill_value(value: int, value_bases: list[int]) -> dict:
    if value == 0:
        return {"value": 0, "valueHex": "0x00", "learned": False}
    for index, base in enumerate(value_bases):
        if base <= value <= base + 3:
            return {
                "value": value,
                "valueHex": f"0x{value:02x}",
                "learned": True,
                "skillIndex": index,
                "level": value - base + 1,
                "baseValue": base,
                "baseValueHex": f"0x{base:02x}",
            }
    return {
        "value": value,
        "valueHex": f"0x{value:02x}",
        "learned": True,
        "unknown": True,
    }


def parse_skill_block(data: bytes, block: dict) -> dict:
    base = block["base"]
    value_bases = block["valueBases"]
    entries = []
    for slot in range(len(value_bases)):
        offset = base + slot
        value = read_u8(data, offset)
        decoded = decode_skill_value(value, value_bases)
        entries.append(
            {
                "slot": slot,
                "offset": offset,
                "offsetHex": hex_offset(offset),
                **decoded,
            }
        )
    return {
        "baseOffset": base,
        "baseOffsetHex": hex_offset(base),
        "slots": len(value_bases),
        "valueBases": [
            {"skillIndex": index, "value": value, "valueHex": f"0x{value:02x}"}
            for index, value in enumerate(value_bases)
        ],
        "entries": entries,
    }


def parse_character(data: bytes, spec: dict) -> dict:
    def read_group(group: dict) -> dict:
        return {
            name: value_record(data, offset, 2, read_u16(data, offset))
            for name, offset in group.items()
        }

    return {
        "key": spec["key"],
        "name": spec["name"],
        "level": value_record(data, spec["level"], 2, read_u16(data, spec["level"])),
        "hp": read_group(spec["hp"]),
        "mp": read_group(spec["mp"]),
        "experience": read_group(spec["experience"]),
        "baseStats": read_group(spec["baseStats"]),
        "enhanceStats": read_group(spec["enhanceStats"]),
        "skills": {
            name: parse_skill_block(data, block)
            for name, block in spec["skills"].items()
        },
    }


def parse_active_descriptor_order(data: bytes) -> dict:
    count = read_u8(data, ACTIVE_DESCRIPTOR_COUNT_OFFSET)
    raw_order = [
        read_u8(data, ACTIVE_DESCRIPTOR_ORDER_OFFSET + index)
        for index in range(ACTIVE_DESCRIPTOR_ORDER_SCAN_BYTES)
    ]
    active_order = raw_order[: min(count, len(raw_order))]
    return {
        "count": {
            **value_record(data, ACTIVE_DESCRIPTOR_COUNT_OFFSET, 1, count),
            "runtimeVa": 0x004576E8,
            "runtimeVaHex": "0x004576e8",
        },
        "orderBytes": [
            {
                **value_record(data, ACTIVE_DESCRIPTOR_ORDER_OFFSET + index, 1, value),
                "index": index,
                "runtimeVa": 0x004576E9 + index,
                "runtimeVaHex": f"0x{0x004576E9 + index:08x}",
                "active": index < count,
            }
            for index, value in enumerate(raw_order)
        ],
        "activeDescriptorIndices": active_order,
        "rawOrderValues": raw_order,
        "confidence": "candidate",
        "evidence": (
            "The save-load path rebuilds runtime descriptor pointers from "
            "0x004576e8 count and 0x004576e9 order bytes before scene selector selection."
        ),
    }


def read_exe_u32(data: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(data):
        return None
    return struct.unpack_from("<I", data, offset)[0]


def dword_window(data: bytes, sections: list[dict], strings: dict[int, str], start_va: int, count: int = 16) -> list[dict]:
    values = []
    for index in range(count):
        va = start_va + index * 4
        value = read_exe_u32(data, sections, va)
        if value is None:
            break
        item = {
            "va": va,
            "vaHex": f"0x{va:08x}",
            "value": value,
            "valueHex": f"0x{value:08x}",
        }
        if 0x00400000 <= value <= 0x00600000:
            item["pointer"] = True
            text = strings.get(value)
            if text:
                item["string"] = text
            else:
                offset = va_to_offset(sections, value)
                if offset is not None:
                    maybe = c_string(data, offset)
                    if maybe and all(32 <= ord(char) < 127 for char in maybe[:24]):
                        item["asciiPrefix"] = maybe[:48]
        values.append(item)
    return values


def collect_linked_cns(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    start_va: int,
    *,
    depth: int = 2,
    count: int = 64,
    max_nodes: int = 24,
) -> dict:
    seen: set[int] = set()
    linked: list[str] = []
    field_maps: list[str] = []

    def add_name(name: str) -> None:
        if not name.endswith(".cns") or name in linked:
            return
        linked.append(name)
        if FIELD_MAP_RE.fullmatch(name) and name[:-4] not in field_maps:
            field_maps.append(name[:-4])

    def visit(va: int, remaining: int) -> None:
        if len(seen) >= max_nodes or va in seen:
            return
        seen.add(va)
        for item in dword_window(data, sections, strings, va, count=count):
            name = item.get("string")
            if name:
                add_name(name)
            value = item["value"]
            if remaining > 0 and item.get("pointer") and 0x00400000 <= value <= 0x00600000:
                visit(value, remaining - 1)

    visit(start_va, depth)
    return {
        "scanDepth": depth,
        "scannedPointers": len(seen),
        "linkedCns": linked,
        "fieldMaps": field_maps,
    }


def resolve_scene_selector(save_data: bytes, exe_path: Path | None) -> dict:
    group = read_u8(save_data, SCENE_SELECTOR_GROUP_OFFSET)
    slot = read_u8(save_data, SCENE_SELECTOR_SLOT_OFFSET)
    result = {
        "group": value_record(save_data, SCENE_SELECTOR_GROUP_OFFSET, 1, group),
        "slot": value_record(save_data, SCENE_SELECTOR_SLOT_OFFSET, 1, slot),
        "tableVa": SCENE_SELECTOR_TABLE_VA,
        "tableVaHex": f"0x{SCENE_SELECTOR_TABLE_VA:08x}",
        "runtimeGlobalVa": 0x0059DE30,
        "runtimeGlobalVaHex": "0x0059de30",
        "loadCodeVa": 0x004234A1,
        "loadCodeVaHex": "0x004234a1",
    }
    if exe_path is None:
        return result

    exe_data = exe_path.read_bytes()
    sections = read_sections(exe_data)
    strings = find_cns_strings(exe_data, sections)
    row_pointer_va = SCENE_SELECTOR_TABLE_VA + group * 4
    row_pointer = read_exe_u32(exe_data, sections, row_pointer_va)
    result["rowPointerVa"] = row_pointer_va
    result["rowPointerVaHex"] = f"0x{row_pointer_va:08x}"
    result["rowPointer"] = row_pointer
    result["rowPointerHex"] = f"0x{row_pointer:08x}" if isinstance(row_pointer, int) else None
    if not isinstance(row_pointer, int):
        return result
    selected_pointer_va = row_pointer + slot * 4
    selected_pointer = read_exe_u32(exe_data, sections, selected_pointer_va)
    result["selectedPointerVa"] = selected_pointer_va
    result["selectedPointerVaHex"] = f"0x{selected_pointer_va:08x}"
    result["selectedPointer"] = selected_pointer
    result["selectedPointerHex"] = f"0x{selected_pointer:08x}" if isinstance(selected_pointer, int) else None
    if isinstance(selected_pointer, int) and 0x00400000 <= selected_pointer <= 0x00600000:
        result["selectedDwords"] = dword_window(exe_data, sections, strings, selected_pointer)
        result["linkedResources"] = collect_linked_cns(exe_data, sections, strings, selected_pointer)
    return result


def parse_savedata_bytes(
    data: bytes,
    file_label: str,
    exe_path: Path | None = None,
    map_index_path: Path | None = None,
) -> dict:
    selector = resolve_scene_selector(data, exe_path)
    position = {
        "x": {
            **value_record(data, SCENE_POSITION_X_OFFSET, 2, read_u16(data, SCENE_POSITION_X_OFFSET)),
            "runtimeVa": 0x004576DC,
            "runtimeVaHex": "0x004576dc",
            "placementUseVaHex": "0x00424a91",
        },
        "y": {
            **value_record(data, SCENE_POSITION_Y_OFFSET, 2, read_u16(data, SCENE_POSITION_Y_OFFSET)),
            "runtimeVa": 0x004576DE,
            "runtimeVaHex": "0x004576de",
            "placementUseVaHex": "0x00424ab6",
        },
        "confidence": "candidate",
        "evidence": (
            "The original object placement path subtracts these runtime words "
            "from object tile coordinates before multiplying by 16 pixels."
        ),
    }
    map_index = load_map_index(map_index_path)
    web_start = choose_web_start(selector, position, map_index)
    return {
        "file": file_label,
        "size": len(data),
        "format": {
            "source": "HDNua/HandyHwanseEditor offset map",
            "endianness": "little",
        },
        "runtimeBlocks": SAVE_BLOCKS,
        "sceneSelector": selector,
        "scenePositionCandidate": position,
        "activeDescriptorOrder": parse_active_descriptor_order(data),
        "webStartCandidate": web_start,
        "money": value_record(data, 0x0008, 3, read_u24(data, 0x0008)),
        "items": {
            key: {**value_record(data, offset, 1, read_u8(data, offset)), "name": name}
            for key, name, offset in ITEM_FIELDS
        },
        "equipmentEvidence": EQUIPMENT_EVIDENCE,
        "characters": [
            parse_character(data, spec)
            for spec in CHARACTERS
        ],
        "unknowns": [
            "exact current map id",
            "story flags",
            "event flags",
            "equipment ownership/equipped offsets",
            "full inventory names",
            "selector single field-map and spawn-tile mapping",
        ],
    }


def parse_savedata(path: Path, exe_path: Path | None = None, map_index_path: Path | None = None) -> dict:
    return parse_savedata_bytes(path.read_bytes(), str(path), exe_path, map_index_path)


def compact_value(record: dict) -> str:
    return f"{record['value']} (`{record['offsetHex']}`)"


def markdown(summary: dict) -> str:
    lines = [
        "# Save Data Summary",
        "",
        f"File: `{summary['file']}`",
        f"Size: {summary['size']} bytes",
        "",
        "## Global",
        "",
        f"- Money: {compact_value(summary['money'])}",
    ]
    selector = summary["sceneSelector"]
    lines.extend(
        [
            "",
            "## Scene Selector",
            "",
            f"- group: {compact_value(selector['group'])}",
            f"- slot: {compact_value(selector['slot'])}",
            f"- runtime load code: `{selector['loadCodeVaHex']}` writes the selected pointer to `{selector['runtimeGlobalVaHex']}`",
            f"- table: `{selector['tableVaHex']}`",
        ]
    )
    if selector.get("rowPointerHex"):
        lines.append(f"- row pointer: `{selector['rowPointerHex']}` from `{selector['rowPointerVaHex']}`")
    if selector.get("selectedPointerHex"):
        lines.append(
            f"- selected pointer: `{selector['selectedPointerHex']}` from `{selector['selectedPointerVaHex']}`"
        )
    resources = selector.get("linkedResources") or {}
    if resources:
        field_maps = ", ".join(resources.get("fieldMaps", [])) or "-"
        linked_cns = ", ".join(resources.get("linkedCns", [])[:12]) or "-"
        lines.append(
            f"- linked field maps: {field_maps} "
            f"(scan depth {resources.get('scanDepth')}, pointers {resources.get('scannedPointers')})"
        )
        lines.append(f"- linked CNS: {linked_cns}")
    if selector.get("selectedDwords"):
        lines.extend(["", "| va | value | note |", "| --- | --- | --- |"])
        for item in selector["selectedDwords"][:10]:
            note = item.get("string") or item.get("asciiPrefix") or ("pointer" if item.get("pointer") else "")
            lines.append(f"| `{item['vaHex']}` | `{item['valueHex']}` | {note or '-'} |")

    active_order = summary["activeDescriptorOrder"]
    lines.extend(
        [
            "",
            "## Active Descriptor Order Candidate",
            "",
            f"- count: {compact_value(active_order['count'])}, runtime `{active_order['count']['runtimeVaHex']}`",
            (
                "- active descriptor indices: "
                + (", ".join(str(value) for value in active_order["activeDescriptorIndices"]) or "-")
            ),
            (
                "- raw first bytes: "
                + ", ".join(f"0x{value:02x}" for value in active_order["rawOrderValues"])
            ),
            f"- confidence: {active_order['confidence']}",
            f"- evidence: {active_order['evidence']}",
        ]
    )

    position = summary["scenePositionCandidate"]
    lines.extend(
        [
            "",
            "## Position Candidate",
            "",
            (
                f"- tile/camera x candidate: {compact_value(position['x'])}, "
                f"runtime `{position['x']['runtimeVaHex']}`, placement use `{position['x']['placementUseVaHex']}`"
            ),
            (
                f"- tile/camera y candidate: {compact_value(position['y'])}, "
                f"runtime `{position['y']['runtimeVaHex']}`, placement use `{position['y']['placementUseVaHex']}`"
            ),
            f"- confidence: {position['confidence']}",
            f"- evidence: {position['evidence']}",
        ]
    )
    web_start = summary.get("webStartCandidate")
    if web_start:
        lines.extend(
            [
                "",
                "## Web Start Candidate",
                "",
                f"- map: `{web_start['map']}` ({web_start['reason']})",
                f"- tile: {web_start['x']},{web_start['y']}",
                f"- selector URL: `{web_start['url']}`",
                f"- explicit map URL: `{web_start['explicitMapUrl']}`",
                f"- available selector maps: {', '.join(web_start.get('availableFieldMaps') or []) or '-'}",
            ]
        )

    lines.extend(
        [
            "",
            "## Runtime Save Blocks",
            "",
            "| save offset | size | runtime VA | name |",
            "| ---: | ---: | --- | --- |",
        ]
    )
    for block in summary["runtimeBlocks"]:
        lines.append(
            f"| `{hex_offset(block['saveOffset'])}` | `{block['size']:#x}` | "
            f"`0x{block['runtimeVa']:08x}` | {block['name']} |"
        )

    lines.extend([
        "",
        "## Items",
        "",
        "| item | offset | value |",
        "| --- | --- | ---: |",
    ])
    for key, record in summary["items"].items():
        lines.append(f"| {record.get('name') or key} | `{record['offsetHex']}` | {record['value']} |")

    equipment = summary.get("equipmentEvidence") or {}
    if equipment:
        lines.extend([
            "",
            "## Equipment Evidence",
            "",
            f"- status: `{equipment.get('status', '-')}`",
            f"- source: {equipment.get('source', '-')}",
        ])
        for note in equipment.get("notes") or []:
            lines.append(f"- {note}")

    lines.extend(["", "## Characters", ""])
    for character in summary["characters"]:
        lines.extend(
            [
                f"### {character['name']}",
                "",
                "| field | current/base | recover/enhance | maximum |",
                "| --- | ---: | ---: | ---: |",
                f"| level | {compact_value(character['level'])} | - | - |",
                (
                    f"| HP | {compact_value(character['hp']['current'])} | "
                    f"{compact_value(character['hp']['recover'])} | "
                    f"{compact_value(character['hp']['maximum'])} |"
                ),
                (
                    f"| MP | {compact_value(character['mp']['current'])} | "
                    f"{compact_value(character['mp']['recover'])} | "
                    f"{compact_value(character['mp']['maximum'])} |"
                ),
                (
                    f"| EXP | {compact_value(character['experience']['current'])} | - | "
                    f"{compact_value(character['experience']['maximum'])} |"
                ),
            ]
        )
        for stat in ["atk", "def", "tech", "quick", "luck"]:
            lines.append(
                f"| {stat.upper()} | {compact_value(character['baseStats'][stat])} | "
                f"{compact_value(character['enhanceStats'][stat])} | - |"
            )
        for block_name, block in character["skills"].items():
            learned = [
                entry
                for entry in block["entries"]
                if entry.get("learned")
            ]
            learned_text = ", ".join(
                (
                    f"slot {entry['slot']} skill {entry.get('skillIndex', '?')} "
                    f"lv {entry.get('level', '?')} ({entry['valueHex']})"
                )
                for entry in learned
            ) or "-"
            lines.extend(
                [
                    "",
                    f"- {block_name} skills at `{block['baseOffsetHex']}`: {learned_text}",
                ]
            )
        lines.append("")

    lines.extend(["## Still Unknown", ""])
    for item in summary["unknowns"]:
        lines.append(f"- {item}")
    return "\n".join(lines).rstrip() + "\n"


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("save", type=Path, help="path to savedat*.dat")
    parser.add_argument("--exe", type=Path, default=Path("Hwanse2.exe"), help="optional Hwanse2.exe path for scene selector resolution")
    parser.add_argument("--no-exe", action="store_true", help="skip executable-backed scene selector resolution")
    parser.add_argument("--maps-index", type=Path, default=Path("out/maps_runtime.js"), help="optional web runtime map index for bounds-based start URL selection")
    parser.add_argument("--json-out", type=Path)
    parser.add_argument("--out", type=Path)
    args = parser.parse_args()

    summary = parse_savedata(args.save, None if args.no_exe else args.exe, args.maps_index)
    if args.json_out:
        args.json_out.parent.mkdir(parents=True, exist_ok=True)
        args.json_out.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text(markdown(summary), encoding="utf-8")
    if not args.json_out and not args.out:
        print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
