Code/SboxSuperuser/Engine/Admin/AdminServerModel.cs

Client-side state model for the admin menu in SboxSuperuser. Sends AdminQuery requests to the host, keeps cached replies for commands, restrictions, alt suspects, content bans, props, permissions, chat, documentation pages and menu windows, polls some of them on a timer, and runs AdminCommandRequest commands while matching every reply back by request ID.

namespace SboxSuperuser.Engine.Admin;

/// <summary>
/// Client state for the server-wide admin views: permitted commands, active restrictions, permission policy and the
/// result of the last command run from the menu. Requests go out only when a view asks; replies are matched by ID.
/// </summary>
internal sealed class AdminServerModel
{
	private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds( 5 );
	private static readonly TimeSpan ChatInterval = TimeSpan.FromSeconds( 2 );

	private readonly Func<AdminQuery, Guid> _query;
	private readonly Func<AdminCommandRequest, Guid> _command;
	private Guid _commandsRequest;
	private Guid _restrictionsRequest;
	private Guid _suspectsRequest;
	private Guid _altsRequest;
	private DateTimeOffset _suspectsAfter;
	private Guid _contentBansRequest;
	private DateTimeOffset _contentBansAfter;
	private Guid _propsRequest;
	private DateTimeOffset _propsAfter;
	private Guid _permissionsRequest;
	private Guid _chatRequest;
	private DateTimeOffset _chatAfter;
	private Guid _runRequest;
	private DateTimeOffset _restrictionsAfter;
	private bool _permissionsStale = true;
	private Guid _documentationRequest;
	private Guid _menuWindowsRequest;
	private readonly Dictionary<Guid, string> _liveRequests = new();
	private readonly Dictionary<string, (AdminReply Reply, DateTimeOffset At)> _live = new( StringComparer.Ordinal );
	private readonly Dictionary<Guid, (string Module, int Page)> _pageRequests = new();
	private readonly Dictionary<(string Module, int Page), AdminReply> _pages = new();

	internal AdminReply Commands { get; private set; }
	internal AdminReply Restrictions { get; private set; }
	/// <summary>The alt-account dashboard, riskiest suspects first.</summary>
	internal AdminReply AltSuspects { get; private set; }
	/// <summary>The accounts linked to <see cref="AltsOf"/>.</summary>
	internal AdminReply Alts { get; private set; }
	internal string AltsOf { get; private set; } = "";
	internal AdminReply ContentBans { get; private set; }
	internal AdminReply Props { get; private set; }
	internal int SuspectPage;
	internal AdminReply Permissions { get; private set; }
	internal AdminReply Chat { get; private set; }
	internal AdminReply Documentation { get; private set; }

	/// <summary>The windows modules added to the menu, filtered by the host to what this viewer may use.</summary>
	internal AdminReply MenuWindows { get; private set; }

	/// <summary>Reply to the last command run from the menu, and which command it was.</summary>
	internal AdminReply LastResult { get; private set; }
	internal string LastCommand { get; private set; } = "";
	internal bool Running => _runRequest != Guid.Empty;

	internal string RestrictionSearch = "";
	internal int RestrictionPage;

	internal AdminServerModel( Func<AdminQuery, Guid> query, Func<AdminCommandRequest, Guid> command )
	{
		_query = query;
		_command = command;
	}

	/// <summary>True once the command list says the caller may run this command.</summary>
	internal bool Can( string command ) => Commands?.Commands.Any( row => row.Name == command ) == true;

	internal void RequestCommands()
	{
		if ( Commands is not null || _commandsRequest != Guid.Empty ) return;
		Try( () => _commandsRequest = _query( new AdminQuery( AdminQueryKind.Commands ) ) );
	}

	/// <summary>Keeps the restrictions list fresh while its window is open.</summary>
	internal void RequestAltSuspects( DateTimeOffset now, bool force = false )
	{
		if ( _suspectsRequest != Guid.Empty || !force && now < _suspectsAfter ) return;
		_suspectsAfter = now + RefreshInterval;
		Try( () => _suspectsRequest = _query( new AdminQuery( AdminQueryKind.AltSuspects, "", SuspectPage ) ) );
	}

