Editor/CodeGenerator.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using Sandbox;
using Editor;

/// <summary>
/// Generates strongly-typed C# files from Network Storage collection schemas,
/// endpoint definitions, workflow IDs, and project configuration.
///
/// Output goes to Code/Data/NetworkStorage/ in the game project, with filenames
/// prefixed "autoGenerated_" to make it clear they are machine-written.
///
/// Called automatically after Push All in the Sync Tool, or manually via
/// the Settings window.
/// </summary>
public static class CodeGenerator
{
	private const string OutputFolder = "Code/Data/NetworkStorage";

	private const string FileHeader =
		"// <auto-generated>\n" +
		"//   Auto-generated by Network Storage Sync Tool — DO NOT EDIT.\n" +
		"//   Any changes will be overwritten on next sync.\n" +
		"//\n" +
		"//   To modify, edit the source files in Editor/Network Storage/ and use\n" +
		"//   Editor → Network Storage → Sync Tool to push changes and regenerate.\n" +
		"// </auto-generated>\n\n";

	private sealed class EndpointMeta
	{
		public string Slug;
		public string Name;
		public string Description;
		public string Method = "POST";
		public bool Enabled = true;
		public JsonElement? Element;
	}

	private sealed class WorkflowMeta
	{
		public string Id;
		public string Description;
		public JsonElement? Element;
	}

	// ──────────────────────────────────────────────────────
	//  Public API
	// ──────────────────────────────────────────────────────

	/// <summary>
	/// Generate all auto-generated C# files from the current local schemas,
	/// and update the runtime credentials file so the game client can auto-configure.
	/// Returns the number of files written.
	/// </summary>
	public static int Generate()
	{
		var dir = SyncToolConfig.Abs( OutputFolder );
		if ( !Directory.Exists( dir ) )
			Directory.CreateDirectory( dir );

		// Clean previous auto-generated files
		foreach ( var f in Directory.GetFiles( dir, "autoGenerated_*.cs" ) )
			File.Delete( f );

		int count = 0;
		count += WriteReadme( dir ) ? 1 : 0;
		count += WriteConfig( dir ) ? 1 : 0;
		count += WriteCollections( dir ) ? 1 : 0;
		count += WriteEndpoints( dir ) ? 1 : 0;
		count += WriteWorkflows( dir ) ? 1 : 0;

		// Keep the runtime credentials file in sync so NetworkStorage.AutoConfigure() works
		WriteRuntimeCredentials();

		return count;
	}

	/// <summary>
	/// Update Assets/network-storage.credentials.json with current SyncToolConfig values.
	/// Merges into the existing file rather than overwriting — unknown fields (e.g. proxyEnabled)
	/// set by the Settings window or other tools are preserved.
	/// </summary>
	private static void WriteRuntimeCredentials()
	{
		if ( string.IsNullOrEmpty( SyncToolConfig.ProjectId ) ) return;

		var path = SyncToolConfig.Abs( SyncToolConfig.RuntimeCredentialsFile );

		// Ensure Assets/ directory exists
		var assetsDir = Path.GetDirectoryName( path );
		if ( !Directory.Exists( assetsDir ) )
			Directory.CreateDirectory( assetsDir );

		// Read existing file to preserve fields we don't own (e.g. proxyEnabled)
		var merged = new Dictionary<string, object>();
		if ( File.Exists( path ) )
		{
			try
			{
				var existing = JsonSerializer.Deserialize<JsonElement>( File.ReadAllText( path ) );
				foreach ( var prop in existing.EnumerateObject() )
				{
					merged[prop.Name] = prop.Value.ValueKind switch
					{
						JsonValueKind.True => (object)true,
						JsonValueKind.False => false,
						JsonValueKind.Number => prop.Value.TryGetInt64( out var l ) ? l : prop.Value.GetDouble(),
						_ => prop.Value.GetString() ?? ""
					};
				}
			}
			catch { /* Corrupt file — start fresh */ }
		}

		// Overwrite only the fields this generator owns
		merged["projectId"] = SyncToolConfig.ProjectId;
		merged["publicKey"] = SyncToolConfig.PublicApiKey;
		merged["baseUrl"] = SyncToolConfig.BaseUrl;
		merged["cdnUrl"] = SyncToolConfig.CdnUrl ?? "";
		merged["apiVersion"] = SyncToolConfig.ApiVersion;
		merged["enableAuthSessions"] = SyncToolConfig.EnableAuthSessions;
		merged["enableEncryptedRequests"] = SyncToolConfig.EnableEncryptedRequests;
		merged["publishTarget"] = SyncToolConfig.PublishTarget;

		File.WriteAllText( path, JsonSerializer.Serialize( merged, new JsonSerializerOptions { WriteIndented = true } ) );
	}

