SboxSuperuser/Engine/Admin/AdminServerModel.cs

Client-side state model for the server-wide admin menu. Sends AdminQuery and AdminCommandRequest messages through injected delegates, matches replies by request ID, tracks in-flight requests per view, and refreshes lists on timers (5s for lists, 2s for chat) or after a command runs.

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 };
}