<?php
class NexoraEconomy {
    private $realm;
    private $db;

    public function __construct($realm=null) {
        $this->realm = NexoraRealm::validate($realm === null ? NexoraRealm::active() : $realm);
        $this->db = Connection::Database('Platform', $this->realm);
    }

    public function paypalCredit($txnId, $userId, $paymentAmount, $payerEmail, $orderId, $creditConfigId, $identifier, $credits) {
        $txnId = trim((string)$txnId);
        if($txnId === '' || !Validator::UnsignedNumber($userId) || !Validator::UnsignedNumber($credits)) throw new InvalidArgumentException('Datos de pago no válidos.');
        $cfg = $this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la transacción del pago.');
        try {
            $existing = $this->db->query_fetch_single(
                'SELECT transaction_id FROM '.WEBENGINE_PAYPAL_TRANSACTIONS.' WITH (UPDLOCK, HOLDLOCK) WHERE transaction_id = ?',
                array($txnId)
            );
            if(is_array($existing)) {
                $this->db->rollBack();
                return array('duplicate'=>true, 'credited'=>false);
            }

            // Platform adds a dedicated unique payment gate. This remains independent
            // from the historical WebEngine transaction table and makes retries safe.
            if(NexoraRealm::platformEnabled()) {
                $gate = $this->db->query_affected(
                    'INSERT INTO NEXORA_PAYMENT_EVENTS (transaction_id, realm, user_id, order_id, created_at) VALUES (?, ?, ?, ?, SYSUTCDATETIME())',
                    array($txnId, $this->realm, (int)$userId, (string)$orderId)
                );
                if($gate !== 1) {
                    $gateExisting = $this->db->query_fetch_single('SELECT transaction_id FROM NEXORA_PAYMENT_EVENTS WHERE transaction_id = ?', array($txnId));
                    if(is_array($gateExisting)) {
                        $this->db->rollBack();
                        return array('duplicate'=>true, 'credited'=>false);
                    }
                    throw new RuntimeException('No se pudo reservar la transacción del pago.');
                }
            }

            $inserted = $this->db->query_affected(
                'INSERT INTO '.WEBENGINE_PAYPAL_TRANSACTIONS.' (transaction_id, user_id, payment_amount, paypal_email, transaction_date, transaction_status, order_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
                array($txnId, $userId, (string)$paymentAmount, (string)$payerEmail, time(), 1, (string)$orderId)
            );
            if($inserted !== 1) throw new RuntimeException('No se pudo registrar el pago de forma idempotente.');

            $balance = $this->atomicCredit($cfg, $identifier, (int)$credits);
            $this->creditLog($cfg, $identifier, (int)$credits, 'add', 'paypal');
            $this->walletLedger((int)$creditConfigId, $identifier, 'credit', (int)$credits, $balance, 'paypal', $txnId);

            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar el pago.');
            NexoraAudit::log('economy.paypal_credit', 'account', $userId, null, array('txn'=>$txnId,'credits'=>(int)$credits,'balance'=>$balance), $this->realm);
            return array('duplicate'=>false, 'credited'=>true, 'balance'=>$balance);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function paypalReverse($orderId, $userId, $status) {
        $orderId = trim((string)$orderId);
        $status = trim((string)$status);
        if($orderId === '' || !Validator::UnsignedNumber($userId)) throw new InvalidArgumentException('Datos de reverso no válidos.');
        if(!in_array($status, array('Reversed','Refunded','Chargeback'), true)) throw new InvalidArgumentException('Estado de reverso no permitido.');
        $this->assertCrossDatabaseTransaction('Me_MuOnline');

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la reversión del pago.');
        try {
            $payment = $this->db->query_fetch_single(
                'SELECT TOP 1 transaction_id, user_id, order_id, transaction_status FROM '.WEBENGINE_PAYPAL_TRANSACTIONS.' WITH (UPDLOCK, HOLDLOCK) WHERE order_id = ? AND user_id = ? ORDER BY id DESC',
                array($orderId, (int)$userId)
            );
            if(!is_array($payment)) {
                $this->db->rollBack();
                return array('matched'=>false,'blocked'=>false);
            }

            $gameDb = $this->qualifiedDatabase(NexoraRealm::databaseName('Me_MuOnline', $this->realm));
            $accountTable = $gameDb.'.[dbo].'.$this->qi(_TBL_MI_);
            $blockAffected = $this->db->query_affected(
                'UPDATE '.$accountTable.' SET '.$this->qi(_CLMN_BLOCCODE_).' = 1 WHERE '.$this->qi(_CLMN_MEMBID_).' = ?',
                array((int)$userId)
            );
            if($blockAffected === false) throw new RuntimeException('No se pudo bloquear la cuenta asociada al reverso.');

            $updated = $this->db->query_affected(
                'UPDATE '.WEBENGINE_PAYPAL_TRANSACTIONS.' SET transaction_status = ? WHERE order_id = ? AND user_id = ?',
                array(0, $orderId, (int)$userId)
            );
            if($updated === false) throw new RuntimeException('No se pudo marcar la transacción como revertida.');

            if(NexoraRealm::platformEnabled()) {
                $this->db->query(
                    'UPDATE NEXORA_PAYMENT_EVENTS SET status = ?, reversed_at = SYSUTCDATETIME() WHERE order_id = ? AND user_id = ?',
                    array(strtolower($status), $orderId, (int)$userId)
                );
            }

            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la reversión del pago.');
            NexoraAudit::log('economy.paypal_reversal', 'account', $userId, array('order'=>$orderId), array('status'=>$status,'blocked'=>true), $this->realm);
            return array('matched'=>true,'blocked'=>true);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function buyZen($creditConfigId, $identifier, $accountUsername, $characterName, $credits, $zen, $maxZen) {
        if(!Validator::UnsignedNumber($credits) || !Validator::UnsignedNumber($zen) || !Validator::UnsignedNumber($maxZen)) throw new InvalidArgumentException('Valores de economía no válidos.');
        $cfg = $this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);

        $gameDb = $this->qualifiedDatabase(NexoraRealm::databaseName('MuOnline', $this->realm));
        $charTable = $gameDb.'.[dbo].'.$this->qi(_TBL_CHR_);
        $nameCol = $this->qi(_CLMN_CHR_NAME_);
        $accountCol = $this->qi(_CLMN_CHR_ACCID_);
        $zenCol = $this->qi(_CLMN_CHR_ZEN_);

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la compra de Zen.');
        try {
            $balance = $this->atomicDebit($cfg, $identifier, (int)$credits);
            $affected = $this->db->query_affected(
                'UPDATE '.$charTable.' SET '.$zenCol.' = '.$zenCol.' + ? WHERE '.$nameCol.' = ? AND '.$accountCol.' = ? AND '.$zenCol.' + ? <= ?',
                array((int)$zen, (string)$characterName, (string)$accountUsername, (int)$zen, (int)$maxZen)
            );
            if($affected !== 1) throw new RuntimeException('No se pudo acreditar el Zen; la operación fue revertida.');
            $this->creditLog($cfg, $identifier, (int)$credits, 'subtract', 'buyzen');
            $this->walletLedger((int)$creditConfigId, $identifier, 'debit', (int)$credits, $balance, 'buyzen', null);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la compra de Zen.');
            NexoraAudit::log('economy.buy_zen', 'character', $characterName, null, array('credits'=>(int)$credits,'zen'=>(int)$zen,'balance'=>$balance), $this->realm);
            return array('balance'=>$balance, 'zen'=>(int)$zen);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }


    public function vipPurchase($creditConfigId, $identifier, $accountUsername, $vipType, $days, $cost) {
        if(!Validator::UnsignedNumber($vipType) || !Validator::UnsignedNumber($days) || !Validator::UnsignedNumber($cost)) throw new InvalidArgumentException('Valores VIP no validos.');
        $vipType=(int)$vipType; $days=(int)$days; $cost=(int)$cost;
        if($vipType < 1 || $days < 1 || $cost < 0) throw new InvalidArgumentException('Valores VIP no validos.');
        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $gameDb=$this->qualifiedDatabase(NexoraRealm::databaseName('Me_MuOnline',$this->realm));
        foreach(array(_TBL_VIP_,_CLMN_VIP_ID_,_CLMN_VIP_TYPE_,_CLMN_VIP_DATE_) as $sqlId) $this->assertIdentifier($sqlId);
        $vipTable=$gameDb.'.[dbo].'.$this->qi(_TBL_VIP_);
        $idCol=$this->qi(_CLMN_VIP_ID_); $typeCol=$this->qi(_CLMN_VIP_TYPE_); $dateCol=$this->qi(_CLMN_VIP_DATE_);
        if(!empty($cfg['config_checkonline']) && (new common())->accountOnline($accountUsername)) throw new RuntimeException(lang('error_14'));
        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la compra VIP.');
        try {
            $current=$this->db->query_fetch_single('SELECT '.$typeCol.' AS vip_type, '.$dateCol.' AS vip_date FROM '.$vipTable.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$idCol.' = ?',array($accountUsername));
            if(!is_array($current)) throw new RuntimeException('La cuenta no existe en el reino activo.');
            $currentType=isset($current['vip_type'])?(int)$current['vip_type']:0;
            $currentTs=!empty($current['vip_date'])?strtotime($current['vip_date']):0;
            $active=$currentType>0 && $currentTs>time();
            if($active && $currentType!==$vipType) throw new RuntimeException(lang('vip_error_9'));
            $balance=$this->atomicDebit($cfg,$identifier,$cost);
            $baseTs=$active?$currentTs:time();
            $expires=date('Y-m-d H:i:s',$baseTs+(86400*$days));
            $affected=$this->db->query_affected('UPDATE '.$vipTable.' SET '.$typeCol.' = ?, '.$dateCol.' = ? WHERE '.$idCol.' = ?',array($vipType,$expires,$accountUsername));
            if($affected!==1) throw new RuntimeException('No se pudo aplicar la membresia VIP; la operacion fue revertida.');
            $this->creditLog($cfg,$identifier,$cost,'subtract','vip/order');
            $this->walletLedger((int)$creditConfigId,$identifier,'debit',$cost,$balance,'vip',null);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la compra VIP.');
            NexoraAudit::log('economy.vip_purchase','account',$accountUsername,array('type'=>$currentType,'expires'=>$current['vip_date']??null),array('type'=>$vipType,'days'=>$days,'expires'=>$expires,'credits'=>$cost,'balance'=>$balance),$this->realm);
            return array('balance'=>$balance,'expires'=>$expires,'type'=>$vipType);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }


    public function exchangeResets($creditConfigId, $identifier, $accountUsername, $characterName, $resetsToExchange, $creditsReward, $exchangeTableName) {
        if(!Validator::UnsignedNumber($resetsToExchange) || !Validator::UnsignedNumber($creditsReward)) throw new InvalidArgumentException('Valores de canje no válidos.');
        $resetsToExchange=(int)$resetsToExchange; $creditsReward=(int)$creditsReward;
        if($resetsToExchange<1 || $creditsReward<0) throw new InvalidArgumentException('Valores de canje no válidos.');
        $this->assertIdentifier($exchangeTableName);
        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $this->assertCrossDatabaseTransaction('MuOnline');
        $this->assertCrossDatabaseTransaction('Me_MuOnline');
        if(!empty($cfg['config_checkonline']) && (new common())->accountOnline($accountUsername)) throw new RuntimeException(lang('error_14'));

        $gameDb=$this->qualifiedDatabase(NexoraRealm::databaseName('MuOnline',$this->realm));
        $accountDb=$this->qualifiedDatabase(NexoraRealm::databaseName('Me_MuOnline',$this->realm));
        $charTable=$gameDb.'.[dbo].'.$this->qi(_TBL_CHR_);
        $exchangeTable=$accountDb.'.[dbo].'.$this->qi($exchangeTableName);
        $nameCol=$this->qi(_CLMN_CHR_NAME_); $accountCol=$this->qi(_CLMN_CHR_ACCID_); $resetCol=$this->qi(_CLMN_CHR_RSTS_);

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar el canje de resets.');
        try {
            $character=$this->db->query_fetch_single('SELECT '.$resetCol.' AS reset_count FROM '.$charTable.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$nameCol.' = ? AND '.$accountCol.' = ?',array($characterName,$accountUsername));
            if(!is_array($character)) throw new RuntimeException('El personaje no pertenece a la cuenta o ya no existe.');
            $row=$this->db->query_fetch_single('SELECT exchanged_resets FROM '.$exchangeTable.' WITH (UPDLOCK,HOLDLOCK) WHERE [character] = ?',array($characterName));
            if(!is_array($row)) {
                $inserted=$this->db->query_affected('INSERT INTO '.$exchangeTable.' ([character], exchanged_resets) VALUES (?,0)',array($characterName));
                if($inserted!==1) throw new RuntimeException('No se pudo iniciar el historial de canjes.');
                $exchanged=0;
            } else $exchanged=(int)$row['exchanged_resets'];
            $currentResets=(int)$character['reset_count'];
            $available=max(0,$currentResets-$exchanged);
            if($available<$resetsToExchange) throw new RuntimeException(lang('exchangeresets_error_9',true));

            $balance=$this->atomicCredit($cfg,$identifier,$creditsReward);
            $updated=$this->db->query_affected('UPDATE '.$exchangeTable.' SET exchanged_resets = exchanged_resets + ? WHERE [character] = ?',array($resetsToExchange,$characterName));
            if($updated!==1) throw new RuntimeException('No se pudo registrar el canje; la operación fue revertida.');
            $this->creditLog($cfg,$identifier,$creditsReward,'add','exchangeresets/exchange');
            $this->walletLedger((int)$creditConfigId,$identifier,'credit',$creditsReward,$balance,'exchange_resets',$characterName.':'.$resetsToExchange);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar el canje de resets.');
            NexoraAudit::log('economy.exchange_resets','character',$characterName,array('exchanged_resets'=>$exchanged,'available'=>$available),array('exchanged_resets'=>$exchanged+$resetsToExchange,'credits'=>$creditsReward,'balance'=>$balance),$this->realm);
            return array('balance'=>$balance,'credits'=>$creditsReward,'exchanged'=>$resetsToExchange,'available_after'=>$available-$resetsToExchange);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function redeemCodeCredits($creditConfigId, $identifier, $accountUsername, $redeemCode, $codesTableName, $logsTableName, $checkIp=false) {
        $redeemCode=strtoupper(trim((string)$redeemCode));
        $accountUsername=trim((string)$accountUsername);
        if($redeemCode==='' || $accountUsername==='') throw new InvalidArgumentException('Datos de canje no válidos.');
        foreach(array($codesTableName,$logsTableName) as $name) $this->assertIdentifier($name);

        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $this->assertCrossDatabaseTransaction('Me_MuOnline');
        if(!empty($cfg['config_checkonline']) && (new common())->accountOnline($accountUsername)) throw new RuntimeException(lang('error_14'));

        $realmDb=$this->qualifiedDatabase(NexoraRealm::databaseName('Me_MuOnline',$this->realm));
        $codesTable=$realmDb.'.[dbo].'.$this->qi($codesTableName);
        $logsTable=$realmDb.'.[dbo].'.$this->qi($logsTableName);
        $ip=class_exists('NexoraSecurity') ? NexoraSecurity::clientIp() : (isset($_SERVER['REMOTE_ADDR'])?(string)$_SERVER['REMOTE_ADDR']:'0.0.0.0');

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar el canje del código.');
        try {
            $code=$this->db->query_fetch_single(
                'SELECT id,redeem_type,redeem_limit,redeem_user,redeem_credit_config_id,redeem_credit_amount,status FROM '.$codesTable.' WITH (UPDLOCK,HOLDLOCK) WHERE redeem_code = ?',
                array($redeemCode)
            );
            if(!is_array($code)) throw new RuntimeException(lang('redeemcode_error_9',true));
            if((int)$code['status']===1) throw new RuntimeException(lang('redeemcode_error_10',true));
            if((int)$code['redeem_credit_config_id']!==(int)$creditConfigId) throw new RuntimeException(lang('redeemcode_error_9',true));

            $used=$this->db->query_fetch_single('SELECT TOP 1 id FROM '.$logsTable.' WHERE code_id=? AND account_username=?',array((int)$code['id'],$accountUsername));
            if(is_array($used)) throw new RuntimeException(lang('redeemcode_error_11',true));
            if($checkIp) {
                $usedIp=$this->db->query_fetch_single('SELECT TOP 1 id FROM '.$logsTable.' WHERE code_id=? AND ip_address=?',array((int)$code['id'],$ip));
                if(is_array($usedIp)) throw new RuntimeException(lang('redeemcode_error_11',true));
            }

            $type=(string)$code['redeem_type'];
            $count=0;
            if($type==='limited') {
                $limit=(int)$code['redeem_limit'];
                if($limit<1) throw new RuntimeException(lang('redeemcode_error_9',true));
                $row=$this->db->query_fetch_single('SELECT COUNT(*) AS total FROM '.$logsTable.' WHERE code_id=?',array((int)$code['id']));
                $count=is_array($row)?(int)$row['total']:0;
                if($count >= $limit) throw new RuntimeException(lang('redeemcode_error_10',true));
            } elseif($type==='account') {
                if(strcasecmp((string)$code['redeem_user'],$accountUsername)!==0) throw new RuntimeException(lang('redeemcode_error_12',true));
            } elseif($type!=='regular') {
                throw new RuntimeException(lang('redeemcode_error_9',true));
            }

            $reward=(int)$code['redeem_credit_amount'];
            if($reward<1) throw new RuntimeException(lang('redeemcode_error_9',true));
            $balance=$this->atomicCredit($cfg,$identifier,$reward);
            $logged=$this->db->query_affected(
                'INSERT INTO '.$logsTable.' (code_id,date_redeemed,ip_address,user_identifier,account_username) VALUES (?,GETDATE(),?,?,?)',
                array((int)$code['id'],$ip,(string)$identifier,$accountUsername)
            );
            if($logged!==1) throw new RuntimeException('No se pudo registrar el canje; la recompensa fue revertida.');

            if($type==='account' || ($type==='limited' && ($count+1)>=(int)$code['redeem_limit'])) {
                $this->db->query_affected('UPDATE '.$codesTable.' SET status=1 WHERE id=?',array((int)$code['id']));
            }

            $this->creditLog($cfg,$identifier,$reward,'add','redeem/code');
            $this->walletLedger((int)$creditConfigId,$identifier,'credit',$reward,$balance,'redeem_code',$this->realm.':'.(int)$code['id']);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar el canje del código.');
            if(class_exists('NexoraAudit')) NexoraAudit::log('economy.redeem_code','account',$accountUsername,null,array('code_id'=>(int)$code['id'],'reward'=>$reward,'balance'=>$balance),$this->realm);
            return array('balance'=>$balance,'reward'=>$reward,'code_id'=>(int)$code['id']);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }



    /**
     * Transfer a character between accounts inside the same active realm.
     * Debit + source slot + destination slot + Character.AccountID + log commit atomically.
     */
    public function transferCharacter($creditConfigId, $identifier, $sourceUsername, $sourceUserId, $targetUsername, $targetUserId, $characterName, $cost, $requiredLevel, $requiredMasterLevel, $requiredResets, $logTableName) {
        $sourceUsername=trim((string)$sourceUsername);
        $targetUsername=trim((string)$targetUsername);
        $characterName=trim((string)$characterName);
        $sourceUserId=(int)$sourceUserId; $targetUserId=(int)$targetUserId;
        $cost=(int)$cost; $requiredLevel=(int)$requiredLevel; $requiredMasterLevel=(int)$requiredMasterLevel; $requiredResets=(int)$requiredResets;
        if($sourceUsername==='' || $targetUsername==='' || $characterName==='' || $sourceUserId<1 || $targetUserId<1 || $cost<0) throw new InvalidArgumentException('Datos de transferencia no validos.');
        if($sourceUserId===$targetUserId || strcasecmp($sourceUsername,$targetUsername)===0) throw new RuntimeException(lang('transfercharacter_error_17',true));
        $this->assertIdentifier($logTableName);

        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $this->assertCrossDatabaseTransaction('Me_MuOnline');
        $gameName=NexoraRealm::databaseName('Me_MuOnline',$this->realm);
        $gameDb=$this->qualifiedDatabase($gameName);
        $mi=$gameDb.'.[dbo].'.$this->qi(_TBL_MI_);
        $ms=$gameDb.'.[dbo].'.$this->qi(_TBL_MS_);
        $ac=$gameDb.'.[dbo].'.$this->qi(_TBL_AC_);
        $chr=$gameDb.'.[dbo].'.$this->qi(_TBL_CHR_);
        $ml=$gameDb.'.[dbo].'.$this->qi(_TBL_MASTERLVL_);
        $logs=$gameDb.'.[dbo].'.$this->qi($logTableName);

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la transferencia del personaje.');
        try {
            $sourceAccount=$this->db->query_fetch_single(
                'SELECT '.$this->qi(_CLMN_MEMBID_).' AS uid,'.$this->qi(_CLMN_USERNM_).' AS username FROM '.$mi.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_MEMBID_).'=? AND '.$this->qi(_CLMN_USERNM_).'=?',
                array($sourceUserId,$sourceUsername)
            );
            if(!is_array($sourceAccount)) throw new RuntimeException(lang('transfercharacter_error_11',true));
            $targetAccount=$this->db->query_fetch_single(
                'SELECT '.$this->qi(_CLMN_MEMBID_).' AS uid,'.$this->qi(_CLMN_USERNM_).' AS username FROM '.$mi.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_MEMBID_).'=? AND '.$this->qi(_CLMN_USERNM_).'=?',
                array($targetUserId,$targetUsername)
            );
            if(!is_array($targetAccount)) throw new RuntimeException(lang('transfercharacter_error_18',true));

            foreach(array(array($sourceUsername,'transfercharacter_error_12'),array($targetUsername,'transfercharacter_error_19')) as $onlineCheck) {
                $row=$this->db->query_fetch_single('SELECT '.$this->qi(_CLMN_CONNSTAT_).' AS online FROM '.$ms.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_MS_MEMBID_).'=?',array($onlineCheck[0]));
                if(is_array($row) && (int)$row['online']===1) throw new RuntimeException(lang($onlineCheck[1],true));
            }

            $character=$this->db->query_fetch_single(
                'SELECT '.$this->qi(_CLMN_CHR_NAME_).' AS char_name,'.$this->qi(_CLMN_CHR_ACCID_).' AS account_id,'.$this->qi(_CLMN_CHR_LVL_).' AS char_level,'.$this->qi(_CLMN_CHR_RSTS_).' AS resets FROM '.$chr.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_CHR_NAME_).'=? AND '.$this->qi(_CLMN_CHR_ACCID_).'=?',
                array($characterName,$sourceUsername)
            );
            if(!is_array($character)) throw new RuntimeException(lang('transfercharacter_error_6',true));
            if($requiredLevel>0 && (int)$character['char_level']<$requiredLevel) throw new RuntimeException(lang('transfercharacter_error_8',true));
            if($requiredResets>0 && (int)$character['resets']<$requiredResets) throw new RuntimeException(lang('transfercharacter_error_24',true));
            if($requiredMasterLevel>0) {
                $master=$this->db->query_fetch_single('SELECT '.$this->qi(_CLMN_ML_LVL_).' AS master_level FROM '.$ml.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_ML_NAME_).'=?',array($characterName));
                $masterLevel=is_array($master)?(int)$master['master_level']:0;
                if($masterLevel<$requiredMasterLevel) throw new RuntimeException(lang('transfercharacter_error_25',true));
            }

            $sourceSlots=$this->db->query_fetch_single('SELECT * FROM '.$ac.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_AC_ID_).'=?',array($sourceUsername));
            $targetSlots=$this->db->query_fetch_single('SELECT * FROM '.$ac.' WITH (UPDLOCK,HOLDLOCK) WHERE '.$this->qi(_CLMN_AC_ID_).'=?',array($targetUsername));
            if(!is_array($sourceSlots)) throw new RuntimeException(lang('transfercharacter_error_20',true));
            if(!is_array($targetSlots)) throw new RuntimeException(lang('transfercharacter_error_18',true));

            $slotNames=array();
            foreach(array_keys($sourceSlots) as $column) if(preg_match('/^GameID([0-9]+)$/i',(string)$column,$m)) $slotNames[(int)$m[1]]=(string)$column;
            ksort($slotNames,SORT_NUMERIC);
            $sourceSlot=null; $targetSlot=null;
            foreach($slotNames as $column) if(isset($sourceSlots[$column]) && strcasecmp(trim((string)$sourceSlots[$column]),$characterName)===0) {$sourceSlot=$column; break;}
            foreach($slotNames as $column) {
                $value=array_key_exists($column,$targetSlots)?$targetSlots[$column]:null;
                if($value===null || trim((string)$value)==='') {$targetSlot=$column; break;}
            }
            if($sourceSlot===null) throw new RuntimeException(lang('transfercharacter_error_20',true));
            if($targetSlot===null) throw new RuntimeException(lang('transfercharacter_error_14',true));
            $this->assertIdentifier($sourceSlot); $this->assertIdentifier($targetSlot);

            $balance=$this->atomicDebit($cfg,$identifier,$cost);

            $sourceSet=$this->qi($sourceSlot).'=NULL';
            if($this->databaseColumnExists($gameName,_TBL_AC_,_CLMN_GAMEIDC_)) {
                $sourceSet.=', '.$this->qi(_CLMN_GAMEIDC_).'=CASE WHEN '.$this->qi(_CLMN_GAMEIDC_).'=? THEN NULL ELSE '.$this->qi(_CLMN_GAMEIDC_).' END';
                $params=array($characterName,$sourceUsername,$characterName);
            } else {
                $params=array($sourceUsername,$characterName);
            }
            $removed=$this->db->query_affected('UPDATE '.$ac.' SET '.$sourceSet.' WHERE '.$this->qi(_CLMN_AC_ID_).'=? AND '.$this->qi($sourceSlot).'=?',$params);
            if($removed!==1) throw new RuntimeException(lang('transfercharacter_error_20',true));

            $inserted=$this->db->query_affected(
                'UPDATE '.$ac.' SET '.$this->qi($targetSlot).'=? WHERE '.$this->qi(_CLMN_AC_ID_).'=? AND ('.$this->qi($targetSlot).' IS NULL OR '.$this->qi($targetSlot)."='')",
                array($characterName,$targetUsername)
            );
            if($inserted!==1) throw new RuntimeException(lang('transfercharacter_error_21',true));

            $ownerChanged=$this->db->query_affected(
                'UPDATE '.$chr.' SET '.$this->qi(_CLMN_CHR_ACCID_).'=? WHERE '.$this->qi(_CLMN_CHR_NAME_).'=? AND '.$this->qi(_CLMN_CHR_ACCID_).'=?',
                array($targetUsername,$characterName,$sourceUsername)
            );
            if($ownerChanged!==1) throw new RuntimeException(lang('transfercharacter_error_22',true));

            $logged=$this->db->query_affected(
                'INSERT INTO '.$logs.' (original_owner_username,new_owner_username,character_name,transfer_date) VALUES (?,?,?,GETDATE())',
                array($sourceUsername,$targetUsername,$characterName)
            );
            if($logged!==1) throw new RuntimeException('No se pudo registrar la transferencia; la operacion fue revertida.');

            $this->creditLog($cfg,$identifier,$cost,'subtract','transfercharacter/transfer');
            $this->walletLedger((int)$creditConfigId,$identifier,'debit',$cost,$balance,'transfer_character',$characterName.':'.$sourceUsername.'->'.$targetUsername);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la transferencia.');

            if(class_exists('NexoraAudit')) NexoraAudit::log(
                'character.transfer','character',$characterName,
                array('account'=>$sourceUsername,'user_id'=>$sourceUserId),
                array('account'=>$targetUsername,'user_id'=>$targetUserId,'cost'=>$cost,'balance'=>$balance),
                $this->realm
            );
            return array('character'=>$characterName,'source'=>$sourceUsername,'target'=>$targetUsername,'balance'=>$balance,'slot'=>$targetSlot);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    /**
     * Rename a character inside the active Nexora realm as one SQL transaction.
     * Credits and all known character-name references either commit together or roll back.
     */
    public function renameCharacter($creditConfigId, $identifier, $accountUsername, $oldName, $newName, $cost) {
        $accountUsername=trim((string)$accountUsername);
        $oldName=trim((string)$oldName);
        $newName=trim((string)$newName);
        $cost=(int)$cost;
        if($accountUsername==='' || $oldName==='' || $newName==='' || $cost<0) throw new InvalidArgumentException('Datos de cambio de nombre no validos.');
        if(strcasecmp($oldName,$newName)===0) throw new RuntimeException('El nuevo nombre debe ser diferente al actual.');

        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $this->assertCrossDatabaseTransaction('Me_MuOnline');
        if((new common())->accountOnline($accountUsername)) throw new RuntimeException(lang('renamecharacter_error_12',true));

        $gameName=NexoraRealm::databaseName('Me_MuOnline',$this->realm);
        $gameDb=$this->qualifiedDatabase($gameName);
        $characterTable=$gameDb.'.[dbo].[Character]';

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar el cambio de nombre.');
        try {
            $owned=$this->db->query_fetch_single(
                'SELECT [Name],[AccountID] FROM '.$characterTable.' WITH (UPDLOCK,HOLDLOCK) WHERE [Name]=? AND [AccountID]=?',
                array($oldName,$accountUsername)
            );
            if(!is_array($owned)) throw new RuntimeException(lang('renamecharacter_error_6',true));

            $duplicate=$this->db->query_fetch_single(
                'SELECT [Name] FROM '.$characterTable.' WITH (UPDLOCK,HOLDLOCK) WHERE [Name]=?',
                array($newName)
            );
            if(is_array($duplicate)) throw new RuntimeException(lang('renamecharacter_error_9',true));

            $balance=$this->atomicDebit($cfg,$identifier,$cost);
            $updatedRefs=0;
            foreach($this->renameCharacterReferences() as $ref) {
                list($table,$column)=$ref;
                if(!$this->databaseColumnExists($gameName,$table,$column)) continue;
                $qualified=$gameDb.'.[dbo].'.$this->qi($table);
                $affected=$this->db->query_affected(
                    'UPDATE '.$qualified.' SET '.$this->qi($column).'=? WHERE '.$this->qi($column).'=?',
                    array($newName,$oldName)
                );
                if($affected===false) throw new RuntimeException('No se pudo actualizar una referencia del personaje: '.$table.'.'.$column);
                $updatedRefs+=(int)$affected;
            }

            $renamed=$this->db->query_affected(
                'UPDATE '.$characterTable.' SET [Name]=? WHERE [Name]=? AND [AccountID]=?',
                array($newName,$oldName,$accountUsername)
            );
            if($renamed!==1) throw new RuntimeException('No se pudo confirmar el nuevo nombre del personaje.');

            $this->creditLog($cfg,$identifier,$cost,'subtract','renamecharacter/rename');
            $this->walletLedger((int)$creditConfigId,$identifier,'debit',$cost,$balance,'rename_character',$oldName.'->'.$newName);
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar el cambio de nombre.');

            if(class_exists('NexoraAudit')) {
                NexoraAudit::log('character.rename','character',$oldName,array('name'=>$oldName),array('name'=>$newName,'references_updated'=>$updatedRefs,'cost'=>$cost,'balance'=>$balance),$this->realm);
            }
            return array('old_name'=>$oldName,'new_name'=>$newName,'references_updated'=>$updatedRefs,'balance'=>$balance);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    /** Known X-Team/WebEngine character-name references. Missing tables/columns are skipped safely. */
    private function renameCharacterReferences() {
        $refs=array(
            array('Character','MarryName'),
            array('AccountCharacter','GameIDC'),
            array('AccountCharacter','GameID1'),array('AccountCharacter','GameID2'),array('AccountCharacter','GameID3'),
            array('AccountCharacter','GameID4'),array('AccountCharacter','GameID5'),array('AccountCharacter','GameID6'),
            array('AccountCharacter','GameID7'),array('AccountCharacter','GameID8'),array('AccountCharacter','GameID9'),array('AccountCharacter','GameID10'),
            array('Guild','G_Master'),array('GuildMember','Name'),
            array('MasterSkillTree','Name'),array('MasterSkillTreeExt','Name'),
            array('Gens_Rank','Name'),array('Gens_Left','Name'),array('Gens_Reward','Name'),
            array('QuestKillCount','Name'),array('QuestWorld','Name'),array('QuestGuide','Name'),
            array('RankingBloodCastle','Name'),array('RankingCastleSiege','Name'),array('RankingChaosCastle','Name'),
            array('RankingDevilSquare','Name'),array('RankingDuel','Name'),array('RankingIllusionTemple','Name'),
            array('ReconnectData','Name'),array('ReconnectOfflineData','Name'),
            array('T_FriendMain','Name'),array('T_FriendList','FriendName'),array('T_WaitFriend','FriendName'),array('T_FriendMail','FriendName'),
            array('OptionData','Name'),array('T_CGuid','Name'),array('T_CurCharName','Name'),
            array('C_PlayerKiller_Info','Killer'),array('C_PlayerKiller_Info','Victim'),
            array('KillLog','Killer'),array('KillLog','Victim'),array('KillLog','CharacterName'),array('KillLog','Name'),
            array('Marry','Name1'),array('Marry','Name2'),array('Marry','Character1'),array('Marry','Character2'),array('Marry','Husband'),array('Marry','Wife'),
            array('AutoAddStats','Name'),array('AutoAddStats','CharacterName')
        );
        return $refs;
    }

    private function databaseColumnExists($database,$table,$column) {
        $this->assertIdentifier($database); $this->assertIdentifier($table); $this->assertIdentifier($column);
        $db=$this->qualifiedDatabase($database);
        $row=$this->db->query_fetch_single(
            'SELECT TOP 1 1 AS ok FROM '.$db.'.INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?',
            array('dbo',$table,$column)
        );
        return is_array($row);
    }


    /**
     * Converts an already-earned Vote Point package into configured credits.
     * Points, daily caps, credit update, credit logs and conversion log commit
     * atomically across Platform and the active realm credit database.
     */
    public function convertVotePoints($creditConfigId, $identifier, $userId, $accountUsername, $ip, $deviceHash, $pointsCost, $creditsReward, $dailyUserCap, $dailyDeviceCap, $dailyIpCap) {
        foreach(array($userId,$pointsCost,$creditsReward) as $n) if(!Validator::UnsignedNumber($n)) throw new InvalidArgumentException('Datos de conversion no validos.');
        $userId=(int)$userId; $pointsCost=(int)$pointsCost; $creditsReward=(int)$creditsReward;
        $dailyUserCap=max(0,(int)$dailyUserCap); $dailyDeviceCap=max(0,(int)$dailyDeviceCap); $dailyIpCap=max(0,(int)$dailyIpCap);
        if($userId<1 || $pointsCost<1 || $creditsReward<1 || !Validator::Ip($ip) || !preg_match('/^[a-f0-9]{64}$/D',(string)$deviceHash)) throw new InvalidArgumentException('Datos de conversion no validos.');
        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la conversion de Vote Points.');
        try {
            $wallet=$this->db->query_fetch_single('SELECT points FROM NEXORA_VOTE_POINTS WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND user_id=?',array($this->realm,$userId));
            if(!is_array($wallet) || (int)$wallet['points']<$pointsCost) throw new RuntimeException('No tienes suficientes Vote Points.');

            $usageUser=$this->db->query_fetch_single('SELECT ISNULL(SUM(credits_granted),0) AS total FROM NEXORA_VOTE_CONVERSIONS WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND user_id=? AND created_at>=CONVERT(date,SYSUTCDATETIME())',array($this->realm,$userId));
            $usageDevice=$this->db->query_fetch_single('SELECT ISNULL(SUM(credits_granted),0) AS total FROM NEXORA_VOTE_CONVERSIONS WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND device_hash=? AND created_at>=CONVERT(date,SYSUTCDATETIME())',array($this->realm,$deviceHash));
            $usageIp=$this->db->query_fetch_single('SELECT ISNULL(SUM(credits_granted),0) AS total FROM NEXORA_VOTE_CONVERSIONS WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND user_ip=? AND created_at>=CONVERT(date,SYSUTCDATETIME())',array($this->realm,$ip));
            $u=is_array($usageUser)?(int)$usageUser['total']:0; $d=is_array($usageDevice)?(int)$usageDevice['total']:0; $i=is_array($usageIp)?(int)$usageIp['total']:0;
            if($dailyUserCap>0 && $u+$creditsReward>$dailyUserCap) throw new RuntimeException('Limite diario de conversion de la cuenta alcanzado.');
            if($dailyDeviceCap>0 && $d+$creditsReward>$dailyDeviceCap) throw new RuntimeException('Limite diario de conversion del dispositivo alcanzado.');
            if($dailyIpCap>0 && $i+$creditsReward>$dailyIpCap) throw new RuntimeException('Limite diario de conversion de la red alcanzado.');

            $spent=$this->db->query_affected('UPDATE NEXORA_VOTE_POINTS SET points=points-?, lifetime_spent=lifetime_spent+?, updated_at=SYSUTCDATETIME() WHERE realm=? AND user_id=? AND points>=?',array($pointsCost,$pointsCost,$this->realm,$userId,$pointsCost));
            if($spent!==1) throw new RuntimeException('No se pudieron reservar los Vote Points.');
            $creditBalance=$this->atomicCredit($cfg,$identifier,$creditsReward);
            $this->creditLog($cfg,$identifier,$creditsReward,'add','vote/points-conversion');
            $this->walletLedger((int)$creditConfigId,$identifier,'credit',$creditsReward,$creditBalance,'vote_points_conversion',$this->realm.':'.$userId.':'.time());
            $logged=$this->db->query_affected('INSERT INTO NEXORA_VOTE_CONVERSIONS (realm,user_id,account_username,points_spent,credits_granted,credit_config,device_hash,user_ip,created_at) VALUES (?,?,?,?,?,?,?,?,SYSUTCDATETIME())',array($this->realm,$userId,(string)$accountUsername,$pointsCost,$creditsReward,(int)$creditConfigId,(string)$deviceHash,(string)$ip));
            if($logged!==1) throw new RuntimeException('No se pudo registrar la conversion; todo fue revertido.');
            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la conversion.');
            $remaining=$this->db->query_fetch_single('SELECT points FROM NEXORA_VOTE_POINTS WHERE realm=? AND user_id=?',array($this->realm,$userId));
            return array('points_balance'=>is_array($remaining)?(int)$remaining['points']:0,'credits_granted'=>$creditsReward,'credit_balance'=>$creditBalance);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function voteReward($creditConfigId, $identifier, $userId, $accountUsername, $ip, $siteId, $reward, $cooldownHours, $votesTable, $logsTable, $saveLogs=true) {
        foreach(array($votesTable,$logsTable) as $name) $this->assertIdentifier($name);
        if(!Validator::UnsignedNumber($userId) || !Validator::UnsignedNumber($siteId) || !Validator::UnsignedNumber($reward) || !Validator::UnsignedNumber($cooldownHours)) throw new InvalidArgumentException('Datos de votación no válidos.');
        $userId=(int)$userId; $siteId=(int)$siteId; $reward=(int)$reward; $cooldownHours=(int)$cooldownHours;
        if($userId<1 || $siteId<1 || $reward<1 || $cooldownHours<1) throw new InvalidArgumentException('Datos de votación no válidos.');
        if(!Validator::Ip($ip)) throw new InvalidArgumentException('Dirección IP no válida.');

        $cfg=$this->creditConfig($creditConfigId);
        $this->assertCrossDatabaseTransaction($cfg['config_database']);
        $now=time();
        $expires=$now+($cooldownHours*3600);

        if(!$this->db->beginTransaction()) throw new RuntimeException('No se pudo iniciar la recompensa de votación.');
        try {
            // Serialize all claims for this realm/site/user and realm/site/IP.
            $userRows=$this->db->query_fetch('SELECT id,timestamp FROM '.$votesTable.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND vote_site_id=? AND user_id=?',array($this->realm,$siteId,$userId));
            if(is_array($userRows)) {
                foreach($userRows as $row) {
                    if((int)$row['timestamp']>$now) throw new RuntimeException(lang('error_15',true));
                    $this->db->query_affected('DELETE FROM '.$votesTable.' WHERE id=?',array((int)$row['id']));
                }
            }
            $ipRows=$this->db->query_fetch('SELECT id,timestamp FROM '.$votesTable.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND vote_site_id=? AND user_ip=?',array($this->realm,$siteId,$ip));
            if(is_array($ipRows)) {
                foreach($ipRows as $row) {
                    if((int)$row['timestamp']>$now) throw new RuntimeException(lang('error_16',true));
                    $this->db->query_affected('DELETE FROM '.$votesTable.' WHERE id=?',array((int)$row['id']));
                }
            }

            $claim=$this->db->query_affected('INSERT INTO '.$votesTable.' (user_id,user_ip,vote_site_id,timestamp,realm) VALUES (?,?,?,?,?)',array($userId,$ip,$siteId,$expires,$this->realm));
            if($claim!==1) throw new RuntimeException('No se pudo reservar el cooldown de votación.');

            $balance=$this->atomicCredit($cfg,$identifier,$reward);
            $this->creditLog($cfg,$identifier,$reward,'add','vote/reward');
            $this->walletLedger((int)$creditConfigId,$identifier,'credit',$reward,$balance,'vote_reward',$this->realm.':'.$siteId.':'.$userId.':'.$now);

            if($saveLogs) {
                $logged=$this->db->query_affected(
                    'INSERT INTO '.$logsTable.' (user_id,votesite_id,timestamp,realm,credits_reward,user_ip,account_username) VALUES (?,?,?,?,?,?,?)',
                    array($userId,$siteId,$now,$this->realm,$reward,$ip,(string)$accountUsername)
                );
                if($logged!==1) throw new RuntimeException('No se pudo registrar la votación; la recompensa fue revertida.');
            }

            if(!$this->db->commit()) throw new RuntimeException('No se pudo confirmar la recompensa de votación.');
            return array('balance'=>$balance,'reward'=>$reward,'expires'=>$expires);
        } catch(Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    private function creditConfig($id) {
        if(!Validator::UnsignedNumber($id)) throw new InvalidArgumentException('Configuración de créditos inválida.');
        $cfg = $this->db->query_fetch_single('SELECT * FROM '.WEBENGINE_CREDITS_CONFIG.' WHERE config_id = ?', array($id));
        if(!is_array($cfg)) throw new RuntimeException('No existe la configuración de créditos solicitada.');
        foreach(array('config_table','config_credits_col','config_user_col') as $key) $this->assertIdentifier($cfg[$key]);
        if(!in_array($cfg['config_database'], array('MuOnline','Me_MuOnline'), true)) throw new RuntimeException('La configuración de créditos apunta a una base no permitida.');
        return $cfg;
    }

    private function atomicCredit($cfg, $identifier, $amount) {
        $target = $this->creditTable($cfg);
        $creditCol = $this->qi($cfg['config_credits_col']);
        $userCol = $this->qi($cfg['config_user_col']);
        $affected = $this->db->query_affected(
            'UPDATE '.$target.' SET '.$creditCol.' = ISNULL('.$creditCol.', 0) + ? WHERE '.$userCol.' = ?',
            array($amount, $identifier)
        );
        if($affected !== 1) throw new RuntimeException('No se pudo acreditar el saldo.');
        return $this->balance($cfg, $identifier);
    }

    private function atomicDebit($cfg, $identifier, $amount) {
        $target = $this->creditTable($cfg);
        $creditCol = $this->qi($cfg['config_credits_col']);
        $userCol = $this->qi($cfg['config_user_col']);
        $affected = $this->db->query_affected(
            'UPDATE '.$target.' SET '.$creditCol.' = '.$creditCol.' - ? WHERE '.$userCol.' = ? AND '.$creditCol.' >= ?',
            array($amount, $identifier, $amount)
        );
        if($affected !== 1) throw new RuntimeException(lang('error_40', true));
        return $this->balance($cfg, $identifier);
    }

    private function balance($cfg, $identifier) {
        $target = $this->creditTable($cfg);
        $creditCol = $this->qi($cfg['config_credits_col']);
        $userCol = $this->qi($cfg['config_user_col']);
        $row = $this->db->query_fetch_single('SELECT '.$creditCol.' AS balance FROM '.$target.' WHERE '.$userCol.' = ?', array($identifier));
        return is_array($row) && isset($row['balance']) ? (int)$row['balance'] : null;
    }

    private function creditTable($cfg) {
        $database = NexoraRealm::databaseName($cfg['config_database'], $this->realm);
        return $this->qualifiedDatabase($database).'.[dbo].'.$this->qi($cfg['config_table']);
    }

    private function creditLog($cfg, $identifier, $credits, $transaction, $module) {
        $this->db->query(
            'INSERT INTO '.WEBENGINE_CREDITS_LOGS.' (log_config, log_identifier, log_credits, log_transaction, log_date, log_inadmincp, log_module, log_ip) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
            array($cfg['config_title'], (string)$identifier, $credits, $transaction, time(), (defined('access') && access === 'admincp') ? 1 : 0, substr($module,0,50), NexoraSecurity::clientIp())
        );
    }

    private function walletLedger($configId, $identifier, $direction, $amount, $balanceAfter, $source, $reference) {
        if(!NexoraRealm::platformEnabled()) return;
        $actor = isset($_SESSION['username']) ? (string)$_SESSION['username'] : 'system';
        $affected = $this->db->query_affected(
            'INSERT INTO NEXORA_WALLET_LEDGER (realm, wallet_config, account_identifier, direction, amount, balance_after, source, reference_key, actor, ip_address, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, SYSUTCDATETIME())',
            array($this->realm, (int)$configId, (string)$identifier, (string)$direction, (int)$amount, $balanceAfter === null ? null : (int)$balanceAfter, (string)$source, $reference, $actor, NexoraSecurity::clientIp())
        );
        if($affected !== 1) throw new RuntimeException('No se pudo registrar el movimiento en el ledger.');
    }

    private function assertCrossDatabaseTransaction($logicalTarget) {
        $platform = NexoraRealm::connection('Platform', $this->realm);
        $target = NexoraRealm::connection($logicalTarget, $this->realm);
        foreach(array('host','port','username','driver') as $key) {
            if((string)$platform[$key] !== (string)$target[$key]) throw new RuntimeException('La operación atómica requiere Platform y el reino en la misma instancia SQL.');
        }
    }

    private function assertIdentifier($value) {
        if(!preg_match('/^[A-Za-z0-9_]+$/D', (string)$value)) throw new RuntimeException('Identificador SQL no permitido en configuración de créditos.');
    }

    private function qi($value) {
        $this->assertIdentifier($value);
        return '['.$value.']';
    }

    private function qualifiedDatabase($value) {
        $this->assertIdentifier($value);
        return '['.$value.']';
    }
}
