<?php
// BoardGameStars API: anonymes Profil, Silber/Gold-Wallet, Gold-Code-Einlösung, Lobbies + Startspieler-Füllung ohne Cronjobs.
// Kein Chat-System. BoardGameStars-Sterneprinzip: 1000 Start-Silber, 100 Tages-Silber,
// Gold nur durch Codes, Stern-Status anhand eingelöster Gold-Codes.

$configFile = __DIR__ . '/config.php';
if (!is_file($configFile)) {
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['status' => 'missing_config', 'hint' => 'Kopiere config.sample.php nach config.php']);
    exit;
}
$config = require $configFile;

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, max-age=0');

function bgs_current_origin(): string {
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    return $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
}
function bgs_origin_allowed(array $config): bool {
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    if ($origin === '') return true;
    $allowed = trim((string)($config['allowed_origin'] ?? ''));
    if ($allowed === '') $allowed = bgs_current_origin();
    return hash_equals($allowed, $origin);
}
if (!bgs_origin_allowed($config)) {
    http_response_code(403);
    echo json_encode(['status' => 'forbidden_origin']);
    exit;
}
if (!empty($_SERVER['HTTP_ORIGIN'])) {
    header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
    header('Vary: Origin');
    header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    http_response_code(204);
    exit;
}

function bgs_json(array $payload, int $status = 200): void {
    http_response_code($status);
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}
function bgs_body(): array {
    $raw = file_get_contents('php://input');
    $data = json_decode($raw ?: '', true);
    return is_array($data) ? $data : [];
}
function bgs_clean(string $value, int $max = 80): string {
    $value = trim(preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? '');
    if (function_exists('mb_substr')) return mb_substr($value, 0, $max, 'UTF-8');
    return substr($value, 0, $max);
}
function bgs_valid_player_id($value): bool {
    return is_string($value) && preg_match('/^bgs_[A-Za-z0-9_-]{16,80}$/', $value);
}
function bgs_now(): int { return time(); }
function bgs_today_key(): string { return gmdate('Y-m-d'); }
function bgs_random_token(int $bytes = 24): string { return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '='); }
function bgs_new_player_id(): string { return 'bgs_' . bin2hex(random_bytes(12)); }
function bgs_secret_hash(string $token, array $config): string {
    return hash_hmac('sha256', $token, (string)($config['server_secret'] ?? ''));
}

