using Sandbox;
using Sandbox.Services;
using Sandbox.Network;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;

namespace GravCoin;

public sealed class PlayerTrader : Component
{
	private static PlayerTrader _local;
	public static PlayerTrader Local => _local.IsValid() ? _local : null;
	public static event Action OnReset;
	[Sync] public int ResetCount { get; private set; }

	[Sync] public double Money        { get; private set; } = 1500.0;
	[Sync] public float  CoinsHeld    { get; private set; }
	[Sync] public double TotalProfit  { get; private set; }
	[Sync] public double ManualProfit { get; private set; }
	[Sync] public double BotProfit    { get; private set; }
	[Sync] public int    TotalTrades  { get; private set; }
	[Sync] public int    TotalBotsPurchased { get; private set; }
	[Sync] public int    HotStreak    { get; private set; }
	[Sync] public bool   TutorialDone { get; private set; }
	[Sync] public int    PermanentRank { get; private set; }
	[Sync] public string PlayerName    { get; private set; } = "";
	[Sync] public double LastMinedAmount { get; private set; }
	[Sync] public bool IsCloudSyncInProgress { get; private set; }
	[Sync] public bool HasCloudSyncFatalError { get; private set; }
	[Sync] public string CloudSyncFatalMessage { get; private set; } = "";
	[Sync] public bool ShowMigrationVeteranPopup { get; private set; }
	[Sync] public double LastCloudCompensationReward { get; private set; }
	[Sync] public bool HasReceivedFirstWin { get; private set; }
	[Sync] public bool HasGrantedStarterInfrastructure { get; private set; }
	[Sync] public long WelcomeBoostEndUnix { get; private set; }
	[Sync] public bool ShowFirstWinPopup { get; private set; }
	[Sync] public double FirstWinBonusAmount { get; private set; }

	// ── Heritage ──────────────────────────────────────────────────
	[Sync] public long HeritageCoins       { get; private set; }
	[Sync] public long HeritageTotalEarned { get; private set; }
	[Sync] public int  HeritageGeneration  { get; private set; }

	// ── Personal stats ───────────────────────────────────────────
	[Sync] public double BestTradePnl   { get; private set; }
	[Sync] public double WorstTradePnl  { get; private set; }
	[Sync] public int    BestStreak     { get; private set; }
	[Sync] public double PeakNetWorth   { get; private set; }
	[Sync] public int    TotalPlaySec   { get; private set; }
	[Sync] public int    TradesWon      { get; private set; }
	[Sync] public int    PanicSellCount { get; private set; }
	[Sync] public long   Achievements   { get; private set; }  // bitfield for 64 milestones
	[Sync] public string RecentTradeHistory { get; private set; } = "";
	[Sync] public int RecentTradeRevision { get; private set; }

	// ── Rank-up notification ────────────────────────────────────
	private int _lastKnownRank = -1;
	private int _newRankIndex  = -1;
	public  int NewRankIndex => _newRankIndex;
	public  void DismissNewRank() => _newRankIndex = -1;


	private float _avgBuyPrice;
	private float _lastBuyPrice;
	private bool  _streakEligible; // requires a buy before streak can increment
	private bool  _isDataLoaded;
	private bool  _loadStarted;
	private bool  _isSaving;
	private bool _saveQueued;
	private bool _saveQueuedCritical;
	private string _saveQueuedReason = "queued";
	private bool _hasClaimedCloudCompensation;
	private double _legacyMigrationReward;
	private UpgradeSaveData _pendingUpgradeData;
	private UpgradeSaveData _lastKnownUpgradeData = new();
	private InfrastructureSaveData _pendingInfrastructureData;
	private MissionSaveData _pendingMissionData;
	private InfrastructureSaveData _lastKnownInfrastructureData = new();
	private MissionSaveData _lastKnownMissionData = new();
	private bool _pendingFtueBootstrap;
	private int  _heritageStarterCapitalTier;
	private int  _heritageStarterInfraTier;
	private bool _heritageInsiderBonus;
	private bool _heritageExtendedWelcome;
	private bool _heritageZeroFeeBonus;
	private bool _heritageTradeAmplifier;
	private bool _heritageDailyFortune;
	private bool _heritageBotFarmBonus;
	private float _playTimeAccum;
	private readonly List<TradingBot> _ownedBots = new();
	private CancellationTokenSource _autoSaveCts;
	private RealTimeSince _lastMiningReward = 10;
	private TimeSince     _sinceLastSave    = 0;
	private RealTimeSince _sinceLastTradeAction = 10;
	private RealTimeSince _sinceLastBotPurchase = 10;
	private RealTimeSince _sinceLastRewardClaim = 10;

	private const int AutoSaveIntervalSec = 60;
	private const float MinSaveInterval  = 10f;
	private const float MinTradeActionIntervalSec = 0.12f;
	private const float MinBotActionIntervalSec = 0.20f;
	private const float MinRewardClaimIntervalSec = 0.70f;
	private const int CloudCompensationRetryCount = 4;
	private const int CloudCompensationRetryDelayMs = 3000;
	private const double CloudCompensationMultiplier = 0.75;
	private const double CloudCompensationCap = 10_000_000_000_000_000.0;
	private const string CloudPeakPrimaryStat = "sm_peak_nw";
	private const string CloudPeakFallbackStat = "gravcoin_net_worth";
	private const string CloudCompensationFatalText = "Network error. Could not retrieve your profile. Please restart the game later.";
	private const int WelcomeBoostDurationSec = 15 * 60;
	private const double WelcomeBoostMultiplier = 1.60;
	private const double FirstBigWinBonus = 500.0;
	private const int MaxRecentTrades = 3;


	// ── Offline earnings ─────────────────────────────────────────
	private double _offlineEarnings;
	private int    _offlineSeconds;
	private bool   _showOfflineReport;

	public double OfflineEarnings    => _offlineEarnings;
	public int    OfflineSeconds     => _offlineSeconds;
	public bool   ShowOfflineReport  => _showOfflineReport;
	public void   DismissOfflineReport() => _showOfflineReport = false;

	// ── Login bonus (Home screen) ────────────────────────────────
	private long _lastBonusClaimTs; // legacy compatibility
	private int _dailyStreak;
	private long _dailyLastClaimTs;
	private long _dailyAnchorUnix;
	private int _dailyClockRollbackStrikes;
	private bool _dailyClockTamperDetected;

	public const int DailyRewardCooldownSec = 24 * 60 * 60;
	private const int DailyRewardClockToleranceSec = 120;
	private const double DailyRewardBaseCap = 250_000_000.0;
	private const int SellHopLookbackSec = 30 * 60;
	private const int SellHopCooldownSec = 2 * 60;

	private string _lastServerId = "";
	private long _lastDisconnectTime;
	private long _manualSellCooldownUntilTs;

	public int DailyStreak => _dailyStreak;
	public bool DailyClockTamperDetected => _dailyClockTamperDetected;
	public bool IsManualSellCooldownActive => ManualSellCooldownSecondsLeft > 0;
	public double ManualSellCooldownSecondsLeft
	{
		get
		{
			long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			return Math.Max( 0, _manualSellCooldownUntilTs - now );
		}
	}

	public bool CanClaimLoginBonus => CanClaimDailyReward;
	public double LoginBonusSecondsLeft => DailyRewardSecondsLeft;

	public bool CanClaimDailyReward
	{
		get
		{
			if ( !_isDataLoaded || _dailyClockTamperDetected ) return false;
			return DailyRewardSecondsLeft <= 0;
		}
	}

	public double DailyRewardSecondsLeft
	{
		get
		{
			long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			long today = StartOfUtcDay( now );
			long last = StartOfUtcDay( _dailyLastClaimTs );
			if ( last < today ) return 0;
			long next = today + DailyRewardCooldownSec;
			return Math.Max( 0, next - now );
		}
	}

	// ── Rank System ──────────────────────────────────────────────
	public bool HasWelcomeBoost => WelcomeBoostSecondsLeft > 0;
	public double WelcomeBoostSecondsLeft
	{
		get
		{
			long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			return Math.Max( 0, WelcomeBoostEndUnix - now );
		}
	}

	public double WelcomeBoostMultiplierValue => HasWelcomeBoost ? WelcomeBoostMultiplier : 1.0;

	public double ApplyWelcomeBoostToGain( double amount )
	{
		if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) )
			return 0;

		return amount * WelcomeBoostMultiplierValue;
	}

	public double ApplyWelcomeBoostToTradeRevenue( double revenue, double investedAmount )
	{
		if ( revenue <= 0 || double.IsNaN( revenue ) || double.IsInfinity( revenue ) )
			return 0;

		if ( investedAmount <= 0 || double.IsNaN( investedAmount ) || double.IsInfinity( investedAmount ) )
			return revenue;

		double gain = Math.Max( 0.0, revenue - investedAmount );
		if ( gain <= 0 )
			return revenue;

		return investedAmount + ApplyWelcomeBoostToGain( gain );
	}

	public void DismissFirstWinPopup()
	{
		ShowFirstWinPopup = false;
		FirstWinBonusAmount = 0;
	}

	public static readonly (string Name, string Icon, double MinNW, string Color)[] Ranks = {
	("Intern", "mdi:file-document-outline", 0, "#71717a"),
	("Junior Trader", "mdi:chart-box-outline", 1_000, "#a1a1aa"),
	("Trader", "mdi:briefcase-outline", 5_000, "#60a5fa"),
	("Senior Trader", "mdi:chart-line", 25_000, "#3b82f6"),
	("Analyst", "mdi:magnify", 100_000, "#818cf8"),
	("Manager", "mdi:office-building", 500_000, "#a78bfa"),
	("CFO", "mdi:tie", 2_000_000, "#c084fc"),
	("Wolf of Wall St", "mdi:wolf", 10_000_000, "#f472b6"),
	("Magnate", "mdi:diamond-stone", 50_000_000, "#fb923c"),
	("Tycoon", "mdi:crown", 200_000_000, "#f59e0b"),
	("Legend", "mdi:star", 1_000_000_000, "#fbbf24"),
	("Titan", "mdi:rocket-launch", 3_000_000_000, "#e879f9"),
	("Galactic Legend", "mdi:star-four-points", 10_000_000_000, "#10b981"),
	("Universal Mogul", "mdi:earth", 50_000_000_000, "#2dd4bf"),
	("Quantum Whale", "mdi:atom", 250_000_000_000, "#a78bfa"),
	("Cosmic Deity", "mdi:eye-outline", 1_000_000_000_000, "#f472b6"),
	("Entropy Master", "mdi:chart-bell-curve", 10_000_000_000_000, "#fb7185"),
	("Omnipotent Trader", "mdi:infinity", 100_000_000_000_000, "#ffffff"),
	("Reality Architect", "mdi:satellite-variant", 1_000_000_000_000_000, "#e9d5ff"),
	("Singularity Sovereign", "mdi:flash", 10_000_000_000_000_000, "#bfdbfe"),
	("Multiversal Emperor", "mdi:shield-check-outline", 100_000_000_000_000_000, "#86efac"),
	("Eternal Market God", "mdi:sword-cross", 1_000_000_000_000_000_000, "#fde68a"),
	("Transcendent God", "mdi:brain", 1e20, "#e11d48"),
	("Void Emperor", "mdi:ufo-outline", 1e22, "#7c3aed"),
	("Omega Sovereign", "mdi:skull", 1e24, "#0ea5e9"),
	("Primordial Force", "mdi:explosion", 1e26, "#f97316"),
	("The Absolute", "mdi:fire", 1e28, "#fafafa"),
};

	public static string FmtM( double v )
	{
		return v.ToFormattedString();
	}
	public static string DollarM( double v ) => (v < 0 ? "-$" : "$") + FmtM( Math.Abs( v ) );
	public static string GravM( double v ) => FmtM( v ) + " GRAV";

	public int RankIndex
	{
		get
		{
			return Math.Max( PermanentRank, ComputeRankIndex() );
		}
	}
	private int ComputeRankIndex()
	{
		double nw = NetWorth;
		for ( int i = Ranks.Length - 1; i >= 0; i-- )
			if ( nw >= Ranks[i].MinNW ) return i;
		return 0;
	}
	public string RankName  => Ranks[RankIndex].Name;
	public string RankIcon  => Ranks[RankIndex].Icon;
	public string RankColor => Ranks[RankIndex].Color;
	public double RankProgress
	{
		get
		{
			int idx = RankIndex;
			if ( idx >= Ranks.Length - 1 ) return 1.0;
			double cur = NetWorth - Ranks[idx].MinNW;
			double range = Ranks[idx + 1].MinNW - Ranks[idx].MinNW;
			return range > 0 ? Math.Clamp( cur / range, 0, 1 ) : 1.0;
		}
	}

	// ── Achievement System ───────────────────────────────────────
