"""VPS-only browser gateway update, with synthetic unauthenticated voice probe.

Never controls a game service. Run prepare before the Mobile release activation,
then activate (stores the previous gateway alongside the Mobile rollback page).
"""
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import tarfile
import time

mode, task, archive_hash, old_hash = sys.argv[1:]
assert mode in ('prepare', 'activate', 'verify') and re.fullmatch('[a-z0-9-]+', task)
assert all(re.fullmatch('[a-f0-9]{64}', h) for h in (archive_hash, old_hash))
root = Path('/var/www/html/ro-th-online')
stage = root / 'staging' / task
candidate = stage / 'gateway-candidate' / 'WebGateway'
live = root / 'gateway/WebGateway'
backup = root / 'deploy' / task

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

def exact(stream, count):
    result = b''
    while len(result) < count:
        chunk = stream.recv(count - len(result))
        if not chunk:
            raise EOFError('Voice probe closed early')
        result += chunk
    return result

def probe(host, port, secure=False):
    raw = socket.create_connection((host, port), timeout=10)
    stream = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if secure else raw
    with stream:
        key = base64.b64encode(os.urandom(16)).decode()
        request = (f'GET /bridge/voice HTTP/1.1\r\nHost: {host}:{port}\r\n'
                   f'Origin: https://ro-th.online\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
                   f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n')
        stream.sendall(request.encode())
        header = b''
        while not header.endswith(b'\r\n\r\n'):
            assert len(header) < 8192
            header += exact(stream, 1)
        assert header.startswith(b'HTTP/1.1 101'), header.split(b'\r\n')[0]
        accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
        assert b'sec-websocket-accept: ' + accept.lower() in header.lower()
        payload = b'{"type":"ping"}'
        packet = b'VT\x01\x01' + struct.pack('!I', len(payload)) + payload
        mask = os.urandom(4)
        stream.sendall(bytes([0x82, 0x80 | len(packet)]) + mask + bytes(v ^ mask[i % 4] for i, v in enumerate(packet)))
        received = b''
        while len(received) < 8 or len(received) < 8 + struct.unpack('!I', received[4:8])[0]:
            h = exact(stream, 2)
            assert h[0] & 15 in (0, 2), 'Expected binary VoiceTcp stream'
            n = h[1] & 127
            if n == 126:
                n = struct.unpack('!H', exact(stream, 2))[0]
            elif n == 127:
                n = struct.unpack('!Q', exact(stream, 8))[0]
            assert n < 65536 and not h[1] & 128
            received += exact(stream, n)
        assert received[:4] == b'VT\x01\x01'
        message = json.loads(received[8:8 + struct.unpack('!I', received[4:8])[0]])
        assert message.get('type') == 'error' and 'not authenticated' in message.get('message', '').lower(), message
        return dict(upgrade101=True, voiceTcpV1=True, unauthenticatedRejected=True)

def pid(name):
    return subprocess.check_output(['pgrep', '-x', name], text=True).strip().splitlines()

def ready(port):
    last = None
    for _ in range(30):
        try:
            return probe('127.0.0.1', port)
        except (OSError, EOFError, AssertionError) as error:
            last = error
            time.sleep(.3)
    raise RuntimeError('Gateway voice readiness failed') from last

if mode == 'prepare':
    assert sha(live) == old_hash
    archive = root / 'staging' / (task + '-gateway.tar.gz')
    assert sha(archive) == archive_hash
    candidate.parent.mkdir(exist_ok=False)
    with tarfile.open(archive) as tar:
        entries = tar.getmembers()
        assert len(entries) == 1 and entries[0].name == 'WebGateway' and entries[0].isfile()
        with tar.extractfile(entries[0]) as src, candidate.open('xb') as dst:
            shutil.copyfileobj(src, dst)
    candidate.chmod(0o755)
    gateway_pid = subprocess.check_output(['systemctl', 'show', 'ro-th-online-gateway', '-p', 'MainPID', '--value'], text=True).strip()
    args = Path('/proc/' + gateway_pid + '/cmdline').read_bytes().decode().rstrip('\0').split('\0')
    args[0] = str(candidate)
    args[args.index('--listen') + 1] = 'http://127.0.0.1:18088/'
    env = dict(os.environ, DOTNET_BUNDLE_EXTRACT_BASE_DIR='/var/cache/ro-th-online')
    with (stage / 'gateway-preflight.log').open('wb') as log:
        process = subprocess.Popen(['runuser', '-u', 'www-data', '--'] + args, stdout=log, stderr=subprocess.STDOUT, env=env, start_new_session=True)
        try:
            checks = ready(18088)
        finally:
            import signal
            os.killpg(process.pid, signal.SIGTERM)
            process.wait(timeout=15)
    result = dict(newHash=sha(candidate), previousHash=old_hash, candidateProbe=checks)
    (stage / 'gateway-verified.json').write_text(json.dumps(result, indent=2))
elif mode == 'activate':
    verified = json.loads((stage / 'gateway-verified.json').read_text())
    assert sha(candidate) == verified['newHash'] and sha(live) == old_hash
    assert (backup / 'release.json').is_file() and not (backup / 'gateway-before').exists()
    before = dict(map=pid('map-server'), voice=pid('voice-server'))
    shutil.copy2(live, backup / 'gateway-before')
    pending = live.with_name('WebGateway.pending')
    assert not pending.exists()
    shutil.copy2(candidate, pending)
    os.chown(pending, live.stat().st_uid, live.stat().st_gid)
    os.replace(pending, live)
    try:
        subprocess.run(['systemctl', 'restart', 'ro-th-online-gateway'], check=True)
        checks = ready(8088)
        public = probe('ro-th.online', 443, True)
        assert before == dict(map=pid('map-server'), voice=pid('voice-server')), 'Game service PID changed'
    except Exception:
        shutil.copy2(backup / 'gateway-before', pending)
        os.replace(pending, live)
        subprocess.run(['systemctl', 'restart', 'ro-th-online-gateway'], check=True)
        raise
    result = dict(**verified, localProbe=checks, publicWssProbe=public, gameServicePidsUnchanged=before)
    (backup / 'gateway-release.json').write_text(json.dumps(result, indent=2))
else:
    result = dict(local=probe('127.0.0.1', 8088), public=probe('ro-th.online', 443, True), gatewayHash=sha(live))
print(json.dumps(result, indent=2))
