Editor/SyncToolPullWriter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;

public static class SyncToolPullWriter
{
	private static readonly JsonSerializerOptions YamlJsonOptions = new()
	{
		Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
	};

	public static bool SaveEndpoint( string slug, JsonElement remoteEndpoint )
	{
		var local = SyncToolTransforms.ServerEndpointToLocal( remoteEndpoint );
		return SaveResource(
			"endpoint",
			slug,
			remoteEndpoint,
			local );
	}

	public static bool SaveCollection( string name, JsonElement remoteCollection )
	{
		var local = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
		return SaveResource(
			"collection",
			name,
			remoteCollection,
			local );
	}

	public static bool SaveWorkflow( string id, JsonElement remoteWorkflow )
	{
		var local = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
		return SaveResource(
			"workflow",
			id,
			remoteWorkflow,
			local );
	}

	public static int SaveCollections( JsonElement serverResponse )
	{
		var data = serverResponse;
		if ( serverResponse.TryGetProperty( "data", out var d ) )
			data = d;
		if ( data.ValueKind != JsonValueKind.Array )
			return 0;

		var count = 0;
		foreach ( var collection in data.EnumerateArray() )
		{
			var local = SyncToolTransforms.ServerCollectionToLocal( collection );
			var name = local.TryGetValue( "name", out var value ) ? value?.ToString() : null;
			if ( string.IsNullOrWhiteSpace( name ) || name == "unknown" )
				continue;

			if ( SaveCollection( name, collection ) )
				count++;
		}

		return count;
	}

	private static bool SaveResource( string kind, string id, JsonElement remote, Dictionary<string, object> local )
	{
		var hasSource = SyncToolTransforms.TryGetSourceText( remote, out var sourceText );
		var sourcePath = SyncToolTransforms.GetSourcePath( remote );
		var hasCompiledRemoteView = SyncToolTransforms.TryGetCanonicalDefinition( remote, out _ )
			|| (remote.TryGetProperty( "definition", out var definition ) && definition.ValueKind == JsonValueKind.Object);

		// The pull preview is built from the compiled/canonical remote view. When the
		// API returns both sourceText and canonicalDefinition, sourceText can be stale
		// or semantically different from what the preview shows, so write the same
		// canonical data the user approved. Only preserve raw sourceText for older
		// responses that do not expose a compiled view.
		if ( hasSource && !hasCompiledRemoteView )
			SyncToolConfig.SaveSourceResource( kind, id, sourceText, sourcePath );
		else
			WriteSource( kind, id, local, sourcePath );

		return true;
	}

	public static void WriteSource( string kind, string id, Dictionary<string, object> data, string sourcePath = null )
	{
		if ( !string.IsNullOrWhiteSpace( sourcePath ) )
		{
			SyncToolConfig.SaveSourceResource( kind, id, BuildSourceText( kind, id, data ), sourcePath );
			return;
		}

		var folder = kind switch
		{
			"collection" => SyncToolConfig.CollectionsPath,
			"endpoint" => SyncToolConfig.EndpointsPath,
			"workflow" => SyncToolConfig.WorkflowsPath,
			"test" => SyncToolConfig.TestsPath,
			_ => SyncToolConfig.SyncToolsPath
		};
		var absoluteFolder = SyncToolConfig.Abs( folder );
		var path = FindExistingSourcePath( absoluteFolder, kind, id )
			?? Path.Combine( absoluteFolder, $"{id}.{kind}.yml" );
		var dir = Path.GetDirectoryName( path );
		if ( !Directory.Exists( dir ) )
			Directory.CreateDirectory( dir );

		File.WriteAllText( path, BuildSourceText( kind, id, data ) );
	}

	private static string FindExistingSourcePath( string absoluteFolder, string kind, string id )
	{
		if ( !Directory.Exists( absoluteFolder ) )
			return null;

		return Directory.GetFiles( absoluteFolder, $"*.{kind}.yml" )
			.Concat( Directory.GetFiles( absoluteFolder, $"*.{kind}.yaml" ) )
			.FirstOrDefault( path => string.Equals( SyncToolConfig.ResourceIdFromFilePath( path, kind ), id, StringComparison.OrdinalIgnoreCase ) );
	}

	private static string BuildSourceText( string kind, string id, Dictionary<string, object> data )
	{
		var topLevelKeys = kind switch
		{
			"collection" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" },
			"endpoint" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "slug", "name", "description", "notes" },
			"workflow" => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" },
			_ => new HashSet<string>( StringComparer.OrdinalIgnoreCase ) { "id", "name", "description", "notes" }
		};
		var body = new Dictionary<string, object>( StringComparer.OrdinalIgnoreCase );
		foreach ( var pair in data )
		{
			if ( !topLevelKeys.Contains( pair.Key ) && !pair.Key.StartsWith( "_", StringComparison.Ordinal ) )
				body[pair.Key] = pair.Value;
		}

		var header = new List<string>
		{
			"sourceVersion: 1",
			$"kind: {kind}",
			$"id: {id}"
		};
		foreach ( var key in new[] { "name", "description", "notes" } )
		{
			if ( data.TryGetValue( key, out var value ) && value != null )
				header.Add( $"{key}: {JsonSerializer.Serialize( value.ToString() )}" );
		}

