#!/usr/bin/env python3
"""Rebuild the pinned NZ:P FTE edition and package native PK3 volumes.

Normal application builds use the checked-in runtime. This explicit update
requires make, clang, uv and Emscripten 3.1.57 on PATH (source emsdk_env.sh).
"""
import argparse
import hashlib
import json
import shutil
import struct
import subprocess
import tarfile
import urllib.request
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
CACHE = Path('/tmp/bgn-nzp')
OUT = ROOT / 'public/runtime/nzp-v1'
ENGINE = 'f68a2b547d2dc4bf6886b922baa1bff487cc5038'
QUAKEC = '7ab55bf7c4dbd3550921687ab7a002d0d8f7a7aa'
WEB = '7f04e06502422630f69295ecacca75072458ab13'
INPUTS = {
    'engine.tar.gz': (f'https://codeload.github.com/nzp-team/fteqw/tar.gz/{ENGINE}', 'c78d20f0506e91ec24e4badb2215272bb02391ef0ea9c45b51f4c1ee3b26a6dc'),
    'quakec-fte.tar.gz': (f'https://codeload.github.com/nzp-team/quakec/tar.gz/{QUAKEC}', '05c883a04ce0b961512d0305c6dbd33b25a8960fcfd42bf3732296bff6cc73bd'),
    'game.pk3': (f'https://raw.githubusercontent.com/nzp-team/nzp-team.github.io/{WEB}/nzp/game.pk3', '847a6fb7c95b26eab0fdbfa31223a225608a5f9f7af4f88af1e04a1c0a14637d'),
    'zlib.tar.gz': ('https://zlib.net/fossils/zlib-1.3.1.tar.gz', '9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23'),
}


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def fetch(name):
    path = CACHE / name
    url, expected = INPUTS[name]
    if not path.exists() or digest(path) != expected:
        for attempt in range(3):
            try:
                urllib.request.urlretrieve(url, path)
                break
            except OSError:
                if attempt == 2:
                    raise
    assert digest(path) == expected, f'Unexpected upstream bytes: {name}'
    return path


def unpack(archive, target):
    if target.exists():
        shutil.rmtree(target)
    target.mkdir(parents=True)
    with tarfile.open(archive) as source:
        for member in source.getmembers():
            parts = Path(member.name).parts[1:]
            if not parts:
                continue
            assert '..' not in parts and not member.issym() and not member.islnk()
            member.name = str(Path(*parts))
            source.extract(member, target)


def run(args, cwd):
    subprocess.run(args, cwd=cwd, check=True)


def build():
    engine, qc = CACHE / 'engine', CACHE / 'quakec-fte'
    unpack(fetch('engine.tar.gz'), engine)
    unpack(fetch('quakec-fte.tar.gz'), qc)
    common = engine / 'engine/common/common.c'
    common.write_text(common.read_text().replace('#include "quakedef.h"', '#include "quakedef.h"\n\n/* Browser Games Now, 2026-10-18: Emscripten version macros. */\n#ifdef FTE_TARGET_WEB\n#include <emscripten/version.h>\n#endif'))
    config = engine / 'engine/common/config_nzportable.h'
    config.write_text(config.read_text().replace('#define VOICECHAT', '// Browser Games Now, 2026-10-18: Solo build without voice chat.\n//#define VOICECHAT').replace('#define HAVE_OPUS', '//#define HAVE_OPUS').replace('//-DNO_OPUS', '-DNO_OPUS').replace('-DOPUS_STATIC', '//-DOPUS_STATIC'))
    shutil.copy2(fetch('zlib.tar.gz'), engine / 'engine/zlib-1.3.1.tar.gz')
    run(['make', 'web-rel', 'FTE_CONFIG=nzportable', 'USE_OPUS=0', 'PKGCONFIG=/usr/bin/false', 'AR=emar', 'ARFLAGS=rc', 'LDFLAGS=-sSTACK_SIZE=5242880', '-j8'], engine / 'engine')
    run(['make', 'CC=clang', 'COMMON_OBJS=comprout.o hash.o qcc_cmdlib.o qcd_main.o decomp.o'], engine / 'engine/qclib')
    venv = CACHE / 'qc-python'
    run(['uv', 'venv', '--python', '3.13', str(venv)], CACHE)
    run(['uv', 'pip', 'install', '--python', str(venv / 'bin/python'), 'pandas==2.2.3', 'numpy==2.2.6', 'fastcrc==0.5.0', 'colorama==0.4.6'], CACHE)
    run([str(venv / 'bin/python'), 'bin/qc_hash_generator.py', '-i', 'tools/asset_conversion_table.csv', '-o', 'source/server/hash_table.qc'], qc)
    menu = qc / 'source/menu/menu_main.qc'
    text = menu.read_text().replace('string menu_main_buttons[6] = {"mm_start", "mm_coop",', '// Browser Games Now, 2026-10-18: Solo-only hosted edition.\nstring menu_main_buttons[5] = {"mm_start",')
    menu.write_text('\n'.join(line for line in text.split('\n') if 'Menu_Button(2, "mm_coop"' not in line and 'Menu_SocialBadge(' not in line))
    (qc / 'build/fte').mkdir(parents=True, exist_ok=True)
    for part in ['ssqc', 'csqc', 'menu']:
        run([str(engine / 'engine/qclib/fteqcc.bin'), '-srcfile', f'progs/{part}.src', '-DFTE', '-O3', '-Ono-compound_jumps', '-Wall'], qc)


