Core/GameProfile.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Prototype;

public sealed class GameSettingsData
{
	public Dictionary<string, float> VoiceVolumes { get; set; } = new();
	public float FieldOfView { get; set; } = 100f;
	public float Brightness { get; set; } = 1f;
	public float SoundVolume { get; set; } = 1f;
	public float MouseSensitivity { get; set; } = 1f;
    public float MusicVolume { get; set; } = 1f;
    public float EffectsVolume { get; set; } = 1f;
    public float VoiceVolume { get; set; } = 1f;
    public bool MicrophoneEnabled { get; set; } = true;
    public bool InvertMouse { get; set; }
    public bool ShowCrosshair { get; set; } = true;
    public bool ShowDamageNumbers { get; set; } = true;
    public bool ShowHitmarker { get; set; } = true;
    public float HitmarkerBodyHue { get; set; } = -1f;
    public float HitmarkerHeadHue { get; set; } = 38f;
    public bool ShowHurtEffect { get; set; } = true;
    public float HurtEffectHue { get; set; } = 358f;

    public bool ReduceWeaponMotion { get; set; }
    public int AudioSettingsVersion { get; set; }
    public bool CrosshairDot { get; set; } = true;
    public bool CrosshairOutline { get; set; } = true;
    public float CrosshairLength { get; set; } = 8f;
    public float CrosshairGap { get; set; } = 4f;
    public float CrosshairThickness { get; set; } = 2f;
    public float CrosshairOpacity { get; set; } = 1f;
    public float CrosshairHue { get; set; } = 180f;
    public float CrosshairSaturation { get; set; } = 1f;
    public float DamageSaturation { get; set; } = 1f;
    public float DamageTextSize { get; set; } = 24f;
    public float DamageHue { get; set; } = 45f;
    public GameSettingsData Copy() => new GameSettingsData
    {
        CrosshairLength = CrosshairLength,
        CrosshairGap = CrosshairGap,
        CrosshairThickness = CrosshairThickness,
        CrosshairOpacity = CrosshairOpacity,
        CrosshairHue = CrosshairHue,
        CrosshairSaturation = CrosshairSaturation,
        DamageSaturation = DamageSaturation,
        DamageTextSize = DamageTextSize,
        DamageHue = DamageHue,
        ShowHitmarker = ShowHitmarker,
        HitmarkerBodyHue = HitmarkerBodyHue,
        HitmarkerHeadHue = HitmarkerHeadHue,
        ShowHurtEffect = ShowHurtEffect,
        HurtEffectHue = HurtEffectHue,

        AudioSettingsVersion = AudioSettingsVersion,
        CrosshairDot = CrosshairDot,
        CrosshairOutline = CrosshairOutline,
        VoiceVolumes = new Dictionary<string, float>(VoiceVolumes),
        FieldOfView = FieldOfView,
        Brightness = Brightness,
        SoundVolume = SoundVolume,
        MouseSensitivity = MouseSensitivity,
        MusicVolume = MusicVolume,
        EffectsVolume = EffectsVolume,
        VoiceVolume = VoiceVolume,
        MicrophoneEnabled = MicrophoneEnabled,
        InvertMouse = InvertMouse,
        ShowCrosshair = ShowCrosshair,
        ShowDamageNumbers = ShowDamageNumbers,
        ReduceWeaponMotion = ReduceWeaponMotion
    };
}

public sealed class RunRecord
{
    public string RunId { get; set; } = "";
    public long OwnerSteamId { get; set; }
    public long GlobalRank { get; set; }
	public string PlayerName { get; set; } = "Runner";
	public string Category { get; set; } = "Solo";
	public int Score { get; set; }
	public int Kills { get; set; }
	public int Levels { get; set; }
	public float Seconds { get; set; }
	public long FinishedAt { get; set; }
}

public static partial class GameProfile
{
	private const string SettingsKey = "prototype.settings.v1";
	private const string RunsKey = "prototype.runs.v1";
	private const string ProgressKey = "prototype.progress.v1";
	public static GameSettingsData Settings { get; private set; } = new();
	public static ProgressionData Progress { get; private set; } = new();
	public static IReadOnlyList<RunRecord> Runs => _runs;
	private static readonly List<RunRecord> _runs = new();
	private static readonly Dictionary<string, List<RunRecord>> _globalRuns = new();
	private static bool _loaded;