		var lines = new List<string>( header );
		if ( body.Count > 0 )
			AppendYamlMapping( lines, body, 0 );
		lines.Add( "" );
		return string.Join( "\n", lines );
	}

	private static void AppendYamlMapping( List<string> lines, IDictionary<string, object> values, int indentLevel )
	{
		if ( values.Count == 0 )
		{
			lines.Add( $"{Indent( indentLevel )}{{}}" );
			return;
		}

		foreach ( var pair in values )
		{
			AppendYamlProperty( lines, pair.Key, pair.Value, indentLevel );
		}
	}

	private static void AppendYamlProperty( List<string> lines, string key, object value, int indentLevel )
	{
		var indent = Indent( indentLevel );
		var yamlKey = FormatYamlKey( key );
		var normalized = NormalizeYamlValue( value );
		switch ( normalized )
		{
			case Dictionary<string, object> dict when dict.Count == 0:
				lines.Add( $"{indent}{yamlKey}: {{}}" );
				return;
			case Dictionary<string, object> dict:
				lines.Add( $"{indent}{yamlKey}:" );
				AppendYamlMapping( lines, dict, indentLevel + 1 );
				return;
			case List<object> list when list.Count == 0:
				lines.Add( $"{indent}{yamlKey}: []" );
				return;
			case List<object> list:
				lines.Add( $"{indent}{yamlKey}:" );
				AppendYamlList( lines, list, indentLevel + 1 );
				return;
			default:
				lines.Add( $"{indent}{yamlKey}: {FormatYamlScalar( normalized )}" );
				return;
		}
	}

	private static void AppendYamlList( List<string> lines, List<object> items, int indentLevel )
	{
		foreach ( var item in items )
		{
			AppendYamlListItem( lines, item, indentLevel );
		}
	}

	private static void AppendYamlListItem( List<string> lines, object item, int indentLevel )
	{
		var indent = Indent( indentLevel );
		var normalized = NormalizeYamlValue( item );
		switch ( normalized )
		{
			case Dictionary<string, object> dict when dict.Count == 0:
				lines.Add( $"{indent}- {{}}" );
				return;
			case Dictionary<string, object> dict:
				lines.Add( $"{indent}-" );
				AppendYamlMapping( lines, dict, indentLevel + 1 );
				return;
			case List<object> list when list.Count == 0:
				lines.Add( $"{indent}- []" );
				return;
			case List<object> list:
				lines.Add( $"{indent}-" );
				AppendYamlList( lines, list, indentLevel + 1 );
				return;
			default:
				lines.Add( $"{indent}- {FormatYamlScalar( normalized )}" );
				return;
		}
	}

	private static object NormalizeYamlValue( object value )
	{
		if ( value is null )
			return null;

		if ( value is JsonElement element )
		{
			return element.ValueKind switch
			{
				JsonValueKind.Object => element.EnumerateObject()
					.ToDictionary( prop => prop.Name, prop => NormalizeYamlValue( prop.Value ), StringComparer.OrdinalIgnoreCase ),
				JsonValueKind.Array => element.EnumerateArray().Select( item => NormalizeYamlValue( item ) ).ToList(),
				JsonValueKind.String => element.GetString(),
				JsonValueKind.Number when element.TryGetInt64( out var l ) => l,
				JsonValueKind.Number when element.TryGetDouble( out var d ) => d,
				JsonValueKind.True => true,
				JsonValueKind.False => false,
				_ => null
			};
		}

		if ( value is Dictionary<string, object> dict )
			return dict.ToDictionary( pair => pair.Key, pair => NormalizeYamlValue( pair.Value ), StringComparer.OrdinalIgnoreCase );

		if ( value is IDictionary<string, object> otherDict )
			return otherDict.ToDictionary( pair => pair.Key, pair => NormalizeYamlValue( pair.Value ), StringComparer.OrdinalIgnoreCase );

		if ( value is IEnumerable<object> sequence && value is not string )
			return sequence.Select( item => NormalizeYamlValue( item ) ).ToList();

		return value;
	}

	private static string FormatYamlKey( string key )
	{
		return key.All( ch => char.IsLetterOrDigit( ch ) || ch == '_' || ch == '-' || ch == '.' )
			? key
			: JsonSerializer.Serialize( key, YamlJsonOptions );
	}

	private static string FormatYamlScalar( object value )
	{
		return value switch
		{
			null => "null",
			bool b => b ? "true" : "false",
			sbyte n => n.ToString( CultureInfo.InvariantCulture ),
			byte n => n.ToString( CultureInfo.InvariantCulture ),
			short n => n.ToString( CultureInfo.InvariantCulture ),
			ushort n => n.ToString( CultureInfo.InvariantCulture ),
			int n => n.ToString( CultureInfo.InvariantCulture ),
			uint n => n.ToString( CultureInfo.InvariantCulture ),
			long n => n.ToString( CultureInfo.InvariantCulture ),
			ulong n => n.ToString( CultureInfo.InvariantCulture ),
			float n => n.ToString( "R", CultureInfo.InvariantCulture ),
			double n => n.ToString( "R", CultureInfo.InvariantCulture ),
			decimal n => n.ToString( CultureInfo.InvariantCulture ),
			_ => JsonSerializer.Serialize( value.ToString(), YamlJsonOptions )
		};
	}

	private static string Indent( int indentLevel ) => new( ' ', indentLevel * 2 );
}