#!/usr/bin/env python3
"""Generate a review page for decompressed CNS tilemap payload layout."""
from __future__ import annotations

import argparse
import html
import json
import struct
from collections import Counter
from pathlib import Path
from typing import Any

from decode_cns import decompress_cns


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


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


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


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


def read_words(decoded: bytes, offset: int, count: int) -> list[int]:
    return list(struct.unpack_from(f"<{count}H", decoded, offset))


def detect_tilemap(decoded: bytes) -> dict[str, Any] | None:
    if len(decoded) >= 4:
        width = int.from_bytes(decoded[0:2], "little")
        height = int.from_bytes(decoded[2:4], "little")
        tile_count = width * height
        expected = 4 + tile_count * 4
        if 0 < width <= 256 and 0 < height <= 256 and len(decoded) == expected:
            return {
                "headerBytes": 4,
                "width": width,
                "height": height,
                "tileCount": tile_count,
                "variant": "standard-u16-size",
            }
    if len(decoded) >= 2:
        width = decoded[0]
        height = decoded[1]
        tile_count = width * height
        expected = 2 + tile_count * 4
        if 0 < width <= 256 and 0 < height <= 256 and len(decoded) == expected:
            return {
                "headerBytes": 2,
                "width": width,
                "height": height,
                "tileCount": tile_count,
                "variant": "compact-u8-size",
            }
    return None


def range_row(label: str, start: int, size: int, role: str, sample: str) -> dict:
    end = start + size - 1 if size else start
    return {
        "label": label,
        "start": start,
        "end": end,
        "startHex": hex_off(start),
        "endHex": hex_off(end),
        "size": size,
        "sizeHex": hex_off(size),
        "role": role,
        "sample": sample,
    }


def layer_stats(values: list[int]) -> dict:
    counts = Counter(values)
    nonzero = sum(1 for value in values if value)
    return {
        "count": len(values),
        "unique": len(counts),
        "zeroCount": counts.get(0, 0),
        "nonzeroCount": nonzero,
        "topValues": [
            {"value": value, "valueHex": hex_word(value), "count": count}
            for value, count in counts.most_common(12)
        ],
    }


