<?php
namespace Plugin\NexoraRoulette;

class NexoraRoulette {
    const PRIZES_TABLE = 'NEXORA_ROULETTE_PRIZES';
    const SPINS_TABLE = 'NEXORA_ROULETTE_SPINS';
    const STATE_TABLE = 'NEXORA_ROULETTE_STATE';
    const PROGRESS_TABLE = 'NEXORA_ROULETTE_PROGRESS';
    const MILESTONE_TABLE = 'NEXORA_ROULETTE_MILESTONES';
    const EXCHANGE_REWARDS_TABLE = 'NEXORA_ROULETTE_EXCHANGE_REWARDS';
    const EXCHANGE_LOG_TABLE = 'NEXORA_ROULETTE_EXCHANGE_LOG';
    const SOCIAL_PROFILE_TABLE = 'NEXORA_ROULETTE_SOCIAL_PROFILES';

    private $realm;
    private $db;
    private $config;

    private static $rarityRanks = array(
        'common' => 1,
        'uncommon' => 2,
        'rare' => 3,
        'epic' => 4,
        'legendary' => 5,
    );

    public function __construct($realm=null, $ensureSchema=true) {
        $this->realm = class_exists('NexoraRealm')
            ? \NexoraRealm::validate($realm === null ? \NexoraRealm::active() : $realm)
            : 'legacy';
        $this->db = \Connection::Database('Platform', $this->realm);
        $this->config = $this->loadConfig();
        if($ensureSchema) $this->ensureSchema();
    }

    public static function configPath($realm=null) {
        $realm = class_exists('NexoraRealm')
            ? \NexoraRealm::validate($realm === null ? \NexoraRealm::active() : $realm)
            : 'legacy';
        $path = __PATH_NEXORA_ROULETTE_ROOT__.'config.'.$realm.'.xml';
        if(!is_file($path)) {
            $fallback = __PATH_NEXORA_ROULETTE_ROOT__.'config.xml';
            if(is_file($fallback)) @copy($fallback, $path);
        }
        return $path;
    }

    public function getRealm() { return $this->realm; }

    public function loadModule($module) {
        if(!\Validator::Alpha($module)) throw new \RuntimeException("Modulo de Ruleta Nexora invalido.");
        $path=__PATH_NEXORA_ROULETTE_ROOT__."modules/".$module.".php";
        if(!is_file($path)) throw new \RuntimeException("No se encontro el modulo de Ruleta Nexora.");
        include $path;
    }
    public function getRealmLabel() { return class_exists('NexoraRealm') ? \NexoraRealm::label($this->realm) : ucfirst($this->realm); }
    public function settings() { return $this->config; }
    public function isEnabled() { return !empty($this->config['enabled']); }

    public static function rarityLabels() {
        return array(
            'common'=>'Común',
            'uncommon'=>'Poco común',
            'rare'=>'Raro',
            'epic'=>'Épico',
            'legendary'=>'Legendario',
        );
    }

    public static function prizeTypeLabels() {
        return array(
            'credits'=>'Saldo / moneda',
            'free_spins'=>'Giros adicionales',
            'fragments'=>'Fragmentos NXR',
            'itemshop'=>'Ítem de Arsenal Nexora',
            'manual'=>'Premio de entrega manual',
        );
    }

    public static function exchangeTypeLabels() {
        return array(
            'credits'=>'Saldo / moneda',
            'free_spins'=>'Giros adicionales',
            'itemshop'=>'Ítem de Arsenal Nexora',
            'manual'=>'Entrega manual',
        );
    }

    public function exchangeSettings() {
        return array(
            'exchange_enabled'=>!empty($this->config['exchange_enabled']),
            'show_exchange'=>!empty($this->config['show_exchange']),
            'exchange_title'=>(string)$this->config['exchange_title'],
            'exchange_description'=>(string)$this->config['exchange_description'],
        );
    }

    public function saveExchangeSettings($input) {
        $path=self::configPath($this->realm);
        if(!is_file($path) || !is_writable($path)) throw new \RuntimeException('La configuración de la ruleta no tiene permiso de escritura.');
        $xml=simplexml_load_file($path);
        if(!$xml) throw new \RuntimeException('No se pudo abrir la configuración del canje.');
        $title=trim((string)($input['exchange_title'] ?? 'Canje de Fragmentos'));
        $description=trim((string)($input['exchange_description'] ?? 'Convierte tu constancia en recompensas controladas del ecosistema Nexora.'));
        if($title==='' || mb_strlen($title)>120) throw new \InvalidArgumentException('El título del canje debe tener entre 1 y 120 caracteres.');
        if(mb_strlen($description)>500) throw new \InvalidArgumentException('La descripción del canje no puede superar 500 caracteres.');
        $values=array(
            'exchange_enabled'=>!empty($input['exchange_enabled'])?1:0,
            'show_exchange'=>!empty($input['show_exchange'])?1:0,
            'exchange_title'=>$title,
            'exchange_description'=>$description,
        );
        foreach($values as $key=>$value) {
            if(isset($xml->{$key})) $xml->{$key}=$value;
            else $xml->addChild($key,(string)$value);
        }
        if(!$xml->asXML($path)) throw new \RuntimeException('No se pudo guardar la configuración del canje.');
        $this->config=$this->loadConfig();
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_settings_update','roulette',$this->realm,null,$this->exchangeSettings(),$this->realm);
        return true;
    }

    public static function appearancePresets() {
        return array(
            'nexora'=>array(
                'label'=>'Nexora Obsidian',
                'accent_color'=>'#D7A94A',
                'accent_soft_color'=>'#F0D18B',
                'secondary_color'=>'#7747C7',
                'panel_color'=>'#101722',
                'background_color'=>'#080B11',
            ),
            'legacy_gold'=>array(
                'label'=>'Legado Dorado',
                'accent_color'=>'#C9953B',
                'accent_soft_color'=>'#F3D58E',
                'secondary_color'=>'#7A5121',
                'panel_color'=>'#17130E',
                'background_color'=>'#090806',
            ),
            'seasonal_violet'=>array(
                'label'=>'Seasonal Violeta',
                'accent_color'=>'#9B6EF3',
                'accent_soft_color'=>'#D9C5FF',
                'secondary_color'=>'#45B7D1',
                'panel_color'=>'#13101D',
                'background_color'=>'#090811',
            ),
            'obsidian'=>array(
                'label'=>'Obsidiana Técnica',
                'accent_color'=>'#AAB5C4',
                'accent_soft_color'=>'#E0E6EE',
                'secondary_color'=>'#52647A',
                'panel_color'=>'#10151B',
                'background_color'=>'#07090C',
            ),
        );
    }

    public function appearanceSettings() {
        $keys=array(
            'appearance_preset','accent_color','accent_soft_color','secondary_color','panel_color','background_color',
            'wheel_size','segment_gap','segment_palette','show_segment_labels','show_stats','show_pool','show_history',
            'hero_kicker','hero_description','center_label','spin_cta_label','spin_duration_ms','turns_min','turns_max',
            'pointer_bounce','winner_glow','dim_non_winners','landing_pulse',
            'sound_enabled','sound_volume','sound_profile','sound_intensity','sound_spin_start','sound_landing','sound_wc','sound_fragments','sound_reentry','segment_ticks','rarity_stinger','show_segment_icons','premium_reveal','reveal_delay_ms'
        );
        $out=array();
        foreach($keys as $key) $out[$key]=$this->config[$key];
        return $out;
    }


    public function progressionSettings() {
        $keys=array(
            'progression_enabled','show_progress','base_fragments_per_spin',
            'fragment_bonus_common','fragment_bonus_uncommon','fragment_bonus_rare','fragment_bonus_epic','fragment_bonus_legendary',
            'milestone_1_spins','milestone_1_fragments','milestone_1_free_spins',
            'milestone_2_spins','milestone_2_fragments','milestone_2_free_spins',
            'milestone_3_spins','milestone_3_fragments','milestone_3_free_spins',
            'milestone_4_spins','milestone_4_fragments','milestone_4_free_spins'
        );
        $out=array();
        foreach($keys as $key) $out[$key]=$this->config[$key];
        return $out;
    }

    public function saveProgression($input) {
        $path=self::configPath($this->realm);
        if(!is_file($path) || !is_writable($path)) throw new \RuntimeException('La configuración de la ruleta no tiene permiso de escritura.');
        $xml=simplexml_load_file($path);
        if(!$xml) throw new \RuntimeException('No se pudo abrir la configuración de progresión.');

        $values=array(
            'progression_enabled'=>!empty($input['progression_enabled'])?1:0,
            'show_progress'=>!empty($input['show_progress'])?1:0,
            'base_fragments_per_spin'=>max(0,min(100000,(int)($input['base_fragments_per_spin']??1))),
            'fragment_bonus_common'=>max(0,min(100000,(int)($input['fragment_bonus_common']??0))),
            'fragment_bonus_uncommon'=>max(0,min(100000,(int)($input['fragment_bonus_uncommon']??0))),
            'fragment_bonus_rare'=>max(0,min(100000,(int)($input['fragment_bonus_rare']??1))),
            'fragment_bonus_epic'=>max(0,min(100000,(int)($input['fragment_bonus_epic']??3))),
            'fragment_bonus_legendary'=>max(0,min(100000,(int)($input['fragment_bonus_legendary']??8))),
        );
        $lastThreshold=0;
        for($i=1;$i<=4;$i++) {
            $threshold=max(0,min(100000000,(int)($input['milestone_'.$i.'_spins']??0)));
            $fragments=max(0,min(100000000,(int)($input['milestone_'.$i.'_fragments']??0)));
            $freeSpins=max(0,min(100000,(int)($input['milestone_'.$i.'_free_spins']??0)));
            if($threshold>0 && $threshold<=$lastThreshold) throw new \InvalidArgumentException('Los hitos activos deben tener umbrales crecientes.');
            if($threshold>0) $lastThreshold=$threshold;
            $values['milestone_'.$i.'_spins']=$threshold;
            $values['milestone_'.$i.'_fragments']=$fragments;
            $values['milestone_'.$i.'_free_spins']=$freeSpins;
        }
        foreach($values as $key=>$value) {
            if(isset($xml->{$key})) $xml->{$key}=$value;
            else $xml->addChild($key,(string)$value);
        }
        if(!$xml->asXML($path)) throw new \RuntimeException('No se pudo guardar la progresión de la ruleta.');
        $this->config=$this->loadConfig();
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.progression_update','roulette',$this->realm,null,$this->progressionSettings(),$this->realm);
        return true;
    }