def sky(face):
    """Original 64px dusk gradient, CC0; no third-party image input."""
    size = 64
    header = struct.pack('<BBBHHBHHHHBB', 0, 0, 2, 0, 0, 0, 0, 0, size, size, 24, 32)
    pixels = bytearray()
    for y in range(size):
        level = 0 if face == 'UP' else 1 if face == 'DN' else y / (size - 1)
        color = [int(a + (b - a) * level) for a, b in zip((24, 32, 48), (86, 82, 76))]
        pixels.extend(bytes(reversed(color)) * size)
    return header + pixels


def package():
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / 'nzp').mkdir(exist_ok=True)
    for old in (OUT / 'nzp').glob('*.pk3'):
        old.unlink()
    engine, qc = CACHE / 'engine', CACHE / 'quakec-fte'
    for name in ['ftewebgl.js', 'ftewebgl.wasm']:
        shutil.copy2(engine / 'engine/release' / name, OUT / name)
    shutil.copy2(engine / 'LICENSE', OUT / 'LICENSE')
    # Split at 20 MiB of uncompressed entries, so even incompressible data fits.
    groups, current, size = [], [], 0
    excluded = []
    with zipfile.ZipFile(fetch('game.pk3')) as source:
        original = {info.filename: source.read(info) for info in source.infolist() if not info.is_dir()}
    for name in list(original):
        if name.startswith('gfx/env/Sky_Waldon01') or name == 'gfx/env/READMEWaldon.txt':
            excluded.append(name)
            del original[name]
    entries = dict(original)
    for face in ['UP', 'DN', 'LF', 'RT', 'FT', 'BK']:
        entries[f'gfx/env/Sky_Waldon01{face}.tga'] = sky(face)
    for name, data in sorted(entries.items()):
        if current and size + len(data) > 20 * 1024**2:
            groups.append(current)
            current, size = [], 0
        current.append((name, data))
        size += len(data)
    groups.append(current)
    restored = {}
    for i, group in enumerate(groups):
        path = OUT / f'nzp/pak{i}.pk3'
        with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as target:
            for name, data in group:
                target.writestr(name, data)
        with zipfile.ZipFile(path) as target:
            for name in target.namelist():
                assert name not in restored
                restored[name] = target.read(name)
    assert restored == entries, 'PK3 split must preserve every included entry'
    with zipfile.ZipFile(OUT / 'nzp/progs.pk3', 'w', zipfile.ZIP_DEFLATED) as target:
        for name in ['qwprogs.dat', 'csprogs.dat', 'menu.dat']:
            target.write(qc / 'build/fte' / name, name)
    (OUT / 'default.fmf').write_text('game nzp\nname "Nazi Zombies Portable"\nbasegame nzp\nmainconfig "nzportable.cfg"\ndisablehomedir 1\n')
    notices = ('Nazi Zombies: Portable by NZ:P Team and contributors.\nhttps://github.com/nzp-team/nzportable\nCode: GPLv2 or later. Assets: CC BY-SA 4.0, with exceptions below.\n\nBrowser Games Now, 2026-10-18: rebuilt pinned FTE engine and QuakeC;\nSolo-only menu, removed social badges and web counter; native PK3 volumes.\nExcluded all 6 Sky_Waldon01 JPGs and their README because it restricts\ncommercial use. Replaced with original procedural dusk skies, dedicated to\nthe public domain under CC0. Four maps retain their geometry/gameplay.\nRemaining included asset bytes are unchanged.\nNo commercial Quake or Call of Duty game files required.\n\n' + original['licenses/attributions.txt'].decode())
    (OUT / 'NOTICES.txt').write_text(notices)
    (OUT / 'ASSETS-LICENSE.txt').write_bytes(original['licenses/cc-by-sa-4.0.txt'])
    for name, directory in [('engine-source.tar.gz', engine), ('quakec-source.tar.gz', qc)]:
        with tarfile.open(OUT / name, 'w:gz') as archive:
            # Source only, including our patches and generated hash table.
            for path in sorted(directory.rglob('*')):
                rel = path.relative_to(directory)
                if any(part.startswith('.') for part in rel.parts) or rel.parts[0] in ['build'] or 'release' in rel.parts or any(part.startswith('libs-') for part in rel.parts):
                    continue
                if path.name.startswith('fteqcc-cli-'):
                    continue
                if path.is_file() and path.suffix not in ['.o', '.a', '.bin', '.gz', '.zip', '.exe']:
                    archive.add(path, arcname=f'{directory.name}/{rel}', recursive=False)
    shutil.copy2(fetch('zlib.tar.gz'), OUT / 'zlib-source.tar.gz')
    shutil.copy2(__file__, OUT / 'prepare-nzp.py')
    (OUT / 'source.html').write_text('<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>NZ:P source and credits</title><h1>NZ:P hosted edition</h1><p>GPLv2 code; NZ:P Team assets under CC BY-SA 4.0 with credited exceptions.</p><ul>' + ''.join(f'<li><a href="{p}">{p}</a></li>' for p in ['engine-source.tar.gz', 'quakec-source.tar.gz', 'zlib-source.tar.gz', 'prepare-nzp.py', 'BUILDING.txt', 'LICENSE', 'ASSETS-LICENSE.txt', 'NOTICES.txt']) + '</ul>')
    (OUT / 'BUILDING.txt').write_text(f'Browser Games Now NZ:P edition, 2026-10-18\nFTE: {ENGINE}\nQuakeC: {QUAKEC}\nWeb asset release: {WEB}\n\nRun prepare-nzp.py from a browser-arcade checkout with Emscripten 3.1.57\nactivated, make, clang, uv and Python 3.13 available. It downloads SHA256-\nverified inputs, builds the engine/compiler and three QC programs, and\nsplits unchanged asset entries into native PK3 volumes. It generates six\nCC0 procedural skies replacing the commercial-restricted Waldon images.\nThe engine source archive includes our Emscripten header and no-voice\npatches. Link with -sSTACK_SIZE=5242880 (5 MiB), required for FTE\nlarge stack-local buffers; modern Emscripten defaults to 64 KiB.\nQuakeC includes the Solo menu change and generated hash table.\nFor an independent rebuild use the same make/QC commands in prepare-nzp.py.\nSource archive may differ in timestamps and compiler build-date strings.\n')
    files = [{'path': str(p.relative_to(OUT)), 'bytes': p.stat().st_size, 'sha256': digest(p)} for p in sorted(OUT.rglob('*')) if p.is_file() and p.name != 'manifest.json']
    assert all(f['bytes'] <= 25 * 1024**2 for f in files), 'Static Assets limit'
    manifest = {'engineCommit': ENGINE, 'quakecCommit': QUAKEC, 'webAssetCommit': WEB, 'assetInputSha256': INPUTS['game.pk3'][1], 'excludedAssets': sorted(excluded), 'unchangedAssetEntries': len(original), 'skyReplacement': 'original procedural dusk, CC0', 'files': files, 'totalBytes': sum(f['bytes'] for f in files)}
    (OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
    print(json.dumps({'files': len(files), 'totalBytes': manifest['totalBytes'], 'assetVolumes': len(groups)}))


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--package-only', action='store_true', help='reuse existing pinned build in /tmp/bgn-nzp')
    args = parser.parse_args()
    CACHE.mkdir(exist_ok=True)
    if not args.package_only:
        build()
    package()
