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

/// <summary>
/// Manages the sync tool configuration for the Network Storage library.
///
/// Config is split into two files for security:
///   config/public/projectConfig.json  — project ID, public key, base URL, API version, preferences
///                                       (safe to commit, ships with published game)
///   config/secret/secret_key.json     — secret key ONLY (gitignored, editor-only, NEVER published)
///
/// The public config is also written to the project root as network-storage.credentials.json
/// so the runtime client can auto-configure via s&amp;box's sandboxed FileSystem.
/// </summary>
public static partial class SyncToolConfig
{
	public enum DataSourceMode { ApiOnly }
	public enum SourceExportMode { SourceOnly }

	// ── Credentials ──
	public static string SecretKey { get; private set; } = "";
	public static string PublicApiKey { get; private set; } = "";
	public static string ProjectId { get; private set; } = "";
	public static string BaseUrl { get; private set; } = "https://api.sboxcool.com";
	public static string CdnUrl { get; private set; } = "";
	public static string ApiVersion { get; private set; } = "v3";

	// ── Preferences ──
	public static DataSourceMode DataSource { get; private set; } = DataSourceMode.ApiOnly;
	public static SourceExportMode SourceExport { get; private set; } = SourceExportMode.SourceOnly;
	public static bool EnableAuthSessions { get; private set; }
	public static bool EnableEncryptedRequests { get; private set; }
	public static string PublishTarget { get; private set; } = "live";

	/// <summary>
	/// When true, the game host proxies Network Storage API calls on behalf of non-host clients.
	/// Required when non-host clients can't generate valid s&amp;box auth tokens (P2P multiplayer).
	/// </summary>
	public static bool ProxyEnabled { get; set; }

	// ── Sync Mappings (C# data files → collection YAML source) ──

	/// <summary>
	/// Configured mappings from C# data files to collection YAML source files.
	/// Used by sync.py to generate collection data from code.
	/// </summary>
	public static List<SyncMapping> SyncMappings { get; private set; } = new();

	public class SyncMapping
	{
		public string CsFile { get; set; } = "";
		public string Collection { get; set; } = "";
		public string Description { get; set; } = "";
	}

	// ── Configurable data folder ──
	private static string _dataFolder = "Network Storage";

	/// <summary>The subfolder name under Editor/ where sync data lives. Configurable in Setup.</summary>
	public static string DataFolder
	{
		get => _dataFolder;
		set => _dataFolder = string.IsNullOrWhiteSpace( value ) ? "Network Storage" : value.Trim();
	}

	// ── Validation ──

	/// <summary>True when all required credentials are present (project ID + both keys). Does not enforce key prefixes.</summary>
	public static bool IsValid => !string.IsNullOrEmpty( SecretKey )
		&& !string.IsNullOrEmpty( PublicApiKey )
		&& !string.IsNullOrEmpty( ProjectId );

	/// <summary>True when keys use the standard sbox_sk_ / sbox_ns_ prefixes.</summary>
	public static bool HasStandardPrefixes =>
		( string.IsNullOrEmpty( SecretKey ) || SecretKey.StartsWith( "sbox_sk_" ) )
		&& ( string.IsNullOrEmpty( PublicApiKey ) || PublicApiKey.StartsWith( "sbox_ns_" ) );

	public static bool HasPublicKey => !string.IsNullOrEmpty( PublicApiKey );

	public static bool IsFullyConfigured => IsValid;

	// ── Paths ──

	/// <summary>
	/// Absolute path to the project root directory.
	/// Derived from Sandbox.FileSystem.Mounted (which points to {project}/assets).
	/// Editor.FileSystem.Root is the ENGINE dir and must NOT be used for project files.
	/// </summary>
	private static string _projectRoot;
	public static string ProjectRoot
	{
		get
		{
			if ( _projectRoot == null )
			{
				var assetsPath = Sandbox.FileSystem.Mounted.GetFullPath( "" );
				_projectRoot = Path.GetDirectoryName( assetsPath );
			}
			return _projectRoot;
		}
	}

	/// <summary>Resolve a project-relative path to an absolute path.</summary>
	public static string Abs( string relativePath ) => Path.Combine( ProjectRoot, relativePath.Replace( '/', Path.DirectorySeparatorChar ) );

