Game/DailyTemplates.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace BlockParty;

/// <summary>The on-disk JSON shape of one daily-challenge obstacle template
/// (Assets/daily-templates/*.json — the file name is the template id). Same primitive conventions
/// as <see cref="LevelJson"/>: enums as names, points as [x,y], rects as [left,bottom,right,top].
/// Authored from the level editor (Save Template) and hand-tunable (Weight/AllowMirrorX).</summary>
public sealed class ObstacleTemplateJson
{
	public float Weight { get; set; } = 1f;
	public bool AllowMirrorX { get; set; }
	/// <summary>When set, spiked surfaces are always spiked exactly as authored instead of rolled
	/// per-side each day (see <see cref="ObstacleTemplate.AuthoredSpikes"/>).</summary>
	public bool AuthoredSpikes { get; set; }
	public float[][] Obstacles { get; set; }
	/// <summary>Indices into <see cref="Obstacles"/> whose rect blocks line of sight.</summary>
	public int[] Vision { get; set; }
	/// <summary>Indices into <see cref="Obstacles"/> flagged as fences (solid only to blocks). Their
	/// colour is NOT stored here — templates are layout-only, and the fence colour rolls with the
	/// rest of the day's palette (see <see cref="LevelCosmeticsRandomizer.RollColors"/>).</summary>
	public int[] Fences { get; set; }
	/// <summary>Indices into <see cref="Obstacles"/> flagged as GLASS (solid only to players).
	/// Layout-only like <see cref="Fences"/> — the glass colour rolls with the day's palette.</summary>
	public int[] Glass { get; set; }
	public float[][] Spawns { get; set; }
	/// <summary>Single player spawn [x,y] (the common case).</summary>
	public float[] Player { get; set; }
	/// <summary>Candidate player spawns [[x,y],...] — written instead of <see cref="Player"/> when
	/// there's more than one; the run picks one from the run Rng.</summary>
	public float[][] Players { get; set; }
	public string[] SpikeableWalls { get; set; }
	public ObstacleSpikeJson[] SpikeableObstacleSides { get; set; }
	public float[][] AltRects { get; set; }
	/// <summary>Pinned blocks (sparse: spawn index + type/phase/pressed) — always spawn on this
	/// template's days, independent of the rolled block count.</summary>
	public PinJson[] Pins { get; set; }
	/// <summary>Collectible coin centres [[x,y],...]. Null/omitted = no coins.</summary>
	public float[][] Coins { get; set; }
}

/// <summary>
/// The daily generator's obstacle-template library, loaded from <c>Assets/daily-templates/*.json</c>
/// (sorted by id). IMPORTANT: the file set AND content are generation inputs — adding, renaming,
/// editing or deleting a template changes every past and future generated day, so template changes
/// ship with a LEADERBOARD_VERSION bump like any generator change (see <see cref="DailyLevelGenerator"/>).
/// Templates are authored in the level editor (Save/Load Template in the inspector) and validated
/// by the daily_validate_templates ConCmd.
/// </summary>
public static class DailyTemplates
{
	public const string DirName = "daily-templates";

	private static List<ObstacleTemplate> _templates;

	/// <summary>Every loaded template, sorted by id (deterministic generation order).</summary>
	public static IReadOnlyList<ObstacleTemplate> All
	{
		get
		{
			EnsureLoaded();
			return _templates ?? (IReadOnlyList<ObstacleTemplate>)Array.Empty<ObstacleTemplate>();
		}
	}

	public static ObstacleTemplate Find( string id )
		=> string.IsNullOrWhiteSpace( id ) ? null : All.FirstOrDefault( t => t.Id == id );

	/// <summary>Drop the loaded set so the next access rescans the mounted folder (hotload hygiene).</summary>
	public static void Reload() => _templates = null;

	/// <summary>Whether a scan has actually found templates (attempts a load, so polling here
	/// self-heals once the mount lands). While false the library may just not be mounted yet, so
	/// dailies must not be generated at all (see <see cref="DailyLevels.Get"/>).</summary>
	public static bool IsLoaded { get { EnsureLoaded(); return _templates is not null; } }

	/// <summary>Hot-insert/replace a just-saved template so it's usable immediately — the mounted
	/// asset filesystem can lag behind the disk write (mirrors <c>Levels.ApplySavedLevel</c>).</summary>
	public static void ApplySaved( ObstacleTemplate template )
	{
		if ( template is null || string.IsNullOrEmpty( template.Id ) )
			return;

		EnsureLoaded();
		_templates ??= new List<ObstacleTemplate>();
		_templates.RemoveAll( t => t.Id == template.Id );
		_templates.Add( template );
		_templates.Sort( ( a, b ) => string.CompareOrdinal( a.Id, b.Id ) );
	}

	/// <summary>Hot-remove a just-deleted template so the browser stops offering it immediately — the
	/// mounted asset filesystem can lag behind the disk delete (the counterpart of <see cref="ApplySaved"/>).</summary>
	public static void ApplyDeleted( string id )
	{
		if ( string.IsNullOrEmpty( id ) )
			return;
		_templates?.RemoveAll( t => t.Id == id );
	}

