#!/usr/bin/env python3
"""Build a review for global item inventory state slots.

This grounds the non-equipment inventory layout.  Equipment ownership lives in
actor state slots; consumables and story/key items use a separate global
inventory array of id/value pairs.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections, va_to_offset


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

CONSUMABLE_ID_BASE_VA = 0x004576EC
CONSUMABLE_COUNT_BASE_VA = 0x004576ED
CONSUMABLE_SLOT_COUNT = 6
CONSUMABLE_MAX_COUNT = 10

IMPORTANT_ID_BASE_VA = 0x004576F8
IMPORTANT_VALUE_BASE_VA = 0x004576F9
IMPORTANT_SLOT_COUNT = 30
IMPORTANT_MAX_VALUE = 1

FUNCTIONS = [
    {
        "key": "battleConsumableUse",
        "label": "전투 소모품 선택 -> actor effect",
        "startVa": 0x0040F6A8,
        "endVa": 0x0040F6DC,
        "role": "선택된 소모품 슬롯 index로 0x4576ec id를 읽고 actor+0x59에 효과 index를 쓴다.",
    },
    {
        "key": "shopConsumableClamp",
        "label": "상점 소모품 구매 상한",
        "startVa": 0x0040FD3F,
        "endVa": 0x0040FDF4,
        "role": "0x4576ec/ed 6칸을 검색하고 현재 수량+구매가능 수량을 10개로 clamp한다.",
    },
    {
        "key": "shopSingleClamp",
        "label": "상점 단일 보유품 구매 상한",
        "startVa": 0x0040FDF4,
        "endVa": 0x0040FE68,
        "role": "보유 predicate 0x421d3d 결과를 기준으로 구매가능 수량을 1개로 clamp한다.",
    },
    {
        "key": "itemSlotLookup",
        "label": "아이템 슬롯 lookup",
        "startVa": 0x00421C8C,
        "endVa": 0x00421D3D,
        "role": "item record+0x10 flag에 따라 6칸 소모품 또는 30칸 중요품 배열에서 id를 찾는다.",
    },
    {
        "key": "itemAddWriter",
        "label": "아이템 추가 writer",
        "startVa": 0x00422093,
        "endVa": 0x0042224B,
        "role": "flag 0이면 0x4576ec/ed 수량형 6칸 max 10, flag 1이면 0x4576f8/f9 단일형 30칸 max 1에 쓴다.",
    },
    {
        "key": "itemPackSortHelper",
        "label": "아이템 슬롯 pack/sort helper",
        "startVa": 0x0042224B,
        "endVa": 0x0042234E,
        "role": "0x4576ec 또는 0x4576f8 pair 배열의 0 구멍을 뒤로 밀고 정렬/압축한다.",
    },
    {
        "key": "itemRemoveWriter",
        "label": "아이템 제거/감소 writer",
        "startVa": 0x004226E9,
        "endVa": 0x00422899,
        "role": "flag에 따라 수량/value를 감소시키고 0이 되면 pair의 id도 0으로 지운다.",
    },
    {
        "key": "uiConsumableStatus",
        "label": "소모품 UI 상태 map",
        "startVa": 0x00410F23,
        "endVa": 0x00410FC4,
        "role": "0x4576ec/ed 6칸에서 없음/부족/있음 상태 0/2/1을 만든다.",
    },
    {
        "key": "uiImportantPageCount",
        "label": "중요품 UI 페이지 수",
        "startVa": 0x00411068,
        "endVa": 0x00411138,
        "role": "0x4576f8 nonzero id를 세어 6칸 페이지 단위로 계산한다.",
    },
    {
        "key": "uiImportantStatus",
        "label": "중요품 UI 상태 map",
        "startVa": 0x00411138,
        "endVa": 0x004111E5,
        "role": "현재 중요품 page에서 0x4576f8/f9 pair를 읽어 없음/부족/있음 상태를 만든다.",
    },
]


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


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value))


def load_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def item_records(ui: dict[str, Any]) -> list[dict[str, Any]]:
    for table in ui.get("tables") or []:
        if table.get("key") == "items":
            return list(table.get("records") or [])
    return []


def get_bytes(exe: bytes, sections: list[dict[str, Any]], va: int, length: int) -> bytes:
    off = va_to_offset(sections, va)
    if off is None:
        return b""
    return exe[off : off + length]


def slot_pairs(exe: bytes, sections: list[dict[str, Any]], base_va: int, count: int, item_names: dict[int, str]) -> list[dict[str, Any]]:
    raw = get_bytes(exe, sections, base_va, count * 2)
    rows = []
    for index in range(count):
        item_id = raw[index * 2] if index * 2 < len(raw) else 0
        value = raw[index * 2 + 1] if index * 2 + 1 < len(raw) else 0
        rows.append(
            {
                "slot": index,
                "idVa": base_va + index * 2,
                "idVaHex": hx(base_va + index * 2),
                "valueVa": base_va + index * 2 + 1,
                "valueVaHex": hx(base_va + index * 2 + 1),
                "itemId": item_id,
                "itemName": item_names.get(item_id, "") if item_id else "",
                "value": value,
            }
        )
    return rows


def classify_items(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for row in records:
        detail = row.get("detailRecord") or {}
        flag = int(detail.get("flag") or 0)
        item_id = int(row.get("index1Based") or 0)
        storage_kind = "consumable-count" if flag == 0 else "important-single-state"
        rows.append(
            {
                "itemId": item_id,
                "itemIdHex": f"0x{item_id:02x}",
                "name": row.get("name") or "",
                "recordVaHex": row.get("recordVaHex") or "",
                "detailRecordVaHex": detail.get("recordVaHex") or row.get("textVaHex") or "",
                "flag": flag,
                "flagHex": detail.get("flagHex") or f"0x{flag:04x}",
                "storageKind": storage_kind,
                "capacity": CONSUMABLE_SLOT_COUNT if flag == 0 else IMPORTANT_SLOT_COUNT,
                "maxValue": CONSUMABLE_MAX_COUNT if flag == 0 else IMPORTANT_MAX_VALUE,
                "iconCell": (row.get("grid") or {}).get("cellIndex"),
                "iconCellHex": (row.get("grid") or {}).get("cellIndexHex"),
                "description": row.get("description") or "",
                "effectSummary": detail.get("exeEffectSummary") or "",
                "referenceSummary": detail.get("referenceSummary") or "",
            }
        )
    return rows


def disassemble_function(exe_path: Path, start_va: int, end_va: int) -> list[str]:
    result = subprocess.run(
        [
            "objdump",
            "-Mintel",
            "-d",
            str(exe_path),
            f"--start-address={start_va:#x}",
            f"--stop-address={end_va:#x}",
        ],
        check=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    return [line.rstrip() for line in result.stdout.splitlines() if line.strip()]


def filtered_snippet(lines: list[str], needles: list[str], context: int = 2) -> list[str]:
    indexes = set()
    for index, line in enumerate(lines):
        if any(needle in line for needle in needles):
            for pos in range(max(0, index - context), min(len(lines), index + context + 1)):
                indexes.add(pos)
    return [lines[index] for index in sorted(indexes)]


def function_rows(exe_path: Path) -> list[dict[str, Any]]:
    needle_map = {
        "battleConsumableUse": ["0x4576ec", "[ecx+0x59]", "0x546a38"],
        "shopConsumableClamp": ["0x4576ec", "0x4576ed", "0xa"],
        "shopSingleClamp": ["0x421d3d", "0x1", "0x59e33b"],
        "itemSlotLookup": ["0x4576ec", "0x4576f8", "[eax+0x10]"],
        "itemAddWriter": ["0x4576ec", "0x4576ed", "0x4576f8", "0x4576f9", "0xa", "0x1e"],
        "itemPackSortHelper": ["0x4576ec", "0x4576f8", "0x1e", "0x6"],
        "itemRemoveWriter": ["0x4576ec", "0x4576ed", "0x4576f8", "0x4576f9"],
        "uiConsumableStatus": ["0x4576ec", "0x4576ed"],
        "uiImportantPageCount": ["0x4576f8"],
        "uiImportantStatus": ["0x4576f8", "0x4576f9"],
    }
    rows = []
    for row in FUNCTIONS:
        lines = disassemble_function(exe_path, row["startVa"], row["endVa"])
        snippet = filtered_snippet(lines, needle_map.get(row["key"], []))
        rows.append(
            {
                **row,
                "startVaHex": hx(row["startVa"]),
                "endVaHex": hx(row["endVa"]),
                "size": row["endVa"] - row["startVa"],
                "evidenceSnippet": snippet[:28],
            }
        )
    return rows


def talisman_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [
        row
        for row in rows
        if row["name"].startswith("부적")
    ]


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    sections = read_sections(exe)
    ui = load_json(args.ui_mappings, {})
    records = item_records(ui)
    item_rows = classify_items(records)
    item_names = {row["itemId"]: row["name"] for row in item_rows}
    consumable_slots = slot_pairs(exe, sections, CONSUMABLE_ID_BASE_VA, CONSUMABLE_SLOT_COUNT, item_names)
    important_slots = slot_pairs(exe, sections, IMPORTANT_ID_BASE_VA, IMPORTANT_SLOT_COUNT, item_names)
    functions = function_rows(args.exe)
    return {
        "scope": "item-inventory-state-layout-review",
        "sourceArtifacts": {
            "exe": str(args.exe.relative_to(ROOT)),
            "uiCnsGridMappings": str(args.ui_mappings.relative_to(ROOT)),
            "battleRecoveryItemEffectReview": "out/battle_recovery_item_effect_review.json",
        },
        "summary": {
            "status": "item-inventory-state-layout-grounded",
            "consumableIdBaseVaHex": hx(CONSUMABLE_ID_BASE_VA),
            "consumableCountBaseVaHex": hx(CONSUMABLE_COUNT_BASE_VA),
            "consumableSlotCount": CONSUMABLE_SLOT_COUNT,
            "consumableMaxCount": CONSUMABLE_MAX_COUNT,
            "importantIdBaseVaHex": hx(IMPORTANT_ID_BASE_VA),
            "importantValueBaseVaHex": hx(IMPORTANT_VALUE_BASE_VA),
            "importantSlotCount": IMPORTANT_SLOT_COUNT,
            "importantMaxValue": IMPORTANT_MAX_VALUE,
            "itemRecordCount": len(item_rows),
            "consumableRecordCount": sum(1 for row in item_rows if row["storageKind"] == "consumable-count"),
            "importantRecordCount": sum(1 for row in item_rows if row["storageKind"] == "important-single-state"),
            "conclusion": (
                "소모품은 전역 6-slot id/count pair 배열이며 수량 상한은 10이다. "
                "중요품/스토리 아이템은 전역 30-slot id/value pair 배열이며 add/shop 경로에서 값 상한은 1이다. "
                "부적은 수량 byte 하나가 아니라 1장/2장/3장/4장 별도 item id 상태로 표현된다."
            ),
        },
        "storageLayout": {
            "consumables": {
                "shape": "6 slots x {id byte, count byte}",
                "idBaseVaHex": hx(CONSUMABLE_ID_BASE_VA),
                "countBaseVaHex": hx(CONSUMABLE_COUNT_BASE_VA),
                "slotAddressRule": "id=0x4576ec+slot*2, count=0x4576ed+slot*2",
                "maxCount": CONSUMABLE_MAX_COUNT,
            },
            "importantItems": {
                "shape": "30 slots x {id byte, value byte}",
                "idBaseVaHex": hx(IMPORTANT_ID_BASE_VA),
                "valueBaseVaHex": hx(IMPORTANT_VALUE_BASE_VA),
                "slotAddressRule": "id=0x4576f8+slot*2, value=0x4576f9+slot*2",
                "maxValue": IMPORTANT_MAX_VALUE,
            },
        },
        "initialSlots": {
            "consumables": consumable_slots,
            "importantItems": important_slots,
            "note": "초기 EXE 데이터의 첫 소모품 pair 03 03은 리프레시 워터 3개와 일치한다.",
        },
        "itemRows": item_rows,
        "talismanRows": talisman_rows(item_rows),
        "functions": functions,
        "stateWriteSummary": [
            {
                "kind": "consumable-add",
                "functionVaHex": hx(0x00422093),
                "branch": "item flag 0",
                "target": "0x4576ec/0x4576ed",
                "limit": "current + amount <= 10",
            },
            {
                "kind": "important-add",
                "functionVaHex": hx(0x00422093),
                "branch": "item flag nonzero",
                "target": "0x4576f8/0x4576f9",
                "limit": "current + amount <= 1",
            },
            {
                "kind": "remove/decrement",
                "functionVaHex": hx(0x004226E9),
                "target": "same pair arrays by item flag",
                "limit": "subtract amount; if value becomes 0, id is cleared",
            },
            {
                "kind": "battle-use",
                "functionVaHex": hx(0x0040F6A8),
                "target": "selected slot id -> actor+0x59 -> 0x546a38 dispatch",
                "limit": "first six consumable ids/effects",
            },
            {
                "kind": "shop-limit",
                "functionVaHex": hx(0x0040FD3F),
                "target": "purchase count UI",
                "limit": "consumable max 10, single/equipment max 1",
            },
        ],
    }


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:
    summary = report["summary"]
    storage = report["storageLayout"]
    consumable_slot_rows = [
        [
            h(row["slot"]),
            f"<code>{h(row['idVaHex'])}</code>",
            h(row["itemId"]),
            h(row["itemName"]),
            f"<code>{h(row['valueVaHex'])}</code>",
            h(row["value"]),
        ]
        for row in report["initialSlots"]["consumables"]
    ]
    important_slot_rows = [
        [
            h(row["slot"]),
            f"<code>{h(row['idVaHex'])}</code>",
            h(row["itemId"] or ""),
            h(row["itemName"]),
            f"<code>{h(row['valueVaHex'])}</code>",
            h(row["value"] or ""),
        ]
        for row in report["initialSlots"]["importantItems"]
    ]
    item_rows = [
        [
            h(row["itemId"]),
            h(row["name"]),
            f"<code>{h(row['flagHex'])}</code>",
            h(row["storageKind"]),
            h(row["maxValue"]),
            h(row.get("effectSummary") or row.get("referenceSummary") or ""),
        ]
        for row in report["itemRows"]
    ]
    function_rows = [
        [
            h(row["label"]),
            f"<code>{h(row['startVaHex'])}..{h(row['endVaHex'])}</code>",
            h(row["role"]),
            "<pre>" + h("\n".join(row.get("evidenceSnippet") or [])) + "</pre>",
        ]
        for row in report["functions"]
    ]
    write_rows = [
        [
            h(row["kind"]),
            f"<code>{h(row['functionVaHex'])}</code>",
            h(row["target"]),
            h(row["limit"]),
        ]
        for row in report["stateWriteSummary"]
    ]
    talisman_rows = [
        [
            h(row["itemId"]),
            h(row["name"]),
            f"<code>{h(row['recordVaHex'])}</code>",
            h(row["iconCellHex"]),
            h(row["description"].replace("\n", " / ")),
        ]
        for row in report["talismanRows"]
    ]
    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; --line:#d8dee6; --muted:#64748b; --head:#eef2f6; --good:#0f766e; --warn:#b45309; }}
      * {{ box-sizing:border-box; }}
      body {{ margin:0; padding:24px; background:var(--bg); color:var(--fg); font:14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
      h1, h2 {{ margin:0 0 12px; }}
      h2 {{ margin-top:24px; }}
      a {{ color:#155e75; }}
      .card {{ background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px; margin:0 0 16px; box-shadow:0 1px 2px rgba(15,23,42,.04); }}
      .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; }}
      .metric {{ border:1px solid var(--line); border-radius:8px; padding:12px; background:#fafbfc; }}
      .metric strong {{ display:block; font-size:20px; }}
      .metric span, .muted {{ color:var(--muted); }}
      .tag {{ display:inline-flex; align-items:center; border-radius:999px; padding:2px 8px; background:#ccfbf1; color:var(--good); }}
      .tag.warn {{ background:#fef3c7; color:var(--warn); }}
      .table-wrap {{ overflow-x:auto; }}
      table {{ width:100%; border-collapse:collapse; background:#fff; }}
      th, td {{ border:1px solid var(--line); padding:7px 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; }}
      pre {{ white-space:pre-wrap; background:#0f172a; color:#dbeafe; padding:10px; border-radius:6px; overflow:auto; max-height:260px; }}
    </style>
  </head>
  <body>
    <main>
      <h1>아이템 인벤토리 상태 슬롯 리뷰</h1>
      <section class="card">
        <p><span class="tag">HWANSE_ITEM_INVENTORY_STATE_LAYOUT_REVIEW_READY</span></p>
        <p>{h(summary["conclusion"])}</p>
        <div class="metrics">
          <div class="metric"><strong>{h(summary["consumableIdBaseVaHex"])}</strong><span>소모품 id base</span></div>
          <div class="metric"><strong>{h(summary["consumableSlotCount"])} slots · max {h(summary["consumableMaxCount"])}</strong><span>소모품 수량형</span></div>
          <div class="metric"><strong>{h(summary["importantIdBaseVaHex"])}</strong><span>중요품 id base</span></div>
          <div class="metric"><strong>{h(summary["importantSlotCount"])} slots · max {h(summary["importantMaxValue"])}</strong><span>중요품 단일형</span></div>
        </div>
      </section>

      <section class="card">
        <h2>Storage Layout</h2>
        <pre>{h(json.dumps(storage, ensure_ascii=False, indent=2))}</pre>
      </section>

      <section class="card">
        <h2>Initial Consumable Slots</h2>
        <p class="muted">{h(report["initialSlots"]["note"])}</p>
        <div class="table-wrap">
          {render_table(["slot", "id VA", "id", "item", "count VA", "count"], consumable_slot_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Initial Important Slots</h2>
        <div class="table-wrap">
          {render_table(["slot", "id VA", "id", "item", "value VA", "value"], important_slot_rows)}
        </div>
      </section>

      <section class="card">
        <h2>부적은 수량이 아니라 상태 레코드</h2>
        <p class="muted">1장/2장/3장/4장이 같은 icon cell을 쓰지만 서로 다른 item id와 설명을 갖는다.</p>
        <div class="table-wrap">
          {render_table(["item id", "name", "record", "icon cell", "description"], talisman_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Item Records</h2>
        <div class="table-wrap">
          {render_table(["id", "name", "flag", "storage", "max", "effect/reference"], item_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Write/Use Semantics</h2>
        <div class="table-wrap">
          {render_table(["kind", "function", "target", "limit"], write_rows)}
        </div>
      </section>

      <section class="card">
        <h2>Evidence Functions</h2>
        <div class="table-wrap">
          {render_table(["function", "VA span", "role", "evidence"], function_rows)}
        </div>
      </section>
    </main>
    <script>
      window.HWANSE_LAST_ITEM_INVENTORY_STATE_LAYOUT_REVIEW = {{
        marker: "HWANSE_ITEM_INVENTORY_STATE_LAYOUT_REVIEW_READY",
        consumableIdBaseVaHex: "{h(summary['consumableIdBaseVaHex'])}",
        consumableCountBaseVaHex: "{h(summary['consumableCountBaseVaHex'])}",
        importantIdBaseVaHex: "{h(summary['importantIdBaseVaHex'])}",
        importantValueBaseVaHex: "{h(summary['importantValueBaseVaHex'])}",
        consumableMaxCount: {summary['consumableMaxCount']},
        importantMaxValue: {summary['importantMaxValue']},
        sealTalismanIsSeparateStateRecords: true,
        initialRefreshWaterCountGrounded: true
      }};
    </script>
  </body>
</html>
"""