function bgs_recovery_normalize($code): string {
    $code = strtoupper((string)$code);
    $code = preg_replace('/[^A-Z0-9]/', '', $code);
    return substr($code ?? '', 0, 80);
}
function bgs_recovery_hash(string $code, array $config): string {
    return hash_hmac('sha256', bgs_recovery_normalize($code), (string)($config['server_secret'] ?? ''));
}
function bgs_new_recovery_code(): string {
    $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    $raw = '';
    for ($i = 0; $i < 16; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
    return 'BGS-REC-' . substr($raw,0,4) . '-' . substr($raw,4,4) . '-' . substr($raw,8,4) . '-' . substr($raw,12,4);
}
function bgs_recovery_event(PDO $pdo, array $config, string $playerId, string $eventType, array $meta = []): void {
    $pdo->prepare('INSERT INTO profile_recovery_events (id, ts, player_id, event_type, ip_hash, meta) VALUES (?, ?, ?, ?, ?, ?)')
        ->execute([bin2hex(random_bytes(12)), bgs_now(), $playerId, $eventType, bgs_client_ip_hash($config), $meta ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '']);
}
function bgs_amount($value, int $min = 1, int $max = 10000000): int {
    if (is_string($value)) $value = trim($value);
    if (!is_numeric($value)) return 0;
    $amount = (int)floor((float)$value);
    return ($amount >= $min && $amount <= $max) ? $amount : 0;
}
function bgs_normalize_code($code): string {
    $code = strtoupper((string)$code);
    $code = preg_replace('/[^A-Z0-9]/', '', $code);
    return substr($code ?? '', 0, 64);
}
function bgs_code_prefix(string $code): string { return substr(bgs_normalize_code($code), 0, 8); }
function bgs_client_ip_hash(array $config): string {
    $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
    return $ip === '' ? '' : substr(hash_hmac('sha256', $ip, (string)($config['server_secret'] ?? '')), 0, 16);
}
function bgs_db(array $config): PDO {
    $file = (string)($config['db_file'] ?? (__DIR__ . '/../data/boardgamestars.sqlite'));
    $dir = dirname($file);
    if (!is_dir($dir)) mkdir($dir, 0755, true);
    $pdo = new PDO('sqlite:' . $file, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $pdo->exec('PRAGMA journal_mode = WAL');
    $pdo->exec('PRAGMA busy_timeout = 5000');
    bgs_install_schema($pdo);
    return $pdo;
}

function bgs_ensure_columns(PDO $pdo, string $table, array $columns): void {
    $existing = [];
    $stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
    foreach ($stmt->fetchAll() as $row) {
        $existing[(string)$row['name']] = true;
    }
    foreach ($columns as $name => $definition) {
        if (!isset($existing[$name])) {
            $pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $name . ' ' . $definition);
        }
    }
}
function bgs_install_schema(PDO $pdo): void {
    $pdo->exec("CREATE TABLE IF NOT EXISTS players (
        player_id TEXT PRIMARY KEY,
        secret_hash TEXT NOT NULL,
        public_name TEXT NOT NULL,
        avatar TEXT DEFAULT '',
        silver_balance INTEGER NOT NULL DEFAULT 0,
        gold_balance INTEGER NOT NULL DEFAULT 0,
        elo INTEGER NOT NULL DEFAULT 1000,
        last_daily_bonus_date TEXT NOT NULL DEFAULT '',
        base_bonus_granted_at INTEGER NOT NULL DEFAULT 0,
        gold_code_redeem_count INTEGER NOT NULL DEFAULT 0,
        last_gold_code_redeemed_at INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
    )");
    bgs_ensure_columns($pdo, 'players', [
        'wins' => 'INTEGER NOT NULL DEFAULT 0',
        'losses' => 'INTEGER NOT NULL DEFAULT 0',
        'games_played' => 'INTEGER NOT NULL DEFAULT 0',
        'last_result_at' => 'INTEGER NOT NULL DEFAULT 0',
        'player_type' => "TEXT NOT NULL DEFAULT 'human'",
        'country_code' => "TEXT NOT NULL DEFAULT 'WORLD'",
        'is_network_player' => 'INTEGER NOT NULL DEFAULT 0',
        'network_active' => 'INTEGER NOT NULL DEFAULT 0',
        'locked_identity' => 'INTEGER NOT NULL DEFAULT 0',
        'stats_visible' => 'INTEGER NOT NULL DEFAULT 1',
        'source' => "TEXT NOT NULL DEFAULT 'user'",
        'last_seen_at' => 'INTEGER NOT NULL DEFAULT 0',
        'recovery_code_hash' => "TEXT NOT NULL DEFAULT ''",
        'recovery_code_created_at' => 'INTEGER NOT NULL DEFAULT 0',
        'recovery_used_at' => 'INTEGER NOT NULL DEFAULT 0',
    ]);
    $pdo->exec("CREATE TABLE IF NOT EXISTS ledger (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        player_id TEXT NOT NULL,
        type TEXT NOT NULL,
        currency TEXT NOT NULL,
        amount INTEGER NOT NULL,
        reason TEXT NOT NULL,
        related_id TEXT DEFAULT '',
        meta TEXT DEFAULT ''
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS ledger_player_ts ON ledger(player_id, ts DESC)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS code_audit (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        player_id TEXT NOT NULL,
        code_prefix TEXT NOT NULL,
        source TEXT NOT NULL,
        status TEXT NOT NULL,
        amount INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("CREATE TABLE IF NOT EXISTS admin_star_codes (
        code_hash TEXT PRIMARY KEY,
        code_prefix TEXT NOT NULL,
        currency TEXT NOT NULL DEFAULT 'gold',
        amount INTEGER NOT NULL DEFAULT 0,
        max_redemptions INTEGER NOT NULL DEFAULT 1,
        redeemed_count INTEGER NOT NULL DEFAULT 0,
        status TEXT NOT NULL DEFAULT 'active',
        note TEXT DEFAULT '',
        created_by TEXT DEFAULT 'admin',
        created_at INTEGER NOT NULL,
        expires_at INTEGER NOT NULL DEFAULT 0,
        last_redeemed_at INTEGER NOT NULL DEFAULT 0
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS admin_star_codes_prefix ON admin_star_codes(code_prefix)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS admin_star_code_redemptions (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        code_hash TEXT NOT NULL,
        code_prefix TEXT NOT NULL,
        player_id TEXT NOT NULL,
        currency TEXT NOT NULL,
        amount INTEGER NOT NULL,
        meta TEXT DEFAULT ''
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS admin_star_redemptions_player_ts ON admin_star_code_redemptions(player_id, ts DESC)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS rate_limits (
        bucket TEXT NOT NULL,
        rate_key TEXT NOT NULL,
        count INTEGER NOT NULL DEFAULT 0,
        first_at INTEGER NOT NULL,
        last_at INTEGER NOT NULL,
        PRIMARY KEY(bucket, rate_key)
    )");
    $pdo->exec("CREATE TABLE IF NOT EXISTS system_state (
        state_key TEXT PRIMARY KEY,
        state_value TEXT NOT NULL,
        updated_at INTEGER NOT NULL
    )");
    $pdo->exec("CREATE TABLE IF NOT EXISTS profile_recovery_events (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        player_id TEXT NOT NULL,
        event_type TEXT NOT NULL,
        ip_hash TEXT NOT NULL DEFAULT '',
        meta TEXT DEFAULT ''
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS profile_recovery_player_ts ON profile_recovery_events(player_id, ts DESC)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS bots (
        bot_id TEXT PRIMARY KEY,
        bot_name TEXT NOT NULL,
        avatar TEXT DEFAULT '',
        elo INTEGER NOT NULL DEFAULT 1000,
        wins INTEGER NOT NULL DEFAULT 0,
        losses INTEGER NOT NULL DEFAULT 0,
        games_played INTEGER NOT NULL DEFAULT 0,
        silver_balance INTEGER NOT NULL DEFAULT 0,
        gold_balance INTEGER NOT NULL DEFAULT 0,
        active INTEGER NOT NULL DEFAULT 1,
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL DEFAULT 0,
        last_result_at INTEGER NOT NULL DEFAULT 0
    )");
    bgs_ensure_columns($pdo, 'bots', [
        'wins' => 'INTEGER NOT NULL DEFAULT 0',
        'losses' => 'INTEGER NOT NULL DEFAULT 0',
        'games_played' => 'INTEGER NOT NULL DEFAULT 0',
        'updated_at' => 'INTEGER NOT NULL DEFAULT 0',
        'last_result_at' => 'INTEGER NOT NULL DEFAULT 0',
    ]);
    $pdo->exec("CREATE TABLE IF NOT EXISTS game_results (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        game_key TEXT NOT NULL,
        player_id TEXT NOT NULL,
        lobby_id TEXT DEFAULT '',
        score INTEGER NOT NULL DEFAULT 0,
        rank INTEGER NOT NULL DEFAULT 0,
        mode TEXT NOT NULL DEFAULT 'online',
        elo_delta INTEGER NOT NULL DEFAULT 0,
        meta TEXT DEFAULT ''
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS game_results_player_ts ON game_results(player_id, ts DESC)");
    $pdo->exec("CREATE INDEX IF NOT EXISTS players_network_active ON players(is_network_player, network_active)");
    $pdo->exec("CREATE INDEX IF NOT EXISTS players_rank_points ON players(elo DESC, wins DESC)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS player_game_stats (
        player_id TEXT NOT NULL,
        game_key TEXT NOT NULL,
        rank_points INTEGER NOT NULL DEFAULT 1000,
        wins INTEGER NOT NULL DEFAULT 0,
        losses INTEGER NOT NULL DEFAULT 0,
        games_played INTEGER NOT NULL DEFAULT 0,
        best_score INTEGER NOT NULL DEFAULT 0,
        total_score INTEGER NOT NULL DEFAULT 0,
        last_rank INTEGER NOT NULL DEFAULT 0,
        last_delta INTEGER NOT NULL DEFAULT 0,
        last_played_at INTEGER NOT NULL DEFAULT 0,
        PRIMARY KEY(player_id, game_key)
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS player_game_stats_game_rank ON player_game_stats(game_key, rank_points DESC, wins DESC)");

    $pdo->exec("CREATE TABLE IF NOT EXISTS autopilot_activations (
        id TEXT PRIMARY KEY,
        ts INTEGER NOT NULL,
        player_id TEXT NOT NULL,
        game_key TEXT NOT NULL,
        lobby_id TEXT NOT NULL DEFAULT '',
        fee INTEGER NOT NULL DEFAULT 0,
        currency TEXT NOT NULL DEFAULT 'silver',
        paid_silver INTEGER NOT NULL DEFAULT 0,
        paid_gold INTEGER NOT NULL DEFAULT 0,
        meta TEXT DEFAULT '',
        UNIQUE(player_id, game_key, lobby_id)
    )");
    $pdo->exec("CREATE INDEX IF NOT EXISTS autopilot_player_ts ON autopilot_activations(player_id, ts DESC)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS lobbies (
        lobby_id TEXT PRIMARY KEY,
        game_key TEXT NOT NULL,
        start_at INTEGER NOT NULL,
        max_players INTEGER NOT NULL,
        entry_fee INTEGER NOT NULL,
        currency TEXT NOT NULL DEFAULT 'auto',
        status TEXT NOT NULL DEFAULT 'waiting',
        created_at INTEGER NOT NULL,
        updated_at INTEGER NOT NULL
    )");
    bgs_ensure_columns($pdo, 'lobbies', [
        'owner_player_id' => "TEXT NOT NULL DEFAULT ''",
        'lobby_mode' => "TEXT NOT NULL DEFAULT 'public_pool'",
        'invite_code' => "TEXT NOT NULL DEFAULT ''",
        'invite_fee_gold' => 'INTEGER NOT NULL DEFAULT 0',
        'invite_reserved_slots' => 'INTEGER NOT NULL DEFAULT 0',
    ]);
    $pdo->exec("CREATE INDEX IF NOT EXISTS lobbies_game_start ON lobbies(game_key, start_at)");
    $pdo->exec("CREATE INDEX IF NOT EXISTS lobbies_invite_code ON lobbies(invite_code)");
    $pdo->exec("CREATE TABLE IF NOT EXISTS lobby_players (
        lobby_id TEXT NOT NULL,
        player_id TEXT NOT NULL,
        is_bot INTEGER NOT NULL DEFAULT 0,
        paid_silver INTEGER NOT NULL DEFAULT 0,
        paid_gold INTEGER NOT NULL DEFAULT 0,
        joined_at INTEGER NOT NULL,
        PRIMARY KEY(lobby_id, player_id)
    )");
    bgs_ensure_columns($pdo, 'lobby_players', [
        'invited' => 'INTEGER NOT NULL DEFAULT 0',
        'join_source' => "TEXT NOT NULL DEFAULT 'public'",
    ]);
}
function bgs_random_name(): string {
    $left = ['Stern','Würfel','Meeple','Karten','Brett','Taktik','Kosmo','Turbo','Pixel','Ritter','Hex','Solo','Arena','Gold','Silber'];
    $right = ['Fuchs','Falke','Drache','Koenig','Ninja','Panda','Wolf','Tiger','Pilot','Meister','Rabe','Luchs','Star','Held','Ass'];
    return $left[random_int(0, count($left)-1)] . $right[random_int(0, count($right)-1)] . random_int(10, 99);
}

function bgs_player_tokens(array $config): array {
    $raw = $config['player_tokens'] ?? [];
    if (!is_array($raw) || !$raw) {
        $raw = [
            ['id'=>'fox','emoji'=>'🦊','label'=>'Sternfuchs'],
            ['id'=>'panda','emoji'=>'🐼','label'=>'Puzzlepanda'],
            ['id'=>'star','emoji'=>'⭐','label'=>'Stern'],
            ['id'=>'dice','emoji'=>'🎲','label'=>'Würfel'],
        ];
    }
    $out = [];
    foreach ($raw as $token) {
        if (!is_array($token)) continue;
        $id = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)($token['id'] ?? '')));
        if ($id === '') continue;
        $out[] = [
            'id' => $id,
            'emoji' => bgs_clean((string)($token['emoji'] ?? '⭐'), 8),
            'label' => bgs_clean((string)($token['label'] ?? $id), 32),
        ];
    }
    return $out;
}
function bgs_token_by_id(array $config, string $id): ?array {
    $id = preg_replace('/[^a-z0-9_-]/', '', strtolower($id));
    foreach (bgs_player_tokens($config) as $token) {
        if (($token['id'] ?? '') === $id) return $token;
    }
    return null;
}
function bgs_public_avatar_token(array $config, string $id): array {
    $token = bgs_token_by_id($config, $id);
    if ($token) return $token;
    $tokens = bgs_player_tokens($config);
    return $tokens[0] ?? ['id'=>'star','emoji'=>'⭐','label'=>'Stern'];
}
function bgs_text_lower(string $value): string {
    if (function_exists('mb_strtolower')) return mb_strtolower($value, 'UTF-8');
    return strtolower($value);
}
function bgs_moderation_key(string $value): string {
    $value = bgs_text_lower($value);
    $value = strtr($value, [
        '0'=>'o','1'=>'i','3'=>'e','4'=>'a','5'=>'s','7'=>'t','@'=>'a','$'=>'s','!'=>'i','|'=>'i',
        'ä'=>'ae','ö'=>'oe','ü'=>'ue','ß'=>'ss','á'=>'a','à'=>'a','â'=>'a','é'=>'e','è'=>'e','ê'=>'e','í'=>'i','ì'=>'i','ó'=>'o','ò'=>'o','ú'=>'u','ù'=>'u','ñ'=>'n','ç'=>'c'
    ]);
    $value = preg_replace('/[^\p{L}\p{N}]+/u', '', $value) ?? '';
    return $value;
}
function bgs_validate_public_name(PDO $pdo, array $config, string $newName, string $playerId = ''): array {
    $name = bgs_clean($newName, 32);
    if ($name === '') return ['ok'=>false,'status'=>'invalid_name'];
    if (preg_match('/https?:\/\/|www\.|@|\.com|\.net|\.org|\.de/i', $name)) return ['ok'=>false,'status'=>'invalid_name'];
    if (!preg_match('/^[\p{L}\p{N}\p{M} _\-\.]{2,32}$/u', $name)) return ['ok'=>false,'status'=>'invalid_name'];
    $key = bgs_moderation_key($name);
    if (strlen($key) < 2) return ['ok'=>false,'status'=>'invalid_name'];
    foreach (($config['blocked_name_words'] ?? []) as $bad) {
        $badKey = bgs_moderation_key((string)$bad);
        if ($badKey !== '' && strpos($key, $badKey) !== false) return ['ok'=>false,'status'=>'blocked_name'];
    }
    $stmt = $pdo->prepare('SELECT player_id, public_name FROM players WHERE public_name = ? COLLATE NOCASE LIMIT 1');
    $stmt->execute([$name]);
    $row = $stmt->fetch();
    if ($row && (string)$row['player_id'] !== $playerId) return ['ok'=>false,'status'=>'name_taken'];
    // Zusätzlicher PHP-Abgleich für Unicode-Namen, weil SQLite NOCASE nicht alle Schriften gleich behandelt.
    $stmt = $pdo->query('SELECT player_id, public_name FROM players');
    $wanted = bgs_moderation_key($name);
    foreach (($stmt ? $stmt->fetchAll() : []) as $r) {
        if ((string)$r['player_id'] === $playerId) continue;
        if (bgs_moderation_key((string)$r['public_name']) === $wanted) return ['ok'=>false,'status'=>'name_taken'];
    }
    return ['ok'=>true,'name'=>$name];
}

function bgs_public_gold_status(int $goldCodeCount): array {
    $count = max(0, $goldCodeCount);
    $starCount = $count >= 2 ? min(4, $count - 1) : 0;
    return [
        'goldCodeCount' => $count,
        'goldRing' => $count >= 1,
        'starCount' => $starCount,
        'goldName' => $count >= 3,
        'level' => $count >= 5 ? 'legend' : ($count >= 3 ? 'vip' : ($count >= 1 ? 'gold' : 'none')),
    ];
}
function bgs_public_player(array $row, array $config): array {
    $silver = max(0, (int)($row['silver_balance'] ?? 0));
    $gold = max(0, (int)($row['gold_balance'] ?? 0));
    $codeCount = max(0, (int)($row['gold_code_redeem_count'] ?? 0));
    return [
        'playerId' => (string)$row['player_id'],
        'publicName' => (string)$row['public_name'],
        'avatar' => (string)($row['avatar'] ?? ''),
        'avatarToken' => bgs_public_avatar_token($config, (string)($row['avatar'] ?? '')),
        'availablePlayerTokens' => bgs_player_tokens($config),
        'playerTokenCostSilver' => max(0, (int)($config['player_token_cost_silver'] ?? 100)),
        'renameCostSilver' => max(0, (int)($config['rename_cost_silver'] ?? 100)),
        'silverBalance' => $silver,
        'goldBalance' => $gold,
        'stars_silver' => $silver,
        'stars_gold' => $gold,
        'balance' => $silver + $gold,
        'elo' => (int)($row['elo'] ?? 1000), // interner Altname
        'rankPoints' => (int)($row['elo'] ?? 1000),
        'pointsLabel' => 'Rangpunkte',
        'goldStatus' => bgs_public_gold_status($codeCount),
        'lastDailyBonusDate' => (string)($row['last_daily_bonus_date'] ?? ''),
        'baseBonus' => (int)($config['base_bonus_silver'] ?? 1000),
        'dailyBonus' => (int)($config['daily_bonus_silver'] ?? 100),
        'currencyName' => 'Sterne',
        'walletSchema' => 2,
        'silverUsage' => 'free_system_games_profile_lobbies',
        'goldUsage' => 'purchased_codes_gold_liga_premium_cosmetics_fee_replacement',
        'privacyMode' => 'anonymous_codes_no_payment_link_to_player',
        'recoveryAvailable' => ((string)($row['recovery_code_hash'] ?? '')) !== '',
        'recoveryCodeCreatedAt' => (int)($row['recovery_code_created_at'] ?? 0),
    ];
}
function bgs_ledger(PDO $pdo, string $playerId, string $type, string $currency, int $amount, string $reason, string $relatedId = '', array $meta = []): void {
    $stmt = $pdo->prepare('INSERT INTO ledger (id, ts, player_id, type, currency, amount, reason, related_id, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
    $stmt->execute([bin2hex(random_bytes(12)), bgs_now(), $playerId, $type, $currency, $amount, $reason, $relatedId, $meta ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : '']);
}
function bgs_get_player(PDO $pdo, string $playerId): ?array {
    $stmt = $pdo->prepare('SELECT * FROM players WHERE player_id = ?');
    $stmt->execute([$playerId]);
    $row = $stmt->fetch();
    return is_array($row) ? $row : null;
}
function bgs_auth(PDO $pdo, array $config, array $data = []): array {
    $playerId = (string)($data['playerId'] ?? $data['player_id'] ?? $_GET['playerId'] ?? $_GET['player_id'] ?? '');
    $token = (string)($data['secretToken'] ?? $data['secret_token'] ?? $_SERVER['HTTP_X_BGS_TOKEN'] ?? '');
    if (!bgs_valid_player_id($playerId) || $token === '') bgs_json(['status' => 'invalid_auth'], 401);
    $row = bgs_get_player($pdo, $playerId);
    if (!$row) bgs_json(['status' => 'unknown_player'], 404);
    if (!hash_equals((string)$row['secret_hash'], bgs_secret_hash($token, $config))) bgs_json(['status' => 'invalid_auth'], 401);
    return $row;
}
function bgs_grant_login_bonuses(PDO $pdo, string $playerId, array $config): array {
    $base = max(0, (int)($config['base_bonus_silver'] ?? 1000));
    $daily = max(0, (int)($config['daily_bonus_silver'] ?? 100));
    $today = bgs_today_key();
    $now = bgs_now();
    $baseGranted = false;
    $dailyGranted = false;
    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $row = bgs_get_player($pdo, $playerId);
        if (!$row) throw new RuntimeException('missing_player');
        if ((int)$row['base_bonus_granted_at'] <= 0 && $base > 0) {
            $pdo->prepare('UPDATE players SET silver_balance = silver_balance + ?, base_bonus_granted_at = ?, updated_at = ? WHERE player_id = ?')->execute([$base, $now, $now, $playerId]);
            bgs_ledger($pdo, $playerId, 'credit', 'silver', $base, 'base_bonus');
            $baseGranted = true;
        }
        if ((string)$row['last_daily_bonus_date'] !== $today && $daily > 0) {
            $pdo->prepare('UPDATE players SET silver_balance = silver_balance + ?, last_daily_bonus_date = ?, updated_at = ? WHERE player_id = ?')->execute([$daily, $today, $now, $playerId]);
            bgs_ledger($pdo, $playerId, 'credit', 'silver', $daily, 'daily_bonus');
            $dailyGranted = true;
        }
        $fresh = bgs_get_player($pdo, $playerId);
        $pdo->exec('COMMIT');
        $payload = bgs_public_player($fresh ?: [], $config);
        $payload['baseGranted'] = $baseGranted;
        $payload['baseAmount'] = $base;
        $payload['dailyGranted'] = $dailyGranted;
        $payload['dailyAmount'] = $daily;
        return $payload;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}
function bgs_rate_check(PDO $pdo, array $config, string $bucket, string $playerId, int $limit = 5, int $window = 900): array {
    $key = $playerId . '|' . bgs_client_ip_hash($config);
    $now = bgs_now();
    $cut = $now - $window;
    $pdo->prepare('DELETE FROM rate_limits WHERE bucket = ? AND last_at < ?')->execute([$bucket, $cut]);
    $stmt = $pdo->prepare('SELECT * FROM rate_limits WHERE bucket = ? AND rate_key = ?');
    $stmt->execute([$bucket, $key]);
    $row = $stmt->fetch();
    if (!$row || (int)$row['first_at'] < $cut) {
        $pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, rate_key, count, first_at, last_at) VALUES (?, ?, 0, ?, ?)')->execute([$bucket, $key, $now, $now]);
        return ['allowed' => true, 'remaining' => $limit, 'retryAfter' => 0];
    }
    $count = (int)$row['count'];
    return ['allowed' => $count < $limit, 'remaining' => max(0, $limit - $count), 'retryAfter' => max(60, $window - ($now - (int)$row['first_at']))];
}
function bgs_rate_failure(PDO $pdo, array $config, string $bucket, string $playerId): void {
    $key = $playerId . '|' . bgs_client_ip_hash($config);
    $now = bgs_now();
    $stmt = $pdo->prepare('SELECT * FROM rate_limits WHERE bucket = ? AND rate_key = ?');
    $stmt->execute([$bucket, $key]);
    $row = $stmt->fetch();
    if (!$row) $pdo->prepare('INSERT INTO rate_limits (bucket, rate_key, count, first_at, last_at) VALUES (?, ?, 1, ?, ?)')->execute([$bucket, $key, $now, $now]);
    else $pdo->prepare('UPDATE rate_limits SET count = count + 1, last_at = ? WHERE bucket = ? AND rate_key = ?')->execute([$now, $bucket, $key]);
}
function bgs_rate_clear(PDO $pdo, array $config, string $bucket, string $playerId): void {
    $key = $playerId . '|' . bgs_client_ip_hash($config);
    $pdo->prepare('DELETE FROM rate_limits WHERE bucket = ? AND rate_key = ?')->execute([$bucket, $key]);
}
function bgs_audit_code(PDO $pdo, string $playerId, string $prefix, string $source, string $status, int $amount = 0): void {
    $stmt = $pdo->prepare('INSERT INTO code_audit (id, ts, player_id, code_prefix, source, status, amount) VALUES (?, ?, ?, ?, ?, ?, ?)');
    $stmt->execute([bin2hex(random_bytes(12)), bgs_now(), $playerId, $prefix, $source, $status, $amount]);
}
function bgs_remote_redeem(string $code, array $config): array {
    $url = trim((string)($config['star_code_api_url'] ?? ''));
    $secret = (string)($config['star_code_api_secret'] ?? '');
    if ($url === '' || $secret === '') return ['status' => 'not_configured'];
    $payload = json_encode(['code' => $code, 'platform' => 'boardgamestars'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Accept: application/json',
                'X-BoardGameStars-Star-Api-Key: ' . $secret,
            ],
            CURLOPT_TIMEOUT => 20,
        ]);
        $raw = curl_exec($ch);
        $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
    } else {
        $ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => "Content-Type: application/json\r\nAccept: application/json\r\nX-BoardGameStars-Star-Api-Key: {$secret}\r\n", 'content' => $payload, 'timeout' => 20, 'ignore_errors' => true]]);
        $raw = @file_get_contents($url, false, $ctx);
        $http = 200;
    }
    if (!is_string($raw) || $raw === '') return ['status' => 'remote_unavailable'];
    $data = json_decode($raw, true);
    if (!is_array($data)) return ['status' => 'remote_invalid_response'];
    if (($http ?? 200) < 200 || ($http ?? 200) >= 300) return ['status' => bgs_clean((string)($data['status'] ?? 'remote_rejected'), 80)];
    return $data;
}
function bgs_remote_validate(string $code, array $config): array {
    $url = trim((string)($config['star_code_check_api_url'] ?? ''));
    if ($url === '') $url = trim((string)($config['star_code_api_url'] ?? ''));
    $secret = (string)($config['star_code_api_secret'] ?? '');
    if ($url === '' || $secret === '') return ['status' => 'not_configured'];
    $payload = json_encode(['code' => $code, 'platform' => 'boardgamestars'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Accept: application/json',
                'X-BoardGameStars-Star-Api-Key: ' . $secret,
            ],
            CURLOPT_TIMEOUT => 20,
        ]);
        $raw = curl_exec($ch);
        $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
    } else {
        $ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => "Content-Type: application/json
Accept: application/json
X-BoardGameStars-Star-Api-Key: {$secret}
", 'content' => $payload, 'timeout' => 20, 'ignore_errors' => true]]);
        $raw = @file_get_contents($url, false, $ctx);
        $http = 200;
    }
    if (!is_string($raw) || $raw === '') return ['status' => 'remote_unavailable'];
    $data = json_decode($raw, true);
    if (!is_array($data)) return ['status' => 'remote_invalid_response'];
    if (($http ?? 200) < 200 || ($http ?? 200) >= 300) return ['status' => bgs_clean((string)($data['status'] ?? 'remote_rejected'), 80)];
    return $data;
}
function bgs_credit_gold_from_code(PDO $pdo, string $playerId, int $amount): array {
    $now = bgs_now();
    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $stmt = $pdo->prepare('UPDATE players SET gold_balance = gold_balance + ?, gold_code_redeem_count = gold_code_redeem_count + 1, last_gold_code_redeemed_at = ?, updated_at = ? WHERE player_id = ?');
        $stmt->execute([$amount, $now, $now, $playerId]);
        bgs_ledger($pdo, $playerId, 'credit', 'gold', $amount, 'remote_code_redeem');
        $row = bgs_get_player($pdo, $playerId);
        $pdo->exec('COMMIT');
        return ['status' => 'ok', 'amount' => $amount, 'currency' => 'gold', 'stars' => bgs_public_player($row ?: [], $GLOBALS['config']), 'source' => 'remote'];
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

function bgs_code_hash_local(string $code, array $config): string {
    return hash_hmac('sha256', bgs_normalize_code($code), (string)($config['server_secret'] ?? ''));
}
function bgs_local_admin_code_lookup(PDO $pdo, string $code, array $config): array {
    $normalized = bgs_normalize_code($code);
    if ($normalized === '') return ['status'=>'invalid_code'];
    $hash = bgs_code_hash_local($normalized, $config);
    $stmt = $pdo->prepare('SELECT * FROM admin_star_codes WHERE code_hash = ? LIMIT 1');
    $stmt->execute([$hash]);
    $row = $stmt->fetch();
    if (!$row) return ['status'=>'code_not_found'];
    $now = bgs_now();
    $status = (string)($row['status'] ?? 'active');
    $maxRedemptions = max(1, (int)($row['max_redemptions'] ?? 1));
    $redeemedCount = max(0, (int)($row['redeemed_count'] ?? 0));
    $expiresAt = (int)($row['expires_at'] ?? 0);
    if ($status !== 'active') return ['status'=>'code_inactive','codePrefix'=>(string)$row['code_prefix']];
    if ($expiresAt > 0 && $expiresAt < $now) return ['status'=>'code_expired','codePrefix'=>(string)$row['code_prefix']];
    if ($redeemedCount >= $maxRedemptions) return ['status'=>'code_already_used','codePrefix'=>(string)$row['code_prefix']];
    $currency = strtolower((string)($row['currency'] ?? 'gold'));
    if (!in_array($currency, ['silver','gold'], true)) return ['status'=>'invalid_code','codePrefix'=>(string)$row['code_prefix']];
    $amount = bgs_amount($row['amount'] ?? 0, 1, 10000000);
    if ($amount <= 0) return ['status'=>'invalid_code','codePrefix'=>(string)$row['code_prefix']];
    return ['status'=>'ok','row'=>$row,'codeHash'=>$hash,'codePrefix'=>(string)$row['code_prefix'],'currency'=>$currency,'amount'=>$amount,'source'=>'admin_local'];
}
function bgs_redeem_local_admin_code(PDO $pdo, string $playerId, string $code, array $config): array {
    $normalized = bgs_normalize_code($code);
    $now = bgs_now();
    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $lookup = bgs_local_admin_code_lookup($pdo, $normalized, $config);
        if (($lookup['status'] ?? '') !== 'ok') { $pdo->exec('ROLLBACK'); return $lookup; }
        $row = $lookup['row'];
        $hash = (string)$lookup['codeHash'];
        $prefix = (string)$lookup['codePrefix'];
        $currency = (string)$lookup['currency'];
        $amount = (int)$lookup['amount'];
        if ($currency === 'gold') {
            $pdo->prepare('UPDATE players SET gold_balance = gold_balance + ?, gold_code_redeem_count = gold_code_redeem_count + 1, last_gold_code_redeemed_at = ?, updated_at = ? WHERE player_id = ?')
                ->execute([$amount, $now, $now, $playerId]);
        } else {
            $pdo->prepare('UPDATE players SET silver_balance = silver_balance + ?, updated_at = ? WHERE player_id = ?')
                ->execute([$amount, $now, $playerId]);
        }
        $newRedeemed = ((int)($row['redeemed_count'] ?? 0)) + 1;
        $maxRedemptions = max(1, (int)($row['max_redemptions'] ?? 1));
        $newStatus = $newRedeemed >= $maxRedemptions ? 'used' : 'active';
        $pdo->prepare('UPDATE admin_star_codes SET redeemed_count = ?, status = ?, last_redeemed_at = ? WHERE code_hash = ?')
            ->execute([$newRedeemed, $newStatus, $now, $hash]);
        $pdo->prepare('INSERT INTO admin_star_code_redemptions (id, ts, code_hash, code_prefix, player_id, currency, amount, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([bin2hex(random_bytes(12)), $now, $hash, $prefix, $playerId, $currency, $amount, json_encode(['source'=>'admin_local_code'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
        bgs_ledger($pdo, $playerId, 'credit', $currency, $amount, 'admin_code_redeem', $prefix, ['source'=>'admin_local_code']);
        $fresh = bgs_get_player($pdo, $playerId);
        $pdo->exec('COMMIT');
        return ['status'=>'ok','amount'=>$amount,'currency'=>$currency,'stars'=>bgs_public_player($fresh ?: [], $config),'source'=>'admin_local','codePrefix'=>$prefix];
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

function bgs_debit(PDO $pdo, string $playerId, int $amount, string $currency, string $reason, string $relatedId = '', array $meta = []): array {
    $currency = strtolower($currency);
    if (!in_array($currency, ['silver', 'gold', 'auto'], true)) $currency = 'auto';
    $now = bgs_now();
    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $row = bgs_get_player($pdo, $playerId);
        if (!$row) throw new RuntimeException('missing_player');
        $silver = max(0, (int)$row['silver_balance']);
        $gold = max(0, (int)$row['gold_balance']);
        $spendSilver = 0;
        $spendGold = 0;
        if ($currency === 'silver') {
            if ($silver < $amount) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_silver','cost'=>$amount,'silverBalance'=>$silver,'stars'=>bgs_public_player($row, $GLOBALS['config'])]; }
            $spendSilver = $amount;
        } elseif ($currency === 'gold') {
            if ($gold < $amount) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_gold','cost'=>$amount,'goldBalance'=>$gold,'stars'=>bgs_public_player($row, $GLOBALS['config'])]; }
            $spendGold = $amount;
        } else {
            // Für Spielgebühren: Silber zuerst, Gold ersetzt fehlendes Silber.
            if ($silver + $gold < $amount) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_stars','cost'=>$amount,'silverBalance'=>$silver,'goldBalance'=>$gold,'stars'=>bgs_public_player($row, $GLOBALS['config'])]; }
            $spendSilver = min($silver, $amount);
            $spendGold = $amount - $spendSilver;
        }
        $pdo->prepare('UPDATE players SET silver_balance = silver_balance - ?, gold_balance = gold_balance - ?, updated_at = ? WHERE player_id = ?')->execute([$spendSilver, $spendGold, $now, $playerId]);
        if ($spendSilver > 0) bgs_ledger($pdo, $playerId, 'debit', 'silver', $spendSilver, $reason, $relatedId, $meta);
        if ($spendGold > 0) bgs_ledger($pdo, $playerId, 'debit', 'gold', $spendGold, $reason, $relatedId, $meta);
        $fresh = bgs_get_player($pdo, $playerId);
        $pdo->exec('COMMIT');
        return ['status'=>'ok','cost'=>$amount,'spent'=>['silver'=>$spendSilver,'gold'=>$spendGold],'currency'=>$spendGold > 0 && $spendSilver > 0 ? 'mixed' : ($spendGold > 0 ? 'gold' : 'silver'),'stars'=>bgs_public_player($fresh ?: [], $GLOBALS['config'])];
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}
function bgs_next_start(int $interval): int {
    $interval = max(60, min(86400, $interval));
    $now = bgs_now();
    return (int)(ceil(($now + 60) / $interval) * $interval);
}
function bgs_new_invite_code(): string {
    return strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
}
function bgs_create_lobby(PDO $pdo, string $gameKey, int $startAt, int $maxPlayers, int $entryFee, string $currency, array $opts = []): array {
    $lobbyId = 'lobby_' . bin2hex(random_bytes(10));
    $now = bgs_now();
    $ownerPlayerId = bgs_clean((string)($opts['ownerPlayerId'] ?? ''), 90);
    $lobbyMode = bgs_clean((string)($opts['lobbyMode'] ?? 'public_pool'), 40);
    $inviteCode = bgs_clean((string)($opts['inviteCode'] ?? ''), 40);
    $inviteFeeGold = max(0, (int)($opts['inviteFeeGold'] ?? 0));
    $reservedSlots = max(0, min($maxPlayers - 1, (int)($opts['inviteReservedSlots'] ?? 0)));
    $pdo->prepare('INSERT INTO lobbies (lobby_id, game_key, start_at, max_players, entry_fee, currency, status, created_at, updated_at, owner_player_id, lobby_mode, invite_code, invite_fee_gold, invite_reserved_slots) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
        ->execute([$lobbyId, $gameKey, $startAt, $maxPlayers, $entryFee, $currency, 'waiting', $now, $now, $ownerPlayerId, $lobbyMode, $inviteCode, $inviteFeeGold, $reservedSlots]);
    return bgs_get_lobby($pdo, $lobbyId) ?: [];
}
function bgs_get_or_create_lobby(PDO $pdo, string $gameKey, int $startAt, int $maxPlayers, int $entryFee, string $currency): array {
    $stmt = $pdo->prepare("SELECT * FROM lobbies WHERE game_key = ? AND start_at = ? AND status = ? AND COALESCE(lobby_mode, 'public_pool') = 'public_pool' LIMIT 1");
    $stmt->execute([$gameKey, $startAt, 'waiting']);
    $row = $stmt->fetch();
    if ($row) return $row;
    return bgs_create_lobby($pdo, $gameKey, $startAt, $maxPlayers, $entryFee, $currency, ['lobbyMode' => 'public_pool']);
}

function bgs_ensure_public_lobbies(PDO $pdo, string $gameKey, int $interval, int $maxPlayers, int $entryFee, string $currency, int $slots = 3): array {
    $slots = max(1, min(8, $slots));
    $interval = max(60, min(86400, $interval));
    $first = bgs_next_start($interval);
    $out = [];
    for ($i = 0; $i < $slots; $i++) {
        $startAt = $first + ($i * $interval);
        $out[] = bgs_get_or_create_lobby($pdo, $gameKey, $startAt, $maxPlayers, $entryFee, $currency);
    }
    return $out;
}
function bgs_get_lobby(PDO $pdo, string $lobbyId): ?array {
    $stmt = $pdo->prepare('SELECT * FROM lobbies WHERE lobby_id = ?');
    $stmt->execute([$lobbyId]);
    $row = $stmt->fetch();
    return is_array($row) ? $row : null;
}
function bgs_invite_link(string $gameKey, string $lobbyId, string $inviteCode): string {
    if ($inviteCode === '') return '';
    return bgs_current_origin() . '/game.php?game=' . rawurlencode($gameKey) . '&mode=online&lobby=' . rawurlencode($lobbyId) . '&invite=' . rawurlencode($inviteCode);
}
function bgs_own_lobby_fee_gold(array $gameCfg, array $config): int {
    return max(0, (int)($gameCfg['ownLobbyFeeGold'] ?? $gameCfg['inviteLobbyFeeGold'] ?? ($config['own_lobby_fee_gold'] ?? 10)));
}
function bgs_invite_reserved_slots(array $lobby, array $config): int {
    if ((string)($lobby['invite_code'] ?? '') === '') return 0;
    return max(0, min(max(0, (int)($lobby['max_players'] ?? 0) - 1), (int)($lobby['invite_reserved_slots'] ?? ($config['invite_lobby_reserved_slots'] ?? 1))));
}

function bgs_start_player_final_fill_seconds(array $config): int {
    return max(10, min(600, (int)($config['start_players_final_fill_seconds'] ?? 60)));
}
function bgs_start_player_gradual_start_seconds(array $config): int {
    $window = bgs_start_player_fill_window($config);
    return max(bgs_start_player_final_fill_seconds($config) + 10, min(7200, (int)($config['start_players_gradual_fill_start_seconds'] ?? $window)));
}
function bgs_lobby_counts(PDO $pdo, string $lobbyId): array {
    $stmt = $pdo->prepare('SELECT is_bot, COUNT(*) AS c FROM lobby_players WHERE lobby_id = ? GROUP BY is_bot');
    $stmt->execute([$lobbyId]);
    $real = 0; $start = 0;
    foreach ($stmt->fetchAll() as $row) {
        if ((int)$row['is_bot'] === 1) $start += (int)$row['c']; else $real += (int)$row['c'];
    }
    return ['real'=>$real, 'start'=>$start, 'total'=>$real + $start];
}
function bgs_lobby_staged_target(array $lobby, array $config): int {
    $max = max(1, (int)($lobby['max_players'] ?? 1));
    $reserve = bgs_invite_reserved_slots($lobby, $config);
    $allowed = max(1, $max - $reserve);
    $left = (int)($lobby['start_at'] ?? 0) - bgs_now();
    $fillStart = bgs_start_player_gradual_start_seconds($config);
    $final = bgs_start_player_final_fill_seconds($config);
    if ($left <= $final) return $allowed;
    if ($left >= $fillStart) return 0;
    $span = max(1, $fillStart - $final);
    $progress = ($fillStart - $left) / $span;
    // Schrittweise füllen: am Anfang wenig, kurz vor Start mehr, ganz am Ende vollständig.
    return max(0, min($allowed, (int)floor($allowed * $progress)));
}
function bgs_fill_lobby_to_staged_target(PDO $pdo, array $lobby, array $config = []): int {
    if (!$lobby || (string)($lobby['status'] ?? '') !== 'waiting') return 0;
    if (!bgs_start_players_enabled($config)) return 0;
    bgs_seed_start_players($pdo, $config);
    $counts = bgs_lobby_counts($pdo, (string)$lobby['lobby_id']);
    $targetTotal = max($counts['real'], bgs_lobby_staged_target($lobby, $config));
    $maxTotal = max(1, (int)($lobby['max_players'] ?? 1) - bgs_invite_reserved_slots($lobby, $config));
    $targetTotal = min($maxTotal, $targetTotal);
    $need = max(0, $targetTotal - $counts['total']);
    if ($need <= 0) return 0;
    $botStmt = $pdo->prepare("SELECT bot_id FROM bots WHERE active = 1 AND bot_id NOT IN (SELECT player_id FROM lobby_players WHERE lobby_id = ?) ORDER BY RANDOM() LIMIT {$need}");
    $botStmt->execute([(string)$lobby['lobby_id']]);
    $bots = $botStmt->fetchAll();
    $now = bgs_now();
    $insert = $pdo->prepare("INSERT OR IGNORE INTO lobby_players (lobby_id, player_id, is_bot, paid_silver, paid_gold, joined_at, invited, join_source) VALUES (?, ?, 1, 0, 0, ?, 0, 'network_start')");
    $made = 0;
    foreach ($bots as $bot) {
        $insert->execute([(string)$lobby['lobby_id'], (string)$bot['bot_id'], $now]);
        $made += $insert->rowCount();
    }
    return $made;
}
function bgs_joined_count(PDO $pdo, string $lobbyId): int {
    $stmt = $pdo->prepare('SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ?');
    $stmt->execute([$lobbyId]);
    return (int)$stmt->fetchColumn();
}
function bgs_remove_one_start_player(PDO $pdo, string $lobbyId): bool {
    // Echte Spieler haben Vorrang. Wenn eine wartende Lobby voll ist, darf ein echter Join
    // einen automatisch eingesetzten Startplatz ersetzen. Das wird nicht öffentlich als Bot-Info ausgegeben.
    $stmt = $pdo->prepare('SELECT player_id FROM lobby_players WHERE lobby_id = ? AND is_bot = 1 ORDER BY joined_at DESC LIMIT 1');
    $stmt->execute([$lobbyId]);
    $startId = $stmt->fetchColumn();
    if (!is_string($startId) || $startId === '') return false;
    $del = $pdo->prepare('DELETE FROM lobby_players WHERE lobby_id = ? AND player_id = ? AND is_bot = 1');
    $del->execute([$lobbyId, $startId]);
    return $del->rowCount() > 0;
}
function bgs_remove_one_start_player_for_invite(PDO $pdo, string $lobbyId): bool {
    return bgs_remove_one_start_player($pdo, $lobbyId);
}
function bgs_lobby_public(PDO $pdo, array $lobby): array {
    $stmt = $pdo->prepare('SELECT player_id, is_bot, paid_silver, paid_gold, joined_at, invited, join_source FROM lobby_players WHERE lobby_id = ? ORDER BY joined_at ASC');
    $stmt->execute([(string)$lobby['lobby_id']]);
    $players = $stmt->fetchAll();
    $pub = [];
    foreach ($players as $row) {
        $pub[] = [
            'playerId' => (string)$row['player_id'],
            'name' => bgs_player_public_name($pdo, (string)$row['player_id'], (int)$row['is_bot']),
            'paidSilver' => (int)$row['paid_silver'],
            'paidGold' => (int)$row['paid_gold'],
            'joinedAt' => (int)$row['joined_at'],
        ];
    }
    return [
        'lobbyId' => (string)$lobby['lobby_id'],
        'gameKey' => (string)$lobby['game_key'],
        'startAt' => (int)$lobby['start_at'],
        'maxPlayers' => (int)$lobby['max_players'],
        'entryFee' => (int)$lobby['entry_fee'],
        'currency' => (string)$lobby['currency'],
        'status' => (string)$lobby['status'],
        'lobbyMode' => (string)($lobby['lobby_mode'] ?? 'public_pool'),
        'ownPublicLobby' => ((string)($lobby['lobby_mode'] ?? 'public_pool')) === 'public_invite',
        'hasInviteLink' => ((string)($lobby['invite_code'] ?? '')) !== '',
        'inviteUrl' => bgs_invite_link((string)$lobby['game_key'], (string)$lobby['lobby_id'], (string)($lobby['invite_code'] ?? '')),
        'inviteReservedSlots' => (int)($lobby['invite_reserved_slots'] ?? 0),
        'inviteFeeGold' => (int)($lobby['invite_fee_gold'] ?? 0),
        'count' => count($players),
        'players' => $pub,
        'serverNow' => bgs_now(),
        'networkIcon' => '🌐',
        'displayHint' => 'network_players',
    ];
}

function bgs_start_players_enabled(array $config): bool {
    if (empty($config['start_players_enabled'])) return false;
    $until = trim((string)($config['start_players_fill_until'] ?? ''));
    if ($until === '') return true;
    $ts = strtotime($until);
    return $ts === false ? true : bgs_now() <= $ts;
}
function bgs_start_player_count(array $config): int {
    return max(1, min(40, (int)($config['start_players_count'] ?? 20)));
}
function bgs_start_player_fill_window(array $config): int {
    return max(30, min(1800, (int)($config['start_players_fill_window_seconds'] ?? 180)));
}
function bgs_start_player_roster(): array {
    // Feste globale Netzwerk-Mitspieler. IDs und Namen bleiben weltweit gleich.
    // Sie werden in der normalen players-Userdatenbank geführt und nur technisch zusätzlich
    // in bots gespiegelt, damit ältere Lobby-Füllfunktionen kompatibel bleiben.
    return [
        ['id' => 'startplayer_yorkinho', 'name' => 'Yorkinho', 'country' => 'BR', 'seedRankPoints' => 1032],
        ['id' => 'startplayer_johnaldo', 'name' => 'Johnaldo', 'country' => 'US', 'seedRankPoints' => 1017],
        ['id' => 'startplayer_dorolski', 'name' => 'Dorolski', 'country' => 'PL', 'seedRankPoints' => 996],
        ['id' => 'startplayer_cn_01', 'name' => '小龙 星棋', 'country' => 'CN', 'seedRankPoints' => 1054],
        ['id' => 'startplayer_cn_02', 'name' => '美玲 星牌', 'country' => 'CN', 'seedRankPoints' => 978],
        ['id' => 'startplayer_jp_01', 'name' => 'はると 星丸', 'country' => 'JP', 'seedRankPoints' => 1011],
        ['id' => 'startplayer_jp_02', 'name' => 'さくら 盤姫', 'country' => 'JP', 'seedRankPoints' => 989],
        ['id' => 'startplayer_ar_01', 'name' => 'ليان نجمة', 'country' => 'AE', 'seedRankPoints' => 1026],
        ['id' => 'startplayer_eu_01', 'name' => 'Mia Sternfuchs', 'country' => 'DE', 'seedRankPoints' => 1003],
        ['id' => 'startplayer_eu_02', 'name' => 'Noah Puzzlepilot', 'country' => 'DE', 'seedRankPoints' => 997],
        ['id' => 'startplayer_eu_03', 'name' => 'Emma Wuerfelblitz', 'country' => 'AT', 'seedRankPoints' => 1019],
        ['id' => 'startplayer_eu_04', 'name' => 'Leon Arenaheld', 'country' => 'CH', 'seedRankPoints' => 982],
        ['id' => 'startplayer_eu_05', 'name' => 'Sofia Kartenprofi', 'country' => 'ES', 'seedRankPoints' => 1041],
        ['id' => 'startplayer_eu_06', 'name' => 'Finn Goldkomet', 'country' => 'FI', 'seedRankPoints' => 965],
        ['id' => 'startplayer_eu_07', 'name' => 'Clara Taktikfalke', 'country' => 'FR', 'seedRankPoints' => 1009],
        ['id' => 'startplayer_eu_08', 'name' => 'Luis Silberdrache', 'country' => 'PT', 'seedRankPoints' => 993],
        ['id' => 'startplayer_eu_09', 'name' => 'Lina Brettmeisterin', 'country' => 'IT', 'seedRankPoints' => 1037],
        ['id' => 'startplayer_eu_10', 'name' => 'Max Meeplemut', 'country' => 'NL', 'seedRankPoints' => 971],
        ['id' => 'startplayer_eu_11', 'name' => 'Nora Sternsprinterin', 'country' => 'SE', 'seedRankPoints' => 1022],
        ['id' => 'startplayer_eu_12', 'name' => 'Ben Wuerfelheld', 'country' => 'IE', 'seedRankPoints' => 1001],
    ];
}
function bgs_seed_start_players(PDO $pdo, array $config = []): int {
    $target = bgs_start_player_count($config);
    $roster = array_slice(bgs_start_player_roster(), 0, $target);
    $now = bgs_now();
    $made = 0;
    // Nur die aktuelle feste globale Roster soll aktiv sein. Alte Startspieler-IDs bleiben in der DB,
    // werden aber auf inaktiv gestellt und erscheinen nicht mehr in neuen Lobbies/Ranglisten.
    $pdo->exec("UPDATE bots SET active = 0");
    $pdo->exec("UPDATE players SET network_active = 0 WHERE is_network_player = 1");

    $insertBot = $pdo->prepare('INSERT OR IGNORE INTO bots (bot_id, bot_name, avatar, elo, wins, losses, games_played, silver_balance, gold_balance, active, created_at, updated_at, last_result_at) VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0, 1, ?, ?, 0)');
    $updateBot = $pdo->prepare('UPDATE bots SET bot_name = ?, active = 1, updated_at = ? WHERE bot_id = ?');
    $insertPlayer = $pdo->prepare("INSERT OR IGNORE INTO players (player_id, secret_hash, public_name, avatar, silver_balance, gold_balance, elo, last_daily_bonus_date, base_bonus_granted_at, gold_code_redeem_count, last_gold_code_redeemed_at, created_at, updated_at, wins, losses, games_played, last_result_at, player_type, country_code, is_network_player, network_active, locked_identity, stats_visible, source, last_seen_at) VALUES (?, ?, ?, '', 0, 0, ?, '', 0, 0, 0, ?, ?, 0, 0, 0, 0, 'network', ?, 1, 1, 1, 1, 'global_network_roster', 0)");
    $updatePlayer = $pdo->prepare("UPDATE players SET public_name = ?, country_code = ?, player_type = 'network', is_network_player = 1, network_active = 1, locked_identity = 1, stats_visible = 1, source = 'global_network_roster', updated_at = ? WHERE player_id = ?");
    foreach ($roster as $i => $entry) {
        $id = (string)$entry['id'];
        $name = (string)$entry['name'];
        $country = strtoupper((string)($entry['country'] ?? 'WORLD'));
        if (!preg_match('/^[A-Z]{2}$/', $country)) $country = 'WORLD';
        $seedRankPoints = (int)($entry['seedRankPoints'] ?? (980 + (($i * 29) % 90)));
        $secretHash = bgs_secret_hash('network-player:' . $id, $config);
        // INSERT OR IGNORE bewahrt bestehende Rangpunkte, Siege, Spiele und Historie dauerhaft.
        $insertPlayer->execute([$id, $secretHash, $name, $seedRankPoints, $now, $now, $country]);
        $made += $insertPlayer->rowCount();
        $updatePlayer->execute([$name, $country, $now, $id]);
        // Kompatibilitätsspiegel für bestehende Lobby-Füllung.
        $insertBot->execute([$id, $name, '', $seedRankPoints, $now, $now]);
        $updateBot->execute([$name, $now, $id]);
    }
    return $made;
}
function bgs_seed_bots(PDO $pdo, int $count = 20): int {
    return bgs_seed_start_players($pdo, ['start_players_count' => $count, 'start_players_enabled' => true]);
}
function bgs_fill_lobbies(PDO $pdo, array $config = []): array {
    if (!bgs_start_players_enabled($config)) return ['status'=>'ok','filled'=>0,'lobbies'=>0,'startPlayersActive'=>false];
    bgs_seed_start_players($pdo, $config);
    $now = bgs_now();
    $from = $now;
    $to = $now + bgs_start_player_gradual_start_seconds($config);
    $stmt = $pdo->prepare("SELECT * FROM lobbies WHERE status = 'waiting' AND start_at BETWEEN ? AND ?");
    $stmt->execute([$from, $to]);
    $lobbies = $stmt->fetchAll();
    if (!$lobbies) return ['status'=>'ok','filled'=>0,'lobbies'=>0,'startPlayersActive'=>true];
    $totalFilled = 0;
    foreach ($lobbies as $lobby) {
        $totalFilled += bgs_fill_lobby_to_staged_target($pdo, $lobby, $config);
    }
    return ['status'=>'ok','filled'=>$totalFilled,'lobbies'=>count($lobbies),'startPlayersActive'=>true,'mode'=>'gradual_10_minute'];
}


function bgs_lazy_maintenance(PDO $pdo, array $config, string $reason = 'request'): array {
    if (empty($config['lazy_maintenance_enabled'])) {
        return ['status'=>'skipped','reason'=>'disabled'];
    }
    $minInterval = max(5, min(3600, (int)($config['lazy_maintenance_min_interval_seconds'] ?? 25)));
    $now = bgs_now();
    $key = 'lazy_maintenance_last_run';

    $stmt = $pdo->prepare('SELECT state_value FROM system_state WHERE state_key = ?');
    $stmt->execute([$key]);
    $last = (int)($stmt->fetchColumn() ?: 0);
    if ($last > 0 && ($now - $last) < $minInterval) {
        return ['status'=>'skipped','reason'=>'recent','lastRun'=>$last,'nextRunAfter'=>$last + $minInterval];
    }

    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $stmt = $pdo->prepare('SELECT state_value FROM system_state WHERE state_key = ?');
        $stmt->execute([$key]);
        $last = (int)($stmt->fetchColumn() ?: 0);
        if ($last > 0 && ($now - $last) < $minInterval) {
            $pdo->exec('COMMIT');
            return ['status'=>'skipped','reason'=>'recent','lastRun'=>$last,'nextRunAfter'=>$last + $minInterval];
        }
        $pdo->prepare('INSERT OR REPLACE INTO system_state (state_key, state_value, updated_at) VALUES (?, ?, ?)')
            ->execute([$key, (string)$now, $now]);
        $pdo->exec('COMMIT');
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        return ['status'=>'skipped','reason'=>'lock_busy'];
    }

    $fill = bgs_fill_lobbies($pdo, $config);
    return ['status'=>'ok','reason'=>$reason,'ranAt'=>$now,'fill'=>$fill];
}

function bgs_read_games(): array {
    $file = __DIR__ . '/../assets/games.json';
    if (!is_file($file)) return ['version'=>1,'maxGames'=>40,'games'=>[]];
    $data = json_decode((string)file_get_contents($file), true);
    return is_array($data) ? $data : ['version'=>1,'maxGames'=>40,'games'=>[]];
}
function bgs_game_config(string $gameKey): array {
    $registry = bgs_read_games();
    foreach (($registry['games'] ?? []) as $game) {
        if (is_array($game) && (string)($game['gameKey'] ?? '') === $gameKey) return $game;
    }
    return [];
}
function bgs_own_lobby_max_players($value, array $gameCfg, array $config): int {
    $gameMax = max(2, min(64, (int)($gameCfg['maxPlayers'] ?? ($config['default_lobby_max_players'] ?? 9))));
    $hardMax = max(2, min($gameMax, (int)($gameCfg['ownLobbyMaxPlayers'] ?? ($config['own_lobby_max_players'] ?? $gameMax))));
    $min = max(2, min($hardMax, (int)($config['own_lobby_min_players'] ?? 2)));
    $requested = is_numeric($value) ? (int)$value : $hardMax;
    return max($min, min($hardMax, $requested));
}
function bgs_can_pay_entry_after_gold_fee(array $row, int $goldFee, int $entryFee, string $entryCurrency): bool {
    $silver = max(0, (int)($row['silver_balance'] ?? 0));
    $gold = max(0, (int)($row['gold_balance'] ?? 0) - max(0, $goldFee));
    if ($entryFee <= 0) return true;
    if ($entryCurrency === 'silver') return $silver >= $entryFee;
    if ($entryCurrency === 'gold') return $gold >= $entryFee;
    return ($silver + $gold) >= $entryFee;
}
function bgs_player_public_name(PDO $pdo, string $id, int $isBot): string {
    // Namen werden zuerst aus der normalen User-/Player-Datenbank gelesen.
    // Der bots-Spiegel ist nur noch technische Abwärtskompatibilität.
    $stmt = $pdo->prepare('SELECT public_name FROM players WHERE player_id = ?');
    $stmt->execute([$id]);
    $name = $stmt->fetchColumn();
    if (is_string($name) && $name !== '') return $name;
    if ($isBot) {
        $stmt = $pdo->prepare('SELECT bot_name FROM bots WHERE bot_id = ?');
        $stmt->execute([$id]);
        $name = $stmt->fetchColumn();
        return is_string($name) && $name !== '' ? $name : $id;
    }
    return 'BoardGameStar';
}

function bgs_record_player_game_stats(PDO $pdo, string $playerId, string $gameKey, int $score, int $rank, int $delta, int $won, int $lost, int $now): void {
    $stmt = $pdo->prepare('INSERT OR IGNORE INTO player_game_stats (player_id, game_key, rank_points, wins, losses, games_played, best_score, total_score, last_rank, last_delta, last_played_at) VALUES (?, ?, 1000, 0, 0, 0, 0, 0, 0, 0, 0)');
    $stmt->execute([$playerId, $gameKey]);
    $stmt = $pdo->prepare('UPDATE player_game_stats SET rank_points = CASE WHEN rank_points + ? < 100 THEN 100 ELSE rank_points + ? END, wins = wins + ?, losses = losses + ?, games_played = games_played + 1, best_score = CASE WHEN ? > best_score THEN ? ELSE best_score END, total_score = total_score + ?, last_rank = ?, last_delta = ?, last_played_at = ? WHERE player_id = ? AND game_key = ?');
    $stmt->execute([$delta, $delta, $won, $lost, $score, $score, $score, $rank, $delta, $now, $playerId, $gameKey]);
}

function bgs_apply_rank_result(PDO $pdo, string $playerId, int $isNetwork, string $gameKey, int $score, int $rank, int $delta, int $won, int $lost, int $now): void {
    if ($isNetwork) {
        $pdo->prepare("UPDATE players SET elo = CASE WHEN elo + ? < 100 THEN 100 ELSE elo + ? END, wins = wins + ?, losses = losses + ?, games_played = games_played + 1, updated_at = ?, last_result_at = ?, player_type = 'network', is_network_player = 1, network_active = 1, stats_visible = 1 WHERE player_id = ?")
            ->execute([$delta, $delta, $won, $lost, $now, $now, $playerId]);
        $pdo->prepare('UPDATE bots SET elo = CASE WHEN elo + ? < 100 THEN 100 ELSE elo + ? END, wins = wins + ?, losses = losses + ?, games_played = games_played + 1, updated_at = ?, last_result_at = ? WHERE bot_id = ?')
            ->execute([$delta, $delta, $won, $lost, $now, $now, $playerId]);
    } else {
        $pdo->prepare('UPDATE players SET elo = CASE WHEN elo + ? < 100 THEN 100 ELSE elo + ? END, wins = wins + ?, losses = losses + ?, games_played = games_played + 1, updated_at = ?, last_result_at = ?, last_seen_at = ? WHERE player_id = ?')
            ->execute([$delta, $delta, $won, $lost, $now, $now, $now, $playerId]);
    }
    bgs_record_player_game_stats($pdo, $playerId, $gameKey, $score, $rank, $delta, $won, $lost, $now);
}
function bgs_fill_one_lobby(PDO $pdo, string $lobbyId, array $config = []): int {
    $lobby = bgs_get_lobby($pdo, $lobbyId);
    if (!$lobby || (string)$lobby['status'] !== 'waiting') return 0;
    return bgs_fill_lobby_to_staged_target($pdo, $lobby, $config);
}
function bgs_lobby_user_is_inside(PDO $pdo, string $lobbyId, string $playerId): bool {
    $stmt = $pdo->prepare('SELECT 1 FROM lobby_players WHERE lobby_id = ? AND player_id = ? LIMIT 1');
    $stmt->execute([$lobbyId, $playerId]);
    return (bool)$stmt->fetchColumn();
}
function bgs_public_leaderboard(PDO $pdo, int $limit = 20): array {
    $limit = max(1, min(100, $limit));
    $rows = $pdo->query("SELECT public_name AS name, elo, wins, games_played, updated_at AS sort_ts, gold_code_redeem_count, is_network_player, country_code FROM players WHERE stats_visible = 1 AND (is_network_player = 0 OR network_active = 1)")->fetchAll();
    usort($rows, static function($a, $b) {
        $eloCmp = ((int)$b['elo']) <=> ((int)$a['elo']);
        if ($eloCmp !== 0) return $eloCmp;
        $winsCmp = ((int)$b['wins']) <=> ((int)$a['wins']);
        if ($winsCmp !== 0) return $winsCmp;
        return ((int)$b['sort_ts']) <=> ((int)$a['sort_ts']);
    });
    $out = [];
    $rank = 1;
    foreach (array_slice($rows ?: [], 0, $limit) as $row) {
        $out[] = [
            'rank' => $rank++,
            'name' => (string)$row['name'],
            'elo' => (int)$row['elo'], // interner Altname
            'rankPoints' => (int)$row['elo'],
            'pointsLabel' => 'Rangpunkte',
            'wins' => (int)($row['wins'] ?? 0),
            'gamesPlayed' => (int)($row['games_played'] ?? 0),
            'countryCode' => (string)($row['country_code'] ?? 'WORLD'),
            'networkIcon' => '🌐',
            'goldStatus' => bgs_public_gold_status((int)($row['gold_code_redeem_count'] ?? 0)),
        ];
    }
    return $out;
}
function bgs_demo_result(PDO $pdo, string $playerId, string $lobbyId, array $config): array {
    $lobby = bgs_get_lobby($pdo, $lobbyId);
    if (!$lobby) return ['status'=>'unknown_lobby'];
    if (!bgs_lobby_user_is_inside($pdo, $lobbyId, $playerId)) return ['status'=>'not_in_lobby'];
    $stmt = $pdo->prepare('SELECT player_id, is_bot FROM lobby_players WHERE lobby_id = ? ORDER BY joined_at ASC');
    $stmt->execute([$lobbyId]);
    $participants = $stmt->fetchAll();
    if (!$participants) return ['status'=>'empty_lobby'];

    $round = [];
    foreach ($participants as $row) {
        $id = (string)$row['player_id'];
        $isStarter = (int)$row['is_bot'] === 1;
        $score = $id === $playerId ? random_int(35, 100) : random_int(25, 100);
        $round[] = [
            'playerId' => $id,
            '_internalStarter' => $isStarter,
            'name' => bgs_player_public_name($pdo, $id, $isStarter ? 1 : 0),
            'score' => $score,
        ];
    }
    usort($round, static fn($a, $b) => ((int)$b['score']) <=> ((int)$a['score']));
    $now = bgs_now();
    $userScore = 0;
    $userDelta = 0;
    $userRank = 0;
    foreach ($round as $idx => $entry) {
        $rank = $idx + 1;
        $delta = $rank === 1 ? 14 : ($rank === 2 ? 7 : ($rank === 3 ? 3 : -2));
        $won = $rank === 1 ? 1 : 0;
        $lost = $won ? 0 : 1;
        bgs_apply_rank_result($pdo, (string)$entry['playerId'], !empty($entry['_internalStarter']) ? 1 : 0, (string)$lobby['game_key'], (int)$entry['score'], $rank, $delta, $won, $lost, $now);
        if ((string)$entry['playerId'] === $playerId) {
            $userScore = (int)$entry['score'];
            $userDelta = $delta;
            $userRank = $rank;
        }
    }
    bgs_ledger($pdo, $playerId, 'credit', 'elo', $userDelta, 'demo_game_result', $lobbyId, ['score'=>$userScore, 'rank'=>$userRank, 'gameKey'=>(string)$lobby['game_key']]);
    $publicRound = [];
    foreach ($round as $entry) {
        unset($entry['_internalStarter']);
        $publicRound[] = $entry;
    }
    $row = bgs_get_player($pdo, $playerId);
    return ['status'=>'ok','score'=>$userScore,'rank'=>$userRank,'eloDelta'=>$userDelta,'pointsDelta'=>$userDelta,'rankPointsDelta'=>$userDelta,'pointsLabel'=>'Rangpunkte','won'=>$userRank===1,'round'=>$publicRound,'stars'=>bgs_public_player($row ?: [], $config),'leaderboard'=>bgs_public_leaderboard($pdo, 20)];
}


function bgs_score_delta(int $score, int $rank = 0): int {
    $score = max(0, min(1000000, $score));
    if ($rank > 0) {
        return $rank === 1 ? 14 : ($rank === 2 ? 7 : ($rank === 3 ? 3 : -2));
    }
    $norm = $score > 100 ? min(100, (int)round($score / 10)) : $score;
    if ($norm >= 90) return 16;
    if ($norm >= 75) return 10;
    if ($norm >= 50) return 4;
    if ($norm >= 25) return -2;
    return -5;
}
function bgs_submit_game_result(PDO $pdo, string $playerId, array $data, array $config): array {
    $gameKey = bgs_clean((string)($data['gameKey'] ?? $data['game_key'] ?? ''), 40);
    if ($gameKey === '') return ['status'=>'invalid_game'];
    $gameCfg = bgs_game_config($gameKey);
    if (!$gameCfg) return ['status'=>'unknown_game'];
    $mode = strtolower(bgs_clean((string)($data['mode'] ?? 'online'), 20));
    if ($mode === 'offline') return ['status'=>'offline_result_not_stored'];
    $score = bgs_amount($data['score'] ?? 0, 0, 1000000);
    $rank = bgs_amount($data['rank'] ?? 0, 0, 1000);
    $lobbyId = bgs_clean((string)($data['lobbyId'] ?? $data['lobby_id'] ?? ''), 80);
    if ($lobbyId !== '' && !bgs_lobby_user_is_inside($pdo, $lobbyId, $playerId)) return ['status'=>'not_in_lobby'];
    $meta = $data['meta'] ?? [];
    if (!is_array($meta)) $meta = [];

    $participantMap = [];
    if ($lobbyId !== '') {
        $stmt = $pdo->prepare('SELECT player_id, is_bot FROM lobby_players WHERE lobby_id = ?');
        $stmt->execute([$lobbyId]);
        foreach ($stmt->fetchAll() as $row) {
            $participantMap[(string)$row['player_id']] = (int)$row['is_bot'];
        }
    }

    $roundUpdates = [];
    $ranking = $meta['ranking'] ?? ($data['ranking'] ?? []);
    if (is_array($ranking) && $participantMap) {
        $seen = [];
        foreach ($ranking as $entry) {
            if (!is_array($entry)) continue;
            $pid = bgs_clean((string)($entry['playerId'] ?? $entry['player_id'] ?? ''), 80);
            if ($pid === '' || !array_key_exists($pid, $participantMap) || isset($seen[$pid])) continue;
            $seen[$pid] = true;
            $roundUpdates[] = [
                'playerId' => $pid,
                'isBot' => (int)$participantMap[$pid],
                'score' => bgs_amount($entry['score'] ?? 0, 0, 1000000),
                'rank' => bgs_amount($entry['rank'] ?? 0, 0, 1000),
                'meta' => $entry,
            ];
        }
    }

    if (!$roundUpdates) {
        $roundUpdates[] = [
            'playerId' => $playerId,
            'isBot' => 0,
            'score' => $score,
            'rank' => $rank,
            'meta' => ['source' => 'single_player_result'],
        ];
    }

    usort($roundUpdates, static function($a, $b) {
        $ar = (int)($a['rank'] ?? 9999);
        $br = (int)($b['rank'] ?? 9999);
        if ($ar === 0) $ar = 9999;
        if ($br === 0) $br = 9999;
        return $ar <=> $br;
    });

    $resultId = 'result_' . bin2hex(random_bytes(10));
    $now = bgs_now();
    $userDelta = bgs_score_delta($score, $rank);
    $userScore = $score;
    $userRank = $rank;
    $publicRound = [];

    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $pdo->prepare('INSERT INTO game_results (id, ts, game_key, player_id, lobby_id, score, rank, mode, elo_delta, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([$resultId, $now, $gameKey, $playerId, $lobbyId, $score, $rank, 'online', $userDelta, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);

        foreach ($roundUpdates as $entry) {
            $pid = (string)$entry['playerId'];
            $entryScore = (int)$entry['score'];
            $entryRank = (int)$entry['rank'];
            $delta = bgs_score_delta($entryScore, $entryRank);
            $won = ($entryRank === 1 || ($entryRank === 0 && $entryScore >= 75)) ? 1 : 0;
            $lost = $won ? 0 : 1;
            bgs_apply_rank_result($pdo, $pid, (int)$entry['isBot'], $gameKey, $entryScore, $entryRank, $delta, $won, $lost, $now);
            if ($pid === $playerId) {
                $userDelta = $delta;
                $userScore = $entryScore;
                $userRank = $entryRank;
            }
            $publicRound[] = [
                'playerId' => $pid,
                'name' => bgs_player_public_name($pdo, $pid, (int)$entry['isBot']),
                'score' => $entryScore,
                'rank' => $entryRank,
                'eloDelta' => $delta,
                'pointsDelta' => $delta,
                'rankPointsDelta' => $delta,
            ];
        }

        if ($lobbyId !== '') {
            $pdo->prepare("UPDATE lobbies SET status = CASE WHEN lobby_id = ? AND status = 'waiting' THEN 'played' ELSE status END, updated_at = ? WHERE lobby_id = ?")
                ->execute([$lobbyId, $now, $lobbyId]);
        }
        $pdo->exec('COMMIT');
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
    bgs_ledger($pdo, $playerId, 'credit', 'elo', $userDelta, 'game_result', $lobbyId !== '' ? $lobbyId : $resultId, ['score'=>$userScore, 'rank'=>$userRank, 'gameKey'=>$gameKey, 'roundSize'=>count($roundUpdates)]);
    $row = bgs_get_player($pdo, $playerId);
    return ['status'=>'ok','resultId'=>$resultId,'gameKey'=>$gameKey,'score'=>$userScore,'rank'=>$userRank,'eloDelta'=>$userDelta,'pointsDelta'=>$userDelta,'rankPointsDelta'=>$userDelta,'pointsLabel'=>'Rangpunkte','won'=>$userRank===1,'round'=>$publicRound,'stars'=>bgs_public_player($row ?: [], $config),'leaderboard'=>bgs_public_leaderboard($pdo, 20)];
}


function bgs_activate_autopilot(PDO $pdo, string $playerId, array $data, array $config): array {
    $gameKey = bgs_clean((string)($data['gameKey'] ?? $data['game_key'] ?? ''), 40);
    if ($gameKey === '') return ['status'=>'invalid_game'];
    $gameCfg = bgs_game_config($gameKey);
    if (!$gameCfg) return ['status'=>'unknown_game'];
    if ((string)($gameCfg['status'] ?? 'live') !== 'live') return ['status'=>'game_not_live'];

    $lobbyId = bgs_clean((string)($data['lobbyId'] ?? $data['lobby_id'] ?? ''), 80);
    if ($lobbyId === '') return ['status'=>'lobby_required'];
    if (!bgs_lobby_user_is_inside($pdo, $lobbyId, $playerId)) return ['status'=>'not_in_lobby'];

    $fee = bgs_amount($data['fee'] ?? ($gameCfg['autopilotFee'] ?? ($config['autopilot_fee_silver'] ?? 10)), 0, 1000000);
    $currency = strtolower((string)($data['currency'] ?? ($config['autopilot_currency'] ?? 'silver')));
    if (!in_array($currency, ['silver','gold','auto'], true)) $currency = 'silver';

    $now = bgs_now();
    $pdo->exec('BEGIN IMMEDIATE');
    try {
        $existing = $pdo->prepare('SELECT * FROM autopilot_activations WHERE player_id = ? AND game_key = ? AND lobby_id = ? LIMIT 1');
        $existing->execute([$playerId, $gameKey, $lobbyId]);
        $old = $existing->fetch();
        if ($old) {
            $fresh = bgs_get_player($pdo, $playerId);
            $pdo->exec('COMMIT');
            return [
                'status'=>'ok',
                'alreadyActive'=>true,
                'cost'=>(int)$old['fee'],
                'currency'=>(string)$old['currency'],
                'spent'=>['silver'=>(int)$old['paid_silver'], 'gold'=>(int)$old['paid_gold']],
                'autopilot'=>['enabled'=>true, 'gameKey'=>$gameKey, 'lobbyId'=>$lobbyId, 'fee'=>(int)$old['fee']],
                'stars'=>bgs_public_player($fresh ?: [], $config)
            ];
        }

        $row = bgs_get_player($pdo, $playerId);
        if (!$row) throw new RuntimeException('missing_player');
        $silver = max(0, (int)$row['silver_balance']);
        $gold = max(0, (int)$row['gold_balance']);
        $spendSilver = 0;
        $spendGold = 0;
        if ($fee > 0) {
            if ($currency === 'silver') {
                if ($silver < $fee) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_silver','cost'=>$fee,'silverBalance'=>$silver,'stars'=>bgs_public_player($row, $config)]; }
                $spendSilver = $fee;
            } elseif ($currency === 'gold') {
                if ($gold < $fee) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_gold','cost'=>$fee,'goldBalance'=>$gold,'stars'=>bgs_public_player($row, $config)]; }
                $spendGold = $fee;
            } else {
                if ($silver + $gold < $fee) { $pdo->exec('ROLLBACK'); return ['status'=>'insufficient_stars','cost'=>$fee,'silverBalance'=>$silver,'goldBalance'=>$gold,'stars'=>bgs_public_player($row, $config)]; }
                $spendSilver = min($silver, $fee);
                $spendGold = $fee - $spendSilver;
            }
            $pdo->prepare('UPDATE players SET silver_balance = silver_balance - ?, gold_balance = gold_balance - ?, updated_at = ? WHERE player_id = ?')
                ->execute([$spendSilver, $spendGold, $now, $playerId]);
            if ($spendSilver > 0) bgs_ledger($pdo, $playerId, 'debit', 'silver', $spendSilver, 'autopilot_demo_mode', $lobbyId, ['gameKey'=>$gameKey]);
            if ($spendGold > 0) bgs_ledger($pdo, $playerId, 'debit', 'gold', $spendGold, 'autopilot_demo_mode', $lobbyId, ['gameKey'=>$gameKey]);
        }

        $id = 'autopilot_' . bin2hex(random_bytes(10));
        $pdo->prepare('INSERT INTO autopilot_activations (id, ts, player_id, game_key, lobby_id, fee, currency, paid_silver, paid_gold, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
            ->execute([$id, $now, $playerId, $gameKey, $lobbyId, $fee, $currency, $spendSilver, $spendGold, json_encode(['source'=>'game_container'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
        $fresh = bgs_get_player($pdo, $playerId);
        $pdo->exec('COMMIT');
        return [
            'status'=>'ok',
            'alreadyActive'=>false,
            'cost'=>$fee,
            'currency'=>$currency,
            'spent'=>['silver'=>$spendSilver, 'gold'=>$spendGold],
            'autopilot'=>['enabled'=>true, 'gameKey'=>$gameKey, 'lobbyId'=>$lobbyId, 'fee'=>$fee],
            'stars'=>bgs_public_player($fresh ?: [], $config)
        ];
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) $pdo->rollBack();
        throw $e;
    }
}

try {
    $pdo = bgs_db($config);
    $action = (string)($_GET['action'] ?? '');
    $data = bgs_body();
    if (!in_array($action, ['health'], true)) {
        bgs_lazy_maintenance($pdo, $config, $action !== '' ? $action : 'request');
    }

    if ($action === 'bootstrap') {
        $playerId = (string)($data['playerId'] ?? $data['player_id'] ?? '');
        $token = (string)($data['secretToken'] ?? $data['secret_token'] ?? '');
        $created = false;
        if (!bgs_valid_player_id($playerId)) $playerId = bgs_new_player_id();
        if ($token === '') $token = bgs_random_token();
        $row = bgs_get_player($pdo, $playerId);
        if ($row) {
            if (!hash_equals((string)$row['secret_hash'], bgs_secret_hash($token, $config))) bgs_json(['status'=>'invalid_auth'], 401);
        } else {
            $now = bgs_now();
            $name = bgs_clean((string)($data['publicName'] ?? $data['public_name'] ?? bgs_random_name()), 32);
            $checkName = bgs_validate_public_name($pdo, $config, $name, $playerId);
            if (empty($checkName['ok'])) $name = bgs_random_name(); else $name = (string)$checkName['name'];
            $defaultToken = bgs_public_avatar_token($config, '');
            $stmt = $pdo->prepare('INSERT INTO players (player_id, secret_hash, public_name, avatar, silver_balance, gold_balance, elo, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, 1000, ?, ?)');
            $stmt->execute([$playerId, bgs_secret_hash($token, $config), $name, (string)$defaultToken['id'], $now, $now]);
            $created = true;
        }
        $stars = bgs_grant_login_bonuses($pdo, $playerId, $config);
        bgs_json(['status'=>'ok','created'=>$created,'playerId'=>$playerId,'secretToken'=>$token,'stars'=>$stars,'shopUrl'=>(string)($config['star_shop_url'] ?? '')]);
    }

    if ($action === 'status') {
        $row = bgs_auth($pdo, $config, $data);
        $stars = bgs_grant_login_bonuses($pdo, (string)$row['player_id'], $config);
        $stmt = $pdo->prepare('SELECT * FROM ledger WHERE player_id = ? ORDER BY ts DESC LIMIT 12');
        $stmt->execute([(string)$row['player_id']]);
        bgs_json(['status'=>'ok','stars'=>$stars,'recent'=>$stmt->fetchAll(),'shopUrl'=>(string)($config['star_shop_url'] ?? '')]);
    }

    if ($action === 'recovery_generate') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $playerId = (string)$row['player_id'];
        $code = bgs_new_recovery_code();
        $now = bgs_now();
        $pdo->prepare('UPDATE players SET recovery_code_hash = ?, recovery_code_created_at = ?, updated_at = ? WHERE player_id = ?')
            ->execute([bgs_recovery_hash($code, $config), $now, $now, $playerId]);
        bgs_recovery_event($pdo, $config, $playerId, 'generated', ['source'=>'wallet']);
        $fresh = bgs_get_player($pdo, $playerId);
        bgs_json(['status'=>'ok','recoveryCode'=>$code,'createdAt'=>$now,'stars'=>bgs_public_player($fresh ?: [], $config)]);
    }

    if ($action === 'recover_profile') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $code = bgs_recovery_normalize($data['recoveryCode'] ?? $data['recovery_code'] ?? '');
        $newToken = (string)($data['newSecretToken'] ?? $data['secretToken'] ?? $data['secret_token'] ?? '');
        if ($code === '' || $newToken === '') bgs_json(['status'=>'invalid_request'], 400);
        $hash = bgs_recovery_hash($code, $config);
        $stmt = $pdo->prepare("SELECT * FROM players WHERE recovery_code_hash = ? AND stats_visible = 1 LIMIT 1");
        $stmt->execute([$hash]);
        $row = $stmt->fetch();
        if (!$row) bgs_json(['status'=>'invalid_recovery_code'], 404);
        if ((int)($row['is_network_player'] ?? 0) === 1 || (int)($row['locked_identity'] ?? 0) === 1) bgs_json(['status'=>'recovery_not_allowed'], 403);
        $playerId = (string)$row['player_id'];
        $now = bgs_now();
        $pdo->prepare('UPDATE players SET secret_hash = ?, recovery_used_at = ?, updated_at = ?, last_seen_at = ? WHERE player_id = ?')
            ->execute([bgs_secret_hash($newToken, $config), $now, $now, $now, $playerId]);
        bgs_recovery_event($pdo, $config, $playerId, 'recovered', ['source'=>'wallet']);
        $stars = bgs_grant_login_bonuses($pdo, $playerId, $config);
        bgs_json(['status'=>'ok','playerId'=>$playerId,'secretToken'=>$newToken,'stars'=>$stars,'message'=>'profile_recovered']);
    }

    if ($action === 'check_code') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $playerId = (string)$row['player_id'];
        $code = bgs_normalize_code($data['code'] ?? '');
        if ($code === '') bgs_json(['status'=>'invalid_request'], 400);
        $prefix = bgs_code_prefix($code);
        $local = bgs_local_admin_code_lookup($pdo, $code, $config);
        if (($local['status'] ?? '') === 'ok') {
            bgs_audit_code($pdo, $playerId, (string)$local['codePrefix'], 'admin_local_check', 'ok', (int)$local['amount']);
            bgs_json(['status'=>'ok','amount'=>(int)$local['amount'],'currency'=>(string)$local['currency'],'source'=>'admin_local']);
        } elseif (($local['status'] ?? '') !== 'code_not_found') {
            bgs_audit_code($pdo, $playerId, (string)($local['codePrefix'] ?? $prefix), 'admin_local_check', (string)$local['status'], 0);
            bgs_json(['status'=>(string)$local['status']], 400);
        }
        $guard = bgs_rate_check($pdo, $config, 'check_code', $playerId, 10, 15 * 60);
        if (empty($guard['allowed'])) {
            bgs_audit_code($pdo, $playerId, $prefix, 'remote_check', 'rate_limited', 0);
            bgs_json(['status'=>'rate_limited','retryAfter'=>(int)$guard['retryAfter']], 429);
        }
        $remote = bgs_remote_validate($code, $config);
        if (($remote['status'] ?? '') !== 'ok') {
            $status = bgs_clean((string)($remote['status'] ?? 'invalid_code'), 80);
            bgs_rate_failure($pdo, $config, 'check_code', $playerId);
            bgs_audit_code($pdo, $playerId, $prefix, 'remote_check', $status, 0);
            bgs_json(['status'=>$status], 400);
        }
        $amount = bgs_amount($remote['amount'] ?? $remote['stars'] ?? 0, 1, 10000000);
        bgs_rate_clear($pdo, $config, 'check_code', $playerId);
        bgs_audit_code($pdo, $playerId, $prefix, 'remote_check', 'ok', $amount);
        bgs_json(['status'=>'ok','amount'=>$amount,'currency'=>'gold']);
    }

    if ($action === 'redeem_code') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $playerId = (string)$row['player_id'];
        $code = bgs_normalize_code($data['code'] ?? '');
        if ($code === '') bgs_json(['status'=>'invalid_request'], 400);
        $prefix = bgs_code_prefix($code);
        $local = bgs_redeem_local_admin_code($pdo, $playerId, $code, $config);
        if (($local['status'] ?? '') === 'ok') {
            bgs_rate_clear($pdo, $config, 'redeem_code', $playerId);
            bgs_audit_code($pdo, $playerId, (string)$local['codePrefix'], 'admin_local', 'ok', (int)$local['amount']);
            bgs_json($local);
        } elseif (($local['status'] ?? '') !== 'code_not_found') {
            bgs_rate_failure($pdo, $config, 'redeem_code', $playerId);
            bgs_audit_code($pdo, $playerId, (string)($local['codePrefix'] ?? $prefix), 'admin_local', (string)$local['status'], 0);
            bgs_json(['status'=>(string)$local['status']], 400);
        }
        $guard = bgs_rate_check($pdo, $config, 'redeem_code', $playerId, 5, 15 * 60);
        if (empty($guard['allowed'])) {
            bgs_audit_code($pdo, $playerId, $prefix, 'remote', 'rate_limited', 0);
            bgs_json(['status'=>'rate_limited','retryAfter'=>(int)$guard['retryAfter']], 429);
        }
        $remote = bgs_remote_redeem($code, $config);
        if (($remote['status'] ?? '') !== 'ok') {
            $status = bgs_clean((string)($remote['status'] ?? 'invalid_code'), 80);
            if (in_array($status, ['used','already_used'], true)) $status = 'code_already_used';
            bgs_rate_failure($pdo, $config, 'redeem_code', $playerId);
            bgs_audit_code($pdo, $playerId, $prefix, 'remote', $status, 0);
            bgs_json(['status'=>$status], 400);
        }
        $amount = bgs_amount($remote['amount'] ?? $remote['stars'] ?? 0, 1, 10000000);
        $currency = strtolower((string)($remote['currency'] ?? 'gold'));
        if ($amount <= 0 || $currency !== 'gold') {
            bgs_rate_failure($pdo, $config, 'redeem_code', $playerId);
            bgs_audit_code($pdo, $playerId, $prefix, 'remote', 'invalid_amount', 0);
            bgs_json(['status'=>'invalid_code'], 400);
        }
        $result = bgs_credit_gold_from_code($pdo, $playerId, $amount);
        bgs_rate_clear($pdo, $config, 'redeem_code', $playerId);
        bgs_audit_code($pdo, $playerId, $prefix, 'remote', 'ok', $amount);
        bgs_json($result);
    }

    if ($action === 'spend') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $amount = bgs_amount($data['amount'] ?? 0, 1, 1000000);
        $currency = strtolower((string)($data['currency'] ?? 'auto'));
        $reason = bgs_clean((string)($data['reason'] ?? 'game_fee'), 60);
        $relatedId = bgs_clean((string)($data['relatedId'] ?? $data['related_id'] ?? ''), 80);
        if ($amount <= 0) bgs_json(['status'=>'invalid_request'], 400);
        $result = bgs_debit($pdo, (string)$row['player_id'], $amount, $currency, $reason, $relatedId, ['source'=>'generic_spend']);
        if (($result['status'] ?? '') !== 'ok') bgs_json($result, 402);
        bgs_json($result);
    }

    if ($action === 'rename') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        if ((int)($row['locked_identity'] ?? 0) === 1 || (int)($row['is_network_player'] ?? 0) === 1) bgs_json(['status'=>'locked_identity'], 403);
        $checkName = bgs_validate_public_name($pdo, $config, (string)($data['publicName'] ?? $data['public_name'] ?? ''), (string)$row['player_id']);
        if (empty($checkName['ok'])) bgs_json(['status'=>(string)($checkName['status'] ?? 'invalid_name')], 400);
        $newName = (string)$checkName['name'];
        if ($newName === (string)$row['public_name']) bgs_json(['status'=>'ok','cost'=>0,'stars'=>bgs_public_player($row, $config)]);
        $cost = max(0, (int)($config['rename_cost_silver'] ?? 100));
        if ($cost > 0) {
            $pay = bgs_debit($pdo, (string)$row['player_id'], $cost, 'silver', 'public_name_change', 'rename');
            if (($pay['status'] ?? '') !== 'ok') bgs_json($pay, 402);
        }
        $pdo->prepare('UPDATE players SET public_name = ?, updated_at = ? WHERE player_id = ?')->execute([$newName, bgs_now(), (string)$row['player_id']]);
        $fresh = bgs_get_player($pdo, (string)$row['player_id']);
        bgs_json(['status'=>'ok','cost'=>$cost,'stars'=>bgs_public_player($fresh ?: [], $config)]);
    }

    if ($action === 'select_token') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        if ((int)($row['locked_identity'] ?? 0) === 1 || (int)($row['is_network_player'] ?? 0) === 1) bgs_json(['status'=>'locked_identity'], 403);
        $tokenId = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)($data['tokenId'] ?? $data['token_id'] ?? '')));
        $token = bgs_token_by_id($config, $tokenId);
        if (!$token) bgs_json(['status'=>'invalid_token'], 400);
        if ((string)($row['avatar'] ?? '') === (string)$token['id']) bgs_json(['status'=>'ok','cost'=>0,'token'=>$token,'stars'=>bgs_public_player($row, $config)]);
        $cost = max(0, (int)($config['player_token_cost_silver'] ?? 100));
        if ($cost > 0) {
            $pay = bgs_debit($pdo, (string)$row['player_id'], $cost, 'silver', 'player_token_change', (string)$token['id'], ['tokenLabel'=>(string)$token['label']]);
            if (($pay['status'] ?? '') !== 'ok') bgs_json($pay, 402);
        }
        $pdo->prepare('UPDATE players SET avatar = ?, updated_at = ? WHERE player_id = ?')->execute([(string)$token['id'], bgs_now(), (string)$row['player_id']]);
        $fresh = bgs_get_player($pdo, (string)$row['player_id']);
        bgs_json(['status'=>'ok','cost'=>$cost,'token'=>$token,'stars'=>bgs_public_player($fresh ?: [], $config)]);
    }


    if ($action === 'lobby_list') {
        $row = bgs_auth($pdo, $config, $data);
        $gameKey = bgs_clean((string)($data['gameKey'] ?? $_GET['gameKey'] ?? 'biathlon'), 40);
        $gameCfg = bgs_game_config($gameKey);
        if ($gameCfg && (string)($gameCfg['status'] ?? 'live') !== 'live') bgs_json(['status'=>'game_not_live'], 400);
        $interval = (int)($data['interval'] ?? $_GET['interval'] ?? ($gameCfg['intervalSeconds'] ?? ($config['default_lobby_interval_seconds'] ?? 600)));
        $maxPlayers = max(2, min(64, (int)($gameCfg['maxPlayers'] ?? ($config['default_lobby_max_players'] ?? 9))));
        $entryFee = bgs_amount($data['entryFee'] ?? ($gameCfg['entryFee'] ?? ($config['default_entry_fee_silver'] ?? 10)), 0, 1000000);
        $currency = strtolower((string)($data['currency'] ?? 'auto'));
        if (!in_array($currency, ['silver','gold','auto'], true)) $currency = 'auto';
        $slots = max(1, min(8, (int)($data['slots'] ?? ($config['lobby_list_slots'] ?? 3))));
        $lobbies = bgs_ensure_public_lobbies($pdo, $gameKey, $interval, $maxPlayers, $entryFee, $currency, $slots);
        bgs_fill_lobbies($pdo, $config);
        $public = [];
        foreach ($lobbies as $lobby) {
            $fresh = bgs_get_lobby($pdo, (string)$lobby['lobby_id']) ?: $lobby;
            $public[] = bgs_lobby_public($pdo, $fresh);
        }
        bgs_json(['status'=>'ok','gameKey'=>$gameKey,'interval'=>$interval,'entryFee'=>$entryFee,'currency'=>$currency,'lobbies'=>$public,'stars'=>bgs_public_player(bgs_get_player($pdo,(string)$row['player_id']) ?: [], $config)]);
    }

    if ($action === 'lobby_next') {
        $gameKey = bgs_clean((string)($_GET['gameKey'] ?? $data['gameKey'] ?? 'biathlon'), 40);
        $interval = (int)($_GET['interval'] ?? $data['interval'] ?? ($config['default_lobby_interval_seconds'] ?? 600));
        bgs_json(['status'=>'ok','gameKey'=>$gameKey,'startAt'=>bgs_next_start($interval)]);
    }

    if ($action === 'lobby_join') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $playerId = (string)$row['player_id'];
        $gameKey = bgs_clean((string)($data['gameKey'] ?? 'biathlon'), 40);
        $gameCfg = bgs_game_config($gameKey);
        if ($gameCfg && (string)($gameCfg['status'] ?? 'live') !== 'live') bgs_json(['status'=>'game_not_live'], 400);
        $interval = (int)($data['interval'] ?? ($gameCfg['intervalSeconds'] ?? ($config['default_lobby_interval_seconds'] ?? 600)));
        $startAt = (int)($data['startAt'] ?? bgs_next_start($interval));
        $defaultMaxPlayers = max(2, min(64, (int)($gameCfg['maxPlayers'] ?? ($config['default_lobby_max_players'] ?? 9))));
        $entryFee = bgs_amount($data['entryFee'] ?? ($gameCfg['entryFee'] ?? ($config['default_entry_fee_silver'] ?? 10)), 0, 1000000);
        $currency = strtolower((string)($data['currency'] ?? 'auto'));
        if (!in_array($currency, ['silver','gold','auto'], true)) $currency = 'auto';

        $requestedLobbyId = bgs_clean((string)($data['lobbyId'] ?? $data['lobby_id'] ?? ''), 80);
        $inviteCode = bgs_clean(strtoupper((string)($data['inviteCode'] ?? $data['invite_code'] ?? '')), 40);
        $createOwnPublicLobby = !empty($data['ownPublicLobby']) || !empty($data['createInviteLobby']) || !empty($data['inviteLobby']);
        $invitedJoin = false;
        $joinSource = 'public';

        if ($requestedLobbyId !== '') {
            $lobby = bgs_get_lobby($pdo, $requestedLobbyId);
            if (!$lobby) bgs_json(['status'=>'unknown_lobby'], 404);
            if ((string)$lobby['game_key'] !== $gameKey) bgs_json(['status'=>'wrong_game_for_lobby'], 400);
            if ((string)$lobby['status'] !== 'waiting') bgs_json(['status'=>'lobby_not_waiting'], 409);
            $entryFee = max(0, (int)($lobby['entry_fee'] ?? $entryFee));
            $currency = strtolower((string)($lobby['currency'] ?? $currency));
            if (!in_array($currency, ['silver','gold','auto'], true)) $currency = 'auto';
            $expectedInvite = (string)($lobby['invite_code'] ?? '');
            if ($expectedInvite !== '' && hash_equals($expectedInvite, $inviteCode)) {
                $invitedJoin = true;
                $joinSource = 'invite_link';
            }
        } elseif ($createOwnPublicLobby) {
            $ownFeeGold = bgs_own_lobby_fee_gold($gameCfg, $config);
            $maxPlayers = bgs_own_lobby_max_players($data['maxPlayers'] ?? $data['ownMaxPlayers'] ?? $defaultMaxPlayers, $gameCfg, $config);
            $freshBefore = bgs_get_player($pdo, $playerId) ?: $row;
            if ((int)($freshBefore['gold_balance'] ?? 0) < $ownFeeGold) {
                bgs_json(['status'=>'insufficient_own_lobby_gold','cost'=>$ownFeeGold,'currency'=>'gold','goldBalance'=>(int)($freshBefore['gold_balance'] ?? 0),'stars'=>bgs_public_player($freshBefore, $config)], 402);
            }
            if (!bgs_can_pay_entry_after_gold_fee($freshBefore, $ownFeeGold, $entryFee, $currency)) {
                bgs_json(['status'=>'insufficient_stars_after_own_lobby','ownLobbyCostGold'=>$ownFeeGold,'entryFee'=>$entryFee,'currency'=>$currency,'stars'=>bgs_public_player($freshBefore, $config)], 402);
            }
            $lobby = bgs_create_lobby($pdo, $gameKey, $startAt, $maxPlayers, $entryFee, $currency, [
                'ownerPlayerId' => $playerId,
                'lobbyMode' => 'public_invite',
                'inviteCode' => bgs_new_invite_code(),
                'inviteFeeGold' => $ownFeeGold,
                'inviteReservedSlots' => max(0, min($maxPlayers - 1, (int)($config['invite_lobby_reserved_slots'] ?? 1))),
            ]);
            if ($ownFeeGold > 0) {
                $ownPay = bgs_debit($pdo, $playerId, $ownFeeGold, 'gold', 'own_public_lobby_create', (string)$lobby['lobby_id'], ['gameKey'=>$gameKey, 'maxPlayers'=>$maxPlayers, 'inviteLink'=>true]);
                if (($ownPay['status'] ?? '') !== 'ok') {
                    $pdo->prepare('DELETE FROM lobbies WHERE lobby_id = ? AND owner_player_id = ?')->execute([(string)$lobby['lobby_id'], $playerId]);
                    bgs_json($ownPay, 402);
                }
            }
            $invitedJoin = true;
            $joinSource = 'creator';
        } else {
            $maxPlayers = $defaultMaxPlayers;
            $lobby = bgs_get_or_create_lobby($pdo, $gameKey, $startAt, $maxPlayers, $entryFee, $currency);
        }

        $pdo->exec('BEGIN IMMEDIATE');
        try {
            $lobby = bgs_get_lobby($pdo, (string)$lobby['lobby_id']) ?: $lobby;
            $check = $pdo->prepare('SELECT * FROM lobby_players WHERE lobby_id = ? AND player_id = ?');
            $check->execute([$lobby['lobby_id'], $playerId]);
            $already = $check->fetch();
            if ($already) {
                $pdo->exec('COMMIT');
                bgs_json(['status'=>'ok','alreadyJoined'=>true,'priorityJoin'=>$invitedJoin,'lobby'=>bgs_lobby_public($pdo, $lobby),'stars'=>bgs_public_player(bgs_get_player($pdo,$playerId) ?: [], $config)]);
            }
            $currentCount = bgs_joined_count($pdo, (string)$lobby['lobby_id']);
            $maxPlayers = (int)$lobby['max_players'];
            if ($currentCount >= $maxPlayers) {
                // Echte Spieler haben Vorrang: Bei voller, wartender Lobby wird ein automatischer Startplatz frei gemacht.
                if (bgs_remove_one_start_player($pdo, (string)$lobby['lobby_id'])) {
                    $currentCount = bgs_joined_count($pdo, (string)$lobby['lobby_id']);
                }
            }
            $reserve = bgs_invite_reserved_slots($lobby, $config);
            if (!$invitedJoin && (string)($lobby['invite_code'] ?? '') !== '' && $reserve > 0 && $currentCount >= ($maxPlayers - $reserve)) {
                $pdo->exec('ROLLBACK');
                bgs_json(['status'=>'invite_priority_reserved','lobby'=>bgs_lobby_public($pdo, $lobby)], 409);
            }
            if ($currentCount >= $maxPlayers) {
                $pdo->exec('ROLLBACK');
                bgs_json(['status'=>'lobby_full'], 409);
            }
            $pdo->exec('COMMIT');
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
        $pay = $entryFee > 0 ? bgs_debit($pdo, $playerId, $entryFee, $currency, 'lobby_entry_fee', (string)$lobby['lobby_id'], ['gameKey'=>$gameKey, 'priorityJoin'=>$invitedJoin]) : ['status'=>'ok','spent'=>['silver'=>0,'gold'=>0],'stars'=>bgs_public_player(bgs_get_player($pdo,$playerId) ?: [], $config)];
        if (($pay['status'] ?? '') !== 'ok') bgs_json($pay, 402);
        $spent = $pay['spent'] ?? ['silver'=>0,'gold'=>0];
        $pdo->prepare('INSERT OR IGNORE INTO lobby_players (lobby_id, player_id, is_bot, paid_silver, paid_gold, joined_at, invited, join_source) VALUES (?, ?, 0, ?, ?, ?, ?, ?)')
            ->execute([(string)$lobby['lobby_id'], $playerId, (int)($spent['silver'] ?? 0), (int)($spent['gold'] ?? 0), bgs_now(), $invitedJoin ? 1 : 0, $joinSource]);
        $freshLobby = bgs_get_lobby($pdo, (string)$lobby['lobby_id']) ?: $lobby;
        bgs_json(['status'=>'ok','alreadyJoined'=>false,'priorityJoin'=>$invitedJoin,'lobby'=>bgs_lobby_public($pdo, $freshLobby),'stars'=>$pay['stars']]);
    }




    if ($action === 'autopilot_activate') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $result = bgs_activate_autopilot($pdo, (string)$row['player_id'], $data, $config);
        if (($result['status'] ?? '') !== 'ok') bgs_json($result, 400);
        bgs_json($result);
    }

    if ($action === 'games') {
        bgs_json(['status'=>'ok','registry'=>bgs_read_games()]);
    }

    if ($action === 'leaderboard') {
        if (bgs_start_players_enabled($config)) bgs_seed_start_players($pdo, $config);
        bgs_json(['status'=>'ok','leaderboard'=>bgs_public_leaderboard($pdo, (int)($_GET['limit'] ?? 20))]);
    }

    if ($action === 'lobby_get') {
        $row = bgs_auth($pdo, $config, $data);
        $lobbyId = bgs_clean((string)($data['lobbyId'] ?? $data['lobby_id'] ?? $_GET['lobbyId'] ?? ''), 80);
        if ($lobbyId === '') bgs_json(['status'=>'invalid_request'], 400);
        $lobby = bgs_get_lobby($pdo, $lobbyId);
        if (!$lobby) bgs_json(['status'=>'unknown_lobby'], 404);
        if (!bgs_lobby_user_is_inside($pdo, $lobbyId, (string)$row['player_id'])) bgs_json(['status'=>'not_in_lobby'], 403);
        if ((int)$lobby['start_at'] <= bgs_now() + bgs_start_player_fill_window($config)) {
            bgs_fill_one_lobby($pdo, $lobbyId, $config);
            $lobby = bgs_get_lobby($pdo, $lobbyId) ?: $lobby;
        }
        bgs_json(['status'=>'ok','lobby'=>bgs_lobby_public($pdo, $lobby)]);
    }


    if ($action === 'submit_result') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $result = bgs_submit_game_result($pdo, (string)$row['player_id'], $data, $config);
        if (($result['status'] ?? '') !== 'ok') bgs_json($result, 400);
        bgs_json($result);
    }

    if ($action === 'demo_result') {
        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') bgs_json(['status'=>'method_not_allowed'], 405);
        $row = bgs_auth($pdo, $config, $data);
        $lobbyId = bgs_clean((string)($data['lobbyId'] ?? $data['lobby_id'] ?? ''), 80);
        if ($lobbyId === '') bgs_json(['status'=>'invalid_request'], 400);
        $result = bgs_demo_result($pdo, (string)$row['player_id'], $lobbyId, $config);
        if (($result['status'] ?? '') !== 'ok') bgs_json($result, 400);
        bgs_json($result);
    }

    if ($action === 'maintenance') {
        $result = bgs_fill_lobbies($pdo, $config);
        bgs_json(['status'=>'ok','mode'=>'lazy_request','maintenance'=>$result]);
    }

    bgs_json(['status'=>'unknown_action'], 404);
} catch (Throwable $e) {
    error_log('BGS API error: ' . $e->getMessage());
    bgs_json(['status'=>'temporary_unavailable'], 500);
}