	/// <summary>Asks for one account's linked alts; the reply replaces <see cref="Alts"/>.</summary>
	internal void RequestAlts( string steamId )
	{
		AltsOf = steamId;
		Alts = null;
		Try( () => _altsRequest = _query( new AdminQuery( AdminQueryKind.Alts, steamId ) ) );
	}

	/// <summary>Keeps the banned-content list fresh while its window is open.</summary>
	internal void RequestContentBans( DateTimeOffset now )
	{
		if ( _contentBansRequest != Guid.Empty || now < _contentBansAfter ) return;
		_contentBansAfter = now + RefreshInterval;
		Try( () => _contentBansRequest = _query( new AdminQuery( AdminQueryKind.ContentBans ) ) );
	}

	/// <summary>Keeps prop owners and the cleanup delay fresh while their window is open.</summary>
	internal void RequestProps( DateTimeOffset now )
	{
		if ( _propsRequest != Guid.Empty || now < _propsAfter ) return;
		_propsAfter = now + RefreshInterval;
		Try( () => _propsRequest = _query( new AdminQuery( AdminQueryKind.Props ) ) );
	}

	internal void RequestRestrictions( DateTimeOffset now, bool force = false )
	{
		if ( _restrictionsRequest != Guid.Empty || !force && now < _restrictionsAfter ) return;
		_restrictionsAfter = now + RefreshInterval;
		Try( () => _restrictionsRequest = _query( new AdminQuery( AdminQueryKind.Restrictions, RestrictionSearch, RestrictionPage ) ) );
	}

	/// <summary>Keeps recent chat fresh while a view shows it; chat moves faster than restrictions.</summary>
	internal void RequestChat( DateTimeOffset now )
	{
		if ( _chatRequest != Guid.Empty || now < _chatAfter ) return;
		_chatAfter = now + ChatInterval;
		Try( () => _chatRequest = _query( new AdminQuery( AdminQueryKind.Chat ) ) );
	}

	/// <summary>Loads the documented-module list once; Refresh reloads it with every page.</summary>
	internal void RequestDocumentation()
	{
		if ( Documentation is not null || _documentationRequest != Guid.Empty ) return;
		Try( () => _documentationRequest = _query( new AdminQuery( AdminQueryKind.Documentation ) ) );
	}

	internal void RefreshDocumentation()
	{
		Documentation = null;
		_pages.Clear();
		RequestDocumentation();
	}

	/// <summary>Returns a cached documentation page, asking the host for it the first time; null while it loads.</summary>
	internal AdminReply DocumentationPage( string module, int page )
	{
		if ( _pages.TryGetValue( (module, page), out var reply ) ) return reply;
		if ( !_pageRequests.ContainsValue( (module, page) ) )
			Try( () => _pageRequests[ _query( new AdminQuery( AdminQueryKind.Documentation, module, page ) ) ] = (module, page) );
		return null;
	}

	/// <summary>Loads the module windows once; Refresh reloads them, for example when the menu opens.</summary>
	internal void RequestMenuWindows()
	{
		if ( MenuWindows is not null || _menuWindowsRequest != Guid.Empty ) return;
		Try( () => _menuWindowsRequest = _query( new AdminQuery( AdminQueryKind.MenuWindows ) ) );
	}

	internal void RefreshMenuWindows()
	{
		MenuWindows = null;
		_live.Clear();
		RequestMenuWindows();
	}

	/// <summary>
	/// A live element's latest reply; null while the first one loads. It asks again every few seconds while shown and
	/// right after RefreshLive, keeping the old reply on screen until the new one arrives.
	/// </summary>
	internal AdminReply Live( string key, string command, IReadOnlyList<string> arguments, DateTimeOffset now )
	{
		var cached = _live.TryGetValue( key, out var entry );
		if ( (!cached || now - entry.At >= RefreshInterval) && !_liveRequests.ContainsValue( key ) )
			Try( () => _liveRequests[ _command( new AdminCommandRequest( command, arguments ) ) ] = key );
		return cached ? entry.Reply : null;
	}