def sample_grid(values: list[int], width: int, height: int, max_w: int = 32, max_h: int = 24) -> list[list[int]]:
    step_x = max(1, (width + max_w - 1) // max_w)
    step_y = max(1, (height + max_h - 1) // max_h)
    rows = []
    for y in range(0, height, step_y):
        row = []
        for x in range(0, width, step_x):
            row.append(values[y * width + x])
        rows.append(row)
    return rows


def summarize_tilemap(path: Path) -> dict | None:
    raw = path.read_bytes()
    decoded = decompress_cns(raw)
    detected = detect_tilemap(decoded)
    if detected is None:
        return None

    header_bytes = detected["headerBytes"]
    width = detected["width"]
    height = detected["height"]
    tile_count = detected["tileCount"]
    layer0_start = header_bytes
    layer0_size = tile_count * 2
    layer1_start = layer0_start + layer0_size
    layer1_size = tile_count * 2
    layer0 = read_words(decoded, layer0_start, tile_count)
    layer1 = read_words(decoded, layer1_start, tile_count)

    if header_bytes == 4:
        header_sample = (
            f"width={width} ({decoded[0]:02x} {decoded[1]:02x}), "
            f"height={height} ({decoded[2]:02x} {decoded[3]:02x})"
        )
    else:
        header_sample = f"width={width} ({decoded[0]:02x}), height={height} ({decoded[1]:02x})"

    return {
        "name": path.name,
        "path": str(path.relative_to(ROOT)),
        "compressedBytes": len(raw),
        "decodedBytes": len(decoded),
        **detected,
        "bytesPerTilePerLayer": 2,
        "bytesPerTileTotal": 4,
        "ranges": [
            range_row(
                "header",
                0,
                header_bytes,
                "map dimensions in tiles; the map name is not stored here",
                header_sample,
            ),
            range_row(
                "layer0",
                layer0_start,
                layer0_size,
                "base tile word grid used for the visible lower map layer",
                ", ".join(hex_word(value) for value in layer0[:8]),
            ),
            range_row(
                "layer1",
                layer1_start,
                layer1_size,
                "upper/flag word grid; original loader copies this grid to the collision flag buffer",
                ", ".join(hex_word(value) for value in layer1[:8]),
            ),
        ],
        "layer0Stats": layer_stats(layer0),
        "layer1Stats": layer_stats(layer1),
        "layer0Sample": sample_grid(layer0, width, height),
        "layer1Sample": sample_grid(layer1, width, height),
        "firstCells": [
            {
                "index": index,
                "x": index % width,
                "y": index // width,
                "layer0": layer0[index],
                "layer0Hex": hex_word(layer0[index]),
                "layer1": layer1[index],
                "layer1Hex": hex_word(layer1[index]),
            }
            for index in range(min(16, tile_count))
        ],
        "notes": [
            "The compressed .cns stream must be decompressed before these offsets apply.",
            "The file name or FLD entry name supplies the map name; no map-name string is present in this tilemap payload.",
            "Layer1 is confirmed as the source copied by the original map loader into the collision flag grid, but the exact pass/block test is EXE collision-helper logic.",
        ],
    }


def build_summary(src_dir: Path) -> dict:
    rows = []
    for path in sorted(src_dir.glob("*.cns")):
        item = summarize_tilemap(path)
        if item is not None:
            rows.append(item)
    counts = Counter(
        "field-map" if row["name"].startswith("map") else "battle-background" if row["name"].startswith("btl_") else "other"
        for row in rows
    )
    return {
        "scope": "decompressed CNS tilemap payload byte layout",
        "sourceDir": str(src_dir.relative_to(ROOT)),
        "tilemapCount": len(rows),
        "counts": dict(sorted(counts.items())),
        "formatSummary": {
            "standard": "u16 width, u16 height, u16 layer0[width*height], u16 layer1[width*height]",
            "compact": "u8 width, u8 height, u16 layer0[width*height], u16 layer1[width*height]",
            "nameSource": "CNS file name / GENSE.FLD entry name, not decompressed payload bytes",
            "collisionSource": "CNS layer1 word grid is copied to the original collision flag buffer; EXE helper interprets it",
        },
        "rows": rows,
    }


def html_page(summary: dict) -> str:
    data = json.dumps(summary, ensure_ascii=False)
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>CNS Tilemap Format</title>",
        "  <style>",
        "    :root{color-scheme:dark;--bg:#101214;--panel:#171a1e;--panel2:#20252b;--line:#303841;--text:#edf0f3;--muted:#aeb7c2;--accent:#7cc7ff;--warn:#ffd166;--good:#7bd88f}",
        "    *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}.topbar{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:16px;align-items:center;padding:14px 18px;background:#0d0f12;border-bottom:1px solid var(--line)}",
        "    h1{font-size:20px;margin:0}h2{font-size:16px;margin:0 0 10px}a{color:var(--accent);text-decoration:none}nav{display:flex;gap:10px;flex-wrap:wrap}.wrap{padding:18px;display:grid;gap:16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px}.grid{display:grid;grid-template-columns:360px minmax(0,1fr);gap:16px}.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap}select,input{background:#0f1115;color:var(--text);border:1px solid var(--line);border-radius:6px;padding:8px 10px}.chip{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:2px 8px;background:var(--panel2);color:var(--muted);white-space:nowrap}.chip.good{color:var(--good);border-color:#2f6b3b}.chip.warn{color:var(--warn);border-color:#765f25}.chip.blue{color:var(--accent);border-color:#285c7e}",
        "    table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid var(--line);padding:7px 8px;vertical-align:top;text-align:left}th{color:#dbe4ee;background:#1d2228}code{color:#f2d479}.muted{color:var(--muted)}.diagram{display:grid;gap:8px}.seg{border:1px solid var(--line);border-radius:6px;overflow:hidden}.seg .bar{height:28px;background:linear-gradient(90deg,#4169e1,#5fb3ff);display:flex;align-items:center;padding:0 8px;color:#081018;font-weight:700}.seg.layer0 .bar{background:linear-gradient(90deg,#7bd88f,#b6f3bb)}.seg.layer1 .bar{background:linear-gradient(90deg,#ffd166,#ff9f68)}.seg .body{padding:8px;background:#111419}.kv{display:grid;grid-template-columns:145px minmax(0,1fr);gap:6px 10px}.kv dt{color:var(--muted)}.kv dd{margin:0;min-width:0}.samplegrid{display:grid;gap:1px;background:#0a0c0f;border:1px solid var(--line);padding:3px;overflow:auto;max-width:100%}.cell{width:13px;height:13px;border-radius:2px;background:#20242a}.legend{display:flex;gap:8px;flex-wrap:wrap}.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width:980px){.grid,.two{grid-template-columns:1fr}}",
        "  </style>",
        "</head>",
        "<body>",
        "  <div class=\"topbar\"><h1>CNS Tilemap Format</h1><nav><a href=\"../web/index.html\">관리 홈</a><a href=\"../docs/CNS_FORMAT.md\">CNS 문서</a><a href=\"map_gallery.html\">맵 갤러리</a></nav></div>",
        "  <div class=\"wrap\">",
        "    <section class=\"panel\"><div class=\"filters\"><select id=\"mapSelect\"></select><input id=\"search\" type=\"search\" placeholder=\"map1_02b, btl_a1\"><span id=\"summary\" class=\"muted\"></span></div></section>",
        "    <section class=\"grid\"><aside class=\"panel\"><h2>Payload Summary</h2><div id=\"meta\"></div></aside><main class=\"panel\"><h2>Byte Ranges After Decompression</h2><div id=\"diagram\" class=\"diagram\"></div></main></section>",
        "    <section class=\"two\"><div class=\"panel\"><h2>Layer0 Sample Grid</h2><div id=\"layer0Grid\"></div></div><div class=\"panel\"><h2>Layer1 / Collision Flag Source Sample Grid</h2><div id=\"layer1Grid\"></div></div></section>",
        "    <section class=\"panel\"><h2>First Cells</h2><div id=\"cells\"></div></section>",
        "  </div>",
        "  <script>",
        f"    const data={data};",
        "    const $=id=>document.getElementById(id);",
        "    const esc=v=>String(v??'').replace(/[&<>\"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[ch]));",
        "    const pct=(a,b)=>b?Math.round(a/b*1000)/10:0;",
        "    function chip(text,cls=''){return `<span class=\"chip ${cls}\">${esc(text)}</span>`}",
        "    function renderOptions(){const q=$('search').value.trim().toLowerCase();const rows=data.rows.filter(r=>!q||r.name.toLowerCase().includes(q));$('mapSelect').innerHTML=rows.map(r=>`<option value=\"${esc(r.name)}\">${esc(r.name)} (${r.width}x${r.height})</option>`).join('');$('summary').textContent=`tilemaps ${data.tilemapCount}, visible ${rows.length}, field ${data.counts['field-map']||0}, battle ${data.counts['battle-background']||0}`;if(rows.length)render(rows[0].name)}",
        "    function color(v){if(!v)return '#20242a';const hue=(v*47)%360;const light=42+Math.min(28,Math.log2(v+1)*4);return `hsl(${hue} 62% ${light}%)`}",
        "    function gridHtml(rows){const cols=rows[0]?.length||1;return `<div class=\"samplegrid\" style=\"grid-template-columns:repeat(${cols},13px)\">${rows.flatMap(row=>row.map(v=>`<span class=\"cell\" title=\"${esc(v)} / 0x${Number(v).toString(16).padStart(4,'0')}\" style=\"background:${color(v)}\"></span>`)).join('')}</div><p class=\"muted\">Downsampled grid. Hover cells for sampled word values.</p>`}",
        "    function statsHtml(label, stats){return `<div class=\"ref\"><b>${esc(label)}</b><br>${chip(`unique ${stats.unique}`,'blue')} ${chip(`zero ${stats.zeroCount}`)} ${chip(`nonzero ${stats.nonzeroCount}`,'warn')}<table><thead><tr><th>value</th><th>count</th></tr></thead><tbody>${stats.topValues.slice(0,6).map(v=>`<tr><td><code>${esc(v.valueHex)}</code></td><td>${v.count}</td></tr>`).join('')}</tbody></table></div>`}",
        "    function rangeHtml(row,total){const cls=row.label==='layer0'?'layer0':row.label==='layer1'?'layer1':'';return `<div class=\"seg ${cls}\"><div class=\"bar\" style=\"width:${Math.max(3,pct(row.size,total))}%\">${esc(row.label)} · ${esc(row.startHex)}..${esc(row.endHex)} · ${row.size} bytes</div><div class=\"body\"><dl class=\"kv\"><dt>role</dt><dd>${esc(row.role)}</dd><dt>sample</dt><dd><code>${esc(row.sample)}</code></dd></dl></div></div>`}",
        "    function render(name){const row=data.rows.find(r=>r.name===name)||data.rows[0];if(!row)return;$('mapSelect').value=row.name;history.replaceState(null,'',`?map=${encodeURIComponent(row.name)}`);$('meta').innerHTML=`<dl class=\"kv\"><dt>file</dt><dd><code>${esc(row.name)}</code></dd><dt>name source</dt><dd>file/FLD entry name, not payload bytes</dd><dt>variant</dt><dd>${chip(row.variant,row.headerBytes===2?'warn':'blue')}</dd><dt>dimensions</dt><dd>${row.width} x ${row.height} tiles (${row.tileCount} cells)</dd><dt>compressed</dt><dd>${row.compressedBytes} bytes</dd><dt>decompressed</dt><dd>${row.decodedBytes} bytes</dd><dt>formula</dt><dd><code>${row.headerBytes} + width*height*2 + width*height*2</code></dd><dt>collision</dt><dd>layer1 is the source grid copied to the original collision flag buffer; EXE helper decides pass/block.</dd></dl><div class=\"legend\">${chip('header','blue')} ${chip('layer0 visible base','good')} ${chip('layer1 flag/upper','warn')}</div><hr>${statsHtml('layer0 stats',row.layer0Stats)}${statsHtml('layer1 stats',row.layer1Stats)}`;$('diagram').innerHTML=row.ranges.map(r=>rangeHtml(r,row.decodedBytes)).join('');$('layer0Grid').innerHTML=gridHtml(row.layer0Sample);$('layer1Grid').innerHTML=gridHtml(row.layer1Sample);$('cells').innerHTML=`<table><thead><tr><th>index</th><th>x,y</th><th>layer0</th><th>layer1</th></tr></thead><tbody>${row.firstCells.map(c=>`<tr><td>${c.index}</td><td>${c.x},${c.y}</td><td><code>${esc(c.layer0Hex)}</code></td><td><code>${esc(c.layer1Hex)}</code></td></tr>`).join('')}</tbody></table>`}",
        "    $('search').addEventListener('input',renderOptions);$('mapSelect').addEventListener('change',e=>render(e.target.value));renderOptions();const params=new URLSearchParams(location.search);const map=params.get('map');if(map&&data.rows.some(r=>r.name===map))render(map);",
        "  </script>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "cns_tilemap_format.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--src-dir", type=Path, default=ROOT / "extract_fld")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.src_dir)
    write_outputs(summary, args.out_dir)
    print(
        "wrote CNS tilemap format summary -> "
        f"{args.out_dir / 'cns_tilemap_format.json'} ({summary['tilemapCount']} tilemaps)"
    )


if __name__ == "__main__":
    main()