	// ──────────────────────────────────────────────────────
	//  Readme
	// ──────────────────────────────────────────────────────

	private static bool WriteReadme( string dir )
	{
		var path = Path.Combine( dir, "Readme.txt" );
		var sb = new StringBuilder();

		sb.AppendLine( "================================================================================" );
		sb.AppendLine( "  Network Storage — Auto-Generated Code" );
		sb.AppendLine( "================================================================================" );
		sb.AppendLine();
		sb.AppendLine( "  DO NOT EDIT the autoGenerated_*.cs files in this folder." );
		sb.AppendLine( "  They are regenerated every time you push from the Sync Tool." );
		sb.AppendLine();
		sb.AppendLine( "  HOW TO MAKE CHANGES" );
		sb.AppendLine( "  —————————-" );
		sb.AppendLine();
		sb.AppendLine( "  1. Edit the YAML source files in your project's Editor/Network Storage/ folder:" );
		sb.AppendLine();
		sb.AppendLine( "       source files: *.collection.yml, *.endpoint.yml, *.workflow.yml" );
		sb.AppendLine( "       .yml is accepted as an alias and is the preferred local format for this project" );
		sb.AppendLine( "       legacy resource JSON is unsupported; use YAML source only" );
		sb.AppendLine( "       config/             — API credentials and project settings" );
		sb.AppendLine();
		sb.AppendLine( "  2. Push changes to the backend and regenerate this folder:" );
		sb.AppendLine();
		sb.AppendLine( "       Editor → Network Storage → Sync Tool → Push All" );
		sb.AppendLine();
		sb.AppendLine( "     Or regenerate code without pushing:" );
		sb.AppendLine();
		sb.AppendLine( "       Editor → Network Storage → Generate Code" );
		sb.AppendLine();
		sb.AppendLine( "  ARCHITECTURE" );
		sb.AppendLine( "  ——————" );
		sb.AppendLine();
		sb.AppendLine( "  Editor/Network Storage/       — Full backend configuration. Stored in your" );
		sb.AppendLine( "                                  project repository for syncing with the" );
		sb.AppendLine( "                                  Network Storage API. Contains endpoint logic," );
		sb.AppendLine( "                                  collection schemas, validation workflows, and" );
		sb.AppendLine( "                                  secret keys. Editor-only — this folder is" );
		sb.AppendLine( "                                  excluded from the published game." );
		sb.AppendLine();
		sb.AppendLine( "  Code/Data/NetworkStorage/     — Game bundle code. Auto-generated typed" );
		sb.AppendLine( "                                  references to your backend schema. Ships with" );
		sb.AppendLine( "                                  the published game so runtime code can use" );
		sb.AppendLine( "                                  constants and types instead of magic strings." );
		sb.AppendLine( "                                  Read-only — regenerated on every sync." );
		sb.AppendLine();
		sb.AppendLine( "  USAGE EXAMPLES" );
		sb.AppendLine( "  ———————" );
		sb.AppendLine();
		sb.AppendLine( "  // Configure the client at startup (auto-generated credentials)" );
		sb.AppendLine( "  NSConfig.EnsureConfigured();" );
		sb.AppendLine();
		sb.AppendLine( "  // Call endpoints with typed slugs instead of magic strings" );
		sb.AppendLine( "  var data = await NetworkStorage.CallEndpoint( NSEndpoints.LoadPlayer );" );
		sb.AppendLine();
		sb.AppendLine( "  // Reference input fields by name" );
		sb.AppendLine( "  var result = await NetworkStorage.CallEndpoint( NSEndpoints.SellOre," );
		sb.AppendLine( "      new { ore_id = \"moon_ore\", kg = 10.0, faction_id = \"avery_exchange\" } );" );
		sb.AppendLine();
		sb.AppendLine( "  // Access collection field paths for queries" );
		sb.AppendLine( "  string field = PlayerDataSchema.Ores.MoonOre;  // \"ores.moon_ore\"" );
		sb.AppendLine();
		sb.AppendLine( "  // Read game constants" );
		sb.AppendLine( "  int startingQC = GameValuesSchema.Progression.StartingCurrency_Value;  // 500" );
		sb.AppendLine();
		sb.AppendLine( "================================================================================" );

		File.WriteAllText( path, sb.ToString() );
		return true;
	}

