using Sandbox;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GravCoin;

public readonly struct PlayerSaveLoadResult
{
	public PlayerSaveLoadResult( PlayerSaveData data, bool hasFile, bool isValid, bool hashMismatch )
	{
		Data = data;
		HasFile = hasFile;
		IsValid = isValid;
		HashMismatch = hashMismatch;
	}

	public PlayerSaveData Data { get; }
	public bool HasFile { get; }
	public bool IsValid { get; }
	public bool HashMismatch { get; }
}

public static class PlayerSaveStore
{
	private const string SaveDirectory = "sandmoney";
	private const string SaveFilePath = SaveDirectory + "/playerdata.json";
	private const string SaveFileBakPath = SaveDirectory + "/playerdata.json.bak";

	private static readonly JsonSerializerOptions JsonOptions = new()
	{
		WriteIndented = false,
		DefaultIgnoreCondition = JsonIgnoreCondition.Never,
		PropertyNameCaseInsensitive = true
	};

	// NaN/Infinity-safe helpers — Math.Max(0, NaN) returns NaN in C#, which causes
	// JsonSerializer to throw and silently kills the save. Always use these for float/double fields.
	private static float SF( float v, float fallback = 0f )
		=> (float.IsNaN( v ) || float.IsInfinity( v )) ? fallback : v;

	private static double SD( double v, double fallback = 0.0 )
		=> (double.IsNaN( v ) || double.IsInfinity( v )) ? fallback : v;

	public static PlayerSaveLoadResult Load()
	{
		var fs = FileSystem.Data;
		if ( fs is null )
			return new( new PlayerSaveData(), hasFile: false, isValid: false, hashMismatch: false );

		if ( !fs.FileExists( SaveFilePath ) )
			return new( new PlayerSaveData(), hasFile: false, isValid: true, hashMismatch: false );

		// Try the main save file first. On JSON parse failure (file corrupted mid-write),
		// fall back to the backup. Hash mismatches are NOT eligible for backup fallback
		// because they may indicate a different player's file on a shared machine.
		PlayerSaveLoadResult mainResult = TryLoadFromFile( fs, SaveFilePath );
		if ( mainResult.IsValid )
			return mainResult;

		// Only fall back to backup on parse errors, not hash mismatches
		if ( !mainResult.HashMismatch && fs.FileExists( SaveFileBakPath ) )
		{
			PlayerSaveLoadResult bakResult = TryLoadFromFile( fs, SaveFileBakPath );
			if ( bakResult.IsValid )
				return bakResult;
		}

		return mainResult;
	}

	private static PlayerSaveLoadResult TryLoadFromFile( BaseFileSystem fs, string path )
	{
		try
		{
			var json = fs.ReadAllText( path );
			if ( string.IsNullOrWhiteSpace( json ) )
				return new( new PlayerSaveData(), hasFile: true, isValid: false, hashMismatch: false );

			var envelope = JsonSerializer.Deserialize<PlayerSaveEnvelope>( json, JsonOptions );
			if ( envelope?.PlayerData is null || string.IsNullOrWhiteSpace( envelope.Hash ) )
				return new( new PlayerSaveData(), hasFile: true, isValid: false, hashMismatch: false );

			var data = Normalize( envelope.PlayerData );
			var expectedHash = ComputeDataHash( data );
			bool ok = string.Equals( envelope.Hash, expectedHash, StringComparison.OrdinalIgnoreCase );

			// Accept hash mismatches so that adding new schema fields (with default values) does not
			// wipe existing saves. The data will be re-hashed and re-saved on the next save cycle.
			// hashMismatch: true signals the caller to schedule a re-save.
			return new( data, hasFile: true, isValid: true, hashMismatch: !ok );
		}
		catch
		{
			return new( new PlayerSaveData(), hasFile: true, isValid: false, hashMismatch: false );
		}
	}

