"""Run on the web VPS via Invoke-WebVps; stage first, activate after local QA.

Inputs are task-specific tar archives and a hash manifest, never a content patch.
Retains old immutable releases and rollback copies. Does not manage game services.
"""
import datetime
import gzip
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tarfile
import urllib.error
import urllib.request

MODE, TASK, SHELL_HASH = sys.argv[1:]
assert MODE in ('prepare', 'activate') and re.fullmatch(r'[a-z0-9-]+', TASK)
assert re.fullmatch(r'[a-f0-9]{64}', SHELL_HASH)
ROOT = Path('/var/www/html/ro-th-online')
STAGE = ROOT / 'staging' / TASK
INDEX = ROOT / 'mobile/index.html'
CONFIG = Path('/etc/apache2/sites-enabled/zz-ro-th-download-ssl.conf').resolve(strict=True)
PUBLIC = ROOT / 'public/Build'
BACKUP = ROOT / 'deploy' / TASK


def sha(path):
    with open(path, 'rb') as stream:
        return hashlib.file_digest(stream, 'sha256').hexdigest()


def extract(archive, destination, expected):
    with tarfile.open(archive) as tar:
        members = tar.getmembers()
        assert len(members) == len(expected) and {m.name for m in members} == set(expected)
        for member in members:
            assert member.isfile() and '/' not in member.name and '\\' not in member.name
            with tar.extractfile(member) as source, open(destination / member.name, 'xb') as target:
                shutil.copyfileobj(source, target)


def small_files(manifest):
    for name, field in [('index.html', 'indexHash'), ('chunk-trial.js', 'chunkHash'), ('ro-th-online-ssl.conf', 'configHash')]:
        assert sha(STAGE / name) == manifest[field], name


def unchanged_live(manifest):
    assert sha(INDEX) == manifest['previousIndexHash'], 'Live index changed'
    assert sha(CONFIG) == manifest['previousConfigHash'], 'Live config changed'


def stored_build_names(manifest):
    return [name for name in manifest['files'] if name != 'WebPreview.data' or not manifest.get('compact')]


def request(path, method='GET'):
    req = urllib.request.Request('https://ro-th.online' + path, method=method)
    try:
        return urllib.request.urlopen(req, timeout=60)
    except urllib.error.HTTPError as error:
        return error


def reload_apache():
    subprocess.run(['apache2ctl', 'configtest'], check=True, stdout=sys.stderr)
    subprocess.run(['systemctl', 'reload', 'apache2'], check=True, stdout=sys.stderr)


