Code/Storage/NetworkStorageCollections.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

namespace Sandbox;

public static partial class NetworkStorage
{
	/// <summary>
	/// Save or replace a collection document. Defaults to the current player's Steam ID.
	/// On dedicated servers, a configured secret key is sent automatically so endpoint-only
	/// collections can be edited when the key has collection execute permission.
	/// </summary>
	public static Task<JsonElement?> SaveDocument( string collectionId, string documentId, object data )
		=> SendStorageRequest( "storage-save", "POST", StorageDocumentPath( collectionId, documentId ), data ?? new { } );

	/// <summary>Alias for SaveDocument.</summary>
	public static Task<JsonElement?> SetDocument( string collectionId, string documentId, object data )
		=> SaveDocument( collectionId, documentId, data );

	/// <summary>
	/// Apply server-side operations to a collection document. This avoids sending the full
	/// document and lets the backend validate increments, ledgers, and rate limits.
	/// </summary>
	public static Task<JsonElement?> UpdateDocument( string collectionId, string documentId, IEnumerable<NetworkStorageOperation> operations )
	{
		var ops = operations?.Where( op => op is not null ).ToArray() ?? Array.Empty<NetworkStorageOperation>();
		return SendStorageRequest( "storage-update", "POST", StorageDocumentPath( collectionId, documentId ), new { ops } );
	}

	/// <summary>Apply server-side operations to a collection document.</summary>
	public static Task<JsonElement?> UpdateDocument( string collectionId, string documentId, params NetworkStorageOperation[] operations )
		=> UpdateDocument( collectionId, documentId, (IEnumerable<NetworkStorageOperation>)operations );

	/// <summary>Alias for UpdateDocument.</summary>
	public static Task<JsonElement?> PatchDocument( string collectionId, string documentId, params NetworkStorageOperation[] operations )
		=> UpdateDocument( collectionId, documentId, operations );

	/// <summary>
	/// Delete a collection document. The backend must have allowRecordDelete enabled for the collection.
	/// Defaults to the current player's Steam ID.
	/// </summary>
	public static Task<JsonElement?> DeleteDocument( string collectionId, string documentId = null )
		=> SendStorageRequest( "storage-delete", "DELETE", StorageDocumentPath( collectionId, documentId ) );

	/// <summary>List save-record metadata for a player in a multi-record collection.</summary>
	public static Task<JsonElement?> ListRecords( string collectionId, string steamId = null )
		=> SendStorageRequest( "storage-records", "GET", StorageRecordIndexPath( collectionId, steamId ) );

	/// <summary>Create a save-record entry for a player in a multi-record collection.</summary>
	public static Task<JsonElement?> CreateRecord( string collectionId, string steamId = null, string recordName = null )
	{
		object body = string.IsNullOrWhiteSpace( recordName ) ? new { } : new { recordName };
		return SendStorageRequest( "storage-record-create", "POST", StorageRecordIndexPath( collectionId, steamId ), body );
	}

	/// <summary>Delete a save-record entry and its stored document data.</summary>
	public static Task<JsonElement?> DeleteRecord( string collectionId, string recordId, string steamId = null )
		=> SendStorageRequest( "storage-record-delete", "DELETE", $"{StorageRecordIndexPath( collectionId, steamId )}/{EscapeRouteSegment( recordId )}" );

	/// <summary>Rename a save-record entry.</summary>
	public static Task<JsonElement?> RenameRecord( string collectionId, string recordId, string recordName, string steamId = null )
		=> SendStorageRequest( "storage-record-rename", "PATCH", $"{StorageRecordIndexPath( collectionId, steamId )}/{EscapeRouteSegment( recordId )}", new { recordName } );

	private static async Task<JsonElement?> SendStorageRequest( string tag, string method, string path, object body = null )
	{
		EnsureConfigured();
		ClearLastEndpointError( tag );

		string url = null;
		string bodyJson = null;
		try
		{
			var usesDedicatedSecret = TryBuildDedicatedStorageHeaders( out var headers );
			if ( !usesDedicatedSecret )
			{
				if ( TryRejectDedicatedServerPlayerAuth( tag ) )
					return null;
				headers = await BuildAuthHeaders();
			}
			url = BuildUrl( path );

			HttpContent content = null;
			if ( body is not null )
			{
				bodyJson = JsonSerializer.Serialize( body );
				content = Http.CreateJsonContent( body );
			}

			if ( NetworkStorageLogConfig.LogRequests )
			{
				var suffix = usesDedicatedSecret ? " secret-key-header" : "";
				NetLog.Request( tag, $"{method}{suffix} {path}" );
				Log.Info( $"[NetworkStorage] {tag} request: {method} {ApiRoot}{path}{suffix}" );
			}

			var raw = await Http.RequestStringAsync( url, method, content, headers );
			if ( NetworkStorageLogConfig.LogResponses )
				Log.Info( $"[NetworkStorage] {tag} → {TruncateJson( raw, 300 )}" );

			var parsed = ParseResponse( tag, raw );
			if ( parsed.HasValue && NetworkStorageLogConfig.LogResponses )
				NetLog.Response( tag, TruncateJson( parsed.Value ) );
			return parsed;
		}
		catch ( Exception ex )
		{
			if ( NetworkStorageLogConfig.LogErrors )
			{
				Log.Warning( $"[NetworkStorage] {tag} FAILED — {ex.Message}" );
				Log.Warning( $"[NetworkStorage]   URL: {url ?? $"{ApiRoot}{path}"}" );
				Log.Warning( $"[NetworkStorage]   Method: {method}" );
				if ( bodyJson != null )
					Log.Warning( $"[NetworkStorage]   Body: {bodyJson}" );
				NetLog.Error( tag, ex.Message );
			}
			RecordEndpointError( tag, "REQUEST_FAILED", ex.Message );
			return null;
		}
	}

	private static string StorageDocumentPath( string collectionId, string documentId )
		=> $"/storage/{EscapeRouteSegment( ProjectId )}/{EscapeRouteSegment( collectionId )}/{EscapeRouteSegment( ResolveStorageDocumentId( documentId ) )}";

	private static string StorageRecordIndexPath( string collectionId, string steamId )
		=> $"/storage/{EscapeRouteSegment( ProjectId )}/{EscapeRouteSegment( collectionId )}/{EscapeRouteSegment( ResolveStorageDocumentId( steamId ) )}/records";

	private static string ResolveStorageDocumentId( string documentId )
		=> string.IsNullOrWhiteSpace( documentId ) ? Game.SteamId.ToString() : documentId;

	private static string EscapeRouteSegment( string value )
		=> Uri.EscapeDataString( value ?? "" );
}