	public static bool Save( PlayerSaveData data )
	{
		var fs = FileSystem.Data;
		if ( fs is null ) return false;

		try
		{
			var normalized = Normalize( data );
			var envelope = new PlayerSaveEnvelope
			{
				PlayerData = normalized,
				Hash = ComputeDataHash( normalized )
			};

			byte[] bytes = JsonSerializer.SerializeToUtf8Bytes( envelope, JsonOptions );

			if ( !fs.DirectoryExists( SaveDirectory ) )
				fs.CreateDirectory( SaveDirectory );

			// Back up the current valid save before overwriting, so a mid-write crash
			// (which would corrupt the main file) can be recovered from on next load.
			if ( fs.FileExists( SaveFilePath ) )
			{
				try
				{
					var currentBytes = fs.ReadAllText( SaveFilePath );
					if ( !string.IsNullOrWhiteSpace( currentBytes ) )
						fs.WriteAllBytes( SaveFileBakPath, System.Text.Encoding.UTF8.GetBytes( currentBytes ) );
				}
				catch
				{
					// Backup failure is non-fatal — continue with the main write.
				}
			}

			fs.WriteAllBytes( SaveFilePath, bytes );
			return true;
		}
		catch
		{
			return false;
		}
	}

	public static string ComputeDataHash( PlayerSaveData data )
	{
		byte[] payload = JsonSerializer.SerializeToUtf8Bytes( Normalize( data ), JsonOptions );
		return PlayerSaveHasher.ComputePlayerSaveHex( payload );
	}