if MODE == 'prepare':
    shell = ROOT / 'staging' / (TASK + '-shell.tar')
    assert sha(shell) == SHELL_HASH
    STAGE.mkdir(exist_ok=False)
    extract(shell, STAGE, ['release.json', 'index.html', 'chunk-trial.js', 'ro-th-online-ssl.conf'])
    manifest = json.loads((STAGE / 'release.json').read_text(encoding='utf-8-sig'))
    assert re.fullmatch(r'mobile-[a-f0-9]{8}', manifest['release'])
    assert manifest['release'] == 'mobile-' + manifest['dataHash'][:8]
    assert set(manifest['files']) in ({'WebPreview.data', 'WebPreview.wasm', 'WebPreview.framework.js', 'WebPreview.loader.js'}, {'WebPreview.data', 'WebPreview.wasm', 'WebPreview.framework.js', 'WebPreview.loader.js', 'Tahoma.ttf'})
    unchanged_live(manifest)
    small_files(manifest)
    archive = ROOT / 'staging' / (TASK + '-build.tar.gz')
    assert sha(archive) == manifest['archiveHash']
    build = STAGE / 'build'
    build.mkdir()
    compact = manifest.get('compact')
    payload_names = stored_build_names(manifest)
    if compact:
        assert re.fullmatch(r'[a-f0-9]{64}', compact['previousDataHash'])
        expected_count = (manifest['files']['WebPreview.data']['bytes'] + 4194303) // 4194304
        assert len(compact['parts']) == expected_count
        for i, part in enumerate(compact['parts']):
            assert part['name'] == f"WebPreview.data.{manifest['dataHash'][:8]}.part{i:03}.gz"
            assert re.fullmatch(r'[a-f0-9]{64}', part['rawHash']) and 0 < part['rawBytes'] <= 4194304
            if 'reuse' in part:
                assert re.fullmatch(r'WebPreview\.data\.' + compact['previousDataHash'][:8] + r'\.part[0-9]{3}\.gz', part['reuse'])
            else:
                payload_names.append(part['name'])
        with tarfile.open(archive) as tar:
            expanded_bytes = sum(member.size for member in tar.getmembers())
        assert shutil.disk_usage(ROOT).free > expanded_bytes + 128 * 1024 * 1024, 'Insufficient staging reserve'
    extract(archive, build, payload_names)
    for name in stored_build_names(manifest):
        expected = manifest['files'][name]
        assert (build / name).stat().st_size == expected['bytes'] and sha(build / name) == expected['sha256'], name
    data = build / 'WebPreview.data'
    if not compact:
        assert sha(data) == manifest['dataHash']
    prefix = 'WebPreview.data.' + manifest['dataHash'][:8]
    compressed = 0
    count = 0
    decoded = hashlib.sha256()
    if compact:
        raw_bytes = 0
        for part in compact['parts']:
            target = STAGE / part['name']
            if 'reuse' in part:
                source = PUBLIC / part['reuse']
                assert not source.is_symlink() and source.resolve(strict=True).parent == PUBLIC.resolve(strict=True)
                encoded = source.read_bytes()
                chunk = gzip.decompress(encoded)
                assert hashlib.sha256(chunk).hexdigest() == part['rawHash'] and len(chunk) == part['rawBytes']
                os.link(source, target)
            else:
                source = build / part['name']
                assert sha(source) == part['encodedHash'] and source.stat().st_size == part['encodedBytes']
                encoded = source.read_bytes()
                chunk = gzip.decompress(encoded)
                assert hashlib.sha256(chunk).hexdigest() == part['rawHash'] and len(chunk) == part['rawBytes']
                source.rename(target)
            decoded.update(chunk)
            raw_bytes += len(chunk)
            count += 1
            compressed += len(encoded)
        assert raw_bytes == manifest['files']['WebPreview.data']['bytes']
    else:
        with data.open('rb') as stream:
            while chunk := stream.read(4194304):
                encoded = gzip.compress(chunk, compresslevel=6, mtime=0)
                (STAGE / f'{prefix}.part{count:03}.gz').write_bytes(encoded)
                decoded.update(gzip.decompress(encoded))
                count += 1
                compressed += len(encoded)
    assert decoded.hexdigest() == manifest['dataHash']
    brotli = STAGE / (prefix + '.br')
    script = """const fs=require('fs'),z=require('zlib'),{pipeline}=require('stream/promises');
pipeline(fs.createReadStream(process.argv[1]),z.createBrotliCompress({params:{[z.constants.BROTLI_PARAM_QUALITY]:4}}),fs.createWriteStream(process.argv[2],{flags:'wx'})).catch(e=>{console.error(e);process.exit(1)});"""
    if compact:
        uploaded = ROOT / 'staging' / (TASK + '-data.br')
        assert not uploaded.is_symlink() and uploaded.resolve(strict=True).parent == (ROOT / 'staging').resolve(strict=True)
        assert sha(uploaded) == compact['brotliHash'] and uploaded.stat().st_size == compact['brotliBytes']
        uploaded.rename(brotli)
    else:
        subprocess.run(['node', '-e', script, str(data), str(brotli)], check=True)
    verify = """const fs=require('fs'),z=require('zlib'),h=require('crypto').createHash('sha256');
const s=fs.createReadStream(process.argv[1]).pipe(z.createBrotliDecompress());s.on('data',b=>h.update(b));s.on('end',()=>console.log(h.digest('hex')));s.on('error',()=>process.exit(1));"""
    assert subprocess.check_output(['node', '-e', verify, str(brotli)], text=True).strip() == manifest['dataHash']
    manifest.update(chunkCount=count, chunkCompressedBytes=compressed, brotliBytes=brotli.stat().st_size, brotliHash=sha(brotli))
    (STAGE / 'verified.json').write_text(json.dumps(manifest, indent=2))
    print('Prepared ' + manifest['release'])