	// ──────────────────────────────────────────────────────
	//  Config
	// ──────────────────────────────────────────────────────

	private static bool WriteConfig( string dir )
	{
		if ( string.IsNullOrEmpty( SyncToolConfig.ProjectId ) ) return false;

		var proxyEnabledLiteral = SyncToolConfig.ProxyEnabled ? "true" : "false";
		var authSessionsLiteral = SyncToolConfig.EnableAuthSessions ? "true" : "false";
		var encryptedRequestsLiteral = SyncToolConfig.EnableEncryptedRequests ? "true" : "false";

		var sb = new StringBuilder();
		sb.Append( FileHeader );
		sb.AppendLine( "namespace Sandbox;" );
		sb.AppendLine();
		sb.AppendLine( "/// <summary>" );
		sb.AppendLine( "/// Network Storage project configuration — auto-generated from Editor → Network Storage → Sync Tool." );
		sb.AppendLine( "/// The public key (sbox_ns_ prefix) is safe to embed — it is NOT a secret." );
		sb.AppendLine( "/// Runtime settings (e.g. ProxyEnabled) are controlled via Editor → Network Storage → Settings." );
		sb.AppendLine( "/// </summary>" );
		sb.AppendLine( "public static class NSConfig" );
		sb.AppendLine( "{" );
		sb.AppendLine( $"\tpublic const string ProjectId = \"{Escape( SyncToolConfig.ProjectId )}\";" );
		sb.AppendLine( $"\tpublic const string PublicKey = \"{Escape( SyncToolConfig.PublicApiKey )}\";" );
		sb.AppendLine( $"\tpublic const string BaseUrl = \"{Escape( SyncToolConfig.BaseUrl )}\";" );
		sb.AppendLine( $"\tpublic const string ApiVersion = \"{Escape( SyncToolConfig.ApiVersion )}\";" );
		sb.AppendLine( $"\tpublic const string PublishTarget = \"{Escape( SyncToolConfig.PublishTarget )}\";" );
		sb.AppendLine();
		sb.AppendLine( "\t/// <summary>" );
		sb.AppendLine( "\t/// Whether the game host proxies Network Storage API calls for non-host clients." );
		sb.AppendLine( "\t/// Enable for editor/local testing. Disable in production when every player has their own Steam account." );
		sb.AppendLine( "\t/// Controlled via Editor → Network Storage → Settings." );
		sb.AppendLine( "\t/// </summary>" );
		sb.AppendLine( $"\tpublic const bool ProxyEnabled = {proxyEnabledLiteral};" );
		sb.AppendLine();
		sb.AppendLine( "\t/// <summary>Whether this project is configured to use fast auth sessions.</summary>" );
		sb.AppendLine( $"\tpublic const bool EnableAuthSessions = {authSessionsLiteral};" );
		sb.AppendLine();
		sb.AppendLine( "\t/// <summary>Whether this project is configured to send encrypted Network Storage requests.</summary>" );
		sb.AppendLine( $"\tpublic const bool EnableEncryptedRequests = {encryptedRequestsLiteral};" );
		sb.AppendLine();
		sb.AppendLine( "\t/// <summary>Configure the NetworkStorage runtime client. Delegates to NetworkStorage.EnsureConfigured().</summary>" );
		sb.AppendLine( "\tpublic static void EnsureConfigured() => NetworkStorage.EnsureConfigured();" );
		sb.AppendLine( "}" );

		File.WriteAllText( Path.Combine( dir, "autoGenerated_Config.cs" ), sb.ToString() );
		return true;
	}

