#!/usr/bin/env python3
"""Summarize non-party descriptor rows against CNS/runtime assets.

This report connects the original 0x00442d95 descriptor table rows 3..11
to extracted CNS assets and browser/runtime asset surfaces, while keeping original
activation/order/script behavior explicitly unpromoted.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
WEB = ROOT / "web"


def load_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return fallback


def load_js_object(path: Path, pattern: str) -> Any:
    if not path.exists():
        return {}
    text = path.read_text(encoding="utf-8")
    match = re.fullmatch(pattern, text, re.S)
    if not match:
        return {}
    if len(match.groups()) == 1:
        return json.loads(match.group(1))
    return tuple(json.loads(group) for group in match.groups())


def payload_index(cns_payloads: list[dict]) -> dict[str, dict]:
    return {
        str(row.get("name")): row
        for row in cns_payloads
        if row.get("name")
    }


def resource_descriptor_index(resource_descriptors: dict) -> dict[str, list[dict]]:
    rows_by_name: dict[str, list[dict]] = {}
    for row in resource_descriptors.get("resourceDescriptorRows") or []:
        name = row.get("name")
        if not name:
            continue
        rows_by_name.setdefault(str(name), []).append(row)
    return rows_by_name


def scene_event_object_index(scene_events: list[dict]) -> dict[str, list[dict]]:
    rows_by_stem: dict[str, list[dict]] = {}
    for row in scene_events:
        for linked in row.get("linkedStrings") or []:
            name = str(linked)
            if not name.lower().endswith(".cns"):
                continue
            stem = Path(name).stem
            if not stem.lower().startswith("z"):
                continue
            active = next(
                (
                    point for point in row.get("activePoints") or []
                    if isinstance(point, dict)
                    and isinstance(point.get("x"), int)
                    and isinstance(point.get("y"), int)
                ),
                None,
            )
            fallback = next(
                (
                    point for point in row.get("points") or []
                    if isinstance(point, dict)
                    and isinstance(point.get("x"), int)
                    and isinstance(point.get("y"), int)
                ),
                None,
            )
            anchor = active or fallback
            rows_by_stem.setdefault(stem, []).append({
                "map": row.get("map") or "",
                "sceneIdHex": row.get("sceneIdHex") or "",
                "recordVaHex": row.get("recordVaHex") or "",
                "linked": name,
                "anchor": {"x": anchor["x"], "y": anchor["y"]} if anchor else None,
                "activePointCount": len(row.get("activePoints") or []),
                "pointCount": len(row.get("points") or []),
            })
    for rows in rows_by_stem.values():
        rows.sort(key=lambda item: (item.get("map") or "", item.get("sceneIdHex") or "", item.get("linked") or ""))
    return rows_by_stem


def category_for_asset(name: str) -> str | None:
    stem = Path(name).stem.lower()
    if stem.startswith("boss") or stem.startswith("z"):
        return "monster"
    if stem.startswith("btl"):
        return "battle"
    if stem.startswith("cara"):
        return "character"
    return "ui"


def non_party_runtime_surface(
    stem: str,
    object_assets: dict,
    battle_enemy_assets: dict,
    web_index_text: str,
) -> str:
    if (
        stem == "btl_etc"
        and "battle_effects:" in web_index_text
        and "browserBattleHitSpriteEffectImplemented" in web_index_text
    ):
        return "battle-hit-effect-prototype"
    if stem in object_assets:
        return "field-object-overlay-candidate"
    if stem in battle_enemy_assets:
        return "battle-sprite-only-candidate"
    direct_ref = f"../extract_fld/{stem}.cns"
    if direct_ref in web_index_text:
        return "direct-battle-party-sprite-asset"
    return "detail-review-only"


def build_summary(
    runtime_movement: dict,
    cns_payloads: list[dict],
    resource_descriptors: dict,
    battle_enemy_candidates: dict,
    object_assets: dict,
    scene_events: list[dict],
    web_index_text: str,
) -> dict:
    descriptor_table = (
        ((runtime_movement.get("spriteFrameSelector") or {}).get("partyActorFrameScripts") or {})
        .get("objectDescriptorTable") or {}
    )
    rows = [
        row for row in descriptor_table.get("rows") or []
        if int(row.get("index") or 0) >= 3
    ]
    payloads = payload_index(cns_payloads)
    resource_rows_by_name = resource_descriptor_index(resource_descriptors)
    scene_rows_by_stem = scene_event_object_index(scene_events)
    battle_enemy_assets = battle_enemy_candidates.get("assets") or {}
    linked_asset_rows: list[dict] = []
    descriptor_rows: list[dict] = []
    descriptor_indices_by_stem: dict[str, list[int]] = {}
    for row in rows:
        linked_cns = row.get("linkedCns") or []
        row_asset_rows = []
        for name in linked_cns:
            stem = Path(name).stem
            descriptor_indices_by_stem.setdefault(stem, []).append(int(row.get("index") or 0))
            payload = payloads.get(name) or {}
            descriptor_refs = resource_rows_by_name.get(name, [])
            asset_category = category_for_asset(name)
            scene_event_rows = scene_rows_by_stem.get(stem, [])
            asset = {
                "descriptorIndex": row.get("index"),
                "name": name,
                "stem": stem,
                "payloadKind": payload.get("kind"),
                "width": payload.get("width"),
                "height": payload.get("height"),
                "cnsSourcePath": f"extract_fld/{stem}.cns",
                "browserRenderPath": f"../extract_fld/{stem}.cns",
                "cnsSourceExists": (ROOT / "extract_fld" / f"{stem}.cns").exists(),
                "detailReviewCategory": asset_category,
                "detailReviewListed": asset_category is not None,
                "resourceDescriptorRefCount": len(descriptor_refs),
                "resourceDescriptorClasses": sorted({ref.get("class") for ref in descriptor_refs if ref.get("class")}),
                "resourceDescriptorShapes": sorted({ref.get("shape") for ref in descriptor_refs if ref.get("shape")}),
                "resourceDescriptorRefsHex": [ref.get("refVaHex") for ref in descriptor_refs if ref.get("refVaHex")],
                "runtimeSurface": non_party_runtime_surface(stem, object_assets, battle_enemy_assets, web_index_text),
                "battleEnemyAssetTable": stem in battle_enemy_assets,
                "objectOverlayAssetTable": stem in object_assets,
                "sceneEventAnchorCount": len(scene_event_rows),
                "sceneEventMaps": sorted({event.get("map") for event in scene_event_rows if event.get("map")}),
                "sceneEventAnchors": scene_event_rows,
                "webIndexDirectAssetRef": f"../extract_fld/{stem}.cns" in web_index_text,
            }
            linked_asset_rows.append(asset)
            row_asset_rows.append(asset)
        descriptor_rows.append({
            "index": row.get("index"),
            "descriptorVaHex": row.get("descriptorVaHex"),
            "resourceClass": row.get("resourceClass"),
            "linkedCns": linked_cns,
            "validFramePointerInitializerCount": row.get("validFramePointerInitializerCount"),
            "usesActorAnimationStateTable": row.get("usesActorAnimationStateTable"),
            "animationStateTableTargetsHex": row.get("animationStateTableTargetsHex") or [],
            "fieldMapCnsRefCount": row.get("fieldMapCnsRefCount"),
            "assetStems": [asset["stem"] for asset in row_asset_rows],
            "runtimeSurfaces": sorted({asset["runtimeSurface"] for asset in row_asset_rows}),
            "sceneEventAnchorCount": sum(asset["sceneEventAnchorCount"] for asset in row_asset_rows),
            "sceneEventMaps": sorted({name for asset in row_asset_rows for name in asset["sceneEventMaps"]}),
        })

    cns_source_count = sum(1 for row in linked_asset_rows if row["cnsSourceExists"])
    detail_review_count = sum(1 for row in linked_asset_rows if row["detailReviewListed"])
    resource_ref_count = sum(1 for row in linked_asset_rows if row["resourceDescriptorRefCount"] > 0)
    web_direct_count = sum(1 for row in linked_asset_rows if row["runtimeSurface"] == "direct-battle-party-sprite-asset")
    battle_enemy_count = sum(1 for row in linked_asset_rows if row["battleEnemyAssetTable"])
    object_overlay_count = sum(1 for row in linked_asset_rows if row["objectOverlayAssetTable"])
    detail_review_only_count = sum(1 for row in linked_asset_rows if row["runtimeSurface"] == "detail-review-only")
    battle_hit_effect_count = sum(1 for row in linked_asset_rows if row["runtimeSurface"] == "battle-hit-effect-prototype")
    descriptor_z_rows = [row for row in linked_asset_rows if row["stem"].lower().startswith("z")]
    descriptor_z_scene_rows = [row for row in descriptor_z_rows if row["sceneEventAnchorCount"] > 0]
    descriptor_z_without_scene = [row["stem"] for row in descriptor_z_rows if row["sceneEventAnchorCount"] == 0]
    scene_linked_z_asset_rows = []
    for stem, event_rows in sorted(scene_rows_by_stem.items()):
        scene_linked_z_asset_rows.append({
            "stem": stem,
            "name": f"{stem}.cns",
            "sceneEventAnchorCount": len(event_rows),
            "sceneEventMaps": sorted({row.get("map") for row in event_rows if row.get("map")}),
            "sceneEventAnchors": event_rows,
            "inDescriptorRows": stem in descriptor_indices_by_stem,
            "descriptorIndices": sorted(descriptor_indices_by_stem.get(stem, [])),
            "objectOverlayAssetTable": stem in object_assets,
        })
    checks = {
        "nonPartyDescriptorRowsPresent": len(rows) == 9,
        "nonPartyDescriptorsHaveNoAnimationStateTables": all(
            row.get("usesActorAnimationStateTable") is False for row in rows
        ),
        "nonPartyDescriptorsHaveNoFieldMapRefs": all(
            int(row.get("fieldMapCnsRefCount") or 0) == 0 for row in rows
        ),
        "linkedAssetsHaveCnsSource": cns_source_count == len(linked_asset_rows),
        "linkedAssetsListedInDetailReview": detail_review_count == len(linked_asset_rows),
        "linkedAssetsHaveExeResourceDescriptors": resource_ref_count == len(linked_asset_rows),
        "directBattlePartySpritesLimitedToRows4To6": web_direct_count == 3,
        "enemyObjectDescriptorAssetsRuntimeSelectableAsBattleSprites": battle_enemy_count == 5,
        "fieldObjectOverlayStillCandidateOnly": object_overlay_count == 1,
        "fieldObjectOverlaySceneAnchorLimitedToZsaIwa": (
            [row["stem"] for row in descriptor_z_scene_rows] == ["zsa_iwa"]
            and (linked_asset_rows and next(
                (row for row in linked_asset_rows if row["stem"] == "zsa_iwa"),
                {},
            ).get("runtimeSurface") == "field-object-overlay-candidate")
        ),
        "descriptorZAssetsWithoutSceneAnchorsStayBattleOnly": all(
            row["runtimeSurface"] == "battle-sprite-only-candidate"
            for row in descriptor_z_rows
            if row["stem"] in descriptor_z_without_scene
        ),
        "battleHitEffectPrototypeIsBrowserOnly": battle_hit_effect_count == 1,
        "remainingBattleEffectRowsDetailReviewOnly": detail_review_only_count == 1,
        "currentSelectorActiveOrderProven": False,
        "originalNonPartyDescriptorScriptBehaviorProven": False,
        "originalFieldObjectRuntimeBound": False,
        "originalBattleEffectTimingProven": False,
        "originalBattleEntryProven": False,
    }
    return {
        "scope": "Non-party descriptor rows 3..11 linked to decoded/runtime assets without original behavior promotion.",
        "source": [
            "out/runtime_movement.json",
            "out/cns_payloads.json",
            "out/original_battle_resource_descriptors.json",
            "out/battle_enemy_candidates.json",
            "out/object_assets.js",
            "web/cns_rect_review.html",
            "web/monster_review.html",
            "web/index.html",
        ],
        "promotionStatus": "asset-linked-behavior-gap-remains",
        "descriptorTableVaHex": descriptor_table.get("descriptorTableHex"),
        "nonPartyDescriptorRowCount": len(rows),
        "linkedCnsCount": len(linked_asset_rows),
        "cnsSourceLinkedAssetCount": cns_source_count,
        "detailReviewLinkedAssetCount": detail_review_count,
        "resourceDescriptorLinkedAssetCount": resource_ref_count,
        "webDirectBattlePartySpriteAssetCount": web_direct_count,
        "battleEnemyAssetTableLinkedCount": battle_enemy_count,
        "objectOverlayAssetTableLinkedCount": object_overlay_count,
        "sceneLinkedObjectAssetCount": len(object_assets),
        "sceneLinkedZAssetCount": len(scene_linked_z_asset_rows),
        "descriptorLinkedZAssetCount": len(descriptor_z_rows),
        "descriptorZAssetSceneAnchorLinkedCount": len(descriptor_z_scene_rows),
        "descriptorZAssetsWithoutSceneAnchor": descriptor_z_without_scene,
        "fieldObjectOverlaySceneAnchorCount": sum(
            1
            for row in linked_asset_rows
            if row["runtimeSurface"] == "field-object-overlay-candidate"
            and row["sceneEventAnchorCount"] > 0
        ),
        "battleHitEffectPrototypeLinkedCount": battle_hit_effect_count,
        "detailReviewOnlyLinkedCount": detail_review_only_count,
        "rowsWithoutFrameInitializer": [
            row.get("index") for row in rows
            if int(row.get("validFramePointerInitializerCount") or 0) == 0
        ],
        "descriptorRows": descriptor_rows,
        "linkedAssetRows": linked_asset_rows,
        "sceneLinkedZAssetRows": scene_linked_z_asset_rows,
        "checks": checks,
        "conclusion": (
            "Rows 3..11 of descriptor table 0x00442d95 now have CNS source and detail-review coverage, "
            "and every linked CNS has an EXE resource descriptor row. btl_etc.cns is wired into the browser-local "
            "battle hit effect prototype, while btl_efc.cns remains detail-review-only. Scene-event linked z*.cns "
            "coverage is separated from descriptor coverage: only descriptor asset zsa_iwa.cns has a field scene "
            "anchor, and zsa_saru.cns/zsl_yoi.cns/zsa_mi.cns/zsa_kika.cns remain battle sprite candidates without "
            "field scene anchors. That proves asset/resource binding and one prototype visual feedback surface only: "
            "these rows still have no +0x68 state-table "
            "selectors, no field-map CNS refs, no current selector 2:0 active-order proof, and no original "
            "non-party script behavior, battle-effect timing, or battle-entry execution proof."
        ),
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Non-Party Descriptor Asset Links",
        "",
        f"- promotion status: `{summary['promotionStatus']}`",
        f"- descriptor table: `{summary['descriptorTableVaHex']}`",
        f"- non-party rows: {summary['nonPartyDescriptorRowCount']}",
        f"- linked CNS assets: {summary['linkedCnsCount']}",
        f"- decoded/listed/resource-described: {summary['decodedLinkedAssetCount']}/{summary['detailReviewLinkedAssetCount']}/{summary['resourceDescriptorLinkedAssetCount']}",
        f"- runtime surfaces: direct battle party {summary['webDirectBattlePartySpriteAssetCount']}, battle sprite candidates {summary['battleEnemyAssetTableLinkedCount']}, field object overlay candidates {summary['objectOverlayAssetTableLinkedCount']}, battle hit/effect prototype {summary['battleHitEffectPrototypeLinkedCount']}, detail-review only {summary['detailReviewOnlyLinkedCount']}",
        f"- scene-linked z assets: {summary['sceneLinkedZAssetCount']}; descriptor z assets with scene anchors: {summary['descriptorZAssetSceneAnchorLinkedCount']}/{summary['descriptorLinkedZAssetCount']}",
        f"- descriptor z assets without scene anchor: {', '.join(f'`{stem}`' for stem in summary['descriptorZAssetsWithoutSceneAnchor']) or '-'}",
        "",
        summary["conclusion"],
        "",
        "## Descriptor Rows",
        "",
        "| row | descriptor | class | CNS | frame init | runtime surfaces | scene maps |",
        "| ---: | --- | --- | --- | ---: | --- | --- |",
    ]
    for row in summary["descriptorRows"]:
        lines.append(
            "| {index} | `{descriptor}` | {cls} | {cns} | {init} | {surfaces} | {scene_maps} |".format(
                index=row["index"],
                descriptor=row["descriptorVaHex"],
                cls=row["resourceClass"],
                cns=", ".join(f"`{name}`" for name in row["linkedCns"]),
                init=row["validFramePointerInitializerCount"],
                surfaces=", ".join(row["runtimeSurfaces"]),
                scene_maps=", ".join(f"`{name}`" for name in row["sceneEventMaps"]) or "-",
            )
        )
    lines.extend([
        "",
        "## Linked Assets",
        "",
        "| asset | payload | CNS | viewer | EXE refs | runtime surface | scene anchors |",
        "| --- | --- | --- | --- | ---: | --- | ---: |",
    ])
    for row in summary["linkedAssetRows"]:
        lines.append(
            "| `{name}` | {payload} {size} | {cns} | {viewer} | {refs} | {surface} | {anchors} |".format(
                name=row["name"],
                payload=row["payloadKind"],
                size=f"{row['width']}x{row['height']}",
                cns="yes" if row["cnsSourceExists"] else "no",
                viewer=row["detailReviewCategory"] or "-",
                refs=row["resourceDescriptorRefCount"],
                surface=row["runtimeSurface"],
                anchors=row["sceneEventAnchorCount"],
            )
        )
    lines.extend([
        "",
        "## Scene-Linked Z Assets",
        "",
        "| asset | maps | descriptor rows | object overlay | anchors |",
        "| --- | --- | --- | --- | ---: |",
    ])
    for row in summary["sceneLinkedZAssetRows"]:
        lines.append(
            "| `{name}` | {maps} | {rows} | {overlay} | {anchors} |".format(
                name=row["name"],
                maps=", ".join(f"`{name}`" for name in row["sceneEventMaps"]) or "-",
                rows=", ".join(str(index) for index in row["descriptorIndices"]) or "-",
                overlay=row["objectOverlayAssetTable"],
                anchors=row["sceneEventAnchorCount"],
            )
        )
    lines.extend([
        "",
        "## Checks",
        "",
        "| check | value |",
        "| --- | --- |",
    ])
    for key, value in summary["checks"].items():
        lines.append(f"| {key} | {value} |")
    return "\n".join(lines) + "\n"


def html_page(summary: dict) -> str:
    def table(headers: list[str], rows: list[list[str]]) -> str:
        head = "".join(f"<th>{html.escape(header)}</th>" for header in headers)
        body = []
        for row in rows:
            body.append("<tr>" + "".join(f"<td>{html.escape(value)}</td>" for value in row) + "</tr>")
        return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"

    descriptor_rows = [
        [
            str(row["index"]),
            str(row["descriptorVaHex"]),
            str(row["resourceClass"]),
            ", ".join(row["linkedCns"]),
            str(row["validFramePointerInitializerCount"]),
            ", ".join(row["runtimeSurfaces"]),
            ", ".join(row["sceneEventMaps"]) or "-",
        ]
        for row in summary["descriptorRows"]
    ]
    asset_rows = [
        [
            str(row["name"]),
            f"{row['payloadKind']} {row['width']}x{row['height']}",
            "yes" if row["cnsSourceExists"] else "no",
            str(row["detailReviewCategory"] or "-"),
            str(row["resourceDescriptorRefCount"]),
            str(row["runtimeSurface"]),
            str(row["sceneEventAnchorCount"]),
        ]
        for row in summary["linkedAssetRows"]
    ]
    scene_rows = [
        [
            str(row["name"]),
            ", ".join(row["sceneEventMaps"]) or "-",
            ", ".join(str(index) for index in row["descriptorIndices"]) or "-",
            str(row["objectOverlayAssetTable"]),
            str(row["sceneEventAnchorCount"]),
        ]
        for row in summary["sceneLinkedZAssetRows"]
    ]
    check_rows = [[key, str(value)] for key, value in summary["checks"].items()]
    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Non-Party Descriptor Asset Links</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; line-height: 1.45; }}
    table {{ border-collapse: collapse; margin: 16px 0; width: 100%; }}
    th, td {{ border: 1px solid #bbb; padding: 4px 6px; text-align: left; vertical-align: top; }}
    th {{ background: #eee; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
  </style>
</head>
<body>
  <h1>Non-Party Descriptor Asset Links</h1>
  <p><b>Promotion status:</b> <code>{html.escape(summary['promotionStatus'])}</code></p>
  <p><b>Scene-linked z assets:</b> {summary['sceneLinkedZAssetCount']}; <b>descriptor z assets with scene anchors:</b> {summary['descriptorZAssetSceneAnchorLinkedCount']}/{summary['descriptorLinkedZAssetCount']}</p>
  <p><b>Descriptor z assets without scene anchor:</b> {html.escape(', '.join(summary['descriptorZAssetsWithoutSceneAnchor']) or '-')}</p>
  <p>{html.escape(summary['conclusion'])}</p>
  <h2>Descriptor Rows</h2>
  {table(["row", "descriptor", "class", "CNS", "frame init", "runtime surfaces", "scene maps"], descriptor_rows)}
  <h2>Linked Assets</h2>
  {table(["asset", "payload", "CNS", "viewer", "EXE refs", "runtime surface", "scene anchors"], asset_rows)}
  <h2>Scene-Linked Z Assets</h2>
  {table(["asset", "maps", "descriptor rows", "object overlay", "anchors"], scene_rows)}
  <h2>Checks</h2>
  {table(["check", "value"], check_rows)}
</body>
</html>
"""


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    out_dir = args.out_dir
    runtime_movement = load_json(out_dir / "runtime_movement.json", {})
    cns_payloads = load_json(out_dir / "cns_payloads.json", [])
    resource_descriptors = load_json(out_dir / "original_battle_resource_descriptors.json", {})
    battle_enemy_candidates = load_json(out_dir / "battle_enemy_candidates.json", {})
    scene_events = load_json(out_dir / "scene_events.json", [])
    object_assets = load_js_object(
        out_dir / "object_assets.js",
        r"window\.HWANSE_OBJECT_ASSETS = (.*);\n?",
    )
    web_index_text = (WEB / "index.html").read_text(encoding="utf-8") if (WEB / "index.html").exists() else ""
    summary = build_summary(
        runtime_movement,
        cns_payloads,
        resource_descriptors,
        battle_enemy_candidates,
        object_assets if isinstance(object_assets, dict) else {},
        scene_events if isinstance(scene_events, list) else [],
        web_index_text,
    )
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "non_party_descriptor_asset_links.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "non_party_descriptor_asset_links.html").write_text(html_page(summary), encoding="utf-8")
    print(f"wrote non-party descriptor asset links -> {out_dir / 'non_party_descriptor_asset_links.html'}")


if __name__ == "__main__":
    main()
