#!/usr/bin/env python3
"""
Rebuild the 7DTD cluster canonical .nim (re-anchor to season10's CURRENT state).

WHAT THIS DOES
  Produces a complete, season10-anchored canonical block/item id map. Every id
  the anchor (season10) currently uses is preserved BYTE-FOR-BYTE; every other
  name from the "universe" (a prior complete canonical) is appended at a fresh,
  non-colliding id above the anchor's max. The result is a superset that decodes
  season10's world + all shared inventories identically, and is COMPLETE so the
  engine never appends => the table stays frozen forever.

WHEN TO RUN
  * season10's live .nim grew (players placed new shapes) and you need to
    re-stamp it — re-anchor so the canonical preserves the new ids.
  * You just want to regenerate the deployed canonical from the anchor.
  NOTE: if MODS CHANGED (new blocks/items added), the universe won't contain the
  new names. Re-harvest first — see CANONICAL_NIM_FREEZE.md "Rebuilding after a
  mod change". This script only RE-ANCHORS an existing complete name set.

USAGE
  1. STOP season10 (panel Stop) so its .nim is final.
  2. Copy season10's CURRENT blockmappings.nim + itemmappings.nim out (the ANCHOR):
       docker run --rm -v panel-rg-season-10-saves:/sv:ro -v /tmp/x:/o debian:12-slim \
         sh -c 'cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/blockmappings.nim" /o/anchor_block.nim;
                cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/itemmappings.nim" /o/anchor_item.nim'
  3. python3 rebuild.py anchor_block.nim anchor_item.nim \
                        UNIVERSE_block.nim UNIVERSE_item.nim  OUT_DIR
       (UNIVERSE = a prior complete canonical, e.g. ~/nimcanon/canonical_final/*.nim)
  4. Confirm "GATE PASS: True". Then copy OUT_DIR/*.nim into the cluster canonical
     dir and chmod 644:
       cp OUT_DIR/*.nim /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/
       chmod 644 /home/refuge/panel/data/7dtdcluster/<cluster_id>/canonical/*.nim
  5. Rebuild each cluster member (POST /api/instances/<id>/rebuild) — the entrypoint
     guard re-stamps the new canonical on boot. (If a member was STOPPED, /rebuild
     leaves it stopped — Start it after.)

.nim BINARY FORMAT (little-endian, no trailing bytes):
  u32 version(=1), u32 count, then count records of:
     u16 id, u16 flag (items=1, blocks=0), u8 namelen, <namelen> UTF-8 name bytes
  Records are stored sorted by ascending id. Ids are SPARSE.
"""
import struct, sys, hashlib, os


def read_nim(p):
    b = open(p, "rb").read()
    ver, cnt = struct.unpack_from("<II", b, 0)
    off = 8
    recs = []
    for _ in range(cnt):
        i, fl, nl = struct.unpack_from("<HHB", b, off)
        off += 5
        nm = b[off:off + nl].decode("utf-8")
        off += nl
        recs.append((i, fl, nm))
    assert off == len(b), f"trailing bytes parsing {p} ({off} != {len(b)})"
    return recs


def write_nim(recs, p):
    out = struct.pack("<II", 1, len(recs))
    for i, fl, nm in recs:
        nb = nm.encode("utf-8")
        out += struct.pack("<HHB", i, fl, len(nb)) + nb
    open(p, "wb").write(out)
    return out


def build(anchor, universe, flag):
    """anchor ids are SACRED (verbatim). append every universe name not present."""
    recs = list(anchor)
    present = {nm for _, _, nm in anchor}
    used = {i for i, _, _ in anchor}
    nid = max(used) + 1
    for nm in sorted({nm for _, _, nm in universe} - present):
        while nid in used:
            nid += 1
        if nid >= 65536:
            raise SystemExit("FATAL: ran out of u16 id space (>65535)")
        recs.append((nid, flag, nm))
        used.add(nid)
        nid += 1
    recs.sort(key=lambda r: r[0])
    return recs


def main():
    if len(sys.argv) != 6:
        print(__doc__)
        sys.exit(2)
    ab, ai, ub, ui, outdir = sys.argv[1:6]
    os.makedirs(outdir, exist_ok=True)
    anchor_b, anchor_i = read_nim(ab), read_nim(ai)
    univ_b, univ_i = read_nim(ub), read_nim(ui)
    fb, fi = build(anchor_b, univ_b, 0), build(anchor_i, univ_i, 1)
    db = write_nim(fb, os.path.join(outdir, "blockmappings.nim"))
    di = write_nim(fi, os.path.join(outdir, "itemmappings.nim"))

    fbm = {i: nm for i, _, nm in fb}
    fim = {i: nm for i, _, nm in fi}
    alt_b = sum(1 for i, _, nm in anchor_b if fbm.get(i) != nm)
    alt_i = sum(1 for i, _, nm in anchor_i if fim.get(i) != nm)
    bids = [i for i, _, _ in fb]
    bnm = [nm for _, _, nm in fb]
    iids = [i for i, _, _ in fi]
    inm = [nm for _, _, nm in fi]
    compl_b = {nm for _, _, nm in univ_b} <= set(bnm)
    compl_i = {nm for _, _, nm in univ_i} <= set(inm)

    print(f"blocks={len(fb)} maxid={max(bids)} | items={len(fi)} maxid={max(iids)}")
    print(f"block sha256 = {hashlib.sha256(db).hexdigest()}")
    print(f"item  sha256 = {hashlib.sha256(di).hexdigest()}")
    print(f"ANCHOR altered (MUST be 0): block={alt_b} item={alt_i}")
    print(f"dup ids: block={len(bids)-len(set(bids))} item={len(iids)-len(set(iids))}"
          f" | dup names: block={len(bnm)-len(set(bnm))} item={len(inm)-len(set(inm))}")
    print(f"complete (universe names all present): block={compl_b} item={compl_i}")
    ok = (alt_b == 0 and alt_i == 0
          and len(bids) == len(set(bids)) and len(iids) == len(set(iids))
          and len(bnm) == len(set(bnm)) and len(inm) == len(set(inm))
          and compl_b and compl_i and max(bids) < 65536 and max(iids) < 65536)
    print("GATE PASS:", ok)
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    main()