	// ──────────────────────────────────────────────────────
	//  Collections
	// ──────────────────────────────────────────────────────

	private static bool WriteCollections( string dir )
	{
		var collections = SyncToolConfig.LoadCollections();
		MergeSourceCollectionSummaries( collections );
		if ( collections.Count == 0 ) return false;

		var sb = new StringBuilder();
		sb.Append( FileHeader );
		sb.AppendLine( "namespace Sandbox;" );

		foreach ( var (name, data) in collections )
		{
			sb.AppendLine();

			var json = JsonSerializer.Serialize( data );
			var root = JsonSerializer.Deserialize<JsonElement>( json );

			var description = GetString( root, "description" ) ?? name;
			var scope = GetString( root, "collectionType" ) ?? "unknown";
			var access = GetString( root, "accessMode" ) ?? "unknown";
			var className = ToPascalCase( name ) + "Schema";

			sb.AppendLine( $"/// <summary>{XmlEscape( description )}</summary>" );
			sb.AppendLine( $"/// <remarks>Collection: {name} | Scope: {scope} | Access: {access}</remarks>" );
			sb.AppendLine( $"public static class {className}" );
			sb.AppendLine( "{" );
			sb.AppendLine( $"\tpublic const string CollectionName = \"{Escape( name )}\";" );
			sb.AppendLine( $"\tpublic const string Scope = \"{Escape( scope )}\";" );
			sb.AppendLine( $"\tpublic const string AccessMode = \"{Escape( access )}\";" );

			// Schema properties
			if ( root.TryGetProperty( "schema", out var schema )
				&& schema.TryGetProperty( "properties", out var props ) )
			{
				sb.AppendLine();
				EmitSchemaProperties( sb, props, "\t", "" );
			}

			// Constants (game_values style)
			if ( root.TryGetProperty( "constants", out var constants )
				&& constants.ValueKind == JsonValueKind.Array )
			{
				sb.AppendLine();
				sb.AppendLine( "\t// ── Constants ──" );
				EmitConstants( sb, constants, "\t" );
			}

			// Tables (game_values style)
			if ( root.TryGetProperty( "tables", out var tables )
				&& tables.ValueKind == JsonValueKind.Array )
			{
				sb.AppendLine();
				sb.AppendLine( "\t// ── Tables ──" );
				sb.AppendLine();
				sb.AppendLine( "\t/// <summary>Table definitions with column key constants.</summary>" );
				sb.AppendLine( "\tpublic static class Tables" );
				sb.AppendLine( "\t{" );
				EmitTables( sb, tables, "\t\t" );
				sb.AppendLine( "\t}" );
			}

			sb.AppendLine( "}" );
		}

		File.WriteAllText( Path.Combine( dir, "autoGenerated_Collections.cs" ), sb.ToString() );
		return true;
	}

	private static void MergeSourceCollectionSummaries( List<(string Name, Dictionary<string, object> Data)> collections )
	{
		var existing = new HashSet<string>( collections.Select( c => c.Name ), StringComparer.OrdinalIgnoreCase );
		foreach ( var summary in SyncToolConfig.LoadSourceSummaries( "collection" ) )
		{
			if ( string.IsNullOrWhiteSpace( summary.Id ) || existing.Contains( summary.Id ) )
				continue;

			collections.Add( (summary.Id, new Dictionary<string, object>
			{
				["name"] = summary.Id,
				["description"] = string.IsNullOrWhiteSpace( summary.Description ) ? summary.Id : summary.Description,
				["collectionType"] = "source",
				["accessMode"] = "unknown",
				["schema"] = new Dictionary<string, object>()
			}) );
			existing.Add( summary.Id );
		}
	}