else:
    manifest = json.loads((STAGE / 'verified.json').read_text())
    unchanged_live(manifest)
    small_files(manifest)
    release = manifest['release']
    assert re.fullmatch(r'mobile-[a-f0-9]{8}', release)
    assert not (PUBLIC / release).exists()
    for name in stored_build_names(manifest):
        expected = manifest['files'][name]
        assert sha(STAGE / 'build' / name) == expected['sha256'], name
    prefix = 'WebPreview.data.' + manifest['dataHash'][:8]
    parts = [STAGE / f'{prefix}.part{i:03}.gz' for i in range(manifest['chunkCount'])]
    brotli = STAGE / (prefix + '.br')
    assert sha(brotli) == manifest['brotliHash']
    digest = hashlib.sha256()
    for part in parts:
        digest.update(gzip.decompress(part.read_bytes()))
    assert digest.hexdigest() == manifest['dataHash']
    for source in parts + [brotli]:
        assert not (PUBLIC / source.name).exists()
    BACKUP.mkdir(exist_ok=False)
    shutil.copy2(INDEX, BACKUP / 'index-before.html')
    shutil.copy2(CONFIG, BACKUP / 'ssl-before.conf')
    shutil.copyfile(STAGE / 'chunk-trial.js', STAGE / 'build/chunk-loader.js')
    for source in (STAGE / 'build').iterdir():
        source.chmod(0o644)
    (STAGE / 'build').chmod(0o755)
    (STAGE / 'build').rename(PUBLIC / release)
    for source in parts + [brotli]:
        source.chmod(0o644)
        source.rename(PUBLIC / source.name)
    try:
        shutil.copyfile(STAGE / 'ro-th-online-ssl.conf', CONFIG)
        reload_apache()
        for name in stored_build_names(manifest) + ['chunk-loader.js']:
            if name == 'WebPreview.data':
                continue
            expected = manifest['chunkHash'] if name == 'chunk-loader.js' else manifest['files'][name]['sha256']
            with request('/Mobile/Build/' + release + '/' + name) as response:
                assert response.status == 200 and hashlib.sha256(response.read()).hexdigest() == expected, name
        for i in [0, manifest['chunkCount'] - 1]:
            with request('/Mobile/Build/' + f'{prefix}.part{i:03}.gz') as response:
                assert response.status == 200 and response.headers.get('Content-Encoding') == 'gzip'
                actual = gzip.decompress(response.read())
            # All decoded parts were already checked against the new build's full
            # data hash. Compressed-only releases intentionally omit duplicate raw data.
            assert actual == gzip.decompress((PUBLIC / f'{prefix}.part{i:03}.gz').read_bytes())
        with request('/Mobile/Build/' + prefix + '.br', 'HEAD') as response:
            assert response.status == 200 and response.headers.get('Content-Encoding') == 'br'
            assert int(response.headers['Content-Length']) == manifest['brotliBytes']
        current = INDEX.stat()
        pending = STAGE / 'index.html'
        os.chmod(pending, current.st_mode)
        os.chown(pending, current.st_uid, current.st_gid)
        os.replace(pending, INDEX)
        for url in ['/Mobile/', '/Mobile/?installed=1']:
            with request(url) as response:
                assert response.status == 200 and hashlib.sha256(response.read()).hexdigest() == manifest['indexHash']
        for url, status in [('/', 404), ('/browser/', 401)]:
            with request(url) as response:
                assert response.status == status
    except Exception:
        shutil.copy2(BACKUP / 'index-before.html', INDEX)
        shutil.copy2(BACKUP / 'ssl-before.conf', CONFIG)
        reload_apache()
        raise
    manifest['publishedAtUtc'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
    manifest['verification'] = dict(httpsIndexBothUrls=True, httpsCodeHashes=True, gzipFirstLastDecoded=True, brotliHeaders=True, root404=True, ownerBrowser401=True)
    (BACKUP / 'release.json').write_text(json.dumps(manifest, indent=2))
    print(json.dumps(manifest, indent=2))