	/// <summary>Root path for all sync data: Editor/{DataFolder}/</summary>
	public static string SyncToolsPath => $"Editor/{DataFolder}";

	/// <summary>Path to config/ directory.</summary>
	public static string ConfigPath => $"{SyncToolsPath}/config";

	/// <summary>Path to config/public/ — safe to commit, ships with game.</summary>
	public static string PublicConfigPath => $"{ConfigPath}/public";

	/// <summary>Path to config/secret/ — gitignored, editor-only, NEVER published.</summary>
	public static string SecretConfigPath => $"{ConfigPath}/secret";

	/// <summary>Path to the public project config file.</summary>
	public static string ProjectConfigFile => $"{PublicConfigPath}/projectConfig.json";

	/// <summary>Path to the secret key file.</summary>
	public static string SecretKeyFile => $"{SecretConfigPath}/secret_key.json";

	/// <summary>Path to the runtime credentials file in Assets/ (shipped with game).</summary>
	public static string RuntimeCredentialsFile => "Assets/network-storage.credentials.json";

	/// <summary>Path to collections/ directory.</summary>
	public static string CollectionsPath => $"{SyncToolsPath}/collections";

	/// <summary>Path to the endpoints directory.</summary>
	public static string EndpointsPath => $"{SyncToolsPath}/endpoints";

	/// <summary>Path to the workflows directory.</summary>
	public static string WorkflowsPath => $"{SyncToolsPath}/workflows";

	/// <summary>Path to the tests directory.</summary>
	public static string TestsPath => $"{SyncToolsPath}/tests";

	// Legacy .env locations for migration
	private static string LegacyEnvInConfig => $"{ConfigPath}/.env";
	private static string LegacyEnvInRoot => $"{SyncToolsPath}/.env";
	private static string LegacyEnvInSyncTools => "Editor/SyncTools/.env";

	// ── JSON options ──
	private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
	private static readonly JsonSerializerOptions _readOptions = new()
	{
		AllowTrailingCommas = true,
		PropertyNameCaseInsensitive = true,
		ReadCommentHandling = JsonCommentHandling.Skip
	};

	private static string NormalizePublishTarget( string target )
	{
		return string.Equals( target, "next", StringComparison.OrdinalIgnoreCase )
			? "next"
			: "live";
	}

	// ── Filesystem helpers (System.IO with absolute paths) ──

	private static string[] FindFiles( string relativeDir, string pattern )
	{
		var absDir = Abs( relativeDir );
		if ( !Directory.Exists( absDir ) ) return Array.Empty<string>();
		return Directory.GetFiles( absDir, pattern ).Select( f => Path.GetFileName( f ) ).OrderBy( f => f ).ToArray();
	}

	private static void EnsureDir( string relativePath )
	{
		var absPath = Abs( relativePath );
		if ( !Directory.Exists( absPath ) )
			Directory.CreateDirectory( absPath );
	}

	// ──────────────────────────────────────────────────────
	//  Load / Save
	// ──────────────────────────────────────────────────────

	/// <summary>
	/// Load configuration from the split config files.
	/// Falls back to legacy .env files for migration.
	/// </summary>
	public static void Load()
	{
		SecretKey = "";
		PublicApiKey = "";
		ProjectId = "";
		BaseUrl = "https://api.sboxcool.com";
		CdnUrl = "";
		ApiVersion = "v3";
		DataSource = DataSourceMode.ApiOnly;
		SourceExport = SourceExportMode.SourceOnly;
		EnableAuthSessions = false;
		EnableEncryptedRequests = false;
		PublishTarget = "live";
		DataFolder = "Network Storage";
		ProxyEnabled = false;

		// ── Try new split config first ──
		if ( File.Exists( Abs( ProjectConfigFile ) ) )
		{
			LoadPublicConfig( ProjectConfigFile );
			if ( File.Exists( Abs( SecretKeyFile ) ) )
				LoadSecretConfig( SecretKeyFile );
			return;
		}

		// ── Try legacy .env locations for migration ──
		string legacyEnv = null;
		if ( File.Exists( Abs( LegacyEnvInConfig ) ) )
			legacyEnv = LegacyEnvInConfig;
		else if ( File.Exists( Abs( LegacyEnvInRoot ) ) )
			legacyEnv = LegacyEnvInRoot;
		else if ( File.Exists( Abs( LegacyEnvInSyncTools ) ) )
			legacyEnv = LegacyEnvInSyncTools;

		if ( legacyEnv != null )
		{
			Log.Info( $"[SyncTool] Found legacy .env — migrating to split config on next save" );
			LoadLegacyEnv( legacyEnv );
			return;
		}

		// ── First install — scaffold ──
		ScaffoldProject();
	}

