Code/Core/NetworkStorageRevisionOutdated.cs
using System;
using System.Text.Json;

namespace Sandbox;

/// <summary>
/// Why the revision was detected as outdated.
/// </summary>
public enum RevisionOutdatedReason
{
	/// <summary>Detected via the load-profile endpoint response.</summary>
	LoadProfileDetected,

	/// <summary>Triggered manually for testing (no backend required).</summary>
	ManualTest,

	/// <summary>Lobby metadata indicates a different revision.</summary>
	LobbyMismatch,

	/// <summary>Grace period expired, revision is now blocked.</summary>
	GraceExpired
}

/// <summary>
/// Data from the load-profile endpoint's revision block.
/// Fires <see cref="NetworkStorage.OnRevisionOutdated"/> when
/// the server reports the client's revision is outdated.
/// </summary>
public struct RevisionOutdatedData
{
	/// <summary>Creates a default instance.</summary>
	public RevisionOutdatedData() { }

	/// <summary>The revision the client is running.</summary>
	public long CurrentRevisionId { get; init; }

	/// <summary>The latest revision available on the server.</summary>
	public long LatestRevisionId { get; init; }

	/// <summary>True when the client revision is older than latest.</summary>
	public bool RevisionOutdated { get; init; }

	/// <summary>Seconds remaining in the grace period, or 0.</summary>
	public int GraceSeconds { get; init; }

	/// <summary>Unix timestamp when grace ends, or null.</summary>
	public long? GraceEndsAtUnixSeconds { get; init; }

	/// <summary>Server's current unix timestamp.</summary>
	public long ServerUnixSeconds { get; init; }

	/// <summary>
	/// Source identifier describing where the detection came from.
	/// Typical values: "load-profile", "manual-test", "lobby"
/// </summary>
	public string Source { get; init; }

	/// <summary>
	/// Why this revision-outdated event was raised.
	/// </summary>
	public RevisionOutdatedReason Reason { get; init; }

	// ── Enforcement mode fields (from _revisionStatus API) ──

	/// <summary>
	/// The enforcement mode from the server. Defaults to ForceUpgrade for backward compatibility.
	/// </summary>
	public RevisionEnforcementMode EnforcementMode { get; init; } = RevisionEnforcementMode.ForceUpgrade;

	/// <summary>
	/// Custom message from the server to display to players.
	/// </summary>
	public string Message { get; init; }

	/// <summary>
	/// What action is currently required: "warn", "block_saves", "block_all".
	/// </summary>
	public string Action { get; init; }

	/// <summary>
	/// True when the grace period has expired (from server).
	/// </summary>
	public bool GraceExpired { get; init; }

	/// <summary>
	/// Minutes remaining in the grace period (from server), or null if no grace.
	/// </summary>
	public int? GraceRemainingMinutes { get; init; }

	/// <summary>
	/// True if the server wants to show update options (Create/Join buttons).
	/// </summary>
	public bool ShowUpdateOptions { get; init; } = true;

	/// <summary>
	/// True if popup should only show once per session.
	/// </summary>
	public bool ShowPopupOnce { get; init; } = true;

	/// <summary>Seconds until grace expires (negative if already expired).</summary>
	public int TimeRemaining
	{
		get
		{
			if ( !GraceEndsAtUnixSeconds.HasValue )
				return 0;
			var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			return (int)Math.Max( 0, GraceEndsAtUnixSeconds.Value - now );
		}
	}

	/// <summary>True when the grace period has fully expired.</summary>
	public bool IsGraceExpired
	{
		get
		{
			if ( !GraceEndsAtUnixSeconds.HasValue )
				return false;
			var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
			return now >= GraceEndsAtUnixSeconds.Value;
		}
	}

