SboxSuperuser/Engine/Admin/PrefixEditorModel.cs

Model behind the admin Prefixes window. It requests the prefix catalog from the host with a prefix.export admin command, parses the catalog and group protection list, and sends queued prefix edit commands one at a time, stopping the queue when a command fails.

using System.Text.Json;
using SboxSuperuser.Core.Prefixes;

namespace SboxSuperuser.Engine.Admin;

/// <summary>
/// What the Prefixes window edits: the full catalog (read with prefix.export) and a queue of prefix commands sent
/// one at a time, so a multi-field save never trips the host's request limits. A failed command drops the rest.
/// </summary>
internal sealed class PrefixEditorModel
{
	private readonly Func<AdminCommandRequest, Guid> _command;
	private readonly Queue<AdminCommandRequest> _queue = new();
	private Guid _exportRequest;
	private Guid _editRequest;
	private bool _stale = true;

	internal PrefixEditorModel( Func<AdminCommandRequest, Guid> command ) => _command = command;

	/// <summary>The catalog as the host last reported it, or null before the first answer.</summary>
	internal PrefixCatalog Catalog { get; private set; }

	/// <summary>Every group on the server with its protection level, owner first, highest protection next.</summary>
	internal IReadOnlyList<GroupRank> Groups { get; private set; } = Array.Empty<GroupRank>();

	/// <summary>Why the catalog couldn't be read, or null.</summary>
	internal string Problem { get; private set; }

	/// <summary>The most recent edit's outcome.</summary>
	internal AdminReply LastResult { get; private set; }

	internal bool Busy => _editRequest != Guid.Empty || _queue.Count > 0;

	/// <summary>Reads the catalog the first time and after edits; call every frame the window shows.</summary>
	internal void Request()
	{
		if ( !_stale || _exportRequest != Guid.Empty || Busy ) return;
		_stale = false;
		_exportRequest = Send( new AdminCommandRequest( "prefix.export" ) );
	}

	internal void Refresh() => _stale = true;

	/// <summary>Adds a prefix command to the queue; arguments that are too long are reported, not sent.</summary>
	internal void Queue( string command, IEnumerable<string> arguments )
	{
		try
		{
			_queue.Enqueue( new AdminCommandRequest( command, arguments ) );
		}
		catch ( ArgumentException error )
		{
			LastResult = new AdminReply { Status = CommandStatus.InvalidArguments, Message = error.Message };
			return;
		}
		SendNext();
	}

	internal bool Receive( AdminReply reply )
	{
		if ( reply.RequestId == Guid.Empty ) return false;
		if ( reply.RequestId == _exportRequest )
		{
			_exportRequest = Guid.Empty;
			Read( reply );
			return true;
		}
		if ( reply.RequestId != _editRequest ) return false;
		_editRequest = Guid.Empty;
		LastResult = reply;
		if ( reply.Status is not (CommandStatus.Success or CommandStatus.NoChange) ) _queue.Clear();
		SendNext();
		if ( !Busy ) _stale = true;
		return true;
	}

	private void Read( AdminReply reply )
	{
		if ( reply.Status != CommandStatus.Success || !reply.Data.TryGetValue( "catalog", out var state ) )
		{
			Problem = reply.Message;
			return;
		}
		try
		{
			using var json = JsonDocument.Parse( state );
			Catalog = PrefixCodec.ReadState( json.RootElement );
			Groups = ReadGroups( reply.Data.GetValueOrDefault( "groups" ) ?? "" );
			Problem = null;
		}
		catch ( Exception error ) when ( error is JsonException or FormatException or ArgumentException or KeyNotFoundException or InvalidOperationException )
		{
			Problem = "The host sent prefixes this version can't read.";
		}
	}

	private static GroupRank[] ReadGroups( string text ) => text
		.Split( ',', StringSplitOptions.RemoveEmptyEntries )
		.Select( pair => pair.Split( ':' ) )
		.Where( parts => parts.Length == 2 && int.TryParse( parts[ 1 ], out _ ) )
		.Select( parts => new GroupRank( parts[ 0 ], int.Parse( parts[ 1 ] ) ) )
		.OrderByDescending( group => group.Protection )
		.ThenBy( group => group.GroupId, StringComparer.Ordinal )
		.ToArray();

	private void SendNext()
	{
		if ( _editRequest != Guid.Empty || _queue.Count == 0 ) return;
		_editRequest = Send( _queue.Dequeue() );
		if ( _editRequest == Guid.Empty ) _queue.Clear();
	}

	private Guid Send( AdminCommandRequest request )
	{
		try
		{
			return _command( request );
		}
		catch ( InvalidOperationException error )
		{
			LastResult = new AdminReply { Status = CommandStatus.Conflict, Message = error.Message };
			return Guid.Empty;
		}
	}
}