    public function ensureSchema() {
        $queries = array(
            "IF OBJECT_ID(N'[dbo].[".self::PRIZES_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::PRIZES_TABLE."](
                    [id] int IDENTITY(1,1) NOT NULL PRIMARY KEY,
                    [realm] varchar(20) NOT NULL,
                    [name] nvarchar(120) NOT NULL,
                    [description] nvarchar(400) NULL,
                    [prize_type] varchar(30) NOT NULL,
                    [rarity] varchar(20) NOT NULL,
                    [weight] int NOT NULL,
                    [amount] int NOT NULL CONSTRAINT [DF_NRP_amount] DEFAULT(0),
                    [credit_config_id] int NULL,
                    [item_id] int NULL,
                    [daily_global_limit] int NOT NULL CONSTRAINT [DF_NRP_dgl] DEFAULT(0),
                    [daily_account_limit] int NOT NULL CONSTRAINT [DF_NRP_dal] DEFAULT(0),
                    [active] bit NOT NULL CONSTRAINT [DF_NRP_active] DEFAULT(1),
                    [sort_order] int NOT NULL CONSTRAINT [DF_NRP_sort] DEFAULT(100),
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRP_created] DEFAULT(SYSUTCDATETIME()),
                    [updated_at] datetime2 NOT NULL CONSTRAINT [DF_NRP_updated] DEFAULT(SYSUTCDATETIME()),
                    CONSTRAINT [CK_NRP_weight] CHECK ([weight] >= 0),
                    CONSTRAINT [CK_NRP_limits] CHECK ([daily_global_limit] >= 0 AND [daily_account_limit] >= 0)
                );
                CREATE INDEX [IX_NRP_realm_active] ON [dbo].[".self::PRIZES_TABLE."]([realm],[active],[sort_order],[id]);
            END",
            "IF OBJECT_ID(N'[dbo].[".self::STATE_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::STATE_TABLE."](
                    [realm] varchar(20) NOT NULL,
                    [account_username] varchar(50) NOT NULL,
                    [free_spins] int NOT NULL CONSTRAINT [DF_NRS_free] DEFAULT(0),
                    [pity_streak] int NOT NULL CONSTRAINT [DF_NRS_pity] DEFAULT(0),
                    [total_spins] bigint NOT NULL CONSTRAINT [DF_NRS_total] DEFAULT(0),
                    [updated_at] datetime2 NOT NULL CONSTRAINT [DF_NRS_updated] DEFAULT(SYSUTCDATETIME()),
                    CONSTRAINT [PK_NRS] PRIMARY KEY ([realm],[account_username]),
                    CONSTRAINT [CK_NRS_nonnegative] CHECK ([free_spins] >= 0 AND [pity_streak] >= 0 AND [total_spins] >= 0)
                );
            END",
            "IF OBJECT_ID(N'[dbo].[".self::SPINS_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::SPINS_TABLE."](
                    [id] bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
                    [spin_token] char(32) NOT NULL,
                    [realm] varchar(20) NOT NULL,
                    [user_id] int NOT NULL,
                    [account_username] varchar(50) NOT NULL,
                    [wallet_config_id] int NULL,
                    [wallet_identifier] nvarchar(190) NULL,
                    [cost] int NOT NULL,
                    [used_free_spin] bit NOT NULL CONSTRAINT [DF_NRX_free] DEFAULT(0),
                    [prize_id] int NOT NULL,
                    [prize_name] nvarchar(120) NOT NULL,
                    [prize_description] nvarchar(400) NULL,
                    [prize_type] varchar(30) NOT NULL,
                    [rarity] varchar(20) NOT NULL,
                    [prize_amount] int NOT NULL CONSTRAINT [DF_NRX_amount] DEFAULT(0),
                    [fragments_earned] int NOT NULL CONSTRAINT [DF_NRX_fragments] DEFAULT(0),
                    [prize_credit_config_id] int NULL,
                    [prize_item_id] int NULL,
                    [status] varchar(30) NOT NULL,
                    [roll_value] int NOT NULL,
                    [total_weight] int NOT NULL,
                    [pity_before] int NOT NULL,
                    [pity_after] int NOT NULL,
                    [balance_before] bigint NULL,
                    [balance_after] bigint NULL,
                    [ip_address] varchar(45) NULL,
                    [admin_note] nvarchar(500) NULL,
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRX_created] DEFAULT(SYSUTCDATETIME()),
                    [claimed_at] datetime2 NULL,
                    CONSTRAINT [UX_NRX_token] UNIQUE ([spin_token])
                );
                CREATE INDEX [IX_NRX_account] ON [dbo].[".self::SPINS_TABLE."]([realm],[account_username],[created_at] DESC);
                CREATE INDEX [IX_NRX_prize_day] ON [dbo].[".self::SPINS_TABLE."]([realm],[prize_id],[created_at] DESC);
                CREATE INDEX [IX_NRX_status] ON [dbo].[".self::SPINS_TABLE."]([realm],[status],[created_at] DESC);
            END",
            "IF OBJECT_ID(N'[dbo].[".self::PROGRESS_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::PROGRESS_TABLE."](
                    [realm] varchar(20) NOT NULL,
                    [account_username] varchar(50) NOT NULL,
                    [fragments] bigint NOT NULL CONSTRAINT [DF_NRG_fragments] DEFAULT(0),
                    [lifetime_fragments] bigint NOT NULL CONSTRAINT [DF_NRG_lifetime] DEFAULT(0),
                    [updated_at] datetime2 NOT NULL CONSTRAINT [DF_NRG_updated] DEFAULT(SYSUTCDATETIME()),
                    CONSTRAINT [PK_NRG] PRIMARY KEY ([realm],[account_username]),
                    CONSTRAINT [CK_NRG_nonnegative] CHECK ([fragments] >= 0 AND [lifetime_fragments] >= 0)
                );
            END",
            "IF OBJECT_ID(N'[dbo].[".self::SOCIAL_PROFILE_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::SOCIAL_PROFILE_TABLE."](
                    [account_username] varchar(50) NOT NULL PRIMARY KEY,
                    [user_id] int NULL,
                    [display_name] nvarchar(32) NOT NULL,
                    [locked] bit NOT NULL CONSTRAINT [DF_NRSP_locked] DEFAULT(0),
                    [moderation_note] nvarchar(250) NULL,
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRSP_created] DEFAULT(SYSUTCDATETIME()),
                    [updated_at] datetime2 NOT NULL CONSTRAINT [DF_NRSP_updated] DEFAULT(SYSUTCDATETIME())
                );
                CREATE INDEX [IX_NRSP_display] ON [dbo].[".self::SOCIAL_PROFILE_TABLE."]([display_name]);
                CREATE INDEX [IX_NRSP_updated] ON [dbo].[".self::SOCIAL_PROFILE_TABLE."]([updated_at] DESC);
            END",
            "IF OBJECT_ID(N'[dbo].[".self::MILESTONE_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::MILESTONE_TABLE."](
                    [id] bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
                    [realm] varchar(20) NOT NULL,
                    [account_username] varchar(50) NOT NULL,
                    [milestone_no] tinyint NOT NULL,
                    [spin_threshold] int NOT NULL,
                    [fragments_reward] int NOT NULL CONSTRAINT [DF_NRM_fragments] DEFAULT(0),
                    [free_spins_reward] int NOT NULL CONSTRAINT [DF_NRM_spins] DEFAULT(0),
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRM_created] DEFAULT(SYSUTCDATETIME()),
                    CONSTRAINT [UX_NRM_account_milestone] UNIQUE ([realm],[account_username],[milestone_no])
                );
                CREATE INDEX [IX_NRM_realm_created] ON [dbo].[".self::MILESTONE_TABLE."]([realm],[created_at] DESC);
            END",
            "IF OBJECT_ID(N'[dbo].[".self::EXCHANGE_REWARDS_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::EXCHANGE_REWARDS_TABLE."](
                    [id] int IDENTITY(1,1) NOT NULL PRIMARY KEY,
                    [realm] varchar(20) NOT NULL,
                    [name] nvarchar(120) NOT NULL,
                    [description] nvarchar(400) NULL,
                    [reward_type] varchar(30) NOT NULL,
                    [fragment_cost] int NOT NULL,
                    [amount] int NOT NULL CONSTRAINT [DF_NRE_amount] DEFAULT(0),
                    [credit_config_id] int NULL,
                    [item_id] int NULL,
                    [image_file] nvarchar(255) NULL,
                    [global_limit] int NOT NULL CONSTRAINT [DF_NRE_global] DEFAULT(0),
                    [account_limit] int NOT NULL CONSTRAINT [DF_NRE_account] DEFAULT(0),
                    [active] bit NOT NULL CONSTRAINT [DF_NRE_active] DEFAULT(1),
                    [sort_order] int NOT NULL CONSTRAINT [DF_NRE_sort] DEFAULT(100),
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRE_created] DEFAULT(SYSUTCDATETIME()),
                    [updated_at] datetime2 NOT NULL CONSTRAINT [DF_NRE_updated] DEFAULT(SYSUTCDATETIME()),
                    CONSTRAINT [CK_NRE_cost] CHECK ([fragment_cost] > 0),
                    CONSTRAINT [CK_NRE_limits] CHECK ([global_limit] >= 0 AND [account_limit] >= 0)
                );
                CREATE INDEX [IX_NRE_realm_active] ON [dbo].[".self::EXCHANGE_REWARDS_TABLE."]([realm],[active],[sort_order],[id]);
            END",
            "IF OBJECT_ID(N'[dbo].[".self::EXCHANGE_REWARDS_TABLE."]', N'U') IS NOT NULL AND COL_LENGTH('dbo.".self::EXCHANGE_REWARDS_TABLE."','image_file') IS NULL BEGIN ALTER TABLE [dbo].[".self::EXCHANGE_REWARDS_TABLE."] ADD [image_file] nvarchar(255) NULL; END",
            "IF OBJECT_ID(N'[dbo].[".self::EXCHANGE_LOG_TABLE."]', N'U') IS NULL BEGIN
                CREATE TABLE [dbo].[".self::EXCHANGE_LOG_TABLE."](
                    [id] bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
                    [reference_key] varchar(120) NOT NULL,
                    [realm] varchar(20) NOT NULL,
                    [user_id] int NOT NULL,
                    [account_username] varchar(50) NOT NULL,
                    [reward_id] int NOT NULL,
                    [reward_name] nvarchar(120) NOT NULL,
                    [reward_type] varchar(30) NOT NULL,
                    [fragment_cost] int NOT NULL,
                    [reward_amount] int NOT NULL CONSTRAINT [DF_NRL_amount] DEFAULT(0),
                    [reward_credit_config_id] int NULL,
                    [reward_item_id] int NULL,
                    [status] varchar(30) NOT NULL,
                    [fragments_before] bigint NOT NULL,
                    [fragments_after] bigint NOT NULL,
                    [balance_before] bigint NULL,
                    [balance_after] bigint NULL,
                    [ip_address] varchar(45) NULL,
                    [admin_note] nvarchar(500) NULL,
                    [created_at] datetime2 NOT NULL CONSTRAINT [DF_NRL_created] DEFAULT(SYSUTCDATETIME()),
                    [claimed_at] datetime2 NULL,
                    CONSTRAINT [UX_NRL_reference] UNIQUE ([reference_key]),
                    CONSTRAINT [CK_NRL_fragments] CHECK ([fragment_cost] > 0 AND [fragments_before] >= 0 AND [fragments_after] >= 0)
                );
                CREATE INDEX [IX_NRL_account] ON [dbo].[".self::EXCHANGE_LOG_TABLE."]([realm],[account_username],[created_at] DESC);
                CREATE INDEX [IX_NRL_reward] ON [dbo].[".self::EXCHANGE_LOG_TABLE."]([realm],[reward_id],[created_at] DESC);
                CREATE INDEX [IX_NRL_status] ON [dbo].[".self::EXCHANGE_LOG_TABLE."]([realm],[status],[created_at] DESC);
            END",
            "IF COL_LENGTH('dbo.".self::SPINS_TABLE."','fragments_earned') IS NULL BEGIN
                ALTER TABLE [dbo].[".self::SPINS_TABLE."] ADD [fragments_earned] int NOT NULL CONSTRAINT [DF_NRX_fragments] DEFAULT(0) WITH VALUES;
            END"
        );
        foreach($queries as $sql) {
            if(!$this->db->query($sql)) throw new \RuntimeException('No se pudo instalar el esquema de Ruleta Nexora. Revisa permisos CREATE TABLE del usuario SQL.');
        }
        return true;
    }

    public function schemaReady() {
        foreach(array(self::PRIZES_TABLE,self::STATE_TABLE,self::SPINS_TABLE,self::PROGRESS_TABLE,self::MILESTONE_TABLE,self::EXCHANGE_REWARDS_TABLE,self::EXCHANGE_LOG_TABLE,self::SOCIAL_PROFILE_TABLE) as $table) {
            $row = $this->db->query_fetch_single("SELECT name FROM sysobjects WHERE xtype='U' AND name=?", array($table));
            if(!is_array($row)) return false;
        }
        return true;
    }

    public function saveSettings($input) {
        $path = self::configPath($this->realm);
        if(!is_file($path) || !is_writable($path)) throw new \RuntimeException('La configuración de la ruleta no tiene permiso de escritura.');
        $xml = simplexml_load_file($path);
        if(!$xml) throw new \RuntimeException('No se pudo abrir la configuración de la ruleta.');

        $enabled = !empty($input['enabled']) ? 1 : 0;
        $title = trim((string)($input['title'] ?? 'Ruleta Nexora'));
        $creditConfig = max(0,(int)($input['credit_config'] ?? 0));
        $spinCost = max(0,(int)($input['spin_cost'] ?? 0));
        $cooldown = max(0,min(86400,(int)($input['cooldown_seconds'] ?? 0)));
        $dailyLimit = max(0,min(100000,(int)($input['daily_spin_limit'] ?? 0)));
        $pityThreshold = max(0,min(100000,(int)($input['pity_threshold'] ?? 0)));
        $pityRarity = strtolower(trim((string)($input['pity_min_rarity'] ?? 'epic')));
        if($title === '' || mb_strlen($title) > 120) throw new \InvalidArgumentException('El título debe tener entre 1 y 120 caracteres.');
        if(!isset(self::$rarityRanks[$pityRarity])) throw new \InvalidArgumentException('Rareza de protección inválida.');
        if($creditConfig > 0) $this->creditConfig($creditConfig, true);
        if($enabled && $creditConfig < 1 && $spinCost > 0) throw new \InvalidArgumentException('Selecciona una moneda de cuenta antes de activar giros con costo.');

        $xml->enabled = $enabled;
        $xml->title = $title;
        $xml->credit_config = $creditConfig;
        $xml->spin_cost = $spinCost;
        $xml->cooldown_seconds = $cooldown;
        $xml->daily_spin_limit = $dailyLimit;
        $xml->show_odds = !empty($input['show_odds']) ? 1 : 0;
        $xml->show_recent_winners = !empty($input['show_recent_winners']) ? 1 : 0;
        $xml->pity_enabled = !empty($input['pity_enabled']) ? 1 : 0;
        $xml->pity_threshold = $pityThreshold;
        $xml->pity_min_rarity = $pityRarity;
        if(!$xml->asXML($path)) throw new \RuntimeException('No se pudo guardar la configuración de la ruleta.');
        $this->config = $this->loadConfig();
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.settings_update','roulette',$this->realm,null,$this->config,$this->realm);
        return true;
    }

    public function saveAppearance($input) {
        $path=self::configPath($this->realm);
        if(!is_file($path) || !is_writable($path)) throw new \RuntimeException('La configuración de la ruleta no tiene permiso de escritura.');
        $xml=simplexml_load_file($path);
        if(!$xml) throw new \RuntimeException('No se pudo abrir la configuración visual de la ruleta.');

        $presets=self::appearancePresets();
        $preset=strtolower(trim((string)($input['appearance_preset'] ?? 'nexora')));
        if(!isset($presets[$preset])) $preset='nexora';

        $hex=function($value,$fallback){
            $value=strtoupper(trim((string)$value));
            return preg_match('/^#[0-9A-F]{6}$/',$value)?$value:$fallback;
        };
        $accent=$hex($input['accent_color'] ?? '',$presets[$preset]['accent_color']);
        $accentSoft=$hex($input['accent_soft_color'] ?? '',$presets[$preset]['accent_soft_color']);
        $secondary=$hex($input['secondary_color'] ?? '',$presets[$preset]['secondary_color']);
        $panel=$hex($input['panel_color'] ?? '',$presets[$preset]['panel_color']);
        $background=$hex($input['background_color'] ?? '',$presets[$preset]['background_color']);

        $wheelSize=max(480,min(720,(int)($input['wheel_size'] ?? 620)));
        $segmentGap=max(1,min(7,(int)($input['segment_gap'] ?? 3)));
        $segmentPalette=strtolower(trim((string)($input['segment_palette'] ?? 'harmonic')));
        if(!in_array($segmentPalette,array('harmonic','rarity'),true)) $segmentPalette='harmonic';

        $heroKicker=trim((string)($input['hero_kicker'] ?? 'ECONOMÍA DE RECOMPENSAS'));
        $heroDescription=trim((string)($input['hero_description'] ?? 'Cada giro se resuelve en servidor y queda registrado dentro del ecosistema.'));
        $centerLabel=trim((string)($input['center_label'] ?? 'RULETA NEXORA'));
        $spinCtaLabel=trim((string)($input['spin_cta_label'] ?? 'GIRAR'));
        if($heroKicker==='' || mb_strlen($heroKicker)>80) throw new \InvalidArgumentException('El subtítulo superior debe tener entre 1 y 80 caracteres.');
        if($heroDescription==='' || mb_strlen($heroDescription)>280) throw new \InvalidArgumentException('La descripción visual debe tener entre 1 y 280 caracteres.');
        if($centerLabel==='' || mb_strlen($centerLabel)>32) throw new \InvalidArgumentException('El texto central debe tener entre 1 y 32 caracteres.');
        if($spinCtaLabel==='' || mb_strlen($spinCtaLabel)>24) throw new \InvalidArgumentException('El texto del botón debe tener entre 1 y 24 caracteres.');

        $duration=max(3500,min(8000,(int)($input['spin_duration_ms'] ?? 5200)));
        $turnsMin=max(5,min(15,(int)($input['turns_min'] ?? 7)));
        $turnsMax=max($turnsMin,min(18,(int)($input['turns_max'] ?? 10)));
        $soundVolume=max(0,min(100,(int)($input['sound_volume'] ?? 36)));
        $soundProfile=strtolower(trim((string)($input['sound_profile'] ?? 'nexora')));
        if(!in_array($soundProfile,array('nexora','minimal','silent'),true)) $soundProfile='nexora';
        $soundIntensity=max(0,min(100,(int)($input['sound_intensity'] ?? 70)));
        $revealDelay=max(200,min(1200,(int)($input['reveal_delay_ms'] ?? 420)));

        $values=array(
            'appearance_preset'=>$preset,
            'accent_color'=>$accent,
            'accent_soft_color'=>$accentSoft,
            'secondary_color'=>$secondary,
            'panel_color'=>$panel,
            'background_color'=>$background,
            'wheel_size'=>$wheelSize,
            'segment_gap'=>$segmentGap,
            'segment_palette'=>$segmentPalette,
            'show_segment_labels'=>!empty($input['show_segment_labels'])?1:0,
            'show_stats'=>!empty($input['show_stats'])?1:0,
            'show_pool'=>!empty($input['show_pool'])?1:0,
            'show_history'=>!empty($input['show_history'])?1:0,
            'hero_kicker'=>$heroKicker,
            'hero_description'=>$heroDescription,
            'center_label'=>$centerLabel,
            'spin_cta_label'=>$spinCtaLabel,
            'spin_duration_ms'=>$duration,
            'turns_min'=>$turnsMin,
            'turns_max'=>$turnsMax,
            'pointer_bounce'=>!empty($input['pointer_bounce'])?1:0,
            'winner_glow'=>!empty($input['winner_glow'])?1:0,
            'dim_non_winners'=>!empty($input['dim_non_winners'])?1:0,
            'landing_pulse'=>!empty($input['landing_pulse'])?1:0,
            'sound_enabled'=>!empty($input['sound_enabled'])?1:0,
            'sound_volume'=>$soundVolume,
            'sound_profile'=>$soundProfile,
            'sound_intensity'=>$soundIntensity,
            'sound_spin_start'=>!empty($input['sound_spin_start'])?1:0,
            'sound_landing'=>!empty($input['sound_landing'])?1:0,
            'sound_wc'=>!empty($input['sound_wc'])?1:0,
            'sound_fragments'=>!empty($input['sound_fragments'])?1:0,
            'sound_reentry'=>!empty($input['sound_reentry'])?1:0,
            'segment_ticks'=>!empty($input['segment_ticks'])?1:0,
            'rarity_stinger'=>!empty($input['rarity_stinger'])?1:0,
            'show_segment_icons'=>!empty($input['show_segment_icons'])?1:0,
            'premium_reveal'=>!empty($input['premium_reveal'])?1:0,
            'reveal_delay_ms'=>$revealDelay,
        );

        foreach($values as $key=>$value) {
            if(isset($xml->{$key})) $xml->{$key}=$value;
            else $xml->addChild($key,(string)$value);
        }
        if(!$xml->asXML($path)) throw new \RuntimeException('No se pudo guardar la personalización de la ruleta.');
        $this->config=$this->loadConfig();
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.appearance_update','roulette',$this->realm,null,$this->appearanceSettings(),$this->realm);
        return true;
    }

    public function creditConfigs() {
        $rows = $this->db->query_fetch('SELECT config_id,config_title,config_user_col_id,config_database,config_table,config_credits_col,config_user_col FROM '.WEBENGINE_CREDITS_CONFIG.' ORDER BY config_id ASC');
        if(!is_array($rows)) return array();
        $out = array();
        foreach($rows as $row) {
            if(!in_array((string)$row['config_user_col_id'],array('userid','username','email'),true)) continue;
            $out[] = $row;
        }
        return $out;
    }

    public function getPrizes($activeOnly=false) {
        $sql = 'SELECT * FROM '.self::PRIZES_TABLE.' WHERE realm=?';
        $params = array($this->realm);
        if($activeOnly) $sql .= ' AND active=1 AND weight>0';
        $sql .= ' ORDER BY sort_order ASC,id ASC';
        $rows = $this->db->query_fetch($sql,$params);
        return is_array($rows) ? $rows : array();
    }

    public function getPrize($id) {
        if(!\Validator::UnsignedNumber($id) || (int)$id < 1) return null;
        $row = $this->db->query_fetch_single('SELECT * FROM '.self::PRIZES_TABLE.' WHERE id=? AND realm=?',array((int)$id,$this->realm));
        return is_array($row) ? $row : null;
    }

    public function prizeAvailability($username='') {
        list($dayStartUtc,$dayEndUtc)=$this->utcDayWindow();
        $global=array(); $account=array();
        $g=$this->db->query_fetch('SELECT prize_id,COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WHERE realm=? AND created_at>=? AND created_at<? GROUP BY prize_id',array($this->realm,$dayStartUtc,$dayEndUtc));
        if(is_array($g)) foreach($g as $r) $global[(int)$r['prize_id']]=(int)$r['total'];
        if(trim((string)$username)!=='') {
            $a=$this->db->query_fetch('SELECT prize_id,COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WHERE realm=? AND account_username=? AND created_at>=? AND created_at<? GROUP BY prize_id',array($this->realm,(string)$username,$dayStartUtc,$dayEndUtc));
            if(is_array($a)) foreach($a as $r) $account[(int)$r['prize_id']]=(int)$r['total'];
        }
        $out=array();
        foreach($this->getPrizes(true) as $p) {
            $id=(int)$p['id'];
            $gl=max(0,(int)$p['daily_global_limit']); $al=max(0,(int)$p['daily_account_limit']);
            $gu=(int)($global[$id]??0); $au=(int)($account[$id]??0);
            $out[$id]=array(
                'global_limit'=>$gl,'global_used'=>$gu,'global_remaining'=>$gl>0?max(0,$gl-$gu):null,
                'account_limit'=>$al,'account_used'=>$au,'account_remaining'=>$al>0?max(0,$al-$au):null,
            );
        }
        return $out;
    }

    public function savePrize($input) {
        $id = max(0,(int)($input['id'] ?? 0));
        $name = trim((string)($input['name'] ?? ''));
        $description = trim((string)($input['description'] ?? ''));
        $type = strtolower(trim((string)($input['prize_type'] ?? 'manual')));
        $rarity = strtolower(trim((string)($input['rarity'] ?? 'common')));
        $weight = max(0,(int)($input['weight'] ?? 0));
        $amount = max(0,(int)($input['amount'] ?? 0));
        $creditConfig = max(0,(int)($input['credit_config_id'] ?? 0));
        $itemId = max(0,(int)($input['item_id'] ?? 0));
        $dailyGlobal = max(0,(int)($input['daily_global_limit'] ?? 0));
        $dailyAccount = max(0,(int)($input['daily_account_limit'] ?? 0));
        $active = !empty($input['active']) ? 1 : 0;
        $sort = (int)($input['sort_order'] ?? 100);

        if($name === '' || mb_strlen($name) > 120) throw new \InvalidArgumentException('El nombre del premio debe tener entre 1 y 120 caracteres.');
        if(mb_strlen($description) > 400) throw new \InvalidArgumentException('La descripción no puede superar 400 caracteres.');
        if(!isset(self::prizeTypeLabels()[$type])) throw new \InvalidArgumentException('Tipo de premio inválido.');
        if(!isset(self::$rarityRanks[$rarity])) throw new \InvalidArgumentException('Rareza inválida.');
        if($weight < 0 || $weight > 100000000) throw new \InvalidArgumentException('Peso de probabilidad inválido.');
        if($type === 'credits') {
            if($amount < 1 || $creditConfig < 1) throw new \InvalidArgumentException('El premio de saldo necesita moneda y cantidad.');
            $this->creditConfig($creditConfig,true);
            $itemId = 0;
        } elseif($type === 'free_spins') {
            if($amount < 1) throw new \InvalidArgumentException('Indica cuántos giros adicionales entrega el premio.');
            $creditConfig = 0; $itemId = 0;
        } elseif($type === 'fragments') {
            if($amount < 1) throw new \InvalidArgumentException('Indica cuántos Fragmentos NXR entrega el premio.');
            $creditConfig = 0; $itemId = 0;
        } elseif($type === 'itemshop') {
            if($itemId < 1) throw new \InvalidArgumentException('Selecciona un ítem de Arsenal Nexora.');
            $this->validateItemshopItem($itemId);
            $creditConfig = 0; $amount = 0;
        } else {
            $creditConfig = 0; $itemId = 0;
        }

        if($id > 0) {
            $current = $this->getPrize($id);
            if(!is_array($current)) throw new \RuntimeException('El premio solicitado no existe en este reino.');
            $affected = $this->db->query_affected(
                'UPDATE '.self::PRIZES_TABLE.' SET name=?,description=?,prize_type=?,rarity=?,weight=?,amount=?,credit_config_id=?,item_id=?,daily_global_limit=?,daily_account_limit=?,active=?,sort_order=?,updated_at=SYSUTCDATETIME() WHERE id=? AND realm=?',
                array($name,$description,$type,$rarity,$weight,$amount,$creditConfig ?: null,$itemId ?: null,$dailyGlobal,$dailyAccount,$active,$sort,$id,$this->realm)
            );
            if($affected !== 1) throw new \RuntimeException('No se pudo actualizar el premio.');
            $savedId = $id;
        } else {
            $affected = $this->db->query_affected(
                'INSERT INTO '.self::PRIZES_TABLE.' (realm,name,description,prize_type,rarity,weight,amount,credit_config_id,item_id,daily_global_limit,daily_account_limit,active,sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
                array($this->realm,$name,$description,$type,$rarity,$weight,$amount,$creditConfig ?: null,$itemId ?: null,$dailyGlobal,$dailyAccount,$active,$sort)
            );
            if($affected !== 1) throw new \RuntimeException('No se pudo crear el premio.');
            $created = $this->db->query_fetch_single('SELECT TOP 1 id FROM '.self::PRIZES_TABLE.' WHERE realm=? ORDER BY id DESC',array($this->realm));
            $savedId = is_array($created) ? (int)$created['id'] : 0;
        }
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.prize_save','roulette_prize',(string)$savedId,null,array('name'=>$name,'type'=>$type,'rarity'=>$rarity,'weight'=>$weight,'active'=>$active),$this->realm);
        return $savedId;
    }

    public function retirePrize($id) {
        $id=(int)$id;
        if($id < 1) throw new \InvalidArgumentException('Premio inválido.');
        $affected=$this->db->query_affected('UPDATE '.self::PRIZES_TABLE.' SET active=0,updated_at=SYSUTCDATETIME() WHERE id=? AND realm=?',array($id,$this->realm));
        if($affected!==1) throw new \RuntimeException('No se pudo retirar el premio.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.prize_retired','roulette_prize',(string)$id,null,array('active'=>0),$this->realm);
        return true;
    }

    public function odds($activeOnly=true) {
        $prizes = $this->getPrizes($activeOnly);
        $total = 0;
        foreach($prizes as $p) $total += max(0,(int)$p['weight']);
        $out=array();
        foreach($prizes as $p) {
            $p['probability'] = $total > 0 ? (((int)$p['weight']/$total)*100) : 0;
            $out[]=$p;
        }
        return array('total_weight'=>$total,'prizes'=>$out);
    }

    public function state($username, $lock=false) {
        $username=(string)$username;
        $hint=$lock?' WITH (UPDLOCK,HOLDLOCK)':'';
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::STATE_TABLE.$hint.' WHERE realm=? AND account_username=?',array($this->realm,$username));
        if(is_array($row)) return $row;
        $this->db->query_affected('INSERT INTO '.self::STATE_TABLE.' (realm,account_username,free_spins,pity_streak,total_spins) VALUES (?,?,0,0,0)',array($this->realm,$username));
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::STATE_TABLE.$hint.' WHERE realm=? AND account_username=?',array($this->realm,$username));
        if(!is_array($row)) throw new \RuntimeException('No se pudo inicializar el estado de la ruleta.');
        return $row;
    }

    public function progress($username,$lock=false) {
        $username=(string)$username;
        $hint=$lock?' WITH (UPDLOCK,HOLDLOCK)':'';
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::PROGRESS_TABLE.$hint.' WHERE realm=? AND account_username=?',array($this->realm,$username));
        if(is_array($row)) return $row;
        $this->db->query_affected('INSERT INTO '.self::PROGRESS_TABLE.' (realm,account_username,fragments,lifetime_fragments) VALUES (?,?,0,0)',array($this->realm,$username));
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::PROGRESS_TABLE.$hint.' WHERE realm=? AND account_username=?',array($this->realm,$username));
        if(!is_array($row)) throw new \RuntimeException('No se pudo inicializar la progresión de la ruleta.');
        return $row;
    }

    public function milestoneDefinitions() {
        $out=array();
        for($i=1;$i<=4;$i++) {
            $threshold=max(0,(int)$this->config['milestone_'.$i.'_spins']);
            if($threshold<1) continue;
            $out[]=array(
                'number'=>$i,
                'spins'=>$threshold,
                'fragments'=>max(0,(int)$this->config['milestone_'.$i.'_fragments']),
                'free_spins'=>max(0,(int)$this->config['milestone_'.$i.'_free_spins']),
            );
        }
        return $out;
    }

    public function milestoneClaims($username) {
        $rows=$this->db->query_fetch('SELECT * FROM '.self::MILESTONE_TABLE.' WHERE realm=? AND account_username=? ORDER BY milestone_no ASC',array($this->realm,(string)$username));
        return is_array($rows)?$rows:array();
    }

    public function progressSnapshot($username) {
        $state=$this->state((string)$username);
        $progress=$this->progress((string)$username);
        $claims=$this->milestoneClaims((string)$username);
        $claimed=array();
        foreach($claims as $row) $claimed[(int)$row['milestone_no']]=$row;
        $definitions=$this->milestoneDefinitions();
        $next=null;
        foreach($definitions as $def) {
            if(!isset($claimed[(int)$def['number']])) { $next=$def; break; }
        }
        return array(
            'fragments'=>(int)$progress['fragments'],
            'lifetime_fragments'=>(int)$progress['lifetime_fragments'],
            'total_spins'=>(int)$state['total_spins'],
            'claimed'=>$claims,
            'milestones'=>$definitions,
            'next'=>$next,
        );
    }

    private static function normalizeNickname($nickname) {
        $nickname=trim((string)$nickname);
        $nickname=preg_replace('/\s+/u',' ',$nickname);
        return trim((string)$nickname);
    }

    private static function validateNickname($nickname) {
        $nickname=self::normalizeNickname($nickname);
        $len=mb_strlen($nickname,'UTF-8');
        if($len<3 || $len>24) throw new \InvalidArgumentException('El apodo debe tener entre 3 y 24 caracteres.');
        if(!preg_match('/^[\p{L}\p{N}](?:[\p{L}\p{N} _\-]{1,22})[\p{L}\p{N}]$/u',$nickname)) {
            throw new \InvalidArgumentException('El apodo sólo puede usar letras, números, espacios, guion y guion bajo; debe comenzar y terminar con letra o número.');
        }
        if(preg_match('/(?:https?:\/\/|www\.|@|\.com\b|\.net\b|\.org\b)/iu',$nickname)) {
            throw new \InvalidArgumentException('El apodo no puede contener enlaces, correos ni dominios.');
        }
        return $nickname;
    }

    public static function issueSocialToken() {
        if(session_status() !== PHP_SESSION_ACTIVE) return '';
        if(!isset($_SESSION['nexora_roulette_social_tokens']) || !is_array($_SESSION['nexora_roulette_social_tokens'])) $_SESSION['nexora_roulette_social_tokens']=array();
        $now=time();
        foreach($_SESSION['nexora_roulette_social_tokens'] as $token=>$expires) if((int)$expires<$now) unset($_SESSION['nexora_roulette_social_tokens'][$token]);
        while(count($_SESSION['nexora_roulette_social_tokens'])>=8) array_shift($_SESSION['nexora_roulette_social_tokens']);
        $token=bin2hex(random_bytes(24));
        $_SESSION['nexora_roulette_social_tokens'][$token]=$now+900;
        return $token;
    }

    public static function consumeSocialToken($token) {
        if(session_status() !== PHP_SESSION_ACTIVE || !is_string($token) || $token==='' || empty($_SESSION['nexora_roulette_social_tokens'][$token])) return false;
        $expires=(int)$_SESSION['nexora_roulette_social_tokens'][$token];
        unset($_SESSION['nexora_roulette_social_tokens'][$token]);
        return $expires>=time();
    }

    public function networkProfile($username) {
        $username=(string)$username;
        if($username==='') return null;
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::SOCIAL_PROFILE_TABLE.' WHERE account_username=?',array($username));
        return is_array($row)?$row:null;
    }

    public function publicDisplayName($username,$fallback='Jugador Nexora') {
        $row=$this->networkProfile((string)$username);
        if(is_array($row) && trim((string)($row['display_name']??''))!=='') return (string)$row['display_name'];
        return (string)$fallback;
    }

    private function nicknameAlreadyUsed($nickname,$username='') {
        $row=$this->db->query_fetch_single('SELECT TOP 1 account_username FROM '.self::SOCIAL_PROFILE_TABLE.' WHERE LOWER(display_name)=LOWER(?) AND account_username<>?',array((string)$nickname,(string)$username));
        return is_array($row);
    }

    public function savePublicNickname($userId,$username,$nickname,$requestToken) {
        $userId=(int)$userId; $username=(string)$username;
        if(!self::consumeSocialToken((string)$requestToken)) throw new \RuntimeException('Esta solicitud de apodo expiró o ya fue utilizada. Recarga la página.');
        if(!\Validator::UnsignedNumber($userId) || !\Validator::UsernameLength($username) || !\Validator::AlphaNumeric($username)) throw new \InvalidArgumentException('Sesión de cuenta inválida.');
        $nickname=self::validateNickname($nickname);
        $existing=$this->networkProfile($username);
        if(is_array($existing) && !empty($existing['locked'])) throw new \RuntimeException('Tu apodo público está bloqueado por moderación. Contacta al staff si necesitas cambiarlo.');
        if($this->nicknameAlreadyUsed($nickname,$username)) throw new \RuntimeException('Ese apodo ya está siendo utilizado por otro jugador.');
        if(is_array($existing)) {
            $affected=$this->db->query_affected('UPDATE '.self::SOCIAL_PROFILE_TABLE.' SET user_id=?,display_name=?,updated_at=SYSUTCDATETIME() WHERE account_username=?',array($userId,$nickname,$username));
        } else {
            $affected=$this->db->query_affected('INSERT INTO '.self::SOCIAL_PROFILE_TABLE.' (account_username,user_id,display_name,locked,moderation_note) VALUES (?,?,?,0,NULL)',array($username,$userId,$nickname));
        }
        if($affected!==1) throw new \RuntimeException('No se pudo guardar tu apodo público.');
        try { if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.public_nickname_update','account',$username,null,array('display_name'=>$nickname),$this->realm); } catch(\Throwable $ignored) {}
        return $this->networkProfile($username);
    }

    public function adminSavePublicNickname($username,$nickname,$locked=false,$note='') {
        $username=trim((string)$username);
        if(!\Validator::UsernameLength($username) || !\Validator::AlphaNumeric($username)) throw new \InvalidArgumentException('Cuenta inválida.');
        $nickname=self::validateNickname($nickname);
        if($this->nicknameAlreadyUsed($nickname,$username)) throw new \RuntimeException('Ese apodo ya está siendo utilizado por otra cuenta.');
        $note=trim((string)$note);
        if(mb_strlen($note,'UTF-8')>250) $note=mb_substr($note,0,250,'UTF-8');
        $existing=$this->networkProfile($username);
        if(is_array($existing)) {
            $affected=$this->db->query_affected('UPDATE '.self::SOCIAL_PROFILE_TABLE.' SET display_name=?,locked=?,moderation_note=?,updated_at=SYSUTCDATETIME() WHERE account_username=?',array($nickname,$locked?1:0,$note===''?null:$note,$username));
        } else {
            $affected=$this->db->query_affected('INSERT INTO '.self::SOCIAL_PROFILE_TABLE.' (account_username,user_id,display_name,locked,moderation_note) VALUES (?,NULL,?,?,?)',array($username,$nickname,$locked?1:0,$note===''?null:$note));
        }
        if($affected!==1) throw new \RuntimeException('No se pudo actualizar la identidad pública.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.public_nickname_admin_update','account',$username,null,array('display_name'=>$nickname,'locked'=>$locked?1:0,'note'=>$note),$this->realm);
        return true;
    }

    public function adminResetPublicNickname($username) {
        $username=trim((string)$username);
        if(!\Validator::UsernameLength($username) || !\Validator::AlphaNumeric($username)) throw new \InvalidArgumentException('Cuenta inválida.');
        $before=$this->networkProfile($username);
        if(!is_array($before)) return false;
        $affected=$this->db->query_affected('DELETE FROM '.self::SOCIAL_PROFILE_TABLE.' WHERE account_username=?',array($username));
        if($affected!==1) throw new \RuntimeException('No se pudo restablecer el apodo público.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.public_nickname_reset','account',$username,$before,null,$this->realm);
        return true;
    }

    public function adminNetworkProfiles($limit=250,$search='') {
        $limit=max(1,min(1000,(int)$limit));
        $search=trim((string)$search);
        if($search==='') {
            $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::SOCIAL_PROFILE_TABLE.' ORDER BY updated_at DESC,account_username ASC');
        } else {
            $like='%'.$search.'%';
            $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::SOCIAL_PROFILE_TABLE.' WHERE account_username LIKE ? OR display_name LIKE ? ORDER BY updated_at DESC,account_username ASC',array($like,$like));
        }
        return is_array($rows)?$rows:array();
    }

    public function accountHistory($username,$limit=25) {
        $limit=max(1,min(100,(int)$limit));
        $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::SPINS_TABLE.' WHERE realm=? AND account_username=? ORDER BY id DESC',array($this->realm,(string)$username));
        return is_array($rows)?$rows:array();
    }

    public function recentWins($limit=12) {
        $limit=max(1,min(50,(int)$limit));
        $sql="SELECT TOP ".$limit." s.id,s.account_username,s.prize_name,s.rarity,s.prize_type,s.created_at,p.display_name "
            ."FROM ".self::SPINS_TABLE." s LEFT JOIN ".self::SOCIAL_PROFILE_TABLE." p ON p.account_username=s.account_username "
            ."WHERE s.realm=? AND s.prize_type IN ('credits','free_spins','fragments','itemshop','manual') ORDER BY s.id DESC";
        $rows=$this->db->query_fetch($sql,array($this->realm));
        return is_array($rows)?$rows:array();
    }

    public function adminSpins($limit=250,$status='') {
        $limit=max(1,min(1000,(int)$limit));
        $params=array($this->realm);
        $where='realm=?';
        if($status!=='') { $where.=' AND status=?'; $params[]=$status; }
        $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::SPINS_TABLE.' WHERE '.$where.' ORDER BY id DESC',$params);
        return is_array($rows)?$rows:array();
    }

    public function pendingClaims($limit=250) {
        $limit=max(1,min(1000,(int)$limit));
        $rows=$this->db->query_fetch("SELECT TOP ".$limit." * FROM ".self::SPINS_TABLE." WHERE realm=? AND status IN ('pending_item','pending_manual') ORDER BY id ASC",array($this->realm));
        return is_array($rows)?$rows:array();
    }

    public function pendingExchangeClaims($limit=250) {
        $limit=max(1,min(1000,(int)$limit));
        $rows=$this->db->query_fetch("SELECT TOP ".$limit." * FROM ".self::EXCHANGE_LOG_TABLE." WHERE realm=? AND status IN ('pending_item','pending_manual') ORDER BY id ASC",array($this->realm));
        return is_array($rows)?$rows:array();
    }

    public function dashboardStats() {
        list($dayStartUtc,$dayEndUtc)=$this->utcDayWindow();
        $today=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS spins,COUNT(DISTINCT account_username) AS players,COALESCE(SUM(CAST(cost AS bigint)),0) AS spent FROM '.self::SPINS_TABLE.' WHERE realm=? AND created_at>=? AND created_at<?',array($this->realm,$dayStartUtc,$dayEndUtc));
        $all=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS spins,COUNT(DISTINCT account_username) AS players FROM '.self::SPINS_TABLE.' WHERE realm=?',array($this->realm));
        $pendingSpins=$this->db->query_fetch_single("SELECT COUNT_BIG(*) AS pending FROM ".self::SPINS_TABLE." WHERE realm=? AND status IN ('pending_item','pending_manual')",array($this->realm));
        $pendingExchange=$this->db->query_fetch_single("SELECT COUNT_BIG(*) AS pending FROM ".self::EXCHANGE_LOG_TABLE." WHERE realm=? AND status IN ('pending_item','pending_manual')",array($this->realm));
        $spinPending=is_array($pendingSpins)?(int)($pendingSpins['pending']??0):0;
        $exchangePending=is_array($pendingExchange)?(int)($pendingExchange['pending']??0):0;
        return array('today'=>is_array($today)?$today:array(),'all'=>is_array($all)?$all:array(),'pending'=>array('pending'=>$spinPending+$exchangePending,'spin_pending'=>$spinPending,'exchange_pending'=>$exchangePending));
    }

    public function economyDashboard() {
        list($dayStartUtc,$dayEndUtc)=$this->utcDayWindow();
        $walletConfigId=max(0,(int)$this->config['credit_config']);
        $aggregateSql='SELECT COUNT_BIG(*) AS spins,COUNT(DISTINCT account_username) AS players,'
            .'COALESCE(SUM(CAST(cost AS bigint)),0) AS spent,'
            .'COALESCE(SUM(CASE WHEN used_free_spin=1 THEN 1 ELSE 0 END),0) AS free_consumed,'
            .'COALESCE(SUM(CASE WHEN prize_type=\'credits\' AND prize_credit_config_id=? THEN CAST(prize_amount AS bigint) ELSE 0 END),0) AS wallet_return,'
            .'COALESCE(SUM(CASE WHEN prize_type=\'itemshop\' THEN 1 ELSE 0 END),0) AS item_prizes,'
            .'COALESCE(SUM(CASE WHEN prize_type=\'manual\' THEN 1 ELSE 0 END),0) AS manual_prizes,'
            .'COALESCE(SUM(CASE WHEN prize_type=\'free_spins\' THEN CAST(prize_amount AS bigint) ELSE 0 END),0) AS free_spins_awarded,'
            .'COALESCE(SUM(CAST(fragments_earned AS bigint)),0) AS spin_fragments '
            .'FROM '.self::SPINS_TABLE.' WHERE realm=?';
        $all=$this->db->query_fetch_single($aggregateSql,array($walletConfigId,$this->realm));
        $today=$this->db->query_fetch_single($aggregateSql.' AND created_at>=? AND created_at<?',array($walletConfigId,$this->realm,$dayStartUtc,$dayEndUtc));
        $milestones=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS claims,COALESCE(SUM(CAST(fragments_reward AS bigint)),0) AS fragments,COALESCE(SUM(CAST(free_spins_reward AS bigint)),0) AS free_spins FROM '.self::MILESTONE_TABLE.' WHERE realm=?',array($this->realm));
        $milestonesToday=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS claims,COALESCE(SUM(CAST(fragments_reward AS bigint)),0) AS fragments,COALESCE(SUM(CAST(free_spins_reward AS bigint)),0) AS free_spins FROM '.self::MILESTONE_TABLE.' WHERE realm=? AND created_at>=? AND created_at<?',array($this->realm,$dayStartUtc,$dayEndUtc));
        $exchangeSql="SELECT COUNT_BIG(*) AS redemptions,COUNT(DISTINCT account_username) AS players,COALESCE(SUM(CAST(fragment_cost AS bigint)),0) AS fragments_spent,COALESCE(SUM(CASE WHEN reward_type='itemshop' THEN 1 ELSE 0 END),0) AS item_redemptions,COALESCE(SUM(CASE WHEN reward_type='manual' THEN 1 ELSE 0 END),0) AS manual_redemptions,COALESCE(SUM(CASE WHEN reward_type='credits' AND reward_credit_config_id=? THEN CAST(reward_amount AS bigint) ELSE 0 END),0) AS wallet_return,COALESCE(SUM(CASE WHEN reward_type='free_spins' THEN CAST(reward_amount AS bigint) ELSE 0 END),0) AS free_spins_awarded FROM ".self::EXCHANGE_LOG_TABLE." WHERE realm=?";
        $exchange=$this->db->query_fetch_single($exchangeSql,array($walletConfigId,$this->realm));
        $exchangeToday=$this->db->query_fetch_single($exchangeSql.' AND created_at>=? AND created_at<?',array($walletConfigId,$this->realm,$dayStartUtc,$dayEndUtc));
        $fragmentSupply=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS accounts,COALESCE(SUM(CAST(fragments AS bigint)),0) AS current_fragments,COALESCE(SUM(CAST(lifetime_fragments AS bigint)),0) AS lifetime_fragments FROM '.self::PROGRESS_TABLE.' WHERE realm=?',array($this->realm));
        $rarities=$this->db->query_fetch('SELECT rarity,COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WHERE realm=? GROUP BY rarity ORDER BY total DESC',array($this->realm));
        $pool=$this->odds(true);
        $directEv=0.0; $freeSpinEv=0.0; $fragmentEv=0.0;
        foreach($pool['prizes'] as $p) {
            $prob=((float)$p['probability'])/100;
            if($p['prize_type']==='credits' && (int)$p['credit_config_id']===$walletConfigId) $directEv += $prob * (int)$p['amount'];
            if($p['prize_type']==='free_spins') $freeSpinEv += $prob * (int)$p['amount'];
            if($p['prize_type']==='fragments') $fragmentEv += $prob * (int)$p['amount'];
        }
        if(!empty($this->config['progression_enabled'])) {
            $fragmentEv += (int)$this->config['base_fragments_per_spin'];
            foreach($pool['prizes'] as $p) {
                $key='fragment_bonus_'.$p['rarity'];
                $fragmentEv += (((float)$p['probability'])/100) * max(0,(int)($this->config[$key]??0));
            }
        }
        $spent=(int)($all['spent']??0); $returned=(int)($all['wallet_return']??0); $exchangeReturned=(int)($exchange['wallet_return']??0);
        $spentToday=(int)($today['spent']??0); $returnedToday=(int)($today['wallet_return']??0); $exchangeReturnedToday=(int)($exchangeToday['wallet_return']??0);
        return array(
            'all'=>is_array($all)?$all:array(),
            'today'=>is_array($today)?$today:array(),
            'milestones'=>is_array($milestones)?$milestones:array(),
            'milestones_today'=>is_array($milestonesToday)?$milestonesToday:array(),
            'exchange'=>is_array($exchange)?$exchange:array(),
            'exchange_today'=>is_array($exchangeToday)?$exchangeToday:array(),
            'fragment_supply'=>is_array($fragmentSupply)?$fragmentSupply:array(),
            'rarities'=>is_array($rarities)?$rarities:array(),
            'expected'=>array(
                'direct_wallet_return_per_spin'=>$directEv,
                'free_spins_per_spin'=>$freeSpinEv,
                'fragments_per_spin'=>$fragmentEv,
                'direct_return_ratio'=>(int)$this->config['spin_cost']>0?$directEv/(int)$this->config['spin_cost']:null,
            ),
            'actual'=>array(
                'net_wallet'=>$returned-$spent,
                'return_ratio'=>$spent>0?$returned/$spent:null,
                'net_wallet_today'=>$returnedToday-$spentToday,
                'return_ratio_today'=>$spentToday>0?$returnedToday/$spentToday:null,
                'net_wallet_with_exchange'=>$returned+$exchangeReturned-$spent,
                'return_ratio_with_exchange'=>$spent>0?($returned+$exchangeReturned)/$spent:null,
                'net_wallet_today_with_exchange'=>$returnedToday+$exchangeReturnedToday-$spentToday,
                'return_ratio_today_with_exchange'=>$spentToday>0?($returnedToday+$exchangeReturnedToday)/$spentToday:null,
                'fragments_total'=>(int)($all['spin_fragments']??0),
                'fragments_today'=>(int)($today['spin_fragments']??0),
            ),
        );
    }

    public function simulateEconomy($spinCount) {
        $spinCount=max(1000,min(100000,(int)$spinCount));
        $pool=$this->getPrizes(true);
        $pool=array_values(array_filter($pool,function($p){ return (int)$p['weight']>0; }));
        if(!$pool) throw new \RuntimeException('No hay premios activos con peso para simular.');
        $walletConfigId=max(0,(int)$this->config['credit_config']);
        $spinCost=max(0,(int)$this->config['spin_cost']);
        $pity=0; $freeSpins=0; $paidSpins=0; $spent=0; $walletReturn=0; $fragments=0; $fragmentPrize=0; $freeAwarded=0;
        $counts=array(); $rarities=array(); $milestonesAwarded=array();
        $definitions=$this->milestoneDefinitions();
        for($i=1;$i<=$spinCount;$i++) {
            if($freeSpins>0) $freeSpins--; else { $paidSpins++; $spent+=$spinCost; }
            $eligible=$pool;
            if($this->pityDue($pity)) {
                $minRank=self::$rarityRanks[$this->config['pity_min_rarity']];
                $filtered=array_values(array_filter($eligible,function($p) use ($minRank){ return $this->rarityRank($p['rarity']) >= $minRank; }));
                if($filtered) $eligible=$filtered;
            }
            list($prize)=$this->weightedPick($eligible);
            $pid=(int)$prize['id'];
            if(!isset($counts[$pid])) $counts[$pid]=array('id'=>$pid,'name'=>$prize['name'],'type'=>$prize['prize_type'],'rarity'=>$prize['rarity'],'count'=>0,'amount'=>(int)$prize['amount']);
            $counts[$pid]['count']++;
            $rarity=(string)$prize['rarity'];
            if(!isset($rarities[$rarity])) $rarities[$rarity]=0;
            $rarities[$rarity]++;
            if($prize['prize_type']==='credits' && (int)$prize['credit_config_id']===$walletConfigId) $walletReturn+=(int)$prize['amount'];
            elseif($prize['prize_type']==='free_spins') { $gain=max(1,(int)$prize['amount']); $freeSpins+=$gain; $freeAwarded+=$gain; }
            elseif($prize['prize_type']==='fragments') { $gain=max(1,(int)$prize['amount']); $fragments+=$gain; $fragmentPrize+=$gain; }
            if(!empty($this->config['progression_enabled'])) {
                $fragments+=max(0,(int)$this->config['base_fragments_per_spin']);
                $bonusKey='fragment_bonus_'.$rarity;
                $fragments+=max(0,(int)($this->config[$bonusKey]??0));
                foreach($definitions as $def) {
                    $no=(int)$def['number'];
                    if($i>=(int)$def['spins'] && empty($milestonesAwarded[$no])) {
                        $milestonesAwarded[$no]=true;
                        $fragments+=(int)$def['fragments'];
                        $freeSpins+=(int)$def['free_spins'];
                        $freeAwarded+=(int)$def['free_spins'];
                    }
                }
            }
            $pity=$this->nextPity($pity,$rarity);
        }
        usort($counts,function($a,$b){ return $b['count'] <=> $a['count']; });
        arsort($rarities);
        return array(
            'spins'=>$spinCount,
            'paid_spins'=>$paidSpins,
            'free_spins_consumed'=>$spinCount-$paidSpins,
            'free_spins_awarded'=>$freeAwarded,
            'spent'=>$spent,
            'wallet_return'=>$walletReturn,
            'net_wallet'=>$walletReturn-$spent,
            'return_ratio'=>$spent>0?$walletReturn/$spent:null,
            'fragments'=>$fragments,
            'fragment_prize_total'=>$fragmentPrize,
            'counts'=>$counts,
            'rarities'=>$rarities,
            'milestones'=>array_keys($milestonesAwarded),
            'notes'=>array('No aplica límites diarios globales/por cuenta. Sí aplica pesos, pity, giros gratuitos y progresión configurada.'),
        );
    }

    public function getExchangeRewards($activeOnly=false) {
        $sql='SELECT * FROM '.self::EXCHANGE_REWARDS_TABLE.' WHERE realm=?';
        $params=array($this->realm);
        if($activeOnly) $sql.=' AND active=1 AND fragment_cost>0';
        $sql.=' ORDER BY sort_order ASC,id ASC';
        $rows=$this->db->query_fetch($sql,$params);
        return is_array($rows)?$rows:array();
    }

    public function getExchangeReward($id) {
        $id=(int)$id;
        if($id<1) return null;
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::EXCHANGE_REWARDS_TABLE.' WHERE id=? AND realm=?',array($id,$this->realm));
        return is_array($row)?$row:null;
    }

    public function saveExchangeReward($input) {
        $id=max(0,(int)($input['id']??0));
        $name=trim((string)($input['name']??''));
        $description=trim((string)($input['description']??''));
        $type=strtolower(trim((string)($input['reward_type']??'manual')));
        $cost=max(0,(int)($input['fragment_cost']??0));
        $amount=max(0,(int)($input['amount']??0));
        $creditConfig=max(0,(int)($input['credit_config_id']??0));
        $itemId=max(0,(int)($input['item_id']??0));
        $imageFile=trim((string)($input['image_file']??''));
        if(mb_strlen($imageFile)>255) throw new \InvalidArgumentException('Ruta de imagen de canje fuera de rango.');
        $globalLimit=max(0,(int)($input['global_limit']??0));
        $accountLimit=max(0,(int)($input['account_limit']??0));
        $active=!empty($input['active'])?1:0;
        $sort=(int)($input['sort_order']??100);
        if($name==='' || mb_strlen($name)>120) throw new \InvalidArgumentException('El nombre de la recompensa debe tener entre 1 y 120 caracteres.');
        if(mb_strlen($description)>400) throw new \InvalidArgumentException('La descripción no puede superar 400 caracteres.');
        if(!isset(self::exchangeTypeLabels()[$type])) throw new \InvalidArgumentException('Tipo de recompensa de canje inválido.');
        if($cost<1 || $cost>1000000000) throw new \InvalidArgumentException('El costo debe ser de al menos 1 Fragmento NXR.');
        if($globalLimit>100000000 || $accountLimit>100000000) throw new \InvalidArgumentException('Límite de canje fuera de rango.');
        if($type==='credits') {
            if($amount<1 || $creditConfig<1) throw new \InvalidArgumentException('El canje de saldo necesita moneda y cantidad.');
            $this->creditConfig($creditConfig,true);
            $itemId=0;
        } elseif($type==='free_spins') {
            if($amount<1) throw new \InvalidArgumentException('Indica cuántos giros adicionales entrega el canje.');
            $creditConfig=0; $itemId=0;
        } elseif($type==='itemshop') {
            if($itemId<1) throw new \InvalidArgumentException('Selecciona un ítem de Arsenal Nexora.');
            $this->validateItemshopItem($itemId);
            $creditConfig=0; $amount=0;
        } else {
            $creditConfig=0; $itemId=0;
        }
        if($id>0) {
            if(!is_array($this->getExchangeReward($id))) throw new \RuntimeException('La recompensa de canje no existe en este reino.');
            $affected=$this->db->query_affected('UPDATE '.self::EXCHANGE_REWARDS_TABLE.' SET name=?,description=?,reward_type=?,fragment_cost=?,amount=?,credit_config_id=?,item_id=?,image_file=?,global_limit=?,account_limit=?,active=?,sort_order=?,updated_at=SYSUTCDATETIME() WHERE id=? AND realm=?',array($name,$description,$type,$cost,$amount,$creditConfig?:null,$itemId?:null,$imageFile!==''?$imageFile:null,$globalLimit,$accountLimit,$active,$sort,$id,$this->realm));
            if($affected!==1) throw new \RuntimeException('No se pudo actualizar la recompensa de canje.');
            $savedId=$id;
        } else {
            $affected=$this->db->query_affected('INSERT INTO '.self::EXCHANGE_REWARDS_TABLE.' (realm,name,description,reward_type,fragment_cost,amount,credit_config_id,item_id,image_file,global_limit,account_limit,active,sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',array($this->realm,$name,$description,$type,$cost,$amount,$creditConfig?:null,$itemId?:null,$imageFile!==''?$imageFile:null,$globalLimit,$accountLimit,$active,$sort));
            if($affected!==1) throw new \RuntimeException('No se pudo crear la recompensa de canje.');
            $created=$this->db->query_fetch_single('SELECT TOP 1 id FROM '.self::EXCHANGE_REWARDS_TABLE.' WHERE realm=? ORDER BY id DESC',array($this->realm));
            $savedId=is_array($created)?(int)$created['id']:0;
        }
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_reward_save','roulette_exchange_reward',(string)$savedId,null,array('name'=>$name,'type'=>$type,'cost'=>$cost,'active'=>$active,'global_limit'=>$globalLimit,'account_limit'=>$accountLimit),$this->realm);
        return $savedId;
    }

    public function retireExchangeReward($id) {
        $id=(int)$id;
        if($id<1) throw new \InvalidArgumentException('Recompensa inválida.');
        $affected=$this->db->query_affected('UPDATE '.self::EXCHANGE_REWARDS_TABLE.' SET active=0,updated_at=SYSUTCDATETIME() WHERE id=? AND realm=?',array($id,$this->realm));
        if($affected!==1) throw new \RuntimeException('No se pudo retirar la recompensa de canje.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_reward_retired','roulette_exchange_reward',(string)$id,null,array('active'=>0),$this->realm);
        return true;
    }

    public function exchangeUsage($username='') {
        $global=array(); $account=array();
        $rows=$this->db->query_fetch('SELECT reward_id,COUNT_BIG(*) AS total FROM '.self::EXCHANGE_LOG_TABLE.' WHERE realm=? GROUP BY reward_id',array($this->realm));
        if(is_array($rows)) foreach($rows as $row) $global[(int)$row['reward_id']]=(int)$row['total'];
        $username=(string)$username;
        if($username!=='') {
            $rows=$this->db->query_fetch('SELECT reward_id,COUNT_BIG(*) AS total FROM '.self::EXCHANGE_LOG_TABLE.' WHERE realm=? AND account_username=? GROUP BY reward_id',array($this->realm,$username));
            if(is_array($rows)) foreach($rows as $row) $account[(int)$row['reward_id']]=(int)$row['total'];
        }
        return array('global'=>$global,'account'=>$account);
    }

    public function accountExchangeHistory($username,$limit=12) {
        $limit=max(1,min(100,(int)$limit));
        $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::EXCHANGE_LOG_TABLE.' WHERE realm=? AND account_username=? ORDER BY id DESC',array($this->realm,(string)$username));
        return is_array($rows)?$rows:array();
    }

    public function exchangeHistory($limit=100) {
        $limit=max(1,min(1000,(int)$limit));
        $rows=$this->db->query_fetch('SELECT TOP '.$limit.' * FROM '.self::EXCHANGE_LOG_TABLE.' WHERE realm=? ORDER BY id DESC',array($this->realm));
        return is_array($rows)?$rows:array();
    }

    public static function issueExchangeToken() {
        if(session_status() !== PHP_SESSION_ACTIVE) return '';
        if(!isset($_SESSION['nexora_roulette_exchange_tokens']) || !is_array($_SESSION['nexora_roulette_exchange_tokens'])) $_SESSION['nexora_roulette_exchange_tokens']=array();
        $now=time();
        foreach($_SESSION['nexora_roulette_exchange_tokens'] as $token=>$expires) if((int)$expires<$now) unset($_SESSION['nexora_roulette_exchange_tokens'][$token]);
        while(count($_SESSION['nexora_roulette_exchange_tokens'])>=8) array_shift($_SESSION['nexora_roulette_exchange_tokens']);
        $token=bin2hex(random_bytes(24));
        $_SESSION['nexora_roulette_exchange_tokens'][$token]=$now+900;
        return $token;
    }

    public static function consumeExchangeToken($token) {
        if(session_status() !== PHP_SESSION_ACTIVE || !is_string($token) || $token==='' || empty($_SESSION['nexora_roulette_exchange_tokens'][$token])) return false;
        $expires=(int)$_SESSION['nexora_roulette_exchange_tokens'][$token];
        unset($_SESSION['nexora_roulette_exchange_tokens'][$token]);
        return $expires>=time();
    }

    public function redeemFragmentReward($rewardId,$userId,$username,$requestToken) {
        $rewardId=(int)$rewardId; $userId=(int)$userId; $username=(string)$username;
        if(empty($this->config['progression_enabled'])) throw new \RuntimeException('La progresión de Fragmentos NXR está desactivada en este reino.');
        if(empty($this->config['exchange_enabled'])) throw new \RuntimeException('El canje de Fragmentos NXR está pausado en este reino.');
        if(!self::consumeExchangeToken((string)$requestToken)) throw new \RuntimeException('Esta solicitud de canje expiró o ya fue utilizada. Recarga la página.');
        if($rewardId<1 || !\Validator::UnsignedNumber($userId) || !\Validator::UsernameLength($username) || !\Validator::AlphaNumeric($username)) throw new \InvalidArgumentException('Solicitud de canje inválida.');

        if(!$this->db->beginTransaction()) throw new \RuntimeException('No se pudo iniciar el canje.');
        try {
            $reward=$this->db->query_fetch_single('SELECT * FROM '.self::EXCHANGE_REWARDS_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE id=? AND realm=? AND active=1',array($rewardId,$this->realm));
            if(!is_array($reward)) throw new \RuntimeException('Esta recompensa ya no está disponible.');
            $cost=max(1,(int)$reward['fragment_cost']);
            $globalLimit=max(0,(int)$reward['global_limit']);
            $accountLimit=max(0,(int)$reward['account_limit']);
            if($globalLimit>0) {
                $usage=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS total FROM '.self::EXCHANGE_LOG_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND reward_id=?',array($this->realm,$rewardId));
                if(is_array($usage) && (int)$usage['total'] >= $globalLimit) throw new \RuntimeException('Esta recompensa agotó su stock de canje.');
            }
            if($accountLimit>0) {
                $usage=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS total FROM '.self::EXCHANGE_LOG_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND reward_id=? AND account_username=?',array($this->realm,$rewardId,$username));
                if(is_array($usage) && (int)$usage['total'] >= $accountLimit) throw new \RuntimeException('Tu cuenta ya alcanzó el límite de esta recompensa.');
            }

            $progress=$this->progress($username,true);
            $fragmentsBefore=(int)$progress['fragments'];
            if($fragmentsBefore<$cost) throw new \RuntimeException('No tienes suficientes Fragmentos NXR para este canje.');
            $reference='roulette_exchange:'.$this->realm.':'.bin2hex(random_bytes(12));
            $type=(string)$reward['reward_type'];
            $status='credited'; $balanceBefore=null; $balanceAfter=null;

            if($type==='credits') {
                $configId=(int)$reward['credit_config_id'];
                $amount=max(1,(int)$reward['amount']);
                $cfg=$this->creditConfig($configId,true);
                $this->assertCrossDatabaseTransaction((string)$cfg['config_database']);
                $identifier=$this->resolveIdentifier($cfg,$userId,$username);
                $balanceBefore=$this->walletBalance($cfg,$identifier,true);
                $this->walletCredit($cfg,$identifier,$amount);
                $balanceAfter=$this->walletBalance($cfg,$identifier);
                $this->creditLog($cfg,$identifier,$amount,'add','nexoraroulette/exchange');
                $this->walletLedger($configId,$identifier,'credit',$amount,$balanceAfter,'roulette_exchange',$reference);
            } elseif($type==='free_spins') {
                $state=$this->state($username,true);
                $amount=max(1,(int)$reward['amount']);
                $updated=$this->db->query_affected('UPDATE '.self::STATE_TABLE.' SET free_spins=free_spins+?,updated_at=SYSUTCDATETIME() WHERE realm=? AND account_username=?',array($amount,$this->realm,$username));
                if($updated!==1) throw new \RuntimeException('No se pudieron acreditar los giros del canje.');
                $status='credited';
            } elseif($type==='itemshop') {
                if((int)$reward['item_id']<1) throw new \RuntimeException('La recompensa no tiene un ítem de Arsenal asociado.');
                $this->validateItemshopItem((int)$reward['item_id']);
                $status='pending_item';
            } elseif($type==='manual') {
                $status='pending_manual';
            } else {
                throw new \RuntimeException('Tipo de recompensa de canje no compatible.');
            }

            $debited=$this->db->query_affected('UPDATE '.self::PROGRESS_TABLE.' SET fragments=fragments-?,updated_at=SYSUTCDATETIME() WHERE realm=? AND account_username=? AND fragments>=?',array($cost,$this->realm,$username,$cost));
            if($debited!==1) throw new \RuntimeException('No se pudo reservar el costo en Fragmentos NXR.');
            $fragmentsAfter=$fragmentsBefore-$cost;
            $inserted=$this->db->query_affected('INSERT INTO '.self::EXCHANGE_LOG_TABLE.' (reference_key,realm,user_id,account_username,reward_id,reward_name,reward_type,fragment_cost,reward_amount,reward_credit_config_id,reward_item_id,status,fragments_before,fragments_after,balance_before,balance_after,ip_address) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',array($reference,$this->realm,$userId,$username,$rewardId,(string)$reward['name'],$type,$cost,(int)$reward['amount'],!empty($reward['credit_config_id'])?(int)$reward['credit_config_id']:null,!empty($reward['item_id'])?(int)$reward['item_id']:null,$status,$fragmentsBefore,$fragmentsAfter,$balanceBefore,$balanceAfter,class_exists('NexoraSecurity')?\NexoraSecurity::clientIp():($_SERVER['REMOTE_ADDR']??'')));
            if($inserted!==1) throw new \RuntimeException('No se pudo registrar el canje.');
            $created=$this->db->query_fetch_single('SELECT id FROM '.self::EXCHANGE_LOG_TABLE.' WHERE reference_key=?',array($reference));
            if(!is_array($created)) throw new \RuntimeException('No se pudo resolver el identificador del canje.');
            $redemptionId=(int)$created['id'];
            if(!$this->db->commit()) throw new \RuntimeException('No se pudo confirmar el canje.');

            $deliveryError='';
            if($type==='itemshop') {
                try {
                    $claim=$this->claimExchangeItem($redemptionId,$userId,$username,null,false);
                    $status='claimed';
                } catch(\Throwable $deliveryEx) {
                    $deliveryError=$deliveryEx->getMessage();
                    $status='pending_item';
                }
            }
            try { if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_redeem','account',$username,null,array('redemption_id'=>$redemptionId,'reward_id'=>$rewardId,'reward_name'=>$reward['name'],'type'=>$type,'fragment_cost'=>$cost,'status'=>$status),$this->realm); } catch(\Throwable $ignored) {}
            return array('redemption_id'=>$redemptionId,'reward'=>$reward,'status'=>$status,'fragments_before'=>$fragmentsBefore,'fragments_after'=>$fragmentsAfter,'balance_before'=>$balanceBefore,'balance_after'=>$balanceAfter,'delivery_error'=>$deliveryError);
        } catch(\Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function claimExchangeItem($redemptionId,$userId,$username,$requestToken=null,$consumeToken=true) {
        $redemptionId=(int)$redemptionId; $userId=(int)$userId; $username=(string)$username;
        if($consumeToken && !self::consumeExchangeToken((string)$requestToken)) throw new \RuntimeException('Esta solicitud de entrega expiró o ya fue utilizada. Recarga la página.');
        if($redemptionId<1) throw new \InvalidArgumentException('Canje inválido.');
        $row=$this->db->query_fetch_single('SELECT * FROM '.self::EXCHANGE_LOG_TABLE.' WHERE id=? AND realm=? AND account_username=? AND user_id=?',array($redemptionId,$this->realm,$username,$userId));
        if(!is_array($row)) throw new \RuntimeException('No se encontró este canje en tu cuenta.');
        if((string)$row['reward_type']!=='itemshop') throw new \RuntimeException('Este canje no corresponde a un ítem de Arsenal.');
        if((string)$row['status']==='claimed') return array('already'=>true,'redemption'=>$row);
        if((string)$row['status']!=='pending_item') throw new \RuntimeException('Este canje no está disponible para reclamar.');
        $itemId=(int)$row['reward_item_id'];
        if($itemId<1) throw new \RuntimeException('El canje no tiene un ítem asociado.');
        $shop=$this->itemShop();
        $delivery=$shop->deliverReward($itemId,$userId,$username,(string)$row['reference_key'],'roulette_exchange');
        $updated=$this->db->query_affected("UPDATE ".self::EXCHANGE_LOG_TABLE." SET status='claimed',claimed_at=SYSUTCDATETIME() WHERE id=? AND realm=? AND account_username=? AND status IN ('pending_item','claimed')",array($redemptionId,$this->realm,$username));
        if($updated<1) throw new \RuntimeException('El ítem fue procesado, pero no se pudo actualizar el canje. Reintenta: la entrega es idempotente y no se duplicará.');
        try { if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_item_claim','account',$username,null,array('redemption_id'=>$redemptionId,'item_id'=>$itemId,'delivery'=>$delivery),$this->realm); } catch(\Throwable $ignored) {}
        return array('already'=>false,'delivery'=>$delivery,'redemption_id'=>$redemptionId);
    }

    public function markExchangeManualDelivered($redemptionId,$note='') {
        $redemptionId=(int)$redemptionId;
        if($redemptionId<1) throw new \InvalidArgumentException('Canje inválido.');
        $note=trim((string)$note);
        if(mb_strlen($note)>500) $note=mb_substr($note,0,500);
        $affected=$this->db->query_affected("UPDATE ".self::EXCHANGE_LOG_TABLE." SET status='manual_delivered',claimed_at=SYSUTCDATETIME(),admin_note=? WHERE id=? AND realm=? AND status='pending_manual'",array($note,$redemptionId,$this->realm));
        if($affected!==1) throw new \RuntimeException('El canje manual ya fue procesado o no existe.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.exchange_manual_delivered','roulette_exchange',(string)$redemptionId,null,array('note'=>$note),$this->realm);
        return true;
    }

    public static function issueSpinToken() {
        if(session_status() !== PHP_SESSION_ACTIVE) return '';
        if(!isset($_SESSION['nexora_roulette_tokens']) || !is_array($_SESSION['nexora_roulette_tokens'])) $_SESSION['nexora_roulette_tokens']=array();
        $now=time();
        foreach($_SESSION['nexora_roulette_tokens'] as $token=>$expires) if((int)$expires<$now) unset($_SESSION['nexora_roulette_tokens'][$token]);
        while(count($_SESSION['nexora_roulette_tokens'])>=8) array_shift($_SESSION['nexora_roulette_tokens']);
        $token=bin2hex(random_bytes(24));
        $_SESSION['nexora_roulette_tokens'][$token]=$now+900;
        return $token;
    }

    public static function consumeSpinToken($token) {
        if(session_status() !== PHP_SESSION_ACTIVE || !is_string($token) || $token==='' || empty($_SESSION['nexora_roulette_tokens'][$token])) return false;
        $expires=(int)$_SESSION['nexora_roulette_tokens'][$token];
        unset($_SESSION['nexora_roulette_tokens'][$token]);
        return $expires>=time();
    }

    public function currentBalance($userId,$username) {
        $configId=(int)$this->config['credit_config'];
        if($configId<1) return null;
        $cfg=$this->creditConfig($configId,true);
        $identifier=$this->resolveIdentifier($cfg,$userId,$username);
        return $this->walletBalance($cfg,$identifier);
    }

    public function spin($userId,$username,$requestToken) {
        if(!$this->isEnabled()) throw new \RuntimeException('La Ruleta Nexora está pausada en este reino.');
        if(!self::consumeSpinToken((string)$requestToken)) throw new \RuntimeException('Este intento de giro ya expiró o fue utilizado. Recarga la página.');
        if(!\Validator::UnsignedNumber($userId) || !\Validator::UsernameLength($username) || !\Validator::AlphaNumeric($username)) throw new \InvalidArgumentException('Sesión de cuenta inválida.');
        $userId=(int)$userId; $username=(string)$username;
        if(!$this->schemaReady()) throw new \RuntimeException('La ruleta todavía no está instalada.');

        $walletConfigId=(int)$this->config['credit_config'];
        $spinCost=max(0,(int)$this->config['spin_cost']);
        $walletCfg=null; $walletIdentifier=null;
        if($spinCost>0) {
            if($walletConfigId<1) throw new \RuntimeException('La moneda de la ruleta no está configurada.');
            $walletCfg=$this->creditConfig($walletConfigId,true);
            $this->assertCrossDatabaseTransaction($walletCfg['config_database']);
            $walletIdentifier=$this->resolveIdentifier($walletCfg,$userId,$username);
        }

        $spinToken=bin2hex(random_bytes(16));
        $ledgerReference='roulette:'.$this->realm.':'.$spinToken;
        if(!$this->db->beginTransaction()) throw new \RuntimeException('No se pudo iniciar el giro.');
        try {
            $state=$this->state($username,true);
            $progress=$this->progress($username,true);
            $freeBefore=max(0,(int)$state['free_spins']);
            $pityBefore=max(0,(int)$state['pity_streak']);
            $totalSpinsAfter=max(0,(int)$state['total_spins'])+1;

            $this->enforceSpinLimits($username);
            $eligible=$this->eligiblePrizes($username);
            if(!$eligible) throw new \RuntimeException('No hay premios disponibles para girar en este momento.');
            $this->assertSpinOnlineCompatibility($username,$walletCfg,$eligible);

            $pityDue=$this->pityDue($pityBefore);
            if($pityDue) {
                $minRank=self::$rarityRanks[$this->config['pity_min_rarity']];
                $filtered=array_values(array_filter($eligible,function($p) use ($minRank){ return $this->rarityRank($p['rarity']) >= $minRank; }));
                if(!$filtered) throw new \RuntimeException('La protección de constancia está activa, pero no hay premios elegibles de la rareza mínima configurada.');
                $eligible=$filtered;
            }

            $usedFree=$freeBefore>0;
            $balanceBefore=null;
            if(!$usedFree && $spinCost>0) {
                $balanceBefore=$this->walletBalance($walletCfg,$walletIdentifier,true);
                $this->walletDebit($walletCfg,$walletIdentifier,$spinCost);
                $this->creditLog($walletCfg,$walletIdentifier,$spinCost,'subtract','nexoraroulette/spin');
                $this->walletLedger($walletConfigId,$walletIdentifier,'debit',$spinCost,$this->walletBalance($walletCfg,$walletIdentifier),'roulette_spin',$ledgerReference);
            } elseif($walletCfg) {
                $balanceBefore=$this->walletBalance($walletCfg,$walletIdentifier,true);
            }

            list($prize,$roll,$totalWeight)=$this->weightedPick($eligible);
            $status='awarded';
            $freeAfter=$freeBefore-($usedFree?1:0);

            if($prize['prize_type']==='credits') {
                $rewardCfg=$this->creditConfig((int)$prize['credit_config_id'],true);
                $this->assertCrossDatabaseTransaction($rewardCfg['config_database']);
                $rewardIdentifier=$this->resolveIdentifier($rewardCfg,$userId,$username);
                $rewardAmount=(int)$prize['amount'];
                $this->walletCredit($rewardCfg,$rewardIdentifier,$rewardAmount);
                $this->creditLog($rewardCfg,$rewardIdentifier,$rewardAmount,'add','nexoraroulette/reward');
                $this->walletLedger((int)$prize['credit_config_id'],$rewardIdentifier,'credit',$rewardAmount,$this->walletBalance($rewardCfg,$rewardIdentifier),'roulette_reward',$ledgerReference);
            } elseif($prize['prize_type']==='free_spins') {
                $freeAfter += max(1,(int)$prize['amount']);
            } elseif($prize['prize_type']==='fragments') {
                // Se acredita más abajo junto con la progresión, dentro de la misma transacción.
            } elseif($prize['prize_type']==='itemshop') {
                $status='pending_item';
            } elseif($prize['prize_type']==='manual') {
                $status='pending_manual';
            }

            $spinFragments=0;
            if(!empty($this->config['progression_enabled'])) {
                $spinFragments+=max(0,(int)$this->config['base_fragments_per_spin']);
                $bonusKey='fragment_bonus_'.(string)$prize['rarity'];
                $spinFragments+=max(0,(int)($this->config[$bonusKey]??0));
            }
            if($prize['prize_type']==='fragments') $spinFragments+=max(1,(int)$prize['amount']);

            $milestonesAwarded=array();
            $milestoneFragments=0;
            $milestoneFreeSpins=0;
            if(!empty($this->config['progression_enabled'])) {
                foreach($this->milestoneDefinitions() as $milestone) {
                    if($totalSpinsAfter < (int)$milestone['spins']) continue;
                    $existing=$this->db->query_fetch_single('SELECT id FROM '.self::MILESTONE_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND account_username=? AND milestone_no=?',array($this->realm,$username,(int)$milestone['number']));
                    if(is_array($existing)) continue;
                    $created=$this->db->query_affected('INSERT INTO '.self::MILESTONE_TABLE.' (realm,account_username,milestone_no,spin_threshold,fragments_reward,free_spins_reward) VALUES (?,?,?,?,?,?)',array($this->realm,$username,(int)$milestone['number'],(int)$milestone['spins'],(int)$milestone['fragments'],(int)$milestone['free_spins']));
                    if($created!==1) throw new \RuntimeException('No se pudo registrar un hito de constancia.');
                    $milestoneFragments+=(int)$milestone['fragments'];
                    $milestoneFreeSpins+=(int)$milestone['free_spins'];
                    $milestonesAwarded[]=$milestone;
                }
            }
            $freeAfter+=$milestoneFreeSpins;
            $fragmentsEarned=$spinFragments+$milestoneFragments;
            if($fragmentsEarned>0) {
                $progressUpdated=$this->db->query_affected('UPDATE '.self::PROGRESS_TABLE.' SET fragments=fragments+?,lifetime_fragments=lifetime_fragments+?,updated_at=SYSUTCDATETIME() WHERE realm=? AND account_username=?',array($fragmentsEarned,$fragmentsEarned,$this->realm,$username));
                if($progressUpdated!==1) throw new \RuntimeException('No se pudo actualizar la progresión del jugador.');
            }

            $pityAfter=$this->nextPity($pityBefore,$prize['rarity']);
            $stateUpdated=$this->db->query_affected(
                'UPDATE '.self::STATE_TABLE.' SET free_spins=?,pity_streak=?,total_spins=total_spins+1,updated_at=SYSUTCDATETIME() WHERE realm=? AND account_username=?',
                array($freeAfter,$pityAfter,$this->realm,$username)
            );
            if($stateUpdated!==1) throw new \RuntimeException('No se pudo actualizar el estado del jugador.');

            $balanceAfter=$walletCfg ? $this->walletBalance($walletCfg,$walletIdentifier) : null;
            $inserted=$this->db->query_affected(
                'INSERT INTO '.self::SPINS_TABLE.' (spin_token,realm,user_id,account_username,wallet_config_id,wallet_identifier,cost,used_free_spin,prize_id,prize_name,prize_description,prize_type,rarity,prize_amount,fragments_earned,prize_credit_config_id,prize_item_id,status,roll_value,total_weight,pity_before,pity_after,balance_before,balance_after,ip_address) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
                array($spinToken,$this->realm,$userId,$username,$walletConfigId?:null,$walletIdentifier,$usedFree?0:$spinCost,$usedFree?1:0,(int)$prize['id'],(string)$prize['name'],(string)$prize['description'],(string)$prize['prize_type'],(string)$prize['rarity'],(int)$prize['amount'],$fragmentsEarned,!empty($prize['credit_config_id'])?(int)$prize['credit_config_id']:null,!empty($prize['item_id'])?(int)$prize['item_id']:null,$status,$roll,$totalWeight,$pityBefore,$pityAfter,$balanceBefore,$balanceAfter,class_exists('NexoraSecurity')?\NexoraSecurity::clientIp():($_SERVER['REMOTE_ADDR']??''))
            );
            if($inserted!==1) throw new \RuntimeException('No se pudo registrar el giro; el saldo fue restaurado.');
            $spinRow=$this->db->query_fetch_single('SELECT TOP 1 id FROM '.self::SPINS_TABLE.' WHERE spin_token=?',array($spinToken));
            if(!is_array($spinRow)) throw new \RuntimeException('No se pudo confirmar el identificador del giro.');
            $spinId=(int)$spinRow['id'];

            if(!$this->db->commit()) throw new \RuntimeException('No se pudo confirmar el giro.');
            try { if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.spin','account',$username,null,array('spin_id'=>$spinId,'prize_id'=>(int)$prize['id'],'prize'=>$prize['name'],'rarity'=>$prize['rarity'],'cost'=>$usedFree?0:$spinCost,'free_spin'=>$usedFree,'status'=>$status),$this->realm); } catch(\Throwable $ignored) {}
            return array(
                'spin_id'=>$spinId,'spin_token'=>$spinToken,'prize'=>$prize,'roll'=>$roll,'total_weight'=>$totalWeight,
                'used_free_spin'=>$usedFree,'cost'=>$usedFree?0:$spinCost,'balance'=>$balanceAfter,'free_spins'=>$freeAfter,
                'pity_before'=>$pityBefore,'pity_after'=>$pityAfter,'pity_triggered'=>$pityDue,'status'=>$status,
                'fragments_earned'=>$fragmentsEarned,
                'fragments_balance'=>(int)$progress['fragments']+$fragmentsEarned,
                'milestones_awarded'=>$milestonesAwarded,
            );
        } catch(\Throwable $e) {
            if($this->db->inTransaction()) $this->db->rollBack();
            throw $e;
        }
    }

    public function claimItem($spinId,$userId,$username) {
        $spinId=(int)$spinId; $userId=(int)$userId; $username=(string)$username;
        if($spinId<1) throw new \InvalidArgumentException('Giro inválido.');
        $spin=$this->db->query_fetch_single('SELECT * FROM '.self::SPINS_TABLE.' WHERE id=? AND realm=? AND account_username=? AND user_id=?',array($spinId,$this->realm,$username,$userId));
        if(!is_array($spin)) throw new \RuntimeException('No se encontró este premio en tu cuenta.');
        if($spin['prize_type']!=='itemshop') throw new \RuntimeException('Este premio no corresponde a una entrega de Arsenal.');
        if($spin['status']==='claimed') return array('already'=>true,'spin'=>$spin);
        if($spin['status']!=='pending_item') throw new \RuntimeException('Este premio no está disponible para reclamar.');
        $itemId=(int)$spin['prize_item_id'];
        if($itemId<1) throw new \RuntimeException('El premio no tiene un ítem asociado.');
        $shop=$this->itemShop();
        $delivery=$shop->deliverReward($itemId,$userId,$username,'roulette:'.$this->realm.':'.$spinId,'roulette');
        $updated=$this->db->query_affected("UPDATE ".self::SPINS_TABLE." SET status='claimed',claimed_at=SYSUTCDATETIME() WHERE id=? AND realm=? AND account_username=? AND status IN ('pending_item','claimed')",array($spinId,$this->realm,$username));
        if($updated<1) throw new \RuntimeException('El ítem fue procesado, pero no se pudo actualizar el estado del giro. Reintenta: la entrega es idempotente y no se duplicará.');
        try { if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.item_claim','account',$username,null,array('spin_id'=>$spinId,'item_id'=>$itemId,'delivery'=>$delivery),$this->realm); } catch(\Throwable $ignored) {}
        return array('already'=>false,'delivery'=>$delivery,'spin_id'=>$spinId);
    }

    public function markManualDelivered($spinId,$note='') {
        $spinId=(int)$spinId;
        if($spinId<1) throw new \InvalidArgumentException('Giro inválido.');
        $note=trim((string)$note);
        if(mb_strlen($note)>500) $note=mb_substr($note,0,500);
        $affected=$this->db->query_affected("UPDATE ".self::SPINS_TABLE." SET status='manual_delivered',claimed_at=SYSUTCDATETIME(),admin_note=? WHERE id=? AND realm=? AND status='pending_manual'",array($note,$spinId,$this->realm));
        if($affected!==1) throw new \RuntimeException('El premio manual ya fue procesado o no existe.');
        if(class_exists('NexoraAudit')) \NexoraAudit::log('roulette.manual_delivered','roulette_spin',(string)$spinId,null,array('note'=>$note),$this->realm);
        return true;
    }

    public function checkPluginUsercpLinks() {
        $cfg=loadConfig('usercp');
        if(!is_array($cfg)) $cfg=array();
        foreach($cfg as $row) if(isset($row['link']) && $row['link']==='roulette') return true;
        $cfg[]=array('active'=>true,'type'=>'internal','phrase'=>'nexoraroulette_title','link'=>'roulette','icon'=>'usercp_default.png','visibility'=>'user','newtab'=>false,'order'=>118);
        usort($cfg,function($a,$b){ return (int)($a['order']??999) <=> (int)($b['order']??999); });
        $json=json_encode($cfg,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
        if($json===false || file_put_contents(__PATH_CONFIGS__.'usercp.json',$json."\n",LOCK_EX)===false) throw new \RuntimeException('No se pudo actualizar el menú UserCP.');
        return true;
    }

    public function itemshopItems() {
        try { return $this->itemShop()->getAdminItems(); } catch(\Throwable $e) { return array(); }
    }

    public function itemshopRewardVisual($itemId) {
        $itemId=(int)$itemId;
        if($itemId < 1) return null;
        try {
            $shop=$this->itemShop();
            $item=$shop->getItem($itemId);
            if(!is_array($item)) return null;
            $section=(int)($item['item_section'] ?? -1);
            $index=(int)($item['item_index'] ?? -1);
            $url='';
            if($section>=0 && $section<=15 && $index>=0 && $index<=511) {
                $relative='img/itemshop-renders-v1/'.$section.'/'.$index.'.webp';
                if(defined('__PATH_TEMPLATE_ROOT__') && is_file(__PATH_TEMPLATE_ROOT__.$relative)) {
                    $url=__PATH_TEMPLATE_IMG__.'itemshop-renders-v1/'.$section.'/'.$index.'.webp';
                }
            }
            if($url==='') $url=(string)$shop->imageUrl($item);
            return array(
                'url'=>$url,
                'name'=>(string)($item['name'] ?? ''),
                'section'=>$section,
                'index'=>$index,
            );
        } catch(\Throwable $e) {
            return null;
        }
    }

    public function itemshopRewardReady() {
        try {
            $shop=$this->itemShop();
            return $shop->ensureRewardDeliverySchema();
        } catch(\Throwable $e) { return false; }
    }

    private function loadConfig() {
        $path=self::configPath($this->realm);
        if(!is_file($path)) throw new \RuntimeException('No existe la configuración de Ruleta Nexora.');
        $xml=simplexml_load_file($path);
        if(!$xml) throw new \RuntimeException('La configuración de Ruleta Nexora no es válida.');
        $rarity=strtolower(trim((string)$xml->pity_min_rarity));
        if(!isset(self::$rarityRanks[$rarity])) $rarity='epic';
        $presets=self::appearancePresets();
        $preset=strtolower(trim((string)$xml->appearance_preset));
        if(!isset($presets[$preset])) $preset='nexora';
        $presetData=$presets[$preset];
        $safeColor=function($value,$fallback){
            $value=strtoupper(trim((string)$value));
            return preg_match('/^#[0-9A-F]{6}$/',$value)?$value:$fallback;
        };
        $palette=strtolower(trim((string)$xml->segment_palette));
        if(!in_array($palette,array('harmonic','rarity'),true)) $palette='harmonic';
        $heroKicker=trim((string)$xml->hero_kicker);
        $heroDescription=trim((string)$xml->hero_description);
        $centerLabel=trim((string)$xml->center_label);
        $spinCtaLabel=trim((string)$xml->spin_cta_label);
        $turnsMin=((string)$xml->turns_min==='')?7:max(5,min(15,(int)$xml->turns_min));
        $turnsMax=((string)$xml->turns_max==='')?10:max($turnsMin,min(18,(int)$xml->turns_max));
        return array(
            'enabled'=>(int)$xml->enabled===1,
            'title'=>trim((string)$xml->title)!==''?trim((string)$xml->title):'Ruleta Nexora',
            'credit_config'=>max(0,(int)$xml->credit_config),
            'spin_cost'=>max(0,(int)$xml->spin_cost),
            'cooldown_seconds'=>max(0,(int)$xml->cooldown_seconds),
            'daily_spin_limit'=>max(0,(int)$xml->daily_spin_limit),
            'show_odds'=>(int)$xml->show_odds===1,
            'show_recent_winners'=>(int)$xml->show_recent_winners===1,
            'pity_enabled'=>(int)$xml->pity_enabled===1,
            'pity_threshold'=>max(0,(int)$xml->pity_threshold),
            'pity_min_rarity'=>$rarity,
            'appearance_preset'=>$preset,
            'accent_color'=>$safeColor((string)$xml->accent_color,$presetData['accent_color']),
            'accent_soft_color'=>$safeColor((string)$xml->accent_soft_color,$presetData['accent_soft_color']),
            'secondary_color'=>$safeColor((string)$xml->secondary_color,$presetData['secondary_color']),
            'panel_color'=>$safeColor((string)$xml->panel_color,$presetData['panel_color']),
            'background_color'=>$safeColor((string)$xml->background_color,$presetData['background_color']),
            'wheel_size'=>max(480,min(720,(int)$xml->wheel_size ?: 620)),
            'segment_gap'=>max(1,min(7,(int)$xml->segment_gap ?: 3)),
            'segment_palette'=>$palette,
            'show_segment_labels'=>(string)$xml->show_segment_labels===''?true:((int)$xml->show_segment_labels===1),
            'show_stats'=>(string)$xml->show_stats===''?true:((int)$xml->show_stats===1),
            'show_pool'=>(string)$xml->show_pool===''?true:((int)$xml->show_pool===1),
            'show_history'=>(string)$xml->show_history===''?true:((int)$xml->show_history===1),
            'hero_kicker'=>$heroKicker!==''?$heroKicker:'ECONOMÍA DE RECOMPENSAS',
            'hero_description'=>$heroDescription!==''?$heroDescription:'Cada giro se resuelve en servidor y queda registrado dentro del ecosistema.',
            'center_label'=>$centerLabel!==''?$centerLabel:'RULETA NEXORA',
            'spin_cta_label'=>$spinCtaLabel!==''?$spinCtaLabel:'GIRAR',
            'spin_duration_ms'=>max(3500,min(8000,(int)$xml->spin_duration_ms ?: 5200)),
            'turns_min'=>$turnsMin,
            'turns_max'=>$turnsMax,
            'pointer_bounce'=>(string)$xml->pointer_bounce===''?true:((int)$xml->pointer_bounce===1),
            'winner_glow'=>(string)$xml->winner_glow===''?true:((int)$xml->winner_glow===1),
            'dim_non_winners'=>(string)$xml->dim_non_winners===''?true:((int)$xml->dim_non_winners===1),
            'landing_pulse'=>(string)$xml->landing_pulse===''?true:((int)$xml->landing_pulse===1),
            'sound_enabled'=>(string)$xml->sound_enabled===''?true:((int)$xml->sound_enabled===1),
            'sound_volume'=>max(0,min(100,((string)$xml->sound_volume==='')?36:(int)$xml->sound_volume)),
            'sound_profile'=>in_array(strtolower(trim((string)$xml->sound_profile)),array('nexora','minimal','silent'),true)?strtolower(trim((string)$xml->sound_profile)):'nexora',
            'sound_intensity'=>max(0,min(100,((string)$xml->sound_intensity==='')?70:(int)$xml->sound_intensity)),
            'sound_spin_start'=>(string)$xml->sound_spin_start===''?true:((int)$xml->sound_spin_start===1),
            'sound_landing'=>(string)$xml->sound_landing===''?true:((int)$xml->sound_landing===1),
            'sound_wc'=>(string)$xml->sound_wc===''?true:((int)$xml->sound_wc===1),
            'sound_fragments'=>(string)$xml->sound_fragments===''?true:((int)$xml->sound_fragments===1),
            'sound_reentry'=>(string)$xml->sound_reentry===''?true:((int)$xml->sound_reentry===1),
            'segment_ticks'=>(string)$xml->segment_ticks===''?true:((int)$xml->segment_ticks===1),
            'rarity_stinger'=>(string)$xml->rarity_stinger===''?true:((int)$xml->rarity_stinger===1),
            'show_segment_icons'=>(string)$xml->show_segment_icons===''?true:((int)$xml->show_segment_icons===1),
            'premium_reveal'=>(string)$xml->premium_reveal===''?true:((int)$xml->premium_reveal===1),
            'reveal_delay_ms'=>max(200,min(1200,((string)$xml->reveal_delay_ms==='')?420:(int)$xml->reveal_delay_ms)),
            'progression_enabled'=>(string)$xml->progression_enabled===''?true:((int)$xml->progression_enabled===1),
            'show_progress'=>(string)$xml->show_progress===''?true:((int)$xml->show_progress===1),
            'base_fragments_per_spin'=>max(0,min(100000,((string)$xml->base_fragments_per_spin==='')?1:(int)$xml->base_fragments_per_spin)),
            'fragment_bonus_common'=>max(0,min(100000,((string)$xml->fragment_bonus_common==='')?0:(int)$xml->fragment_bonus_common)),
            'fragment_bonus_uncommon'=>max(0,min(100000,((string)$xml->fragment_bonus_uncommon==='')?0:(int)$xml->fragment_bonus_uncommon)),
            'fragment_bonus_rare'=>max(0,min(100000,((string)$xml->fragment_bonus_rare==='')?1:(int)$xml->fragment_bonus_rare)),
            'fragment_bonus_epic'=>max(0,min(100000,((string)$xml->fragment_bonus_epic==='')?3:(int)$xml->fragment_bonus_epic)),
            'fragment_bonus_legendary'=>max(0,min(100000,((string)$xml->fragment_bonus_legendary==='')?8:(int)$xml->fragment_bonus_legendary)),
            'milestone_1_spins'=>max(0,(int)(((string)$xml->milestone_1_spins==='')?10:$xml->milestone_1_spins)),
            'milestone_1_fragments'=>max(0,(int)(((string)$xml->milestone_1_fragments==='')?10:$xml->milestone_1_fragments)),
            'milestone_1_free_spins'=>max(0,(int)(((string)$xml->milestone_1_free_spins==='')?0:$xml->milestone_1_free_spins)),
            'milestone_2_spins'=>max(0,(int)(((string)$xml->milestone_2_spins==='')?25:$xml->milestone_2_spins)),
            'milestone_2_fragments'=>max(0,(int)(((string)$xml->milestone_2_fragments==='')?25:$xml->milestone_2_fragments)),
            'milestone_2_free_spins'=>max(0,(int)(((string)$xml->milestone_2_free_spins==='')?1:$xml->milestone_2_free_spins)),
            'milestone_3_spins'=>max(0,(int)(((string)$xml->milestone_3_spins==='')?50:$xml->milestone_3_spins)),
            'milestone_3_fragments'=>max(0,(int)(((string)$xml->milestone_3_fragments==='')?50:$xml->milestone_3_fragments)),
            'milestone_3_free_spins'=>max(0,(int)(((string)$xml->milestone_3_free_spins==='')?1:$xml->milestone_3_free_spins)),
            'milestone_4_spins'=>max(0,(int)(((string)$xml->milestone_4_spins==='')?100:$xml->milestone_4_spins)),
            'milestone_4_fragments'=>max(0,(int)(((string)$xml->milestone_4_fragments==='')?100:$xml->milestone_4_fragments)),
            'milestone_4_free_spins'=>max(0,(int)(((string)$xml->milestone_4_free_spins==='')?2:$xml->milestone_4_free_spins)),
            'exchange_enabled'=>(string)$xml->exchange_enabled===''?false:((int)$xml->exchange_enabled===1),
            'show_exchange'=>(string)$xml->show_exchange===''?false:((int)$xml->show_exchange===1),
            'exchange_title'=>trim((string)$xml->exchange_title)!==''?trim((string)$xml->exchange_title):'Canje de Fragmentos',
            'exchange_description'=>trim((string)$xml->exchange_description)!==''?trim((string)$xml->exchange_description):'Convierte tu constancia en recompensas controladas del ecosistema Nexora.',
        );
    }

    private function enforceSpinLimits($username) {
        list($dayStartUtc,$dayEndUtc)=$this->utcDayWindow();
        $daily=max(0,(int)$this->config['daily_spin_limit']);
        if($daily>0) {
            $row=$this->db->query_fetch_single('SELECT COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND account_username=? AND created_at>=? AND created_at<?',array($this->realm,$username,$dayStartUtc,$dayEndUtc));
            if(is_array($row) && (int)$row['total'] >= $daily) throw new \RuntimeException('Ya alcanzaste el límite diario de giros de este reino.');
        }
        $cooldown=max(0,(int)$this->config['cooldown_seconds']);
        if($cooldown>0) {
            $row=$this->db->query_fetch_single('SELECT TOP 1 DATEDIFF(second,created_at,SYSUTCDATETIME()) AS elapsed FROM '.self::SPINS_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND account_username=? ORDER BY id DESC',array($this->realm,$username));
            if(is_array($row) && isset($row['elapsed']) && (int)$row['elapsed'] < $cooldown) {
                $remaining=$cooldown-(int)$row['elapsed'];
                throw new \RuntimeException('El próximo giro estará disponible en '.$remaining.' s.');
            }
        }
    }

    private function eligiblePrizes($username) {
        $prizes=$this->getPrizes(true);
        if(!$prizes) return array();
        list($dayStartUtc,$dayEndUtc)=$this->utcDayWindow();
        $global=array(); $account=array();
        $g=$this->db->query_fetch('SELECT prize_id,COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND created_at>=? AND created_at<? GROUP BY prize_id',array($this->realm,$dayStartUtc,$dayEndUtc));
        if(is_array($g)) foreach($g as $r) $global[(int)$r['prize_id']]=(int)$r['total'];
        $a=$this->db->query_fetch('SELECT prize_id,COUNT_BIG(*) AS total FROM '.self::SPINS_TABLE.' WITH (UPDLOCK,HOLDLOCK) WHERE realm=? AND account_username=? AND created_at>=? AND created_at<? GROUP BY prize_id',array($this->realm,$username,$dayStartUtc,$dayEndUtc));
        if(is_array($a)) foreach($a as $r) $account[(int)$r['prize_id']]=(int)$r['total'];
        $out=array();
        foreach($prizes as $p) {
            $id=(int)$p['id']; $gl=(int)$p['daily_global_limit']; $al=(int)$p['daily_account_limit'];
            if($gl>0 && ($global[$id]??0)>=$gl) continue;
            if($al>0 && ($account[$id]??0)>=$al) continue;
            $out[]=$p;
        }
        return $out;
    }

    private function utcDayWindow() {
        $timezone=date_default_timezone_get();
        try { $localTz=new \DateTimeZone($timezone ?: 'UTC'); }
        catch(\Throwable $e) { $localTz=new \DateTimeZone('UTC'); }
        $startLocal=(new \DateTimeImmutable('today',$localTz))->setTime(0,0,0);
        $endLocal=$startLocal->modify('+1 day');
        $utc=new \DateTimeZone('UTC');
        return array(
            $startLocal->setTimezone($utc)->format('Y-m-d H:i:s'),
            $endLocal->setTimezone($utc)->format('Y-m-d H:i:s'),
        );
    }

    private function assertSpinOnlineCompatibility($username,$walletCfg,$eligiblePrizes) {
        $requiresOffline=is_array($walletCfg) && !empty($walletCfg['config_checkonline']);
        if(!$requiresOffline) {
            $checked=array();
            foreach($eligiblePrizes as $prize) {
                if(($prize['prize_type']??'')!=='credits') continue;
                $configId=(int)($prize['credit_config_id']??0);
                if($configId<1 || isset($checked[$configId])) continue;
                $checked[$configId]=true;
                $cfg=$this->creditConfig($configId,true);
                if(!empty($cfg['config_checkonline'])) { $requiresOffline=true; break; }
            }
        }
        if($requiresOffline && class_exists('common') && (new \common())->accountOnline((string)$username)) {
            throw new \RuntimeException('Desconecta la cuenta del juego antes de girar: una de las billeteras configuradas exige operación offline para proteger el saldo.');
        }
    }

    private function weightedPick($prizes) {
        $total=0;
        foreach($prizes as $p) $total+=max(0,(int)$p['weight']);
        if($total<1) throw new \RuntimeException('La tabla de premios no tiene pesos válidos.');
        $roll=random_int(1,$total); $cursor=0;
        foreach($prizes as $p) {
            $cursor+=max(0,(int)$p['weight']);
            if($roll<=$cursor) return array($p,$roll,$total);
        }
        throw new \RuntimeException('No se pudo resolver el giro.');
    }

    private function pityDue($pityBefore) {
        return !empty($this->config['pity_enabled']) && (int)$this->config['pity_threshold']>0 && ((int)$pityBefore+1)>=(int)$this->config['pity_threshold'];
    }

    private function nextPity($before,$rarity) {
        if(empty($this->config['pity_enabled']) || (int)$this->config['pity_threshold']<1) return 0;
        $min=self::$rarityRanks[$this->config['pity_min_rarity']];
        return $this->rarityRank($rarity)>=$min ? 0 : ((int)$before+1);
    }

    private function rarityRank($rarity) { return self::$rarityRanks[strtolower((string)$rarity)] ?? 1; }

    private function creditConfig($id,$accountOnly=false) {
        if(!\Validator::UnsignedNumber($id) || (int)$id<1) throw new \InvalidArgumentException('Configuración de moneda inválida.');
        $cfg=$this->db->query_fetch_single('SELECT * FROM '.WEBENGINE_CREDITS_CONFIG.' WHERE config_id=?',array((int)$id));
        if(!is_array($cfg)) throw new \RuntimeException('No existe la configuración de moneda seleccionada.');
        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 moneda apunta a una base no permitida.');
        if($accountOnly && !in_array($cfg['config_user_col_id'],array('userid','username','email'),true)) throw new \RuntimeException('La ruleta sólo acepta monedas vinculadas a cuenta, no a personaje.');
        return $cfg;
    }

    private function resolveIdentifier($cfg,$userId,$username) {
        switch((string)$cfg['config_user_col_id']) {
            case 'userid': return (int)$userId;
            case 'username': return (string)$username;
            case 'email':
                $account=(new \Account())->accountInformation((int)$userId);
                if(!is_array($account) || !isset($account[_CLMN_EMAIL_])) throw new \RuntimeException('No se pudo resolver el correo de la cuenta para esta moneda.');
                return (string)$account[_CLMN_EMAIL_];
        }
        throw new \RuntimeException('La moneda no utiliza un identificador de cuenta compatible.');
    }

    private function walletBalance($cfg,$identifier,$lock=false) {
        $target=$this->creditTable($cfg);
        $hint=$lock?' WITH (UPDLOCK,HOLDLOCK)':'';
        $row=$this->db->query_fetch_single('SELECT '.$this->qi($cfg['config_credits_col']).' AS balance FROM '.$target.$hint.' WHERE '.$this->qi($cfg['config_user_col']).'=?',array($identifier));
        if(!is_array($row)) throw new \RuntimeException('No existe la billetera configurada para esta cuenta.');
        return (int)$row['balance'];
    }

    private function walletDebit($cfg,$identifier,$amount) {
        $target=$this->creditTable($cfg); $col=$this->qi($cfg['config_credits_col']); $user=$this->qi($cfg['config_user_col']);
        $affected=$this->db->query_affected('UPDATE '.$target.' SET '.$col.'='.$col.'-? WHERE '.$user.'=? AND '.$col.'>=?',array((int)$amount,$identifier,(int)$amount));
        if($affected!==1) throw new \RuntimeException('No tienes saldo suficiente para este giro.');
    }

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

    private function creditTable($cfg) {
        $database=class_exists('NexoraRealm') ? \NexoraRealm::databaseName($cfg['config_database'],$this->realm) : $cfg['config_database'];
        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,(int)$credits,$transaction,time(),(defined('access')&&access==='admincp')?1:0,substr($module,0,50),class_exists('NexoraSecurity')?\NexoraSecurity::clientIp():($_SERVER['REMOTE_ADDR']??'')));
    }

    private function walletLedger($configId,$identifier,$direction,$amount,$balanceAfter,$source,$reference) {
        if(!class_exists('NexoraRealm') || !\NexoraRealm::platformEnabled()) return;
        $exists=$this->db->query_fetch_single("SELECT name FROM sysobjects WHERE xtype='U' AND name='NEXORA_WALLET_LEDGER'");
        if(!is_array($exists)) 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,class_exists('NexoraSecurity')?\NexoraSecurity::clientIp():($_SERVER['REMOTE_ADDR']??'')));
        if($affected!==1) throw new \RuntimeException('No se pudo registrar el movimiento económico en el ledger.');
    }

    private function validateItemshopItem($itemId) {
        $shop=$this->itemShop();
        $item=$shop->getItem((int)$itemId);
        if(!is_array($item)) throw new \RuntimeException('El ítem seleccionado no existe en Arsenal Nexora.');
        return $item;
    }

    private function itemShop() {
        if(!class_exists('Plugin\\ItemShop\\ItemShop')) {
            $root=__PATH_PLUGINS__.'itemshop/';
            $classFile=$root.'classes/class.itemshop.php';
            if(!is_file($classFile)) throw new \RuntimeException('Arsenal Nexora no está disponible para entregar este premio.');
            if(!defined('__PATH_ITEMSHOP_ROOT__')) define('__PATH_ITEMSHOP_ROOT__',$root);
            if(!defined('__ITEMSHOP_HOME__')) define('__ITEMSHOP_HOME__',__BASE_URL__.'shop/');
            include_once($classFile);
        }
        return new \Plugin\ItemShop\ItemShop($this->realm);
    }

    private function assertCrossDatabaseTransaction($logicalTarget) {
        if(!class_exists('NexoraRealm')) return;
        $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 ruleta requiere Platform y la billetera en la misma instancia SQL para garantizar una transacción atómica.');
    }

    private function assertIdentifier($value) {
        if(!preg_match('/^[A-Za-z0-9_]+$/D',(string)$value)) throw new \RuntimeException('Identificador SQL no permitido.');
    }
    private function qi($value) { $this->assertIdentifier($value); return '['.$value.']'; }
    private function qualifiedDatabase($value) { $this->assertIdentifier($value); return '['.$value.']'; }
}
