Game/LocalLevels.cs
using System.Collections.Generic;
using Sandbox;

namespace BlockParty;

/// <summary>
/// STANDALONE (non-editor) local level files. A packaged build has no editor assembly, so the
/// in-game level editor can't write the project's <c>Assets/levels</c> folder — instead levels save
/// as <c>levels/{id}.json</c> in the game data folder (<see cref="FileSystem.Data"/>), beside the
/// <c>gifs/</c> export folder, so a player's creations are easy to find, hand-edit and share.
/// <see cref="Levels.Reload"/> folds these files into the registry (marked
/// <see cref="LevelDef.IsLocal"/>), which makes a local level loadable BY ID everywhere the editor
/// loads levels (the picker, <c>LoadFromLevel</c>, the last-opened restore) and browsable in the
/// level picker's Local grid view. A local file whose id collides with a shipped level is skipped —
/// it must never shadow map/leaderboard content.
/// </summary>
public static class LocalLevels
{
	/// <summary>Player level files live in their own subfolder of the game data directory, beside
	/// the <see cref="GifExporter.DirName"/> export folder.</summary>
	public const string DirName = "levels";

	/// <summary>Whether local level files are in play: standalone builds only — in the editor the
	/// project's Assets folder is the single source of truth.</summary>
	public static bool Enabled => !Game.IsEditor;

	/// <summary>Data-folder path of a local level's JSON file.</summary>
	public static string PathFor( string id ) => $"{DirName}/{id}.json";

	/// <summary>Read every parseable level JSON in the data folder's <see cref="DirName"/> directory.
	/// A hand-dropped file that isn't a level either fails to parse or comes out with no id, so it's
	/// skipped without a warning. Registry policy (collision handling, timestamps) belongs to the
	/// caller (<see cref="Levels.Reload"/>).</summary>
	public static List<LevelDef> LoadAll()
	{
		var result = new List<LevelDef>();
		if ( !Enabled ) return result;

		try
		{
			foreach ( var name in FileSystem.Data.FindFile( DirName, "*.json" ) )
			{
				var path = $"{DirName}/{name}";
				try
				{
					var e = EditorLevel.FromJsonString( FileSystem.Data.ReadAllText( path ), out _ );
					if ( e is null || string.IsNullOrWhiteSpace( e.Id ) ) continue;

					var def = e.ToLevelDef();
					def.SourcePath = path;
					def.IsLocal = true;
					result.Add( def );
				}
				catch { /* not a level file — skip quietly */ }
			}
		}
		catch ( System.Exception ex )
		{
			Log.Warning( $"[BlockParty] scanning local levels: {ex.Message}" );
		}

		return result;
	}

	/// <summary>Standalone counterpart of the editor-assembly <c>level_save</c>: write the open
	/// editor level to <c>levels/{id}.json</c> in the game data folder and fold it into the live
	/// registry so it can be re-opened / test-played immediately. Same refusals as the editor path:
	/// unnamed or unsafe ids, and overwriting an EXISTING file that this document wasn't loaded from.</summary>
	public static void Save( LevelEditorStage stage )
	{
		var id = stage.Level.Id?.Trim();
		if ( !IsSafeLevelId( id ) )
		{
			stage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? "level has no id" : $"invalid level id '{id}'" );
			return;
		}

		// Shipped ids are permanently taken: a local file must never shadow map/leaderboard content
		// (the loader skips such files, so allowing the save would just write an unloadable file).
		if ( Levels.IsShippedLevel( id ) )
		{
			stage.ReportProjectError( $"'{id}' is a built-in level; pick another id" );
			return;
		}

		// The ws{fileId} namespace belongs to installed workshop levels — a local file there could
		// shadow (or be shadowed by) a downloaded level at the registry fold-in, depending on order.
		if ( WorkshopLevels.IsWorkshopId( id ) )
		{
			stage.ReportProjectError( $"'{id}' is a reserved workshop id; pick another id" );
			return;
		}

		var path = PathFor( id );
		bool updatingSource = stage.OwnsProjectFile( id );
		if ( FileSystem.Data.FileExists( path ) && !updatingSource )
		{
			stage.ReportProjectError( $"level '{id}' already exists; load it before overwriting" );
			return;
		}

		// The document id IS the file name from here on: serialize and register the trimmed form so a
		// reopened level's id (and ProjectSourceId) matches the file it came from.
		stage.Level.Id = id;
		FileSystem.Data.CreateDirectory( DirName );
		FileSystem.Data.WriteAllText( path, stage.Level.ToJsonString() );
		stage.MarkSavedToProject( id );

		// Stamp the save time so the picker's Recent sort works for local levels too (standalone has
		// no editor assembly to stat files, so the save moment IS the timestamp).
		EditorPrefs.Current.LevelBrowserLevelTimes ??= new();
		EditorPrefs.Current.LevelBrowserLevelTimes[id] = System.DateTimeOffset.UtcNow.Ticks;
		EditorPrefs.Save();

		var def = stage.Level.ToLevelDef();
		def.SourcePath = path;
		def.IsLocal = true;
		Levels.ApplySavedLevel( def );
		Log.Info( $"[BlockParty] Saved local level '{id}' -> {FileSystem.Data.GetFullPath( path )}." );
	}

	/// <summary>Standalone counterpart of the editor-assembly <c>level_delete</c>: remove the open
	/// level's local JSON file and drop it from the live registry.</summary>
	public static void Delete( LevelEditorStage stage )
	{
		var id = stage.Level.Id?.Trim();
		if ( !IsSafeLevelId( id ) )
		{
			stage.ReportProjectError( string.IsNullOrWhiteSpace( id ) ? "level has no id" : $"invalid level id '{id}'" );
			return;
		}

		// Same claim rule as Save: only the file this document came from can be deleted.
		if ( !stage.OwnsProjectFile( id ) )
		{
			stage.ReportProjectError( $"'{id}' isn't the file this level was opened from; load it before deleting" );
			return;
		}

		var path = PathFor( id );
		if ( !FileSystem.Data.FileExists( path ) )
		{
			stage.ReportProjectError( $"no local file for level '{id}'" );
			return;
		}

		FileSystem.Data.DeleteFile( path );
		EditorPrefs.Current.LevelBrowserLevelTimes?.Remove( id );
		EditorPrefs.Save();

		// Only drop a LOCAL registry entry: if the id somehow matches a shipped level (its local
		// file was collision-skipped at load), the registry entry isn't ours to remove.
		if ( Levels.Get( id )?.IsLocal == true )
			Levels.RemoveDeletedLevel( id );

		stage.ResetToDefault();
		Log.Info( $"[BlockParty] Deleted local level '{id}'." );
	}

	/// <summary>Filename- and console-token-safe id: ASCII letters, digits, '-', '_' and '.', never
	/// "." / "..". The game assembly can't consult Path.GetInvalidFileNameChars, so this is a
	/// stricter allowlist mirror of the editor writer's rules (which also refuse whitespace).</summary>
	public static bool IsSafeLevelId( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || id == "." || id == ".." ) return false;
		foreach ( var c in id )
		{
			bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
				|| c == '-' || c == '_' || c == '.';
			if ( !ok ) return false;
		}
		return true;
	}
}