	public static void Load()
	{
		if ( _loaded ) return;
		_loaded = true;
		Settings = Sandbox.Game.Cookies.Get<GameSettingsData>( SettingsKey, null ) ?? new GameSettingsData();
		Progress = Sandbox.Game.Cookies.Get<ProgressionData>( ProgressKey, null ) ?? new ProgressionData();
		Progress.SkillRanks ??= new Dictionary<string, int>();
        Progress.CoinReceipts ??= new();
        if (!Progress.CoinsMigrated) { Progress.Coins += Math.Max(0, Progress.Insight) * 60; Progress.CoinsMigrated = true; SaveProgress(); }
		if (Progress.SkillTreeVersion < 2)
        {
            foreach (var pair in Progress.SkillRanks.Where(x => !x.Key.StartsWith("tree_")).ToArray())
            {
                for (var r = 0; r < pair.Value; r++) Progress.Coins += 100 + r * 110 + r * r * 90;
                Progress.SkillRanks.Remove(pair.Key);
            }
            Progress.SkillTreeVersion = 2;
            SaveProgress();
        }
        Settings.VoiceVolumes ??= new();
        if (Settings.AudioSettingsVersion < 1)
        {
            Settings.SoundVolume = Settings.MusicVolume = Settings.EffectsVolume = Settings.VoiceVolume = 1f;
            Settings.AudioSettingsVersion = 1;
            SaveSettings();
        }
		Settings.FieldOfView = Math.Clamp( Settings.FieldOfView, 75f, 120f );
		Settings.Brightness = Math.Clamp( Settings.Brightness, .65f, 1.5f );
		Settings.SoundVolume = Math.Clamp( Settings.SoundVolume, 0f, 2f );
		Settings.MouseSensitivity = Math.Clamp( Settings.MouseSensitivity, .25f, 3f );
        ValidateSettings();
		_runs.AddRange( Sandbox.Game.Cookies.Get<List<RunRecord>>( RunsKey, null ) ?? new List<RunRecord>() );
		LoadPendingRuns();
		ApplyAudio();
	}

	private static void ValidateSettings()
    {
        static float Safe(float v, float min, float max, float fallback) => float.IsFinite(v) ? Math.Clamp(v, min, max) : fallback;
        Settings.CrosshairLength = Safe(Settings.CrosshairLength,0f,24f,8f);
        Settings.CrosshairGap = Safe(Settings.CrosshairGap,0f,20f,4f);
        Settings.CrosshairThickness = Safe(Settings.CrosshairThickness,1f,6f,2f);
        Settings.CrosshairOpacity = Safe(Settings.CrosshairOpacity,0f,1f,1f);
        Settings.CrosshairSaturation = Safe(Settings.CrosshairSaturation,0f,1f,1f);
        Settings.DamageSaturation = Safe(Settings.DamageSaturation,0f,1f,1f);
        Settings.CrosshairHue = Safe(Settings.CrosshairHue,0f,360f,180f);
        Settings.DamageTextSize = Safe(Settings.DamageTextSize,14f,56f,24f);
        Settings.DamageHue = Safe(Settings.DamageHue,0f,360f,45f);
        Settings.HitmarkerBodyHue = Safe(Settings.HitmarkerBodyHue,-1f,360f,-1f);
        Settings.HitmarkerHeadHue = Safe(Settings.HitmarkerHeadHue,-1f,360f,38f);
        Settings.HurtEffectHue = Safe(Settings.HurtEffectHue,-1f,360f,358f);

        Settings.FieldOfView = Safe(Settings.FieldOfView,75f,120f,100f);
        Settings.Brightness = Safe(Settings.Brightness,.65f,1.5f,1f);
        Settings.MouseSensitivity = Safe(Settings.MouseSensitivity,.25f,3f,1f);
        Settings.SoundVolume = Safe(Settings.SoundVolume,0f,2f,1f);
        Settings.MusicVolume = Safe(Settings.MusicVolume,0f,2f,1f);
        Settings.EffectsVolume = Safe(Settings.EffectsVolume,0f,2f,1f);
        Settings.VoiceVolume = Safe(Settings.VoiceVolume,0f,2f,1f);
    }