	private static void LoadPublicConfig( string path )
	{
		var json = JsonSerializer.Deserialize<JsonElement>( File.ReadAllText( Abs( path ) ), _readOptions );
		ProjectId = json.TryGetProperty( "projectId", out var pid ) ? pid.GetString() ?? "" : "";
		PublicApiKey = json.TryGetProperty( "publicKey", out var pk ) ? pk.GetString() ?? "" : "";
		BaseUrl = json.TryGetProperty( "baseUrl", out var bu ) ? bu.GetString()?.TrimEnd( '/' ) ?? "https://api.sboxcool.com" : "https://api.sboxcool.com";
		CdnUrl = json.TryGetProperty( "cdnUrl", out var cu ) ? cu.GetString() ?? "" : "";
		ApiVersion = json.TryGetProperty( "apiVersion", out var av ) ? av.GetString()?.Trim( '/' ) ?? "v3" : "v3";
		DataFolder = json.TryGetProperty( "dataFolder", out var df ) ? df.GetString() ?? "Network Storage" : "Network Storage";
		if ( json.TryGetProperty( "dataSource", out var ds ) )
		{
			var configured = ds.GetString();
			if ( !string.IsNullOrWhiteSpace( configured ) &&
				!string.Equals( configured, "api_only", StringComparison.OrdinalIgnoreCase ) )
				Log.Warning( $"[SyncTool] dataSource '{configured}' is no longer supported; using api_only." );
		}
		DataSource = DataSourceMode.ApiOnly;
		if ( json.TryGetProperty( "sourceExportMode", out var sem ) )
		{
			var configured = sem.GetString();
			if ( !string.IsNullOrWhiteSpace( configured ) &&
				!string.Equals( configured, "source_only", StringComparison.OrdinalIgnoreCase ) )
				Log.Warning( $"[SyncTool] sourceExportMode '{configured}' is no longer supported; using source_only." );
		}
		SourceExport = SourceExportMode.SourceOnly;
		// Only override the default when the property is explicitly present in the file.
		// If absent, leave ProxyEnabled at its default (false) set in Load().
		if ( json.TryGetProperty( "proxyEnabled", out var pe ) )
			ProxyEnabled = pe.GetBoolean();
		if ( json.TryGetProperty( "enableAuthSessions", out var eas ) )
			EnableAuthSessions = eas.ValueKind == JsonValueKind.True;
		if ( json.TryGetProperty( "enableEncryptedRequests", out var eer ) )
			EnableEncryptedRequests = eer.ValueKind == JsonValueKind.True;
		if ( json.TryGetProperty( "publishTarget", out var pt ) )
			PublishTarget = NormalizePublishTarget( pt.GetString() );

		// Sync mappings
		SyncMappings.Clear();
		if ( json.TryGetProperty( "syncMappings", out var mappings ) && mappings.ValueKind == JsonValueKind.Array )
		{
			foreach ( var m in mappings.EnumerateArray() )
			{
				SyncMappings.Add( new SyncMapping
				{
					CsFile = m.TryGetProperty( "csFile", out var cs ) ? cs.GetString() ?? "" : "",
					Collection = m.TryGetProperty( "collection", out var col ) ? col.GetString() ?? "" : "",
					Description = m.TryGetProperty( "description", out var desc ) ? desc.GetString() ?? "" : ""
				} );
			}
		}
	}

	private static void LoadSecretConfig( string path )
	{
		var json = JsonSerializer.Deserialize<JsonElement>( File.ReadAllText( Abs( path ) ), _readOptions );
		SecretKey = json.TryGetProperty( "secretKey", out var sk ) ? sk.GetString() ?? "" : "";
	}