	private static void EmitSchemaProperties( StringBuilder sb, JsonElement props, string indent, string pathPrefix )
	{
		foreach ( var prop in props.EnumerateObject() )
		{
			var key = prop.Name;
			var val = prop.Value;
			var fullPath = string.IsNullOrEmpty( pathPrefix ) ? key : $"{pathPrefix}.{key}";
			var type = GetString( val, "type" ) ?? "unknown";

			if ( type == "object" && val.TryGetProperty( "properties", out var nested ) )
			{
				// Nested object → inner static class
				sb.AppendLine();
				sb.AppendLine( $"{indent}/// <summary>{XmlEscape( ToPascalCase( key ) )} fields.</summary>" );
				sb.AppendLine( $"{indent}public static class {ToPascalCase( key )}" );
				sb.AppendLine( $"{indent}{{" );
				EmitSchemaProperties( sb, nested, indent + "\t", fullPath );
				sb.AppendLine( $"{indent}}}" );
			}
			else
			{
				// Leaf property → const string
				var doc = BuildTypeDoc( val );
				sb.AppendLine( $"{indent}/// <summary>{XmlEscape( doc )}</summary>" );
				sb.AppendLine( $"{indent}public const string {ToPascalCase( key )} = \"{Escape( fullPath )}\";" );
			}
		}
	}

	private static void EmitConstants( StringBuilder sb, JsonElement constants, string indent )
	{
		foreach ( var group in constants.EnumerateArray() )
		{
			var id = GetString( group, "id" ) ?? "unknown";
			var name = GetString( group, "name" ) ?? id;

			sb.AppendLine();
			sb.AppendLine( $"{indent}/// <summary>{XmlEscape( name )}</summary>" );
			sb.AppendLine( $"{indent}public static class {ToPascalCase( id )}" );
			sb.AppendLine( $"{indent}{{" );

			if ( group.TryGetProperty( "entries", out var entries ) )
			{
				foreach ( var entry in entries.EnumerateObject() )
				{
					var constName = ToPascalCase( entry.Name );
					var val = entry.Value;

					if ( val.ValueKind == JsonValueKind.Number )
					{
						// Determine int vs double
						if ( val.TryGetDouble( out var dbl ) )
						{
							bool isInt = dbl == Math.Floor( dbl ) && Math.Abs( dbl ) < int.MaxValue;
							var valueStr = isInt ? ((int)dbl).ToString() : dbl.ToString( "G" );
							var typeName = isInt ? "int" : "double";
							var suffix = isInt ? "" : "";

							sb.AppendLine( $"{indent}\t/// <summary>Key: {entry.Name} | Default: {valueStr}</summary>" );
							sb.AppendLine( $"{indent}\tpublic const string {constName} = \"{Escape( entry.Name )}\";" );

							if ( isInt )
								sb.AppendLine( $"{indent}\tpublic const int {constName}_Value = {(int)dbl};" );
							else
								sb.AppendLine( $"{indent}\tpublic const double {constName}_Value = {dbl:G};" );
						}
					}
					else if ( val.ValueKind == JsonValueKind.String )
					{
						var strVal = val.GetString() ?? "";
						sb.AppendLine( $"{indent}\t/// <summary>Key: {entry.Name} | Default: \"{XmlEscape( strVal )}\"</summary>" );
						sb.AppendLine( $"{indent}\tpublic const string {constName} = \"{Escape( entry.Name )}\";" );
						sb.AppendLine( $"{indent}\tpublic const string {constName}_Value = \"{Escape( strVal )}\";" );
					}
				}
			}

			sb.AppendLine( $"{indent}}}" );
		}
	}

