SboxSuperuser/Core/Menu/MenuPreferences.cs

A preferences record in the SboxSuperuser moderator menu. Stores recent kick/ban reasons and the last used duration on the local machine, with a revision counter so the caller knows when to save. Serializes to and validates from JSON with schema version, size and length limits.

using System.Text.Json;

namespace SboxSuperuser.Core.Menu;

/// <summary>Moderator conveniences kept on their own machine: recently used reasons and the last duration.</summary>
internal sealed class MenuPreferences
{
	internal const int SchemaVersion = 1;
	internal const int MaximumCharacters = 8192;
	internal const int MaximumReasons = 8;
	private const int MaximumReasonLength = 256;
	private const int MaximumDurationLength = 32;

	private readonly List<string> _reasons = new();

	internal IReadOnlyList<string> RecentReasons => _reasons;
	internal string LastDuration { get; private set; } = "";

	/// <summary>Raised by every change, so the caller knows the preferences need saving.</summary>
	internal int Revision { get; private set; }

	/// <summary>Records a confirmed action: its reason moves to the front of the list and its duration is kept.</summary>
	internal void Remember( string reason, string duration )
	{
		reason = reason?.Trim() ?? "";
		if ( IsValid( reason, MaximumReasonLength ) && (_reasons.Count == 0 || _reasons[ 0 ] != reason) )
		{
			_reasons.Remove( reason );
			_reasons.Insert( 0, reason );
			if ( _reasons.Count > MaximumReasons ) _reasons.RemoveAt( _reasons.Count - 1 );
			Revision++;
		}

		duration = duration?.Trim() ?? "";
		if ( IsValid( duration, MaximumDurationLength ) && duration != LastDuration )
		{
			LastDuration = duration;
			Revision++;
		}
	}

	internal string Write() => JsonSerializer.Serialize( new
	{
		schemaVersion = SchemaVersion,
		recentReasons = _reasons,
		lastDuration = LastDuration
	} );

	internal static MenuPreferences Parse( string json )
	{
		if ( string.IsNullOrEmpty( json ) || json.Length > MaximumCharacters )
			throw new ArgumentException( "Menu preferences are empty or too large." );

		using var document = JsonDocument.Parse( json );
		var root = document.RootElement;
		if ( root.ValueKind != JsonValueKind.Object
			|| !root.TryGetProperty( "schemaVersion", out var schema ) || !schema.TryGetInt32( out var version ) || version != SchemaVersion
			|| !root.TryGetProperty( "recentReasons", out var reasons ) || reasons.ValueKind != JsonValueKind.Array
			|| reasons.GetArrayLength() > MaximumReasons
			|| !root.TryGetProperty( "lastDuration", out var duration ) || duration.ValueKind != JsonValueKind.String )
		{
			throw new ArgumentException( $"Expected menu preferences schemaVersion {SchemaVersion}." );
		}

		var preferences = new MenuPreferences();
		foreach ( var reason in reasons.EnumerateArray() )
		{
			var text = reason.ValueKind == JsonValueKind.String ? reason.GetString() : null;
			if ( !IsValid( text, MaximumReasonLength ) || preferences._reasons.Contains( text ) )
				throw new ArgumentException( "Recent reasons must be distinct printable text." );
			preferences._reasons.Add( text );
		}

		var last = duration.GetString();
		if ( last.Length > 0 && !IsValid( last, MaximumDurationLength ) ) throw new ArgumentException( "Invalid last duration." );
		preferences.LastDuration = last;
		return preferences;
	}

	private static bool IsValid( string text, int maximumLength ) =>
		!string.IsNullOrEmpty( text ) && text.Length <= maximumLength && !text.Any( char.IsControl );
}