    public static void SaveSettings()
    {
        ValidateSettings();
		Sandbox.Game.Cookies.Set( SettingsKey, Settings );
		ApplyAudio();
	}

	public static void ApplySettings(GameSettingsData draft)
    {
        // Preserve per-player voice adjustments made outside this menu.
        var voices = Settings.VoiceVolumes;
        Settings = draft.Copy();
        Settings.AudioSettingsVersion = 1;
        Settings.VoiceVolumes = voices;
        SaveSettings();
    }

    private static float _audioTarget = 1f;
    private static void ApplyAudio()
    {
        // Settings only set the target; avoid an abrupt gain jump during a shot.
        _audioTarget = Math.Clamp(Settings.SoundVolume, 0f, 2f);
    }

    public static void UpdateAudio(float delta)
    {
        var mixer = Sandbox.Audio.Mixer.Master;
        if (mixer is null) return;
        var step = Math.Clamp(delta, 0f, .02f) / .08f;
        mixer.Volume += Math.Clamp(_audioTarget - mixer.Volume, -step, step);
    }

	public static void AddRun( RunRecord record, bool publish = true )
	{
		Load();
		if (!string.IsNullOrEmpty(record.RunId) && _runs.Any(r => r.RunId == record.RunId)) return;
        var previousBest = _runs.Where( x => string.Equals( x.Category, record.Category, StringComparison.OrdinalIgnoreCase ) ).Select( x => x.Score ).DefaultIfEmpty( -1 ).Max();
        if (publish) QueueRun(record, record.Score > previousBest);
		_runs.Add( record );
		var compact = _runs.OrderByDescending( x => x.Score ).ThenByDescending( x => x.Levels ).ThenBy( x => x.Seconds ).Take( 100 ).ToList();
		_runs.Clear(); _runs.AddRange( compact );
		Sandbox.Game.Cookies.Set( RunsKey, _runs );
		if (!publish) return;
        // Coins are saved incrementally from host-confirmed rewards, never again at results.
		SaveProgress();
	}

	public static int SkillRank( string id ) => Progress.SkillRanks.GetValueOrDefault( id );
	public static void SelectHero( string id ) { Progress.SelectedHero = HeroCatalog.Get( id ).Id; SaveProgress(); }
	public static int SkillCost(SkillSpec skill) => 150 + skill.Tier * 250 + SkillRank(skill.Id) * 200;
    public static void BankCoins(string runId, int total)
    {
        Load();
        if (string.IsNullOrEmpty(runId) || total < 0) return;
        var previous = Progress.CoinReceipts.GetValueOrDefault(runId);
        if (total <= previous) return;
        Progress.Coins += total - previous;
        Progress.CoinReceipts[runId] = total;
        SaveProgress();
    }
    public static string SkillRequirement(SkillSpec skill) => !string.IsNullOrEmpty(skill.Parent) && SkillRank(skill.Parent) < 2
        ? $"Requires {HeroCatalog.Skills.First(x => x.Id == skill.Parent).Name} rank 2" : "";
    public static bool BuySkill( SkillSpec skill )
    {
        Load();
        if (!HeroCatalog.Skills.Contains(skill) || SkillRequirement(skill).Length > 0) return false;
        if (PrototypeGame.Instance is not null && PrototypeGame.Instance.LocalPhase != RunPhase.Lobby) return false;
		var rank = SkillRank( skill.Id );
		if ( rank >= skill.MaxRank ) return false;
		var cost = SkillCost(skill);
		if ( Progress.Coins < cost ) return false;
		Progress.Coins -= cost; Progress.SkillRanks[skill.Id] = rank + 1; SaveProgress(); return true;
	}
	private static void SaveProgress() => Sandbox.Game.Cookies.Set( ProgressKey, Progress );

