#!/usr/bin/env python3
import hashlib
import json
import os
import shutil
import sys
import tempfile
import time
import zipfile
from pathlib import Path, PurePosixPath

BASE_DIR = Path('/opt/rpgmultiplayer/updates/chaapaai')
VERSIONS_DIR = BASE_DIR / 'versions'
LATEST_FILE = BASE_DIR / 'latest.json'
STAGING_DIR = BASE_DIR / 'staging'


def fail(message):
    raise RuntimeError(message)


def sha256_file(path):
    h = hashlib.sha256()
    with path.open('rb') as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b''):
            h.update(chunk)
    return h.hexdigest()


def read_json(path):
    return json.loads(path.read_text(encoding='utf-8'))


def safe_rel_path(raw):
    raw = str(raw).replace('\\', '/')
    p = PurePosixPath(raw)
    if not raw or raw.startswith('/') or p.is_absolute() or '..' in p.parts:
        fail(f'Ungueltiger Release-Pfad: {raw}')
    return p.as_posix()


def atomic_write_json(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temp_name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=str(path.parent))
    try:
        with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
            json.dump(data, f, indent=2, ensure_ascii=True)
            f.write('\n')
            f.flush()
            os.fsync(f.fileno())
        os.replace(temp_name, path)
    except Exception:
        try:
            os.unlink(temp_name)
        except OSError:
            pass
        raise