	private static void LoadLegacyEnv( string envPath )
	{
		foreach ( var line in File.ReadAllText( Abs( envPath ) ).Split( '\n' ) )
		{
			var trimmed = line.Trim();
			if ( string.IsNullOrEmpty( trimmed ) || trimmed.StartsWith( '#' ) ) continue;
			var eq = trimmed.IndexOf( '=' );
			if ( eq < 0 ) continue;

			var key = trimmed[..eq].Trim();
			var val = trimmed[( eq + 1 )..].Trim();

			switch ( key )
			{
				case "SBOXCOOL_SECRET_KEY": SecretKey = val; break;
				case "SBOXCOOL_PUBLIC_KEY": PublicApiKey = val; break;
				case "SBOXCOOL_PROJECT_ID": ProjectId = val; break;
				case "SBOXCOOL_BASE_URL": BaseUrl = val.TrimEnd( '/' ); break;
			case "SBOXCOOL_CDN_URL": CdnUrl = val; break;
				case "SBOXCOOL_API_VERSION": ApiVersion = val.Trim( '/' ); break;
				case "SBOXCOOL_DATA_FOLDER": DataFolder = val; break;
				case "SBOXCOOL_DATA_SOURCE":
					if ( !string.Equals( val, "api_only", StringComparison.OrdinalIgnoreCase ) )
						Log.Warning( $"[SyncTool] legacy SBOXCOOL_DATA_SOURCE '{val}' is no longer supported; using api_only." );
					DataSource = DataSourceMode.ApiOnly;
					break;
			}
		}
	}