	private static void EmitTables( StringBuilder sb, JsonElement tables, string indent )
	{
		bool first = true;
		foreach ( var table in tables.EnumerateArray() )
		{
			var id = GetString( table, "id" ) ?? "unknown";
			var name = GetString( table, "name" ) ?? id;
			var desc = GetString( table, "description" );

			if ( !first ) sb.AppendLine();
			first = false;

			var colCount = 0;
			var rowCount = 0;

			if ( table.TryGetProperty( "columns", out var cols ) )
				colCount = cols.GetArrayLength();
			if ( table.TryGetProperty( "rows", out var rows ) )
				rowCount = rows.GetArrayLength();

			var summary = $"{XmlEscape( name )} — {colCount} columns, {rowCount} rows";
			if ( desc != null ) summary += $". {XmlEscape( desc )}";

			sb.AppendLine( $"{indent}/// <summary>{summary}</summary>" );
			sb.AppendLine( $"{indent}public static class {ToPascalCase( id )}" );
			sb.AppendLine( $"{indent}{{" );
			sb.AppendLine( $"{indent}\tpublic const string TableId = \"{Escape( id )}\";" );
			sb.AppendLine( $"{indent}\tpublic const int RowCount = {rowCount};" );

			if ( table.TryGetProperty( "columns", out var columns ) )
			{
				sb.AppendLine();
				foreach ( var col in columns.EnumerateArray() )
				{
					var colKey = GetString( col, "key" ) ?? "unknown";
					var colType = GetString( col, "type" ) ?? "unknown";
					sb.AppendLine( $"{indent}\t/// <summary>Column type: {colType}</summary>" );
					sb.AppendLine( $"{indent}\tpublic const string Col_{ToPascalCase( colKey )} = \"{Escape( colKey )}\";" );
				}
			}

			sb.AppendLine( $"{indent}}}" );
		}
	}

	// ──────────────────────────────────────────────────────
	//  Endpoints
	// ──────────────────────────────────────────────────────

	private static bool WriteEndpoints( string dir )
	{
		var sorted = LoadEndpointMetadata();
		if ( sorted.Count == 0 ) return false;

		var sb = new StringBuilder();
		sb.Append( FileHeader );
		sb.AppendLine( "namespace Sandbox;" );
		sb.AppendLine();
		sb.AppendLine( "/// <summary>" );
		sb.AppendLine( "/// Network Storage endpoint slugs." );
		sb.AppendLine( "/// Use these constants with NetworkStorage.CallEndpoint() instead of raw strings." );
		sb.AppendLine( "/// </summary>" );
		sb.AppendLine( "public static class NSEndpoints" );
		sb.AppendLine( "{" );

		// Slug constants
		foreach ( var endpoint in sorted )
		{
			var docParts = new List<string>();
			if ( !string.IsNullOrWhiteSpace( endpoint.Description ) )
				docParts.Add( XmlEscape( endpoint.Description ) );
			docParts.Add( $"Method: {endpoint.Method}" );
			if ( !endpoint.Enabled ) docParts.Add( "DISABLED" );

			sb.AppendLine( $"\t/// <summary>{string.Join( " | ", docParts )}</summary>" );
			sb.AppendLine( $"\tpublic const string {ToPascalCase( endpoint.Slug )} = \"{Escape( endpoint.Slug )}\";" );
			sb.AppendLine();
		}

		// Input schemas
		var endpointsWithInput = sorted
			.Where( x => x.Element.HasValue && HasInputProperties( x.Element.Value ) )
			.ToList();
		if ( endpointsWithInput.Count > 0 )
		{
			sb.AppendLine( "\t/// <summary>Input field keys for endpoints that accept POST data.</summary>" );
			sb.AppendLine( "\tpublic static class Input" );
			sb.AppendLine( "\t{" );

			bool first = true;
			foreach ( var endpoint in endpointsWithInput )
			{
				if ( !first ) sb.AppendLine();
				first = false;

				var ep = endpoint.Element.Value;
				var inputProps = ep.GetProperty( "input" ).GetProperty( "properties" );
				var required = new HashSet<string>();
				if ( ep.GetProperty( "input" ).TryGetProperty( "required", out var req )
					&& req.ValueKind == JsonValueKind.Array )
				{
					foreach ( var r in req.EnumerateArray() )
						if ( r.GetString() is string rs ) required.Add( rs );
				}

				var name = endpoint.Name;
				sb.AppendLine( $"\t\t/// <summary>{XmlEscape( name )} input fields.</summary>" );
				sb.AppendLine( $"\t\tpublic static class {ToPascalCase( endpoint.Slug )}" );
				sb.AppendLine( "\t\t{" );

				foreach ( var field in inputProps.EnumerateObject() )
				{
					var doc = BuildTypeDoc( field.Value );
					if ( required.Contains( field.Name ) ) doc += " (required)";

					sb.AppendLine( $"\t\t\t/// <summary>{XmlEscape( doc )}</summary>" );
					sb.AppendLine( $"\t\t\tpublic const string {ToPascalCase( field.Name )} = \"{Escape( field.Name )}\";" );
				}

				sb.AppendLine( "\t\t}" );
			}

			sb.AppendLine( "\t}" );
		}

		sb.AppendLine( "}" );

		File.WriteAllText( Path.Combine( dir, "autoGenerated_Endpoints.cs" ), sb.ToString() );
		return true;
	}