def main():
    if len(sys.argv) != 5 or sys.argv[1] != 'game':
        print('Usage: publish_staged.py game VERSION STAGE_ID EXPECTED_BASE_VERSION', file=sys.stderr)
        return 2

    version, stage_id, expected_base = sys.argv[2], sys.argv[3], sys.argv[4]
    if '/' in version or '\\' in version or version in {'', '.', '..'}:
        fail('Ungueltige Version.')
    if not stage_id.startswith('release-') or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_' for c in stage_id):
        fail('Ungueltige Stage-ID.')

    stage = STAGING_DIR / stage_id
    payload = stage / 'payload.zip'
    stage_manifest_path = stage / 'stage_manifest.json'
    final_dir = VERSIONS_DIR / version
    incoming_dir = VERSIONS_DIR / ('.incoming-' + version + '-' + stage_id[-12:])

    if final_dir.exists():
        fail(f'Die Version {version} existiert bereits.')
    if not payload.is_file() or not stage_manifest_path.is_file():
        fail('Staging-Paket ist unvollstaendig.')

    latest_before = read_json(LATEST_FILE)
    actual_base = str(latest_before.get('version', '')).strip()
    if actual_base != expected_base:
        fail(f'Produktive Version hat sich geaendert: erwartet {expected_base}, aktuell {actual_base}.')

    stage_manifest = read_json(stage_manifest_path)
    if str(stage_manifest.get('project')) != 'chaapaai':
        fail('Falsches Projekt im Stage-Manifest.')
    if str(stage_manifest.get('version')) != version:
        fail('Versionsabweichung im Stage-Manifest.')
    if str(stage_manifest.get('base_version')) != expected_base:
        fail('Basisversionsabweichung im Stage-Manifest.')

    files = stage_manifest.get('files')
    if not isinstance(files, dict) or not files:
        fail('Stage-Manifest enthaelt keine Dateien.')
    if int(stage_manifest.get('file_count', -1)) != len(files):
        fail('Dateizaehler im Stage-Manifest stimmt nicht.')

    expected = {}
    for raw_path, info in files.items():
        rel = safe_rel_path(raw_path)
        if rel in expected:
            fail(f'Doppelter Dateipfad: {rel}')
        if not isinstance(info, dict):
            fail(f'Ungueltige Dateiinformation: {rel}')
        digest = str(info.get('sha256', '')).lower()
        size = int(info.get('size', -1))
        if len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest):
            fail(f'Ungueltiger SHA-256: {rel}')
        if size < 0:
            fail(f'Ungueltige Dateigroesse: {rel}')
        expected[rel] = {'size': size, 'sha256': digest}

    expected_payload_hash = str(stage_manifest.get('payload_sha256', '')).lower()
    if sha256_file(payload).lower() != expected_payload_hash:
        fail('SHA-256 des Staging-ZIP stimmt nicht.')

    if incoming_dir.exists():
        shutil.rmtree(incoming_dir)
    files_dir = incoming_dir / 'files'
    files_dir.mkdir(parents=True, exist_ok=False)

    published = False
    try:
        with zipfile.ZipFile(payload, 'r') as zf:
            archive_files = []
            for info in zf.infolist():
                if info.is_dir():
                    continue
                rel = safe_rel_path(info.filename)
                archive_files.append(rel)
                if rel not in expected:
                    fail(f'Unerwartete Datei im ZIP: {rel}')
                target = files_dir / Path(*PurePosixPath(rel).parts)
                target.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(info, 'r') as src, target.open('wb') as dst:
                    shutil.copyfileobj(src, dst, length=1024 * 1024)

        if set(archive_files) != set(expected):
            missing = sorted(set(expected) - set(archive_files))
            fail('Dateien fehlen im ZIP: ' + ', '.join(missing[:10]))

        for rel, meta in expected.items():
            path = files_dir / Path(*PurePosixPath(rel).parts)
            if not path.is_file():
                fail(f'Datei fehlt nach Entpacken: {rel}')
            if path.stat().st_size != meta['size']:
                fail(f'Dateigroesse stimmt nicht: {rel}')
            if sha256_file(path).lower() != meta['sha256']:
                fail(f'SHA-256 stimmt nicht: {rel}')

        manifest = {
            'project': 'chaapaai',
            'version': version,
            'file_count': len(expected),
            'files': expected,
        }
        atomic_write_json(incoming_dir / 'manifest.json', manifest)

        # Noch einmal direkt vor dem Umschalten prüfen, dass niemand parallel
        # eine andere Spielversion veröffentlicht hat.
        latest_now = read_json(LATEST_FILE)
        if str(latest_now.get('version', '')).strip() != expected_base:
            fail('latest.json wurde waehrend des Releases veraendert. Abbruch.')
        if final_dir.exists():
            fail(f'Die Version {version} wurde parallel bereits angelegt.')

        os.replace(incoming_dir, final_dir)
        published = True

        timestamp = time.strftime('%Y%m%d-%H%M%S')
        backup = BASE_DIR / f'latest.json.bak-{timestamp}'
        shutil.copy2(LATEST_FILE, backup)

        latest_new = dict(latest_now)
        latest_new['project'] = 'chaapaai'
        latest_new['version'] = version
        latest_new['manifest'] = f'versions/{version}/manifest.json'
        latest_new['files'] = f'versions/{version}/files/'
        atomic_write_json(LATEST_FILE, latest_new)

        # Nachkontrolle der geschriebenen Daten.
        check = read_json(LATEST_FILE)
        if str(check.get('version')) != version:
            fail('Nachkontrolle von latest.json fehlgeschlagen.')

        print(f'OK: Spielversion {version} veroeffentlicht.')
        print(f'Dateien: {len(expected)}')
        print(f'Manifest: versions/{version}/manifest.json')
        print(f'Backup: {backup.name}')
        return 0

    except Exception:
        # Wenn latest.json noch auf der alten Version steht, darf ein eventuell
        # bereits umbenannter Release-Ordner wieder entfernt werden.
        try:
            latest_check = read_json(LATEST_FILE)
            still_old = str(latest_check.get('version', '')).strip() == expected_base
        except Exception:
            still_old = False
        if incoming_dir.exists():
            shutil.rmtree(incoming_dir, ignore_errors=True)
        if published and still_old and final_dir.exists():
            shutil.rmtree(final_dir, ignore_errors=True)
        raise
    finally:
        shutil.rmtree(stage, ignore_errors=True)


if __name__ == '__main__':
    try:
        raise SystemExit(main())
    except Exception as exc:
        print('FEHLER:', exc, file=sys.stderr)
        raise SystemExit(1)