def render_docs(report: dict[str, Any]) -> str:
    s = report["summary"]
    lines = [
        "# 아이템 인벤토리 상태 슬롯 리뷰",
        "",
        "## 결론",
        "",
        "- 장비 보유/장착은 actor 상태 구조체 슬롯이고, 이 문서는 장비가 아닌 전역 아이템 인벤토리 슬롯을 다룬다.",
        f"- 소모품: `{s['consumableIdBaseVaHex']}` id byte + `{s['consumableCountBaseVaHex']}` count byte, 6-slot pair, 최대 10개.",
        f"- 중요품/스토리 아이템: `{s['importantIdBaseVaHex']}` id byte + `{s['importantValueBaseVaHex']}` value byte, 30-slot pair, add/shop 경로에서 최대 1.",
        "- `부적 1장`부터 `부적 4장`은 같은 아이콘을 쓰는 별도 item id 상태이며, 하나의 수량 byte를 1..4로 올리는 구조가 아니다.",
        "- 초기 EXE 데이터의 첫 소모품 pair `03 03`은 리프레시 워터 3개와 일치한다.",
        "",
        "## 핵심 함수",
        "",
        "| 함수 | 범위 | 의미 |",
        "|---|---|---|",
    ]
    for row in report["functions"]:
        lines.append(f"| {row['label']} | `{row['startVaHex']}..{row['endVaHex']}` | {row['role']} |")
    lines.extend(
        [
            "",
            "## 소모품",
            "",
            "| id | name | max | effect/reference |",
            "|---:|---|---:|---|",
        ]
    )
    for row in report["itemRows"]:
        if row["storageKind"] != "consumable-count":
            continue
        lines.append(f"| {row['itemId']} | {row['name']} | {row['maxValue']} | {row.get('effectSummary') or row.get('referenceSummary') or ''} |")
    lines.extend(["", "## 부적 상태 레코드", "", "| id | name | record | icon |", "|---:|---|---|---|"])
    for row in report["talismanRows"]:
        lines.append(f"| {row['itemId']} | {row['name']} | `{row['recordVaHex']}` | `{row.get('iconCellHex')}` |")
    return "\n".join(lines) + "\n"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--ui-mappings", type=Path, default=OUT / "ui_cns_grid_mappings.json")
    parser.add_argument("--json-out", type=Path, default=OUT / "item_inventory_state_layout_review.json")
    parser.add_argument("--web-out", type=Path, default=WEB / "item_inventory_state_layout_review.html")
    parser.add_argument("--docs-out", type=Path, default=DOCS / "ITEM_INVENTORY_STATE_LAYOUT_REVIEW.md")
    args = parser.parse_args()

    report = build(args)
    write_json(args.json_out, report)
    args.web_out.write_text(render_html(report), encoding="utf-8")
    args.docs_out.write_text(render_docs(report), encoding="utf-8")
    print(
        f"wrote {args.json_out} / {args.web_out} / {args.docs_out} "
        f"(items={report['summary']['itemRecordCount']}, consumables={report['summary']['consumableRecordCount']})"
    )


if __name__ == "__main__":
    main()