	// ──────────────────────────────────────────────────────
	//  Workflows
	// ──────────────────────────────────────────────────────

	private static List<EndpointMeta> LoadEndpointMetadata()
	{
		var items = SyncToolConfig.LoadEndpoints()
			.Select( ep =>
			{
				var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() ?? "" : "";
				if ( string.IsNullOrWhiteSpace( slug ) )
					return null;

				var enabled = true;
				if ( ep.TryGetProperty( "enabled", out var en ) && en.ValueKind == JsonValueKind.False )
					enabled = false;

				return new EndpointMeta
				{
					Slug = slug,
					Name = GetString( ep, "name" ) ?? slug,
					Description = GetString( ep, "description" ),
					Method = GetString( ep, "method" ) ?? "POST",
					Enabled = enabled,
					Element = ep
				};
			} )
			.Where( item => item != null )
			.ToDictionary( item => item.Slug, item => item, StringComparer.OrdinalIgnoreCase );

		foreach ( var source in SyncToolConfig.LoadSourceSummaries( "endpoint" ) )
		{
			if ( string.IsNullOrWhiteSpace( source.Id ) || items.ContainsKey( source.Id ) )
				continue;

			items[source.Id] = new EndpointMeta
			{
				Slug = source.Id,
				Name = string.IsNullOrWhiteSpace( source.Name ) ? source.Id : source.Name,
				Description = source.Description,
				Method = "POST",
				Enabled = true
			};
		}

		return items.Values
			.OrderBy( item => item.Slug, StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	private static bool WriteWorkflows( string dir )
	{
		var sorted = LoadWorkflowMetadata();
		if ( sorted.Count == 0 ) return false;

		var sb = new StringBuilder();
		sb.Append( FileHeader );
		sb.AppendLine( "namespace Sandbox;" );
		sb.AppendLine();
		sb.AppendLine( "/// <summary>" );
		sb.AppendLine( "/// Reusable Network Storage workflow IDs." );
		sb.AppendLine( "/// Referenced in endpoint steps for shared validation and computation logic." );
		sb.AppendLine( "/// </summary>" );
		sb.AppendLine( "public static class NSWorkflows" );
		sb.AppendLine( "{" );

		foreach ( var workflow in sorted )
		{
			if ( !string.IsNullOrWhiteSpace( workflow.Description ) )
				sb.AppendLine( $"\t/// <summary>{XmlEscape( workflow.Description )}</summary>" );
			sb.AppendLine( $"\tpublic const string {ToPascalCase( workflow.Id )} = \"{Escape( workflow.Id )}\";" );
			sb.AppendLine();
		}

		sb.AppendLine( "}" );

		File.WriteAllText( Path.Combine( dir, "autoGenerated_Workflows.cs" ), sb.ToString() );
		return true;
	}

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

	private static List<WorkflowMeta> LoadWorkflowMetadata()
	{
		var items = SyncToolConfig.LoadWorkflows()
			.Select( wf =>
			{
				var id = wf.TryGetProperty( "id", out var i ) ? i.GetString() ?? "" : "";
				if ( string.IsNullOrWhiteSpace( id ) )
					return null;

				return new WorkflowMeta
				{
					Id = id,
					Description = GetString( wf, "description" ),
					Element = wf
				};
			} )
			.Where( item => item != null )
			.GroupBy( item => item.Id, StringComparer.OrdinalIgnoreCase )
			.ToDictionary( group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase );

		foreach ( var source in SyncToolConfig.LoadSourceSummaries( "workflow" ) )
		{
			if ( string.IsNullOrWhiteSpace( source.Id ) || items.ContainsKey( source.Id ) )
				continue;

			items[source.Id] = new WorkflowMeta
			{
				Id = source.Id,
				Description = source.Description
			};
		}

		return items.Values
			.OrderBy( item => item.Id, StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	/// <summary>Convert snake_case or kebab-case to PascalCase.</summary>
	private static string ToPascalCase( string input )
	{
		if ( string.IsNullOrEmpty( input ) ) return "Unknown";

		var sb = new StringBuilder();
		bool capitalizeNext = true;

		foreach ( var ch in input )
		{
			if ( ch == '_' || ch == '-' || ch == '.' )
			{
				capitalizeNext = true;
				continue;
			}

			sb.Append( capitalizeNext ? char.ToUpperInvariant( ch ) : ch );
			capitalizeNext = false;
		}

		var result = sb.ToString();

		// Ensure it starts with a letter (C# identifier requirement)
		if ( result.Length > 0 && char.IsDigit( result[0] ) )
			result = "_" + result;

		return result;
	}

	/// <summary>Build a type description string from a JSON schema property.</summary>
	private static string BuildTypeDoc( JsonElement prop )
	{
		var parts = new List<string>();
		var type = GetString( prop, "type" ) ?? "unknown";

		if ( type == "array" )
		{
			var itemType = "unknown";
			if ( prop.TryGetProperty( "items", out var items ) )
				itemType = GetString( items, "type" ) ?? "unknown";
			parts.Add( $"Type: array ({itemType})" );
		}
		else
		{
			parts.Add( $"Type: {type}" );
		}

		if ( prop.TryGetProperty( "min", out var min ) ) parts.Add( $"Min: {min}" );
		if ( prop.TryGetProperty( "max", out var max ) ) parts.Add( $"Max: {max}" );
		if ( prop.TryGetProperty( "default", out var def ) ) parts.Add( $"Default: {def}" );
		if ( prop.TryGetProperty( "_ledger", out var ledger ) && ledger.GetBoolean() ) parts.Add( "Ledger-tracked" );
		if ( prop.TryGetProperty( "_unique", out var unique ) && unique.GetBoolean() ) parts.Add( "Unique" );

		return string.Join( " | ", parts );
	}

	private static bool HasInputProperties( JsonElement ep )
	{
		return ep.TryGetProperty( "input", out var input )
			&& input.TryGetProperty( "properties", out var props )
			&& props.EnumerateObject().Any();
	}

	private static string GetString( JsonElement el, string key )
	{
		if ( el.TryGetProperty( key, out var val ) && val.ValueKind == JsonValueKind.String )
			return val.GetString();
		return null;
	}

	private static string Escape( string s ) => s?.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" ) ?? "";

	private static string XmlEscape( string s )
	{
		if ( string.IsNullOrEmpty( s ) ) return "";
		// Collapse newlines to spaces so multi-line YAML descriptions don't
		// spill out of single-line /// <summary>…</summary> doc comments and
		// leak into the generated C# as free-floating tokens.
		return s.Replace( "\r\n", " " )
			.Replace( "\n", " " )
			.Replace( "\r", " " )
			.Replace( "&", "&amp;" )
			.Replace( "<", "&lt;" )
			.Replace( ">", "&gt;" )
			.Replace( "\"", "&quot;" );
	}
}