Editor/SyncToolWindow.cs

Editor window for a Network Storage sync tool. Lists local and remote endpoints, collections, and workflows, with per-item push/pull/test/diff actions, a publish target selector (Staged/Main vs Live), and revision/server status panels. It also starts background revision polling and compatibility checks.

Http CallsFile Access
🌐 api.sboxcool.com
#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;
using static SyncToolVerification;

/// <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>
public partial class SyncToolWindow : PaintedWindow
{
	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 );
		// The game reads the target from the credentials file and NSConfig; regenerate so both match the selection.
		TryRunCodeGeneration( "publish target change" );
		_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 bool Warn;     // Non-blocking diagnostic (preflight warning): shown in orange, not counted as pushed/failed
		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();
		_ = RefreshCompatibilityAsync();
	}

	private async Task RefreshCompatibilityAsync()
	{
		try
		{
			await EditorCompatibility.RefreshAsync();
			Update();
			if ( EditorCompatibility.TakeModal() is { } modal )
				MessageDialog.Show( modal.Title, modal.Message, "" );
		}
		catch ( Exception ex )
		{
			Log.Info( $"[SyncTool] Compatibility check: {ex.Message}" );
		}
	}

	/// <summary>One dismissible notice under the server line. Errors cannot be dismissed because they block Push All.</summary>
	private void DrawCompatBanner( float pad, float w, ref float y )
	{
		var notice = EditorCompatibility.Banner;
		if ( notice is null ) return;
		var color = notice.Level == "error" ? Color.Red : notice.Level == "warning" ? Color.Orange : Color.Cyan;
		Paint.SetDefaultFont( size: 9 );
		Paint.SetPen( color.WithAlpha( 0.95f ) );
		foreach ( var line in EditorCompatibility.Wrap( $"{notice.Title}: {notice.Message}", w - 70 ) )
		{
			Paint.DrawText( new Rect( pad, y, w - 70, 14 ), line, TextFlag.LeftCenter );
			y += 14;
		}
		if ( notice.Level != "error" )
			DrawSmallButton( new Rect( pad + w - 64, y - 16, 64, 18 ), "Dismiss", Color.White, "compat_dismiss", () => { EditorCompatibility.DismissBanner(); Update(); } );
		y += 6;
	}

	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 SyncToolConfig.FindPayloadSourceFiles( kind, directory ).ToArray();
	}

	/// <summary>Shows which server this window talks to and the last request, so a non-default Base URL is never silent.</summary>
	private static void DrawServerLines( float pad, float w, ref float y )
	{
		var baseUrl = SyncToolConfig.BaseUrl ?? "";
		var isHosted = baseUrl.Contains( "api.sboxcool.com", StringComparison.OrdinalIgnoreCase );

		Paint.SetDefaultFont( size: 9 );
		Paint.SetPen( isHosted ? Color.White.WithAlpha( 0.72f ) : Color.Orange.WithAlpha( 0.95f ) );
		Paint.DrawText( new Rect( pad, y, w, 14 ), isHosted ? $"Server: {baseUrl}" : $"Server: {baseUrl} (self-hosted)", TextFlag.LeftCenter );
		y += 18;

		// Game code refuses some hosts. This only warns; the Sync Tool itself still works.
		if ( SyncToolConfig.IsGameClientReachable( baseUrl, SyncToolConfig.ApiVersion ) == false )
		{
			Paint.SetDefaultFont( size: 8 );
			Paint.SetPen( Color.Orange.WithAlpha( 0.85f ) );
			Paint.DrawText( new Rect( pad, y, w, 12 ), SyncToolConfig.GameClientUnreachableLine1, TextFlag.LeftCenter );
			y += 12;
			Paint.DrawText( new Rect( pad, y, w, 12 ), SyncToolConfig.GameClientUnreachableLine2, TextFlag.LeftCenter );
			y += 14;
			Paint.SetDefaultFont( size: 9 );
		}

		if ( !string.IsNullOrEmpty( SyncToolApi.LastRequestSummary ) )
		{
			Paint.SetPen( Color.White.WithAlpha( 0.55f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), $"Last request: {SyncToolApi.LastRequestSummary}", TextFlag.LeftCenter );
			y += 18;
		}
	}

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

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

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

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

		// 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;
			DrawServerLines( pad, w, ref y );
			DrawCompatBanner( pad, w, ref y );

			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 ) && !IsBlockedLog( e ) );
			var blockedCount = _syncLog.Count( e => IsBlockedLog( 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 );
				sx += 90;
			}
			if ( blockedCount > 0 )
			{
				Paint.SetPen( Color.White.WithAlpha( 0.6f ) );
				Paint.DrawText( new Rect( sx, y, 110, 16 ), $"○ {blockedCount} not pushed", 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 = entry.Warn ? "⚠"
						: isMergeAvail ? "⇄"
						: entry.Ok ? ( isVerified ? "●" : "✓" )
						: ( isMismatch ? "▲" : "✗" );
					var iconColor = entry.Warn ? Color.Orange.WithAlpha( 0.9f )
						: 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 = entry.Warn ? Color.Orange.WithAlpha( 0.8f )
						: 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 );
					DrawOneLineText( new Rect( pad + 26 + nameW + 4, y, w - nameW - 100, 16 ), detailText, TextFlag.LeftCenter, entry.Name, $"synclog_{_syncLog.IndexOf( entry )}" );

					// 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;

		// Brand header (approved icon + short title, never clips at minimum width) + Push All + Test All
		NetworkStorageBrand.DrawIcon( pad, y, 22f );
		Paint.SetDefaultFont( size: 13, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad + 30f, y, w * 0.55f, 22 ), Title, 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;
			DrawServerLines( pad, w, ref y );
			DrawCompatBanner( pad, w, ref y );

			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 } );
	}

	/// <summary>
	/// The first line of <paramref name="text"/>, cut to <paramref name="maxWidth"/> with an ellipsis. Rows are one line
	/// high, so a multi-line message shows its first line and the full text opens on click.
	/// </summary>
	private static string FitToOneLine( string text, float maxWidth )
	{
		if ( string.IsNullOrEmpty( text ) ) return "";
		var trimmed = text.Trim();
		var lineEnd = trimmed.IndexOfAny( new[] { '\r', '\n' } );
		var line = lineEnd < 0 ? trimmed : trimmed[..lineEnd].TrimEnd() + " …";
		while ( line.Length > 1 && Paint.MeasureText( line ).x > maxWidth )
			line = line[..^2] + "…";
		return line;
	}

	/// <summary>Draws one line of text. When the full text does not fit, clicking the row opens it in a dialog.</summary>
	private void DrawOneLineText( Rect rect, string text, TextFlag flag, string title, string regionId )
	{
		var fullText = (text ?? "").Trim();
		var shown = FitToOneLine( fullText, rect.Width );
		Paint.DrawText( rect, shown, flag );
		if ( !_busy && shown != fullText )
			_buttons.Add( new ClickRegion { Rect = rect, Id = regionId, OnClick = () => MessageDialog.Show( title, fullText ) } );
	}

	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 ( pullAction != null && ( state.RemoteDiffers || _hasCheckedRemote && state.Status != SyncStatus.LocalOnly && state.Status != SyncStatus.Unknown ) )
		{
			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 ) );
				DrawOneLineText( new Rect( pad + 22, y, w - 30, 14 ), detailText, TextFlag.LeftCenter, mapping.CsFile, $"mapping_{mapping.Collection}" );
				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 OnContentMousePress( MouseEvent e )
	{
		base.OnContentMousePress(e);
		if ( _busy ) return;

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

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

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

	protected override void OnContentKeyPress( 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.OnContentKeyPress(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 = PreviewYaml( id, localJson );
		}
		if ( remoteJson != null )
		{
			state.RemoteJson = remoteJson;
			state.RemoteYaml = PreviewYaml( id, remoteJson );
		}
		if ( status.HasValue ) state.Status = status.Value;
		_items[id] = state;
	}

	/// <summary>Display-only YAML for a row. An endpoint shows its script as its own block, the way a pull writes it.</summary>
	private static string PreviewYaml( string id, string json ) =>
		id.StartsWith( "ep_", StringComparison.Ordinal )
			? SyncToolPullWriter.EndpointPreviewText( id[3..], json )
			: SyncToolYamlRenderer.RenderFromJson( json );

	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 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 );
						// The handler script is compared on its own. A matching remote sourceText must not hide a script
						// changed on the live definition, because that sourceText can be stale.
						var scriptMatches = SyncToolTransforms.EndpointScriptsMatch( localEp, ep );
						var differs = !scriptMatches || ( !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";
	}

	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;
		if ( EditorCompatibility.PushBlocked )
		{
			MessageDialog.Show( "Push All refused", EditorCompatibility.PushBlockedMessage, EditorCompatibility.PushBlockedSteps );
			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();

		// Script-file problems stop the whole push before anything is sent; rows are set in ReportPushBlocked.
		if ( !EndpointScriptsReadyForPush() )
		{
			_busy = false;
			_busyItem = null;
			ScrollToBottom();
			Update();
			return;
		}

		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 ) && !IsBlockedLog( e ) );
		var blockedCount = _syncLog.Count( e => IsBlockedLog( 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 && blockedCount == 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 || blockedCount > 0 )
			_status = $"Done: {okCount} pushed, {failCount} failed" + ( blockedCount > 0 ? $", {blockedCount} not pushed" : "" );
		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 ( SyncToolVerification.MatchEndpoint( slug, remoteEndpoint, localEpBySlug, localEpSourceTextBySlug ) != SyncToolVerification.Match.None )
				{
					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 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 );
				if ( ok )
					await SyncToolConfig.SyncEndpointTypingsAsync();
				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 );
					if ( ok )
						await SyncToolConfig.SyncEndpointTypingsAsync();
				}
				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
	// ──────────────────────────────────────────────────────

	/// <summary>
	/// Refuses a push while an endpoint's script file is missing, ambiguous, or unreadable. The problem is
	/// shown in the Sync window and nothing is sent.
	/// </summary>
	private bool EndpointScriptsReadyForPush()
	{
		var problems = SyncToolConfig.EndpointScriptProblems();
		if ( problems.Count == 0 )
			return true;

		var message = "Endpoint script files need fixing before push:\n" + string.Join( "\n", problems.Values );
		SyncToolApi.ReportLocalError( "endpoints", message );
		ShowSyncBatchFailureWindow( message, "LOCAL_SCRIPT_FILE" );
		ReportPushBlocked( problems );
		SetStatus( "Push blocked: endpoint script file problem", true );
		return false;
	}

	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 );
			if ( !resp.HasValue )
				return false;

			await SyncToolConfig.SyncEndpointTypingsAsync();
			return true;
		}
		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 );
				}

				// A refused batch names the endpoints with broken scripts. Those rows fail; the rest read "not pushed (blocked)".
				if ( SyncToolApi.LastErrorBody is JsonElement refusalBody && BlockPushForEndpointScripts( refusalBody, null ) )
					return (false, false, false);

				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());

			if ( hasEndpoints && epOk )
				await SyncToolConfig.SyncEndpointTypingsAsync();

			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 )
			{
				if ( SyncToolApi.LastErrorBody is JsonElement refusalBody && BlockPushForEndpointScripts( refusalBody, null ) )
					return (false, false);

				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);
	}

	/// <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;
			}

			var scriptError = SyncToolConfig.EndpointScriptError( localFile );
			if ( scriptError != null )
			{
				SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} has a script problem: {scriptError}" );
				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() ?? "" : "";
	}


	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