	private static PlayerSaveData Normalize( PlayerSaveData data )
	{
		data ??= new PlayerSaveData();
		data.Version = Math.Max( 1, data.Version );
		data.Money = Math.Max( 0.0, SD( data.Money ) );
		data.CoinsHeld = Math.Max( 0f, SF( data.CoinsHeld ) );
		data.AvgBuyPrice = Math.Max( 0f, SF( data.AvgBuyPrice ) );
		data.TotalProfit = SD( data.TotalProfit );
		data.ManualProfit = SD( data.ManualProfit );
		data.BotProfit = SD( data.BotProfit );
		data.BestTradePnl = SD( data.BestTradePnl );
		data.WorstTradePnl = SD( data.WorstTradePnl );
		data.PeakNetWorth = Math.Max( 0.0, SD( data.PeakNetWorth ) );
		data.TotalTrades = Math.Max( 0, data.TotalTrades );
		data.TotalBotsPurchased = Math.Max( 0, data.TotalBotsPurchased );
		data.HasCompletedTutorial = data.HasCompletedTutorial || data.TutorialDone;
		data.TutorialDone = data.HasCompletedTutorial;
		data.PermanentRank = Math.Max( 0, data.PermanentRank );
		data.BestStreak = Math.Max( 0, data.BestStreak );
		data.TotalPlaySec = Math.Max( 0, data.TotalPlaySec );
		data.TradesWon = Math.Max( 0, data.TradesWon );
		data.PanicSellCount = Math.Max( 0, data.PanicSellCount );
		data.MineCount = Math.Max( 0, data.MineCount );
		data.LastServerID ??= "";
		if ( data.LastServerID.Length > 256 )
			data.LastServerID = data.LastServerID[..256];
		data.LastDisconnectTime = Math.Max( 0, data.LastDisconnectTime );
		data.LastSeenUnix = Math.Max( 0, data.LastSeenUnix );
		data.DailyStreak = Math.Clamp( data.DailyStreak, 0, 3650 );
		data.DailyLastClaimTs = Math.Max( 0, data.DailyLastClaimTs );
		data.DailyAnchorUnix = Math.Max( 0, data.DailyAnchorUnix );
		data.DailyClockRollbackStrikes = Math.Clamp( data.DailyClockRollbackStrikes, 0, 10_000 );
		data.DailyRewardHash ??= "";
		data.WelcomeBoostEndUnix = Math.Max( 0, data.WelcomeBoostEndUnix );

		data.Upgrades ??= new UpgradeSaveData();
		data.Upgrades.GasLevel = Math.Clamp( data.Upgrades.GasLevel, 0, 5 );
		data.Upgrades.CoolingLevel = Math.Clamp( data.Upgrades.CoolingLevel, 0, 5 );
		data.Upgrades.UpgradeRevision = Math.Max( 0, data.Upgrades.UpgradeRevision );
		data.Upgrades.HashLevel = Math.Clamp( data.Upgrades.HashLevel, 0, 3 );
		data.Upgrades.RewardLevel = Math.Clamp( data.Upgrades.RewardLevel, 0, 3 );
		data.Upgrades.InsiderLastUsedAt = SF( data.Upgrades.InsiderLastUsedAt, -999_999f );
		data.Upgrades.LastInsiderTip = SF( data.Upgrades.LastInsiderTip );

		data.Bots ??= new();
		foreach ( var bot in data.Bots )
		{
			if ( bot is null ) continue;
			bot.Tier = Math.Clamp( bot.Tier, 0, 4 );
			bot.FuelSeconds = Math.Max( 0f, SF( bot.FuelSeconds ) );
			bot.MaxFuelSeconds = Math.Max( 0f, SF( bot.MaxFuelSeconds ) );
			bot.Quality = Math.Clamp( bot.Quality, 0, 100 );
			bot.TradesWon = Math.Max( 0, bot.TradesWon );
			bot.TradesTotal = Math.Max( 0, bot.TradesTotal );
			bot.NetProfit = SD( bot.NetProfit );
			bot.HeldCoins = Math.Max( 0f, SF( bot.HeldCoins ) );
			bot.InvestedAmount = Math.Max( 0, SD( bot.InvestedAmount ) );
			bot.EntryPrice = Math.Max( 0f, SF( bot.EntryPrice ) );
			bot.PositionTP = Math.Max( 0f, SF( bot.PositionTP ) );
			bot.PositionSL = Math.Max( 0f, SF( bot.PositionSL ) );
			bot.PositionTargetPrice = Math.Max( 0f, SF( bot.PositionTargetPrice ) );
			bot.PositionHoldSeconds = Math.Max( 0f, SF( bot.PositionHoldSeconds ) );
			bot.PositionCooldownTicks = Math.Max( 0, bot.PositionCooldownTicks );
		}

		data.Infrastructure ??= new InfrastructureSaveData();
		data.Infrastructure.Revision = Math.Max( 0, data.Infrastructure.Revision );
		data.Infrastructure.Machines ??= new();
		foreach ( var machine in data.Infrastructure.Machines )
		{
			if ( machine is null ) continue;
			machine.Id ??= "";
			if ( machine.Id.Length > 64 )
				machine.Id = machine.Id[..64];
			machine.Owned = Math.Max( 0, machine.Owned );
			machine.CycleElapsedSeconds = Math.Max( 0f, SF( machine.CycleElapsedSeconds ) );
			machine.LifetimeEarned = Math.Max( 0.0, SD( machine.LifetimeEarned ) );
		}

		data.Missions ??= new MissionSaveData();
		data.Missions.Revision = Math.Max( 0, data.Missions.Revision );
		data.Missions.ClaimedMissionIds ??= new();
		var uniqueIds = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var sanitizedIds = new List<string>();
		foreach ( var missionId in data.Missions.ClaimedMissionIds )
		{
			if ( string.IsNullOrWhiteSpace( missionId ) ) continue;
			string trimmed = missionId.Trim();
			if ( trimmed.Length > 64 )
				trimmed = trimmed[..64];
			if ( uniqueIds.Add( trimmed ) )
				sanitizedIds.Add( trimmed );
		}
		data.Missions.ClaimedMissionIds = sanitizedIds;

		data.Heritage ??= new HeritageSaveData();
		data.Heritage.TotalCoinsEarned = Math.Max( 0, data.Heritage.TotalCoinsEarned );
		data.Heritage.Coins = Math.Clamp( data.Heritage.Coins, 0, data.Heritage.TotalCoinsEarned );
		data.Heritage.Generation = Math.Max( 0, data.Heritage.Generation );
		data.Heritage.StarterCapitalTier = Math.Clamp( data.Heritage.StarterCapitalTier, 0, 4 );
		data.Heritage.StarterInfraTier   = Math.Clamp( data.Heritage.StarterInfraTier, 0, 2 );

		return data;
	}
}
