<?php

declare(strict_types=1);

defined('APP_BOOTSTRAPPED') or exit('No direct access.');

/*
|--------------------------------------------------------------------------
| GameWalletService
|--------------------------------------------------------------------------
| Saldo lokal adalah sumber utama.
|
| ENTER:
|   saldo lokal -> provider API -> game session OPEN
|
| EXIT:
|   saldo aktual provider -> saldo lokal -> game session CLOSED
|--------------------------------------------------------------------------
*/

class GameWalletService
{
    private PDO $pdo;

    private bool $lockHeld = false;

    private string $lockName = '';

    private float $tolerance = 0.01;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;

        $this->pdo->setAttribute(
            PDO::ATTR_ERRMODE,
            PDO::ERRMODE_EXCEPTION
        );

        $this->pdo->setAttribute(
            PDO::ATTR_DEFAULT_FETCH_MODE,
            PDO::FETCH_ASSOC
        );

        $this->pdo->setAttribute(
            PDO::ATTR_EMULATE_PREPARES,
            false
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Muat adapter Nexus + SGX
    |--------------------------------------------------------------------------
    */

    public static function bootApi(): void
    {
        $srcPath = defined('APP_SRC_PATH')
            ? APP_SRC_PATH
            : __DIR__;

        $requiredFiles = [
            $srcPath . '/GameApiException.php',
            $srcPath . '/Contracts/GameApiAdapterInterface.php',
            $srcPath . '/Adapters/AbstractGameApiAdapter.php',
            $srcPath . '/Adapters/SgxGameApiAdapter.php',
            $srcPath . '/Adapters/NexusGameApiAdapter.php',
            $srcPath . '/GameApi.php',
        ];

        foreach ($requiredFiles as $requiredFile) {
            if (!is_file($requiredFile)) {
                throw new RuntimeException(
                    'File API game tidak ditemukan: ' .
                    $requiredFile
                );
            }

            require_once $requiredFile;
        }

        $configPath = defined('APP_CONFIG_PATH')
            ? APP_CONFIG_PATH . '/api.php'
            : dirname(__DIR__) . '/config/api.php';

        if (!is_file($configPath)) {
            throw new RuntimeException(
                'Konfigurasi API game tidak ditemukan.'
            );
        }

        $config = require $configPath;

        if (!is_array($config)) {
            throw new RuntimeException(
                'Konfigurasi API game harus berupa array.'
            );
        }

        GameApi::init($config);
    }

    /*
    |--------------------------------------------------------------------------
    | Player code
    |--------------------------------------------------------------------------
    */

    public function resolvePlayerCode(
        array $user,
        string $api
    ): string {
        $api = $this->normalizeApi($api);

        $columns = $api === 'nexus'
            ? [
                'player_code_nexus',
                'nexus_player_code',
                'extplayer_nexus',
                'nexus_username',
                'extplayer',
            ]
            : [
                'player_code_sgx',
                'sgx_player_code',
                'extplayer_sgx',
                'sgx_username',
                'extplayer',
            ];

        foreach ($columns as $column) {
            if (!array_key_exists($column, $user)) {
                continue;
            }

            $value = trim(
                (string) $user[$column]
            );

            if ($value !== '') {
                return $this->validatePlayerCode(
                    $value
                );
            }
        }

        return $this->validatePlayerCode(
            (string) (
                $user['username'] ?? ''
            )
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Masuk game
    |--------------------------------------------------------------------------
    */

    public function enterGame(
        int $userId,
        string $api,
        string $providerCode,
        string $gameCode,
        string $gameName
    ): array {
        $api = $this->normalizeApi($api);

        $providerCode = strtoupper(
            trim($providerCode)
        );

        $gameCode = trim($gameCode);
        $gameName = trim($gameName);

        if ($providerCode === '') {
            throw new RuntimeException(
                'Kode provider kosong.'
            );
        }

        if ($gameCode === '') {
            throw new RuntimeException(
                'Kode game kosong.'
            );
        }

        $this->acquireUserLock($userId);

        $sessionId = 0;
        $sessionCode = '';
        $enterReference = '';
        $localAmount = 0.0;
        $playerCode = '';
        $providerBefore = 0.0;
        $providerAfter = 0.0;
        $providerDepositSucceeded = false;

        try {
            /*
             * Pulihkan operasi API yang terputus sebelum proses baru.
             */
            $this->recoverIncompleteOperationsUnlocked(
                $userId
            );

            /*
             * Tutup sesi lama sebelum membuka provider baru.
             */
            $this->exitOpenSessionsUnlocked(
                $userId,
                'closed'
            );

            $user = $this->loadUser($userId);

            if (
                strtoupper(
                    (string) (
                        $user['status'] ?? ''
                    )
                ) !== 'ACTIVE'
            ) {
                throw new RuntimeException(
                    'Akun tidak aktif.'
                );
            }

            /*
             * Pastikan provider API lain tidak masih menyimpan saldo dari
             * session lama. Provider lain hanya diperiksa bila user pernah
             * memiliki session/operasi pada provider tersebut.
             */
            $this->secureOtherProviderBalancesUnlocked(
                $userId,
                $user,
                $api
            );

            /*
             * Saldo lokal mungkin bertambah setelah pengamanan provider lain.
             */
            $user = $this->loadUser($userId);

            $playerCode =
                $this->resolvePlayerCode(
                    $user,
                    $api
                );

            $adapter = GameApi::adapter($api);

            if (
                $api === 'sgx' &&
                method_exists(
                    $adapter,
                    'createUser'
                )
            ) {
                try {
                    $adapter->createUser(
                        $playerCode
                    );
                } catch (Throwable $exception) {
                    /*
                     * Adapter SGX sudah menormalkan DUPLICATED_USER
                     * sebagai sukses. Error lain tidak boleh diabaikan.
                     */
                    throw new RuntimeException(
                        'Akun SGX belum dapat disiapkan: ' .
                        $exception->getMessage(),
                        0,
                        $exception
                    );
                }
            }

            $providerBefore =
                $this->getProviderBalance(
                    $adapter,
                    $playerCode
                );

            /*
             * Provider tidak boleh berisi saldo tanpa session.
             * Ini mencegah saldo provider lama tercampur dengan transfer baru.
             */
            if ($providerBefore > $this->tolerance) {
                $this->returnOrphanBalanceUnlocked(
                    $userId,
                    $api,
                    $playerCode,
                    $providerBefore
                );

                $providerBefore =
                    $this->getProviderBalance(
                        $adapter,
                        $playerCode
                    );

                if (
                    $providerBefore >
                    $this->tolerance
                ) {
                    throw new RuntimeException(
                        'Saldo lama di ' .
                        strtoupper($api) .
                        ' belum berhasil diamankan.'
                    );
                }

                /*
                 * Saldo lokal mungkin bertambah setelah orphan return.
                 */
                $user = $this->loadUser($userId);
            }

            $localAmount = round(
                (float) (
                    $user['balance_local'] ??
                    0
                ),
                2
            );

            $sessionCode =
                $this->makeSessionCode();

            $enterReference =
                'GIN-' . $sessionCode;

            $this->insertOperation(
                $enterReference,
                $userId,
                null,
                $api,
                $playerCode,
                'ENTER',
                $localAmount,
                $providerBefore,
                $providerBefore,
                'STARTED',
                null
            );

            if ($localAmount > 0) {
                $this->debitLocal(
                    $userId,
                    $localAmount,
                    $enterReference,
                    'GAME_TRANSFER_OUT',
                    'Transfer saldo lokal ke provider ' .
                    strtoupper($api)
                );

                $this->updateOperationStatusSafe(
                    $enterReference,
                    'LOCAL_DEBITED',
                    $providerBefore,
                    null
                );

                try {
                    $adapter->deposit(
                        $playerCode,
                        $localAmount,
                        $this->providerTransactionId(
                            $enterReference
                        )
                    );

                    $providerDepositSucceeded = true;
                } catch (Throwable $depositException) {
                    /*
                     * Timeout belum tentu gagal. Saldo lokal tidak boleh
                     * langsung dikreditkan bila saldo provider tidak dapat
                     * diverifikasi karena dapat menyebabkan saldo ganda.
                     */
                    $verifiedBalance =
                        $this->safeProviderBalance(
                            $adapter,
                            $playerCode,
                            -1.0
                        );

                    if ($verifiedBalance < 0) {
                        $this->updateOperationStatusSafe(
                            $enterReference,
                            'LOCAL_DEBITED',
                            $providerBefore,
                            'Status deposit provider belum dapat diverifikasi'
                        );

                        throw new RuntimeException(
                            'Status transfer ke ' .
                            strtoupper($api) .
                            ' belum dapat dipastikan. Saldo sedang diamankan dan akan direkonsiliasi otomatis.',
                            0,
                            $depositException
                        );
                    }

                    if (
                        $verifiedBalance <
                        (
                            $providerBefore +
                            $localAmount -
                            $this->tolerance
                        )
                    ) {
                        /*
                         * Beri waktu singkat untuk provider yang memproses
                         * deposit sedikit terlambat, terutama SGX yang tidak
                         * mengirim transaction ID ke provider.
                         */
                        usleep(750000);

                        $delayedBalance =
                            $this->safeProviderBalance(
                                $adapter,
                                $playerCode,
                                -1.0
                            );

                        if ($delayedBalance < 0) {
                            $this->updateOperationStatusSafe(
                                $enterReference,
                                'LOCAL_DEBITED',
                                $verifiedBalance,
                                'Verifikasi kedua deposit provider gagal'
                            );

                            throw new RuntimeException(
                                'Status transfer ke ' .
                                strtoupper($api) .
                                ' belum dapat dipastikan. Saldo sedang diamankan.',
                                0,
                                $depositException
                            );
                        }

                        $verifiedBalance = max(
                            $verifiedBalance,
                            $delayedBalance
                        );
                    }

                    if (
                        $verifiedBalance >=
                        (
                            $providerBefore +
                            $localAmount -
                            $this->tolerance
                        )
                    ) {
                        $providerDepositSucceeded = true;
                    } else {
                        $this->rollbackEnterKnownState(
                            $userId,
                            $api,
                            $playerCode,
                            $providerBefore,
                            $verifiedBalance,
                            $localAmount,
                            $sessionCode,
                            $enterReference,
                            'Deposit provider gagal terverifikasi'
                        );

                        throw new RuntimeException(
                            'Transfer saldo ke ' .
                            strtoupper($api) .
                            ' gagal: ' .
                            $depositException->getMessage(),
                            0,
                            $depositException
                        );
                    }
                }
            }

            $providerAfter =
                $this->getProviderBalance(
                    $adapter,
                    $playerCode
                );

            $this->updateOperationStatusSafe(
                $enterReference,
                'PROVIDER_SUCCESS',
                $providerAfter,
                null
            );

            if (
                $localAmount > 0 &&
                $providerAfter <
                (
                    $providerBefore +
                    $localAmount -
                    $this->tolerance
                )
            ) {
                $this->rollbackEnterKnownState(
                    $userId,
                    $api,
                    $playerCode,
                    $providerBefore,
                    $providerAfter,
                    $localAmount,
                    $sessionCode,
                    $enterReference,
                    'Saldo provider lebih kecil dari nominal transfer'
                );

                throw new RuntimeException(
                    'Saldo provider belum terverifikasi. Saldo lokal telah dipulihkan.'
                );
            }

            $this->pdo->beginTransaction();

            try {
                $insertSession =
                    $this->pdo->prepare(
                        "INSERT INTO game_sessions (
                            session_code,
                            user_id,
                            provider,
                            provider_username,
                            game_code,
                            game_name,
                            local_balance_before,
                            transfer_amount,
                            provider_balance_before,
                            provider_balance_after,
                            status,
                            login_ip,
                            user_agent,
                            api_reference,
                            opened_at
                        ) VALUES (
                            :session_code,
                            :user_id,
                            :provider,
                            :provider_username,
                            :game_code,
                            :game_name,
                            :local_balance_before,
                            :transfer_amount,
                            :provider_balance_before,
                            :provider_balance_after,
                            'open',
                            :login_ip,
                            :user_agent,
                            :api_reference,
                            CURRENT_TIMESTAMP
                        )"
                    );

                $insertSession->execute([
                    ':session_code' =>
                        $sessionCode,
                    ':user_id' => $userId,
                    ':provider' => $api,
                    ':provider_username' =>
                        $playerCode,
                    ':game_code' => substr(
                        $gameCode,
                        0,
                        80
                    ),
                    ':game_name' => substr(
                        $gameName !== ''
                            ? $gameName
                            : $gameCode,
                        0,
                        150
                    ),
                    ':local_balance_before' =>
                        $this->money($localAmount),
                    ':transfer_amount' =>
                        $this->money($localAmount),
                    ':provider_balance_before' =>
                        $this->money($providerBefore),
                    ':provider_balance_after' =>
                        $this->money($providerAfter),
                    ':login_ip' =>
                        $this->clientIp(),
                    ':user_agent' =>
                        $this->userAgent(),
                    ':api_reference' =>
                        $enterReference,
                ]);

                $sessionId =
                    (int) $this->pdo
                        ->lastInsertId();

                $this->insertOperation(
                    $enterReference,
                    $userId,
                    $sessionId,
                    $api,
                    $playerCode,
                    'ENTER',
                    $localAmount,
                    $providerBefore,
                    $providerAfter,
                    'COMPLETED',
                    null
                );

                $this->pdo->commit();
            } catch (Throwable $databaseException) {
                if ($this->pdo->inTransaction()) {
                    $this->pdo->rollBack();
                }

                /*
                 * API deposit mungkin sudah sukses. Tarik kembali agar
                 * saldo tidak tertinggal tanpa game session.
                 */
                if (
                    $providerDepositSucceeded ||
                    $providerAfter >
                        $this->tolerance
                ) {
                    try {
                        $this->returnProviderAmountUnlocked(
                            $userId,
                            $api,
                            $playerCode,
                            $providerAfter,
                            'GDB-' . $sessionCode,
                            'GAME_TRANSFER_ROLLBACK',
                            'Rollback karena pembuatan session gagal'
                        );
                    } catch (Throwable $rollbackException) {
                        error_log(
                            'GAME WALLET DATABASE ROLLBACK FAILED: ' .
                            $rollbackException->getMessage()
                        );
                    }
                }

                throw $databaseException;
            }

            return [
                'session_id' => $sessionId,
                'session_code' => $sessionCode,
                'api' => $api,
                'player_code' => $playerCode,
                'local_amount' => $localAmount,
                'provider_balance_before' =>
                    $providerBefore,
                'provider_balance_after' =>
                    $providerAfter,
                'enter_reference' =>
                    $enterReference,
            ];
        } finally {
            $this->releaseUserLock();
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Keluar semua sesi OPEN user
    |--------------------------------------------------------------------------
    */

    public function exitOpenSessions(
        int $userId,
        string $finalStatus = 'closed'
    ): array {
        $this->acquireUserLock($userId);

        try {
            $this->recoverIncompleteOperationsUnlocked(
                $userId
            );

            return $this->exitOpenSessionsUnlocked(
                $userId,
                $finalStatus
            );
        } finally {
            $this->releaseUserLock();
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Gagal launch setelah saldo sudah masuk provider
    |--------------------------------------------------------------------------
    */

    public function failSession(
        int $userId,
        int $sessionId
    ): array {
        $this->acquireUserLock($userId);

        try {
            $session = $this->loadOpenSession(
                $userId,
                $sessionId
            );

            if (!$session) {
                return [
                    'success' => true,
                    'message' =>
                        'Session sudah selesai.',
                    'returned_amount' => 0.0,
                ];
            }

            return $this->exitSessionUnlocked(
                $session,
                'failed'
            );
        } finally {
            $this->releaseUserLock();
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Cek apakah user punya session OPEN
    |--------------------------------------------------------------------------
    */

    public function hasOpenSession(
        int $userId
    ): bool {
        $statement = $this->pdo->prepare(
            "SELECT id
             FROM game_sessions
             WHERE user_id = :user_id
               AND status = 'open'
             LIMIT 1"
        );

        $statement->execute([
            ':user_id' => $userId,
        ]);

        return (bool) $statement->fetchColumn();
    }

    /*
    |--------------------------------------------------------------------------
    | Internal exit
    |--------------------------------------------------------------------------
    */

    private function exitOpenSessionsUnlocked(
        int $userId,
        string $finalStatus
    ): array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM game_sessions
             WHERE user_id = :user_id
               AND status = 'open'
             ORDER BY id ASC"
        );

        $statement->execute([
            ':user_id' => $userId,
        ]);

        $sessions = $statement->fetchAll();

        $results = [];

        foreach ($sessions as $session) {
            $results[] =
                $this->exitSessionUnlocked(
                    $session,
                    $finalStatus
                );
        }

        return $results;
    }

    private function exitSessionUnlocked(
        array $session,
        string $finalStatus
    ): array {
        $sessionId = (int) $session['id'];
        $userId = (int) $session['user_id'];
        $api = $this->normalizeApi(
            (string) $session['provider']
        );

        $storedPlayerCode = trim(
            (string) (
                $session[
                    'provider_username'
                ] ?? ''
            )
        );

        try {
            $playerCode =
                $this->validatePlayerCode(
                    $storedPlayerCode
                );
        } catch (Throwable $exception) {
            /*
             * Kompatibilitas session lama yang belum menyimpan
             * provider_username.
             */
            $sessionUser =
                $this->loadUser($userId);

            $playerCode =
                $this->resolvePlayerCode(
                    $sessionUser,
                    $api
                );
        }

        $sessionCode = (string)
            $session['session_code'];

        $exitReference =
            'GOUT-' . $sessionCode;

        /*
         * Bila ledger kredit sudah ada, cukup tutup session.
         */
        $existingCredit =
            $this->findLedger(
                $userId,
                'GAME_TRANSFER_IN',
                $exitReference
            );

        if ($existingCredit) {
            $this->closeSession(
                $sessionId,
                $finalStatus,
                (float) (
                    $session[
                        'provider_balance_after'
                    ] ?? 0
                ),
                $exitReference
            );

            return [
                'success' => true,
                'session_id' => $sessionId,
                'returned_amount' =>
                    (float) $existingCredit['amount'],
                'already_processed' => true,
            ];
        }

        $adapter = GameApi::adapter($api);

        $providerBalance =
            $this->getProviderBalance(
                $adapter,
                $playerCode
            );

        if ($providerBalance > $this->tolerance) {
            $this->insertOperation(
                $exitReference,
                $userId,
                $sessionId,
                $api,
                $playerCode,
                $finalStatus === 'failed'
                    ? 'ROLLBACK'
                    : 'EXIT',
                $providerBalance,
                $providerBalance,
                $providerBalance,
                'STARTED',
                null
            );

            try {
                $adapter->withdraw(
                    $playerCode,
                    $providerBalance,
                    $this->providerTransactionId(
                        $exitReference
                    )
                );
            } catch (Throwable $withdrawException) {
                /*
                 * Timeout belum tentu gagal. Verifikasi saldo provider.
                 */
                $verifiedBalance =
                    $this->safeProviderBalance(
                        $adapter,
                        $playerCode,
                        -1.0
                    );

                if ($verifiedBalance < 0) {
                    $this->updateOperationStatusSafe(
                        $exitReference,
                        'STARTED',
                        $providerBalance,
                        'Status withdraw provider belum dapat diverifikasi'
                    );

                    throw new RuntimeException(
                        'Status penarikan saldo dari ' .
                        strtoupper($api) .
                        ' belum dapat dipastikan. Sistem akan melakukan rekonsiliasi otomatis.',
                        0,
                        $withdrawException
                    );
                }

                if (
                    $verifiedBalance >
                    $this->tolerance
                ) {
                    throw new RuntimeException(
                        'Penarikan saldo dari ' .
                        strtoupper($api) .
                        ' gagal: ' .
                        $withdrawException->getMessage(),
                        0,
                        $withdrawException
                    );
                }
            }

            $afterWithdraw =
                $this->getProviderBalance(
                    $adapter,
                    $playerCode
                );

            if (
                $afterWithdraw >
                $this->tolerance
            ) {
                throw new RuntimeException(
                    'Saldo ' .
                    strtoupper($api) .
                    ' belum menjadi nol setelah withdraw.'
                );
            }

            $this->updateOperationStatusSafe(
                $exitReference,
                'PROVIDER_SUCCESS',
                $afterWithdraw,
                null
            );

            $this->creditLocal(
                $userId,
                $providerBalance,
                $exitReference,
                'GAME_TRANSFER_IN',
                'Pengembalian saldo dari provider ' .
                strtoupper($api)
            );
        }

        $this->closeSession(
            $sessionId,
            $finalStatus,
            $providerBalance,
            $exitReference
        );

        $this->insertOperationSafe(
            $exitReference,
            $userId,
            $sessionId,
            $api,
            $playerCode,
            $finalStatus === 'failed'
                ? 'ROLLBACK'
                : 'EXIT',
            $providerBalance,
            $providerBalance,
            0.0,
            'COMPLETED',
            null
        );

        return [
            'success' => true,
            'session_id' => $sessionId,
            'returned_amount' =>
                $providerBalance,
            'already_processed' => false,
        ];
    }

    /*
    |--------------------------------------------------------------------------
    | Rollback ENTER ketika saldo provider berhasil dibaca
    |--------------------------------------------------------------------------
    */

    private function rollbackEnterKnownState(
        int $userId,
        string $api,
        string $playerCode,
        float $providerBefore,
        float $currentProviderBalance,
        float $localAmount,
        string $sessionCode,
        string $enterReference,
        string $reason
    ): void {
        $providerReturned = 0.0;

        if (
            $currentProviderBalance >
            $this->tolerance
        ) {
            $this->returnProviderAmountUnlocked(
                $userId,
                $api,
                $playerCode,
                $currentProviderBalance,
                'GVR-' . $sessionCode,
                'GAME_TRANSFER_ROLLBACK',
                $reason
            );

            $providerReturned =
                $currentProviderBalance;
        }

        /*
         * Sebelum deposit, saldo provider sudah dipaksa mendekati nol.
         * Bila provider hanya menerima sebagian atau tidak menerima dana,
         * pulihkan sisa debit lokal.
         */
        $remaining = round(
            max(
                0,
                $localAmount -
                max(
                    0,
                    $providerReturned -
                    $providerBefore
                )
            ),
            2
        );

        if ($remaining > $this->tolerance) {
            $this->creditLocal(
                $userId,
                $remaining,
                'GRM-' . $sessionCode,
                'GAME_TRANSFER_ROLLBACK',
                $reason . ' — pemulihan sisa saldo lokal'
            );
        }

        $this->updateOperationStatusSafe(
            $enterReference,
            'COMPLETED',
            0.0,
            $reason
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Recovery operasi yang terputus
    |--------------------------------------------------------------------------
    */

    private function recoverIncompleteOperationsUnlocked(
        int $userId
    ): void {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM game_wallet_operations
             WHERE user_id = :user_id
               AND status IN (
                    'STARTED',
                    'LOCAL_DEBITED',
                    'PROVIDER_SUCCESS'
               )
             ORDER BY id ASC
             LIMIT 20"
        );

        $statement->execute([
            ':user_id' => $userId,
        ]);

        foreach ($statement->fetchAll() as $operation) {
            $this->recoverOperationUnlocked(
                $operation
            );
        }
    }

    private function recoverOperationUnlocked(
        array $operation
    ): void {
        $operationCode =
            (string) $operation[
                'operation_code'
            ];

        $userId =
            (int) $operation['user_id'];

        $sessionId = !empty(
            $operation['session_id']
        )
            ? (int) $operation[
                'session_id'
            ]
            : null;

        $api = $this->normalizeApi(
            (string) $operation[
                'api_provider'
            ]
        );

        $playerCode =
            $this->validatePlayerCode(
                (string) $operation[
                    'player_code'
                ]
            );

        $operationType = strtoupper(
            (string) $operation[
                'operation_type'
            ]
        );

        $recordedAmount = round(
            (float) (
                $operation['amount'] ??
                0
            ),
            2
        );

        $status = strtoupper(
            (string) $operation['status']
        );

        $adapter = GameApi::adapter($api);

        $currentBalance =
            $this->getProviderBalance(
                $adapter,
                $playerCode
            );

        /*
         * ENTER yang terputus tidak boleh dilanjutkan ke game.
         * Dana provider ditarik kembali ke saldo lokal.
         */
        if ($operationType === 'ENTER') {
            $localDebitExists =
                $this->findLedger(
                    $userId,
                    'GAME_TRANSFER_OUT',
                    $operationCode
                ) !== null;

            /*
             * Jangan memulihkan saldo lokal berdasarkan satu pembacaan nol
             * ketika deposit provider sebelumnya berstatus tidak pasti.
             */
            if (
                $localDebitExists &&
                $recordedAmount > 0 &&
                $currentBalance <=
                    $this->tolerance
            ) {
                usleep(750000);

                $currentBalance =
                    $this->getProviderBalance(
                        $adapter,
                        $playerCode
                    );
            }

            $returnAmount = 0.0;

            if (
                $currentBalance >
                $this->tolerance
            ) {
                $recoveryReference =
                    'GREC-' .
                    substr(
                        sha1($operationCode),
                        0,
                        24
                    );

                try {
                    $adapter->withdraw(
                        $playerCode,
                        $currentBalance,
                        $this->providerTransactionId(
                            $recoveryReference
                        )
                    );
                } catch (Throwable $exception) {
                    $verified =
                        $this->safeProviderBalance(
                            $adapter,
                            $playerCode,
                            -1.0
                        );

                    if ($verified < 0) {
                        throw new RuntimeException(
                            'Status recovery ENTER belum dapat diverifikasi.',
                            0,
                            $exception
                        );
                    }

                    if (
                        $verified >
                        $this->tolerance
                    ) {
                        throw $exception;
                    }
                }

                $after =
                    $this->getProviderBalance(
                        $adapter,
                        $playerCode
                    );

                if ($after > $this->tolerance) {
                    throw new RuntimeException(
                        'Recovery ENTER belum berhasil menarik saldo provider.'
                    );
                }

                $returnAmount =
                    $localDebitExists
                        ? max(
                            $recordedAmount,
                            $currentBalance
                        )
                        : $currentBalance;
            } elseif (
                $localDebitExists &&
                $recordedAmount > 0
            ) {
                /*
                 * Saldo lokal sudah dipotong tetapi provider terverifikasi nol.
                 */
                $returnAmount =
                    $recordedAmount;
            }

            if ($returnAmount > 0) {
                $this->creditLocal(
                    $userId,
                    $returnAmount,
                    'GRV-' .
                    substr(
                        sha1($operationCode),
                        0,
                        24
                    ),
                    'GAME_TRANSFER_ROLLBACK',
                    'Recovery transfer masuk game yang terputus'
                );
            }

            $this->updateOperationStatusSafe(
                $operationCode,
                'COMPLETED',
                0.0,
                'Recovered before launch'
            );

            return;
        }

        /*
         * EXIT/ROLLBACK/ORPHAN yang terputus:
         * - provider masih berisi saldo -> withdraw
         * - provider sudah nol -> anggap withdraw sebelumnya sukses
         */
        $returnAmount = max(
            $recordedAmount,
            $currentBalance
        );

        if (
            $currentBalance >
            $this->tolerance
        ) {
            try {
                $adapter->withdraw(
                    $playerCode,
                    $currentBalance,
                    $this->providerTransactionId(
                        $operationCode
                    )
                );
            } catch (Throwable $exception) {
                $verified =
                    $this->safeProviderBalance(
                        $adapter,
                        $playerCode,
                        -1.0
                    );

                if (
                    $verified >
                    $this->tolerance
                ) {
                    throw $exception;
                }
            }

            $after =
                $this->getProviderBalance(
                    $adapter,
                    $playerCode
                );

            if ($after > $this->tolerance) {
                throw new RuntimeException(
                    'Recovery EXIT belum berhasil menarik saldo provider.'
                );
            }
        }

        if ($returnAmount > 0) {
            $transactionType =
                $operationType ===
                    'ORPHAN_RETURN'
                    ? 'GAME_ORPHAN_RETURN'
                    : (
                        $operationType ===
                            'ROLLBACK'
                            ? 'GAME_TRANSFER_ROLLBACK'
                            : 'GAME_TRANSFER_IN'
                    );

            $this->creditLocal(
                $userId,
                $returnAmount,
                $operationCode,
                $transactionType,
                'Recovery wallet provider yang terputus'
            );
        }

        if ($sessionId !== null) {
            $this->closeSession(
                $sessionId,
                $operationType ===
                    'ROLLBACK'
                    ? 'failed'
                    : 'closed',
                $returnAmount,
                $operationCode
            );
        }

        $this->updateOperationStatusSafe(
            $operationCode,
            'COMPLETED',
            0.0,
            'Recovered'
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Cegah saldo tertinggal pada provider API lain
    |--------------------------------------------------------------------------
    */

    private function secureOtherProviderBalancesUnlocked(
        int $userId,
        array $user,
        string $targetApi
    ): void {
        foreach (
            ['nexus', 'sgx']
            as $otherApi
        ) {
            if ($otherApi === $targetApi) {
                continue;
            }

            if (
                !$this->hasProviderHistory(
                    $userId,
                    $otherApi
                )
            ) {
                continue;
            }

            $otherPlayerCode =
                $this->resolvePlayerCode(
                    $user,
                    $otherApi
                );

            $otherAdapter =
                GameApi::adapter(
                    $otherApi
                );

            $otherBalance =
                $this->getProviderBalance(
                    $otherAdapter,
                    $otherPlayerCode
                );

            if (
                $otherBalance >
                $this->tolerance
            ) {
                $this->returnOrphanBalanceUnlocked(
                    $userId,
                    $otherApi,
                    $otherPlayerCode,
                    $otherBalance
                );
            }
        }
    }

    private function hasProviderHistory(
        int $userId,
        string $api
    ): bool {
        $sessionStatement =
            $this->pdo->prepare(
                "SELECT 1
                 FROM game_sessions
                 WHERE user_id = :user_id
                   AND LOWER(provider) = :api
                 LIMIT 1"
            );

        $sessionStatement->execute([
            ':user_id' => $userId,
            ':api' => $api,
        ]);

        if ($sessionStatement->fetchColumn()) {
            return true;
        }

        $operationStatement =
            $this->pdo->prepare(
                "SELECT 1
                 FROM game_wallet_operations
                 WHERE user_id = :user_id
                   AND api_provider = :api
                 LIMIT 1"
            );

        $operationStatement->execute([
            ':user_id' => $userId,
            ':api' => $api,
        ]);

        return (bool)
            $operationStatement
                ->fetchColumn();
    }

    /*
    |--------------------------------------------------------------------------
    | Saldo provider tanpa session
    |--------------------------------------------------------------------------
    */

    private function returnOrphanBalanceUnlocked(
        int $userId,
        string $api,
        string $playerCode,
        float $amount
    ): void {
        if ($amount <= $this->tolerance) {
            return;
        }

        $reference =
            'GOR-' .
            strtoupper($api) .
            '-' .
            $userId .
            '-' .
            date('ymdHis') .
            '-' .
            strtoupper(
                bin2hex(
                    random_bytes(2)
                )
            );

        $this->returnProviderAmountUnlocked(
            $userId,
            $api,
            $playerCode,
            $amount,
            $reference,
            'GAME_ORPHAN_RETURN',
            'Pengembalian saldo provider tanpa session'
        );
    }

    private function returnProviderAmountUnlocked(
        int $userId,
        string $api,
        string $playerCode,
        float $amount,
        string $reference,
        string $transactionType,
        string $description
    ): void {
        if ($amount <= $this->tolerance) {
            return;
        }

        $adapter = GameApi::adapter($api);

        $operationType =
            $transactionType ===
                'GAME_TRANSFER_ROLLBACK'
                ? 'ROLLBACK'
                : 'ORPHAN_RETURN';

        $this->insertOperation(
            $reference,
            $userId,
            null,
            $api,
            $playerCode,
            $operationType,
            $amount,
            $amount,
            $amount,
            'STARTED',
            null
        );

        try {
            $adapter->withdraw(
                $playerCode,
                $amount,
                $this->providerTransactionId(
                    $reference
                )
            );
        } catch (Throwable $exception) {
            $verifiedBalance =
                $this->safeProviderBalance(
                    $adapter,
                    $playerCode,
                    -1.0
                );

            if ($verifiedBalance < 0) {
                $this->updateOperationStatusSafe(
                    $reference,
                    'STARTED',
                    $amount,
                    'Status withdraw provider belum dapat diverifikasi'
                );

                throw new RuntimeException(
                    'Status pengamanan saldo provider belum dapat dipastikan.',
                    0,
                    $exception
                );
            }

            if (
                $verifiedBalance >
                $this->tolerance
            ) {
                throw $exception;
            }
        }

        $after =
            $this->getProviderBalance(
                $adapter,
                $playerCode
            );

        if ($after > $this->tolerance) {
            throw new RuntimeException(
                'Saldo provider belum nol setelah pengamanan.'
            );
        }

        $this->updateOperationStatusSafe(
            $reference,
            'PROVIDER_SUCCESS',
            $after,
            null
        );

        $this->creditLocal(
            $userId,
            $amount,
            $reference,
            $transactionType,
            $description
        );

        $this->insertOperationSafe(
            $reference,
            $userId,
            null,
            $api,
            $playerCode,
            $operationType,
            $amount,
            $amount,
            $after,
            'COMPLETED',
            null
        );
    }

    /*
    |--------------------------------------------------------------------------
    | Saldo lokal
    |--------------------------------------------------------------------------
    */

    private function debitLocal(
        int $userId,
        float $amount,
        string $reference,
        string $transactionType,
        string $description
    ): array {
        $amount = round($amount, 2);

        if ($amount <= 0) {
            return [
                'balance_before' => 0.0,
                'balance_after' => 0.0,
            ];
        }

        $this->pdo->beginTransaction();

        try {
            $user = $this->loadUserForUpdate(
                $userId
            );

            $existing = $this->findLedgerForUpdate(
                $userId,
                $transactionType,
                $reference
            );

            if ($existing) {
                $this->pdo->commit();

                return [
                    'balance_before' =>
                        (float) $existing[
                            'balance_before'
                        ],
                    'balance_after' =>
                        (float) $existing[
                            'balance_after'
                        ],
                ];
            }

            $balanceBefore = round(
                (float) (
                    $user['balance_local'] ??
                    0
                ),
                2
            );

            if (
                $amount >
                $balanceBefore +
                $this->tolerance
            ) {
                throw new RuntimeException(
                    'Saldo lokal tidak mencukupi.'
                );
            }

            $balanceAfter = round(
                $balanceBefore - $amount,
                2
            );

            $update = $this->pdo->prepare(
                "UPDATE users
                 SET balance_local =
                    :balance_after
                 WHERE id = :user_id"
            );

            $update->execute([
                ':balance_after' =>
                    $this->money($balanceAfter),
                ':user_id' => $userId,
            ]);

            $this->insertLedger(
                $userId,
                $transactionType,
                'DEBIT',
                $amount,
                $balanceBefore,
                $balanceAfter,
                $reference,
                $description
            );

            $this->pdo->commit();

            return [
                'balance_before' =>
                    $balanceBefore,
                'balance_after' =>
                    $balanceAfter,
            ];
        } catch (Throwable $exception) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }

            throw $exception;
        }
    }

    private function creditLocal(
        int $userId,
        float $amount,
        string $reference,
        string $transactionType,
        string $description
    ): array {
        $amount = round($amount, 2);

        if ($amount <= 0) {
            return [
                'balance_before' => 0.0,
                'balance_after' => 0.0,
            ];
        }

        $this->pdo->beginTransaction();

        try {
            $user = $this->loadUserForUpdate(
                $userId
            );

            $existing = $this->findLedgerForUpdate(
                $userId,
                $transactionType,
                $reference
            );

            if ($existing) {
                $this->pdo->commit();

                return [
                    'balance_before' =>
                        (float) $existing[
                            'balance_before'
                        ],
                    'balance_after' =>
                        (float) $existing[
                            'balance_after'
                        ],
                ];
            }

            $balanceBefore = round(
                (float) (
                    $user['balance_local'] ??
                    0
                ),
                2
            );

            $balanceAfter = round(
                $balanceBefore + $amount,
                2
            );

            $update = $this->pdo->prepare(
                "UPDATE users
                 SET balance_local =
                    :balance_after
                 WHERE id = :user_id"
            );

            $update->execute([
                ':balance_after' =>
                    $this->money($balanceAfter),
                ':user_id' => $userId,
            ]);

            $this->insertLedger(
                $userId,
                $transactionType,
                'CREDIT',
                $amount,
                $balanceBefore,
                $balanceAfter,
                $reference,
                $description
            );

            $this->pdo->commit();

            return [
                'balance_before' =>
                    $balanceBefore,
                'balance_after' =>
                    $balanceAfter,
            ];
        } catch (Throwable $exception) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }

            throw $exception;
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Database helpers
    |--------------------------------------------------------------------------
    */

    private function loadUser(
        int $userId
    ): array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM users
             WHERE id = :user_id
             LIMIT 1"
        );

        $statement->execute([
            ':user_id' => $userId,
        ]);

        $user = $statement->fetch();

        if (!$user) {
            throw new RuntimeException(
                'User tidak ditemukan.'
            );
        }

        return $user;
    }

    private function loadUserForUpdate(
        int $userId
    ): array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM users
             WHERE id = :user_id
             LIMIT 1
             FOR UPDATE"
        );

        $statement->execute([
            ':user_id' => $userId,
        ]);

        $user = $statement->fetch();

        if (!$user) {
            throw new RuntimeException(
                'User tidak ditemukan.'
            );
        }

        return $user;
    }

    private function loadOpenSession(
        int $userId,
        int $sessionId
    ): ?array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM game_sessions
             WHERE id = :session_id
               AND user_id = :user_id
               AND status = 'open'
             LIMIT 1"
        );

        $statement->execute([
            ':session_id' => $sessionId,
            ':user_id' => $userId,
        ]);

        $session = $statement->fetch();

        return $session ?: null;
    }

    private function closeSession(
        int $sessionId,
        string $status,
        float $finalProviderBalance,
        string $reference
    ): void {
        $status = $status === 'failed'
            ? 'failed'
            : 'closed';

        $statement = $this->pdo->prepare(
            "UPDATE game_sessions
             SET
                provider_balance_after =
                    :provider_balance_after,
                status = :status,
                api_reference =
                    :api_reference,
                closed_at =
                    COALESCE(
                        closed_at,
                        CURRENT_TIMESTAMP
                    )
             WHERE id = :session_id
               AND status = 'open'"
        );

        $statement->execute([
            ':provider_balance_after' =>
                $this->money(
                    $finalProviderBalance
                ),
            ':status' => $status,
            ':api_reference' =>
                $reference,
            ':session_id' => $sessionId,
        ]);
    }

    private function insertLedger(
        int $userId,
        string $transactionType,
        string $direction,
        float $amount,
        float $balanceBefore,
        float $balanceAfter,
        string $reference,
        string $description
    ): void {
        $statement = $this->pdo->prepare(
            "INSERT INTO balance_transactions (
                user_id,
                transaction_type,
                direction,
                amount,
                balance_before,
                balance_after,
                reference_code,
                description
             ) VALUES (
                :user_id,
                :transaction_type,
                :direction,
                :amount,
                :balance_before,
                :balance_after,
                :reference_code,
                :description
             )"
        );

        $statement->execute([
            ':user_id' => $userId,
            ':transaction_type' =>
                $transactionType,
            ':direction' => $direction,
            ':amount' => $this->money($amount),
            ':balance_before' =>
                $this->money($balanceBefore),
            ':balance_after' =>
                $this->money($balanceAfter),
            ':reference_code' => $reference,
            ':description' => substr(
                $description,
                0,
                255
            ),
        ]);
    }