	// Latch the library only once a scan actually FINDS templates, so a boot-before-mount race can't
	// latch an empty or partial library — mirrors the
	// content-based `Levels._loaded` retry for the same race. An empty scan retries on the next access.
	private static void EnsureLoaded()
	{
		if ( _templates is not null )
			return;

		try
		{
			if ( !FileSystem.Mounted.DirectoryExists( DirName ) )
				return;

			var loaded = new List<ObstacleTemplate>();
			foreach ( var name in FileSystem.Mounted.FindFile( DirName, "*.json" ) )
			{
				var path = $"{DirName}/{name}";
				try
				{
					var json = Json.Deserialize<ObstacleTemplateJson>( FileSystem.Mounted.ReadAllText( path ) );
					var id = name.EndsWith( ".json", StringComparison.OrdinalIgnoreCase ) ? name[..^".json".Length] : name;
					loaded.Add( FromJson( id, json ) );
				}
				catch ( Exception ex )
				{
					Log.Warning( $"[BlockParty] daily template '{path}' skipped: {ex.Message}" );
				}
			}

			if ( loaded.Count == 0 )
			{
				// Folder visible but nothing loaded: either the mount is mid-race (retry silently) or
				// every template file is gone/broken (a real authoring problem — the warnings above /
				// daily_validate_templates cover the broken-file case).
				return;
			}

			loaded.Sort( ( a, b ) => string.CompareOrdinal( a.Id, b.Id ) );
			_templates = loaded;
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[BlockParty] scanning '{DirName}': {ex.Message}" );
		}
	}

	// ── JSON ↔ runtime/editor conversions ────────────────────────────────────────────────────────────