	/// <summary>Discards this search and its pending replies when its arguments change.</summary>
	internal void ResetLive( string key )
	{
		_live.Remove( key );
		foreach ( var request in _liveRequests.Where( pair => pair.Value == key ).Select( pair => pair.Key ).ToArray() ) _liveRequests.Remove( request );
	}

	/// <summary>Makes every live element ask again now; called after any command runs from the menu.</summary>
	internal void RefreshLive()
	{
		foreach ( var key in _live.Keys.ToArray() ) _live[ key ] = (_live[ key ].Reply, DateTimeOffset.MinValue);
	}

	/// <summary>Reloads the policy when it is first shown and after every command that may have changed it.</summary>
	internal void RequestPermissions()
	{
		if ( !_permissionsStale || _permissionsRequest != Guid.Empty ) return;
		_permissionsStale = false;
		Try( () => _permissionsRequest = _query( new AdminQuery( AdminQueryKind.Permissions ) ) );
	}

	/// <summary>Runs one command; returns false while another is still waiting for the host.</summary>
	internal bool Run( string command, IEnumerable<string> arguments )
	{
		if ( Running ) return false;
		LastCommand = command;
		LastResult = null;
		Try( () => _runRequest = _command( new AdminCommandRequest( command, arguments ) ) );
		return Running;
	}

	/// <summary>Takes a reply meant for this model; returns false when the reply belongs to someone else.</summary>
	internal bool Receive( AdminReply reply )
	{
		if ( reply.RequestId == _commandsRequest )
		{
			_commandsRequest = Guid.Empty;
			Commands = reply;
		}
		else if ( reply.RequestId == _suspectsRequest )
		{
			_suspectsRequest = Guid.Empty;
			AltSuspects = reply;
		}
		else if ( reply.RequestId == _altsRequest )
		{
			_altsRequest = Guid.Empty;
			Alts = reply;
		}
		else if ( reply.RequestId == _contentBansRequest )
		{
			_contentBansRequest = Guid.Empty;
			ContentBans = reply;
		}
		else if ( reply.RequestId == _propsRequest )
		{
			_propsRequest = Guid.Empty;
			Props = reply;
		}
		else if ( reply.RequestId == _restrictionsRequest )
		{
			_restrictionsRequest = Guid.Empty;
			Restrictions = reply;
		}
		else if ( reply.RequestId == _permissionsRequest )
		{
			_permissionsRequest = Guid.Empty;
			Permissions = reply;
		}
		else if ( reply.RequestId == _chatRequest )
		{
			_chatRequest = Guid.Empty;
			Chat = reply;
		}
		else if ( reply.RequestId == _documentationRequest )
		{
			_documentationRequest = Guid.Empty;
			Documentation = reply;
		}
		else if ( reply.RequestId == _menuWindowsRequest )
		{
			_menuWindowsRequest = Guid.Empty;
			MenuWindows = reply;
		}
		else if ( _liveRequests.Remove( reply.RequestId, out var live ) )
		{
			_live[ live ] = (reply, DateTimeOffset.UtcNow);
		}
		else if ( _pageRequests.Remove( reply.RequestId, out var page ) )
		{
			_pages[ page ] = reply;
		}
		else if ( reply.RequestId == _runRequest )
		{
			_runRequest = Guid.Empty;
			LastResult = reply;
			RefreshLive();
			// Any command may have changed what these views show, so they reload.
			_restrictionsAfter = DateTimeOffset.MinValue;
			_contentBansAfter = DateTimeOffset.MinValue;
			_propsAfter = DateTimeOffset.MinValue;
			_permissionsStale = true;
		}
		else
		{
			return false;
		}
		return true;
	}

	private void Try( Action action )
	{
		try { action(); }
		catch ( ArgumentException error ) { Fail( error.Message ); }
		catch ( InvalidOperationException error ) { Fail( error.Message ); }
	}

	private void Fail( string message ) => LastResult = new AdminReply { Status = CommandStatus.InvalidArguments, Message = message };
}