    private function findLedger(
        int $userId,
        string $transactionType,
        string $reference
    ): ?array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM balance_transactions
             WHERE user_id = :user_id
               AND transaction_type =
                    :transaction_type
               AND reference_code =
                    :reference_code
             ORDER BY id ASC
             LIMIT 1"
        );

        $statement->execute([
            ':user_id' => $userId,
            ':transaction_type' =>
                $transactionType,
            ':reference_code' =>
                $reference,
        ]);

        $row = $statement->fetch();

        return $row ?: null;
    }

    private function findLedgerForUpdate(
        int $userId,
        string $transactionType,
        string $reference
    ): ?array {
        $statement = $this->pdo->prepare(
            "SELECT *
             FROM balance_transactions
             WHERE user_id = :user_id
               AND transaction_type =
                    :transaction_type
               AND reference_code =
                    :reference_code
             ORDER BY id ASC
             LIMIT 1
             FOR UPDATE"
        );

        $statement->execute([
            ':user_id' => $userId,
            ':transaction_type' =>
                $transactionType,
            ':reference_code' =>
                $reference,
        ]);

        $row = $statement->fetch();

        return $row ?: null;
    }

    private function insertOperation(
        string $operationCode,
        int $userId,
        ?int $sessionId,
        string $api,
        string $playerCode,
        string $operationType,
        float $amount,
        float $providerBefore,
        float $providerAfter,
        string $status,
        ?string $errorMessage
    ): void {
        $statement = $this->pdo->prepare(
            "INSERT INTO game_wallet_operations (
                operation_code,
                user_id,
                session_id,
                api_provider,
                player_code,
                operation_type,
                amount,
                provider_balance_before,
                provider_balance_after,
                status,
                error_message
             ) VALUES (
                :operation_code,
                :user_id,
                :session_id,
                :api_provider,
                :player_code,
                :operation_type,
                :amount,
                :provider_balance_before,
                :provider_balance_after,
                :status,
                :error_message
             )
             ON DUPLICATE KEY UPDATE
                session_id =
                    COALESCE(
                        VALUES(session_id),
                        session_id
                    ),
                provider_balance_after =
                    VALUES(
                        provider_balance_after
                    ),
                status = VALUES(status),
                error_message =
                    VALUES(error_message),
                updated_at =
                    CURRENT_TIMESTAMP"
        );

        $statement->execute([
            ':operation_code' =>
                $operationCode,
            ':user_id' => $userId,
            ':session_id' => $sessionId,
            ':api_provider' => $api,
            ':player_code' => $playerCode,
            ':operation_type' =>
                $operationType,
            ':amount' => $this->money($amount),
            ':provider_balance_before' =>
                $this->money($providerBefore),
            ':provider_balance_after' =>
                $this->money($providerAfter),
            ':status' => $status,
            ':error_message' =>
                $errorMessage !== null
                    ? substr(
                        $errorMessage,
                        0,
                        500
                    )
                    : null,
        ]);
    }

    private function insertOperationSafe(
        string $operationCode,
        int $userId,
        ?int $sessionId,
        string $api,
        string $playerCode,
        string $operationType,
        float $amount,
        float $providerBefore,
        float $providerAfter,
        string $status,
        ?string $errorMessage
    ): void {
        try {
            $this->insertOperation(
                $operationCode,
                $userId,
                $sessionId,
                $api,
                $playerCode,
                $operationType,
                $amount,
                $providerBefore,
                $providerAfter,
                $status,
                $errorMessage
            );
        } catch (Throwable $exception) {
            error_log(
                'GAME WALLET OPERATION LOG ERROR: ' .
                $exception->getMessage()
            );
        }
    }

    private function updateOperationStatusSafe(
        string $operationCode,
        string $status,
        float $providerAfter,
        ?string $errorMessage
    ): void {
        try {
            $statement = $this->pdo->prepare(
                "UPDATE game_wallet_operations
                 SET
                    provider_balance_after =
                        :provider_balance_after,
                    status = :status,
                    error_message =
                        :error_message,
                    updated_at =
                        CURRENT_TIMESTAMP
                 WHERE operation_code =
                    :operation_code"
            );

            $statement->execute([
                ':provider_balance_after' =>
                    $this->money(
                        $providerAfter
                    ),
                ':status' => $status,
                ':error_message' =>
                    $errorMessage !== null
                        ? substr(
                            $errorMessage,
                            0,
                            500
                        )
                        : null,
                ':operation_code' =>
                    $operationCode,
            ]);
        } catch (Throwable $exception) {
            error_log(
                'GAME WALLET OPERATION UPDATE ERROR: ' .
                $exception->getMessage()
            );
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Provider helpers
    |--------------------------------------------------------------------------
    */

    private function getProviderBalance(
        $adapter,
        string $playerCode
    ): float {
        $result = $adapter->getBalance(
            $playerCode
        );

        $balance = $result['balance'] ??
            $result['user_balance'] ??
            0;

        if (!is_numeric($balance)) {
            throw new RuntimeException(
                'Saldo provider tidak valid.'
            );
        }

        return round(
            (float) $balance,
            2
        );
    }

    private function safeProviderBalance(
        $adapter,
        string $playerCode,
        float $fallback
    ): float {
        try {
            return $this->getProviderBalance(
                $adapter,
                $playerCode
            );
        } catch (Throwable $exception) {
            error_log(
                'GAME WALLET BALANCE VERIFY ERROR: ' .
                $exception->getMessage()
            );

            return $fallback;
        }
    }

    /*
    |--------------------------------------------------------------------------
    | Advisory lock
    |--------------------------------------------------------------------------
    */

    private function acquireUserLock(
        int $userId
    ): void {
        $this->lockName =
            'game_wallet_user_' .
            $userId;

        $statement = $this->pdo->prepare(
            'SELECT GET_LOCK(
                :lock_name,
                20
            )'
        );

        $statement->execute([
            ':lock_name' =>
                $this->lockName,
        ]);

        if (
            (int) $statement
                ->fetchColumn() !== 1
        ) {
            throw new RuntimeException(
                'Wallet sedang diproses. Silakan coba lagi.'
            );
        }

        $this->lockHeld = true;
    }

    private function releaseUserLock(): void
    {
        if (
            !$this->lockHeld ||
            $this->lockName === ''
        ) {
            return;
        }

        try {
            $statement = $this->pdo->prepare(
                'SELECT RELEASE_LOCK(
                    :lock_name
                )'
            );

            $statement->execute([
                ':lock_name' =>
                    $this->lockName,
            ]);
        } catch (Throwable $exception) {
            error_log(
                'GAME WALLET RELEASE LOCK ERROR: ' .
                $exception->getMessage()
            );
        }

        $this->lockHeld = false;
        $this->lockName = '';
    }

    /*
    |--------------------------------------------------------------------------
    | Transaction ID provider
    |--------------------------------------------------------------------------
    | Nexus mewajibkan agent_sign mengikuti:
    | /^[A-Za-z0-9_]+$/
    |
    | Reference database tetap dipertahankan untuk audit. Hanya nilai yang
    | dikirim ke provider yang dinormalisasi secara deterministik.
    |--------------------------------------------------------------------------
    */

    private function providerTransactionId(
        string $reference
    ): string {
        $reference = trim($reference);

        if ($reference === '') {
            throw new RuntimeException(
                'Reference transaksi provider kosong.'
            );
        }

        $safe = preg_replace(
            '/[^A-Za-z0-9_]+/',
            '_',
            $reference
        );

        $safe = preg_replace(
            '/_+/',
            '_',
            (string) $safe
        );

        $safe = trim(
            (string) $safe,
            '_'
        );

        /*
         * Jaga agar transaction ID tidak terlalu panjang.
         * Hash tetap deterministik sehingga retry memakai ID yang sama.
         */
        if (strlen($safe) > 80) {
            $prefix = substr(
                $safe,
                0,
                32
            );

            $safe =
                rtrim($prefix, '_') .
                '_' .
                strtoupper(
                    substr(
                        hash(
                            'sha256',
                            $reference
                        ),
                        0,
                        32
                    )
                );
        }

        if (
            $safe === '' ||
            preg_match(
                '/^[A-Za-z0-9_]+$/',
                $safe
            ) !== 1
        ) {
            throw new RuntimeException(
                'Reference transaksi provider tidak valid.'
            );
        }

        return $safe;
    }

    /*
    |--------------------------------------------------------------------------
    | Utility
    |--------------------------------------------------------------------------
    */

    private function normalizeApi(
        string $api
    ): string {
        $api = strtolower(trim($api));

        if (
            !in_array(
                $api,
                ['nexus', 'sgx'],
                true
            )
        ) {
            throw new RuntimeException(
                'API provider tidak valid.'
            );
        }

        return $api;
    }

    private function validatePlayerCode(
        string $playerCode
    ): string {
        $playerCode = trim($playerCode);

        if (
            preg_match(
                '/^[A-Za-z0-9_.-]{3,64}$/',
                $playerCode
            ) !== 1
        ) {
            throw new RuntimeException(
                'Player code tidak valid.'
            );
        }

        return $playerCode;
    }

    private function makeSessionCode(): string
    {
        return
            'GS' .
            date('ymdHis') .
            strtoupper(
                bin2hex(
                    random_bytes(4)
                )
            );
    }

    private function money(
        float $amount
    ): string {
        return number_format(
            round($amount, 2),
            2,
            '.',
            ''
        );
    }

    private function clientIp(): string
    {
        return substr(
            trim(
                (string) (
                    $_SERVER[
                        'REMOTE_ADDR'
                    ] ?? ''
                )
            ),
            0,
            45
        );
    }

    private function userAgent(): string
    {
        return substr(
            trim(
                (string) (
                    $_SERVER[
                        'HTTP_USER_AGENT'
                    ] ?? ''
                )
            ),
            0,
            255
        );
    }
}