	public static ObstacleTemplate FromJson( string id, ObstacleTemplateJson json )
	{
		var spawns = (json.Spawns ?? Array.Empty<float[]>()).Select( Point ).ToArray();

		// Sparse pins → aligned-with-spawns array (nulls = free slots).
		var pins = new PinnedBlock[spawns.Length];
		foreach ( var pin in json.Pins ?? Array.Empty<PinJson>() )
		{
			if ( pin is null || pin.Spawn < 0 || pin.Spawn >= pins.Length ) continue;
			if ( !Enum.TryParse<BlockType>( pin.Type, true, out var type ) ) continue;
			pins[pin.Spawn] = new PinnedBlock
			{
				Type = type,
				Phase = Math.Clamp( pin.Phase, 0, Block.NUM_PHASES - 1 ),
				PressedSides = (pin.Pressed ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray(),
			};
		}

		return new()
		{
			Id = id,
			Weight = Math.Max( 0f, json.Weight ),
			AllowMirrorX = json.AllowMirrorX,
			AuthoredSpikes = json.AuthoredSpikes,
			Obstacles = (json.Obstacles ?? Array.Empty<float[]>()).Select( Rect ).ToArray(),
			VisionObstacles = json.Vision ?? Array.Empty<int>(),
			FenceObstacles = json.Fences ?? Array.Empty<int>(),
			GlassObstacles = json.Glass ?? Array.Empty<int>(),
			SpawnPositions = spawns,
			SpawnPins = pins.Any( p => p is not null ) ? pins : Array.Empty<PinnedBlock>(),
			PlayerSpawns = json.Players is { Length: > 0 } list
				? list.Select( Point ).ToArray()
				: new[] { json.Player is { Length: 2 } p ? new Vector2( p[0], p[1] ) : new Vector2( 16, 40 ) },
			SpikeableWalls = (json.SpikeableWalls ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray(),
			SpikeableObstacleSides = (json.SpikeableObstacleSides ?? Array.Empty<ObstacleSpikeJson>())
				.Select( s => (s.Obstacle, (s.Sides ?? Array.Empty<string>()).Select( ParseDirection ).Where( d => d != Direction.None ).ToArray()) )
				.ToArray(),
			AlternatePlayfieldRects = (json.AltRects ?? Array.Empty<float[]>()).Select( Rect ).ToArray(),
			Coins = (json.Coins ?? Array.Empty<float[]>()).Select( Point ).ToArray(),
		};
	}

	/// <summary>Build the on-disk shape from an editor level. The editor's spiked walls/faces are
	/// spike CANDIDATES rolled per-side each day, unless the template's AuthoredSpikes toggle
	/// makes them spiked exactly as authored on every day.</summary>
	public static ObstacleTemplateJson BuildJson( EditorLevel level ) => new()
	{
		Obstacles = level.Obstacles.Select( Arr ).ToArray(),
		Vision = level.VisionObstacles.OrderBy( i => i ).ToArray(),
		Fences = level.FenceObstacles.OrderBy( i => i ).ToArray(),
		Glass = level.GlassObstacles.OrderBy( i => i ).ToArray(),
		Spawns = level.SpawnPositions.Select( Arr ).ToArray(),
		Player = level.PlayerSpawns.Count == 1 ? Arr( level.PlayerSpawns[0] ) : null,
		Players = level.PlayerSpawns.Count > 1 ? level.PlayerSpawns.Select( Arr ).ToArray() : null,
		SpikeableWalls = level.SpikedWalls.Select( d => d.ToString() ).ToArray(),
		SpikeableObstacleSides = level.SpikedObstacleSides
			.OrderBy( s => s.Obstacle )
			.Select( s => new ObstacleSpikeJson { Obstacle = s.Obstacle, Sides = s.Sides.Select( d => d.ToString() ).ToArray() } )
			.ToArray(),
		AltRects = level.AlternatePlayfieldRects.Select( Arr ).ToArray(),
		Coins = level.Coins.Count > 0 ? level.Coins.Select( Arr ).ToArray() : null,
		Pins = Enumerable.Range( 0, level.SpawnPositions.Count )
			.Where( i => level.SpawnPin( i ) is not null )
			.Select( i => new PinJson
			{
				Spawn = i,
				Type = level.SpawnPin( i ).Type.ToString(),
				Phase = level.SpawnPin( i ).Phase,
				Pressed = level.SpawnPin( i ).Pressed.Count > 0 ? level.SpawnPin( i ).Pressed.Select( d => d.ToString() ).ToArray() : null,
			} )
			.ToArray(),
	};

	/// <summary>Replace an editor level's LAYOUT with a template's: obstacles, vision flags, spawn
	/// grid, player spawn, spikes (candidates become the editor's spiked walls/faces) and alternate
	/// rects. Blocks, cosmetics, id/name etc. are left untouched so a template can seed a map level.</summary>
	public static void ApplyToEditor( ObstacleTemplate template, EditorLevel level )
	{
		level.Obstacles = new List<RectF>( template.Obstacles );
		level.VisionObstacles = new List<int>( template.VisionObstacles );
		level.FenceObstacles = new List<int>( template.FenceObstacles );
		level.GlassObstacles = new List<int>( template.GlassObstacles );
		level.SpikedWalls = new List<Direction>( template.SpikeableWalls );
		level.SpikedObstacleSides = template.SpikeableObstacleSides.Select( s =>
		{
			var spike = new ObstacleSpike { Obstacle = s.Obstacle };
			spike.Sides.AddRange( s.Sides );
			return spike;
		} ).ToList();

		// Every per-slot list must be cleared together with SpawnPositions: AddSpawn APPENDS to all of
		// them, so leaving one populated shifts it out of step and the old level's entries end up read
		// as the template's. Templates carry no start direction / turn-mode authoring (daily levels
		// don't use them), so both simply reset to their defaults.
		level.SpawnPositions.Clear();
		level.SpawnPins.Clear();
		level.SpawnAbilityTimers.Clear();
		level.SpawnDirections.Clear();
		level.SpawnTurnModes.Clear();
		for ( int i = 0; i < template.SpawnPositions.Length; i++ )
		{
			level.AddSpawn( template.SpawnPositions[i] );
			var pin = i < template.SpawnPins.Length ? template.SpawnPins[i] : null;
			if ( pin is not null )
				level.SetSpawnPin( i, new EditorBlock( pin.Type ) { Phase = pin.Phase, Pressed = pin.PressedSides.ToList() } );
		}

		level.PlayerSpawns = template.PlayerSpawns.Length > 0
			? new List<Vector2>( template.PlayerSpawns )
			: new List<Vector2> { new( 16, 40 ) };

		// The alt playfield is part of the LAYOUT, so it follows the template both ways: a template
		// without alt rects switches it back off, or the previous level's toggle would leave the
		// editor claiming a second pattern this template never had.
		level.AlternatePlayfieldRects = new List<RectF>( template.AlternatePlayfieldRects );
		level.AlternatePlayfieldEnabled = level.AlternatePlayfieldRects.Count > 0;

		level.Coins = new List<Vector2>( template.Coins );
	}

	/// <summary>Kebab-case a user-typed template name into a file/id-safe slug.</summary>
	public static string SanitizeId( string name )
	{
		var chars = (name ?? "").Trim().ToLowerInvariant()
			.Select( c => char.IsLetterOrDigit( c ) ? c : '-' )
			.ToArray();
		var id = new string( chars );
		while ( id.Contains( "--" ) ) id = id.Replace( "--", "-" );
		return id.Trim( '-' );
	}

	private static RectF Rect( float[] a ) => a is { Length: 4 }
		? new RectF( a[0], a[1], a[2], a[3] )
		: throw new FormatException( "rect must be [left,bottom,right,top]" );

	private static Vector2 Point( float[] a ) => a is { Length: 2 }
		? new Vector2( a[0], a[1] )
		: throw new FormatException( "point must be [x,y]" );

	private static float[] Arr( RectF r ) => new[] { r.Left, r.Bottom, r.Right, r.Top };
	private static float[] Arr( Vector2 p ) => new[] { p.x, p.y };

	private static Direction ParseDirection( string name )
		=> Enum.TryParse<Direction>( name, ignoreCase: true, out var d ) ? d : Direction.None;
}