Player/UndoSystem/UndoSystem.cs
using Sandbox.UI;
using System.Collections.Generic;
using System.Linq;

internal class UndoSystem : GameObjectSystem<UndoSystem>
{
	public sealed class HistoryItem
	{
		public Guid Id { get; set; }
		public string Name { get; set; }
		public string Icon { get; set; }
		public int ObjectCount { get; set; }
	}

	/// <summary>
	/// Raised on the requesting client when the host sends the latest undo history.
	/// </summary>
	public static event Action<IReadOnlyList<HistoryItem>> HistoryUpdated;

	Dictionary<long, PlayerStack> stacks = new();

	public UndoSystem( Scene scene ) : base( scene )
	{
	}

	/// <summary>
	/// Get the undo stack for a specific SteamId
	/// </summary>
	public PlayerStack For( long steamId )
	{
		if ( !stacks.TryGetValue( steamId, out var stack ) )
		{
			stack = new PlayerStack( steamId );
			stacks[steamId] = stack;
		}
		return stack;
	}

	/// <summary>
	/// Call this when a player disconnects to prevent memory leaks!
	/// </summary>
	public void RemovePlayer( long steamId )
	{
		stacks.Remove( steamId );
	}

	/// <summary>
	/// Remove a GameObject from all player undo stacks.
	/// </summary>
	public void Remove( GameObject go )
	{
		foreach ( var stack in stacks.Values )
		{
			stack.Remove( go );
		}
	}

	/// <summary>
	/// Ask the host for the local player's current undo history.
	/// </summary>
	[Rpc.Host]
	public static void RequestHistory()
	{
		if ( Rpc.Caller is not { } caller ) return;

		SendHistory( caller );
	}

	/// <summary>
	/// Undo one specific history entry. The caller can only address their own stack.
	/// </summary>
	[Rpc.Host]
	public static void UndoEntry( Guid id )
	{
		if ( Rpc.Caller is not { } caller ) return;

		Current?.For( caller.SteamId ).Undo( id );
		SendHistory( caller );
	}

	private static void SendHistory( Connection target )
	{
		if ( !Networking.IsHost || target is null ) return;

		var history = Current?.For( target.SteamId ).GetHistory() ?? [];
		var json = Sandbox.Json.Serialize( history );

		using ( Rpc.FilterInclude( target ) )
		{
			ReceiveHistory( json );
		}
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private static void ReceiveHistory( string json )
	{
		var history = Sandbox.Json.Deserialize<List<HistoryItem>>( json ) ?? [];
		HistoryUpdated?.Invoke( history );
	}

	/// <summary>
	/// Per-player undo stack
	/// </summary>
	public class PlayerStack
	{
		long steamId;
		List<Entry> entries = new();
		const int MaxUndoSteps = 128; // Bounded history prevents indefinite memory leaks

		public PlayerStack( long steamId )
		{
			this.steamId = steamId;
		}

		/// <summary>
		/// Create a new undo entry
		/// </summary>
		public Entry Create()
		{
			var entry = new Entry( steamId );
			entries.Add( entry );

			if ( entries.Count > MaxUndoSteps )
			{
				entries.RemoveAt( 0 );
			}

			return entry;
		}

		/// <summary>
		/// Run the undo
		/// </summary>
		public void Undo()
		{
			while ( entries.Count > 0 )
			{
				var entry = entries[^1];
				entries.RemoveAt( entries.Count - 1 );

				if ( entry.Run() )
					break;
			}

			var target = Connection.All.FirstOrDefault( x => x.SteamId == steamId );
			if ( target is not null )
				SendHistory( target );
		}

		/// <summary>
		/// Run and remove one specific entry from this player's history.
		/// </summary>
		public void Undo( Guid id )
		{
			var index = entries.FindIndex( x => x.Id == id );
			if ( index < 0 ) return;

			var entry = entries[index];
			entries.RemoveAt( index );
			entry.Run();
		}

		/// <summary>
		/// Return newest-first history, pruning entries whose objects no longer exist.
		/// </summary>
		public List<HistoryItem> GetHistory()
		{
			entries.RemoveAll( x => !x.HasObjects );

			return entries
				.AsEnumerable()
				.Reverse()
				.Select( x => new HistoryItem
				{
					Id = x.Id,
					Name = x.Name,
					Icon = x.Icon,
					ObjectCount = x.ObjectCount
				} )
				.ToList();
		}

		/// <summary>
		/// Remove a GameObject from all entries in this stack.
		/// </summary>
		public void Remove( GameObject go )
		{
			foreach ( var entry in entries )
				entry.Remove( go );
		}
	}

	/// <summary>
	/// An undo entry
	/// </summary>
	public class Entry
	{
		public Guid Id { get; } = Guid.NewGuid();

		/// <summary>
		/// The name of the undo, should fit the format "Undo something". Like "Undo Spawn Prop".
		/// </summary>
		public string Name { get; set; }
		public string Icon { get; set; }

		long SteamId;

		HashSet<GameObject> gameObjects = new();

		public bool HasObjects => gameObjects.Any( x => x.IsValid() );
		public int ObjectCount => gameObjects.Count( x => x.IsValid() );

		internal Entry( long steamId )
		{
			SteamId = steamId;
		}

		/// <summary>
		/// Add a GameObject that should be destroyed when the undo is undone
		/// </summary>
		public void Add( GameObject go )
		{
			gameObjects.Add( go );
		}

		/// <summary>
		/// Add a collection of GameObjects that should be destroyed when the undo is undone
		/// </summary>
		/// <param name="gos"></param>
		public void Add( params IEnumerable<GameObject> gos )
		{
			foreach ( var go in gos )
			{
				Add( go );
			}
		}

		/// <summary>
		/// Remove a GameObject from this entry so it will no longer be destroyed on undo.
		/// </summary>
		public void Remove( GameObject go )
		{
			gameObjects.Remove( go );
		}

		/// <summary>
		/// Run this undo
		/// </summary>
		public bool Run( bool sendNotice = true )
		{
			var actioned = false;

			foreach ( var go in gameObjects )
			{
				if ( go.IsValid() )
				{
					go.Destroy();
					actioned = true;
				}
			}

			if ( !actioned )
				return false;

			if ( sendNotice )
			{
				var c = Connection.All.FirstOrDefault( x => x.SteamId == SteamId );
				if ( c is not null )
				{
					using ( Rpc.FilterInclude( c ) )
					{
						UndoNotice( Name );
					}
				}
			}

			return true;
		}

		[Rpc.Broadcast]
		public static void UndoNotice( string title )
		{
			Notices.AddNotice( "cached", "#3273eb", $"Undo {title}".Trim(), 5 );
			Sound.Play( "sounds/ui/ui.undo.sound" );
		}
	}
}