	internal static RevisionOutdatedData? FromJson( JsonElement json )
	{
		if ( json.ValueKind != JsonValueKind.Object )
			return null;

		if ( !json.TryGetProperty( "revisionOutdated", out var outdated ) )
			return null;

		return new RevisionOutdatedData
		{
			CurrentRevisionId = json.TryGetProperty( "currentRevisionId", out var cr ) && cr.ValueKind == JsonValueKind.Number ? cr.GetInt64() : 0,
			LatestRevisionId = json.TryGetProperty( "latestRevisionId", out var lr ) && lr.ValueKind == JsonValueKind.Number ? lr.GetInt64() : 0,
			RevisionOutdated = outdated.ValueKind == JsonValueKind.True,
			GraceSeconds = json.TryGetProperty( "graceSeconds", out var gs ) && gs.ValueKind == JsonValueKind.Number ? gs.GetInt32() : 0,
			GraceEndsAtUnixSeconds = json.TryGetProperty( "graceEndsAtUnixSeconds", out var ge ) && ge.ValueKind == JsonValueKind.Number ? ge.GetInt64() : (long?)null,
			ServerUnixSeconds = json.TryGetProperty( "serverUnixSeconds", out var su ) && su.ValueKind == JsonValueKind.Number ? su.GetInt64() : DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
			Source = "load-profile",
			Reason = RevisionOutdatedReason.LoadProfileDetected,
		};
	}

	internal static RevisionOutdatedData? FromRevisionStatusJson( JsonElement json )
	{
		if ( json.ValueKind != JsonValueKind.Object )
			return null;

		if ( !json.TryGetProperty( "isOutdatedRevision", out var outdated ) )
			return null;

		var enforcementMode = RevisionEnforcementMode.ForceUpgrade;
		if ( json.TryGetProperty( "enforcementMode", out var em ) && em.ValueKind == JsonValueKind.String && em.GetString() == "allow_continue" )
			enforcementMode = RevisionEnforcementMode.AllowContinue;

		var graceRemaining = json.TryGetProperty( "graceRemainingMinutes", out var gr ) && gr.ValueKind == JsonValueKind.Number ? gr.GetInt32() : (int?)null;
		var graceSeconds = graceRemaining.HasValue ? Math.Max( 0, graceRemaining.Value * 60 ) : 0;
		var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

		var showUpdateOptions = true;
		var showPopupOnce = true;
		if ( json.TryGetProperty( "policy", out var policy ) && policy.ValueKind == JsonValueKind.Object )
		{
			showUpdateOptions = !policy.TryGetProperty( "showUpdateOptions", out var suo ) || suo.ValueKind != JsonValueKind.False;
			showPopupOnce = !policy.TryGetProperty( "showPopupOnce", out var spo ) || spo.ValueKind != JsonValueKind.False;
		}

		return new RevisionOutdatedData
		{
			CurrentRevisionId = json.TryGetProperty( "playerRevision", out var pr ) && pr.ValueKind == JsonValueKind.Number ? pr.GetInt64() : 0,
			LatestRevisionId = json.TryGetProperty( "currentRevision", out var cr ) && cr.ValueKind == JsonValueKind.Number ? cr.GetInt64() : 0,
			RevisionOutdated = outdated.ValueKind == JsonValueKind.True,
			GraceSeconds = graceSeconds,
			GraceEndsAtUnixSeconds = graceSeconds > 0 ? now + graceSeconds : null,
			ServerUnixSeconds = now,
			Source = "_revisionStatus",
			Reason = json.TryGetProperty( "graceExpired", out var ge ) && ge.ValueKind == JsonValueKind.True
				? RevisionOutdatedReason.GraceExpired
				: RevisionOutdatedReason.LoadProfileDetected,
			EnforcementMode = enforcementMode,
			Message = json.TryGetProperty( "message", out var message ) ? message.GetString() : null,
			Action = json.TryGetProperty( "action", out var action ) ? action.GetString() : null,
			GraceExpired = json.TryGetProperty( "graceExpired", out var expired ) && expired.ValueKind == JsonValueKind.True,
			GraceRemainingMinutes = graceRemaining,
			ShowUpdateOptions = showUpdateOptions,
			ShowPopupOnce = showPopupOnce
		};
	}
}

public static partial class NetworkStorage
{
	/// <summary>
	/// Fired when the load-profile endpoint reports an outdated revision,
	/// or when <see cref="TestFireRevisionOutdated"/> is called.
	/// Subscribe to show custom in-game UI for revision warnings.
	/// </summary>
	public static event Action<RevisionOutdatedData> OnRevisionOutdated;

	/// <summary>
	/// Called internally when a parsed server response contains a
	/// load-profile response contains a <c>revision</c> block.
	/// </summary>
	internal static void FireRevisionOutdated( RevisionOutdatedData data )
	{
		if ( data.RevisionOutdated )
		{
			NetworkStoragePackageInfo.UpdateFromRevisionBlock( data );
			OnRevisionOutdated?.Invoke( data );
		}
	}
}