Editor/SyncToolWindow.cs
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
using Editor;

/// <summary>
/// Editor window for Network Storage sync operations.
/// Checks remote for newer versions, shows per-item Push/Pull buttons.
/// Push warns if remote is newer. Pull buttons only appear when remote differs.
/// After push or pull, re-checks and clears stale state.
/// </summary>
[Dock( "Editor", "Network Storage Sync", "cloud" )]
public partial class SyncToolWindow : DockWindow
{
	private static readonly JsonSerializerOptions _readOptions = new()
	{
		AllowTrailingCommas = true,
		PropertyNameCaseInsensitive = true,
		ReadCommentHandling = JsonCommentHandling.Skip
	};

	private string _status = "Ready";
	private bool _statusIsError;
	private bool _busy;
	private static int _openWindowCount;
	public static bool IsWindowOpen => _openWindowCount > 0;
	private string _busyItem;
	private Dictionary<string, ItemState> _items = new();
	private List<ClickRegion> _buttons = new();
	private Vector2 _mousePos;
	private bool _hasCheckedRemote;

	// Generate state
	private bool _generateBusy;

	// Cached file lists
	private string[] _endpointFiles = Array.Empty<string>();
	private string[] _collectionFiles = Array.Empty<string>(); // collections/{name}.collection.yml
	private string[] _workflowFiles = Array.Empty<string>(); // workflows/{id}.workflow.yml

	// Remote data cache (from last check)
	private JsonElement? _remoteEndpoints;
	private JsonElement? _remoteCollections;
	private JsonElement? _remoteWorkflows;

	// ── Revision info ──
	private bool _packageInfoDetected;
	private string _packageIdent;
	private long? _currentRevisionId;
	private string _publishStatus;
	private string _publishTarget = "live"; // "live" or "next"

	// ── Server-side revision state (from GET /game-package) ──
	private long? _serverCurrentRevisionId;
	private long? _serverLatestRevisionId;
	private string _serverLastSyncedAt;
	private long? _serverRevisionFirstSyncedAtUnix;
	private int _serverEndpointOverrideCount;

	private void SetPublishTarget( string target )
	{
		var normalized = string.Equals( target, "next", StringComparison.OrdinalIgnoreCase )
			? "next"
			: "live";

		if ( string.Equals( _publishTarget, normalized, StringComparison.OrdinalIgnoreCase ) )
			return;

		_publishTarget = normalized;
		SyncToolConfig.SetPublishTarget( normalized );
		_remoteEndpoints = null;
		_remoteCollections = null;
		_hasCheckedRemote = false;
		Update();
	}

	private string PublishTargetLabel => _publishTarget == "next" ? "Staged/Main" : "Live";
	private string PushAllLabel => _publishTarget == "next" ? "Push Staged" : "Push Live";
	private string TestAllLabel => _publishTarget == "next" ? "Test Staged" : "Test Live";

	// ── Scroll state ──
	private float _scrollY;
	private float _scrollAreaTop;
	private float _contentHeight;
	private const float RowH = 29f;

	// ── Sync log (shown after push/pull) ──
	private List<SyncLogEntry> _syncLog = new();

	private struct SyncLogEntry
	{
		public string Name;
		public string Type;   // "Endpoint", "Collection", "Workflow"
		public bool Ok;
		public string Detail; // "Pushed", "Created (new)", "Failed", "Verified ✓", "Mismatch — see diff", etc.
	}

	private float MaxScroll => !IsValid ? 0 : Math.Max( 0, _contentHeight - ( Height - _scrollAreaTop ) + 60 );

	private struct ClickRegion
	{
		public Rect Rect;
		public string Id;
		public Action OnClick;
	}

	private enum SyncStatus { Unknown, InSync, LocalOnly, RemoteOnly, Differs, MergeAvailable }

	private struct ItemState
	{
		public string SyncResult;
		public bool RemoteDiffers;
		public SyncStatus Status;
		public string DiffSummary;
		public string LocalJson;
		public string RemoteJson;
		// Display-only YAML rendering of LocalJson/RemoteJson, sorted by key.
		// DiffViewWindow uses these so users see YAML, matching the on-disk format.
		public string LocalYaml;
		public string RemoteYaml;
	}

	public SyncToolWindow()
	{
		_openWindowCount++;
		Title = "Network Storage Sync";
		Size = new Vector2( 720, 620 );
		MinimumSize = new Vector2( 550, 400 );
		
		// Enable mouse tracking for hover effects
		MouseTracking = true;
		
		SyncToolConfig.Load();
		_publishTarget = SyncToolConfig.PublishTarget;
		RefreshFileList();
		// Kick off package detection in the background so the revision panel
		// shows immediately without waiting for "Pull from Web".
		if ( SyncToolConfig.IsValid )
			_ = DetectPackageInfoAsync();
	}

	private async Task DetectPackageInfoAsync()
	{
		try
		{
			await NetworkStoragePackageInfo.DetectAsync();
			_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
			_packageIdent = NetworkStoragePackageInfo.PackageIdent;
			_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
			_publishStatus = NetworkStoragePackageInfo.PublishStatus;
			_lastKnownRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;

			// Sync package info to backend so server state is available immediately
			if ( _packageInfoDetected )
				await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );

			// Fetch server-side revision state so the panel shows immediately
			await RefreshServerPackageStateAsync();
			Update();
		}
		catch ( Exception ex )
		{
			Log.Info( $"[SyncTool] Background package detection: {ex.Message}" );
		}
	}

	private async Task RefreshServerPackageStateAsync()
	{
		try
		{
			var serverPkg = await SyncToolApi.GetGamePackage();
			if ( serverPkg.HasValue && serverPkg.Value.TryGetProperty( "gamePackage", out var gp ) && gp.ValueKind == JsonValueKind.Object )
			{
				_serverCurrentRevisionId = gp.TryGetProperty( "currentRevisionId", out var cr ) && cr.ValueKind == JsonValueKind.Number ? cr.GetInt64() : null;
				_serverLatestRevisionId = gp.TryGetProperty( "latestRevisionId", out var lr ) && lr.ValueKind == JsonValueKind.Number ? lr.GetInt64() : null;
				_serverLastSyncedAt = gp.TryGetProperty( "lastSyncedAt", out var ls ) && ls.ValueKind == JsonValueKind.String ? ls.GetString() : null;
				_serverRevisionFirstSyncedAtUnix = gp.TryGetProperty( "revisionFirstSyncedAtUnix", out var rfs ) && rfs.ValueKind == JsonValueKind.Number ? rfs.GetInt64() : null;

				if ( !_packageInfoDetected && _serverCurrentRevisionId.HasValue )
				{
					_currentRevisionId = _serverCurrentRevisionId;
					_packageIdent = gp.TryGetProperty( "packageIdent", out var pi ) ? pi.GetString() : null;
					_publishStatus = gp.TryGetProperty( "publishStatus", out var ps ) ? ps.GetString() : null;
					_packageInfoDetected = true;
				}
			}

			_serverEndpointOverrideCount = 0;
			if ( _remoteEndpoints.HasValue && _remoteEndpoints.Value.ValueKind == JsonValueKind.Array )
			{
				foreach ( var ep in _remoteEndpoints.Value.EnumerateArray() )
				{
					if ( ep.TryGetProperty( "revisionTarget", out var rt ) && rt.ValueKind == JsonValueKind.String && rt.GetString() == "next" )
						_serverEndpointOverrideCount++;
				}
			}
		}
		catch ( Exception ex )
		{
			Log.Info( $"[SyncTool] Server state refresh failed: {ex.Message}" );
		}
	}

	/// <summary>
	/// Send local package info to the backend so it always has the latest local revision.
	/// </summary>
	private async Task SyncLocalPackageInfoAsync()
	{
		try
		{
			await NetworkStoragePackageInfo.DetectAsync();
			_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
			if ( _packageInfoDetected )
			{
				_packageIdent = NetworkStoragePackageInfo.PackageIdent;
				_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
				_publishStatus = NetworkStoragePackageInfo.PublishStatus;
				await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
				await RefreshServerPackageStateAsync();
				Update();
			}
		}
		catch ( Exception ex )
		{
			Log.Info( $"[SyncTool] Package info sync failed: {ex.Message}" );
		}
	}

