Editor/SyncToolApi.cs

Editor HTTP client for the Network Storage v3 management API used by the SyncTool editor window. It builds authenticated HTTP requests (using keys from SyncToolConfig), sends them with HttpClient, parses JSON responses, records last error codes/messages per path and per-resource, and exposes convenience methods for endpoints, collections, workflows, tests, sync jobs, validation, and related operations.

Networking
🌐 {SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/{path}, {SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/validate
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;

/// <summary>
/// HTTP client for the Network Storage v3 management API.
/// Used by the SyncTool editor window to push/pull game data.
/// All requests are authenticated with the secret key from .env.
/// </summary>
public static class SyncToolApi
{
	private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds( 30 ) };
	private static readonly ConcurrentDictionary<string, string> _lastErrorMessagesByPath = new( StringComparer.OrdinalIgnoreCase );
	private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _lastResourceErrorMessagesByPath = new( StringComparer.OrdinalIgnoreCase );
	private static readonly JsonSerializerOptions _readOptions = new()
	{
		AllowTrailingCommas = true,
		PropertyNameCaseInsensitive = true,
		ReadCommentHandling = JsonCommentHandling.Skip
	};

	/// <summary>
	/// Last error code from the server (e.g., "KEY_UPGRADE_REQUIRED", "FORBIDDEN").
	/// Reset on each request. Null if the last request succeeded or had no structured error.
	/// </summary>
	public static string LastErrorCode { get; private set; }

	/// <summary>
	/// Last error message from the server.
	/// </summary>
	public static string LastErrorMessage { get; private set; }

	/// <summary>
	/// Last error message for a specific management API path.
	/// Push All runs resource groups in parallel, so the global message can be
	/// cleared by a sibling request before the UI reads it.
	/// </summary>
	public static string GetLastErrorMessage( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return null;

		return _lastErrorMessagesByPath.TryGetValue( path, out var message ) ? message : null;
	}

	/// <summary>
	/// Last structured error message for a specific resource inside a failed batch request.
	/// </summary>
	public static string GetLastResourceErrorMessage( string path, string resourceId )
	{
		if ( string.IsNullOrWhiteSpace( path ) || string.IsNullOrWhiteSpace( resourceId ) )
			return null;

		return _lastResourceErrorMessagesByPath.TryGetValue( path, out var messages ) &&
			messages.TryGetValue( resourceId, out var message )
				? message
				: null;
	}

	public static void ReportLocalError( string path, string message, Exception ex = null )
	{
		if ( string.IsNullOrWhiteSpace( path ) || string.IsNullOrWhiteSpace( message ) )
			return;

		LastErrorCode = "LOCAL_ERROR";
		LastErrorMessage = message;
		_lastErrorMessagesByPath[path] = message;
		if ( ex != null )
			Log.Warning( $"[SyncTool] {path}: {message}\n{ex}" );
		else
			Log.Warning( $"[SyncTool] {path}: {message}" );
	}

	/// <summary>
	/// Make an authenticated request to the management API.
	/// </summary>
	public static async Task<JsonElement?> Request( string method, string path, JsonElement? body = null, Dictionary<string, string> extraHeaders = null )
	{
		ClearErrorStateForPath( path );
		LastErrorCode = null;
		LastErrorMessage = null;

		if ( !SyncToolConfig.IsValid )
		{
			Log.Warning( "[SyncTool] Config not valid - load .env first" );
			return null;
		}

		var url = $"{SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/{path}";

		var sk = SyncToolConfig.SecretKey ?? "";
		var pk = SyncToolConfig.PublicApiKey ?? "";
		Log.Info( $"[SyncTool] {method} {path}" );

		var request = new HttpRequestMessage( new HttpMethod( method ), url );
		request.Headers.Add( "x-api-key", sk );
		request.Headers.Add( "x-public-key", pk );
		request.Headers.Add( "User-Agent", "SyncTool-sbox/2.0" );

		if ( extraHeaders != null )
		{
			foreach ( var header in extraHeaders )
				request.Headers.TryAddWithoutValidation( header.Key, header.Value );
		}

		if ( body.HasValue )
		{
			var json = JsonSerializer.Serialize( body.Value );
			request.Content = new StringContent( json, Encoding.UTF8, "application/json" );
		}

		try
		{
			var response = await _http.SendAsync( request );
			var text = await response.Content.ReadAsStringAsync();

			if ( !response.IsSuccessStatusCode )
			{
				Log.Warning( $"[SyncTool] {method} {path}: HTTP {(int)response.StatusCode}" );
				try
				{
					var errJson = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
					if ( errJson.TryGetProperty( "error", out var errCode ) )
						LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
							? codeVal.GetString()
							: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
					if ( errJson.TryGetProperty( "message", out var errMsg ) )
						LastErrorMessage = errMsg.GetString();

					CaptureStructuredErrors( path, errJson );
					LogStructuredErrors( path );

					if ( string.IsNullOrWhiteSpace( LastErrorMessage ) )
					{
						LastErrorMessage = BuildStructuredErrorSummary( path, errJson )
							?? BuildStructuredErrorMessageFromBody( text, (int)response.StatusCode );
					}

					if ( !string.IsNullOrWhiteSpace( LastErrorMessage ) )
						_lastErrorMessagesByPath[path] = LastErrorMessage;
					else
						_lastErrorMessagesByPath[path] = $"HTTP {(int)response.StatusCode}";

					// Skip error window for 404/405 only for Sync endpoint fallback behavior.
					// Other 404/405 responses are meaningful and should remain visible.
					var statusCode = (int)response.StatusCode;
					var suppressWindow = string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase )
						&& (statusCode == 404 || statusCode == 405);
					if ( ShouldShowErrorWindow() && !suppressWindow && (!string.IsNullOrEmpty( LastErrorCode ) || !string.IsNullOrEmpty( LastErrorMessage )) )
						EndpointErrorWindow.Show( path, errJson );

					if ( LastErrorCode == "KEY_UPGRADE_REQUIRED" )
					{
						Log.Warning( "[SyncTool] Your secret key uses an old format. Generate a new one at sbox.cool." );
					}
					else if ( LastErrorCode == "FORBIDDEN" )
					{
						Log.Warning( $"[SyncTool] Permission denied: {LastErrorMessage}" );
					}
					else if ( LastErrorCode == "COLLECTION_DELETE_WEBSITE_ONLY" )
					{
						Log.Warning( "[SyncTool] Collection definitions can only be deleted from the website dashboard after verification. Row/document deletion through the runtime API is still supported." );
					}
				}
				catch
				{
					LastErrorMessage = BuildStructuredErrorMessageFromBody( text, (int)response.StatusCode );
					_lastErrorMessagesByPath[path] = LastErrorMessage;
					Log.Warning( $"[SyncTool] -> {LastErrorMessage}" );
					if ( ShouldShowErrorWindow() )
						MessageDialog.Show( $"Sync Error: {path}", LastErrorMessage, text );
				}
				return null;
			}

			var result = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
			LogStructuredWarnings( path, result );

			// Check for validation errors in successful HTTP responses (ok: false)
			if ( result.TryGetProperty( "ok", out var okProp ) && okProp.ValueKind == JsonValueKind.False )
			{
				if ( result.TryGetProperty( "error", out var errCode ) )
					LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
						? codeVal.GetString()
						: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
				if ( result.TryGetProperty( "message", out var errMsg ) )
					LastErrorMessage = errMsg.GetString();

				CaptureStructuredErrors( path, result );
				LogStructuredErrors( path );

				if ( string.IsNullOrWhiteSpace( LastErrorMessage ) )
					LastErrorMessage = BuildStructuredErrorSummary( path, result );

				_lastErrorMessagesByPath[path] = LastErrorMessage ?? LastErrorCode ?? "Validation failed";

				// Show error window when any structured validation failures are present.
				if ( ShouldShowErrorWindow() && ( !string.IsNullOrEmpty( LastErrorCode ) || !string.IsNullOrEmpty( LastErrorMessage ) ) )
					EndpointErrorWindow.Show( path, result );
			}

			return result;
		}
		catch ( Exception ex )
		{
			LastErrorMessage = ex.Message;
			_lastErrorMessagesByPath[path] = ex.Message;
			Log.Warning( $"[SyncTool] {method} {path}: {ex.Message}" );
			return null;
		}
	}

	private static Dictionary<string, string> NoCacheHeaders() => new()
	{
		["Cache-Control"] = "no-cache",
		["Pragma"] = "no-cache"
	};

	private static string WithCacheBust( string path )
	{
		var separator = path.Contains( "?" ) ? "&" : "?";
		return $"{path}{separator}_syncToolTs={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
	}

	/// <summary>Fetch current server endpoints (includes staged/next-revision endpoints).</summary>
	public static Task<JsonElement?> GetEndpoints() => Request( "GET", WithCacheBust( "endpoints?includeStaged=true" ), null, NoCacheHeaders() );

	/// <summary>Fetch endpoints for a publish target.</summary>
	public static async Task<JsonElement?> GetEndpointsForPublishTarget( string publishTarget )
	{
		var result = await (string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
			? Request( "GET", WithCacheBust( "endpoints?includeStaged=true&revisionTarget=next" ), null, NoCacheHeaders() )
			: Request( "GET", WithCacheBust( "endpoints?revisionTarget=live" ), null, NoCacheHeaders() ));
		return !result.HasValue || !string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
			? result
			: FilterPayloadByRevisionTarget( result.Value, "next", "slug" );
	}

	/// <summary>Push endpoints to server.</summary>
	public static Task<JsonElement?> PushEndpoints( JsonElement data ) => Request( "PUT", "endpoints", data );

	/// <summary>Push endpoints to server with publish-target support.</summary>
	public static Task<JsonElement?> PushEndpoints( JsonElement data, string publishTarget )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "PUT", "endpoints", data, headers );
	}
 

	/// <summary>Upsert a single endpoint. Server handles merge.</summary>
	public static Task<JsonElement?> PatchEndpoint( JsonElement data, string publishTarget = "live" )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "PATCH", "endpoints", data, headers );
	}

	/// <summary>Upsert a single collection. Server handles merge.</summary>
	public static Task<JsonElement?> PatchCollection( JsonElement data, string publishTarget = "live" )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "PATCH", "collections", data, headers );
	}

	/// <summary>Upsert a single workflow. Server handles merge.</summary>
	public static Task<JsonElement?> PatchWorkflow( JsonElement data )
	{
		return Request( "PATCH", "workflows", data );
	}
 	/// <summary>Push endpoints to server asynchronously. Returns { jobId } immediately, processes in background.</summary>
 	public static Task<JsonElement?> PushEndpointsAsync( JsonElement data, string publishTarget = "live" )
 	{
 		var headers = new Dictionary<string, string>
 		{
 			["x-ns-publish-target"] = publishTarget ?? "live"
 		};
 		return Request( "PUT", "endpoints?async=true", data, headers );
 	}
 
 	/// <summary>Poll async sync job status.</summary>
 	public static Task<JsonElement?> GetSyncJobStatus( string jobId )
 	{
 		return Request( "GET", $"sync-jobs/{jobId}" );
 	}

	/// <summary>Fetch current server collections (includes staged/next-revision collections).</summary>
	public static Task<JsonElement?> GetCollections() => Request( "GET", WithCacheBust( "collections?includeStaged=true" ), null, NoCacheHeaders() );

	/// <summary>Fetch collections for a publish target.</summary>
	public static async Task<JsonElement?> GetCollectionsForPublishTarget( string publishTarget )
	{
		var result = await (string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
			? Request( "GET", WithCacheBust( "collections?includeStaged=true&revisionTarget=next" ), null, NoCacheHeaders() )
			: Request( "GET", WithCacheBust( "collections?revisionTarget=live" ), null, NoCacheHeaders() ));
		return !result.HasValue || !string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
			? result
			: FilterPayloadByRevisionTarget( result.Value, "next", "name" );
	}

	/// <summary>Push collection schemas to server.</summary>
	public static Task<JsonElement?> PushCollections( JsonElement data ) => Request( "PUT", "collections", data );

	/// <summary>Push collection schemas to server with publish-target support.</summary>
	public static Task<JsonElement?> PushCollections( JsonElement data, string publishTarget )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "PUT", "collections", data, headers );
	}

	/// <summary>Fetch current server workflows.</summary>
	public static Task<JsonElement?> GetWorkflows() => Request( "GET", WithCacheBust( "workflows" ), null, NoCacheHeaders() );

	/// <summary>Push workflows to server.</summary>
	public static Task<JsonElement?> PushWorkflows( JsonElement data ) => Request( "PUT", "workflows", data );

	/// <summary>Fetch read-only project settings used by the editor/runtime config.</summary>
	public static Task<JsonElement?> GetProjectSettings() => Request( "GET", "settings" );

	/// <summary>Fetch current server tests.</summary>
	public static Task<JsonElement?> GetTests() => Request( "GET", "tests" );

	/// <summary>Push tests to server.</summary>
	public static Task<JsonElement?> PushTests( JsonElement data ) => Request( "PUT", "tests", data );

 	/// <summary>Ask backend compiler to canonicalize and safely upgrade one source file.</summary>
	public static Task<JsonElement?> UpgradeSource( JsonElement data ) => Request( "POST", "source-upgrade", data );

	private static Dictionary<string, string> PublishTargetHeaders( string publishTarget )
	{
		var normalized = string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase ) ? "next" : "live";
		return new Dictionary<string, string> { ["x-ns-publish-target"] = normalized };
	}

	/// <summary>Run a single test via dry-run.</summary>
	public static Task<JsonElement?> RunTest( JsonElement data, string publishTarget = "live" ) => Request( "POST", "test-endpoint", data, PublishTargetHeaders( publishTarget ) );

	/// <summary>Run all tests via dry-run.</summary>
	public static Task<JsonElement?> RunAllTests( JsonElement data, string publishTarget = "live" ) => Request( "POST", "run-tests", data, PublishTargetHeaders( publishTarget ) );

	/// <summary>Suggest tests for an endpoint.</summary>
	public static Task<JsonElement?> SuggestTests( JsonElement data ) => Request( "POST", "suggest-tests", data );

	/// <summary>Auto-test one or all endpoints (no saved tests needed). Pass { slug } for one, {} for all.</summary>
	public static Task<JsonElement?> AutoTest( JsonElement data, string publishTarget = "live" ) => Request( "POST", "auto-test", data, PublishTargetHeaders( publishTarget ) );

	/// <summary>
	/// Push all resources (endpoints, collections, workflows) in a single batch request.
	/// Server processes synchronously and returns combined results.
	/// </summary>
	public static Task<JsonElement?> PushSync( JsonElement data, string publishTarget = "live" )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "PUT", "sync", data, headers );
	}

	/// <summary>Validate resources before pushing. Does not write server-side data.</summary>
	public static Task<JsonElement?> PreflightSync( JsonElement data, string publishTarget = "live" )
	{
		var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
		return Request( "POST", "sync/preflight", data, headers );
	}

	/// <summary>Sync package/revision info with backend.</summary>
	public static Task<JsonElement?> SyncPackageInfo( JsonElement data ) => Request( "POST", "package-sync", data );

	/// <summary>Fetch stored game-package/revision state from backend.</summary>
	public static Task<JsonElement?> GetGamePackage() => Request( "GET", "game-package" );

	/// <summary>
	/// Validate credentials against the server.
	/// Sends secret key via x-api-key header and optionally public key via x-public-key header.
	/// Returns { ok, project, checks, permissions? }
	/// </summary>
	public static async Task<JsonElement?> Validate( string publicKey = null )
	{
		LastErrorCode = null;
		LastErrorMessage = null;

		if ( !SyncToolConfig.IsValid )
		{
			Log.Warning( "[SyncTool] Validate: config not valid" );
			return null;
		}

		var url = $"{SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/validate";
		var sk = SyncToolConfig.SecretKey ?? "";
		var pk = publicKey ?? "";

		Log.Info( $"[SyncTool] Validating credentials..." );

		var request = new HttpRequestMessage( HttpMethod.Get, url );
		request.Headers.Add( "x-api-key", sk );
		request.Headers.Add( "User-Agent", "SyncTool-sbox/2.0" );

		if ( !string.IsNullOrEmpty( publicKey ) )
			request.Headers.Add( "x-public-key", publicKey );

		try
		{
			var response = await _http.SendAsync( request );
			var text = await response.Content.ReadAsStringAsync();

			if ( !response.IsSuccessStatusCode )
			{
				LastErrorCode = $"HTTP_{(int)response.StatusCode}";
				LastErrorMessage = $"Server returned HTTP {(int)response.StatusCode}";
				Log.Warning( $"[SyncTool] Validate failed: HTTP {(int)response.StatusCode}" );
				return null;
			}

			var result = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );

			if ( result.TryGetProperty( "error", out var errCode ) )
			{
				LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
					? codeVal.GetString()
					: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
				if ( result.TryGetProperty( "message", out var errMsg ) )
					LastErrorMessage = errMsg.GetString();
				Log.Warning( $"[SyncTool] Validate error: {LastErrorCode} - {LastErrorMessage}" );
			}

			return result;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Validate: {ex.Message}" );
			return null;
		}
	}

	private static bool ShouldShowErrorWindow()
	{
		return SyncToolWindow.IsWindowOpen;
	}

	private static void ClearErrorStateForPath( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return;

		_lastErrorMessagesByPath.TryRemove( path, out _ );
		_lastResourceErrorMessagesByPath.TryRemove( path, out _ );

		if ( string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase ) )
		{
			_lastErrorMessagesByPath.TryRemove( "endpoints", out _ );
			_lastErrorMessagesByPath.TryRemove( "collections", out _ );
			_lastErrorMessagesByPath.TryRemove( "workflows", out _ );
			_lastResourceErrorMessagesByPath.TryRemove( "endpoints", out _ );
			_lastResourceErrorMessagesByPath.TryRemove( "collections", out _ );
			_lastResourceErrorMessagesByPath.TryRemove( "workflows", out _ );
		}
	}

	private static string BuildStructuredErrorMessageFromBody( string body, int statusCode )
	{
		if ( statusCode == 404 || statusCode == 405 )
			return $"Backend route missing or unavailable (HTTP {statusCode}). The management API route may not be deployed on this backend.";

		var detail = TruncateForLog( body, 500 );
		return string.IsNullOrWhiteSpace( detail )
			? $"HTTP {statusCode}"
			: $"HTTP {statusCode}: {detail}";
	}

	private static void CaptureStructuredErrors( string path, JsonElement payload )
	{
		var entries = CollectStructuredResultEntries( path, payload );
		if ( entries.Count == 0 )
			return;

		var messagesByPath = new Dictionary<string, ConcurrentDictionary<string, string>>( StringComparer.OrdinalIgnoreCase );
		foreach ( var entry in entries )
		{
			var item = entry.Item;
			if ( item.ValueKind != JsonValueKind.Object )
				continue;

			var itemOk = item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.True;
			if ( itemOk )
				continue;

			var resourceId = GetStringProperty( item, "resourceId", "slug", "name", "id" );
			if ( string.IsNullOrWhiteSpace( resourceId ) )
				continue;

			var detail = BuildStructuredErrorDetail( item );
			if ( string.IsNullOrWhiteSpace( detail ) )
				continue;

			if ( !messagesByPath.TryGetValue( entry.Path, out var messages ) )
			{
				messages = new ConcurrentDictionary<string, string>( StringComparer.OrdinalIgnoreCase );
				messagesByPath[entry.Path] = messages;
			}
			messages[resourceId] = detail;
		}

		foreach ( var pair in messagesByPath )
			_lastResourceErrorMessagesByPath[pair.Key] = pair.Value;
	}

	private static void LogStructuredErrors( string path )
	{
		if ( !_lastResourceErrorMessagesByPath.TryGetValue( path, out var messages ) )
			return;

		foreach ( var pair in messages )
			Log.Warning( $"[SyncTool] {path}:{pair.Key} -> {pair.Value}" );
	}

	private static string BuildStructuredErrorSummary( string path, JsonElement payload )
	{
		var entries = CollectStructuredResultEntries( path, payload );
		foreach ( var entry in entries )
		{
			if ( entry.Item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.True )
				continue;

			var resourceId = GetStringProperty( entry.Item, "resourceId", "slug", "name", "id" );
			var detail = BuildStructuredErrorDetail( entry.Item );
			if ( string.IsNullOrWhiteSpace( detail ) )
				continue;

			return string.IsNullOrWhiteSpace( resourceId )
				? $"{entry.Path}: {detail}"
				: $"{entry.Path}:{resourceId} {detail}";
		}

		var message = GetStringProperty( payload, "message", "error" );
		return string.IsNullOrWhiteSpace( message ) ? null : message;
	}

	private static JsonElement? FilterPayloadByRevisionTarget( JsonElement payload, string targetRevision, params string[] idKeys )
	{
		if ( !payload.TryGetProperty( "data", out var data ) || data.ValueKind != JsonValueKind.Array )
			return payload;

		var targetItemsById = new Dictionary<string, JsonElement>( StringComparer.OrdinalIgnoreCase );
		var hasTargetItems = false;
		foreach ( var item in data.EnumerateArray() )
		{
			if ( item.ValueKind != JsonValueKind.Object )
				continue;

			var resourceId = GetStringProperty( item, idKeys );
			if ( string.IsNullOrWhiteSpace( resourceId ) )
				continue;

			if ( item.TryGetProperty( "revisionTarget", out var rev )
				&& rev.ValueKind == JsonValueKind.String
				&& string.Equals( rev.GetString(), targetRevision, StringComparison.OrdinalIgnoreCase ) )
			{
				targetItemsById[resourceId] = item;
				hasTargetItems = true;
			}
		}

		if ( !hasTargetItems )
		{
			var deduped = new List<JsonElement>( data.GetArrayLength() );
			var seenNoTarget = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
			var hasDuplicates = false;
			foreach ( var item in data.EnumerateArray() )
			{
				if ( item.ValueKind != JsonValueKind.Object )
					continue;

				var resourceId = GetStringProperty( item, idKeys );
				if ( string.IsNullOrWhiteSpace( resourceId ) || !seenNoTarget.Add( resourceId ) )
				{
					hasDuplicates = hasDuplicates || !string.IsNullOrWhiteSpace( resourceId );
					continue;
				}

				deduped.Add( item );
			}

			if ( hasDuplicates )
			{
				var payloadObjNoTarget = JsonSerializer.Deserialize<Dictionary<string, object>>( payload.GetRawText(), _readOptions );
				if ( payloadObjNoTarget != null )
				{
					payloadObjNoTarget["data"] = deduped;
					return JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payloadObjNoTarget ) );
				}
			}
		}

		var selected = new List<JsonElement>( data.GetArrayLength() );
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var item in data.EnumerateArray() )
		{
			if ( item.ValueKind != JsonValueKind.Object )
				continue;

			var resourceId = GetStringProperty( item, idKeys );
			if ( string.IsNullOrWhiteSpace( resourceId ) || seen.Contains( resourceId ) )
				continue;

			if ( targetItemsById.TryGetValue( resourceId, out var targetItem ) )
				selected.Add( targetItem );
			else
				selected.Add( item );

			seen.Add( resourceId );
		}

		var payloadObj = JsonSerializer.Deserialize<Dictionary<string, object>>( payload.GetRawText(), _readOptions );
		if ( payloadObj == null )
			return payload;

		payloadObj["data"] = selected;
		return JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payloadObj ) );
	}

	private static void LogStructuredWarnings( string path, JsonElement payload )
	{
		var entries = CollectStructuredResultEntries( path, payload );
		foreach ( var entry in entries )
		{
			if ( entry.Item.ValueKind != JsonValueKind.Object )
				continue;

			if ( entry.Item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.False )
				continue;

			var resourceId = GetStringProperty( entry.Item, "resourceId", "slug", "name", "id" ) ?? "(unknown)";
			if ( !entry.Item.TryGetProperty( "diagnostics", out var diagnostics ) || diagnostics.ValueKind != JsonValueKind.Array )
				continue;

			foreach ( var diagnostic in diagnostics.EnumerateArray() )
			{
				var severity = GetStringProperty( diagnostic, "severity" );
				if ( string.Equals( severity, "error", StringComparison.OrdinalIgnoreCase ) )
					continue;

				var formatted = FormatDiagnostic( diagnostic );
				if ( string.IsNullOrWhiteSpace( formatted ) )
					continue;

				// Only log warnings, skip info-level messages
				if ( !string.Equals( severity, "info", StringComparison.OrdinalIgnoreCase ) )
					Log.Warning( $"[SyncTool] {entry.Path}:{resourceId} {severity} -> {formatted}" );
			}
		}
	}

	private static List<(string Path, JsonElement Item)> CollectStructuredResultEntries( string path, JsonElement payload )
	{
		var entries = new List<(string, JsonElement)>();
		var topLevel = GetStructuredResourceArray( payload );
		if ( topLevel.HasValue )
		{
			foreach ( var item in topLevel.Value.EnumerateArray() )
				entries.Add( ( path, item ) );
		}

		if ( !string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase ) )
			return entries;

		foreach ( var section in new[] { "endpoints", "collections", "workflows" } )
		{
			if ( !payload.TryGetProperty( section, out var sectionValue ) || sectionValue.ValueKind != JsonValueKind.Object )
				continue;

			if ( !sectionValue.TryGetProperty( "results", out var sectionResults ) || sectionResults.ValueKind != JsonValueKind.Array )
				continue;

			foreach ( var item in sectionResults.EnumerateArray() )
				entries.Add( ( section, item ) );
		}

		return entries;
	}

	private static JsonElement? GetStructuredResourceArray( JsonElement payload )
	{
		foreach ( var key in new[] { "results", "resources", "items" } )
		{
			if ( payload.TryGetProperty( key, out var value ) && value.ValueKind == JsonValueKind.Array )
				return value;
		}

		return null;
	}

	private static string BuildStructuredErrorDetail( JsonElement item )
	{
		var parts = new List<string>();
		var message = GetStringProperty( item, "message" );
		var error = GetStringProperty( item, "error" );
		if ( !string.IsNullOrWhiteSpace( message ) &&
			!string.Equals( message, "One or more endpoints failed validation.", StringComparison.OrdinalIgnoreCase ) &&
			!string.Equals( message, "One or more collections failed validation.", StringComparison.OrdinalIgnoreCase ) &&
			!string.Equals( message, "One or more workflows failed validation.", StringComparison.OrdinalIgnoreCase ) )
		{
			parts.Add( message );
		}
		else if ( !string.IsNullOrWhiteSpace( error ) && !string.Equals( error, "VALIDATION_FAILED", StringComparison.OrdinalIgnoreCase ) )
		{
			parts.Add( error );
		}

		if ( item.TryGetProperty( "diagnostics", out var diagnostics ) && diagnostics.ValueKind == JsonValueKind.Array )
		{
			foreach ( var diagnostic in diagnostics.EnumerateArray() )
			{
				var formatted = FormatDiagnostic( diagnostic );
				if ( !string.IsNullOrWhiteSpace( formatted ) )
					parts.Add( formatted );
			}
		}

		if ( parts.Count == 0 && item.TryGetProperty( "errors", out var errors ) && errors.ValueKind == JsonValueKind.Array )
		{
			foreach ( var errorItem in errors.EnumerateArray() )
			{
				var formatted = FormatDiagnostic( errorItem );
				if ( !string.IsNullOrWhiteSpace( formatted ) )
					parts.Add( formatted );
			}
		}

		return parts.Count > 0 ? string.Join( " | ", parts ) : GetStringProperty( item, "message", "error" );
	}

	private static string FormatDiagnostic( JsonElement diagnostic )
	{
		if ( diagnostic.ValueKind == JsonValueKind.String )
			return diagnostic.GetString();

		if ( diagnostic.ValueKind != JsonValueKind.Object )
			return diagnostic.ToString();

		var code = GetStringProperty( diagnostic, "code", "type", "budgetCategory" );
		var message = GetStringProperty( diagnostic, "message", "detail", "reason", "error" );
		var head = !string.IsNullOrWhiteSpace( code ) && !string.IsNullOrWhiteSpace( message )
			? $"{code}: {message}"
			: !string.IsNullOrWhiteSpace( message ) ? message : code;

		var context = new List<string>();
		var sourcePath = GetStringProperty( diagnostic, "sourcePath", "path", "file" );
		var sourcePointer = GetStringProperty( diagnostic, "sourcePointer" );
		if ( !string.IsNullOrWhiteSpace( sourcePath ) )
		{
			var line = GetIntProperty( diagnostic, "line", "sourceLine" );
			var column = GetIntProperty( diagnostic, "column", "sourceColumn" );
			var location = sourcePath;
			if ( line.HasValue )
				location += column.HasValue ? $":{line}:{column}" : $":{line}";
			context.Add( location );
		}
		else if ( !string.IsNullOrWhiteSpace( sourcePointer ) )
		{
			context.Add( sourcePointer );
		}

		var nodeId = GetStringProperty( diagnostic, "nodeId", "canonicalNode", "stepId" );
		if ( !string.IsNullOrWhiteSpace( nodeId ) )
			context.Add( $"node={nodeId}" );

		var suggestion = GetStringProperty( diagnostic, "suggestedFix", "suggestion", "fix" );
		if ( !string.IsNullOrWhiteSpace( suggestion ) )
			context.Add( $"fix={suggestion}" );

		if ( string.IsNullOrWhiteSpace( head ) )
			head = diagnostic.ToString();

		return context.Count > 0 ? $"{head} ({string.Join( ", ", context )})" : head;
	}

	private static string GetStringProperty( JsonElement element, params string[] keys )
	{
		foreach ( var key in keys )
		{
			if ( element.TryGetProperty( key, out var value ) && value.ValueKind == JsonValueKind.String )
				return value.GetString();
		}

		return null;
	}

	private static int? GetIntProperty( JsonElement element, params string[] keys )
	{
		foreach ( var key in keys )
		{
			if ( !element.TryGetProperty( key, out var value ) || value.ValueKind != JsonValueKind.Number )
				continue;

			if ( value.TryGetInt32( out var number ) )
				return number;
		}

		return null;
	}

	private static string PrettyPrintJson( string text )
	{
		if ( string.IsNullOrWhiteSpace( text ) )
			return text;

		try
		{
			var parsed = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
			return JsonSerializer.Serialize( parsed, new JsonSerializerOptions { WriteIndented = true } );
		}
		catch
		{
			return text;
		}
	}

	private static string TruncateForLog( string text, int maxLength )
	{
		if ( string.IsNullOrEmpty( text ) || text.Length <= maxLength )
			return text;

		return $"{text[..maxLength]}... (truncated, {text.Length} chars)";
	}
}