#!/usr/bin/env python3
"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE
table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits.

VENDORED, SELF-CONTAINED copy of nim-freeze/remap_7dt.py with nimtool.read_nim
inlined and the host sys.path dependency removed, so it runs inside the
panel-7dtd container (python3-minimal, no extra packages). Keep behaviour
byte-identical to remap_7dt.py — the entrypoint's auto-provision relies on
apply()'s exit code (0 = remapped + self-verified safe, 1 = refuse to start).

7dt format (little-endian, no trailing bytes):
  [0]   u8  version (=6)
  [1:5] u32 count
  then count * 17-byte records; block id = u16 @ record offset +12,
  low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement.
Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF).
"""
import sys, struct

REC = 17; HDR = 5; IDOFF = 12


# --- inlined from nimtool.py (read_nim / parse_nim_bytes), byte-faithful ---
def read_nim(path):
    with open(path, "rb") as f:
        data = f.read()
    return parse_nim_bytes(data)


def parse_nim_bytes(data):
    if len(data) < 8:
        raise ValueError("file too short for header (%d bytes)" % len(data))
    version, count = struct.unpack_from("<II", data, 0)
    off = 8
    records = []
    for i in range(count):
        if off + 5 > len(data):
            raise ValueError("record %d: truncated header at offset %d (len=%d)" % (i, off, len(data)))
        rid, flag = struct.unpack_from("<HH", data, off)
        namelen = data[off + 4]
        off += 5
        if off + namelen > len(data):
            raise ValueError("record %d (id=%d): name truncated at offset %d (len=%d)" % (i, rid, off, len(data)))
        name = data[off:off + namelen].decode("utf-8")
        off += namelen
        records.append((rid, flag, name))
    if off != len(data):
        raise ValueError("trailing bytes: consumed %d of %d" % (off, len(data)))
    return records, version
# --- end inlined ---


def load_id2name(path):
    if path.endswith('.nim'):
        recs, _ = read_nim(path)
        return {i: n for i, _, n in recs}
    d = {}
    for line in open(path, encoding='utf-8', errors='replace'):
        line = line.rstrip('\n')
        if not line: continue
        p = line.split('\t')
        if len(p) >= 2 and p[0].isdigit():
            d[int(p[0])] = p[1]
    return d

def load_name2id(path):
    recs, _ = read_nim(path)
    return {n: i for i, _, n in recs}

def canon_idset(path):
    recs, _ = read_nim(path)
    return {i for i, _, n in recs}

def decode_ids(b):
    ver = b[0]; count = struct.unpack_from('<I', b, 1)[0]
    assert len(b) == HDR + REC*count, f"BAD LEN {len(b)} != {HDR+REC*count} (ver={ver} count={count})"
    out = []
    for r in range(count):
        pos = HDR + r*REC + IDOFF
        orig = struct.unpack_from('<H', b, pos)[0]
        out.append((pos, orig, orig & 0x7FFF, orig & 0x8000))
    return ver, count, out

def analyze(native, canon, infile):
    b = open(infile, 'rb').read()
    n2 = load_id2name(native); c2 = load_name2id(canon)
    ver, count, recs = decode_ids(b)
    from collections import Counter
    dist = Counter(r[2] for r in recs)
    unmap_id = sorted(i for i in dist if i not in n2)
    nm_for = {i: n2.get(i) for i in dist}
    unmap_name = sorted(set(nm_for[i] for i in dist if nm_for[i] is not None and nm_for[i] not in c2))
    inst_unmap_id = sum(dist[i] for i in unmap_id)
    print(f"  file={infile.split('/')[-1]} ver={ver} count={count} distinct_ids={len(dist)}")
    print(f"  distinct ids NOT in native table: {len(unmap_id)}  (instances {inst_unmap_id}/{count} = {100*inst_unmap_id/count:.1f}%)")
    if unmap_id[:8]: print(f"    e.g. {unmap_id[:8]}")
    print(f"  distinct names NOT in canon: {len(unmap_name)}")
    if unmap_name[:8]: print(f"    e.g. {unmap_name[:8]}")
    nchg = sum(dist[i] for i in dist if i in n2 and n2[i] in c2 and (c2[n2[i]] & 0x7FFF) != i)
    print(f"  instances whose canon id DIFFERS from native (would be rewritten): {nchg} ({100*nchg/count:.1f}%)")
    ok = (len(unmap_id) == 0 and len(unmap_name) == 0)
    print(f"  >>> FULLY REMAPPABLE: {ok}")
    return ok

def decodecheck(table, infile):
    b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table))
    ver, count, recs = decode_ids(b)
    from collections import Counter
    dist = Counter(r[2] for r in recs)
    miss = sum(dist[i] for i in dist if i not in ids)
    print(f"  {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%)  [clean world should be ~0%]")

def apply(native, canon, infile, outfile):
    src = open(infile, 'rb').read(); b = bytearray(src)
    n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon)
    ver, count, recs = decode_ids(b)
    changed = 0; bad = []
    for (pos, orig, bid, high) in recs:
        name = n2.get(bid)
        if name is None: bad.append(('noid', bid)); continue
        cid = c2.get(name)
        if cid is None: bad.append(('noname', name)); continue
        new = high | (cid & 0x7FFF)
        if new != orig:
            struct.pack_into('<H', b, pos, new); changed += 1
    if bad:
        print(f"  !! {len(bad)} unmappable records, ABORT (e.g. {bad[:5]})"); return False
    open(outfile, 'wb').write(b)
    assert len(b) == len(src), "LENGTH CHANGED"
    diffbytes = [k for k in range(len(src)) if src[k] != b[k]]
    badpos = [k for k in diffbytes if (k - HDR) % REC not in (IDOFF, IDOFF+1)]
    ver2, count2, recs2 = decode_ids(bytes(b))
    unresolved = sum(1 for (_,o,bid,_) in recs2 if bid not in cset)
    print(f"  wrote {outfile.split('/')[-1]}: count={count}(unchanged) len={len(b)}(unchanged) records_rewritten={changed}")
    print(f"  byte-diff outside id field: {len(badpos)} (MUST be 0)   ids unresolved in canon after remap: {unresolved} (MUST be 0)")
    return len(badpos) == 0 and unresolved == 0

if __name__ == '__main__':
    cmd = sys.argv[1]
    if cmd == 'analyze':   analyze(sys.argv[2], sys.argv[3], sys.argv[4])
    elif cmd == 'decodecheck': decodecheck(sys.argv[2], sys.argv[3])
    elif cmd == 'apply':   sys.exit(0 if apply(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) else 1)