	/// <summary>
	/// Save configuration to split config files.
	/// Public config → config/public/projectConfig.json (safe to commit)
	/// Secret key   → config/secret/secret_key.json (gitignored, editor-only)
	/// Runtime copy → {project root}/network-storage.credentials.json (ships with game)
	/// </summary>
	public static void Save( string secretKey, string publicApiKey, string projectId,
		string baseUrl = null, DataSourceMode? dataSource = null, string dataFolder = null, string cdnUrl = null )
	{
		if ( dataFolder != null )
			DataFolder = dataFolder;

		EnsureSyncToolsDir();

		SecretKey = secretKey ?? "";
		PublicApiKey = publicApiKey ?? "";
		ProjectId = projectId ?? "";
		BaseUrl = ( baseUrl ?? "https://api.sboxcool.com" ).TrimEnd( '/' );
		CdnUrl = ( cdnUrl ?? "" ).TrimEnd( '/' );
		DataSource = DataSourceMode.ApiOnly;
		SourceExport = SourceExportMode.SourceOnly;

		// ── Write public config (safe to commit) ──
		var publicConfig = new Dictionary<string, object>
		{
			["projectId"] = ProjectId,
			["publicKey"] = PublicApiKey,
			["baseUrl"] = BaseUrl,
			["cdnUrl"] = CdnUrl,
			["apiVersion"] = ApiVersion,
			["dataFolder"] = DataFolder,
			["dataSource"] = "api_only",
			["sourceExportMode"] = "source_only",
			["enableAuthSessions"] = EnableAuthSessions,
			["enableEncryptedRequests"] = EnableEncryptedRequests,
			["publishTarget"] = PublishTarget,
			["proxyEnabled"] = ProxyEnabled
		};

		if ( SyncMappings.Count > 0 )
		{
			publicConfig["syncMappings"] = SyncMappings.Select( m => new Dictionary<string, string>
			{
				["csFile"] = m.CsFile,
				["collection"] = m.Collection,
				["description"] = m.Description
			} ).ToList();
		}

		File.WriteAllText( Abs( ProjectConfigFile ), JsonSerializer.Serialize( publicConfig, _jsonOptions ) );
		Log.Info( "[SyncTool] Public config saved to config/public/projectConfig.json" );

		// ── Write secret key (gitignored, NEVER published) ──
		var secretConfig = new Dictionary<string, string> { ["secretKey"] = SecretKey };
		File.WriteAllText( Abs( SecretKeyFile ), JsonSerializer.Serialize( secretConfig, _jsonOptions ) );
		Log.Info( "[SyncTool] Secret key saved to config/secret/secret_key.json" );

		// ── Write runtime credentials (ships with game, NO secret key) ──
		// Merges into existing file — preserves any fields set by other tools.
		if ( !string.IsNullOrEmpty( ProjectId ) )
		{
			var credsPath = Abs( RuntimeCredentialsFile );
			var merged = new Dictionary<string, object>();

			// Read existing file to preserve fields we don't own
			if ( File.Exists( credsPath ) )
			{
				try
				{
					var existing = JsonSerializer.Deserialize<JsonElement>( File.ReadAllText( credsPath ), _readOptions );
					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 save owns
			merged["projectId"] = ProjectId;
			merged["publicKey"] = PublicApiKey;
			merged["baseUrl"] = BaseUrl;
			merged["cdnUrl"] = CdnUrl ?? "";
			merged["apiVersion"] = ApiVersion;
			merged["proxyEnabled"] = ProxyEnabled;
			merged["enableAuthSessions"] = EnableAuthSessions;
			merged["enableEncryptedRequests"] = EnableEncryptedRequests;

			File.WriteAllText( credsPath, JsonSerializer.Serialize( merged, _jsonOptions ) );
			Log.Info( "[SyncTool] Runtime credentials written to Assets/network-storage.credentials.json" );
		}

		// ── Write .gitignore in secret/ to protect the key ──
		var secretGitignore = Abs( $"{SecretConfigPath}/.gitignore" );
		if ( !File.Exists( secretGitignore ) )
			File.WriteAllText( secretGitignore, "*\n!.gitignore\n" );
	}

	/// <summary>
	/// For display in the Setup window — show the path to the secret key file.
	/// </summary>
	public static string EnvFilePath => SecretKeyFile;

	/// <summary>
	/// Update just the data source preference without touching other fields.
	/// </summary>
	public static void SetDataSource( DataSourceMode mode )
	{
		DataSource = DataSourceMode.ApiOnly;
		if ( File.Exists( Abs( ProjectConfigFile ) ) )
			Save( SecretKey, PublicApiKey, ProjectId, BaseUrl, DataSource );
	}

	/// <summary>
	/// Persist the selected publish target ("live" or "next") so next launch uses it.
	/// </summary>
	public static void SetPublishTarget( string target )
	{
		PublishTarget = NormalizePublishTarget( target );
		if ( File.Exists( Abs( ProjectConfigFile ) ) )
			Save( SecretKey, PublicApiKey, ProjectId, BaseUrl, DataSource, DataFolder, CdnUrl );
	}

	/// <summary>
	/// Update sync mappings and re-save config.
	/// </summary>
	public static void SaveSyncMappings( List<SyncMapping> mappings )
	{
		SyncMappings = mappings ?? new();
		if ( File.Exists( Abs( ProjectConfigFile ) ) )
			Save( SecretKey, PublicApiKey, ProjectId, BaseUrl, DataSource, DataFolder, CdnUrl );
		Log.Info( $"[SyncTool] Saved {SyncMappings.Count} sync mapping(s)" );
	}

	/// <summary>
	/// Get the absolute path to a sync mapping's C# file.
	/// The csFile path is relative to the project root.
	/// </summary>
	public static string GetMappingCsPath( SyncMapping mapping )
		=> Abs( mapping.CsFile );

	/// <summary>
	/// Get the absolute path to a sync mapping's collection YAML source file.
	/// </summary>
	public static string GetMappingCollectionPath( SyncMapping mapping )
		=> Abs( $"{CollectionsPath}/{mapping.Collection}.collection.yml" );

	/// <summary>Path to sync.py script inside the network-storage library.</summary>
	public static string SyncPyPath => "Libraries/sboxcool.network-storage/Editor/sync.py";

	/// <summary>
	/// Update the proxy-enabled preference and re-save config.
	/// Also pushes the setting to the runtime client immediately.
	/// </summary>
	public static void SetProxyEnabled( bool enabled )
	{
		ProxyEnabled = enabled;
		NetworkStorage.ProxyEnabled = enabled;
		if ( File.Exists( Abs( ProjectConfigFile ) ) )
			Save( SecretKey, PublicApiKey, ProjectId, BaseUrl, DataSource, DataFolder, CdnUrl );
		Log.Info( $"[SyncTool] Proxy mode {( enabled ? "ENABLED" : "DISABLED" )}" );
	}

	// ──────────────────────────────────────────────────────
	//  Data file loaders
	// ──────────────────────────────────────────────────────

	/// <summary>Load all collection files from collections/ directory.</summary>
	public static List<(string Name, Dictionary<string, object> Data)> LoadCollections()
	{
		var list = new List<(string, Dictionary<string, object>)>();

		foreach ( var source in LoadSourceCanonicalResources( "collection" ) )
		{
			var dict = JsonSerializer.Deserialize<Dictionary<string, object>>( source.GetRawText(), _readOptions );
			var name = source.TryGetProperty( "name", out var nameProperty )
				? nameProperty.GetString()
				: null;
			if ( string.IsNullOrWhiteSpace( name ) )
				name = dict?.GetValueOrDefault( "name" )?.ToString();
			if ( string.IsNullOrWhiteSpace( name ) && source.TryGetProperty( "id", out var idProperty ) )
				name = idProperty.GetString();
			if ( !string.IsNullOrWhiteSpace( name ) && dict != null )
				list.Add( (name, dict) );
		}
		if ( list.Count > 0 )
			return list;

		return list;
	}

	/// <summary>Return true when an endpoint JSON object is marked deprecated.</summary>
	public static bool IsEndpointDeprecated( JsonElement ep )
	{
		return IsTruthyFlag( ep, "_deprecated" )
			|| IsTruthyFlag( ep, "deprecated" )
			|| IsTruthyFlag( ep, "depreciated" )
			|| IsTruthyFlag( ep, "depricated" );
	}

	private static bool IsTruthyFlag( JsonElement ep, string propertyName )
	{
		if ( !ep.TryGetProperty( propertyName, out var flag ) )
			return false;

		return flag.ValueKind switch
		{
			JsonValueKind.True => true,
			JsonValueKind.Number => flag.TryGetInt32( out var n ) && n != 0,
			JsonValueKind.String => IsTruthyString( flag.GetString() ),
			_ => false
		};
	}

	private static bool IsTruthyString( string value )
	{
		return value != null && (value.Equals( "true", StringComparison.OrdinalIgnoreCase )
			|| value.Equals( "on", StringComparison.OrdinalIgnoreCase )
			|| value.Equals( "yes", StringComparison.OrdinalIgnoreCase )
			|| value == "1");
	}

	/// <summary>Load all active endpoint definitions from the endpoints/ directory.</summary>
	public static List<JsonElement> LoadEndpoints( bool includeDeprecated = false )
	{
		var list = new List<JsonElement>();

		var sourceEndpoints = LoadSourceCanonicalResources( "endpoint" );
		if ( sourceEndpoints.Count > 0 )
		{
			foreach ( var ep in sourceEndpoints )
			{
				if ( !includeDeprecated && IsEndpointDeprecated( ep ) )
					continue;

				list.Add( ep );
			}

			return list;
		}

		return list;
	}

	/// <summary>Load all workflow definitions from the workflows/ directory.</summary>
	public static List<JsonElement> LoadWorkflows()
	{
		var sourceWorkflows = LoadSourceCanonicalResources( "workflow" );
		if ( sourceWorkflows.Count > 0 )
			return sourceWorkflows;

		return new List<JsonElement>();
	}

	// ──────────────────────────────────────────────────────
	//  Data file writers (for Pull)
	// ──────────────────────────────────────────────────────

	/// <summary>Save endpoint definitions as individual YAML source files.</summary>
	public static void SaveEndpoints( List<Dictionary<string, object>> endpoints )
	{
		EnsureSyncToolsDir();
		EnsureDir( EndpointsPath );

		foreach ( var file in FindFiles( EndpointsPath, "*.json" ) )
			File.Delete( Abs( $"{EndpointsPath}/{file}" ) );

		foreach ( var ep in endpoints )
		{
			var epJson = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( ep ) );
			if ( IsEndpointDeprecated( epJson ) )
				continue;

			var slug = ep.TryGetValue( "slug", out var s ) ? s?.ToString() ?? "unknown" : "unknown";
			SyncToolPullWriter.WriteSource( "endpoint", slug, ep );
		}

		Log.Info( $"[SyncTool] Saved {endpoints.Count} endpoint files to endpoints/" );
	}

	/// <summary>Save workflow definitions as individual YAML source files.</summary>
	public static void SaveWorkflows( List<Dictionary<string, object>> workflows )
	{
		EnsureSyncToolsDir();
		EnsureDir( WorkflowsPath );

		foreach ( var file in FindFiles( WorkflowsPath, "*.json" ) )
			File.Delete( Abs( $"{WorkflowsPath}/{file}" ) );

		foreach ( var wf in workflows )
		{
			var id = wf.TryGetValue( "id", out var s ) ? s?.ToString() ?? "unknown" : "unknown";
			SyncToolPullWriter.WriteSource( "workflow", id, wf );
		}

		Log.Info( $"[SyncTool] Saved {workflows.Count} workflow files to workflows/" );
	}

	/// <summary>Save a single workflow to workflows/{id}.workflow.yml.</summary>
	public static void SaveWorkflow( string id, Dictionary<string, object> data )
	{
		EnsureSyncToolsDir();
		EnsureDir( WorkflowsPath );
		SyncToolPullWriter.WriteSource( "workflow", id, data );
		Log.Info( $"[SyncTool] Saved workflows/{id}.workflow.yml" );
	}

	/// <summary>Load all test definitions from the tests/ directory.</summary>
	public static List<JsonElement> LoadTests()
	{
		var list = new List<JsonElement>();
		foreach ( var source in LoadSourceCanonicalResources( "test" ) )
		{
			if ( source.ValueKind == JsonValueKind.Object )
				list.Add( source );
		}

		return list;
	}

	/// <summary>Save test definitions as individual YAML source files.</summary>
	public static void SaveTests( List<Dictionary<string, object>> tests )
	{
		EnsureSyncToolsDir();
		EnsureDir( TestsPath );

		foreach ( var test in tests )
		{
			var id = test.TryGetValue( "id", out var s ) ? s?.ToString() ?? "unknown" : "unknown";
			SyncToolPullWriter.WriteSource( "test", id, test );
		}

		Log.Info( $"[SyncTool] Saved {tests.Count} test files to tests/" );
	}

	/// <summary>Save a collection to collections/{name}.collection.yml.</summary>
	public static void SaveCollection( string name, Dictionary<string, object> data )
	{
		EnsureSyncToolsDir();
		EnsureDir( CollectionsPath );
		SyncToolPullWriter.WriteSource( "collection", name, data );
		Log.Info( $"[SyncTool] Saved collections/{name}.collection.yml" );
	}

	/// <summary>Save multiple collections.</summary>
	public static void SaveCollections( List<(string Name, Dictionary<string, object> Data)> collections )
	{
		foreach ( var (name, data) in collections )
			SaveCollection( name, data );
	}

	/// <summary>Check if local data files exist.</summary>
	public static bool HasLocalData()
	{
		return HasSourceFiles();
	}

	/// <summary>Find the local workflow file whose embedded id matches the given workflow id.</summary>
	public static string FindWorkflowFileById( string id )
	{
		var files = FindFiles( WorkflowsPath, "*.workflow.yml" )
			.Concat( FindFiles( WorkflowsPath, "*.workflow.yaml" ) )
			.ToArray();
		var canonical = files.FirstOrDefault( f => string.Equals( ResourceIdFromFilePath( f, "workflow" ), id, StringComparison.OrdinalIgnoreCase ) );
		if ( canonical != null )
			return Abs( $"{WorkflowsPath}/{canonical}" );

		foreach ( var file in files )
		{
			if ( WorkflowFileMatchesId( file, id ) )
				return Abs( $"{WorkflowsPath}/{file}" );
		}

		return null;
	}

	private static void EnsureSyncToolsDir()
	{
		EnsureDir( SyncToolsPath );
		EnsureDir( ConfigPath );
		EnsureDir( PublicConfigPath );
		EnsureDir( SecretConfigPath );
		EnsureDir( CollectionsPath );
		EnsureDir( EndpointsPath );
		EnsureDir( WorkflowsPath );
		EnsureDir( TestsPath );
	}

	private static void DeleteDuplicateWorkflowFiles( string id, string keepFile )
	{
		foreach ( var file in FindFiles( WorkflowsPath, "*.workflow.yml" )
			.Concat( FindFiles( WorkflowsPath, "*.workflow.yaml" ) ) )
		{
			if ( string.Equals( file, keepFile, StringComparison.OrdinalIgnoreCase ) )
				continue;

			if ( WorkflowFileMatchesId( file, id ) )
				File.Delete( Abs( $"{WorkflowsPath}/{file}" ) );
		}
	}

	private static bool WorkflowFileMatchesId( string file, string id )
	{
		var fullPath = Abs( $"{WorkflowsPath}/{file}" );
		try
		{
			var wf = TryLoadSourceCanonicalResource( "workflow", fullPath, out var sourceWorkflow ) ? sourceWorkflow : default;
			if ( wf.TryGetProperty( "id", out var wfId ) )
				return string.Equals( wfId.GetString(), id, StringComparison.OrdinalIgnoreCase );
		}
		catch
		{
		}

		return string.Equals( ResourceIdFromFilePath( file, "workflow" ), id, StringComparison.OrdinalIgnoreCase );
	}

	// ──────────────────────────────────────────────────────
	//  First-install scaffolding
	// ──────────────────────────────────────────────────────

	/// <summary>
	/// Creates the full folder structure with sample files on first install.
	/// </summary>
	private static void ScaffoldProject()
	{
		Log.Info( "[NetworkStorage] First install detected — scaffolding Editor/Network Storage/ ..." );

		EnsureSyncToolsDir();

		// ── Public config with placeholders ──
		var publicConfig = new Dictionary<string, object>
		{
			["projectId"] = "your-project-id-here",
			["publicKey"] = "sbox_ns_your_public_key_here",
			["baseUrl"] = "https://api.sboxcool.com",
			["cdnUrl"] = "",
			["apiVersion"] = "v3",
			["dataFolder"] = "Network Storage",
			["dataSource"] = "api_only",
			["sourceExportMode"] = "source_only",
			["enableAuthSessions"] = false,
			["enableEncryptedRequests"] = false,
			["publishTarget"] = "live",
			["proxyEnabled"] = false
		};
		File.WriteAllText( Abs( ProjectConfigFile ), JsonSerializer.Serialize( publicConfig, _jsonOptions ) );

		// ── Secret key with placeholder ──
		var secretConfig = new Dictionary<string, string>
		{
			["secretKey"] = "sbox_sk_your_secret_key_here"
		};
		File.WriteAllText( Abs( SecretKeyFile ), JsonSerializer.Serialize( secretConfig, _jsonOptions ) );

		// ── .gitignore in secret/ — ignore everything ──
		File.WriteAllText( Abs( $"{SecretConfigPath}/.gitignore" ), "*\n!.gitignore\n" );

		// ── Sample collection ──
		var sampleCollection = @"{
  ""name"": ""players"",
  ""scope"": ""user"",
  ""schema"": {
    ""currency"": { ""type"": ""number"", ""default"": 0 },
    ""xp"": { ""type"": ""number"", ""default"": 0 },
    ""level"": { ""type"": ""number"", ""default"": 1 }
  }
}";
		SyncToolPullWriter.WriteSource( "collection", "players",
			JsonSerializer.Deserialize<Dictionary<string, object>>( sampleCollection, _readOptions ) );

		// ── Sample endpoint ──
		var sampleEndpoint = @"{
  ""slug"": ""init-player"",
  ""description"": ""Initialize a new player with default values"",
  ""steps"": [
    {
      ""type"": ""read"",
      ""collection"": ""players"",
      ""as"": ""player""
    },
    {
      ""type"": ""condition"",
      ""field"": ""player"",
      ""operator"": ""is_null"",
      ""onFail"": ""return""
    },
    {
      ""type"": ""write"",
      ""collection"": ""players"",
      ""data"": {
        ""currency"": 0,
        ""xp"": 0,
        ""level"": 1
      }
    }
  ]
}";
		SyncToolPullWriter.WriteSource( "endpoint", "init-player",
			JsonSerializer.Deserialize<Dictionary<string, object>>( sampleEndpoint, _readOptions ) );

		Log.Info( "[NetworkStorage] Scaffolding complete. Open Editor → Network Storage → Setup to enter your API keys." );
	}
}