public static readonly (string Name, string Icon, string Desc)[] AchievementDefs = {
	("First Step", "mdi:shoe-print", "Make your first trade"),
	("Apprentice Trader", "mdi:book-open-page-variant", "Make 10 trades"),
	("Trade Machine", "mdi:cog-outline", "Make 100 trades"),
	("A Thousand Thanks", "mdi:target", "Make 1,000 trades"),
	("First Blood", "mdi:sword-cross", "Win your first profitable trade"),
	("Combo x3", "mdi:fire", "Reach a Hot Streak of 3"),
	("Unstoppable", "mdi:flash", "Reach a Hot Streak of 10"),
	("Millionaire", "mdi:cash-multiple", "Reach $1M net worth"),
	("Ten Million", "mdi:diamond-stone", "Reach $10M net worth"),
	("Billionaire", "mdi:bank", "Reach $1B net worth"),
	("Bot Collector", "mdi:robot-outline", "Own 3 bots at the same time"),
	("Bot Army", "mdi:robot-industrial", "Own 5 bots at the same time"),
	("Survivor", "mdi:shield-check-outline", "Survive a Panic Sell"),
	("Diamond Hands", "mdi:rhombus-outline", "Never panic sell (50+ trades)"),
	("Mining King", "mdi:pickaxe", "Mine 100 blocks"),
	("Top Trader", "mdi:trophy-outline", "Earn $100k total profit"),
	("Living Legend", "mdi:star-four-points", "Earn $10M total profit"),
	("Big Hit", "mdi:explosion", "Earn $10k in a single trade"),
	("Wolf", "mdi:wolf", "Reach the 'Wolf of Wall St' rank"),
	("Dedicated Trader", "mdi:clipboard-text-outline", "Make 500 trades"),
	("Hundred Million", "mdi:cash-fast", "Reach $100M net worth"),
	("Million Profit", "mdi:medal-outline", "Earn $1M total profit"),
	("First T3 Bot", "mdi:brain", "Own an Algo ML bot"),
	("First T4 Bot", "mdi:ufo-outline", "Own a Spatial AI bot"),
	("Full Upgrade", "mdi:tools", "All upgrades at maximum"),
	("10 Hours", "mdi:clock-outline", "Play for 10 hours"),
	("Tycoon Rank", "mdi:crown", "Reach the Tycoon rank"),
};

	// Achievement slug names for s&box Live Services (must match names defined in game config)
	private static readonly string[] AchievementSlugs = {
		"premier_pas", "apprenti_trader", "machine_a_trades", "mille_mercis",
		"premier_sang", "combo_x3", "inarretable",
		"millionnaire", "dix_millions", "milliardaire",
		"collecteur_de_bots", "armee_de_bots",
		"survivant", "diamant_hands", "roi_du_minage",
		"top_trader", "legende_vivante", "gros_coup", "loup",
		"trader_assidu", "cent_millions", "million_de_profit",
		"premier_bot_t3", "premier_bot_t4", "full_upgrade",
		"dix_heures", "rang_tycoon",
	};

	public bool HasAchievement( int id ) => id >= 0 && id < 64 && (Achievements & (1L << id)) != 0;
	private readonly List<int> _newAchievements = new();
	public IReadOnlyList<int> NewAchievements => _newAchievements;
	public void DismissNewAchievements() => _newAchievements.Clear();

	private void TryUnlock( int id )
	{
		if ( id < 0 || id >= 64 ) return;
		long mask = 1L << id;
		if ( (Achievements & mask) != 0 ) return;
		Achievements |= mask;
		_newAchievements.Add( id );

		// Register with s&box Live Services
		if ( id < AchievementSlugs.Length )
		{
			try { Sandbox.Services.Achievements.Unlock( AchievementSlugs[id] ); }
			catch { }
		}
	}

	private int _mineCount;
	public int TotalMinedCount() => _mineCount;

	private void CheckAchievements()
	{
		int netRank = ComputeRankIndex();
		if ( netRank > PermanentRank )
		{
			PermanentRank = netRank;
			RequestSave();
			
			// Force an immediate commit for critical rank updates
		}

		if ( TotalTrades >= 1 )    TryUnlock( 0 );
		if ( TotalTrades >= 10 )   TryUnlock( 1 );
		if ( TotalTrades >= 100 )  TryUnlock( 2 );
		if ( TotalTrades >= 1000 ) TryUnlock( 3 );
		if ( TradesWon >= 1 )      TryUnlock( 4 );
		if ( HotStreak >= 3 )      TryUnlock( 5 );
		if ( HotStreak >= 10 )     TryUnlock( 6 );
		if ( NetWorth >= 1_000_000 )       TryUnlock( 7 );
		if ( NetWorth >= 10_000_000 )      TryUnlock( 8 );
		if ( NetWorth >= 1_000_000_000 )   TryUnlock( 9 );
		int botCount = _ownedBots.Count( b => b.IsValid() );
		if ( botCount >= 3 ) TryUnlock( 10 );
		if ( botCount >= 5 ) TryUnlock( 11 );
		// 12 = panic sell (unlocked in PanicSell)
		if ( TotalTrades >= 50 && PanicSellCount == 0 ) TryUnlock( 13 );
		if ( _mineCount >= 100 )  TryUnlock( 14 );
		if ( TotalProfit >= 100_000 )      TryUnlock( 15 );
		if ( TotalProfit >= 10_000_000 )   TryUnlock( 16 );
		// 17 = big trade (unlocked in SellCoins)
		if ( RankIndex >= 7 )  TryUnlock( 18 ); // Loup
		if ( TotalTrades >= 500 ) TryUnlock( 19 );
		if ( NetWorth >= 100_000_000 )     TryUnlock( 20 );
		if ( TotalProfit >= 1_000_000 )    TryUnlock( 21 );
		if ( _ownedBots.Any( b => b.IsValid() && b.Tier == BotTier.T3 ) ) TryUnlock( 22 );
		if ( _ownedBots.Any( b => b.IsValid() && b.Tier == BotTier.T4 ) ) TryUnlock( 23 );
		var upg = Components.Get<UpgradeSystem>( FindMode.InSelf );
		if ( upg is not null && upg.GasLevel >= 5 && upg.CoolingLevel >= 3
		     && upg.GpuUnlocked && upg.SatelliteUnlocked && upg.InsiderUnlocked
		     && upg.HashLevel >= 3 && upg.RewardLevel >= 3 ) TryUnlock( 24 );
		if ( TotalPlaySec >= 36000 ) TryUnlock( 25 ); // 10h
		if ( RankIndex >= 9 ) TryUnlock( 26 ); // Tycoon


		// ── Rank-up notification ──
		int curRank = RankIndex;
		if ( _lastKnownRank >= 0 && curRank > _lastKnownRank )
		{
			_newRankIndex = curRank;
			if ( WorldEventManager.Instance?.RankUpSound is not null )
				Sound.Play( WorldEventManager.Instance.RankUpSound );
		}
		_lastKnownRank = curRank;
	}
	public bool IsSaving => _isSaving;
	public bool IsDataLoaded => _isDataLoaded;
	public bool CanProcessGameplay() => IsLocalOwner && _isDataLoaded && !IsCloudSyncInProgress && !HasCloudSyncFatalError;
	public bool HasClaimedCloudCompensation => _hasClaimedCloudCompensation;

	public void DismissMigrationVeteranPopup()
	{
		ShowMigrationVeteranPopup = false;
	}

	private float _lastBotCleanup;
	/// <summary>All bots owned by this player (cleaned of destroyed refs lazily).</summary>
	public IReadOnlyList<TradingBot> OwnedBots
	{
		get 
		{ 
			// Only run cleanup periodically to avoid allocating Enumerators/Arrays every frame
			if (Time.Now - _lastBotCleanup > 1.0f)
			{
				_lastBotCleanup = Time.Now;
				for (int i = _ownedBots.Count - 1; i >= 0; i--)
				{
					if (!_ownedBots[i].IsValid()) _ownedBots.RemoveAt(i);
				}
			}
			return _ownedBots; 
		}
	}

	private MarketManager Market   => MarketManager.Instance;
	private UpgradeSystem Upgrades => Components.Get<UpgradeSystem>( FindMode.InSelf );
	private InfrastructureManager Infrastructure => Components.Get<InfrastructureManager>( FindMode.InSelf );
	private MissionManager Missions => Components.Get<MissionManager>( FindMode.InSelf );
	private bool IsLocalOwner => Network.Owner is null || Network.Owner == Connection.Local;

	public double BotLockedCoinValue
	{
		get
		{
			double price = Market?.CurrentPrice ?? 0f;
			if ( price <= 0 || _ownedBots.Count == 0 ) return 0;
			double total = 0;
			for ( int i = 0; i < _ownedBots.Count; i++ )
			{
				var bot = _ownedBots[i];
				if ( !bot.IsValid() ) continue;
				total += bot.HeldCoins * price;
			}
			return total;
		}
	}

	public double InfrastructureAssetValue => Infrastructure?.ComputeAssetValue() ?? 0;
	public double NetWorth => Money + CoinsHeld * (Market?.CurrentPrice ?? 0f) + BotLockedCoinValue;

	// ── Heritage computed ─────────────────────────────────────────
	public int    HeritageLevel            => (int)Math.Floor( Math.Sqrt( (double)HeritageTotalEarned ) );
	public long   HeritageCoinsPreview     => ComputeHeritageCoinsForNetWorth( NetWorth );
	public double HeritageStarterCapital   => _heritageStarterCapitalTier switch
	{
		1 => 500_000_000.0,          // +$500M
		2 => 100_000_000_000.0,      // +$100B
		3 => 10_000_000_000_000.0,   // +$10T
		4 => 500_000_000_000_000.0,  // +$500T
		_ => 0.0
	};
	public int  HeritageStarterCapitalTier => _heritageStarterCapitalTier;
	public int  HeritageStarterInfraTier   => _heritageStarterInfraTier;
	public bool HeritageInsiderBonus       => _heritageInsiderBonus;
	public bool HeritageExtendedWelcome    => _heritageExtendedWelcome;
	public bool HeritageZeroFeeBonus    => _heritageZeroFeeBonus;
	public bool HeritageTradeAmplifier  => _heritageTradeAmplifier;
	public bool HeritageDailyFortune    => _heritageDailyFortune;
	public bool HeritageBotFarmBonus    => _heritageBotFarmBonus;

	/// <summary>Coins earned on reset — tier-based (you earn the coins for your current bracket, not cumulative).</summary>
	public static long ComputeHeritageCoinsForNetWorth( double nw )
	{
		if ( nw < 1e12 ) return 0;   // < $1T
		if ( nw < 1e13 ) return 1;   // $1T – $10T
		if ( nw < 1e14 ) return 3;   // $10T – $100T
		if ( nw < 1e15 ) return 5;   // $100T – $1Qa
		if ( nw < 1e16 ) return 8;   // $1Qa – $10Qa
		if ( nw < 1e18 ) return 12;  // $10Qa – $1Qi
		if ( nw < 1e21 ) return 18;  // $1Qi – $1Sx
		if ( nw < 1e24 ) return 25;  // $1Sx – $1Sp
		return 35;                    // $1Sp+
	}

	protected override void OnStart()
	{
		if ( IsProxy || !IsLocalOwner ) return;
		InitLocal();
	}

	protected override void OnFixedUpdate()
	{
		// Safety net: if OnStart had an IsProxy race on the client, catch it here.
		if ( !IsProxy && IsLocalOwner && _local != this )
			InitLocal();

		if ( !IsProxy && IsLocalOwner )
			ApplyPendingUpgradeDataIfReady();

		if ( !IsProxy && IsLocalOwner )
			ApplyPendingInfrastructureDataIfReady();

		if ( !IsProxy && IsLocalOwner )
			ApplyPendingMissionDataIfReady();

		if ( !IsProxy && IsLocalOwner && _isDataLoaded )
		{
			UpdateDailyClockAnchor();

			// Track play time
			_playTimeAccum += Time.Delta;
			if ( _playTimeAccum >= 1f ) { TotalPlaySec += (int)_playTimeAccum; _playTimeAccum -= (int)_playTimeAccum; }

			// Track peak net worth
			if ( NetWorth > PeakNetWorth ) PeakNetWorth = NetWorth;

			// Check achievements periodically
			CheckAchievements();
		}
	}

	private void InitLocal()
	{
		if ( !IsLocalOwner ) return;
		_local     = this;
		PlayerName = Network.Owner?.DisplayName ?? "Trader";
		if ( Infrastructure is null )
			Components.Create<InfrastructureManager>();
		if ( Missions is null )
			Components.Create<MissionManager>();
		StartAutoSaveLoop();
		if ( !_loadStarted )
		{
			_loadStarted = true;
			Money = PlayerSaveData.StartingMoney;
			_ = LoadFromDataStoreAsync();
		}
	}

	protected override void OnDestroy()
	{
		StopAutoSaveLoop();
		if ( IsProxy || !IsLocalOwner ) return;
		_ = ForceFlushOnExitAsync();
	}

	private void StartAutoSaveLoop()
	{
		if ( _autoSaveCts is not null ) return;
		_autoSaveCts = new CancellationTokenSource();
		_ = AutoSaveLoopAsync( _autoSaveCts.Token );
	}

	private void StopAutoSaveLoop()
	{
		if ( _autoSaveCts is null ) return;
		_autoSaveCts.Cancel();
		_autoSaveCts.Dispose();
		_autoSaveCts = null;
	}

	private async System.Threading.Tasks.Task AutoSaveLoopAsync( CancellationToken token )
	{
		while ( !token.IsCancellationRequested )
		{
			try
			{
				await global::System.Threading.Tasks.Task.Delay( AutoSaveIntervalSec * 1000, token );
			}
			catch ( System.Threading.Tasks.TaskCanceledException )
			{
				break;
			}

			if ( token.IsCancellationRequested ) break;
			if ( IsProxy || !IsLocalOwner || !_isDataLoaded || IsCloudSyncInProgress || HasCloudSyncFatalError ) continue;

			TrySave( waitForBackend: false, reason: "auto_loop" );
		}
	}

	// ── Trading ───────────────────────────────────────────────────────
	private static long StartOfUtcDay( long unixTs )
	{
		if ( unixTs <= 0 ) return 0;
		var dt = DateTimeOffset.FromUnixTimeSeconds( unixTs ).UtcDateTime.Date;
		return new DateTimeOffset( dt, TimeSpan.Zero ).ToUnixTimeSeconds();
	}

	private void UpdateDailyClockAnchor()
	{
		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		if ( now > _dailyAnchorUnix )
			_dailyAnchorUnix = now;

		_dailyClockTamperDetected = now + DailyRewardClockToleranceSec < _dailyAnchorUnix;
	}

	private bool IsClockRollbackDetected( long now )
	{
		return now + DailyRewardClockToleranceSec < _dailyAnchorUnix;
	}

	private static string GetCurrentServerId()
	{
		var host = Connection.Host;
		if ( host != null )
		{
			if ( host.Id != Guid.Empty )
				return host.Id.ToString();

			string hostSteam = host.SteamId.ToString();
			if ( !string.IsNullOrWhiteSpace( hostSteam ) && hostSteam != "0" )
				return hostSteam;
		}

		if ( !string.IsNullOrWhiteSpace( Networking.ServerName ) )
			return $"name:{Networking.ServerName}";

		return "local";
	}

	private void ApplySellCooldownFromServerHop()
	{
		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		string currentServerId = GetCurrentServerId();

		bool changedServer = !string.IsNullOrWhiteSpace( _lastServerId )
			&& !string.Equals( currentServerId, _lastServerId, StringComparison.Ordinal );
		long sinceDisconnect = _lastDisconnectTime > 0 ? now - _lastDisconnectTime : long.MaxValue;
		bool recentDisconnect = sinceDisconnect >= 0 && sinceDisconnect <= SellHopLookbackSec;

		_manualSellCooldownUntilTs = (changedServer && recentDisconnect) ? now + SellHopCooldownSec : 0;
	}

	private double ComputeEconomyBase()
	{
		// Manual economy curve (slightly nerfed to keep a better balance with infrastructure/bots).
		double nw = Math.Max( 100.0, NetWorth );
		double earlyBurst = 320.0 * (1.0 - Math.Exp( -nw / 18_000.0 ));
		double coreScale = 0.50 * Math.Pow( nw, 0.54 );
		double midScale = 0.0086 * Math.Pow( nw, 0.76 );
		double lateSoftCap = 1.0 / (1.0 + 0.22 * Math.Pow( nw / 100_000_000_000_000_000.0, 0.37 ));
		double baseValue = (72.0 + earlyBurst + coreScale + midScale) * lateSoftCap;
		return Math.Max( 95.0, baseValue );
	}

	public double EstimateMiningReward( int tierIndex )
	{
		double multiplier = tierIndex switch
		{
			0 => 0.240,
			1 => 0.450,
			2 => 0.780,
			3 => 1.200,
			4 => 1.900,
			_ => 0.240
		};

		double reward = ComputeEconomyBase() * multiplier;
		double floor = Upgrades?.MiningReward ?? 95.0;
		return Math.Round( ApplyWelcomeBoostToGain( Math.Max( reward, floor ) ), 2 );
	}

	public double EstimateRelayReward( float timeRatio )
	{
		double minMult = 0.500;
		double maxMult = 2.200;
		double multiplier = minMult + (maxMult - minMult) * Math.Clamp( timeRatio, 0, 1 );
		double reward = ComputeEconomyBase() * multiplier;
		double floor = (Upgrades?.MiningReward ?? 95.0) * (1.0 + Math.Clamp( timeRatio, 0, 1 ) * 2.0);
		return Math.Round( ApplyWelcomeBoostToGain( Math.Max( reward, floor ) ), 2 );
	}

	public double EstimateDailyReward()
	{
		double economyBase = ComputeEconomyBase();
		// Start with 400 base and stronger economy scaling
		double baseReward = 400.0 + Math.Pow( economyBase, 1.12 ) * 0.40;
		double dynamicCap = Math.Max( DailyRewardBaseCap, NetWorth * 0.0045 );
		baseReward = Math.Min( baseReward, dynamicCap );
		
		int nextStreak = Math.Clamp( _dailyStreak + 1, 1, 7 );
		// Day 1 starts at x1.3 multiplier
		double streakMult = 1.3 + (nextStreak - 1) * 0.15;
		if ( nextStreak >= 7 ) streakMult += 0.25;
		double result = Math.Round( baseReward * streakMult, 2 );
		if ( _heritageDailyFortune ) result *= 2.0;
		return result;
	}

	[Rpc.Owner]
	public void CmdBuyBot( BotTier tier )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( _sinceLastBotPurchase < MinBotActionIntervalSec ) return;
		_sinceLastBotPurchase = 0;
		if ( (int)tier < 1 || (int)tier > 4 ) return;

		_ownedBots.RemoveAll( b => !b.IsValid() );
		int maxSlots = 1 + (Upgrades?.ExtraBotSlots ?? 0);
		if ( _ownedBots.Count >= maxSlots ) return;

		double cost = TradingBot.GetCost( tier );
		if ( Money < cost ) return;

		Money -= cost;
		var go = new GameObject( true, $"Bot_{tier}" );
		go.SetParent( GameObject );
		var bot = go.Components.Create<TradingBot>();
		bot.Tier = tier; bot.IsActive = true;
		bot.Quality = Random.Shared.Next( 10, 51 );
		bot.MaxFuelSeconds = TradingBot.GetBaseAutonomy( tier );
		bot.FuelSeconds = bot.MaxFuelSeconds;
		go.NetworkSpawn();
		_ownedBots.Add( bot );
		TotalBotsPurchased++;
		TrySave( waitForBackend: true, reason: "buy_bot" );
	}

	public bool BuyCoins( double usdAmount )
	{
		if ( IsProxy || !CanProcessGameplay() || Market is null ) return false;
		if ( _sinceLastTradeAction < MinTradeActionIntervalSec ) return false;
		_sinceLastTradeAction = 0;
		if ( double.IsNaN( usdAmount ) || double.IsInfinity( usdAmount ) ) return false;
		usdAmount = Math.Min( usdAmount, Money );
		if ( usdAmount <= 0 ) return false;

		float  price      = Market.CurrentPrice;
		if ( price <= 0 || float.IsNaN( price ) || float.IsInfinity( price ) ) return false;
		// No fee on buy — fees are sell-only
		float  coins      = (float)(usdAmount / price);
		double spent      = usdAmount;
		float  totalCoins = CoinsHeld + coins;
		_avgBuyPrice      = totalCoins > 0
			? (CoinsHeld * _avgBuyPrice + coins * price) / totalCoins : price;
		_lastBuyPrice     = price;

		Money -= spent; CoinsHeld += coins;
		PushRecentTradeHistory( isBuy: true, coins, price );
		Market.AddGlobalVolume( (float)spent );
		_streakEligible = true;
		TrySave( waitForBackend: true, reason: "buy_coins" );

		if ( WorldEventManager.Instance?.BuySound is not null )
			Sound.Play( WorldEventManager.Instance.BuySound );

		ChatSystem.Instance?.SendTradeInfo( true, coins, price );
		return true;
	}

	public bool SellCoins( float coins, bool isBot = false )
	{
		if ( IsProxy || !CanProcessGameplay() || Market is null || CoinsHeld <= 0 ) return false;
		if ( !isBot )
		{
			if ( IsManualSellCooldownActive ) return false;
			if ( _sinceLastTradeAction < MinTradeActionIntervalSec ) return false;
			_sinceLastTradeAction = 0;
		}
		if ( float.IsNaN( coins ) || float.IsInfinity( coins ) ) return false;
		coins = Math.Min( coins, CoinsHeld );
		if ( coins <= 0 ) return false;

		float  price   = Market.CurrentPrice;
		if ( price <= 0 || float.IsNaN( price ) || float.IsInfinity( price ) ) return false;
		
		// Manual Trade Tax Increase: Managers (Index 5+) pay 5% base tax instead of market standard
		float baseTax = Market.TaxPercent;
		if ( !isBot && RankIndex >= 5 )
		{
			baseTax = 4.0f;
		}

		float taxRate = isBot || _heritageZeroFeeBonus ? 0f : (baseTax / 100f * (1f - (Upgrades?.GasReduction ?? 0f)));
		double revenue = coins * price * (1f - taxRate);
		double cost    = coins * _avgBuyPrice;
		bool   profit  = revenue > cost;
		bool   streakProfit = price > _lastBuyPrice;
		bool   streakBreak  = price < _lastBuyPrice;

		if ( !isBot )
		{
			if ( streakBreak ) HotStreak = 0;
			else if ( _streakEligible && streakProfit ) HotStreak++;
		}

		if ( profit )
		{
			if ( !isBot )
			{
				TradesWon++;
				// Progressive hot streak bonus: 3=+5%, 5=+12%, 10=+20%
				double streakBonus = HotStreak >= 10 ? 1.20
				                   : HotStreak >= 5  ? 1.12
				                   : HotStreak >= 3  ? 1.05 : 1.0;
				revenue *= streakBonus;
			}
			else
			{
				TradesWon++;
			}
		}
		if ( !isBot ) _streakEligible = false;

		if ( HotStreak > BestStreak ) BestStreak = HotStreak;

		double creditedRevenue = ApplyWelcomeBoostToTradeRevenue( revenue, cost );
		if ( !isBot && profit && _heritageTradeAmplifier )
			creditedRevenue += Math.Max( 0, creditedRevenue - cost ) * 0.20;
		double pnl = creditedRevenue - cost;
		TotalProfit += pnl;
		if ( isBot ) BotProfit += pnl;
		else ManualProfit += pnl;

		if ( pnl > BestTradePnl )  BestTradePnl = pnl;
		if ( pnl < WorstTradePnl ) WorstTradePnl = pnl;
		if ( pnl >= 10_000 ) TryUnlock( 17 ); // "Gros Coup" achievement
		if ( !isBot && pnl > 0 )
			TryAwardFirstBigWinBonusIfNeeded();

		TotalTrades++; CoinsHeld -= coins; Money += creditedRevenue;
		if ( !isBot )
		{
			PushRecentTradeHistory( isBuy: false, coins, price );
		}
		Market.AddGlobalVolume( (float)revenue );
		TrySave( waitForBackend: true, reason: "sell_coins" );

		if ( WorldEventManager.Instance?.SellSound is not null )
			Sound.Play( WorldEventManager.Instance.SellSound );

		if ( !isBot ) ChatSystem.Instance?.SendTradeInfo( false, coins, price );
		return true;
	}

	private void PushRecentTradeHistory( bool isBuy, float quantity, float price )
	{
		if ( quantity <= 0f || price <= 0f ) return;

		var entries = string.IsNullOrWhiteSpace( RecentTradeHistory )
			? new List<string>()
			: RecentTradeHistory.Split( ';', StringSplitOptions.RemoveEmptyEntries ).ToList();

		string entry = string.Join( "|",
			isBuy ? "B" : "S",
			quantity.ToString( "R", CultureInfo.InvariantCulture ),
			price.ToString( "R", CultureInfo.InvariantCulture ) );

		entries.Insert( 0, entry );

		if ( entries.Count > MaxRecentTrades )
		{
			entries.RemoveRange( MaxRecentTrades, entries.Count - MaxRecentTrades );
		}

		RecentTradeHistory = string.Join( ";", entries );
		RecentTradeRevision++;
	}

	public void TradeCoins( float amount )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( float.IsNaN( amount ) || float.IsInfinity( amount ) ) return;
		CoinsHeld = Math.Max( 0f, CoinsHeld + amount );
	}

	public void PanicSell()
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		PanicSellCount++;
		TryUnlock( 12 ); // "Survivant" achievement
		SellCoins( CoinsHeld );
		foreach ( var bot in _ownedBots )
			if ( bot.IsValid() ) bot.IsActive = false;
	}

	public bool DeductMoney( double amount )
	{
		if ( IsProxy || !CanProcessGameplay() ) return false;
		if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) ) return false;
		if ( Money < amount ) return false;
		Money -= amount;
		return true;
	}

	public void AddMoney( double amount )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) ) return;
		Money += amount;
		RequestSave();
	}

	public void RequestSave()
	{
		if ( !IsLocalOwner ) return;
		if ( !_isDataLoaded || IsCloudSyncInProgress || HasCloudSyncFatalError ) return;
		TrySave( waitForBackend: false, reason: "manual" );
	}

	public void RequestCriticalSave( string reason = "critical" )
	{
		if ( !IsLocalOwner ) return;
		if ( !_isDataLoaded || IsCloudSyncInProgress || HasCloudSyncFatalError ) return;
		TrySave( waitForBackend: true, reason: reason );
	}

	/// <summary>
	/// Called by UpgradeSystem immediately after committing an upgrade so that
	/// _lastKnownUpgradeData stays current even if the subsequent save is delayed
	/// (e.g. another save is in-flight) or if UpgradeSystem is destroyed before
	/// ForceFlushOnExitAsync can call Upgrades.ExportSaveData().
	/// </summary>
	public void NotifyUpgradeCommitted( UpgradeSaveData snapshot )
	{
		if ( snapshot is null ) return;
		_lastKnownUpgradeData = CloneUpgradeSaveData( snapshot );
	}

	private void ResetProfileCore( bool forceSaveAfterReset )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;

		// ── Award heritage coins based on net worth before wipe ───
		long coinsEarned = ComputeHeritageCoinsForNetWorth( NetWorth );
		HeritageGeneration++;
		if ( coinsEarned > 0 )
		{
			HeritageTotalEarned += coinsEarned;
			HeritageCoins       += coinsEarned;
		}

		// Core balances & trading stats
		Money        = PlayerSaveData.StartingMoney;
		CoinsHeld    = 0f;
		_avgBuyPrice = 0f;

		TotalProfit  = 0;
		ManualProfit = 0;
		BotProfit    = 0;
		TotalTrades  = 0;
		TotalBotsPurchased = 0;
		TradesWon    = 0;

		HotStreak    = 0;
		BestStreak   = 0;
		BestTradePnl  = 0;
		WorstTradePnl = 0;

		PeakNetWorth   = 0;
		TotalPlaySec   = 0;
		PanicSellCount = 0;

		// Tutorial / achievements / mining
		TutorialDone  = false;
		PermanentRank = 0;
		Achievements  = 0;
		_mineCount   = 0;
		_newAchievements.Clear();
		_lastKnownRank = -1;
		_newRankIndex  = -1;

		// Offline earnings / bonus
		_offlineEarnings  = 0;
		_offlineSeconds   = 0;
		_showOfflineReport = false;
		_lastBonusClaimTs = 0;
		_dailyStreak = 0;
		_dailyLastClaimTs = 0;
		_dailyAnchorUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		_dailyClockRollbackStrikes = 0;
		_dailyClockTamperDetected = false;
		HasReceivedFirstWin = false;
		HasGrantedStarterInfrastructure = false;
		WelcomeBoostEndUnix = 0;
		ShowFirstWinPopup = false;
		FirstWinBonusAmount = 0;
		_pendingFtueBootstrap = false;
		_pendingMissionData = null;

		// Destroy all owned bots
		foreach ( var bot in _ownedBots.ToArray() )
		{
			if ( bot.IsValid() )
				bot.GameObject.Destroy();
		}
		_ownedBots.Clear();

		// Reset all upgrades
		Upgrades?.ResetUpgrades();
		Infrastructure?.ResetInfrastructure();
		Missions?.ResetMissions( requestSave: false );

		// NUCLEAR RESET: Clear every stat
		Money = PlayerSaveData.StartingMoney;
		CoinsHeld = 0;
		TotalProfit = 0;
		ManualProfit = 0;
		BotProfit = 0;
		TotalTrades = 0;
		TotalBotsPurchased = 0;
		TradesWon = 0;
		BestTradePnl = 0;
		WorstTradePnl = 0;
		BestStreak = 0;
		PeakNetWorth = 0;
		PanicSellCount = 0;
		_mineCount = 0;
		TutorialDone = false;
		PermanentRank = 0;
		_avgBuyPrice = 0;
		_lastBonusClaimTs = 0;
		_dailyStreak = 0;
		_dailyLastClaimTs = 0;
		_dailyAnchorUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		_dailyClockRollbackStrikes = 0;
		_dailyClockTamperDetected = false;
		_lastServerId = GetCurrentServerId();
		_lastDisconnectTime = 0;
		_manualSellCooldownUntilTs = 0;
		_hasClaimedCloudCompensation = true;
		_legacyMigrationReward = 0;
		ShowMigrationVeteranPopup = false;
		LastCloudCompensationReward = 0;
		HasReceivedFirstWin = false;
		HasGrantedStarterInfrastructure = false;
		WelcomeBoostEndUnix = 0;
		ShowFirstWinPopup = false;
		FirstWinBonusAmount = 0;
		_pendingFtueBootstrap = false;
		_pendingMissionData = null;

		// ── Apply heritage bonuses immediately ────────────────────
		double heritageCapital = HeritageStarterCapital;
		if ( heritageCapital > 0 )
			Money += heritageCapital;

		// Trigger FTUE bootstrap now so starter infra + welcome boost apply immediately
		_pendingFtueBootstrap = true;
		TryRunFtueBootstrap();

		// Apply upgrade-based heritage bonuses after reset
		if ( _heritageInsiderBonus )  Upgrades?.GrantHeritageInsider();
		if ( _heritageZeroFeeBonus )  Upgrades?.GrantHeritageMaxVpn();
		if ( _heritageBotFarmBonus )  Upgrades?.GrantHeritageBotFarm();

		ResetCount++;
		OnReset?.Invoke();

		if ( !forceSaveAfterReset )
			return;

		// Persist reset state to local Steam Cloud storage
		_isSaving = false; // Bypass guard if we just finished a save
		TrySave( waitForBackend: true, reason: "nuclear_reset" );
	}

	/// <summary>Reset all player stats and progression back to a fresh profile.</summary>
	[Rpc.Owner]
	public void CmdResetProfile()
	{
		ResetProfileCore( forceSaveAfterReset: true );
	}

	/// <summary>Reset all player stats and progression without forcing an immediate save.</summary>
	[Rpc.Owner]
	public void CmdResetProfileNoSave()
	{
		ResetProfileCore( forceSaveAfterReset: false );
	}

	/// <summary>Purchase a heritage bonus with spendable heritage coins.</summary>
	[Rpc.Owner]
	public void CmdBuyHeritageBonus( int bonusId )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		bool changed = false;
		switch ( bonusId )
		{
			case 1: // Capital I — 1 coin (+$500M)
				if ( _heritageStarterCapitalTier < 1 && HeritageCoins >= 1 )
				{
					double prev = HeritageStarterCapital;
					HeritageCoins -= 1; _heritageStarterCapitalTier = 1; changed = true;
					Money += HeritageStarterCapital - prev;
				}
				break;
			case 2: // Capital II — 4 coins (+$100B) [req I]
				if ( _heritageStarterCapitalTier == 1 && HeritageCoins >= 4 )
				{
					double prev = HeritageStarterCapital;
					HeritageCoins -= 4; _heritageStarterCapitalTier = 2; changed = true;
					Money += HeritageStarterCapital - prev;
				}
				break;
			case 3: // Capital III — 14 coins (+$10T) [req II]
				if ( _heritageStarterCapitalTier == 2 && HeritageCoins >= 14 )
				{
					double prev = HeritageStarterCapital;
					HeritageCoins -= 14; _heritageStarterCapitalTier = 3; changed = true;
					Money += HeritageStarterCapital - prev;
				}
				break;
			case 4: // Capital IV — 30 coins (+$500T) [req III]
				if ( _heritageStarterCapitalTier == 3 && HeritageCoins >= 30 )
				{
					double prev = HeritageStarterCapital;
					HeritageCoins -= 30; _heritageStarterCapitalTier = 4; changed = true;
					Money += HeritageStarterCapital - prev;
				}
				break;
			case 5: // Starter Infra I — 2 coins (Pi Cluster + GPU Rig)
				if ( _heritageStarterInfraTier < 1 && HeritageCoins >= 2 )
				{
					HeritageCoins -= 2; _heritageStarterInfraTier = 1; changed = true;
					Infrastructure?.EnsureStarterMachineOwned( 1, requestSave: false );
				}
				break;
			case 6: // Starter Infra II — 6 coins (+ Edge Rack + Modular DC) [req I]
				if ( _heritageStarterInfraTier == 1 && HeritageCoins >= 6 )
				{
					HeritageCoins -= 6; _heritageStarterInfraTier = 2; changed = true;
					Infrastructure?.EnsureStarterMachineOwned( 2, requestSave: false );
					Infrastructure?.EnsureStarterMachineOwned( 3, requestSave: false );
				}
				break;
			case 7: // Insider Access — 3 coins
				if ( !_heritageInsiderBonus && HeritageCoins >= 3 )
				{
					HeritageCoins -= 3; _heritageInsiderBonus = true; changed = true;
					Upgrades?.GrantHeritageInsider();
				}
				break;
			case 8: // Welcome Overdrive — 2 coins (30-min boost)
				if ( !_heritageExtendedWelcome && HeritageCoins >= 2 )
				{
					HeritageCoins -= 2; _heritageExtendedWelcome = true; changed = true;
					// If a welcome boost is currently active, extend it by the extra 15 min
					long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
					if ( WelcomeBoostEndUnix > now )
						WelcomeBoostEndUnix += WelcomeBoostDurationSec;
				}
				break;
			case 9: // Zero Network Fees — 8 coins (max VPN from start)
				if ( !_heritageZeroFeeBonus && HeritageCoins >= 8 )
				{
					HeritageCoins -= 8; _heritageZeroFeeBonus = true; changed = true;
					Upgrades?.GrantHeritageMaxVpn();
				}
				break;
			case 10: // Trade Amplifier — 10 coins (+20% manual trade profits) — flag-based, active immediately
				if ( !_heritageTradeAmplifier && HeritageCoins >= 10 )
				{ HeritageCoins -= 10; _heritageTradeAmplifier = true; changed = true; }
				break;
			case 11: // Daily Fortune — 6 coins (daily rewards ×2) — flag-based, active immediately
				if ( !_heritageDailyFortune && HeritageCoins >= 6 )
				{ HeritageCoins -= 6; _heritageDailyFortune = true; changed = true; }
				break;
			case 12: // Bot Farm — 6 coins (start with Server Farm Lv2)
				if ( !_heritageBotFarmBonus && HeritageCoins >= 6 )
				{
					HeritageCoins -= 6; _heritageBotFarmBonus = true; changed = true;
					Upgrades?.GrantHeritageBotFarm();
				}
				break;
		}
		if ( changed )
			TrySave( waitForBackend: true, reason: "heritage_bonus" );
	}

	/// <summary>Track bot P/L without going through SellCoins.</summary>
	public void TrackBotProfit( double pnl, string sourceLabel = "BOT PROFIT" )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		TotalProfit += pnl;
		BotProfit   += pnl;

		if ( pnl > 0 )
			NodeConsoleTickerFeed.PushLog( sourceLabel, $"+{DollarM( pnl )}", true );
	}

	/// <summary>Track passive infrastructure income without categorizing it as manual/bot trading.</summary>
	public void TrackPassiveIncome( double amount ) => TrackPassiveIncome( amount, "PASSIVE INCOME" );

	public void TrackPassiveIncome( double amount, string sourceLabel )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) ) return;
		TotalProfit += amount;
		NodeConsoleTickerFeed.PushLog( sourceLabel, $"+{DollarM( amount )}", true );
	}

	public void RemoveBot( TradingBot bot )
	{
		_ownedBots.Remove( bot );
	}

	[Rpc.Owner]
	public void CmdClaimLoginBonus()
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		CmdClaimDailyReward();
	}

	[Rpc.Owner]
	public void CmdClaimDailyReward()
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( _sinceLastRewardClaim < MinRewardClaimIntervalSec ) return;
		_sinceLastRewardClaim = 0;

		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		if ( IsClockRollbackDetected( now ) )
		{
			_dailyClockRollbackStrikes++;
			_dailyClockTamperDetected = true;
			RequestSave();
			return;
		}

		long today = StartOfUtcDay( now );
		long lastClaimDay = StartOfUtcDay( _dailyLastClaimTs );
		if ( lastClaimDay >= today ) return;

		long dayDiff = lastClaimDay > 0 ? (today - lastClaimDay) / DailyRewardCooldownSec : 1;
		if ( dayDiff == 1 )
			_dailyStreak = Math.Clamp( _dailyStreak + 1, 1, 3650 );
		else
			_dailyStreak = 1;

		double amount = EstimateDailyReward();
		Money += amount;
		_lastBonusClaimTs = now;
		_dailyLastClaimTs = now;
		_dailyAnchorUnix = Math.Max( _dailyAnchorUnix, now );
		RequestCriticalSave( "daily_reward" );
	}

	[Rpc.Owner]
	public void CmdCompleteTutorial()
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		TutorialDone = true;
		TrySave( waitForBackend: true, reason: "tutorial" );
	}

	[Rpc.Owner]
	public void CmdDebugAddMoney( double amount )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		Money += amount;
	}

	[Rpc.Owner]
	public void CmdClaimMiningReward( int tierIndex, bool isClassic = false )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( _lastMiningReward < 1.0f ) return; // Min 1s between block claims
		_lastMiningReward = 0;
		tierIndex = Math.Clamp( tierIndex, 0, 4 );
		double reward = EstimateMiningReward( tierIndex );
		ApplyMiningReward( reward );
	}

	[Rpc.Owner]
	public void CmdClaimRelayReward( float timeRatio )
	{
		if ( IsProxy || !CanProcessGameplay() ) return;
		if ( _sinceLastRewardClaim < MinRewardClaimIntervalSec ) return;
		_sinceLastRewardClaim = 0;
		double reward = EstimateRelayReward( Math.Clamp( timeRatio, 0, 1 ) );
		ApplyMiningReward( reward );
	}

	private void ApplyMiningReward( double amount )
	{
		if ( double.IsNaN( amount ) || double.IsInfinity( amount ) ) return;

		// Floor reward based on upgrades or minimum starter payout
		double floor = ApplyWelcomeBoostToGain( Upgrades?.MiningReward ?? 95.0 );
		amount = Math.Max( amount, floor );

		Money += amount;
		ManualProfit += amount;
		TotalProfit += amount;
		LastMinedAmount = amount;
		_mineCount++;
		bool awardedFirstBigWin = TryAwardFirstBigWinBonusIfNeeded( includeInLastMinedAmount: true );
		if ( awardedFirstBigWin )
			TrySave( waitForBackend: true, reason: "first_big_win" );
		else
			RequestSave();

		if ( WorldEventManager.Instance?.MiningRewardSound is not null )
			Sound.Play( WorldEventManager.Instance.MiningRewardSound );
	}

	private bool TryAwardFirstBigWinBonusIfNeeded( bool includeInLastMinedAmount = false )
	{
		if ( HasReceivedFirstWin ) return false;

		HasReceivedFirstWin = true;
		double bonusAmount = FirstBigWinBonus;
		FirstWinBonusAmount = bonusAmount;
		ShowFirstWinPopup = true;

		Money += bonusAmount;
		ManualProfit += bonusAmount;
		TotalProfit += bonusAmount;
		if ( includeInLastMinedAmount )
			LastMinedAmount += bonusAmount;

		return true;
	}


	// Persistence (FileSystem.Data / Steam Cloud)
	// Keep aligned with UpgradeSystem.MaxBots max (base 1 + cooling bonus 6 = 7).
	private const int MaxBotSlots = 7;

	private async System.Threading.Tasks.Task LoadFromDataStoreAsync()
	{
		if ( !IsLocalOwner )
		{
			_isDataLoaded = true;
			return;
		}

		try
		{
			bool shouldSaveAfterLoad = false;
			var load = PlayerSaveStore.Load();
			PlayerSaveData saveData = load.Data ?? new PlayerSaveData();

			if ( !load.IsValid )
			{
				saveData = new PlayerSaveData();
				shouldSaveAfterLoad = true;
			}
			else if ( !load.HasFile || load.HashMismatch )
			{
				// Re-save immediately: either first run, or hash mismatch from a schema migration
				// (new fields added). This updates the stored hash to match the current schema.
				shouldSaveAfterLoad = true;
			}

			saveData.HasMigrated = true;
			IsCloudSyncInProgress = false;
			HasCloudSyncFatalError = false;
			CloudSyncFatalMessage = "";
			ShowMigrationVeteranPopup = false;
			LastCloudCompensationReward = 0;

			if ( !saveData.HasClaimedCloudCompensation )
			{
				IsCloudSyncInProgress = true;

				var cloudResult = await TryReadLegacyPeakNetWorthAsync();
				IsCloudSyncInProgress = false;
				if ( !cloudResult.Success )
				{
					HasCloudSyncFatalError = true;
					CloudSyncFatalMessage = CloudCompensationFatalText;
					_isDataLoaded = false;
					return;
				}

				double peak = Math.Max( 0.0, cloudResult.PeakNetWorth );
				double rewardRaw = peak * CloudCompensationMultiplier;
				double reward = peak > 0
					? Math.Min( rewardRaw, CloudCompensationCap )
					: 0.0;

				if ( reward > 0 )
				{
					// Veteran flow: balance must be exactly the migration reward (no starter cash).
					saveData.Money = reward;
					saveData.LegacyMigrationReward = reward;
					LastCloudCompensationReward = reward;
					saveData.HasCompletedTutorial = true;
					saveData.TutorialDone = true;
					ShowMigrationVeteranPopup = true;
				}
				else
				{
					saveData.HasCompletedTutorial = saveData.HasCompletedTutorial || saveData.TutorialDone;
					saveData.TutorialDone = saveData.HasCompletedTutorial;
				}

				saveData.HasClaimedCloudCompensation = true;
				saveData.HasMigrated = true;
				shouldSaveAfterLoad = true;
			}

			ApplySaveData( saveData );
			_isDataLoaded = true;

			if ( shouldSaveAfterLoad )
				TrySave( waitForBackend: true, reason: "post_load" );
		}
		catch
		{
			IsCloudSyncInProgress = false;
			HasCloudSyncFatalError = true;
			CloudSyncFatalMessage = CloudCompensationFatalText;
			_isDataLoaded = false;
		}
	}

	private async System.Threading.Tasks.Task<(bool Success, double PeakNetWorth)> TryReadLegacyPeakNetWorthAsync()
	{
		for ( int attempt = 1; attempt <= CloudCompensationRetryCount; attempt++ )
		{
			try
			{
				await Stats.LocalPlayer.Refresh();
				double peakPrimary = Stats.LocalPlayer.Get( CloudPeakPrimaryStat ).LastValue;
				double peakFallback = Stats.LocalPlayer.Get( CloudPeakFallbackStat ).LastValue;
				double peakNetWorth = Math.Max( peakPrimary, peakFallback );
				if ( double.IsNaN( peakNetWorth ) || double.IsInfinity( peakNetWorth ) )
					throw new Exception( "Invalid cloud stat payload for peak net worth." );

				peakNetWorth = Math.Max( 0.0, peakNetWorth );
				return (true, peakNetWorth);
			}
			catch
			{
				if ( attempt < CloudCompensationRetryCount )
					await global::System.Threading.Tasks.Task.Delay( CloudCompensationRetryDelayMs );
			}
		}

		return (false, 0.0);
	}

	private void ApplySaveData( PlayerSaveData data )
	{
		data ??= new PlayerSaveData();
		data.HasMigrated = true;

		Money = Math.Max( 0.0, data.Money );
		CoinsHeld = Math.Max( 0f, data.CoinsHeld );
		_avgBuyPrice = Math.Max( 0f, data.AvgBuyPrice );
		TotalProfit = data.TotalProfit;
		ManualProfit = data.ManualProfit;
		BotProfit = data.BotProfit;
		TotalTrades = Math.Max( 0, data.TotalTrades );
		TotalBotsPurchased = Math.Max( 0, data.TotalBotsPurchased );

		BestTradePnl = data.BestTradePnl;
		WorstTradePnl = data.WorstTradePnl;
		BestStreak = Math.Max( 0, data.BestStreak );
		PeakNetWorth = data.PeakNetWorth;
		TotalPlaySec = Math.Max( 0, data.TotalPlaySec );
		TradesWon = Math.Max( 0, data.TradesWon );
		PanicSellCount = Math.Max( 0, data.PanicSellCount );
		Achievements = data.Achievements;
		_mineCount = Math.Max( 0, data.MineCount );
		_lastServerId = data.LastServerID ?? "";
		_lastDisconnectTime = Math.Max( 0, data.LastDisconnectTime );

		data.HasCompletedTutorial = data.HasCompletedTutorial || data.TutorialDone;
		TutorialDone = data.HasCompletedTutorial;
		PermanentRank = Math.Max( Math.Max( 0, data.PermanentRank ), ComputeRankIndex() );
		_hasClaimedCloudCompensation = data.HasClaimedCloudCompensation;
		_legacyMigrationReward = Math.Max( 0.0, data.LegacyMigrationReward );
		HasReceivedFirstWin = data.HasReceivedFirstWin;
		HasGrantedStarterInfrastructure = data.HasGrantedStarterInfrastructure;
		WelcomeBoostEndUnix = Math.Max( 0, data.WelcomeBoostEndUnix );
		ShowFirstWinPopup = false;
		FirstWinBonusAmount = 0;

		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		_lastBonusClaimTs = data.LastBonusClaimTs > 0 ? data.LastBonusClaimTs : 0;

		bool hasDailyPayload = data.DailyStreak > 0 || data.DailyLastClaimTs > 0 || !string.IsNullOrWhiteSpace( data.DailyRewardHash );
		bool dailyIntegrityOk = !hasDailyPayload
			|| DailyRewardSecurity.Verify(
				data.DailyStreak,
				data.DailyLastClaimTs,
				data.DailyAnchorUnix,
				data.DailyClockRollbackStrikes,
				PlayerName,
				data.DailyRewardHash );

		if ( dailyIntegrityOk )
		{
			_dailyStreak = Math.Max( 0, data.DailyStreak );
			_dailyLastClaimTs = Math.Max( 0, data.DailyLastClaimTs );
			_dailyAnchorUnix = Math.Max( Math.Max( 0, data.DailyAnchorUnix ), Math.Max( 0, data.LastSeenUnix ) );
			_dailyClockRollbackStrikes = Math.Max( 0, data.DailyClockRollbackStrikes );
		}
		else
		{
			_dailyStreak = 0;
			_dailyLastClaimTs = 0;
			_dailyAnchorUnix = Math.Max( 0, data.LastSeenUnix );
			_dailyClockRollbackStrikes = Math.Max( 1, data.DailyClockRollbackStrikes + 1 );
		}

		if ( _dailyAnchorUnix <= 0 )
			_dailyAnchorUnix = now;

		_dailyClockTamperDetected = !dailyIntegrityOk || IsClockRollbackDetected( now );
		ApplySellCooldownFromServerHop();

		_pendingUpgradeData = data.Upgrades ?? new UpgradeSaveData();
		_lastKnownUpgradeData = CloneUpgradeSaveData( _pendingUpgradeData );
		ApplyPendingUpgradeDataIfReady();
		_pendingInfrastructureData = data.Infrastructure ?? new InfrastructureSaveData();
		_lastKnownInfrastructureData = CloneInfrastructureSaveData( _pendingInfrastructureData );
		ApplyPendingInfrastructureDataIfReady();
		_pendingMissionData = data.Missions ?? new MissionSaveData();
		_lastKnownMissionData = CloneMissionSaveData( _pendingMissionData );
		ApplyPendingMissionDataIfReady();
		HeritageCoins               = Math.Max( 0, data.Heritage?.Coins ?? 0 );
		HeritageTotalEarned         = Math.Max( 0, data.Heritage?.TotalCoinsEarned ?? 0 );
		HeritageGeneration          = Math.Max( 0, data.Heritage?.Generation ?? 0 );
		_heritageStarterCapitalTier = Math.Clamp( data.Heritage?.StarterCapitalTier ?? 0, 0, 4 );
		_heritageStarterInfraTier   = Math.Clamp( data.Heritage?.StarterInfraTier ?? 0, 0, 2 );
		_heritageInsiderBonus       = data.Heritage?.InsiderBonus ?? false;
		_heritageExtendedWelcome    = data.Heritage?.ExtendedWelcome ?? false;
		_heritageZeroFeeBonus       = data.Heritage?.ZeroFeeBonus ?? false;
		_heritageTradeAmplifier     = data.Heritage?.TradeAmplifier ?? false;
		_heritageDailyFortune       = data.Heritage?.DailyFortune ?? false;
		_heritageBotFarmBonus       = data.Heritage?.BotFarmBonus ?? false;

		_pendingFtueBootstrap = ShouldRunFtueBootstrap( data );
		TryRunFtueBootstrap();

		// Re-apply upgrade-based heritage bonuses (upgrades may have been reset)
		if ( _heritageInsiderBonus )  Upgrades?.GrantHeritageInsider();
		if ( _heritageZeroFeeBonus )  Upgrades?.GrantHeritageMaxVpn();
		if ( _heritageBotFarmBonus )  Upgrades?.GrantHeritageBotFarm();

		ClearOwnedBots();
		LoadBotsFromSave( data.Bots );
		TotalBotsPurchased = Math.Max( TotalBotsPurchased, _ownedBots.Count );
		ApplyOfflineEarnings( data.LastSeenUnix );
	}

	private void ApplyPendingUpgradeDataIfReady()
	{
		if ( _pendingUpgradeData is null ) return;
		if ( Upgrades is null ) return;
		Upgrades.ImportSaveData( _pendingUpgradeData );
		_lastKnownUpgradeData = CloneUpgradeSaveData( _pendingUpgradeData );
		_pendingUpgradeData = null;
		if ( _heritageInsiderBonus )  Upgrades.GrantHeritageInsider();
		if ( _heritageZeroFeeBonus )  Upgrades.GrantHeritageMaxVpn();
		if ( _heritageBotFarmBonus )  Upgrades.GrantHeritageBotFarm();
	}

	private void ApplyPendingInfrastructureDataIfReady()
	{
		if ( _pendingInfrastructureData is null ) return;
		if ( Infrastructure is null ) return;
		Infrastructure.ImportSaveData( _pendingInfrastructureData );
		_lastKnownInfrastructureData = CloneInfrastructureSaveData( _pendingInfrastructureData );
		_pendingInfrastructureData = null;
		TryRunFtueBootstrap();
	}

	private void ApplyPendingMissionDataIfReady()
	{
		if ( _pendingMissionData is null ) return;
		if ( Missions is null ) return;
		Missions.ImportSaveData( _pendingMissionData );
		_lastKnownMissionData = CloneMissionSaveData( _pendingMissionData );
		_pendingMissionData = null;
	}

	private bool ShouldRunFtueBootstrap( PlayerSaveData data )
	{
		if ( data is null ) return false;
		if ( HasGrantedStarterInfrastructure && WelcomeBoostEndUnix > 0 ) return false;
		if ( data.LegacyMigrationReward > 0 ) return false;

		bool freshSave = data.LastSeenUnix <= 0;
		bool noTrades = data.TotalTrades <= 0 && data.MineCount <= 0;
		bool noInfra = data.Infrastructure?.Machines is null
			|| data.Infrastructure.Machines.All( m => m is null || m.Owned <= 0 );
		bool nearStarterCash = data.Money <= 2_500.0;

		return freshSave || (noTrades && noInfra && nearStarterCash);
	}

	private void TryRunFtueBootstrap()
	{
		if ( !_pendingFtueBootstrap ) return;
		if ( Infrastructure is null ) return;

		_pendingFtueBootstrap = false;
		bool changed = false;
		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

		if ( !HasGrantedStarterInfrastructure )
		{
			// Tier 0 = standard (Pi Cluster only)
			Infrastructure.EnsureStarterMachineOwned( 0, requestSave: false );
			if ( _heritageStarterInfraTier >= 1 )
				Infrastructure.EnsureStarterMachineOwned( 1, requestSave: false ); // GPU Rig
			if ( _heritageStarterInfraTier >= 2 )
			{
				Infrastructure.EnsureStarterMachineOwned( 2, requestSave: false ); // Edge Rack
				Infrastructure.EnsureStarterMachineOwned( 3, requestSave: false ); // Modular Datacenter
			}
			HasGrantedStarterInfrastructure = true;
			changed = true;
		}

		if ( WelcomeBoostEndUnix <= now )
		{
			long boostDuration = _heritageExtendedWelcome ? WelcomeBoostDurationSec * 2L : WelcomeBoostDurationSec;
			WelcomeBoostEndUnix = now + boostDuration;
			changed = true;
		}

		if ( changed )
			TrySave( waitForBackend: true, reason: "ftue_bootstrap" );
	}

	private void ClearOwnedBots()
	{
		foreach ( var bot in Components.GetAll<TradingBot>( FindMode.EverythingInSelfAndDescendants ) )
			bot.GameObject.Destroy();
		_ownedBots.Clear();
	}

	private void LoadBotsFromSave( List<BotSaveData> bots )
	{
		if ( bots is null ) return;

		for ( int i = 0; i < bots.Count && i < MaxBotSlots; i++ )
		{
			var b = bots[i];
			if ( b is null ) continue;
			if ( b.Tier < 1 || b.Tier > 4 ) continue;

			var tier = (BotTier)b.Tier;
			var go = new GameObject( true, $"Bot_{tier}" );
			go.SetParent( GameObject );

			var bot = go.Components.Create<TradingBot>();
			bot.Tier = tier;
			bot.MaxFuelSeconds = b.MaxFuelSeconds > 0 ? b.MaxFuelSeconds : TradingBot.GetBaseAutonomy( tier );
			bot.FuelSeconds = Math.Clamp( b.FuelSeconds, 0f, bot.MaxFuelSeconds );
			bot.NeedsMaintenance = bot.FuelSeconds <= 0f;
			bot.IsActive = b.IsActive && bot.FuelSeconds > 0f;
			bot.NetProfit = b.NetProfit;
			bot.TradesWon = Math.Max( 0, b.TradesWon );
			bot.TradesTotal = Math.Max( 0, b.TradesTotal );
			bot.Quality = b.Quality > 0 ? Math.Clamp( b.Quality, 1, 100 ) : Random.Shared.Next( 15, 75 );
			if ( b.UserTradeAmount > 0 ) bot.UserTradeAmount = b.UserTradeAmount;
			if ( b.UserTakeProfitPct > 0 ) bot.UserTakeProfitPct = b.UserTakeProfitPct;
			if ( b.UserStopLossPct > 0 ) bot.UserStopLossPct = b.UserStopLossPct;
			bot.RestorePositionState(
				b.HeldCoins,
				b.InvestedAmount,
				b.EntryPrice,
				b.PositionTP,
				b.PositionSL,
				b.PositionTargetPrice,
				b.PositionHoldSeconds,
				b.PositionCooldownTicks );
			bot.InitSettingsIfNeeded();
			bot.MarkFuelLoaded();

			go.NetworkSpawn();
			_ownedBots.Add( bot );
		}
	}

	private void ApplyOfflineEarnings( long lastSeenUnix )
	{
		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		if ( lastSeenUnix <= 0 ) return;

		int offlineSec = (int)Math.Clamp( now - lastSeenUnix, 0, 86400 );
		if ( offlineSec <= 300 ) return;

		double botEarnings = 0;
		foreach ( var bot in _ownedBots )
		{
			if ( !bot.IsValid() || !bot.IsActive || bot.FuelSeconds <= 0f ) continue;

			float simTime = Math.Min( offlineSec, bot.FuelSeconds );
			double epmRate = TradingBot.GetOfflineEarningsPerMin( bot.Tier, bot.Quality );
			double earned = epmRate * (simTime / 60.0);
			earned = ApplyWelcomeBoostToGain( earned );

			bot.FuelSeconds -= simTime;
			bot.NetProfit += earned;

			if ( bot.FuelSeconds <= 0f )
			{
				bot.FuelSeconds = 0f;
				bot.IsActive = false;
				bot.NeedsMaintenance = true;
			}

			botEarnings += earned;
		}

		double infrastructureEarnings = Infrastructure?.SimulateOfflineEarnings( offlineSec ) ?? 0;
		double totalEarnings = botEarnings + infrastructureEarnings;
		if ( totalEarnings <= 0 ) return;

		Money += totalEarnings;
		BotProfit += botEarnings;
		TotalProfit += totalEarnings;
		_offlineSeconds = offlineSec;
		_offlineEarnings = totalEarnings;
		_showOfflineReport = true;
		RequestSave();
	}

	private void TrySave( bool waitForBackend, string reason, bool markDisconnect = false )
	{
		if ( !IsLocalOwner ) return;
		if ( !_isDataLoaded || IsCloudSyncInProgress || HasCloudSyncFatalError )
		{
			return;
		}

		if ( _isSaving )
		{
			_saveQueued = true;
			_saveQueuedCritical |= waitForBackend;
			if ( !string.IsNullOrWhiteSpace( reason ) )
				_saveQueuedReason = reason;
			return;
		}

		if ( !waitForBackend && _sinceLastSave < MinSaveInterval ) return;

		_isSaving = true;
		_sinceLastSave = 0;
		_ownedBots.RemoveAll( b => !b.IsValid() );

		_ = FlushDataAsync( reason, markDisconnect );
	}

	private async System.Threading.Tasks.Task FlushDataAsync( string reason, bool markDisconnect )
	{
		try
		{
			Save( markDisconnect: markDisconnect );
		}
		catch
		{
		}
		finally
		{
			_isSaving = false;
		}

		if ( _saveQueued )
		{
			bool queuedCritical = _saveQueuedCritical;
			string queuedReason = string.IsNullOrWhiteSpace( _saveQueuedReason ) ? "queued" : _saveQueuedReason;
			_saveQueued = false;
			_saveQueuedCritical = false;
			_saveQueuedReason = "queued";
			TrySave( queuedCritical, $"queued_{queuedReason}" );
		}

		await global::System.Threading.Tasks.Task.CompletedTask;
	}

	private bool Save( bool pushLeaderboard = true, bool markDisconnect = false )
	{
		if ( !_isDataLoaded || !IsLocalOwner || IsCloudSyncInProgress || HasCloudSyncFatalError ) return false;
		ApplyPendingUpgradeDataIfReady();
		ApplyPendingInfrastructureDataIfReady();
		ApplyPendingMissionDataIfReady();
		var snapshot = BuildSaveData( markDisconnect );
		bool ok = PlayerSaveStore.Save( snapshot );
		if ( pushLeaderboard ) PushLeaderboardStats();
		return ok;
	}

	private void PushLeaderboardStats()
	{
		try
		{
			Stats.SetValue( "gravcoin_net_worth", NetWorth );
			Stats.SetValue( "sm_rank_floor", PermanentRank );
			Stats.SetValue( "sm_profit", TotalProfit );
			Stats.SetValue( "sm_manual_profit", ManualProfit );
			Stats.SetValue( "sm_bot_profit", BotProfit );
			Stats.SetValue( "sm_trades", TotalTrades );
			Stats.SetValue( "sm_trades_won", TradesWon );
			Stats.SetValue( "sm_total_money", Money );
			Stats.SetValue( "sm_max_level", PermanentRank );
			Stats.SetValue( "sm_heritage_gen", HeritageGeneration );
			Stats.SetValue( "sm_heritage_lvl", HeritageLevel );
			_ = Stats.FlushAsync();
		}
		catch
		{
		}
	}

	private async System.Threading.Tasks.Task PushLeaderboardStatsAndWaitAsync()
	{
		try
		{
			Stats.SetValue( "gravcoin_net_worth", NetWorth );
			Stats.SetValue( "sm_rank_floor", PermanentRank );
			Stats.SetValue( "sm_profit", TotalProfit );
			Stats.SetValue( "sm_manual_profit", ManualProfit );
			Stats.SetValue( "sm_bot_profit", BotProfit );
			Stats.SetValue( "sm_trades", TotalTrades );
			Stats.SetValue( "sm_trades_won", TradesWon );
			Stats.SetValue( "sm_total_money", Money );
			Stats.SetValue( "sm_max_level", PermanentRank );
			await Stats.FlushAndWaitAsync();
		}
		catch
		{
		}
	}

	private PlayerSaveData BuildSaveData( bool markDisconnect = false )
	{
		var upgrades = SnapshotUpgradeSaveData();
		var infrastructure = SnapshotInfrastructureSaveData();
		var missions = SnapshotMissionSaveData();
		var bots = new List<BotSaveData>();

		for ( int i = 0; i < _ownedBots.Count && i < MaxBotSlots; i++ )
		{
			var bot = _ownedBots[i];
			if ( !bot.IsValid() ) continue;
			bots.Add( new BotSaveData
			{
				Tier = (int)bot.Tier,
				FuelSeconds = bot.FuelSeconds,
				MaxFuelSeconds = bot.MaxFuelSeconds,
				IsActive = bot.IsActive,
				NetProfit = bot.NetProfit,
				TradesWon = bot.TradesWon,
				TradesTotal = bot.TradesTotal,
				Quality = bot.Quality,
				UserTradeAmount = bot.UserTradeAmount,
				UserTakeProfitPct = bot.UserTakeProfitPct,
				UserStopLossPct = bot.UserStopLossPct,
				HeldCoins = bot.HeldCoins,
				InvestedAmount = bot.InvestedAmount,
				EntryPrice = bot.EntryPrice,
				PositionTP = bot.PositionTP,
				PositionSL = bot.PositionSL,
				PositionTargetPrice = bot.PositionTargetPrice,
				PositionHoldSeconds = bot.PositionHoldSeconds,
				PositionCooldownTicks = bot.PositionCooldownTicks
			} );
		}

		long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
		if ( string.IsNullOrWhiteSpace( _lastServerId ) )
			_lastServerId = GetCurrentServerId();

		if ( markDisconnect )
		{
			_lastServerId = GetCurrentServerId();
			_lastDisconnectTime = now;
		}

		_dailyAnchorUnix = Math.Max( _dailyAnchorUnix, now );
		string dailyHash = DailyRewardSecurity.ComputeHash(
			_dailyStreak,
			_dailyLastClaimTs,
			_dailyAnchorUnix,
			_dailyClockRollbackStrikes );

		return new PlayerSaveData
		{
			Version = 1,
			HasMigrated = true,
			HasClaimedCloudCompensation = _hasClaimedCloudCompensation,
			LegacyMigrationReward = _legacyMigrationReward,
			Money = Money,
			CoinsHeld = CoinsHeld,
			AvgBuyPrice = _avgBuyPrice,
			TotalProfit = TotalProfit,
			ManualProfit = ManualProfit,
			BotProfit = BotProfit,
			TotalTrades = TotalTrades,
			TotalBotsPurchased = TotalBotsPurchased,
			HasCompletedTutorial = TutorialDone,
			TutorialDone = TutorialDone,
			PermanentRank = PermanentRank,
			BestTradePnl = BestTradePnl,
			WorstTradePnl = WorstTradePnl,
			BestStreak = BestStreak,
			PeakNetWorth = PeakNetWorth,
			TotalPlaySec = TotalPlaySec,
			TradesWon = TradesWon,
			PanicSellCount = PanicSellCount,
			Achievements = Achievements,
			MineCount = _mineCount,
			LastServerID = _lastServerId,
			LastDisconnectTime = _lastDisconnectTime,
			LastSeenUnix = now,
			LastBonusClaimTs = _lastBonusClaimTs,
			DailyStreak = _dailyStreak,
			DailyLastClaimTs = _dailyLastClaimTs,
			DailyAnchorUnix = _dailyAnchorUnix,
			DailyClockRollbackStrikes = _dailyClockRollbackStrikes,
			DailyRewardHash = dailyHash,
			HasReceivedFirstWin = HasReceivedFirstWin,
			WelcomeBoostEndUnix = WelcomeBoostEndUnix,
			HasGrantedStarterInfrastructure = HasGrantedStarterInfrastructure,
			Upgrades = upgrades,
			Bots = bots,
			Infrastructure = infrastructure,
			Missions = missions,
			Heritage = new HeritageSaveData
			{
				TotalCoinsEarned = HeritageTotalEarned,
				Coins            = HeritageCoins,
				Generation       = HeritageGeneration,
				StarterCapitalTier = _heritageStarterCapitalTier,
				StarterInfraTier   = _heritageStarterInfraTier,
				InsiderBonus       = _heritageInsiderBonus,
				ExtendedWelcome    = _heritageExtendedWelcome,
				ZeroFeeBonus       = _heritageZeroFeeBonus,
				TradeAmplifier     = _heritageTradeAmplifier,
				DailyFortune       = _heritageDailyFortune,
				BotFarmBonus       = _heritageBotFarmBonus,
			}
		};
	}

	private InfrastructureSaveData SnapshotInfrastructureSaveData()
	{
		var live = Infrastructure?.ExportSaveData();
		if ( IsInfrastructureSnapshotProbablyUninitialized( live ) )
		{
			if ( !IsInfrastructureSnapshotProbablyUninitialized( _pendingInfrastructureData ) )
				live = _pendingInfrastructureData;
			else if ( !IsInfrastructureSnapshotProbablyUninitialized( _lastKnownInfrastructureData ) )
				live = _lastKnownInfrastructureData;
		}

		var snapshot = CloneInfrastructureSaveData( live ?? new InfrastructureSaveData() );
		_lastKnownInfrastructureData = CloneInfrastructureSaveData( snapshot );
		return snapshot;
	}

	private UpgradeSaveData SnapshotUpgradeSaveData()
	{
		var live = Upgrades?.ExportSaveData();
		if ( live is not null )
		{
			_lastKnownUpgradeData = CloneUpgradeSaveData( live );
		}
		else if ( _pendingUpgradeData is not null )
		{
			_lastKnownUpgradeData = CloneUpgradeSaveData( _pendingUpgradeData );
		}

		return CloneUpgradeSaveData( _lastKnownUpgradeData );
	}

	private MissionSaveData SnapshotMissionSaveData()
	{
		var live = Missions?.ExportSaveData();
		if ( IsMissionSnapshotProbablyUninitialized( live ) )
		{
			if ( !IsMissionSnapshotProbablyUninitialized( _pendingMissionData ) )
				live = _pendingMissionData;
			else if ( !IsMissionSnapshotProbablyUninitialized( _lastKnownMissionData ) )
				live = _lastKnownMissionData;
		}

		var snapshot = CloneMissionSaveData( live ?? new MissionSaveData() );
		_lastKnownMissionData = CloneMissionSaveData( snapshot );
		return snapshot;
	}

	private static bool IsInfrastructureSnapshotProbablyUninitialized( InfrastructureSaveData data )
	{
		if ( data is null ) return true;
		if ( data.Revision > 0 ) return false;
		if ( data.Machines is null || data.Machines.Count == 0 ) return true;

		for ( int i = 0; i < data.Machines.Count; i++ )
		{
			var machine = data.Machines[i];
			if ( machine is null ) continue;
			if ( machine.Owned > 0 ) return false;
			if ( machine.AutomationUnlocked || machine.SpeedUpgradeUnlocked || machine.ProfitUpgradeUnlocked ) return false;
			if ( machine.IsCycleRunning || machine.CycleElapsedSeconds > 0f || machine.LifetimeEarned > 0.0 ) return false;
		}

		return true;
	}

	private static bool IsMissionSnapshotProbablyUninitialized( MissionSaveData data )
	{
		if ( data is null ) return true;
		return data.Revision <= 0 && (data.ClaimedMissionIds is null || data.ClaimedMissionIds.Count == 0);
	}

	private static InfrastructureSaveData CloneInfrastructureSaveData( InfrastructureSaveData data )
	{
		data ??= new InfrastructureSaveData();
		var clone = new InfrastructureSaveData
		{
			Revision = data.Revision,
			Machines = new List<InfrastructureMachineSaveData>()
		};

		if ( data.Machines is null ) return clone;

		for ( int i = 0; i < data.Machines.Count; i++ )
		{
			var machine = data.Machines[i];
			if ( machine is null ) continue;
			clone.Machines.Add( new InfrastructureMachineSaveData
			{
				Id = machine.Id ?? "",
				Owned = machine.Owned,
				AutomationUnlocked = machine.AutomationUnlocked,
				SpeedUpgradeUnlocked = machine.SpeedUpgradeUnlocked,
				ProfitUpgradeUnlocked = machine.ProfitUpgradeUnlocked,
				IsCycleRunning = machine.IsCycleRunning,
				CycleElapsedSeconds = machine.CycleElapsedSeconds,
				LifetimeEarned = machine.LifetimeEarned
			} );
		}

		return clone;
	}

	private static UpgradeSaveData CloneUpgradeSaveData( UpgradeSaveData data )
	{
		data ??= new UpgradeSaveData();
		return new UpgradeSaveData
		{
			GasLevel = data.GasLevel,
			CoolingLevel = data.CoolingLevel,
			UpgradeRevision = data.UpgradeRevision,
			InsiderUnlocked = data.InsiderUnlocked,
			GpuUnlocked = data.GpuUnlocked,
			SatelliteUnlocked = data.SatelliteUnlocked,
			InsiderLastUsedAt = data.InsiderLastUsedAt,
			LastInsiderTip = data.LastInsiderTip,
			HashLevel = data.HashLevel,
			RewardLevel = data.RewardLevel
		};
	}

	private static MissionSaveData CloneMissionSaveData( MissionSaveData data )
	{
		data ??= new MissionSaveData();
		return new MissionSaveData
		{
			Revision = data.Revision,
			ClaimedMissionIds = data.ClaimedMissionIds is null
				? new List<string>()
				: data.ClaimedMissionIds
					.Where( id => !string.IsNullOrWhiteSpace( id ) )
					.Select( id => id.Trim() )
					.ToList()
		};
	}

	public async System.Threading.Tasks.Task ForceFlushOnExitAsync()
	{
		if ( IsProxy || !_isDataLoaded || !IsLocalOwner )
			return;

		// A background save may have been queued but not yet completed (e.g. a bot trade
		// fired RequestSave() just before OnDestroy). Force-clear the flag so the final
		// disconnect flush is never skipped — losing data on quit is worse than saving twice.
		_isSaving = false;
		_isSaving = true;
		_sinceLastSave = 0;
		_ownedBots.RemoveAll( b => !b.IsValid() );
		CloseBotPositionsForDisconnect();

		try
		{
			if ( Save( pushLeaderboard: false, markDisconnect: true ) )
				await PushLeaderboardStatsAndWaitAsync();
		}
		finally
		{
			_isSaving = false;
		}
	}

	private void CloseBotPositionsForDisconnect()
	{
		for ( int i = 0; i < _ownedBots.Count; i++ )
		{
			var bot = _ownedBots[i];
			if ( !bot.IsValid() ) continue;
			// Preserve active state so offline simulation can still consume fuel/earnings.
			bot.ForceLiquidateAllPositions( disableBotAfter: false );
		}
	}
}