// ── Revision change watcher ──
	// Periodically re-detects package info and auto-syncs when the revision changes
	// (e.g. after the developer publishes a new game revision from the s&box editor).
	private long? _lastKnownRevisionId;
	private float _revisionCheckTimer;
	private const float RevisionCheckIntervalSeconds = 15f;
	private bool _revisionSyncInFlight;

	[EditorEvent.Frame]
	private void OnEditorFrame()
	{
		if ( !SyncToolConfig.IsValid || _revisionSyncInFlight )
			return;

		_revisionCheckTimer += RealTime.Delta;
		if ( _revisionCheckTimer < RevisionCheckIntervalSeconds )
			return;
		_revisionCheckTimer = 0;

		_ = CheckRevisionChangeAsync();
	}

	private async Task CheckRevisionChangeAsync()
	{
		_revisionSyncInFlight = true;
		try
		{
			await NetworkStoragePackageInfo.DetectAsync();
			var newRevision = NetworkStoragePackageInfo.CurrentRevisionId;

			if ( newRevision.HasValue && newRevision != _lastKnownRevisionId && _lastKnownRevisionId.HasValue )
			{
				Log.Info( $"[SyncTool] Revision changed: {_lastKnownRevisionId} → {newRevision}. Auto-syncing..." );

				_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
				_packageIdent = NetworkStoragePackageInfo.PackageIdent;
				_currentRevisionId = newRevision;
				_publishStatus = NetworkStoragePackageInfo.PublishStatus;

				// Push updated package info to backend
				var syncResp = await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
				if ( syncResp.HasValue )
					Log.Info( $"[SyncTool] Auto-sync after revision change: ok" );

				// Refresh server-side state
				await RefreshServerPackageStateAsync();
				Update();
			}

			_lastKnownRevisionId = newRevision ?? _lastKnownRevisionId;
		}
		catch ( Exception ex )
		{
			Log.Info( $"[SyncTool] Revision check failed: {ex.Message}" );
		}
		finally
		{
			_revisionSyncInFlight = false;
		}
	}

	[Menu( "Editor", "Network Storage/Sync Tool" )]
	public static void OpenWindow()
	{
		var window = new SyncToolWindow();
		window.Show();
	}

	protected override bool OnClose()
	{
		_openWindowCount = Math.Max( 0, _openWindowCount - 1 );
		return base.OnClose();
	}

	private void SetStatus( string message, bool isError = false )
	{
		_status = message;
		_statusIsError = isError;
	}

	private void ShowGenerateFailure( string message, string detail = null )
	{
		SetStatus( message, isError: true );
		MessageDialog.Show( "Generate Failed", message, detail );
	}

	private static string ExtractGenerateFailureSummary( string stdout, string stderr, int exitCode )
	{
		foreach ( var source in new[] { stderr, stdout } )
		{
			if ( string.IsNullOrWhiteSpace( source ) )
				continue;

			var lines = source
				.Replace( "\r", "" )
				.Split( '\n' )
				.Select( x => x.Trim() )
				.Where( x => !string.IsNullOrWhiteSpace( x ) )
				.ToArray();

			var explicitError = lines.FirstOrDefault( x => x.StartsWith( "ERROR:", StringComparison.OrdinalIgnoreCase ) );
			if ( !string.IsNullOrWhiteSpace( explicitError ) )
				return explicitError["ERROR:".Length..].Trim();

			var usefulLine = lines.FirstOrDefault( x =>
				!x.StartsWith( "===", StringComparison.Ordinal ) &&
				!x.StartsWith( "Project root:", StringComparison.OrdinalIgnoreCase ) &&
				!x.StartsWith( "Data dir:", StringComparison.OrdinalIgnoreCase ) );

			if ( !string.IsNullOrWhiteSpace( usefulLine ) )
				return usefulLine;
		}

		return $"Generation failed (exit {exitCode})";
	}

	private static string BuildGenerateFailureDetail( string stdout, string stderr )
	{
		var detail = !string.IsNullOrWhiteSpace( stderr ) ? stderr.Trim() : stdout.Trim();
		if ( string.IsNullOrWhiteSpace( detail ) )
			return null;

		const int maxLength = 4000;
		if ( detail.Length > maxLength )
			detail = detail[..maxLength] + "\n...";

		return detail;
	}

	private void RefreshFileList()
	{
		var epDir = SyncToolConfig.Abs( SyncToolConfig.EndpointsPath );
		_endpointFiles = Directory.Exists( epDir )
			? FindResourceFiles( epDir, "endpoint" )
			: Array.Empty<string>();

		var colDir = SyncToolConfig.Abs( SyncToolConfig.CollectionsPath );
		_collectionFiles = Directory.Exists( colDir )
			? FindResourceFiles( colDir, "collection" )
			: Array.Empty<string>();

		var wfDir = SyncToolConfig.Abs( SyncToolConfig.WorkflowsPath );
		_workflowFiles = Directory.Exists( wfDir )
			? FindResourceFiles( wfDir, "workflow" )
			: Array.Empty<string>();
	}

	private static string[] FindResourceFiles( string directory, string kind )
	{
		return Directory.GetFiles( directory, $"*.{kind}.yml" )
			.Concat( Directory.GetFiles( directory, $"*.{kind}.yaml" ) )
			.OrderBy( f => f, StringComparer.OrdinalIgnoreCase )
			.ToArray();
	}

	private static string ResourceIdFromFile( string filePath, string kind )
		=> SyncToolConfig.ResourceIdFromFilePath( filePath, kind );

	// ──────────────────────────────────────────────────────
	//  Rendering
	// ──────────────────────────────────────────────────────

	protected override void OnPaint()
	{
		base.OnPaint();
		_buttons.Clear();

		var y = 38f;
		var pad = 16f;
		var w = Width - pad * 2;

		// ── Header + Push All button ──
		Paint.SetDefaultFont( size: 13, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w * 0.55f, 22 ), "Network Storage Sync", TextFlag.LeftCenter );

		// Push All + Test All buttons
		if ( SyncToolConfig.IsValid )
		{
			var btnW2 = 92f;
			var testAllW = 92f;
			var pushAllRect = new Rect( pad + w - btnW2, y, btnW2, 22 );
			var testAllRect = new Rect( pad + w - btnW2 - 4 - testAllW, y, testAllW, 22 );
			DrawSmallButton( pushAllRect, PushAllLabel, Color.Green, "push_all", () => _ = PushAll() );
			DrawSmallButton( testAllRect, TestAllLabel, Color.Cyan, "test_all", () => TestResultsWindow.OpenAndRun( publishTarget: _publishTarget ) );
		}
		y += 30;

		// ── Config status ──
		if ( !SyncToolConfig.IsValid )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.Red );
			Paint.DrawText( new Rect( pad, y, w, 16 ), "Not configured — click Setup to enter your keys", TextFlag.LeftCenter );
			y += 24;
		}
		else
		{
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), $"Connected — {SyncToolConfig.ProjectId}", TextFlag.LeftCenter );
			y += 18;

			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.White.WithAlpha( 0.72f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ),
				$"Auth Sessions: {SyncToolConfig.AuthSessionsLabel}   Encrypted Requests: {SyncToolConfig.EncryptedRequestsLabel}",
				TextFlag.LeftCenter );
			y += 20;
		}

		// ── Check for Updates / Pull from Web button ──
		if ( SyncToolConfig.IsValid )
		{
			var checkBtnH = 26f;
			var checkLabel = _hasCheckedRemote ? $"Pull from {PublishTargetLabel} (re-check)" : $"Check {PublishTargetLabel} for Updates";
			var checkRect = new Rect( pad, y, w, checkBtnH );
			DrawWideButton( checkRect, checkLabel, Color.Cyan, "check_updates", () => _ = CheckForUpdates() );
			y += checkBtnH + 8;

			var pullableCount = GetPullableResourceIds().Length;
			if ( pullableCount > 0 )
			{
				var pullAllRect = new Rect( pad, y, w, checkBtnH );
				DrawWideButton( pullAllRect, $"Pull All ({pullableCount}) from {(_publishTarget == "next" ? "Staged/Main" : "Live")}", Color.Cyan, "pull_all_changed",
					() => PullAllChangedResources() );
				y += checkBtnH + 8;
			}

			var remoteSemanticsCount = GetRemoteSemanticsCount();
			if ( remoteSemanticsCount > 0 )
			{
				var semanticsLabel = remoteSemanticsCount == 1
					? "Merge All (1 semantic item)"
					: $"Merge All ({remoteSemanticsCount} semantic items)";
				var semanticsRect = new Rect( pad, y, w, checkBtnH );
				DrawWideButton( semanticsRect, semanticsLabel, Color.Green, "pull_remote_semantics_all",
					() => PullAllRemoteSemantics() );
				y += checkBtnH + 8;
			}
		}

		// ── Package / Revision Info ──
		if ( SyncToolConfig.IsValid )
		{
			if ( _packageInfoDetected )
			{
				var infoH = 104f;
				Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
				Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
				Paint.DrawRect( new Rect( pad, y, w, infoH ), 4 );

				var col1 = pad + 8;
				var col2 = pad + w * 0.48f;
				var labelW = 90f;
				var rowH = 16f;
				var ry = y + 4;

				// ═══ LEFT SIDE: Package Info ═══
				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, 12 ), "REVISION / PACKAGE INFO", TextFlag.LeftCenter );
				ry += 14;

				// Row 1: Package
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Package:", TextFlag.LeftCenter );
				Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, col2 - col1 - labelW - 8, rowH ), _packageIdent ?? "Unknown", TextFlag.LeftCenter );

				// Row 2: Revision | Latest
				ry += rowH;
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Revision:", TextFlag.LeftCenter );
				Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 60, rowH ), _serverCurrentRevisionId?.ToString() ?? "—", TextFlag.LeftCenter );

				if ( _serverLatestRevisionId.HasValue && _serverLatestRevisionId != _serverCurrentRevisionId )
				{
					Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
					Paint.DrawText( new Rect( col1 + labelW + 65, ry, 50, rowH ), "Latest:", TextFlag.LeftCenter );
					Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
					Paint.DrawText( new Rect( col1 + labelW + 115, ry, 60, rowH ), _serverLatestRevisionId.Value.ToString(), TextFlag.LeftCenter );
				}

				// Row 3: Last synced
				ry += rowH;
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Last synced:", TextFlag.LeftCenter );
				var syncedAgo = FormatSyncedAgo( _serverLastSyncedAt );
				var syncedIsNever = string.IsNullOrWhiteSpace( _serverLastSyncedAt );
				Paint.SetPen( syncedIsNever ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), syncedAgo, TextFlag.LeftCenter );

				// Row 4: First synced
				ry += rowH;
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "First synced:", TextFlag.LeftCenter );
				var firstSyncedAgo = FormatUnixAgo( _serverRevisionFirstSyncedAtUnix );
				var firstIsNever = !_serverRevisionFirstSyncedAtUnix.HasValue;
				Paint.SetPen( firstIsNever ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), firstSyncedAgo, TextFlag.LeftCenter );

				// Row 5: Endpoint overrides (if any)
				if ( _serverEndpointOverrideCount > 0 )
				{
					ry += rowH;
					Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
					Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, rowH ), $"{_serverEndpointOverrideCount} endpoint(s) staged", TextFlag.LeftCenter );
				}

				// ═══ RIGHT SIDE: Publish Target ═══
				var rightX = col2;
				var rightW = w - (col2 - pad) - 8;
				var cardH = 38f;
				var cardGap = 4f;
				var radioR = 5f;
				var cardY = y + 4;

				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( rightX, cardY, rightW, 12 ), "PUBLISH TARGET", TextFlag.LeftCenter );
				cardY += 14;

				var isNext = _publishTarget == "next";
				var isLive = !isNext;

				// Card: Staged/Main
				var nextCardRect = new Rect( rightX, cardY, rightW, cardH );
				Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
				Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
				Paint.DrawRect( nextCardRect, 3 );

				var radioX = rightX + 10;
				var radioY = cardY + 10;
				Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.9f ) : Color.Transparent );
				Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
				Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );

				var textX = rightX + 22;
				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( isNext ? Color.Yellow : Color.White.WithAlpha( 0.85f ) );
				Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Staged/Main", TextFlag.LeftCenter );
				Paint.SetDefaultFont( size: 7 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Editor + next release", TextFlag.LeftCenter );
				Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Live players unaffected", TextFlag.LeftCenter );

				if ( !_busy )
					_buttons.Add( new ClickRegion { Rect = nextCardRect, Id = "publish-target-next", OnClick = () => { SetPublishTarget( "next" ); } } );

				cardY += cardH + cardGap;

				// Card: Live
				var liveCardRect = new Rect( rightX, cardY, rightW, cardH );
				Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
				Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
				Paint.DrawRect( liveCardRect, 3 );

				radioY = cardY + 10;
				Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.9f ) : Color.Transparent );
				Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
				Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );

				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( isLive ? Color.Green : Color.White.WithAlpha( 0.85f ) );
				Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Live", TextFlag.LeftCenter );
				Paint.SetDefaultFont( size: 7 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Production / existing live", TextFlag.LeftCenter );
				Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Deployed immediately", TextFlag.LeftCenter );

				if ( !_busy )
					_buttons.Add( new ClickRegion { Rect = liveCardRect, Id = "publish-target-live", OnClick = () => { SetPublishTarget( "live" ); } } );

				y += infoH + 6;
			}
			else
			{
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.Yellow.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( pad, y + 4, w, 16 ), "No package revision detected — publishing will use default live behavior.", TextFlag.LeftCenter );
				y += 26;
			}
		}

		DrawSeparator( ref y, w, pad );

		// ── Begin scrollable content ──
		_scrollAreaTop = y;
		y -= _scrollY;

		// ── Data Sources ──
		if ( SyncToolConfig.SyncMappings.Count > 0 )
		{
			DrawDataSourcesSection( ref y, pad, w );
			DrawSeparator( ref y, w, pad );
		}

		// ── Build item sets for all sections ──
		var remoteEpSlugs = GetRemoteEndpointSlugs();
		var allSlugs = new HashSet<string>();
		foreach ( var f in _endpointFiles ) allSlugs.Add( ResourceIdFromFile( f, "endpoint" ) );
		foreach ( var s in remoteEpSlugs ) allSlugs.Add( s );

		var localColNames = _collectionFiles.Select( f => ResourceIdFromFile( f, "collection" ) ).ToHashSet();
		var remoteColNames = GetRemoteCollectionNames();
		var allColNames = new HashSet<string>( localColNames );
		foreach ( var n in remoteColNames ) allColNames.Add( n );

		var localWfIds = _workflowFiles.Select( f => ResourceIdFromFile( f, "workflow" ) ).ToHashSet();
		var remoteWfIds = GetRemoteWorkflowIds();
		var allWfIds = new HashSet<string>( localWfIds );
		foreach ( var id2 in remoteWfIds ) allWfIds.Add( id2 );

		// ── CHANGES section (only after checking remote) ──
		if ( _hasCheckedRemote )
		{
			var changedIds = _items
				.Where( kv => kv.Value.Status != SyncStatus.Unknown && kv.Value.Status != SyncStatus.InSync )
				.OrderBy( kv => kv.Key )
				.Select( kv => kv.Key )
				.ToList();

			if ( changedIds.Count > 0 )
			{
				DrawSectionHeader( ref y, pad, w, $"CHANGES ({changedIds.Count})" );
				foreach ( var changedId in changedIds )
					DrawChangedItemRow( ref y, pad, w, changedId );
				DrawSeparator( ref y, w, pad );
			}
		}

		// ── Endpoints ──
		var syncedEpSlugs = allSlugs
			.Where( s => !_hasCheckedRemote || GetItemStatus( $"ep_{s}" ) == SyncStatus.InSync || GetItemStatus( $"ep_{s}" ) == SyncStatus.Unknown )
			.OrderBy( s => s ).ToList();

		DrawSectionHeader( ref y, pad, w, $"ENDPOINTS ({allSlugs.Count})" );
		if ( syncedEpSlugs.Count > 0 )
		{
			foreach ( var slug in syncedEpSlugs )
			{
				var id = $"ep_{slug}";
				var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
				var hasLocal = localFile != null;
				var deprecated = hasLocal && IsEndpointDeprecated( localFile );
				var info = deprecated ? $"{GetEndpointInfo( localFile )} - deprecated, ignored by sync" : hasLocal ? GetEndpointInfo( localFile ) : "remote only";
				var capturedSlug = slug;
				DrawResourceRow( ref y, pad, w, $"{slug}.endpoint.yml", info, id,
					hasLocal && !deprecated ? () => PushItem( id ) : null,
					deprecated ? null : () => PullItem( id ),
					deprecated ? null : () => TestResultsWindow.OpenAndRun( capturedSlug, _publishTarget ),
					deprecated, staged: IsEndpointStaged( slug ) );
			}
		}
		else if ( allSlugs.Count == 0 )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
			Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No endpoint files found", TextFlag.LeftCenter );
			y += 22;
		}

		DrawSeparator( ref y, w, pad );

		// ── Collections ──
		var syncedColNames = allColNames
			.Where( n => !_hasCheckedRemote || GetItemStatus( $"col_{n}" ) == SyncStatus.InSync || GetItemStatus( $"col_{n}" ) == SyncStatus.Unknown )
			.OrderBy( n => n ).ToList();

		DrawSectionHeader( ref y, pad, w, $"COLLECTIONS ({allColNames.Count})" );
		if ( syncedColNames.Count > 0 )
		{
			foreach ( var colName in syncedColNames )
			{
				var id = $"col_{colName}";
				var hasLocal = localColNames.Contains( colName );
				DrawResourceRow( ref y, pad, w, $"{colName}.collection.yml", hasLocal ? "schema" : "remote only", id,
					hasLocal ? () => PushItem( id ) : null,
					() => PullItem( id ) );
			}
		}
		else if ( allColNames.Count == 0 )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
			Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No collections found", TextFlag.LeftCenter );
			y += 22;
		}

		DrawSeparator( ref y, w, pad );

		// ── Workflows ──
		var syncedWfIds = allWfIds
			.Where( id2 => !_hasCheckedRemote || GetItemStatus( $"wf_{id2}" ) == SyncStatus.InSync || GetItemStatus( $"wf_{id2}" ) == SyncStatus.Unknown )
			.OrderBy( n => n ).ToList();

		DrawSectionHeader( ref y, pad, w, $"WORKFLOWS ({allWfIds.Count})" );
		if ( syncedWfIds.Count > 0 )
		{
			foreach ( var wfId in syncedWfIds )
			{
				var itemId = $"wf_{wfId}";
				var hasLocal = localWfIds.Contains( wfId );
				var info = hasLocal ? GetWorkflowInfo( SyncToolConfig.FindWorkflowFileById( wfId ) ) : "remote only";
				DrawResourceRow( ref y, pad, w, $"{wfId}.workflow.yml", info, itemId,
					hasLocal ? () => PushItem( itemId ) : null,
					() => PullItem( itemId ) );
			}
		}
		else if ( allWfIds.Count == 0 )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
			Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No workflow files found", TextFlag.LeftCenter );
			y += 22;
		}

		y += 8;
		DrawSeparator( ref y, w, pad );

		// ── Status bar ──
		Paint.SetDefaultFont( size: 9 );
		Paint.SetPen( _busy ? Color.Yellow : _statusIsError ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.4f ) );
		Paint.DrawText( new Rect( pad, y, w, 16 ), _status, TextFlag.LeftCenter );
		y += 22;

		// ── Sync log (shown after push/pull) ──
		if ( _syncLog.Count > 0 )
		{
			DrawSeparator( ref y, w, pad );
			DrawSectionHeader( ref y, pad, w, "SYNC RESULTS" );

			var okCount = _syncLog.Count( e => e.Ok && IsResourceSyncLog( e ) );
			var failCount = _syncLog.Count( e => !e.Ok && IsResourceSyncLog( e ) );
			var verifiedCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Verified" ) );
			var mismatchCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Mismatch" ) );

			// Summary counts
			Paint.SetDefaultFont( size: 9, weight: 600 );
			var sx = pad + 8f;
			if ( okCount > 0 )
			{
				Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
				Paint.DrawText( new Rect( sx, y, 90, 16 ), $"✓ {okCount} pushed", TextFlag.LeftCenter );
				sx += 80;
			}
			if ( verifiedCount > 0 )
			{
				Paint.SetPen( Color.Cyan.WithAlpha( 0.8f ) );
				Paint.DrawText( new Rect( sx, y, 90, 16 ), $"● {verifiedCount} verified", TextFlag.LeftCenter );
				sx += 85;
			}
			if ( mismatchCount > 0 )
			{
				Paint.SetPen( Color.Orange.WithAlpha( 0.9f ) );
				Paint.DrawText( new Rect( sx, y, 100, 16 ), $"▲ {mismatchCount} mismatch", TextFlag.LeftCenter );
				sx += 90;
			}
			var mergeLogCount = _syncLog.Count( e => e.Detail != null && e.Detail.Contains( "Remote semantics available" ) );
			if ( mergeLogCount > 0 )
			{
				Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
				Paint.DrawText( new Rect( sx, y, 140, 16 ), $"⇄ {mergeLogCount} semantics", TextFlag.LeftCenter );
				sx += 120;
			}
			if ( failCount > 0 )
			{
				Paint.SetPen( Color.Red.WithAlpha( 0.8f ) );
				Paint.DrawText( new Rect( sx, y, 90, 16 ), $"✗ {failCount} failed", TextFlag.LeftCenter );
			}
			y += 22;

			// Per-item log
			foreach ( var entry in _syncLog )
			{
				if ( y > _scrollAreaTop && y < Height )
				{
					// Icon
					var isMismatch = entry.Detail != null && entry.Detail.Contains( "Mismatch" );
					var isVerified = entry.Detail != null && entry.Detail.Contains( "Verified" );
					var isMergeAvail = entry.Detail != null && entry.Detail.Contains( "Remote semantics available" );
					var icon = isMergeAvail ? "⇄"
						: entry.Ok ? ( isVerified ? "●" : "✓" )
						: ( isMismatch ? "▲" : "✗" );
					var iconColor = isMergeAvail ? Color.Green.WithAlpha( 0.7f )
						: entry.Ok ? ( isVerified ? Color.Cyan.WithAlpha( 0.7f ) : Color.Green.WithAlpha( 0.6f ) )
						: ( isMismatch ? Color.Orange.WithAlpha( 0.8f ) : Color.Red.WithAlpha( 0.7f ) );

					Paint.SetDefaultFont( size: 9 );
					Paint.SetPen( iconColor );
					Paint.DrawText( new Rect( pad + 8, y, 16, 16 ), icon, TextFlag.Center );

					// Name
					Paint.SetPen( Color.White.WithAlpha( 0.8f ) );
					var nameW = w * 0.35f;
					Paint.DrawText( new Rect( pad + 26, y, nameW, 16 ), entry.Name, TextFlag.LeftCenter );

					// Detail
					var detailText = entry.Detail ?? ( entry.Ok ? "Pushed" : "Failed" );
					var detailColor = isMismatch ? Color.Orange.WithAlpha( 0.8f )
						: isVerified ? Color.Cyan.WithAlpha( 0.6f )
						: entry.Ok ? Color.Green.WithAlpha( 0.5f )
						: Color.Red.WithAlpha( 0.6f );
					Paint.SetDefaultFont( size: 8 );
					Paint.SetPen( detailColor );
					Paint.DrawText( new Rect( pad + 26 + nameW + 4, y, w - nameW - 100, 16 ), detailText, TextFlag.LeftCenter );

					// Type badge
					Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
					Paint.DrawText( new Rect( pad + w - 70, y, 70, 16 ), entry.Type, TextFlag.RightCenter );
				}
				y += 18;
			}
			y += 8;
		}

		// ── Record content height for scrollbar ──
		_contentHeight = ( y + _scrollY ) - _scrollAreaTop;

		// ── Redraw header background to cover scrolled content ──
		Paint.SetBrush( new Color( 0.133f, 0.133f, 0.133f ) );
		Paint.SetPen( Color.Transparent );
		Paint.DrawRect( new Rect( 0, 0, Width, _scrollAreaTop ) );

		// Re-draw header (replayed from top)
		RedrawHeader( pad, w );

		// ── Scrollbar ──
		if ( MaxScroll > 0 )
		{
			var trackX = Width - 8;
			var trackH = Height - _scrollAreaTop - 4;
			var viewRatio = ( Height - _scrollAreaTop ) / _contentHeight;
			var thumbH = Math.Max( 20, trackH * viewRatio );
			var thumbY = _scrollAreaTop + ( _scrollY / MaxScroll ) * ( trackH - thumbH );

			Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
			Paint.SetPen( Color.Transparent );
			Paint.DrawRect( new Rect( trackX, _scrollAreaTop, 6, trackH ) );

			Paint.SetBrush( Color.White.WithAlpha( 0.15f ) );
			Paint.DrawRect( new Rect( trackX, thumbY, 6, thumbH ), 3 );
		}
	}

	/// <summary>
	/// Redraws the fixed header area on top of scrolled content so it doesn't bleed through.
	/// </summary>
	private void RedrawHeader( float pad, float w )
	{
		var y = 38f;

		// Header + Push All + Test All
		Paint.SetDefaultFont( size: 13, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w * 0.55f, 22 ), "Network Storage Sync", TextFlag.LeftCenter );

		if ( SyncToolConfig.IsValid )
		{
			var btnW2 = 92f;
			var testAllW = 92f;
			var pushAllRect = new Rect( pad + w - btnW2, y, btnW2, 22 );
			var testAllRect = new Rect( pad + w - btnW2 - 4 - testAllW, y, testAllW, 22 );
			DrawSmallButton( pushAllRect, PushAllLabel, Color.Green, "push_all", () => _ = PushAll() );
			DrawSmallButton( testAllRect, TestAllLabel, Color.Cyan, "test_all", () => TestResultsWindow.OpenAndRun( publishTarget: _publishTarget ) );
		}
		y += 30;

		// Config status
		if ( !SyncToolConfig.IsValid )
		{
			Paint.SetDefaultFont( size: 10 );
			Paint.SetPen( Color.Red );
			Paint.DrawText( new Rect( pad, y, w, 16 ), "Not configured — click Setup to enter your keys", TextFlag.LeftCenter );
			y += 24;
		}
		else
		{
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), $"Connected — {SyncToolConfig.ProjectId}", TextFlag.LeftCenter );
			y += 18;

			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.White.WithAlpha( 0.72f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ),
				$"Auth Sessions: {SyncToolConfig.AuthSessionsLabel}   Encrypted Requests: {SyncToolConfig.EncryptedRequestsLabel}",
				TextFlag.LeftCenter );
			y += 20;
		}

		// Check for Updates button
		if ( SyncToolConfig.IsValid )
		{
			var checkBtnH = 26f;
			var checkLabel = _hasCheckedRemote ? $"Pull from {PublishTargetLabel} (re-check)" : $"Check {PublishTargetLabel} for Updates";
			var checkRect = new Rect( pad, y, w, checkBtnH );
			DrawWideButton( checkRect, checkLabel, Color.Cyan, "check_updates", () => _ = CheckForUpdates() );
			y += checkBtnH + 8;

			var pullableCount = GetPullableResourceIds().Length;
			if ( pullableCount > 0 )
			{
				var pullAllRect = new Rect( pad, y, w, checkBtnH );
				DrawWideButton( pullAllRect, $"Pull All ({pullableCount}) from {(_publishTarget == "next" ? "Staged/Main" : "Live")}", Color.Cyan, "pull_all_changed",
					() => PullAllChangedResources() );
				y += checkBtnH + 8;
			}

			var remoteSemanticsCount = GetRemoteSemanticsCount();
			if ( remoteSemanticsCount > 0 )
			{
				var semanticsLabel = remoteSemanticsCount == 1
					? "Merge All (1 semantic item)"
					: $"Merge All ({remoteSemanticsCount} semantic items)";
				var semanticsRect = new Rect( pad, y, w, checkBtnH );
				DrawWideButton( semanticsRect, semanticsLabel, Color.Green, "pull_remote_semantics_all",
					() => PullAllRemoteSemantics() );
				y += checkBtnH + 8;
			}
		}

		// ── Package / Revision Info ──
		if ( SyncToolConfig.IsValid )
		{
			if ( _packageInfoDetected )
			{
				var infoH = 104f;
				Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
				Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
				Paint.DrawRect( new Rect( pad, y, w, infoH ), 4 );

				var col1 = pad + 8;
				var col2 = pad + w * 0.48f;
				var labelW = 90f;
				var rowH = 16f;
				var ry = y + 4;

				// ═══ LEFT SIDE: Package Info ═══
				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, 12 ), "REVISION / PACKAGE INFO", TextFlag.LeftCenter );
				ry += 14;
				// Row 1: Package
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Package:", TextFlag.LeftCenter );
				Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, col2 - col1 - labelW - 8, rowH ), _packageIdent ?? "Unknown", TextFlag.LeftCenter );

				// Row 2: Revision | Latest
				ry += rowH;
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Revision:", TextFlag.LeftCenter );
				Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 60, rowH ), _serverCurrentRevisionId?.ToString() ?? "—", TextFlag.LeftCenter );

				if ( _serverLatestRevisionId.HasValue && _serverLatestRevisionId != _serverCurrentRevisionId )
				{
					Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
					Paint.DrawText( new Rect( col1 + labelW + 65, ry, 50, rowH ), "Latest:", TextFlag.LeftCenter );
					Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
					Paint.DrawText( new Rect( col1 + labelW + 115, ry, 60, rowH ), _serverLatestRevisionId.Value.ToString(), TextFlag.LeftCenter );
				}

				// Row 3: Last synced
				ry += rowH;
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Last synced:", TextFlag.LeftCenter );
				var syncedAgo = FormatSyncedAgo( _serverLastSyncedAt );
				var syncedIsNever = string.IsNullOrWhiteSpace( _serverLastSyncedAt );
				Paint.SetPen( syncedIsNever ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), syncedAgo, TextFlag.LeftCenter );

				// Row 4: First synced
				ry += rowH;
				Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
				Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "First synced:", TextFlag.LeftCenter );
				var firstSyncedAgo = FormatUnixAgo( _serverRevisionFirstSyncedAtUnix );
				var firstIsNever = !_serverRevisionFirstSyncedAtUnix.HasValue;
				Paint.SetPen( firstIsNever ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), firstSyncedAgo, TextFlag.LeftCenter );

				// Row 5: Endpoint overrides (if any)
				if ( _serverEndpointOverrideCount > 0 )
				{
					ry += rowH;
					Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
					Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, rowH ), $"{_serverEndpointOverrideCount} endpoint(s) staged", TextFlag.LeftCenter );
				}

				// ═══ RIGHT SIDE: Publish Target ═══
				var rightX = col2;
				var rightW = w - (col2 - pad) - 8;
				var cardH = 38f;
				var cardGap = 4f;
				var radioR = 5f;
				var cardY = y + 4;

				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( rightX, cardY, rightW, 12 ), "PUBLISH TARGET", TextFlag.LeftCenter );
				cardY += 14;

				var isNext = _publishTarget == "next";
				var isLive = !isNext;

				// Card: Staged/Main
				var nextCardRect = new Rect( rightX, cardY, rightW, cardH );
				Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
				Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
				Paint.DrawRect( nextCardRect, 3 );

				var radioX = rightX + 10;
				var radioY = cardY + 10;
				Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.9f ) : Color.Transparent );
				Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
				Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );

				var textX = rightX + 22;
				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( isNext ? Color.Yellow : Color.White.WithAlpha( 0.85f ) );
				Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Staged/Main", TextFlag.LeftCenter );
				Paint.SetDefaultFont( size: 7 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Editor + next release", TextFlag.LeftCenter );
				Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Live players unaffected", TextFlag.LeftCenter );

				if ( !_busy )
					_buttons.Add( new ClickRegion { Rect = nextCardRect, Id = "publish-target-next", OnClick = () => { SetPublishTarget( "next" ); } } );

				cardY += cardH + cardGap;

				// Card: Live
				var liveCardRect = new Rect( rightX, cardY, rightW, cardH );
				Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
				Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
				Paint.DrawRect( liveCardRect, 3 );

				radioY = cardY + 10;
				Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.9f ) : Color.Transparent );
				Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
				Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );

				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( isLive ? Color.Green : Color.White.WithAlpha( 0.85f ) );
				Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Live", TextFlag.LeftCenter );
				Paint.SetDefaultFont( size: 7 );
				Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
				Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Production / existing live", TextFlag.LeftCenter );
				Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Deployed immediately", TextFlag.LeftCenter );

				if ( !_busy )
					_buttons.Add( new ClickRegion { Rect = liveCardRect, Id = "publish-target-live", OnClick = () => { SetPublishTarget( "live" ); } } );

				y += infoH + 6;
			}
			else
			{
				Paint.SetDefaultFont( size: 9 );
				Paint.SetPen( Color.Yellow.WithAlpha( 0.75f ) );
				Paint.DrawText( new Rect( pad, y + 4, w, 16 ), "No package revision detected — publishing will use default live behavior.", TextFlag.LeftCenter );
				y += 26;
			}
		}

		DrawSeparator( ref y, w, pad );
	}

	// ──────────────────────────────────────────────────────
	//  Drawing helpers
	// ──────────────────────────────────────────────────────

	private void DrawSectionHeader( ref float y, float pad, float w, string title )
	{
		Paint.SetDefaultFont( size: 10, weight: 700 );
		Paint.SetPen( Color.White.WithAlpha( 0.7f ) );
		Paint.DrawText( new Rect( pad, y, w, 18 ), title, TextFlag.LeftCenter );
		y += 24;
	}

	private static string FormatSyncedAgo( string isoTimestamp )
	{
		if ( string.IsNullOrWhiteSpace( isoTimestamp ) )
			return "Never";

		if ( !DateTimeOffset.TryParse( isoTimestamp, out var ts ) )
			return isoTimestamp;

		var ago = DateTimeOffset.UtcNow - ts;
		if ( ago.TotalSeconds < 60 ) return "Just now";
		if ( ago.TotalMinutes < 60 ) return $"{(int)ago.TotalMinutes}m ago";
		if ( ago.TotalHours < 24 ) return $"{(int)ago.TotalHours}h ago";
		if ( ago.TotalDays < 7 ) return $"{(int)ago.TotalDays}d ago";
		return ts.ToString( "yyyy-MM-dd HH:mm" );
	}

	private static string FormatUnixAgo( long? unixSeconds )
	{
		if ( !unixSeconds.HasValue || unixSeconds.Value <= 0 )
			return "Never";

		var ts = DateTimeOffset.FromUnixTimeSeconds( unixSeconds.Value );
		var ago = DateTimeOffset.UtcNow - ts;
		if ( ago.TotalSeconds < 60 ) return "Just now";
		if ( ago.TotalMinutes < 60 ) return $"{(int)ago.TotalMinutes}m ago";
		if ( ago.TotalHours < 24 ) return $"{(int)ago.TotalHours}h ago";
		if ( ago.TotalDays < 7 ) return $"{(int)ago.TotalDays}d ago";
		return ts.ToString( "yyyy-MM-dd HH:mm" );
	}

	private bool IsEndpointStaged( string slug )
	{
		if ( !_remoteEndpoints.HasValue || _remoteEndpoints.Value.ValueKind != JsonValueKind.Array )
			return false;
		foreach ( var ep in _remoteEndpoints.Value.EnumerateArray() )
		{
			if ( ep.TryGetProperty( "slug", out var s ) && s.GetString() == slug &&
				 ep.TryGetProperty( "revisionTarget", out var rt ) && rt.GetString() == "next" )
				return true;
		}
		return false;
	}

	private void DrawSeparator( ref float y, float w, float pad )
	{
		Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
		Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
		y += 8;
	}

	private void DrawSmallButton( Rect rect, string label, Color color, string id, Action onClick )
	{
		var hovered = rect.IsInside( _mousePos );
		var isBusy = _busy && _busyItem == id;

		Paint.SetBrush( color.WithAlpha( isBusy ? 0.2f : hovered ? 0.15f : 0.08f ) );
		Paint.SetPen( color.WithAlpha( hovered ? 0.5f : 0.25f ) );
		Paint.DrawRect( rect, 3 );
		Paint.SetDefaultFont( size: 9, weight: 600 );
		Paint.SetPen( isBusy ? Color.Yellow : color.WithAlpha( hovered ? 1f : 0.7f ) );
		Paint.DrawText( rect, isBusy ? "..." : label, TextFlag.Center );

		if ( !_busy )
			_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
	}

	private void DrawWideButton( Rect rect, string label, Color color, string id, Action onClick )
	{
		var hovered = rect.IsInside( _mousePos );
		var isBusy = _busy && _busyItem == id;

		Paint.SetBrush( color.WithAlpha( isBusy ? 0.15f : hovered ? 0.1f : 0.05f ) );
		Paint.SetPen( color.WithAlpha( hovered ? 0.4f : 0.2f ) );
		Paint.DrawRect( rect, 4 );
		Paint.SetDefaultFont( size: 10, weight: 600 );
		Paint.SetPen( isBusy ? Color.Yellow : color.WithAlpha( hovered ? 0.9f : 0.6f ) );
		Paint.DrawText( rect, isBusy ? "Checking..." : label, TextFlag.Center );

		if ( !_busy )
			_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
	}

	private void DrawResourceRow( ref float y, float pad, float w, string name, string info, string id,
		Action pushAction, Action pullAction, Action testAction = null, bool deprecated = false, bool staged = false )
	{
		var rowH = 28f;
		var btnW = 48f;
		var btnH = 20f;
		var rowRect = new Rect( pad, y, w, rowH );
		var hovered = rowRect.IsInside( _mousePos );

		if ( hovered )
		{
			Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
			Paint.SetPen( Color.Transparent );
			Paint.DrawRect( rowRect, 3 );
		}

		// Status icon + label (leftmost)
		_items.TryGetValue( id, out var state );
		var hasResult = !string.IsNullOrEmpty( state.SyncResult );
		var hasStatusBadge = _hasCheckedRemote || hasResult;
		var hasIndicator = hasResult || state.RemoteDiffers || state.Status == SyncStatus.MergeAvailable || ( _hasCheckedRemote && state.Status != SyncStatus.Unknown );

		// Icon
		if ( hasResult )
		{
			var iconColor = state.SyncResult == "OK" ? Color.Green.WithAlpha( 0.8f ) : Color.Red.WithAlpha( 0.8f );
			Paint.SetPen( iconColor );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), state.SyncResult == "OK" ? "✓" : "✗", TextFlag.Center );
		}
		else if ( state.Status == SyncStatus.MergeAvailable )
		{
			Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "⇄", TextFlag.Center );
		}
		else if ( state.RemoteDiffers || state.Status == SyncStatus.Differs )
		{
			Paint.SetPen( Color.Orange.WithAlpha( 0.8f ) );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "●", TextFlag.Center );
		}
		else if ( state.Status == SyncStatus.LocalOnly )
		{
			Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "▲", TextFlag.Center );
		}
		else if ( state.Status == SyncStatus.InSync )
		{
			Paint.SetPen( Color.Green.WithAlpha( 0.5f ) );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "✓", TextFlag.Center );
		}
		else if ( state.Status == SyncStatus.RemoteOnly )
		{
			Paint.SetPen( Color.Cyan.WithAlpha( 0.8f ) );
			Paint.SetDefaultFont( size: 9 );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "▼", TextFlag.Center );
		}

		var contentX = pad + ( hasIndicator ? 22 : 8 );
		var btnY = y + ( rowH - btnH ) / 2;

		// Pull/Review button — LEFT side (only if remote has changes to pull)
		if ( state.Status == SyncStatus.MergeAvailable )
		{
			var mergeW = 56f;
			var mergeRect = new Rect( contentX, btnY, mergeW, btnH );
			var capturedMergeId = id;
			var capturedMergeName = name;
			DrawSmallButton( mergeRect, "Review", Color.Green, $"merge_{id}",
				() => OpenMergeView( capturedMergeId, capturedMergeName ) );
			contentX += mergeW + 6;
		}
		else if ( state.RemoteDiffers )
		{
			var pullRect = new Rect( contentX, btnY, btnW, btnH );
			DrawSmallButton( pullRect, "Pull", Color.Cyan, $"pull_{id}", pullAction );
			contentX += btnW + 6;
		}

		// File name
		// Calculate right-side button area width
		var rightBtnsW = 4f;
		if ( pushAction != null ) rightBtnsW += btnW + 4;
		if ( testAction != null ) rightBtnsW += btnW + 4;

		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( deprecated ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.9f ) );
		var badgeReserve = hasStatusBadge && !hasResult ? 86f : 70f;
		var nameW = w - ( contentX - pad ) - rightBtnsW - badgeReserve;
		Paint.DrawText( new Rect( contentX, y, nameW, rowH ), name, TextFlag.LeftCenter );

		// Deployment target indicator (only for endpoints with remote data)
		var afterNameX = contentX + Paint.MeasureText( name ).x + 4;
		if ( id.StartsWith( "ep_" ) && _hasCheckedRemote && state.Status != SyncStatus.LocalOnly )
		{
			Paint.SetDefaultFont( size: 7 );
			var targetText = staged ? "Staged" : "Live";
			var targetColor = staged ? Color.Yellow : Color.Green;
			var targetW = Paint.MeasureText( targetText ).x + 6;
			var targetRect = new Rect( afterNameX, y + ( rowH - 12 ) / 2, targetW, 12 );

			Paint.SetBrush( targetColor.WithAlpha( 0.1f ) );
			Paint.SetPen( targetColor.WithAlpha( 0.25f ) );
			Paint.DrawRect( targetRect, 2 );
			Paint.SetPen( targetColor.WithAlpha( 0.7f ) );
			Paint.DrawText( targetRect, targetText, TextFlag.Center );

			afterNameX += targetW + 4;
		}

		// Deprecated badge
		if ( deprecated )
		{
			Paint.SetDefaultFont( size: 7 );

			var depText = "deprecated";
			var depTextW = Paint.MeasureText( depText ).x + 8;
			var depBadgeRect = new Rect( afterNameX, y + ( rowH - 14 ) / 2, depTextW, 14 );

			Paint.SetBrush( new Color( 0.96f, 0.62f, 0.04f, 0.12f ) );
			Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.25f ) );
			Paint.DrawRect( depBadgeRect, 3 );

			Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.7f ) );
			Paint.DrawText( depBadgeRect, depText, TextFlag.Center );
		}

		// Status badge (right of name, before buttons)
		float statusBadgeEndX = contentX + nameW + 4;
		if ( hasStatusBadge && !hasResult )
		{
			// Skip the simple "Synced" badge if we'll show "Synced: live" or "Synced: revision" below
			var skipSyncedBadge = state.Status == SyncStatus.InSync && ( staged || _packageInfoDetected );

			var (badgeText, badgeColor) = state.Status switch
			{
				SyncStatus.InSync => skipSyncedBadge ? ("", Color.Transparent) : ("Synced", Color.Green.WithAlpha( 0.5f )),
				SyncStatus.LocalOnly => ("Local", Color.Yellow.WithAlpha( 0.7f )),
				SyncStatus.RemoteOnly => ("Remote", Color.Cyan.WithAlpha( 0.7f )),
				SyncStatus.Differs => ("Changed", Color.Orange.WithAlpha( 0.7f )),
				SyncStatus.MergeAvailable => ("Semantic", Color.Green.WithAlpha( 0.7f )),
				_ => ("", Color.White.WithAlpha( 0.3f ))
			};

			if ( !string.IsNullOrEmpty( badgeText ) )
			{
				Paint.SetDefaultFont( size: 8 );
				Paint.SetPen( badgeColor );
				var badgeX = contentX + nameW + 4;
				var badgeW = Math.Max( 50f, Paint.MeasureText( badgeText ).x + 8 );
				Paint.DrawText( new Rect( badgeX, y, badgeW, rowH ), badgeText, TextFlag.LeftCenter );
				statusBadgeEndX = badgeX + badgeW + 4;
			}
		}

		// Deployment status badge (only show when actually in sync)
		if ( state.Status == SyncStatus.InSync )
		{
			if ( staged )
			{
				var nextRev = ((_serverCurrentRevisionId ?? _currentRevisionId) ?? 0) + 1;
				var revText = $"Synced: rev {nextRev}";
				Paint.SetDefaultFont( size: 7 );
				var revW = Paint.MeasureText( revText ).x + 10;
				var revRect = new Rect( statusBadgeEndX, y + ( rowH - 14 ) / 2, revW, 14 );
				Paint.SetBrush( Color.Yellow.WithAlpha( 0.1f ) );
				Paint.SetPen( Color.Yellow.WithAlpha( 0.3f ) );
				Paint.DrawRect( revRect, 3 );
				Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
				Paint.DrawText( revRect, revText, TextFlag.Center );
			}
			else if ( _packageInfoDetected )
			{
				var revText = "Synced: live";
				Paint.SetDefaultFont( size: 7 );
				var revW = Paint.MeasureText( revText ).x + 10;
				var revRect = new Rect( statusBadgeEndX, y + ( rowH - 14 ) / 2, revW, 14 );
				Paint.SetBrush( Color.Green.WithAlpha( 0.1f ) );
				Paint.SetPen( Color.Green.WithAlpha( 0.3f ) );
				Paint.DrawRect( revRect, 3 );
				Paint.SetPen( Color.Green.WithAlpha( 0.7f ) );
				Paint.DrawText( revRect, revText, TextFlag.Center );
			}
		}

		// Test button — RIGHT side, before Push
		var rightX = pad + w - 4;
		if ( pushAction != null )
		{
			rightX -= btnW;
			var pushRect = new Rect( rightX, btnY, btnW, btnH );
			DrawSmallButton( pushRect, "Push", Color.White, $"push_{id}", pushAction );
			rightX -= 4;
		}
		if ( testAction != null )
		{
			rightX -= btnW;
			var testRect = new Rect( rightX, btnY, btnW, btnH );
			DrawSmallButton( testRect, "Test", Color.Cyan, $"test_{id}", _busy ? null : testAction );
		}

		y += rowH + 1;

		// Diff/status details (below the row)
		if ( !string.IsNullOrEmpty( state.DiffSummary ) && ( state.RemoteDiffers || state.Status == SyncStatus.LocalOnly || state.Status == SyncStatus.MergeAvailable ) )
		{
			// Summary text
			var summaryColor = state.Status == SyncStatus.MergeAvailable ? Color.Green.WithAlpha( 0.6f )
				: state.Status == SyncStatus.LocalOnly ? Color.Yellow.WithAlpha( 0.6f )
				: Color.Orange.WithAlpha( 0.6f );
			Paint.SetDefaultFont( size: 8 );
			Paint.SetPen( summaryColor );
			Paint.DrawText( new Rect( pad + 22, y, w - 80, 14 ), state.DiffSummary, TextFlag.LeftCenter );

			// Action buttons on the diff line
			if ( state.Status != SyncStatus.LocalOnly && state.Status != SyncStatus.MergeAvailable )
			{
				var btnX = pad + w - 4;

				// View Diff button
				var diffBtnW = 56f;
				var diffBtnH = 16f;
				btnX -= diffBtnW;
				var diffBtnRect = new Rect( btnX, y - 1, diffBtnW, diffBtnH );
				var diffHovered = diffBtnRect.IsInside( _mousePos );

				Paint.SetBrush( Color.Orange.WithAlpha( diffHovered ? 0.2f : 0.08f ) );
				Paint.SetPen( Color.Orange.WithAlpha( diffHovered ? 0.6f : 0.25f ) );
				Paint.DrawRect( diffBtnRect, 3 );
				Paint.SetDefaultFont( size: 8, weight: 600 );
				Paint.SetPen( Color.Orange.WithAlpha( diffHovered ? 1f : 0.7f ) );
				Paint.DrawText( diffBtnRect, "View Diff", TextFlag.Center );

				var capturedId = id;
				var capturedName = name;
				if ( !_busy )
					_buttons.Add( new ClickRegion { Rect = diffBtnRect, Id = $"diff_{id}", OnClick = () => OpenDiffView( capturedId, capturedName ) } );

				// Merge Meta button (for collections where schema is same but metadata differs)
				if ( id.StartsWith( "col_" ) && state.DiffSummary != null && state.DiffSummary.Contains( "schema: identical" ) && state.DiffSummary.Contains( "metadata differs" ) )
				{
					var mergeBtnW = 68f;
					btnX -= mergeBtnW + 4;
					var mergeBtnRect = new Rect( btnX, y - 1, mergeBtnW, diffBtnH );
					var mergeHovered = mergeBtnRect.IsInside( _mousePos );

					Paint.SetBrush( Color.Green.WithAlpha( mergeHovered ? 0.2f : 0.08f ) );
					Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 0.6f : 0.25f ) );
					Paint.DrawRect( mergeBtnRect, 3 );
					Paint.SetDefaultFont( size: 8, weight: 600 );
					Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 1f : 0.7f ) );
					Paint.DrawText( mergeBtnRect, "Merge Meta", TextFlag.Center );

					if ( !_busy )
						_buttons.Add( new ClickRegion { Rect = mergeBtnRect, Id = $"merge_{id}", OnClick = () => MergeMetadata( capturedId ) } );
				}
			}

			y += 18;
		}

		y += 1;
	}

	// ──────────────────────────────────────────────────────
	//  Data Sources (C# → Collection sync mappings)
	// ──────────────────────────────────────────────────────

	private void DrawDataSourcesSection( ref float y, float pad, float w )
	{
		DrawSectionHeader( ref y, pad, w, $"DATA SOURCES ({SyncToolConfig.SyncMappings.Count})" );

		foreach ( var mapping in SyncToolConfig.SyncMappings )
		{
			var status = GetDataSourceStatus( mapping );

			var rowH = 28f;
			var btnW = 68f;
			var btnH = 20f;
			var rowRect = new Rect( pad, y, w, rowH );
			var hovered = rowRect.IsInside( _mousePos );

			if ( hovered )
			{
				Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
				Paint.SetPen( Color.Transparent );
				Paint.DrawRect( rowRect, 3 );
			}

			// Status icon
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( status.Color );
			Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), status.Icon, TextFlag.Center );

			// Mapping label
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
			var labelSuffix = status.IsStale ? " (stale)" : "";
			Paint.DrawText( new Rect( pad + 22, y, w - btnW - 30, rowH ),
				$"{mapping.CsFile} → {mapping.Collection}.collection.yml{labelSuffix}", TextFlag.LeftCenter );

			// Generate button
			if ( status.SourceExists )
			{
				var btnY = y + ( rowH - btnH ) / 2;
				var btnRect = new Rect( pad + w - btnW - 4, btnY, btnW, btnH );
				var genId = $"gen_{mapping.Collection}";
				var isBusyGen = _generateBusy && _busyItem == genId;
				var capturedCollection = mapping.Collection;
				DrawSmallButton( btnRect, isBusyGen ? "..." : "Generate", Color.Green, genId,
					() => ConfirmDialog.Show(
						"Generate Collection Data",
						$"This will overwrite {capturedCollection}.collection.yml with data parsed from your C# source files.",
						() => _ = RunGenerate( capturedCollection ),
						"Local YAML source will be regenerated from C# definitions" ) );
			}

			y += rowH + 1;

			// Description
			var detailText = string.IsNullOrEmpty( mapping.Description )
				? status.Detail
				: $"{status.Label}: {status.Detail} - {mapping.Description}";
			if ( !string.IsNullOrEmpty( detailText ) )
			{
				Paint.SetDefaultFont( size: 8 );
				Paint.SetPen( status.IsStale ? Color.Yellow.WithAlpha( 0.75f ) : Color.White.WithAlpha( 0.35f ) );
				Paint.DrawText( new Rect( pad + 22, y, w - 30, 14 ), detailText, TextFlag.LeftCenter );
				y += 16;
			}
		}

	}

	private async Task RunGenerate( string collectionFilter )
	{
		if ( _generateBusy ) return;
		_generateBusy = true;
		_busyItem = collectionFilter != null ? $"gen_{collectionFilter}" : "generate_all";
		SetStatus( "Generating collection data from C# sources..." );
		Update();

		try
		{
			var syncPy = SyncToolConfig.Abs( SyncToolConfig.SyncPyPath );
			if ( !File.Exists( syncPy ) )
			{
				ShowGenerateFailure( $"sync.py not found at {SyncToolConfig.SyncPyPath}" );
				return;
			}

			var projectRoot = SyncToolConfig.ProjectRoot;
			var args = $"\"{syncPy}\" --project-root \"{projectRoot}\" --generate";
			if ( collectionFilter != null )
				args += $" --collection \"{collectionFilter}\"";

			var psi = new ProcessStartInfo
			{
				FileName = "python",
				Arguments = args,
				WorkingDirectory = SyncToolConfig.ProjectRoot,
				RedirectStandardOutput = true,
				RedirectStandardError = true,
				UseShellExecute = false,
				CreateNoWindow = true
			};

			var process = Process.Start( psi );
			if ( process == null )
			{
				ShowGenerateFailure( "Failed to start Python. Check that Python is installed and on PATH." );
				return;
			}

			var stdout = await Task.Run( () => process.StandardOutput.ReadToEnd() );
			var stderr = await Task.Run( () => process.StandardError.ReadToEnd() );
			process.WaitForExit( 30000 );
			var trimmedStdout = stdout?.Trim();
			var trimmedStderr = stderr?.Trim();

			// Log output to console, not inline
			if ( !string.IsNullOrWhiteSpace( trimmedStdout ) )
				Log.Info( $"[SyncTool] sync.py output:\n{trimmedStdout}" );
			if ( !string.IsNullOrWhiteSpace( trimmedStderr ) )
				Log.Warning( $"[SyncTool] sync.py errors:\n{trimmedStderr}" );

			if ( process.ExitCode == 0 )
			{
				if ( string.IsNullOrWhiteSpace( trimmedStdout ) && string.IsNullOrWhiteSpace( trimmedStderr ) )
				{
					ShowGenerateFailure(
						"sync.py exited with code 0 but produced no output",
						$"Command: python {args}\nWorking directory: {SyncToolConfig.ProjectRoot}" );
				}
				else
				{
					SetStatus( "Generation complete" );
					RefreshFileList();
				}
			}
			else
			{
				var summary = ExtractGenerateFailureSummary( stdout, stderr, process.ExitCode );
				var detail = BuildGenerateFailureDetail( stdout, stderr );
				ShowGenerateFailure( summary, detail );
			}
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Generate error: {ex.Message}" );
			ShowGenerateFailure( $"Generate failed: {ex.Message}" );
		}
		finally
		{
			_generateBusy = false;
			_busyItem = null;
			Update();
		}
	}

	// ──────────────────────────────────────────────────────
	//  Mouse + Scroll
	// ──────────────────────────────────────────────────────

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );
		if ( _busy ) return;

		foreach ( var btn in _buttons )
		{
			if ( btn.Rect.IsInside( e.LocalPosition ) )
			{
				btn.OnClick?.Invoke();
				return;
			}
		}
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );
		_mousePos = e.LocalPosition;
		Update();
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		var direction = e.Delta > 0 ? -1 : 1;
		_scrollY = Math.Clamp( _scrollY + direction * RowH * 3, 0, MaxScroll );
		Update();
		e.Accept();
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		var handled = true;
		switch ( e.Key )
		{
			case KeyCode.Up: _scrollY = Math.Max( 0, _scrollY - RowH ); break;
			case KeyCode.Down: _scrollY = Math.Min( MaxScroll, _scrollY + RowH ); break;
			case KeyCode.PageUp: _scrollY = Math.Max( 0, _scrollY - RowH * 10 ); break;
			case KeyCode.PageDown: _scrollY = Math.Min( MaxScroll, _scrollY + RowH * 10 ); break;
			case KeyCode.Home: _scrollY = 0; break;
			case KeyCode.End: _scrollY = MaxScroll; break;
			default: handled = false; break;
		}

		if ( handled ) Update();
		else base.OnKeyPress( e );
	}

	/// <summary>Scroll to show the bottom of the content.</summary>
	private void ScrollToBottom()
	{
		if ( !IsValid ) return;
		_scrollY = MaxScroll;
		Update();
	}

	// ──────────────────────────────────────────────────────
	//  Helpers
	// ──────────────────────────────────────────────────────

	private bool HasRemoteDiff( string idPrefix )
	{
		return _items.Any( kv => kv.Key.StartsWith( idPrefix ) && kv.Value.RemoteDiffers );
	}

	private SyncStatus GetItemStatus( string id )
	{
		return _items.TryGetValue( id, out var s ) ? s.Status : SyncStatus.Unknown;
	}

	/// <summary>
	/// Draws a row in the CHANGES section — reconstructs push/pull actions from the item id prefix.
	/// </summary>
	private void DrawChangedItemRow( ref float y, float pad, float w, string id )
	{
		string name, info;
		Action pushAction, pullAction;
		Action testAction = null;

		bool deprecated = false;

		if ( id.StartsWith( "ep_" ) )
		{
			var slug = id.Substring( 3 );
			var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
			var hasLocal = localFile != null;
			deprecated = hasLocal && IsEndpointDeprecated( localFile );
			name = $"{slug}.endpoint.yml";
			info = deprecated ? $"{GetEndpointInfo( localFile )} - deprecated, ignored by sync" : hasLocal ? GetEndpointInfo( localFile ) : "remote only";
			var capturedId = id;
			var capturedSlug = slug;
			pushAction = hasLocal && !deprecated ? () => PushItem( capturedId ) : null;
			pullAction = deprecated ? null : () => PullItem( capturedId );
			testAction = deprecated ? null : () => TestResultsWindow.OpenAndRun( capturedSlug, _publishTarget );
		}
		else if ( id.StartsWith( "col_" ) )
		{
			var colName = id.Substring( 4 );
			var hasLocal = _collectionFiles.Any( f => ResourceIdFromFile( f, "collection" ) == colName );
			name = $"{colName}.collection.yml";
			info = hasLocal ? "schema" : "remote only";
			var capturedId = id;
			pushAction = hasLocal ? () => PushItem( capturedId ) : null;
			pullAction = () => PullItem( capturedId );
		}
		else if ( id.StartsWith( "wf_" ) )
		{
			var wfId = id.Substring( 3 );
			var localFile = SyncToolConfig.FindWorkflowFileById( wfId );
			var hasLocal = localFile != null;
			name = $"{wfId}.workflow.yml";
			info = hasLocal ? GetWorkflowInfo( localFile ) : "remote only";
			var capturedId = id;
			pushAction = hasLocal ? () => PushItem( capturedId ) : null;
			pullAction = () => PullItem( capturedId );
		}
		else return;

		DrawResourceRow( ref y, pad, w, name, info, id, pushAction, pullAction, testAction, deprecated,
			staged: id.StartsWith( "ep_" ) && IsEndpointStaged( id.Substring( 3 ) ) );
	}

	private List<string> GetRemoteEndpointSlugs()
	{
		if ( !_remoteEndpoints.HasValue ) return new List<string>();
		var data = _remoteEndpoints.Value;
		if ( data.TryGetProperty( "data", out var d ) ) data = d;
		if ( data.ValueKind != JsonValueKind.Array ) return new List<string>();

		var slugs = new List<string>();
		foreach ( var ep in data.EnumerateArray() )
		{
			if ( SyncToolConfig.IsEndpointDeprecated( ep ) )
				continue;

			var slug = GetRemoteEndpointSlug( ep );
			if ( !string.IsNullOrEmpty( slug ) )
				slugs.Add( slug );
		}
		return slugs;
	}

	private static string GetRemoteEndpointSlug( JsonElement ep )
	{
		if ( ep.TryGetProperty( "slug", out var s ) && s.ValueKind == JsonValueKind.String )
			return s.GetString();

		var local = SyncToolTransforms.ServerEndpointToLocal( ep );
		return local.TryGetValue( "slug", out var value ) ? value?.ToString() : "";
	}

	private List<string> GetRemoteCollectionNames()
	{
		if ( !_remoteCollections.HasValue ) return new List<string>();
		var collections = SyncToolTransforms.ServerToCollections( _remoteCollections.Value );
		return collections.Select( c => c.Name ).ToList();
	}

	private List<string> GetRemoteWorkflowIds()
	{
		if ( !_remoteWorkflows.HasValue ) return new List<string>();
		var data = _remoteWorkflows.Value;
		if ( data.TryGetProperty( "data", out var d ) ) data = d;
		if ( data.ValueKind != JsonValueKind.Array ) return new List<string>();

		var ids = new List<string>();
		foreach ( var wf in data.EnumerateArray() )
		{
			if ( wf.TryGetProperty( "id", out var id ) )
				ids.Add( id.GetString() );
		}
		return ids;
	}

	private string GetWorkflowInfo( string filePath )
	{
		try
		{
			if ( !TryReadLocalResourceFile( filePath, "workflow", out var wf ) )
				return "";
			return wf.TryGetProperty( "name", out var n ) ? n.GetString() : "workflow";
		}
		catch { return ""; }
	}

	private string GetEndpointInfo( string filePath )
	{
		try
		{
			if ( !TryReadLocalResourceFile( filePath, "endpoint", out var ep ) )
				return "";
			return ep.TryGetProperty( "method", out var m ) ? m.GetString() : "POST";
		}
		catch { return ""; }
	}

	private string[] GetActiveEndpointFiles()
	{
		return _endpointFiles.Where( f => !IsEndpointDeprecated( f ) ).ToArray();
	}

	private bool IsEndpointDeprecated( string filePath )
	{
		try
		{
			if ( !TryReadLocalResourceFile( filePath, "endpoint", out var ep ) )
				return false;
			return SyncToolConfig.IsEndpointDeprecated( ep );
		}
		catch { return false; }
	}

	private bool TryReadLocalResourceFile( string filePath, string kind, out JsonElement resource )
	{
		resource = default;
		return SyncToolConfig.TryLoadSourceCanonicalResource( kind, filePath, out resource );
	}

	private void SetItemState( string id, string result = null, bool? remoteDiffers = null,
		string diffSummary = null, string localJson = null, string remoteJson = null, SyncStatus? status = null )
	{
		_items.TryGetValue( id, out var state );
		if ( result != null ) state.SyncResult = result;
		if ( remoteDiffers.HasValue ) state.RemoteDiffers = remoteDiffers.Value;
		if ( diffSummary != null ) state.DiffSummary = diffSummary;
		if ( localJson != null )
		{
			state.LocalJson = localJson;
			state.LocalYaml = SyncToolYamlRenderer.RenderFromJson( localJson );
		}
		if ( remoteJson != null )
		{
			state.RemoteJson = remoteJson;
			state.RemoteYaml = SyncToolYamlRenderer.RenderFromJson( remoteJson );
		}
		if ( status.HasValue ) state.Status = status.Value;
		_items[id] = state;
	}

	private void ClearAllRemoteDiffs()
	{
		foreach ( var key in _items.Keys.ToList() )
		{
			var s = _items[key];
			s.RemoteDiffers = false;
			_items[key] = s;
		}
	}

	private string[] GetRemoteSemanticsIds()
	{
		return _items
			.Where( x => x.Value.Status == SyncStatus.MergeAvailable && !string.IsNullOrEmpty( x.Value.RemoteJson ) && !IsGeneratedMappedCollectionId( x.Key ) )
			.Select( x => x.Key )
			.OrderBy( x => x )
			.ToArray();
	}

	private static bool IsGeneratedMappedCollectionId( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || !id.StartsWith( "col_" ) )
			return false;

		var collection = id[4..];
		return SyncToolConfig.SyncMappings.Any( mapping => string.Equals( mapping.Collection, collection, StringComparison.OrdinalIgnoreCase ) );
	}

	private int GetRemoteSemanticsCount()
	{
		return GetRemoteSemanticsIds().Length;
	}

	// ──────────────────────────────────────────────────────
	//  Check for Updates (compare local vs remote)
	// ──────────────────────────────────────────────────────

	private async Task CheckForUpdates()
	{
		if ( _busy || !SyncToolConfig.IsValid ) return;
		_scrollY = 0;
		_busy = true;
		_busyItem = "check_updates";
		_status = "Checking remote for changes...";
		_items.Clear();
		_syncLog.Clear();
		RefreshFileList();
		Update();

		try
		{
			await DoCheckForUpdates();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Check for updates failed: {ex.Message}" );
			_status = $"Check failed: {ex.Message}";
		}
		finally
		{
			_busy = false;
			_busyItem = null;
			_ = SyncLocalPackageInfoAsync();
			Update();
		}
	}

	private async Task DoCheckForUpdates()
	{
		_items.Clear();

		var diffs = 0;
		var remoteSemanticsCount = 0;
		var localOnlyCount = 0;

		// ── Load local files via SyncToolConfig (uses System.IO with correct project root) ──
		var localEndpoints = SyncToolConfig.LoadEndpoints();
		var localCollections = SyncToolConfig.LoadCollections();
		var localWorkflows = SyncToolConfig.LoadWorkflows();

		var localEpBySlug = new Dictionary<string, JsonElement>();
		var localEpSourceTextBySlug = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var ep in localEndpoints )
		{
			var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
			if ( !string.IsNullOrEmpty( slug ) )
			{
				localEpBySlug[slug] = ep;
				if ( SyncToolTransforms.TryGetSourceText( ep, out var localSourceText ) )
					localEpSourceTextBySlug[slug] = NormalizeSourceTextForVerification( localSourceText );
			}
		}

		// Also track deprecated local files so the diff loop can distinguish
		// "remote endpoint has no local counterpart" (truly remote-only) from
		// "remote endpoint has a local counterpart flagged deprecated" (intentionally ignored).
		var localDeprecatedSlugs = new HashSet<string>();
		foreach ( var ep in SyncToolConfig.LoadEndpoints( includeDeprecated: true ) )
		{
			if ( !SyncToolConfig.IsEndpointDeprecated( ep ) ) continue;
			var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
			if ( !string.IsNullOrEmpty( slug ) ) localDeprecatedSlugs.Add( slug );
		}

		var localColByName = new Dictionary<string, string>();
		var localColSourceTextByName = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var (name, data) in localCollections )
		{
			var stripped = SyncToolTransforms.StripServerManagedFields( data );
			localColByName[name] = JsonSerializer.Serialize( stripped, new JsonSerializerOptions { WriteIndented = true } );
			if ( data.TryGetValue( "sourceText", out var sourceText ) && sourceText is string source && !string.IsNullOrWhiteSpace( source ) )
				localColSourceTextByName[name] = NormalizeSourceTextForVerification( source );
		}

		var localWfById = new Dictionary<string, JsonElement>();
		var localWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var wf in localWorkflows )
		{
			var wfId = wf.TryGetProperty( "id", out var id ) ? id.GetString() : "";
			if ( !string.IsNullOrEmpty( wfId ) )
			{
				localWfById[wfId] = wf;
				if ( SyncToolTransforms.TryGetSourceText( wf, out var localSourceText ) )
					localWfSourceTextById[wfId] = NormalizeSourceTextForVerification( localSourceText );
			}
		}

		// ── Fetch endpoints, collections, workflows in parallel ──
		// Always read the selected publish target so Live checks do not accidentally
		// compare against staged overrides, and Staged/Main checks see next-release data.
		var remoteEpsTask = SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
		var remoteColsTask = SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
		var remoteWfsTask = SyncToolApi.GetWorkflows();
		var projectSettingsTask = SyncToolApi.GetProjectSettings();

		await Task.WhenAll( remoteEpsTask, remoteColsTask, remoteWfsTask, projectSettingsTask );

		_remoteEndpoints = await remoteEpsTask;
		_remoteCollections = await remoteColsTask;
		_remoteWorkflows = await remoteWfsTask;
		var projectSettings = await projectSettingsTask;
		if ( projectSettings.HasValue )
			SyncToolConfig.TryApplyProjectSecuritySettings( projectSettings.Value );
		if ( _remoteEndpoints.HasValue )
			SyncToolConfig.TryApplyProjectSecuritySettings( _remoteEndpoints.Value );
		if ( _remoteCollections.HasValue )
			SyncToolConfig.TryApplyProjectSecuritySettings( _remoteCollections.Value );
		if ( _remoteWorkflows.HasValue )
			SyncToolConfig.TryApplyProjectSecuritySettings( _remoteWorkflows.Value );

		// ── Detect package/revision info ──
		try
		{
			await NetworkStoragePackageInfo.DetectAsync();
			_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
			_packageIdent = NetworkStoragePackageInfo.PackageIdent;
			_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
			_publishStatus = NetworkStoragePackageInfo.PublishStatus;

			if ( _packageInfoDetected )
			{
				var syncResp = await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
			}

			await RefreshServerPackageStateAsync();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Package detection failed: {ex.Message}" );
			_packageInfoDetected = false;
		}

		if ( !_remoteEndpoints.HasValue )
		{
			_status = "Failed to fetch endpoints from server — check Base URL and credentials";
			_hasCheckedRemote = true;
			return;
		}

		if ( !_remoteCollections.HasValue )
		{
			_status = "Failed to fetch collections from server — check Base URL and credentials";
			_hasCheckedRemote = true;
			return;
		}

		// ── Process endpoints ──
		var remoteSlugs = new HashSet<string>();
		{
			var data = _remoteEndpoints.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind == JsonValueKind.Array )
			{
				foreach ( var ep in data.EnumerateArray() )
				{
					if ( SyncToolConfig.IsEndpointDeprecated( ep ) )
						continue;

					var slug = GetRemoteEndpointSlug( ep );
					if ( string.IsNullOrEmpty( slug ) ) continue;
					remoteSlugs.Add( slug );
					var id = $"ep_{slug}";

					var remoteLocal = SyncToolTransforms.ServerEndpointToLocal( ep );
					var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );

					if ( !localEpBySlug.TryGetValue( slug, out var localEp ) )
					{
						if ( localDeprecatedSlugs.Contains( slug ) )
						{
							// Local file exists but is flagged `_deprecated` — sync intentionally
							// ignores it. Treat as in-sync from a diff perspective so the row
							// isn't flagged "Remote only" every check; the main endpoints list
							// still labels it "deprecated, ignored by sync".
							SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
								diffSummary: "Deprecated locally — ignored by sync",
								localJson: "", remoteJson: PrettyJson( remoteJson ) );
						}
						else
						{
							SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
								diffSummary: "Remote only — not in local files",
								localJson: "", remoteJson: PrettyJson( remoteJson ) );
							diffs++;
						}
					}
					else
					{
						// Transform local same as remote to strip server-managed fields.
						// If both sides expose identical sourceText, trust the backend compiler's
						// canonicalization instead of the editor's lightweight YAML parser.
						var localTransformed = SyncToolTransforms.ServerEndpointToLocal( localEp );
						var localJson = JsonSerializer.Serialize( localTransformed, new JsonSerializerOptions { WriteIndented = true } );
						var sourceTextMatches = localEpSourceTextBySlug.TryGetValue( slug, out var localSourceText )
							&& SyncToolTransforms.TryGetSourceText( ep, out var remoteSourceText )
							&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
						var differs = !sourceTextMatches && NormalizeJson( remoteJson ) != NormalizeJson( localJson );

						if ( differs )
						{
							var localPretty = PrettyJson( localJson );
							var remotePretty = PrettyJson( remoteJson );
							var classification = ClassifyRemoteDifference( id, localPretty, remotePretty );

							SetItemState( id, remoteDiffers: true, status: classification.Status,
								diffSummary: classification.Summary,
								localJson: localPretty, remoteJson: remotePretty );

							if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
							else diffs++;
						}
						else
						{
							SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
								localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
						}
					}
				}
			}
		}

		foreach ( var slug in localEpBySlug.Keys )
		{
			if ( !remoteSlugs.Contains( slug ) )
			{
				var id = $"ep_{slug}";
				var localJson = JsonSerializer.Serialize( localEpBySlug[slug], new JsonSerializerOptions { WriteIndented = true } );
				SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
					diffSummary: "Local only — not pushed to server",
					localJson: PrettyJson( localJson ), remoteJson: "" );
				localOnlyCount++;
			}
		}

		// ── Check collections ──
		var remoteColNames = new HashSet<string>();
		{
			var remoteCollections = _remoteCollections.Value;
			if ( remoteCollections.TryGetProperty( "data", out var remoteCollectionData ) ) remoteCollections = remoteCollectionData;
			if ( remoteCollections.ValueKind == JsonValueKind.Array )
			{
				foreach ( var remoteCollection in remoteCollections.EnumerateArray() )
				{
					var remoteLocal = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
					var colName = remoteLocal.TryGetValue( "name", out var nameValue ) ? nameValue?.ToString() ?? "unknown" : "unknown";
					remoteColNames.Add( colName );
					var id = $"col_{colName}";
					var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );

					if ( !localColByName.TryGetValue( colName, out var localJson ) )
					{
						SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
							diffSummary: "Remote only — no local file",
							localJson: "", remoteJson: PrettyJson( remoteJson ) );
						diffs++;
					}
					else
					{
						var sourceTextMatches = localColSourceTextByName.TryGetValue( colName, out var localSourceText )
							&& SyncToolTransforms.TryGetSourceText( remoteCollection, out var remoteSourceText )
							&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
						var differs = !sourceTextMatches && !CollectionSemanticsMatch( localJson, remoteJson );

						if ( differs )
						{
							var localPretty = PrettyJson( localJson );
							var remotePretty = PrettyJson( remoteJson );
							var classification = ClassifyRemoteDifference( id, localPretty, remotePretty );

							if ( IsGeneratedMappedCollectionId( id ) && classification.Analysis.IsRemoteAdditiveOnly )
							{
								SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
									diffSummary: "Generated from C# — remote semantics ignored",
									localJson: localPretty, remoteJson: remotePretty );
							}
							else
							{
								SetItemState( id, remoteDiffers: true, status: classification.Status,
									diffSummary: classification.Summary,
									localJson: localPretty, remoteJson: remotePretty );

								if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
								else diffs++;
							}
						}
						else
						{
							SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
								localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
						}
					}
				}
			}
		}

		foreach ( var colName in localColByName.Keys )
		{
			if ( !remoteColNames.Contains( colName ) )
			{
				var id = $"col_{colName}";
				SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
					diffSummary: "Local only — not pushed to server",
					localJson: PrettyJson( localColByName[colName] ), remoteJson: "" );
				localOnlyCount++;
			}
		}

		// ── Check workflows ──
		var remoteWfIds = new HashSet<string>();
		if ( _remoteWorkflows.HasValue )
		{
			var remoteWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
			var remoteWorkflowData = _remoteWorkflows.Value;
			if ( remoteWorkflowData.TryGetProperty( "data", out var remoteWorkflowArray ) ) remoteWorkflowData = remoteWorkflowArray;
			if ( remoteWorkflowData.ValueKind == JsonValueKind.Array )
			{
				foreach ( var remoteWorkflow in remoteWorkflowData.EnumerateArray() )
				{
					var remoteLocal = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
					var remoteId = remoteLocal.TryGetValue( "id", out var value ) ? value?.ToString() : null;
					if ( !string.IsNullOrEmpty( remoteId ) && SyncToolTransforms.TryGetSourceText( remoteWorkflow, out var remoteSourceText ) )
						remoteWfSourceTextById[remoteId] = NormalizeSourceTextForVerification( remoteSourceText );
				}
			}

			var workflows = SyncToolTransforms.ServerToWorkflows( _remoteWorkflows.Value );
			foreach ( var (wfId, remoteLocal) in workflows )
			{
				remoteWfIds.Add( wfId );
				var id = $"wf_{wfId}";
				var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );

				if ( !localWfById.TryGetValue( wfId, out var localWf ) )
				{
					SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
						diffSummary: "Remote only — no local file",
						localJson: "", remoteJson: PrettyJson( remoteJson ) );
					diffs++;
				}
				else
				{
					// Transform local same as remote to strip server-managed fields
					var localTransformed = SyncToolTransforms.ServerWorkflowToLocal( localWf );
					var localJson = JsonSerializer.Serialize( localTransformed, new JsonSerializerOptions { WriteIndented = true } );
					var sourceTextMatches = localWfSourceTextById.TryGetValue( wfId, out var localSourceText )
						&& remoteWfSourceTextById.TryGetValue( wfId, out var remoteSourceText )
						&& localSourceText == remoteSourceText;
					var normRemote = NormalizeJson( remoteJson );
					var normLocal = NormalizeJson( localJson );
					var differs = !sourceTextMatches && normRemote != normLocal;
					(SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) classification = default;

					LogDiffResult( wfId, "workflow", localJson, remoteJson, differs, classification );

					if ( differs )
					{
						var localPretty = PrettyJson( localJson );
						var remotePretty = PrettyJson( remoteJson );
						classification = ClassifyRemoteDifference( id, localPretty, remotePretty );

						SetItemState( id, remoteDiffers: true, status: classification.Status,
							diffSummary: classification.Summary,
							localJson: localPretty, remoteJson: remotePretty );

						if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
						else diffs++;
					}
					else
					{
						SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
							localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
					}
				}
			}
		}

		foreach ( var wfId in localWfById.Keys )
		{
			if ( !remoteWfIds.Contains( wfId ) )
			{
				var id = $"wf_{wfId}";
				var localJson = JsonSerializer.Serialize( localWfById[wfId], new JsonSerializerOptions { WriteIndented = true } );
				SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
					diffSummary: "Local only — not pushed to server",
					localJson: PrettyJson( localJson ), remoteJson: "" );
				localOnlyCount++;
			}
		}

			_hasCheckedRemote = true;
		var parts = new List<string>();
		if ( diffs > 0 ) parts.Add( $"{diffs} remote diff(s)" );
		if ( remoteSemanticsCount > 0 ) parts.Add( $"{remoteSemanticsCount} remote semantics" );
		if ( localOnlyCount > 0 ) parts.Add( $"{localOnlyCount} local only" );
		_status = parts.Count > 0
			? string.Join( ", ", parts )
			: "Everything is in sync";
	}

	/// <summary>
	/// Normalize JSON for comparison — sorts all object keys recursively so key order doesn't cause false diffs.
	/// </summary>
	private static string NormalizeSourceTextForVerification( string sourceText )
	{
		return (sourceText ?? "").Replace( "\r\n", "\n" ).Replace( '\r', '\n' ).Trim();
	}

	private static string NormalizeJson( string json )
	{
		try
		{
			var el = JsonSerializer.Deserialize<JsonElement>( json );
			var sorted = SortJsonElement( el );
			return JsonSerializer.Serialize( sorted, new JsonSerializerOptions { WriteIndented = false } );
		}
		catch { return json.Trim(); }
	}

	private static object SortJsonElement( JsonElement el )
	{
		switch ( el.ValueKind )
		{
			case JsonValueKind.Object:
				var sorted = new SortedDictionary<string, object>();
				foreach ( var prop in el.EnumerateObject() )
				{
					if ( IsIgnoredComparisonField( prop.Name ) )
						continue;
					sorted[prop.Name] = SortJsonElement( prop.Value );
				}
				return sorted.Count == 0 ? null : sorted;
			case JsonValueKind.Array:
				var arr = new List<object>();
				foreach ( var item in el.EnumerateArray() )
					arr.Add( SortJsonElement( item ) );
				return arr;
			case JsonValueKind.String:
				return el.GetString();
			case JsonValueKind.Number:
				return el.TryGetInt64( out var l ) ? (object)l : el.GetDouble();
			case JsonValueKind.True:
				return true;
			case JsonValueKind.False:
				return false;
			default:
				return null;
		}
	}

	private static string PrettyJson( string json )
	{
		if ( string.IsNullOrEmpty( json ) ) return "";
		try
		{
			var el = JsonSerializer.Deserialize<JsonElement>( json );
			return JsonSerializer.Serialize( el, new JsonSerializerOptions { WriteIndented = true } );
		}
		catch { return json; }
	}

	private (SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) ClassifyRemoteDifference(
		string id, string localJson, string remoteJson )
	{
		var analysis = JsonDiffUtilities.Analyze( localJson, remoteJson );
		if ( analysis.IsRemoteAdditiveOnly )
			return (SyncStatus.MergeAvailable, BuildRemoteSemanticsSummary( analysis ), analysis);

		var lineSummary = JsonDiffUtilities.SummarizeLineDifferences( analysis.LineCounts );
		var detail = BuildResourceDiffDetail( id, localJson, remoteJson );
		return (SyncStatus.Differs, CombineDiffSummary( lineSummary, detail ), analysis);
	}

	private string BuildResourceDiffDetail( string id, string localJson, string remoteJson )
	{
		if ( id.StartsWith( "ep_" ) )
			return DiffEndpoint( localJson, remoteJson, id[3..] );
		if ( id.StartsWith( "col_" ) )
			return DiffCollectionSchema( localJson, remoteJson );

		return null;
	}

	private static string BuildRemoteSemanticsSummary( JsonDiffUtilities.ComparisonResult analysis )
	{
		var lineSummary = analysis.LineCounts.HasChanges
			? JsonDiffUtilities.SummarizeLineDifferences( analysis.LineCounts )
			: $"Remote added {analysis.Added.Count} field{Plural( analysis.Added.Count )}";
		var preview = JsonDiffUtilities.PreviewPaths( analysis.Added );

		return string.IsNullOrWhiteSpace( preview )
			? $"{lineSummary} | Pull Remote Semantics"
			: $"{lineSummary} | Pull Remote Semantics: {preview}";
	}

	private static string BuildRemoteSemanticsLogDetail( JsonDiffUtilities.ComparisonResult analysis )
	{
		var preview = JsonDiffUtilities.PreviewPaths( analysis.Added );
		var count = analysis.Added.Count;
		var countText = $"{count} remote field{Plural( count )}";

		return string.IsNullOrWhiteSpace( preview )
			? $"Remote semantics available - {countText}"
			: $"Remote semantics available - {countText}: {preview}";
	}

	private static string CombineDiffSummary( string lineSummary, string detail )
	{
		if ( string.IsNullOrWhiteSpace( detail ) )
			return lineSummary;

		if ( detail.Equals( "Field ordering differs (content identical)", StringComparison.OrdinalIgnoreCase ) )
			return lineSummary;

		if ( detail.StartsWith( "Content differs", StringComparison.OrdinalIgnoreCase ) )
			return lineSummary;

		if ( string.IsNullOrWhiteSpace( lineSummary ) )
			return detail;

		return $"{lineSummary} | {detail}";
	}

	private static string Plural( int count )
	{
		return count == 1 ? "" : "s";
	}

	private static string DescribeSyncItem( string id )
	{
		if ( id.StartsWith( "ep_" ) ) return $"{id[3..]}.endpoint.yml (endpoint)";
		if ( id.StartsWith( "col_" ) ) return $"{id[4..]}.collection.yml (collection)";
		if ( id.StartsWith( "wf_" ) ) return $"{id[3..]}.workflow.yml (workflow)";
		if ( id.StartsWith( "test_" ) ) return $"{id[5..]}.test.yml (test)";
		return id;
	}

	// ──────────────────────────────────────────────────────
	//  Push (with remote-newer warning)
	// ──────────────────────────────────────────────────────

	private async Task PushAll()
	{
		if ( _busy || !SyncToolConfig.IsValid ) return;
		_scrollY = 0;
		_busy = true;
		_busyItem = "push_all";
		_status = "Pushing all resources...";
		_syncLog.Clear();
		foreach ( var k in _items.Keys.ToList() ) SetItemState( k, result: null );
		Update();

		var activeEndpointFiles = GetActiveEndpointFiles();

		var pushAllPayload = BuildPushAllPayload( out var hasAnyPushResource );
		if ( !hasAnyPushResource || !await RunPreflightOrOfferFix( pushAllPayload, () => _ = PushAll() ) )
		{
			_busy = false;
			_busyItem = null;
			ScrollToBottom();
			Update();
			return;
		}

		// ── Load local files for pre-push comparison ──
		// Apply same transform as remote so id/createdAt are stripped from both sides
		var localEndpoints = SyncToolConfig.LoadEndpoints();
		var localEpBySlug = new Dictionary<string, string>();
		var localEpSourceTextBySlug = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var ep in localEndpoints )
		{
			var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
			if ( !string.IsNullOrEmpty( slug ) )
			{
				var localTransformed = SyncToolTransforms.ServerEndpointToLocal( ep );
				localEpBySlug[slug] = NormalizeJson( JsonSerializer.Serialize( localTransformed ) );
				if ( SyncToolTransforms.TryGetSourceText( ep, out var localSourceText ) )
					localEpSourceTextBySlug[slug] = NormalizeSourceTextForVerification( localSourceText );
			}
		}

		// ── Push all resources in single batch (falls back to individual if batch not supported) ──
		_busyItem = "push_all";
		_status = "Pushing all resources...";
		Update();

		var (epOk, colOk, wfOk) = await DoPushAllBatched();

		// ── Log push results ──
		if ( activeEndpointFiles.Length > 0 )
		{
			foreach ( var f in activeEndpointFiles )
			{
				var slug = ResourceIdFromFile( f, "endpoint" );
				var failDetail = GetPushFailDetail( "endpoints", slug );
				var detail = epOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
				SetItemState( $"ep_{slug}", result: epOk ? "OK" : "FAIL",
					remoteDiffers: false, diffSummary: "", status: epOk ? SyncStatus.InSync : null );
				_syncLog.Add( new SyncLogEntry { Name = $"{slug}.endpoint.yml", Type = "Endpoint", Ok = epOk, Detail = detail } );
			}
		}

		if ( _collectionFiles.Length > 0 )
		{
			foreach ( var f in _collectionFiles )
			{
				var name = ResourceIdFromFile( f, "collection" );
				var failDetail = GetPushFailDetail( "collections", name );
				var detail = colOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
				SetItemState( $"col_{name}", result: colOk ? "OK" : "FAIL",
					remoteDiffers: false, diffSummary: "", status: colOk ? SyncStatus.InSync : null );
				_syncLog.Add( new SyncLogEntry { Name = $"{name}.collection.yml", Type = "Collection", Ok = colOk, Detail = detail } );
			}
		}

		if ( _workflowFiles.Length > 0 )
		{
			foreach ( var f in _workflowFiles )
			{
				var name = ResourceIdFromFile( f, "workflow" );
				var failDetail = GetPushFailDetail( "workflows", name );
				var detail = wfOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
				SetItemState( $"wf_{name}", result: wfOk ? "OK" : "FAIL",
					remoteDiffers: false, diffSummary: "", status: wfOk ? SyncStatus.InSync : null );
				_syncLog.Add( new SyncLogEntry { Name = $"{name}.workflow.yml", Type = "Workflow", Ok = wfOk, Detail = detail } );
			}
		}

		ScrollToBottom();

		// ── Auto-verify: re-fetch remote and compare to local ──
		_busyItem = "verify";
		_status = "Verifying remote matches local...";
		Update();

		await VerifyPushResults( localEpBySlug, localEpSourceTextBySlug, _publishTarget );

		// ── Auto-generate typed C# files from the pushed schemas ──
		_busyItem = "codegen";
		_status = "Generating typed C# code...";
		Update();

		try
		{
			var filesWritten = CodeGenerator.Generate();
			_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = true, Detail = $"{filesWritten} files written to Code/Data/NetworkStorage/" } );
		}
		catch ( Exception ex )
		{
			_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = false, Detail = ex.Message } );
		}

		// Invalidate cached remote data — next check will fetch fresh
		// Sync local package info after push so backend has latest revision
		await SyncLocalPackageInfoAsync();

		ClearAllRemoteDiffs();
		_remoteEndpoints = null;
		_remoteCollections = null;
		_remoteWorkflows = null;
		_hasCheckedRemote = false;

		var okCount = _syncLog.Count( e => e.Ok && IsResourceSyncLog( e ) );
		var failCount = _syncLog.Count( e => !e.Ok && IsResourceSyncLog( e ) );
		var mismatchCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Mismatch" ) );
		var verifiedCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Verified" ) );
		var mergeCount = _syncLog.Count( e => e.Detail != null && e.Detail.Contains( "Remote semantics available" ) );

		if ( mismatchCount > 0 )
			ShowVerificationMismatchWindow( mismatchCount );

		if ( mergeCount > 0 && mismatchCount == 0 && failCount == 0 )
			_status = $"Pushed OK - {mergeCount} item(s) can pull remote semantics";
		else if ( mismatchCount > 0 )
			_status = $"Done: {okCount} pushed, {mismatchCount} mismatch(es) — check diffs";
		else if ( failCount > 0 )
			_status = $"Done: {okCount - failCount} OK, {failCount} failed";
		else
			_status = $"All synced and verified ({verifiedCount} resources)";

		_busy = false;
		_busyItem = null;

		// Sync local package info so backend has the latest revision
		_ = SyncLocalPackageInfoAsync();

		ScrollToBottom();
	}

	/// <summary>
	/// After pushing, re-fetch remote data and compare each resource to local.
	/// Updates sync log entries with "Verified ✓" or "Mismatch — see diff".
	/// </summary>
	private async Task VerifyPushResults( Dictionary<string, string> localEpBySlug, Dictionary<string, string> localEpSourceTextBySlug, string publishTarget )
	{
		try
		{
			// Re-load local files fresh, stripping server-managed fields for fair comparison
			var localCollections = SyncToolConfig.LoadCollections();
			var localColByName = new Dictionary<string, string>();
			var localColSourceTextByName = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
			foreach ( var (name, data) in localCollections )
			{
				var stripped = SyncToolTransforms.StripServerManagedFields( data );
				localColByName[name] = NormalizeJson( JsonSerializer.Serialize( stripped, new JsonSerializerOptions { WriteIndented = true } ) );
				if ( data.TryGetValue( "sourceText", out var sourceText ) && sourceText is string source && !string.IsNullOrWhiteSpace( source ) )
					localColSourceTextByName[name] = NormalizeSourceTextForVerification( source );
			}

			var localWorkflows = SyncToolConfig.LoadWorkflows();
			var localWfById = new Dictionary<string, string>();
			var localWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
			foreach ( var wf in localWorkflows )
			{
				var wfId = wf.TryGetProperty( "id", out var id ) ? id.GetString() : "";
				if ( !string.IsNullOrEmpty( wfId ) )
				{
					var localTransformed = SyncToolTransforms.ServerWorkflowToLocal( wf );
					localWfById[wfId] = NormalizeJson( JsonSerializer.Serialize( localTransformed ) );
					if ( SyncToolTransforms.TryGetSourceText( wf, out var localSourceText ) )
						localWfSourceTextById[wfId] = NormalizeSourceTextForVerification( localSourceText );
				}
			}

			// Fetch all 3 resource types in parallel
			var remoteEpsTask = SyncToolApi.GetEndpointsForPublishTarget( publishTarget );
			var remoteColsTask = SyncToolApi.GetCollectionsForPublishTarget( publishTarget );
			var remoteWfsTask = SyncToolApi.GetWorkflows();

			await Task.WhenAll( remoteEpsTask, remoteColsTask, remoteWfsTask );

			var remoteEps = await remoteEpsTask;
			var remoteCols = await remoteColsTask;
			var remoteWfs = await remoteWfsTask;

			// Process endpoint results
			if ( remoteEps.HasValue )
			{
				var data = remoteEps.Value;
				if ( data.TryGetProperty( "data", out var d ) ) data = d;
				if ( data.ValueKind == JsonValueKind.Array )
				{
					foreach ( var ep in data.EnumerateArray() )
					{
						var slug = GetRemoteEndpointSlug( ep );
						if ( string.IsNullOrEmpty( slug ) ) continue;

						var remoteLocal = SyncToolTransforms.ServerEndpointToLocal( ep );
						var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );

						var logIdx = _syncLog.FindIndex( e => e.Name == $"{slug}.endpoint.yml" && e.Type == "Endpoint" );
						if ( logIdx < 0 ) continue;

						var entry = _syncLog[logIdx];
						if ( !entry.Ok ) continue; // Skip failed pushes

						var sourceTextMatches = localEpSourceTextBySlug.TryGetValue( slug, out var localSourceText )
							&& SyncToolTransforms.TryGetSourceText( ep, out var remoteSourceText )
							&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );

						if ( sourceTextMatches || (localEpBySlug.TryGetValue( slug, out var localNorm ) && localNorm == remoteNorm) )
						{
							entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
						}
						else
						{
							var eid = $"ep_{slug}";
							var localPretty = localEpBySlug.TryGetValue( slug, out var lj ) ? PrettyJson( lj ) : "{}";
							var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
							var classification = ClassifyRemoteDifference( eid, localPretty, remotePretty );

							entry.Detail = classification.Status == SyncStatus.MergeAvailable
								? BuildRemoteSemanticsLogDetail( classification.Analysis )
								: $"Mismatch - {classification.Summary}";
							entry.Ok = classification.Status == SyncStatus.MergeAvailable;
							SetItemState( eid, remoteDiffers: true, status: classification.Status,
								diffSummary: classification.Summary,
								localJson: localPretty, remoteJson: remotePretty );
						}
						_syncLog[logIdx] = entry;
					}
				}
			}

			// Process collection results
			if ( remoteCols.HasValue )
			{
				var remoteCollections = remoteCols.Value;
				if ( remoteCollections.TryGetProperty( "data", out var remoteCollectionData ) ) remoteCollections = remoteCollectionData;
				if ( remoteCollections.ValueKind == JsonValueKind.Array )
				{
					foreach ( var remoteCollection in remoteCollections.EnumerateArray() )
					{
						var remoteLocal = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
						var colName = remoteLocal.TryGetValue( "name", out var nameValue ) ? nameValue?.ToString() ?? "unknown" : "unknown";
						var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
						var logIdx = _syncLog.FindIndex( e => e.Name == $"{colName}.collection.yml" && e.Type == "Collection" );
						if ( logIdx < 0 ) continue;

						var entry = _syncLog[logIdx];
						if ( !entry.Ok ) continue;

						var sourceTextMatches = localColSourceTextByName.TryGetValue( colName, out var localSourceText )
							&& SyncToolTransforms.TryGetSourceText( remoteCollection, out var remoteSourceText )
							&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );

						if ( sourceTextMatches || (localColByName.TryGetValue( colName, out var localNorm ) && (localNorm == remoteNorm || CollectionSemanticsMatch( localNorm, remoteNorm ))) )
						{
							entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
						}
						else
						{
							var cid = $"col_{colName}";
							var localPretty = PrettyJson( localColByName.GetValueOrDefault( colName, "{}" ) );
							var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
							var classification = ClassifyRemoteDifference( cid, localPretty, remotePretty );

							if ( IsGeneratedMappedCollectionId( cid ) && classification.Analysis.IsRemoteAdditiveOnly )
							{
								entry.Detail = "Verified generated C# source ✓ (remote semantics ignored)";
								entry.Ok = true;
								SetItemState( cid, remoteDiffers: false, status: SyncStatus.InSync,
									diffSummary: "Generated from C# — remote semantics ignored",
									localJson: localPretty, remoteJson: remotePretty );
							}
							else
							{
								entry.Detail = classification.Status == SyncStatus.MergeAvailable
									? BuildRemoteSemanticsLogDetail( classification.Analysis )
									: $"Mismatch - {classification.Summary}";
								entry.Ok = classification.Status == SyncStatus.MergeAvailable;
								SetItemState( cid, remoteDiffers: true, status: classification.Status,
									diffSummary: classification.Summary,
									localJson: localPretty, remoteJson: remotePretty );
							}
						}
						_syncLog[logIdx] = entry;
					}
				}
			}

			// Process workflow results
			if ( remoteWfs.HasValue )
			{
				var remoteWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
				var remoteWorkflowData = remoteWfs.Value;
				if ( remoteWorkflowData.TryGetProperty( "data", out var remoteWorkflowArray ) ) remoteWorkflowData = remoteWorkflowArray;
				if ( remoteWorkflowData.ValueKind == JsonValueKind.Array )
				{
					foreach ( var remoteWorkflow in remoteWorkflowData.EnumerateArray() )
					{
						var remoteLocalForSource = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
						var remoteId = remoteLocalForSource.TryGetValue( "id", out var value ) ? value?.ToString() : null;
						if ( !string.IsNullOrEmpty( remoteId ) && SyncToolTransforms.TryGetSourceText( remoteWorkflow, out var remoteSourceText ) )
							remoteWfSourceTextById[remoteId] = NormalizeSourceTextForVerification( remoteSourceText );
					}
				}

				var workflows = SyncToolTransforms.ServerToWorkflows( remoteWfs.Value );
				foreach ( var (wfId, remoteLocal) in workflows )
				{
					var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
					var logIdx = _syncLog.FindIndex( e => e.Name == $"{wfId}.workflow.yml" && e.Type == "Workflow" );
					if ( logIdx < 0 ) continue;

					var entry = _syncLog[logIdx];
					if ( !entry.Ok ) continue;

					var sourceTextMatches = localWfSourceTextById.TryGetValue( wfId, out var localSourceText )
						&& remoteWfSourceTextById.TryGetValue( wfId, out var remoteSourceText )
						&& localSourceText == remoteSourceText;

					if ( sourceTextMatches || (localWfById.TryGetValue( wfId, out var localNorm ) && localNorm == remoteNorm) )
					{
						entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
					}
					else
					{
						var wid = $"wf_{wfId}";
						var localPretty = PrettyJson( localWfById.GetValueOrDefault( wfId, "{}" ) );
						var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
						var classification = ClassifyRemoteDifference( wid, localPretty, remotePretty );

						entry.Detail = classification.Status == SyncStatus.MergeAvailable
							? BuildRemoteSemanticsLogDetail( classification.Analysis )
							: $"Mismatch - {classification.Summary}";
						entry.Ok = classification.Status == SyncStatus.MergeAvailable;
						SetItemState( wid, remoteDiffers: true, status: classification.Status,
							diffSummary: classification.Summary,
							localJson: localPretty, remoteJson: remotePretty );
					}
					_syncLog[logIdx] = entry;
				}
			}

			await RetryEndpointVerificationMismatches( localEpBySlug, localEpSourceTextBySlug, publishTarget );
			await RetryCollectionVerificationMismatches( localColByName, localColSourceTextByName, publishTarget );

			Update();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Post-push verification failed: {ex.Message}" );
			_status = $"Push done, verification failed: {ex.Message}";
		}
	}

	private async Task RetryEndpointVerificationMismatches( Dictionary<string, string> localEpBySlug, Dictionary<string, string> localEpSourceTextBySlug, string publishTarget )
	{
		var endpointMismatches = _syncLog
			.Where( e => e.Type == "Endpoint" && e.Detail != null && e.Detail.Contains( "Mismatch" ) )
			.Select( e => e.Name.EndsWith( ".endpoint.yml", StringComparison.OrdinalIgnoreCase )
				? e.Name[..^".endpoint.yml".Length]
				: e.Name )
			.Where( slug => !string.IsNullOrWhiteSpace( slug ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();

		if ( endpointMismatches.Count == 0 )
			return;

		await RePushEndpointVerificationMismatches( endpointMismatches, publishTarget );

		var delaysMs = new[] { 1000, 2000, 3000, 5000, 8000 };
		foreach ( var delayMs in delaysMs )
		{
			_status = $"Waiting for remote readback ({endpointMismatches.Count} endpoint mismatch(es))...";
			Update();
			await Task.Delay( delayMs );

			var remoteEps = await SyncToolApi.GetEndpointsForPublishTarget( publishTarget );
			if ( !remoteEps.HasValue )
				continue;

			var data = remoteEps.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind != JsonValueKind.Array )
				continue;

			var remaining = new List<string>();
			foreach ( var slug in endpointMismatches )
			{
				var remoteEndpoint = data.EnumerateArray().FirstOrDefault( ep => string.Equals( GetRemoteEndpointSlug( ep ), slug, StringComparison.OrdinalIgnoreCase ) );
				if ( remoteEndpoint.ValueKind != JsonValueKind.Object )
				{
					remaining.Add( slug );
					continue;
				}

				if ( EndpointVerificationMatches( slug, remoteEndpoint, localEpBySlug, localEpSourceTextBySlug ) )
				{
					var logIdx = _syncLog.FindIndex( e => e.Name == $"{slug}.endpoint.yml" && e.Type == "Endpoint" );
					if ( logIdx >= 0 )
					{
						var entry = _syncLog[logIdx];
						entry.Ok = true;
						entry.Detail = "Verified source ✓ (after readback retry)";
						_syncLog[logIdx] = entry;
					}

					SetItemState( $"ep_{slug}", remoteDiffers: false, diffSummary: "", status: SyncStatus.InSync );
				}
				else
				{
					remaining.Add( slug );
				}
			}

			endpointMismatches = remaining;
			if ( endpointMismatches.Count == 0 )
				return;
		}
	}

	private async Task RePushEndpointVerificationMismatches( List<string> slugs, string publishTarget )
	{
		if ( slugs == null || slugs.Count == 0 )
			return;

		_status = $"Refreshing staged endpoint override(s) ({slugs.Count})...";
		Update();

		foreach ( var slug in slugs )
		{
			try
			{
				var localFile = _endpointFiles.FirstOrDefault( f => string.Equals( ResourceIdFromFile( f, "endpoint" ), slug, StringComparison.OrdinalIgnoreCase ) );
				if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "endpoint", localFile, out var localEp, includeDeprecated: true ) )
					continue;

				var localDict = JsonSerializer.Deserialize<Dictionary<string, object>>( localEp.GetRawText() );
				var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { endpoint = localDict } ) );
				var patchResp = await SyncToolApi.PatchEndpoint( payload, publishTarget );
				if ( patchResp.HasValue )
					continue;

				var singleArray = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new[] { localDict } ) );
				await SyncToolApi.PushEndpoints( singleArray, publishTarget );
			}
			catch ( Exception ex )
			{
				Log.Warning( $"[SyncTool] Verification re-push for {slug} failed: {ex.Message}" );
			}
		}
	}

	private bool EndpointVerificationMatches( string slug, JsonElement remoteEndpoint, Dictionary<string, string> localEpBySlug, Dictionary<string, string> localEpSourceTextBySlug )
	{
		var sourceTextMatches = localEpSourceTextBySlug.TryGetValue( slug, out var localSourceText )
			&& SyncToolTransforms.TryGetSourceText( remoteEndpoint, out var remoteSourceText )
			&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
		if ( sourceTextMatches )
			return true;

		if ( !localEpBySlug.TryGetValue( slug, out var localNorm ) )
			return false;

		var remoteLocal = SyncToolTransforms.ServerEndpointToLocal( remoteEndpoint );
		var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
		return localNorm == remoteNorm;
	}

	private void ShowVerificationMismatchWindow( int mismatchCount )
	{
		if ( !IsWindowOpen || mismatchCount <= 0 )
			return;

		var mismatchEntries = _syncLog
			.Where( e => e.Detail != null && e.Detail.Contains( "Mismatch" ) )
			.Select( e => new Dictionary<string, object>
			{
				["type"] = e.Type,
				["name"] = e.Name,
				["detail"] = e.Detail,
			} )
			.ToList();

		var payload = new Dictionary<string, object>
		{
			["error"] = "SYNC_VERIFICATION_MISMATCH",
			["message"] = mismatchEntries.Count == 1
				? "1 pushed resource mismatches remote after verification."
				: $"{mismatchEntries.Count} pushed resources mismatch remote after verification.",
			["publishTarget"] = _publishTarget,
			["mismatches"] = mismatchEntries,
		};

		var payloadElement = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payload ) );
		EndpointErrorWindow.Show( "sync", "SYNC_VERIFICATION_MISMATCH", payloadElement.TryGetProperty( "message", out var message ) ? message.GetString() : "Sync verification mismatch", payloadElement );
	}

	private static bool IsBatchSyncEndpointUnavailable( string errorCode, string errorMessage )
	{
		if ( string.IsNullOrWhiteSpace( errorCode ) && string.IsNullOrWhiteSpace( errorMessage ) )
			return false;

		var normalizedCode = (errorCode ?? "").ToUpperInvariant();
		if ( normalizedCode == "NOT_FOUND" || normalizedCode == "ENDPOINT_NOT_FOUND" || normalizedCode == "METHOD_NOT_ALLOWED"
			|| normalizedCode == "HTTP_404" || normalizedCode == "HTTP_405"
			|| normalizedCode.Contains( "404" ) || normalizedCode.Contains( "405" ) )
			return true;

		var lowerMessage = (errorMessage ?? "").ToLowerInvariant();
		return lowerMessage.Contains( "404" )
			|| lowerMessage.Contains( "405" )
			|| lowerMessage.Contains( "not found" )
			|| lowerMessage.Contains( "does not exist" )
			|| lowerMessage.Contains( "not available" )
			|| lowerMessage.Contains( "method not allowed" )
			|| lowerMessage.Contains( "sync endpoint" );
	}

	private static void ShowSyncBatchFailureWindow( string errorMessage, string errorCode = null )
	{
		if ( !SyncToolWindow.IsWindowOpen )
			return;

		var payload = new Dictionary<string, object>
		{
			["error"] = string.IsNullOrWhiteSpace( errorCode ) ? "SYNC_BATCH_FAILED" : errorCode,
			["message"] = string.IsNullOrWhiteSpace( errorMessage ) ? "Batch sync failed" : errorMessage,
			["source"] = "sync",
			["action"] = "PushAll"
		};

		var payloadElement = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payload ) );
		EndpointErrorWindow.Show( "sync", string.IsNullOrWhiteSpace( errorCode ) ? "SYNC_BATCH_FAILED" : errorCode,
			payloadElement.TryGetProperty( "message", out var message ) ? message.GetString() : "Batch sync failed",
			payloadElement );
	}

	private void PushItem( string id )
	{
		if ( _busy || !SyncToolConfig.IsValid ) return;

		_items.TryGetValue( id, out var state );
		var label = id.StartsWith( "ep_" ) ? $"endpoint '{id[3..]}'"
			: id.StartsWith( "col_" ) ? $"collection '{id[4..]}'"
			: id.StartsWith( "wf_" ) ? $"workflow '{id[3..]}'"
			: id.StartsWith( "test_" ) ? $"test '{id[5..]}'"
			: "resource";

		if ( state.RemoteDiffers )
		{
			ConfirmDialog.Show(
				"Overwrite Remote?",
				$"The stored {label} is newer on your project dashboard. Pushing will overwrite the remote version with your local file.",
				() => _ = DoPushItem( id ),
				detail: state.DiffSummary
			);
		}
		else
		{
			_ = DoPushItem( id );
		}
	}

	private async Task DoPushItem( string id )
	{
		_busy = true;
		_busyItem = $"push_{id}";
		_status = $"Pushing {id}...";
		Update();

		try
		{
			var preflightPayload = BuildSinglePushPayload( id );
			var endpointIds = id.StartsWith( "ep_" ) ? new[] { id[3..] } : null;
			if ( !await RunPreflightOrOfferFix( preflightPayload, () => _ = DoPushItem( id ), endpointIds ) )
				return;

			bool ok;
			string itemName;
			string itemType;

			if ( id.StartsWith( "ep_" ) )
			{
				var slug = id[3..];
				ok = await DoPushSingleEndpointMerged( slug );
				itemName = $"{slug}.endpoint.yml";
				itemType = "Endpoint";
			}
			else if ( id.StartsWith( "col_" ) )
			{
				var colName = id[4..];
				ok = await DoPushSingleCollection( colName );
				itemName = $"{colName}.collection.yml";
				itemType = "Collection";
			}
			else if ( id.StartsWith( "wf_" ) )
			{
				var wfId = id[3..];
				ok = await DoPushSingleWorkflow( wfId );
				itemName = $"{wfId}.workflow.yml";
				itemType = "Workflow";
			}
			else
			{
				ok = false;
				itemName = id;
				itemType = "Unknown";
			}

			// Update this item's state
			SetItemState( id, result: ok ? "OK" : "FAIL", remoteDiffers: false, diffSummary: "",
				status: ok ? SyncStatus.InSync : null );

			// Add to sync log
			_syncLog.Add( new SyncLogEntry { Name = itemName, Type = itemType, Ok = ok, Detail = ok ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : GetPushFailDetailForItem( id ) } );

			// Regenerate typed C# files so Code/Data/NetworkStorage/ stays in sync
			if ( ok )
			{
				try
				{
					var filesWritten = CodeGenerator.Generate();
					_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = true, Detail = $"{filesWritten} files written to Code/Data/NetworkStorage/" } );
				}
				catch ( Exception ex )
				{
					_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = false, Detail = ex.Message } );
				}
			}

			// Invalidate cached remote data so next Check for Updates is fresh,
			// but preserve other items' diff state so they don't disappear
			_remoteEndpoints = null;
			_remoteCollections = null;
			_remoteWorkflows = null;

			_status = ok ? $"Pushed {id}" : $"Push failed for {id}";
			ScrollToBottom();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Push {id} failed: {ex.Message}" );
			SetItemState( id, result: "FAIL" );
			_status = $"Push failed for {id}: {ex.Message}";
		}
		finally
		{
			_busy = false;
			_busyItem = null;
			_ = SyncLocalPackageInfoAsync();
			Update();
		}
	}

	// ──────────────────────────────────────────────────────
	//  Pull (per-item)
	// ──────────────────────────────────────────────────────

	private void PullItem( string id )
	{
		if ( _busy || !SyncToolConfig.IsValid ) return;

		var label = id.StartsWith( "ep_" ) ? $"endpoint '{id[3..]}'"
			: id.StartsWith( "col_" ) ? $"collection '{id[4..]}'"
			: id.StartsWith( "wf_" ) ? $"workflow '{id[3..]}'"
			: id.StartsWith( "test_" ) ? $"test '{id[5..]}'"
			: "resource";
		_items.TryGetValue( id, out var pullState );

		var warning = IsLocalChangedSinceCached( id, pullState )
			? "Local changes detected. Applying remote will create a backup, then overwrite your local YAML."
			: "A backup will be created before overwriting local YAML.";

		new PullPreviewWindow(
			DescribeSyncItem( id ),
			pullState.LocalYaml ?? "",
			pullState.RemoteYaml ?? "",
			warning,
			() => _ = DoPullItem( id, pullState.RemoteJson ) ).Show();
	}

	private async Task DoPullItem( string id, string approvedRemoteJson = null )
	{
		_busy = true;
		_busyItem = $"pull_{id}";
		_status = $"Pulling {id}...";
		Update();

		try
		{
			BackupLocalFileForPull( id );

			var ok = TryWriteApprovedRemoteToLocal( id, approvedRemoteJson );
			if ( !ok )
			{
				if ( id.StartsWith( "ep_" ) )
				{
					var slug = id[3..];
					ok = await DoPullSingleEndpoint( slug );
				}
				else if ( id.StartsWith( "col_" ) )
				{
					var colName = id[4..];
					ok = await DoPullSingleCollection( colName );
				}
				else if ( id.StartsWith( "wf_" ) )
				{
					var wfId = id[3..];
					ok = await DoPullSingleWorkflow( wfId );
				}
			}

			if ( ok )
			{
				SetItemState( id, result: "OK", remoteDiffers: false, diffSummary: "",
					status: SyncStatus.InSync );
				RefreshFileList();

				// Regenerate typed C# files so Code/Data/NetworkStorage/ reflects the pulled data
				try
				{
					CodeGenerator.Generate();
				}
				catch ( Exception ex )
				{
					Log.Warning( $"[SyncTool] Code generation after pull failed: {ex.Message}" );
				}

				// Invalidate cached remote data so next Check is fresh,
				// but keep _hasCheckedRemote and other items' state so they don't disappear
				_remoteEndpoints = null;
				_remoteCollections = null;
				_remoteWorkflows = null;
			}
			else
			{
				SetItemState( id, result: "FAIL" );
			}

			_status = ok ? $"Pulled {id}" : $"Pull failed for {id}";
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Pull {id} failed: {ex.Message}" );
			SetItemState( id, result: "FAIL" );
			_status = $"Pull failed for {id}: {ex.Message}";
		}
		finally
		{
			_busy = false;
			_busyItem = null;
			_ = SyncLocalPackageInfoAsync();
			Update();
		}
	}

	private bool TryWriteApprovedRemoteToLocal( string id, string approvedRemoteJson )
	{
		if ( string.IsNullOrWhiteSpace( approvedRemoteJson ) )
			return false;

		try
		{
			var data = JsonSerializer.Deserialize<Dictionary<string, object>>( approvedRemoteJson, _readOptions );
			if ( data == null )
				return false;

			if ( id.StartsWith( "ep_", StringComparison.OrdinalIgnoreCase ) )
			{
				SyncToolPullWriter.WriteSource( "endpoint", id[3..], data );
				return true;
			}
			if ( id.StartsWith( "col_", StringComparison.OrdinalIgnoreCase ) )
			{
				SyncToolPullWriter.WriteSource( "collection", id[4..], data );
				return true;
			}
			if ( id.StartsWith( "wf_", StringComparison.OrdinalIgnoreCase ) )
			{
				SyncToolPullWriter.WriteSource( "workflow", id[3..], data );
				return true;
			}
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Failed to write approved pull preview for {id}: {ex.Message}" );
		}

		return false;
	}

	/// <summary>
	/// Get a human-readable failure message after a failed push, using SyncToolApi error state.
	/// </summary>
	private static string GetPushFailDetail( string resource, string resourceId = null )
	{
		if ( SyncToolApi.LastErrorCode == "KEY_UPGRADE_REQUIRED" )
			return "Key uses old format — regenerate at sbox.cool";
		if ( SyncToolApi.LastErrorCode == "FORBIDDEN" )
			return $"No write permission for {resource}";
		if ( !string.IsNullOrWhiteSpace( resourceId ) )
		{
			var resourceMessage = SyncToolApi.GetLastResourceErrorMessage( resource, resourceId );
			if ( !string.IsNullOrEmpty( resourceMessage ) )
				return resourceMessage;
		}
		var pathMessage = SyncToolApi.GetLastErrorMessage( resource );
		if ( !string.IsNullOrEmpty( pathMessage ) )
			return pathMessage;
		if ( !string.IsNullOrEmpty( SyncToolApi.LastErrorMessage ) )
			return SyncToolApi.LastErrorMessage;
		return "Push failed";
	}

	private static string GetPushFailDetailForItem( string id )
	{
		if ( id.StartsWith( "ep_" ) )
			return GetPushFailDetail( "endpoints", id[3..] );
		if ( id.StartsWith( "col_" ) )
			return GetPushFailDetail( "collections", id[4..] );
		if ( id.StartsWith( "wf_" ) )
			return GetPushFailDetail( "workflows", id[3..] );
		return GetPushFailDetail( "resource" );
	}

	// ──────────────────────────────────────────────────────
	//  Push implementation
	// ──────────────────────────────────────────────────────

	private async Task<bool> DoPushAllEndpoints()
	{
		try
		{
			var localEps = SyncToolConfig.LoadSourcePayloadResources( "endpoint", includeDeprecated: false );
			if ( localEps.Count == 0 )
			{
				SyncToolApi.ReportLocalError( "endpoints", "No readable local endpoint source files were loaded for push." );
				return false;
			}
			var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( localEps ) );
			var resp = await SyncToolApi.PushEndpoints( serverFmt, _publishTarget );
			return resp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint push preparation failed: {ex.Message}", ex );
			return false;
		}
	}

	/// <summary>
	/// Push all resources (endpoints, collections, workflows) in a single batch request.
	/// Returns a tuple of (endpointsOk, collectionsOk, workflowsOk).
	/// Falls back to individual pushes if the batch endpoint is not available (HTTP 404).
	/// </summary>
	private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushAllBatched()
	{
		try
		{
			// Load raw local source. Backend compiler owns canonicalization.
			var localEps = SyncToolConfig.LoadSourcePayloadResources( "endpoint", includeDeprecated: false );
			var localCols = SyncToolConfig.LoadSourcePayloadResources( "collection" );
			var localWfs = SyncToolConfig.LoadSourcePayloadResources( "workflow" );

			var hasEndpoints = localEps.Count > 0;
			var hasCollections = localCols.Count > 0;
			var hasWorkflows = localWfs.Count > 0;

			if ( !hasEndpoints && !hasCollections && !hasWorkflows )
				return (false, false, false);

			// For next/staged pushes, keep endpoints and collections in one /sync request.
			// Separate PATCH calls can land on different API workers and overwrite each
			// other's revision override buckets before caches converge.
			var batchPayload = new Dictionary<string, object>();

			if ( hasEndpoints )
				batchPayload["endpoints"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localEps ) );

			if ( hasCollections )
				batchPayload["collections"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localCols ) );

			if ( hasWorkflows )
				batchPayload["workflows"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localWfs ) );

			var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( batchPayload ) );
			var resp = await SyncToolApi.PushSync( payload, _publishTarget );

			// Check if batch endpoint is not available (404/405) — fall back to individual pushes
			var errCode = SyncToolApi.LastErrorCode ?? "";
			var errMsg = SyncToolApi.LastErrorMessage ?? "";
			if ( !resp.HasValue )
			{
				var isBatchSyncUnavailable = IsBatchSyncEndpointUnavailable( errCode, errMsg );
				if ( isBatchSyncUnavailable )
				{
					// Batch sync endpoint not available, falling back to individual pushes
					return await DoPushAllIndividual( hasEndpoints, hasCollections, hasWorkflows );
				}

				var finalErrMsg = string.IsNullOrEmpty( errMsg ) ? "Unknown error" : errMsg;
				SyncToolApi.ReportLocalError( "sync", $"Batch sync failed: {finalErrMsg}" );
				ShowSyncBatchFailureWindow( finalErrMsg, errCode );
				return (false, false, false);
			}

			// Parse per-resource results from batch response
			var epOk = !hasEndpoints || (resp.Value.TryGetProperty( "endpoints", out var epResult ) &&
				epResult.TryGetProperty( "ok", out var epOkProp ) && epOkProp.GetBoolean());
			var colOk = !hasCollections || (resp.Value.TryGetProperty( "collections", out var colResult ) &&
				colResult.TryGetProperty( "ok", out var colOkProp ) && colOkProp.GetBoolean());
			var wfOk = !hasWorkflows || (resp.Value.TryGetProperty( "workflows", out var wfResult ) &&
				wfResult.TryGetProperty( "ok", out var wfOkProp ) && wfOkProp.GetBoolean());

			return (hasEndpoints && epOk, hasCollections && colOk, hasWorkflows && wfOk);
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "sync", $"Batch sync preparation failed: {ex.Message}", ex );
			return (false, false, false);
		}
	}

	/// <summary>
	/// Next-release push path: use the dedicated endpoints/collections routes for staged
	/// overrides so immediate verification reads the same target that was written.
	/// </summary>
	private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushNextWithDedicatedEndpoints( bool hasEndpoints, bool hasCollections, bool hasWorkflows )
	{
		var endpointsTask = hasEndpoints ? DoPushAllEndpoints() : Task.FromResult( false );
		var collectionsTask = hasCollections ? DoPushCollections() : Task.FromResult( false );
		var workflowsTask = hasWorkflows ? DoPushAllWorkflows() : Task.FromResult( false );

		await Task.WhenAll( endpointsTask, collectionsTask, workflowsTask );

		return (await endpointsTask, await collectionsTask, await workflowsTask);
	}

	private async Task<(bool epOk, bool wfOk)> DoPushEndpointsAndWorkflowsBatched( List<JsonElement> localEps, List<JsonElement> localWfs, bool hasEndpoints, bool hasWorkflows )
	{
		try
		{
			var batchPayload = new Dictionary<string, object>();
			if ( hasEndpoints )
				batchPayload["endpoints"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localEps ) );
			if ( hasWorkflows )
				batchPayload["workflows"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localWfs ) );

			var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( batchPayload ) );
			var resp = await SyncToolApi.PushSync( payload, _publishTarget );

			var errCode = SyncToolApi.LastErrorCode ?? "";
			var errMsg = SyncToolApi.LastErrorMessage ?? "";
			if ( !resp.HasValue )
			{
				var finalErrMsg = string.IsNullOrEmpty( errMsg ) ? "Unknown error" : errMsg;
				SyncToolApi.ReportLocalError( "sync", $"Endpoint/workflow batch sync failed: {finalErrMsg}" );
				ShowSyncBatchFailureWindow( finalErrMsg, errCode );
				return (false, false);
			}

			var epOk = !hasEndpoints || (resp.Value.TryGetProperty( "endpoints", out var epResult ) &&
				epResult.TryGetProperty( "ok", out var epOkProp ) && epOkProp.GetBoolean());
			var wfOk = !hasWorkflows || (resp.Value.TryGetProperty( "workflows", out var wfResult ) &&
				wfResult.TryGetProperty( "ok", out var wfOkProp ) && wfOkProp.GetBoolean());

			return (hasEndpoints && epOk, hasWorkflows && wfOk);
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "sync", $"Endpoint/workflow batch sync preparation failed: {ex.Message}", ex );
			return (false, false);
		}
	}

	/// <summary>
	/// Fallback: push endpoints, collections, workflows in parallel individual requests.
	/// </summary>
	private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushAllIndividual( bool hasEndpoints, bool hasCollections, bool hasWorkflows )
	{
		var pushEpTask = hasEndpoints ? DoPushAllEndpoints() : Task.FromResult( false );
		var pushColTask = hasCollections ? DoPushCollections() : Task.FromResult( false );
		var pushWfTask = hasWorkflows ? DoPushAllWorkflows() : Task.FromResult( false );

		await Task.WhenAll( pushEpTask, pushColTask, pushWfTask );

		return (await pushEpTask, await pushColTask, await pushWfTask);
	}

	private static bool IsIgnoredComparisonField( string name )
	{
		return name is "authoringMode"
			or "sourceText"
			or "sourceFormat"
			or "sourceVersion"
			or "sourcePath"
			or "compilerFingerprint"
			or "compilerFingerprintHash"
			or "sourceHash"
			or "dependencyHash"
			or "canonicalHash"
			or "executionPlanHash"
			or "dependencies"
			or "executionPlan"
			or "diagnostics"
			or "canonicalDefinition";
	}

	/// <summary>
	/// Push a single endpoint by merging it into the remote list.
	/// GETs remote endpoints, replaces the matching slug, PUTs the merged list.
	/// </summary>
	private async Task<bool> DoPushSingleEndpointMerged( string slug )
	{
		try
		{
			var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
			if ( localFile == null )
			{
				SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} was not found." );
				return false;
			}

			if ( !SyncToolConfig.TryLoadSourcePayloadResource( "endpoint", localFile, out var localEp, includeDeprecated: true ) )
			{
				SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} could not be read." );
				return false;
			}

			// Push raw source only; the backend compiler owns canonicalization.
			var localDict = JsonSerializer.Deserialize<Dictionary<string, object>>( localEp.GetRawText() );
			var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { endpoint = localDict } ) );

			// Try PATCH first (server handles upsert)
			var resp = await SyncToolApi.PatchEndpoint( payload, _publishTarget );
			if ( resp.HasValue )
				return true;

			// Fall back to GET-merge-PUT if PATCH not supported (404/405)
			var errCode = SyncToolApi.LastErrorCode ?? "";
			var errMsg = SyncToolApi.LastErrorMessage ?? "";
			var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
			if ( !isNotSupported )
			{
				// Real error, not "endpoint not found"
				return false;
			}


			var remoteResp = await SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
			if ( !remoteResp.HasValue ) return false;

			var data = remoteResp.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind != JsonValueKind.Array )
			{
				SyncToolApi.ReportLocalError( "endpoints", "Remote endpoints payload was not an array." );
				return false;
			}

			var merged = new List<object>();
			var replaced = false;
			foreach ( var ep in data.EnumerateArray() )
			{
				var epSlug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
				if ( epSlug == slug )
				{
					if ( ep.TryGetProperty( "id", out var idEl ) )
						localDict["id"] = idEl.GetString();
					if ( ep.TryGetProperty( "createdAt", out var caEl ) )
						localDict["createdAt"] = caEl.GetString();
					merged.Add( localDict );
					replaced = true;
				}
				else
				{
					merged.Add( ep );
				}
			}

			if ( !replaced )
			{
				localDict["id"] = Guid.NewGuid().ToString( "N" )[..16];
				merged.Add( localDict );
			}

			var mergedJson = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( merged ) );
			resp = await SyncToolApi.PushEndpoints( mergedJson, _publishTarget );
			return resp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "endpoints", $"Single endpoint push preparation failed for {slug}: {ex.Message}", ex );
			return false;
		}
	}

	private async Task<bool> DoPushSingleCollection( string colName )
	{
		try
		{
			var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
			if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "collection", localFile, out var localCollection ) )
			{
				SyncToolApi.ReportLocalError( "collections", $"Local collection file for {colName} was not found." );
				return false;
			}

			// Try PATCH first (server handles upsert and source compilation)
			var collectionPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( localCollection.GetRawText() );
			var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { collection = collectionPayload } ) );
			var resp = await SyncToolApi.PatchCollection( payload, _publishTarget );
			if ( resp.HasValue )
				return true;

			// Fall back to PUT if PATCH not supported
			var errCode = SyncToolApi.LastErrorCode ?? "";
			var errMsg = SyncToolApi.LastErrorMessage ?? "";
			var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
			if ( !isNotSupported )
				return false;

			var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new List<JsonElement> { localCollection } ) );
			resp = await SyncToolApi.PushCollections( serverFmt, _publishTarget );
			return resp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "collections", $"Single collection push failed for {colName}: {ex.Message}", ex );
			return false;
		}
	}

	private async Task<bool> DoPushSingleWorkflow( string wfId )
	{
		try
		{
			var localFile = _workflowFiles.FirstOrDefault( f => ResourceIdFromFile( f, "workflow" ) == wfId );
			if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "workflow", localFile, out var localWf ) )
			{
				SyncToolApi.ReportLocalError( "workflows", $"Local workflow file for {wfId} was not found." );
				return false;
			}

			// Try PATCH first (server handles upsert and source compilation)
			var workflowPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( localWf.GetRawText() );
			var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { workflow = workflowPayload } ) );
			var resp = await SyncToolApi.PatchWorkflow( payload );
			if ( resp.HasValue )
				return true;

			// Fall back to GET-merge-PUT if PATCH not supported
			var errCode = SyncToolApi.LastErrorCode ?? "";
			var errMsg = SyncToolApi.LastErrorMessage ?? "";
			var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
			if ( !isNotSupported )
				return false;

			var existing = await SyncToolApi.GetWorkflows();
			var serverFmt = SyncToolTransforms.WorkflowsToServer( new List<JsonElement> { localWf }, existing );
			resp = await SyncToolApi.PushWorkflows( serverFmt );
			return resp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "workflows", $"Single workflow push failed for {wfId}: {ex.Message}", ex );
			return false;
		}
	}

	private async Task<bool> DoPushCollections()
	{
		try
		{
			var collections = SyncToolConfig.LoadSourcePayloadResources( "collection" );
			if ( collections.Count == 0 )
			{
				SyncToolApi.ReportLocalError( "collections", "No readable local collection source files were loaded for push." );
				return false;
			}

			// Staged/Main collection writes must use the per-resource PATCH route. The bulk
			// PUT route can return success while updating only the live collection set on
			// older backends, which makes immediate verification read back the old Live row.
			if ( string.Equals( _publishTarget, "next", StringComparison.OrdinalIgnoreCase ) )
			{
				var allOk = true;
				foreach ( var collection in collections )
				{
					var collectionPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( collection.GetRawText() );
					var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { collection = collectionPayload } ) );
					var resp = await SyncToolApi.PatchCollection( payload, _publishTarget );
					allOk &= resp.HasValue;
				}
				return allOk;
			}

			var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( collections ) );
			var bulkResp = await SyncToolApi.PushCollections( serverFmt, _publishTarget );
			return bulkResp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "collections", $"Local collection push preparation failed: {ex.Message}", ex );
			return false;
		}
	}

	private async Task<bool> DoPushAllWorkflows()
	{
		try
		{
			var localWfs = SyncToolConfig.LoadSourcePayloadResources( "workflow" );
			if ( localWfs.Count == 0 )
			{
				SyncToolApi.ReportLocalError( "workflows", "No readable local workflow source files were loaded for push." );
				return false;
			}
			var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( localWfs ) );
			var resp = await SyncToolApi.PushWorkflows( serverFmt );
			return resp.HasValue;
		}
		catch ( Exception ex )
		{
			SyncToolApi.ReportLocalError( "workflows", $"Local workflow push preparation failed: {ex.Message}", ex );
			return false;
		}
	}

	// ──────────────────────────────────────────────────────
	//  Pull implementation
	// ──────────────────────────────────────────────────────

	private async Task<bool> DoPullSingleEndpoint( string slug )
	{
		// Use cached selected-target remote data if available, otherwise fetch that target.
		var resp = _remoteEndpoints ?? await SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
		if ( !resp.HasValue ) return false;

		try
		{
			var data = resp.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind != JsonValueKind.Array ) return false;

			foreach ( var ep in data.EnumerateArray() )
			{
				var epSlug = GetRemoteEndpointSlug( ep );
				if ( !string.Equals( epSlug, slug, StringComparison.OrdinalIgnoreCase ) ) continue;
				if ( SyncToolConfig.IsEndpointDeprecated( ep ) ) return false;

				return SyncToolPullWriter.SaveEndpoint( slug, ep );
			}
			return false;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Pull endpoint {slug} failed: {ex.Message}" );
			return false;
		}
	}

	private async Task<bool> DoPullCollections()
	{
		var resp = _remoteCollections ?? await SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
		if ( !resp.HasValue ) return false;
		try
		{
			return SyncToolPullWriter.SaveCollections( resp.Value ) > 0;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Pull collections failed: {ex.Message}" );
			return false;
		}
	}

	private async Task<bool> DoPullSingleCollection( string colName )
	{
		var resp = _remoteCollections ?? await SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
		if ( !resp.HasValue ) return false;
		try
		{
			var data = resp.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind != JsonValueKind.Array ) return false;

			foreach ( var collection in data.EnumerateArray() )
			{
				var local = SyncToolTransforms.ServerCollectionToLocal( collection );
				var name = local.TryGetValue( "name", out var value ) ? value?.ToString() : null;
				if ( string.Equals( name, colName, StringComparison.OrdinalIgnoreCase ) )
					return SyncToolPullWriter.SaveCollection( colName, collection );
			}

			return false;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Pull collection {colName} failed: {ex.Message}" );
			return false;
		}
	}

	private async Task<bool> DoPullSingleWorkflow( string wfId )
	{
		var resp = _remoteWorkflows ?? await SyncToolApi.GetWorkflows();
		if ( !resp.HasValue ) return false;
		try
		{
			var data = resp.Value;
			if ( data.TryGetProperty( "data", out var d ) ) data = d;
			if ( data.ValueKind != JsonValueKind.Array ) return false;

			foreach ( var workflow in data.EnumerateArray() )
			{
				var local = SyncToolTransforms.ServerWorkflowToLocal( workflow );
				var id = local.TryGetValue( "id", out var value ) ? value?.ToString() : null;
				if ( string.Equals( id, wfId, StringComparison.OrdinalIgnoreCase ) )
					return SyncToolPullWriter.SaveWorkflow( wfId, workflow );
			}

			return false;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Pull workflow {wfId} failed: {ex.Message}" );
			return false;
		}
	}

	// ──────────────────────────────────────────────────────
	//  Diff breakdown helpers
	// ──────────────────────────────────────────────────────

	/// <summary>
	/// Compare two endpoint resources key-by-key.
	/// Categorizes changes as cosmetic (name, description, notes) vs structural (steps, input, response, method).
	/// </summary>
	private string DiffEndpoint( string localJson, string remoteJson, string slug )
	{
		try
		{
			var local = JsonSerializer.Deserialize<JsonElement>( localJson );
			var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );

			var cosmetic = new List<string>();
			var structural = new List<string>();

			// ── Cosmetic fields (harmless label changes) ──
			var localName = GetStr( local, "name" );
			var remoteName = GetStr( remote, "name" );
			if ( localName != remoteName )
				cosmetic.Add( $"name: \"{remoteName}\" → \"{localName}\"" );

			var localDesc = GetStr( local, "description" );
			var remoteDesc = GetStr( remote, "description" );
			if ( localDesc != remoteDesc )
			{
				var label = string.IsNullOrEmpty( remoteDesc ) ? "description added locally" : "description differs";
				cosmetic.Add( label );
			}

			var localNotes = GetStr( local, "notes" );
			var remoteNotes = GetStr( remote, "notes" );
			if ( localNotes != remoteNotes )
			{
				var label = string.IsNullOrEmpty( remoteNotes ) ? "notes added locally" : "notes differ";
				cosmetic.Add( label );
			}

			var localEnabled = !local.TryGetProperty( "enabled", out var le ) || le.ValueKind != JsonValueKind.False;
			var remoteEnabled = !remote.TryGetProperty( "enabled", out var re ) || re.ValueKind != JsonValueKind.False;
			if ( localEnabled != remoteEnabled )
				cosmetic.Add( $"enabled: {remoteEnabled} → {localEnabled}" );

			// ── Structural fields (logic changes) ──
			var localMethod = GetStr( local, "method" );
			var remoteMethod = GetStr( remote, "method" );
			if ( localMethod != remoteMethod )
				structural.Add( $"method: {remoteMethod} → {localMethod}" );

			var localInput = local.TryGetProperty( "input", out var li ) ? NormalizeJson( li.ToString() ) : "{}";
			var remoteInput = remote.TryGetProperty( "input", out var ri ) ? NormalizeJson( ri.ToString() ) : "{}";
			if ( localInput != remoteInput )
				structural.Add( "input schema" );

			var localSteps = local.TryGetProperty( "steps", out var ls ) ? NormalizeJson( ls.ToString() ) : "[]";
			var remoteSteps = remote.TryGetProperty( "steps", out var rs ) ? NormalizeJson( rs.ToString() ) : "[]";
			if ( localSteps != remoteSteps )
			{
				var lCount = ls.ValueKind == JsonValueKind.Array ? ls.GetArrayLength() : 0;
				var rCount = rs.ValueKind == JsonValueKind.Array ? rs.GetArrayLength() : 0;
				structural.Add( lCount != rCount ? $"steps: {rCount} → {lCount}" : "step logic" );
			}

			var localResp = local.TryGetProperty( "response", out var lresp ) ? NormalizeJson( lresp.ToString() ) : "{}";
			var remoteResp = remote.TryGetProperty( "response", out var rresp ) ? NormalizeJson( rresp.ToString() ) : "{}";
			if ( localResp != remoteResp )
				structural.Add( "response" );

			// ── Build summary ──
			if ( structural.Count == 0 && cosmetic.Count > 0 )
				return $"cosmetic only: {string.Join( ", ", cosmetic )}";
			if ( structural.Count > 0 && cosmetic.Count == 0 )
				return $"logic differs: {string.Join( ", ", structural )}";
			if ( structural.Count > 0 && cosmetic.Count > 0 )
				return $"logic: {string.Join( ", ", structural )} | cosmetic: {string.Join( ", ", cosmetic )}";

			return "Field ordering differs (content identical)";
		}
		catch { return "Content differs (click View Diff)"; }
	}

	private static string GetStr( JsonElement el, string key )
	{
		return el.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
	}

	/// <summary>
	/// Compare two collection resources field-by-field.
	/// Distinguishes schema (structural) from metadata (non-structural) changes.
	/// </summary>
	private static bool CollectionSemanticsMatch( string localJson, string remoteJson )
	{
		try
		{
			var local = JsonSerializer.Deserialize<JsonElement>( localJson );
			var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
			var rateLimitDefault = new Dictionary<string, object> { ["mode"] = "none" };
			var emptyObject = new Dictionary<string, object>();

			foreach ( var field in new[] { "name", "description", "notes" } )
			{
				if ( NormalizeCollectionField( local, field, "" ) != NormalizeCollectionField( remote, field, "" ) )
					return false;
			}

			if ( NormalizeCollectionField( local, "collectionType", "per-steamid" ) != NormalizeCollectionField( remote, "collectionType", "per-steamid" ) ) return false;
			if ( NormalizeCollectionField( local, "accessMode", "public" ) != NormalizeCollectionField( remote, "accessMode", "public" ) ) return false;
			if ( NormalizeCollectionField( local, "visibility", "" ) != NormalizeCollectionField( remote, "visibility", "" ) ) return false;
			if ( NormalizeCollectionField( local, "maxRecords", 1 ) != NormalizeCollectionField( remote, "maxRecords", 1 ) ) return false;
			if ( NormalizeCollectionField( local, "allowRecordDelete", false ) != NormalizeCollectionField( remote, "allowRecordDelete", false ) ) return false;
			if ( NormalizeCollectionField( local, "requireSaveVersion", false ) != NormalizeCollectionField( remote, "requireSaveVersion", false ) ) return false;
			if ( NormalizeCollectionField( local, "webhookOnRateLimit", false ) != NormalizeCollectionField( remote, "webhookOnRateLimit", false ) ) return false;
			if ( NormalizeCollectionField( local, "rateLimitAction", "reject" ) != NormalizeCollectionField( remote, "rateLimitAction", "reject" ) ) return false;
			if ( NormalizeCollectionField( local, "rateLimits", rateLimitDefault ) != NormalizeCollectionField( remote, "rateLimits", rateLimitDefault ) ) return false;
			if ( NormalizeCollectionField( local, "schema", emptyObject ) != NormalizeCollectionField( remote, "schema", emptyObject ) ) return false;
			if ( NormalizeCollectionField( local, "constants", emptyObject ) != NormalizeCollectionField( remote, "constants", emptyObject ) ) return false;
			if ( NormalizeCollectionField( local, "tables", emptyObject ) != NormalizeCollectionField( remote, "tables", emptyObject ) ) return false;

			return true;
		}
		catch
		{
			return false;
		}
	}

	private static string NormalizeCollectionField( JsonElement element, string field, object defaultValue )
	{
		var json = element.ValueKind == JsonValueKind.Object && element.TryGetProperty( field, out var value )
			? value.GetRawText()
			: JsonSerializer.Serialize( defaultValue );
		return NormalizeJson( json );
	}

	private string DiffCollectionSchema( string localJson, string remoteJson )
	{
		try
		{
			if ( string.IsNullOrEmpty( localJson ) )
				return "New — no local file exists";

			var local = JsonSerializer.Deserialize<JsonElement>( localJson );
			var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );

			var schemaChanges = new List<string>();
			var metaChanges = new List<string>();

			// Compare schema (structural)
			var localSchema = local.TryGetProperty( "schema", out var ls ) ? NormalizeJson( ls.ToString() ) : "{}";
			var remoteSchema = remote.TryGetProperty( "schema", out var rs ) ? NormalizeJson( rs.ToString() ) : "{}";

			if ( localSchema == remoteSchema )
				schemaChanges.Add( "schema: identical" );
			else
				schemaChanges.Add( "schema: differs" );

			// Compare metadata fields (non-structural)
			CompareField( local, remote, "name", metaChanges );
			CompareField( local, remote, "description", metaChanges );
			CompareField( local, remote, "notes", metaChanges );
			CompareField( local, remote, "accessMode", metaChanges );
			CompareField( local, remote, "visibility", metaChanges );
			CompareField( local, remote, "collectionType", metaChanges );
			CompareField( local, remote, "maxRecords", metaChanges );
			CompareField( local, remote, "allowRecordDelete", metaChanges );
			CompareField( local, remote, "requireSaveVersion", metaChanges );
			CompareField( local, remote, "webhookOnRateLimit", metaChanges );
			CompareField( local, remote, "rateLimitAction", metaChanges );

			// Compare rateLimits object
			var localRL = local.TryGetProperty( "rateLimits", out var lrl ) ? NormalizeJson( lrl.ToString() ) : "";
			var remoteRL = remote.TryGetProperty( "rateLimits", out var rrl ) ? NormalizeJson( rrl.ToString() ) : "";
			if ( localRL != remoteRL )
				metaChanges.Add( "rateLimits" );

			// Compare constants/tables
			var localConst = local.TryGetProperty( "constants", out var lc ) ? NormalizeJson( lc.ToString() ) : "";
			var remoteConst = remote.TryGetProperty( "constants", out var rc ) ? NormalizeJson( rc.ToString() ) : "";
			if ( localConst != remoteConst )
				metaChanges.Add( "constants" );

			var localTables = local.TryGetProperty( "tables", out var lt ) ? NormalizeJson( lt.ToString() ) : "";
			var remoteTables = remote.TryGetProperty( "tables", out var rt ) ? NormalizeJson( rt.ToString() ) : "";
			if ( localTables != remoteTables )
				metaChanges.Add( "tables" );

			var parts = new List<string>();
			parts.AddRange( schemaChanges );
			if ( metaChanges.Count > 0 )
				parts.Add( $"metadata differs: {string.Join( ", ", metaChanges )}" );

			return string.Join( " | ", parts );
		}
		catch { return "Content differs (click View Diff)"; }
	}

	private static void CompareField( JsonElement local, JsonElement remote, string field, List<string> changes )
	{
		var lv = local.TryGetProperty( field, out var l ) ? l.ToString() : "";
		var rv = remote.TryGetProperty( field, out var r ) ? r.ToString() : "";
		if ( lv != rv )
			changes.Add( $"{field}: {lv} → {rv}" );
	}

	/// <summary>
	/// Merge only non-structural metadata from remote into the local collection file.
	/// Pulls: description, accessMode, rateLimits, rateLimitAction, maxRecords, etc.
	/// Keeps: schema, constants, tables unchanged (local version preserved).
	/// </summary>
	private void MergeMetadata( string id )
	{
		if ( _busy || !id.StartsWith( "col_" ) ) return;

		_items.TryGetValue( id, out var state );
		if ( string.IsNullOrEmpty( state.RemoteJson ) ) return;

		var colName = id[4..];

		ConfirmDialog.Show(
			"Merge Metadata from Remote",
			$"This will update non-structural fields (description, accessMode, rateLimits, etc.) in your local '{colName}' collection file with values from the server. Your schema, constants, and tables will NOT be changed.",
			() =>
			{
				try
				{
					var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
					if ( localFile == null ) return;

					if ( !TryReadLocalResourceFile( localFile, "collection", out var localElement ) )
						return;
					var local = JsonSerializer.Deserialize<Dictionary<string, object>>( localElement.GetRawText(), _readOptions );
					var remote = JsonSerializer.Deserialize<JsonElement>( state.RemoteJson );

					// Merge metadata fields from remote (non-structural)
					string[] metaFields = { "description", "accessMode", "collectionType", "maxRecords",
						"allowRecordDelete", "requireSaveVersion", "rateLimitAction", "webhookOnRateLimit", "rateLimits" };

					foreach ( var field in metaFields )
					{
						if ( remote.TryGetProperty( field, out var val ) )
						{
							local[field] = val.ValueKind switch
							{
								JsonValueKind.String => (object)val.GetString(),
								JsonValueKind.Number => val.TryGetInt32( out var i ) ? i : val.GetDouble(),
								JsonValueKind.True => true,
								JsonValueKind.False => false,
								_ => val
							};
						}
					}

					// Save — preserves local schema, constants, tables
					SyncToolPullWriter.WriteSource( "collection", colName, local );

					SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
					_status = $"Merged metadata for {colName}";
					Update();
				}
				catch ( Exception ex )
				{
					_status = $"Merge failed: {ex.Message}";
					Update();
				}
			}
		);
	}

	/// <summary>
	/// Pull additive remote-only fields into every eligible local file.
	/// </summary>
	private void PullAllRemoteSemantics()
	{
		if ( _busy || !SyncToolConfig.IsValid ) return;

		var ids = GetRemoteSemanticsIds();
		if ( ids.Length == 0 ) return;

		var detail = string.Join( "\n", ids.Select( DescribeSyncItem ) );
		ConfirmDialog.Show(
			"Pull Remote Semantics",
			$"This will update {ids.Length} local file(s) with additive remote-only fields. Files with modified or missing content are excluded from this action.",
			() => _ = DoPullAllRemoteSemantics( ids ),
			detail: detail );
	}

	private Task DoPullAllRemoteSemantics( string[] ids )
	{
		_busy = true;
		_busyItem = "pull_remote_semantics_all";
		_status = $"Pulling remote semantics for {ids.Length} item(s)...";
		Update();

		try
		{
			var okCount = 0;
			var failCount = 0;

			foreach ( var id in ids )
			{
				if ( !_items.TryGetValue( id, out var state ) || string.IsNullOrEmpty( state.RemoteJson ) )
				{
					failCount++;
					continue;
				}

				if ( TryApplyRemoteJsonToLocal( id, state.RemoteJson, out var error ) )
				{
					okCount++;
					SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
				}
				else
				{
					failCount++;
					SetItemState( id, result: "FAIL" );
					Log.Warning( $"[SyncTool] Pull remote semantics failed for {id}: {error}" );
				}
			}

			if ( okCount > 0 )
			{
				RefreshFileList();
				TryRunCodeGeneration( "pull remote semantics" );
				InvalidateRemoteCache();
			}

			_status = failCount == 0
				? $"Pulled remote semantics for {okCount} item(s)"
				: $"Pulled remote semantics for {okCount} item(s), {failCount} failed";
		}
		finally
		{
			_busy = false;
			_busyItem = null;
			_ = SyncLocalPackageInfoAsync();
			Update();
		}

		return Task.CompletedTask;
	}

	/// <summary>
	/// Open the MergeViewWindow showing additive remote-only fields with explanations.
	/// </summary>
	private void OpenMergeView( string id, string name )
	{
		if ( !_items.TryGetValue( id, out var state ) ) return;

		var (added, changed, _) = MergeViewWindow.AnalyzeDifferences( state.LocalJson ?? "{}", state.RemoteJson ?? "{}" );

		var resourceType = id.StartsWith( "ep_" ) ? "endpoint"
			: id.StartsWith( "col_" ) ? "collection"
			: "workflow";

		var capturedId = id;
		var capturedName = name;
		var window = new MergeViewWindow( name, resourceType, added, changed,
			onMerge: () => DoMergeItem( capturedId ),
			onViewDiff: () => OpenDiffView( capturedId, capturedName ) );
		window.Show();
	}

	/// <summary>
	/// Accept the additive remote semantics by saving the remote JSON to the local file.
	/// </summary>
	private void DoMergeItem( string id )
	{
		_items.TryGetValue( id, out var state );
		if ( string.IsNullOrEmpty( state.RemoteJson ) ) return;

		if ( TryApplyRemoteJsonToLocal( id, state.RemoteJson, out var error ) )
		{
			SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
			RefreshFileList();
			TryRunCodeGeneration( "pull remote semantics" );
			InvalidateRemoteCache();
			_status = $"Pulled remote semantics for {id}";
			Update();
		}
		else
		{
			_status = $"Pull remote semantics failed: {error}";
			Update();
		}
	}

	private bool TryApplyRemoteJsonToLocal( string id, string remoteJson, out string error )
	{
		try
		{
			var json = TryGetCurrentLocalJson( id, out var localJson )
				? MergeRemoteOnlyFields( localJson, remoteJson )
				: PrettyJson( remoteJson );

			if ( id.StartsWith( "ep_" ) )
			{
				var slug = id[3..];
				var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
				SyncToolPullWriter.WriteSource( "endpoint", slug, data );
			}
			else if ( id.StartsWith( "col_" ) )
			{
				var colName = id[4..];
				var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
				SyncToolPullWriter.WriteSource( "collection", colName, data );
			}
			else if ( id.StartsWith( "wf_" ) )
			{
				var wfId = id[3..];
				var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
				SyncToolPullWriter.WriteSource( "workflow", wfId, data );
			}
			else if ( id.StartsWith( "test_" ) )
			{
				var testId = id[5..];
				var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
				SyncToolPullWriter.WriteSource( "test", testId, data );
			}
			else
			{
				error = $"Unsupported sync item: {id}";
				return false;
			}

			error = null;
			return true;
		}
		catch ( Exception ex )
		{
			error = ex.Message;
			return false;
		}
	}

	private bool TryGetCurrentLocalJson( string id, out string localJson )
	{
		localJson = null;
		JsonElement local;

		if ( id.StartsWith( "ep_" ) )
		{
			var slug = id[3..];
			var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
			if ( localFile == null || !TryReadLocalResourceFile( localFile, "endpoint", out local ) )
				return false;

			localJson = JsonSerializer.Serialize( SyncToolTransforms.ServerEndpointToLocal( local ), new JsonSerializerOptions { WriteIndented = true } );
			return true;
		}

		if ( id.StartsWith( "col_" ) )
		{
			var colName = id[4..];
			var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
			if ( localFile == null || !TryReadLocalResourceFile( localFile, "collection", out local ) )
				return false;

			var data = JsonSerializer.Deserialize<Dictionary<string, object>>( local.GetRawText(), _readOptions );
			localJson = JsonSerializer.Serialize( SyncToolTransforms.StripServerManagedFields( data ), new JsonSerializerOptions { WriteIndented = true } );
			return true;
		}

		if ( id.StartsWith( "wf_" ) )
		{
			var wfId = id[3..];
			var localFile = SyncToolConfig.FindWorkflowFileById( wfId );
			if ( localFile == null || !TryReadLocalResourceFile( localFile, "workflow", out local ) )
				return false;

			localJson = JsonSerializer.Serialize( SyncToolTransforms.ServerWorkflowToLocal( local ), new JsonSerializerOptions { WriteIndented = true } );
			return true;
		}

		return false;
	}

	private static string MergeRemoteOnlyFields( string localJson, string remoteJson )
	{
		var local = JsonSerializer.Deserialize<JsonElement>( localJson );
		var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
		var merged = MergeRemoteOnlyFields( local, remote );
		return JsonSerializer.Serialize( merged, new JsonSerializerOptions { WriteIndented = true } );
	}

	private static object MergeRemoteOnlyFields( JsonElement local, JsonElement remote )
	{
		if ( local.ValueKind == JsonValueKind.Object && remote.ValueKind == JsonValueKind.Object )
		{
			var result = new Dictionary<string, object>( StringComparer.OrdinalIgnoreCase );
			var remoteProps = remote.EnumerateObject().ToDictionary( prop => prop.Name, prop => prop.Value, StringComparer.OrdinalIgnoreCase );

			foreach ( var localProp in local.EnumerateObject() )
			{
				result[localProp.Name] = remoteProps.TryGetValue( localProp.Name, out var remoteValue )
					? MergeRemoteOnlyFields( localProp.Value, remoteValue )
					: JsonElementToPlainObject( localProp.Value );
			}

			foreach ( var remoteProp in remote.EnumerateObject() )
			{
				if ( !result.ContainsKey( remoteProp.Name ) )
					result[remoteProp.Name] = JsonElementToPlainObject( remoteProp.Value );
			}

			return result;
		}

		return JsonElementToPlainObject( local );
	}

	private static object JsonElementToPlainObject( JsonElement value )
	{
		return value.ValueKind switch
		{
			JsonValueKind.Object => value.EnumerateObject()
				.ToDictionary( prop => prop.Name, prop => JsonElementToPlainObject( prop.Value ), StringComparer.OrdinalIgnoreCase ),
			JsonValueKind.Array => value.EnumerateArray().Select( JsonElementToPlainObject ).ToList(),
			JsonValueKind.String => value.GetString(),
			JsonValueKind.Number when value.TryGetInt64( out var integer ) => integer,
			JsonValueKind.Number when value.TryGetDouble( out var number ) => number,
			JsonValueKind.True => true,
			JsonValueKind.False => false,
			_ => null
		};
	}

	private void TryRunCodeGeneration( string context )
	{
		try
		{
			CodeGenerator.Generate();
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[SyncTool] Code generation after {context} failed: {ex.Message}" );
		}
	}

	private void InvalidateRemoteCache()
	{
		_remoteEndpoints = null;
		_remoteCollections = null;
		_remoteWorkflows = null;
	}

	private void OpenDiffView( string id, string name )
	{
		if ( !_items.TryGetValue( id, out var state ) ) return;
		var window = new DiffViewWindow( name, state.LocalYaml ?? "", state.RemoteYaml ?? "" );
		window.Show();
	}

	[Button( "Docs", Icon = "menu_book" )]
	public void OpenDocs()
	{
		Process.Start( new ProcessStartInfo
		{
			FileName = "https://sboxcool.com/wiki/network-storage-v3",
			UseShellExecute = true
		} );
	}

	[Button( "Setup", Icon = "settings" )]
	public void OpenSetup()
	{
		var window = new SetupWindow();
		window.Show();
	}

	[Button( "Refresh", Icon = "refresh" )]
	public void Refresh()
	{
		SyncToolConfig.Load();
		_publishTarget = SyncToolConfig.PublishTarget;
		RefreshFileList();
		_items.Clear();
		_syncLog.Clear();
		_scrollY = 0;
		_hasCheckedRemote = false;

		_remoteEndpoints = null;
		_remoteCollections = null;
		_remoteWorkflows = null;
		_status = SyncToolConfig.IsValid ? "Refreshed" : "Config invalid — check .env";
		Update();
		_status = SyncToolConfig.IsValid ? "Refreshed" : "Config invalid — check .env";
		Update();
	}

	private static void LogDiffResult( string id, string kind, string localJson, string remoteJson, bool differs, (SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) classification )
	{
		// Diff details available in classification.Analysis if needed for debugging
	}

	private static string TruncateVal( string v, int maxLen = 120 )
	{
		if ( string.IsNullOrEmpty( v ) ) return "(empty)";
		return v.Length <= maxLen ? v : v[..maxLen] + $"… ({v.Length} chars)";
	}


} // end class