	public static IEnumerable<RunRecord> Category( string category ) =>
		(_globalRuns.TryGetValue( category, out var global ) && global.Count > 0 ? global : _runs.Where( x => string.Equals( x.Category, category, StringComparison.OrdinalIgnoreCase ) ))
			.OrderByDescending( x => x.Score ).ThenByDescending( x => x.Levels ).ThenByDescending( x => x.Kills ).ThenBy( x => x.Seconds );

	public static string BoardSource(string category) => _globalRuns.TryGetValue(category, out var rows) && rows.Count > 0 ? "Global rankings" : "Local run history";
	public static async Task RefreshGlobal( string category )
	{
		var key = category.ToLowerInvariant();
		try
		{
			var scoreBoard = Sandbox.Services.Leaderboards.GetFromStat( $"prototype_{key}_score" );
			scoreBoard.MaxEntries = 30; scoreBoard.SetAggregationMax(); scoreBoard.SetSortDescending(); scoreBoard.FilterByNone();
			await scoreBoard.Refresh();
			if ( scoreBoard.Entries is null || scoreBoard.Entries.Length == 0 ) { _globalRuns.Remove(category); return; }
			var ids = scoreBoard.Entries.Select( x => x.SteamId ).ToArray();
			var kills = await ReadValues( $"prototype_{key}_kills", ids );
			var levels = await ReadValues( $"prototype_{key}_levels", ids );
			var times = await ReadValues( $"prototype_{key}_time", ids );
			_globalRuns[category] = scoreBoard.Entries.Select( entry => new RunRecord
			{
				OwnerSteamId = entry.SteamId, GlobalRank = entry.Rank,
                PlayerName = string.IsNullOrWhiteSpace( entry.DisplayName ) ? entry.SteamId.ToString() : entry.DisplayName,
				Category = category, Score = (int)Math.Clamp( entry.Value, 0, int.MaxValue ),
				Kills = (int)kills.GetValueOrDefault( entry.SteamId ), Levels = (int)levels.GetValueOrDefault( entry.SteamId ),
				Seconds = (float)times.GetValueOrDefault( entry.SteamId ), FinishedAt = entry.Timestamp.ToUnixTimeSeconds()
			} ).ToList();
            var metadataTasks = scoreBoard.Entries.Select(async entry =>
            {
                if(string.IsNullOrWhiteSpace(entry.DataUrl)) return;
                try
                {
                    using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(3));
                    var json = await Sandbox.Http.RequestStringAsync(entry.DataUrl, cancellationToken:timeout.Token);
                    using var doc = System.Text.Json.JsonDocument.Parse(json);
                    var row = _globalRuns[category].First(x => x.OwnerSteamId == entry.SteamId);
                    if(doc.RootElement.TryGetProperty("team",out var team)) row.PlayerName = team.GetString() ?? row.PlayerName;
                    if(doc.RootElement.TryGetProperty("run",out var run)) row.RunId = run.GetString() ?? "";
                    if(doc.RootElement.TryGetProperty("kills",out var k) && k.TryGetInt32(out var kv)) row.Kills=kv;
                    if(doc.RootElement.TryGetProperty("levels",out var l) && l.TryGetInt32(out var lv)) row.Levels=lv;
                    if(doc.RootElement.TryGetProperty("seconds",out var t) && t.TryGetSingle(out var tv)) row.Seconds=tv;
                }
                catch { /* Legacy entries keep their recorded account name. */ }
            }).ToArray();
            foreach (var metadataTask in metadataTasks) await metadataTask;
		}
		catch ( Exception exception ) { Log.Warning( $"Leaderboard refresh unavailable: {exception.Message}" ); }
	}

	private static async Task<Dictionary<long, double>> ReadValues( string stat, long[] ids )
	{
		var board = Sandbox.Services.Leaderboards.GetFromStat( stat );
		board.MaxEntries = 30; board.IncludeSteamIds( ids ); board.SetAggregationLast(); board.FilterByNone();
		await board.Refresh();
		return (board.Entries ?? Array.Empty<Sandbox.Services.Leaderboards.Board2.Entry>()).ToDictionary( x => x.SteamId, x => x.Value );
	}
}