Game/ReplayViewStats.cs
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace BlockParty;

/// <summary>
/// One replay-screen visit. The manager retains this across seeks, restarts and HUD rebuilds.
/// Only Begin submits a view and reads the global sum and distinct contributing accounts once.
/// </summary>
public sealed class ReplayViewStats
{
	private readonly string _statName;
	private readonly CancellationTokenSource _lifetime = new();
	private bool _cancelled;
	private bool _finished;
	// These engine operations are shared across replay visits. Re-entering while a request is
	// stalled must not launch another request (or another engine waiter) for the same global data.
	private static Task _flushTask;
	private static Task _refreshTask;
	private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds( 15 );

	public double? Viewers { get; private set; }
	public double? Views { get; private set; }
	public bool IsLoading { get; private set; } = true;
	public int Revision { get; private set; }

	private ReplayViewStats( string statName ) => _statName = statName;

	/// <summary>
	/// Uses existing recorded fields, so old replays work without migrating their payloads.
	/// Matches archive identity, including author, character and level revision. Display metadata,
	/// board version, JSON schema version and optional embedded level snapshots do not change it.
	/// Keep this encoding stable: changing it would start new counters for existing replays.
	/// </summary>
	public static string StatNameFor( RunData run, long steamId )
	{
		string identity = JsonSerializer.Serialize( new string[]
		{
			steamId.ToString( CultureInfo.InvariantCulture ),
			run.SimVersion.ToString( CultureInfo.InvariantCulture ),
			run.Seed.ToString( CultureInfo.InvariantCulture ),
			run.StepCount.ToString( CultureInfo.InvariantCulture ),
			run.FinalScore.ToString( CultureInfo.InvariantCulture ),
			run.InputDeltas ?? "",
			run.DailyId ?? "",
			run.LevelId ?? "",
			run.LevelHash ?? "",
			run.CharacterId ?? ""
		} );
		return "replay_views_" + identity.Md5().ToLowerInvariant();
	}

	public static ReplayViewStats Begin( RunData run, long steamId )
	{
		// Matches share-code export and local saving when the caller has no author metadata.
		if ( steamId == 0 ) steamId = (long)Game.SteamId;
		var visit = new ReplayViewStats( StatNameFor( run, steamId ) );
		_ = visit.LoadAsync( visit._lifetime.Token );
		return visit;
	}

	/// <summary>
	/// Stop waiting and updating this visit. GlobalStats.Refresh has no cancellation API,
	/// so an engine-owned HTTP request already in flight may finish in the background. The queued
	/// view remains valid even when the user leaves immediately; cancelling never resubmits it.
	/// </summary>
	public void Cancel()
	{
		if ( _cancelled ) return;
		_cancelled = true;
		if ( !_finished ) _lifetime.Cancel();
	}

	private async Task LoadAsync( CancellationToken token )
	{
		try
		{
			Sandbox.Services.Stats.Increment( _statName, 1 );
			EditorLog( $"Queued view for '{_statName}'; sharing pending flush: {_flushTask is not null && !_flushTask.IsCompleted}." );
			// Do not gate reads on the upload: the installed FlushAndWaitAsync doesn't wait for
			// ingestion, and cancellation doesn't bound its HTTP request or semaphore wait.
			if ( _flushTask is null || _flushTask.IsCompleted )
				_flushTask = FlushAsync();

			// Read once on entry. This snapshot may predate the view queued above; submission
			// proceeds independently, with no follow-up reads while this replay stays open.
			token.ThrowIfCancellationRequested();
			var started = DateTime.UtcNow;
			try
			{
				var stats = Sandbox.Services.Stats.Global;
				if ( stats is null )
				{
					EditorLog( $"Read for '{_statName}': global stats unavailable." );
					return;
				}
				var previousRefresh = stats.LastRefresh;
				EditorLog( $"Read for '{_statName}' started; last refresh UTC: {previousRefresh:O}; sharing pending refresh: {_refreshTask is not null && !_refreshTask.IsCompleted}." );
				if ( _refreshTask is null || _refreshTask.IsCompleted )
				{
					_refreshTask = stats.Refresh();
					_ = ObserveAsync( _refreshTask );
				}
				await AwaitBoundedAsync( _refreshTask, token );
				token.ThrowIfCancellationRequested();
				bool found = stats.TryGet( _statName, out var stat );
				if ( Application.IsEditor )
				{
					string age = stats.LastRefresh == default ? "never refreshed" : $"{(DateTime.UtcNow - stats.LastRefresh).TotalSeconds:F2}s";
					string result = found ? $"present; viewers={stat.Players}, views={stat.Sum}" : "absent";
					EditorLog( $"Read for '{_statName}' completed in {(DateTime.UtcNow - started).TotalSeconds:F2}s; stat {result}; last refresh UTC: {stats.LastRefresh:O}; cache age: {age}; refresh timestamp advanced: {stats.LastRefresh > previousRefresh}. An unchanged timestamp can mean a cached result or no successful fetch." );
				}
				if ( found )
				{
					Viewers = stat.Players;
					Views = stat.Sum;
				}
			}
			catch ( OperationCanceledException ) when ( token.IsCancellationRequested )
			{
				throw;
			}
			catch ( Exception e )
			{
				EditorLog( $"Read for '{_statName}' failed after {(DateTime.UtcNow - started).TotalSeconds:F2}s ({e.Message})." );
			}
		}
		catch ( OperationCanceledException ) when ( token.IsCancellationRequested ) { }
		catch ( Exception e )
		{
			EditorLog( $"View submission for '{_statName}' failed ({e.Message})." );
		}
		finally
		{
			if ( !token.IsCancellationRequested )
			{
				IsLoading = false;
				Revision++;
			}
			_finished = true;
			_lifetime.Dispose();
		}
	}

	private static async Task FlushAsync()
	{
		try
		{
			await Sandbox.Services.Stats.FlushAsync();
			EditorLog( "Flush call completed; this does not confirm backend ingestion or that pending views were included." );
		}
		catch ( Exception e )
		{
			// An uncertain submission must not be retried: it may already have counted remotely.
			EditorLog( $"Flush failed ({e.Message})." );
		}
	}

	private static void EditorLog( string message )
	{
		if ( Application.IsEditor ) Log.Info( $"[BlockParty] Replay views: {message}" );
	}

	private static async Task AwaitBoundedAsync( Task request, CancellationToken token )
	{
		using var timeout = CancellationTokenSource.CreateLinkedTokenSource( token );
		timeout.CancelAfter( RequestTimeout );
		try
		{
			// Task.WhenAny is not on s&box's whitelist. Yield between completion
			// checks instead; this never starts another HTTP request and exit cancels the delay.
			while ( !request.IsCompleted )
				await Task.Delay( 50, timeout.Token );
			token.ThrowIfCancellationRequested();
			await request;
		}
		catch ( OperationCanceledException ) when ( timeout.IsCancellationRequested && !token.IsCancellationRequested )
		{
			throw new TimeoutException( "Replay stats read timed out." );
		}
	}

	// Observe once per engine request, even if every visit waiting for it is cancelled. Late
	// failures do not touch a retired visit or create another waiter per timed-out/cancelled visit.
	private static async Task ObserveAsync( Task request )
	{
		try { await request; }
		catch { }
	}
}