Game/EditorLevel.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace BlockParty;

/// <summary>
/// A MUTABLE, editor-friendly mirror of <see cref="LevelDef"/> used by the in-editor
/// <see cref="LevelEditorStage"/>. <see cref="LevelDef"/> is immutable authored data; this class
/// holds the same information as editable lists so the editor can drag/toggle it in place, then
/// convert it via <see cref="ToJson"/> / <see cref="FromJson"/> (and the string forms
/// <see cref="ToJsonString"/> / <see cref="FromJsonString"/>) — the round-trip JSON form stored in
/// the <c>Assets/levels/*.json</c> files and used by the editor clipboard / paste import. The wire
/// shape is <see cref="LevelJson"/> (primitive fields only).
/// </summary>
public sealed class EditorLevel
{
	/// <summary>Version of the <see cref="LevelJson"/> wire shape. Parsing is otherwise lenient
	/// (missing keys default, unknown enum names skip), which would silently DEGRADE a layout authored
	/// by a newer build — fatal for replay determinism once levels travel between players (workshop).
	/// The reader refuses a higher <see cref="LevelJson.V"/> outright instead.</summary>
	public const int SCHEMA_VERSION = 1;

	// A fresh document starts with NO id/name: the project Save/Delete buttons stay inert until the
	// level is deliberately named, so scratch work can't overwrite (or delete) a real level.
	public string Id = "";
	public string Name = "";
	public string Music = "";
	public float MusicVolume = 1f;
	public bool IsTest;

	/// <summary>Project level ID this editor document was loaded from or last saved as. Session-only:
	/// used to distinguish updating the open level from overwriting a different level with the same ID.</summary>
	public string ProjectSourceId;

	/// <summary>False = exact <see cref="Blocks"/> list; true = <see cref="Pool"/> + <see cref="BlockCount"/>.</summary>
	public bool UsePool = false;

	/// <summary>Exact block list (type + optional starting phase / pre-pressed sides per entry).</summary>
	public List<EditorBlock> Blocks = new();
	/// <summary>Finite random pool. Each entry can be selected once; repeat a type to allow duplicates.</summary>
	public List<BlockType> Pool = new();
	public int BlockCount = 2;

	/// <summary>Pool mode: starting phase applied to every drawn block (0 = classic).</summary>
	public int PoolStartPhase = 0;

	public List<Vector2> SpawnPositions = new();

	/// <summary>Optional per-position PINNED BLOCK (type + phase + pressed sides), kept aligned with
	/// <see cref="SpawnPositions"/> (null = free slot). A pinned slot ALWAYS spawns its authored
	/// block, independent of and in addition to the random block count. Use <see cref="AddSpawn"/> /
	/// <see cref="RemoveSpawnAt"/> so the lists stay in lockstep.</summary>
	public List<EditorBlock> SpawnPins = new();
	public List<BlockAbilityTimer> SpawnAbilityTimers = new();
	public List<Direction> SpawnDirections = new();
	public List<TurnMode> SpawnTurnModes = new();

	/// <summary>Candidate player starts — always at least one (the editor never deletes the last).
	/// The run picks one from the run Rng when there's more than one. Exactly two map to Twin 1 then
	/// Twin 2; with three or more, Twins pick two distinct random starts.</summary>
	public List<Vector2> PlayerSpawns = new() { new( 20f, 7f ) };
	public string ForcedCharacterId;

	public List<RectF> Obstacles = new();
	public List<Direction> SpikedWalls = new();

	/// <summary>Obstacle-face spikes, referencing an obstacle by its INDEX into <see cref="Obstacles"/>
	/// (index-based so a spec follows its obstacle when the obstacle is moved/resized in the editor).</summary>
	public List<ObstacleSpike> SpikedObstacleSides = new();

	/// <summary>Indices into <see cref="Obstacles"/> whose rect also blocks line of sight (an obstacle
	/// does NOT block vision by default). Index-based so a flag follows its obstacle when others are
	/// added/removed (see <see cref="RemoveObstacleAt"/>); toggled per-obstacle in the editor.</summary>
	public List<int> VisionObstacles = new();

	/// <summary>Indices into <see cref="Obstacles"/> flagged as FENCES — obstacles solid ONLY to
	/// blocks (the player, lasers, hazards and fields pass straight through; spikes are not
	/// allowed on them, and they never merge with normal obstacles or arena walls). Mutually
	/// exclusive with <see cref="VisionObstacles"/> per obstacle; index-based like the vision
	/// flags so a flag follows its obstacle (see <see cref="RemoveObstacleAt"/>).</summary>
	public List<int> FenceObstacles = new();

	/// <summary>Indices into <see cref="Obstacles"/> flagged as GLASS — obstacles solid ONLY to
	/// players (every character collides, including the wrap character; blocks, hazards, lasers,
	/// fields and player projectiles pass through). Spikes ARE allowed on glass sides (player-only
	/// hazard, tinted teeth). Mutually exclusive with the fence and vision flags per obstacle;
	/// index-based like them (see <see cref="RemoveObstacleAt"/>).</summary>
	public List<int> GlassObstacles = new();

	/// <summary>Collectible coin centres (see <see cref="LevelDef.Coins"/>).</summary>
	public List<Vector2> Coins = new();

	public bool ShuffleTypes = true;

	// ── daily-template authoring state (EDITOR SESSION ONLY — never serialized; lives here so it
	// survives stage rebuilds/test-play round trips alongside the layout it describes) ─────────────
	public string DailyTemplateName = "";
	public bool DailyTemplateMirrorX;
	public bool DailyTemplateAuthoredSpikes;
	public float DailyTemplateWeight = 1f;

	public Color? WallColor;
	public Color? OutOfBoundsColor;
	public Color? CheckerboardColor;
	public Color? CheckerboardSecondColor;
	public Color? BackgroundBlockColor;
	/// <summary>Fence border colour (bars derive a brighter version); null = default amber.</summary>
	public Color? FenceColor;
	/// <summary>Glass colour (pane tint, streaks, border and teeth tint derive from it); null = default lavender.</summary>
	public Color? GlassColor;

	/// <summary>Exactly three, front → back, or null. Overrides <see cref="BackgroundBlockColor"/>.</summary>
	public List<Color> BackgroundBlockColors = null;
	public float BackgroundBlockScale = 1f;
	public float BackgroundBlockDensity = 1f;
	public float BackgroundBlockOpacity = 1f;
	public float BackgroundDriftSpeed = 1f;
	public BackgroundDriftBias BackgroundDriftBias;
	public PlayfieldPattern PlayfieldPattern;
	public int PlayfieldCellScale = 1;
	public List<string> PlayfieldPatternRows = null;
	public bool AlternatePlayfieldEnabled;
	public Color? AlternateCheckerboardColor;
	public Color? AlternateCheckerboardSecondColor;
	public PlayfieldPattern AlternatePlayfieldPattern;
	public int AlternatePlayfieldCellScale = 1;
	public List<string> AlternatePlayfieldPatternRows = null;
	public List<RectF> AlternatePlayfieldRects = new();

	// ── background particle ambience (see LevelDef.BackgroundParticlesEnabled) ──────────────────────
	public bool BackgroundParticlesEnabled;
	public Direction BackgroundParticleEdge = Direction.Up;
	public Color? BackgroundParticleColor;
	public float BackgroundParticleOpacity = 1f;
	public int BackgroundParticleSizeMin = 1;
	public int BackgroundParticleSizeMax = 2;
	public float BackgroundParticleAngle;
	public float BackgroundParticleAngleRange;
	public float BackgroundParticleSpeedMin = 90f;
	public float BackgroundParticleSpeedMax = 140f;
	public float BackgroundParticleGravity = 60f;
	public float BackgroundParticleSpawnRate = 12f;
	public int BackgroundParticleTrailLength;
	public Color? BackgroundParticleTrailColor;
	public bool BackgroundParticleTrailFade = true;
	public bool BackgroundParticleImpactEnabled;
	public int BackgroundParticleImpactCountMin = 2;
	public int BackgroundParticleImpactCountMax = 4;
	public float BackgroundParticleImpactSpeedMin = 15f;
	public float BackgroundParticleImpactSpeedMax = 40f;
	public int BackgroundParticleImpactSizeMin = 1;
	public int BackgroundParticleImpactSizeMax = 2;
	public float BackgroundParticleImpactGravity = 150f;
	public float BackgroundParticleImpactAngleRange = 60f;
	public Color? BackgroundParticleImpactColor;

	/// <summary>Reset every PURELY VISUAL field to its default, so what remains is the sim input alone.
	/// Used by <see cref="LevelDef.ContentHash"/>: a recorded run is re-validated against the level's
	/// hash, and cosmetics must not retire replays whose layout is untouched (the same reason Music is
	/// blanked there). Audio counts as cosmetic; the caller blanks Music itself.</summary>
	public void ClearCosmetics()
	{
		MusicVolume = 1f;
		WallColor = OutOfBoundsColor = CheckerboardColor = CheckerboardSecondColor = null;
		BackgroundBlockColor = FenceColor = GlassColor = null;
		BackgroundBlockColors = null;
		BackgroundBlockScale = BackgroundBlockDensity = BackgroundBlockOpacity = BackgroundDriftSpeed = 1f;
		BackgroundDriftBias = BackgroundDriftBias.None;
		PlayfieldPattern = PlayfieldPattern.Checker;
		PlayfieldCellScale = 1;
		PlayfieldPatternRows = null;

		// The alternate playfield is decoration over the SAME arena — its rects gate no collision.
		AlternatePlayfieldEnabled = false;
		AlternateCheckerboardColor = AlternateCheckerboardSecondColor = null;
		AlternatePlayfieldPattern = PlayfieldPattern.Checker;
		AlternatePlayfieldCellScale = 1;
		AlternatePlayfieldPatternRows = null;
		AlternatePlayfieldRects = new List<RectF>();

		// Particles run on the Background Rng and never touch the sim.
		BackgroundParticlesEnabled = false;
		BackgroundParticleEdge = Direction.Up;
		BackgroundParticleColor = BackgroundParticleTrailColor = BackgroundParticleImpactColor = null;
		BackgroundParticleOpacity = 1f;
		BackgroundParticleSizeMin = 1;
		BackgroundParticleSizeMax = 2;
		BackgroundParticleAngle = BackgroundParticleAngleRange = 0f;
		BackgroundParticleSpeedMin = 90f;
		BackgroundParticleSpeedMax = 140f;
		BackgroundParticleGravity = 60f;
		BackgroundParticleSpawnRate = 12f;
		BackgroundParticleTrailLength = 0;
		BackgroundParticleTrailFade = true;
		BackgroundParticleImpactEnabled = false;
		BackgroundParticleImpactCountMin = 2;
		BackgroundParticleImpactCountMax = 4;
		BackgroundParticleImpactSpeedMin = 15f;
		BackgroundParticleImpactSpeedMax = 40f;
		BackgroundParticleImpactSizeMin = 1;
		BackgroundParticleImpactSizeMax = 2;
		BackgroundParticleImpactGravity = 150f;
		BackgroundParticleImpactAngleRange = 60f;
	}

	/// <summary>Number of RANDOM blocks this level would spawn (pinned blocks are extra).</summary>
	public int Count => UsePool ? BlockCount : Blocks.Count;

	/// <summary>Describes an invalid finite-pool count, or null when the block selection is valid.</summary>
	public string PoolValidationError
	{
		get
		{
			if ( !UsePool ) return null;
			if ( BlockCount < 0 ) return $"count {BlockCount} cannot be negative";
			int poolCount = Pool?.Count ?? 0;
			return BlockCount > poolCount
				? $"count {BlockCount} exceeds {poolCount} pool entries; repeat a type in the pool to allow duplicates"
				: null;
		}
	}

	/// <summary>Spawn slots without a pinned block — what the random <see cref="Count"/> fills.</summary>
	public int FreeSpawnCount => SpawnPositions.Count - SpawnPins.Count( p => p is not null );

	/// <summary>True when every block this level spawns starts at max phase: the run is decided as won the
	/// instant it begins (GameStage.TryBeginVictory). Pins always spawn; random draws (sharing
	/// <see cref="PoolStartPhase"/> in pool mode) are capped by the free slots, as at runtime.</summary>
	public bool StartsComplete()
	{
		int max = Block.NUM_PHASES - 1;
		int spawned = 0;
		foreach ( var pin in SpawnPins )
		{
			if ( pin is null ) continue;
			if ( pin.Phase < max ) return false;
			spawned++;
		}
		if ( UsePool )
		{
			int draws = Math.Min( Math.Clamp( BlockCount, 0, Pool.Count ), FreeSpawnCount );
			if ( draws > 0 && PoolStartPhase < max ) return false;
			spawned += draws;
		}
		else
		{
			int placed = Math.Min( Blocks.Count, FreeSpawnCount );
			for ( int i = 0; i < placed; i++ )
				if ( Blocks[i].Phase < max ) return false;
			spawned += placed;
		}
		return spawned > 0;
	}

	/// <summary>Add a spawn position, keeping <see cref="SpawnPins"/> aligned (new slot free).
	/// Returns the new index.</summary>
	public int AddSpawn( Vector2 pos )
	{
		SpawnPositions.Add( pos );
		SpawnPins.Add( null );
		SpawnAbilityTimers.Add( null );
		SpawnDirections.Add( Direction.None );
		SpawnTurnModes.Add( TurnMode.Free );
		return SpawnPositions.Count - 1;
	}

	/// <summary>Remove a spawn position and its aligned pin.</summary>
	public void RemoveSpawnAt( int index )
	{
		if ( index < 0 || index >= SpawnPositions.Count ) return;
		SpawnPositions.RemoveAt( index );
		if ( index < SpawnPins.Count ) SpawnPins.RemoveAt( index );
		if ( index < SpawnAbilityTimers.Count ) SpawnAbilityTimers.RemoveAt( index );
		if ( index < SpawnDirections.Count ) SpawnDirections.RemoveAt( index );
		if ( index < SpawnTurnModes.Count ) SpawnTurnModes.RemoveAt( index );
	}

	/// <summary>The block pinned to spawn slot <paramref name="index"/>, or null when free / out of range.</summary>
	public EditorBlock SpawnPin( int index )
		=> index >= 0 && index < SpawnPins.Count ? SpawnPins[index] : null;

	/// <summary>Set (or clear, with null) the pinned block for spawn slot <paramref name="index"/>,
	/// padding <see cref="SpawnPins"/> if needed to stay aligned.</summary>
	public void SetSpawnPin( int index, EditorBlock pin )
	{
		if ( index < 0 ) return;
		while ( SpawnPins.Count <= index ) SpawnPins.Add( null );
		SpawnPins[index] = pin;
	}

	public Direction SpawnDirection( int index )
		=> index >= 0 && index < SpawnDirections.Count ? SpawnDirections[index] : Direction.None;

	public void SetSpawnDirection( int index, Direction direction )
	{
		if ( index < 0 || index >= SpawnPositions.Count ) return;
		while ( SpawnDirections.Count <= index ) SpawnDirections.Add( Direction.None );
		SpawnDirections[index] = direction;
	}

	public TurnMode SpawnTurnMode( int index )
		=> index >= 0 && index < SpawnTurnModes.Count ? SpawnTurnModes[index] : TurnMode.Free;

	public void SetSpawnTurnMode( int index, TurnMode mode )
	{
		if ( index < 0 || index >= SpawnPositions.Count ) return;
		while ( SpawnTurnModes.Count <= index ) SpawnTurnModes.Add( TurnMode.Free );
		SpawnTurnModes[index] = mode;
	}

	public float SpawnAbilityInterval( int index )
		=> SpawnAbilityTimerAt( index )?.Interval ?? Block.DEFAULT_ABILITY_INTERVAL;

	public float SpawnAbilityRandomness( int index )
		=> SpawnAbilityTimerAt( index )?.Randomness ?? 0f;

	public float SpawnAbilityStartOffset( int index )
		=> SpawnAbilityTimerAt( index )?.StartOffset ?? 0f;

	public float SpawnAbilityClosedTime( int index )
		=> SpawnAbilityTimerAt( index )?.ClosedTime ?? Block.DEFAULT_ABILITY_CLOSED_TIME;

	public void SetSpawnAbilityTimer( int index, float interval, float randomness, float startOffset, float closedTime )
	{
		if ( index < 0 || index >= SpawnPositions.Count ) return;
		while ( SpawnAbilityTimers.Count <= index ) SpawnAbilityTimers.Add( null );
		interval = MathF.Max( 0.05f, interval );
		randomness = Math.Clamp( randomness, 0f, MathF.Max( 0f, interval - 0.05f ) );
		startOffset = Math.Clamp( startOffset, 0f, interval );
		closedTime = MathF.Max( 0f, closedTime );
		SpawnAbilityTimers[index] = MathF.Abs( interval - Block.DEFAULT_ABILITY_INTERVAL ) < 0.001f
			&& randomness < 0.001f && startOffset < 0.001f
			&& MathF.Abs( closedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) < 0.001f
			? null
			: new BlockAbilityTimer { Interval = interval, Randomness = randomness, StartOffset = startOffset, ClosedTime = closedTime };
	}

	public bool SpawnBlockedInEveryDirection( int index )
	{
		if ( index < 0 || index >= SpawnPositions.Count ) return false;
		foreach ( Direction direction in Globals.GetAllDirections() )
			if ( !SpawnBlocked( index, direction ) ) return false;
		return true;
	}

	bool SpawnBlocked( int index, Direction direction )
	{
		const float half = 20f;
		Vector2 center = SpawnPositions[index] + Globals.GetVectorForDirection( direction );
		var probe = new RectF( center.x - half, center.y - half, center.x + half, center.y + half );
		if ( direction == Direction.Left && probe.Left < Arena.WALL_SIZE ) return true;
		if ( direction == Direction.Right && probe.Right > Arena.WIDTH - Arena.WALL_SIZE ) return true;
		if ( direction == Direction.Down && probe.Bottom < Arena.WALL_SIZE ) return true;
		if ( direction == Direction.Up && probe.Top > Arena.HEIGHT - Arena.WALL_SIZE ) return true;
		for ( int i = 0; i < Obstacles.Count; i++ )
		{
			if ( ObstacleIsGlass( i ) ) continue;   // glass is solid only to players — it can't box a block in
			if ( probe.Intersects( Obstacles[i] ) ) return true;
		}
		return false;
	}

	BlockAbilityTimer SpawnAbilityTimerAt( int index )
		=> index >= 0 && index < SpawnAbilityTimers.Count ? SpawnAbilityTimers[index] : null;

	/// <summary>Remove a player spawn — refused when it's the last one (a level always keeps at
	/// least one player spawn).</summary>
	public void RemovePlayerSpawnAt( int index )
	{
		if ( index < 0 || index >= PlayerSpawns.Count || PlayerSpawns.Count <= 1 ) return;
		PlayerSpawns.RemoveAt( index );
	}

	public void RemoveCoinAt( int index )
	{
		if ( index < 0 || index >= Coins.Count ) return;
		Coins.RemoveAt( index );
	}

	/// <summary>Remove an obstacle and keep the obstacle-spike specs consistent (drop specs on the
	/// removed obstacle; shift higher indices down).</summary>
	public void RemoveObstacleAt( int index )
	{
		if ( index < 0 || index >= Obstacles.Count ) return;
		Obstacles.RemoveAt( index );
		SpikedObstacleSides.RemoveAll( s => s.Obstacle == index );
		foreach ( var s in SpikedObstacleSides )
			if ( s.Obstacle > index ) s.Obstacle--;
		// Keep the vision-blocking and fence flags aligned with the shifted obstacle indices.
		VisionObstacles.RemoveAll( i => i == index );
		for ( int k = 0; k < VisionObstacles.Count; k++ )
			if ( VisionObstacles[k] > index ) VisionObstacles[k]--;
		FenceObstacles.RemoveAll( i => i == index );
		for ( int k = 0; k < FenceObstacles.Count; k++ )
			if ( FenceObstacles[k] > index ) FenceObstacles[k]--;
		GlassObstacles.RemoveAll( i => i == index );
		for ( int k = 0; k < GlassObstacles.Count; k++ )
			if ( GlassObstacles[k] > index ) GlassObstacles[k]--;
	}

	/// <summary>Toggle one outward-normal spike on an obstacle face (adds a spec if needed, removes the
	/// spec entirely when its last side is toggled off).</summary>
	public void ToggleObstacleSpike( int obstacleIndex, Direction side )
	{
		var spec = SpikedObstacleSides.FirstOrDefault( s => s.Obstacle == obstacleIndex );
		if ( spec is null )
		{
			SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = obstacleIndex, Sides = { side } } );
			return;
		}

		if ( spec.Sides.Contains( side ) )
		{
			spec.Sides.Remove( side );
			if ( spec.Sides.Count == 0 )
				SpikedObstacleSides.Remove( spec );
		}
		else
		{
			spec.Sides.Add( side );
		}
	}

	public bool ObstacleSpiked( int obstacleIndex, Direction side )
		=> SpikedObstacleSides.Any( s => s.Obstacle == obstacleIndex && s.Sides.Contains( side ) );

	/// <summary>True when obstacle <paramref name="index"/> is flagged to block line of sight.</summary>
	public bool ObstacleBlocksVision( int index ) => VisionObstacles.Contains( index );

	/// <summary>Toggle whether obstacle <paramref name="index"/> blocks line of sight. Turning it on
	/// clears the fence and glass flags — those are see-through by definition.</summary>
	public void ToggleObstacleVision( int index )
	{
		if ( index < 0 || index >= Obstacles.Count ) return;
		if ( !VisionObstacles.Remove( index ) )
		{
			VisionObstacles.Add( index );
			FenceObstacles.Remove( index );
			GlassObstacles.Remove( index );
		}
	}

	/// <summary>True when obstacle <paramref name="index"/> is flagged as a fence (solid only to blocks).</summary>
	public bool ObstacleIsFence( int index ) => FenceObstacles.Contains( index );

	/// <summary>Toggle whether obstacle <paramref name="index"/> is a fence. Turning it on clears the
	/// vision and glass flags (mutually exclusive) and strips any authored spikes — fences can't be
	/// spiked (the player never touches a fence, so a spiked face would be meaningless).</summary>
	public void ToggleObstacleFence( int index )
	{
		if ( index < 0 || index >= Obstacles.Count ) return;
		if ( !FenceObstacles.Remove( index ) )
		{
			FenceObstacles.Add( index );
			VisionObstacles.Remove( index );
			GlassObstacles.Remove( index );
			SpikedObstacleSides.RemoveAll( s => s.Obstacle == index );
		}
	}

	/// <summary>True when obstacle <paramref name="index"/> is flagged as glass (solid only to players).</summary>
	public bool ObstacleIsGlass( int index ) => GlassObstacles.Contains( index );

	/// <summary>Toggle whether obstacle <paramref name="index"/> is glass. Turning it on clears the
	/// fence and vision flags (mutually exclusive). Authored spikes are KEPT — glass sides may be
	/// spiked (a player-only hazard, drawn with tinted teeth).</summary>
	public void ToggleObstacleGlass( int index )
	{
		if ( index < 0 || index >= Obstacles.Count ) return;
		if ( !GlassObstacles.Remove( index ) )
		{
			GlassObstacles.Add( index );
			VisionObstacles.Remove( index );
			FenceObstacles.Remove( index );
		}
	}

	// ── conversion to/from the immutable LevelDef ───────────────────────────────────────────────────

	public static EditorLevel FromLevelDef( LevelDef d )
	{
		var e = new EditorLevel
		{
			Id = d.Id ?? "my-level",
			Name = d.Name ?? "MY LEVEL",
			Music = d.Music ?? "",
			MusicVolume = d.MusicVolume,
			IsTest = d.IsTest,
			ProjectSourceId = d.Id,
			PlayerSpawns = d.PlayerSpawns is { Count: > 0 } ? d.PlayerSpawns.ToList() : new List<Vector2> { new( 16f, 40f ) },
			ForcedCharacterId = d.ForcedCharacterId,
			ShuffleTypes = d.ShuffleTypes,
			WallColor = d.WallColor,
			OutOfBoundsColor = d.OutOfBoundsColor,
			CheckerboardColor = d.CheckerboardColor,
			CheckerboardSecondColor = d.CheckerboardSecondColor,
			BackgroundBlockColor = d.BackgroundBlockColor,
			FenceColor = d.FenceColor,
			GlassColor = d.GlassColor,
			BackgroundBlockScale = d.BackgroundBlockScale,
			BackgroundBlockDensity = d.BackgroundBlockDensity,
			BackgroundBlockOpacity = d.BackgroundBlockOpacity,
			BackgroundDriftSpeed = d.BackgroundDriftSpeed,
			BackgroundDriftBias = d.BackgroundDriftBias,
			PlayfieldPattern = d.PlayfieldPattern,
			PlayfieldCellScale = d.PlayfieldCellScale,
			PlayfieldPatternRows = d.PlayfieldPatternRows?.ToList(),
			AlternatePlayfieldEnabled = d.AlternatePlayfieldEnabled,
			AlternateCheckerboardColor = d.AlternateCheckerboardColor,
			AlternateCheckerboardSecondColor = d.AlternateCheckerboardSecondColor,
			AlternatePlayfieldPattern = d.AlternatePlayfieldPattern,
			AlternatePlayfieldCellScale = d.AlternatePlayfieldCellScale,
			AlternatePlayfieldPatternRows = d.AlternatePlayfieldPatternRows?.ToList(),
			AlternatePlayfieldRects = ( d.AlternatePlayfieldRects ?? Array.Empty<RectF>() ).ToList(),
			BackgroundParticlesEnabled = d.BackgroundParticlesEnabled,
			BackgroundParticleEdge = d.BackgroundParticleEdge,
			BackgroundParticleColor = d.BackgroundParticleColor,
			BackgroundParticleOpacity = d.BackgroundParticleOpacity,
			BackgroundParticleSizeMin = d.BackgroundParticleSizeMin,
			BackgroundParticleSizeMax = d.BackgroundParticleSizeMax,
			BackgroundParticleAngle = d.BackgroundParticleAngle,
			BackgroundParticleAngleRange = d.BackgroundParticleAngleRange,
			BackgroundParticleSpeedMin = d.BackgroundParticleSpeedMin,
			BackgroundParticleSpeedMax = d.BackgroundParticleSpeedMax,
			BackgroundParticleGravity = d.BackgroundParticleGravity,
			BackgroundParticleSpawnRate = d.BackgroundParticleSpawnRate,
			BackgroundParticleTrailLength = d.BackgroundParticleTrailLength,
			BackgroundParticleTrailColor = d.BackgroundParticleTrailColor,
			BackgroundParticleTrailFade = d.BackgroundParticleTrailFade,
			BackgroundParticleImpactEnabled = d.BackgroundParticleImpactEnabled,
			BackgroundParticleImpactCountMin = d.BackgroundParticleImpactCountMin,
			BackgroundParticleImpactCountMax = d.BackgroundParticleImpactCountMax,
			BackgroundParticleImpactSpeedMin = d.BackgroundParticleImpactSpeedMin,
			BackgroundParticleImpactSpeedMax = d.BackgroundParticleImpactSpeedMax,
			BackgroundParticleImpactSizeMin = d.BackgroundParticleImpactSizeMin,
			BackgroundParticleImpactSizeMax = d.BackgroundParticleImpactSizeMax,
			BackgroundParticleImpactGravity = d.BackgroundParticleImpactGravity,
			BackgroundParticleImpactAngleRange = d.BackgroundParticleImpactAngleRange,
			BackgroundParticleImpactColor = d.BackgroundParticleImpactColor,
		};

		if ( d.Blocks is not null )
		{
			e.UsePool = false;
			e.Blocks = new List<EditorBlock>();
			for ( int i = 0; i < d.Blocks.Count; i++ )
			{
				var eb = new EditorBlock( d.Blocks[i] );
				var st = d.BlockStarts is not null && i < d.BlockStarts.Count ? d.BlockStarts[i] : null;
				if ( st is not null )
				{
					eb.Phase = st.Phase;
					if ( st.PressedSides is not null ) eb.Pressed = st.PressedSides.ToList();
				}
				e.Blocks.Add( eb );
			}
		}
		else
		{
			e.UsePool = true;
			e.Pool = ( d.Pool ?? Array.Empty<BlockType>() ).ToList();
			e.BlockCount = d.BlockCount;
		}
		e.PoolStartPhase = d.PoolStartPhase;

		e.SpawnPositions = ( d.SpawnPositions ?? Array.Empty<Vector2>() ).ToList();
		e.SpawnPins = new List<EditorBlock>();
		e.SpawnAbilityTimers = new List<BlockAbilityTimer>();
		e.SpawnDirections = new List<Direction>();
		e.SpawnTurnModes = new List<TurnMode>();
		for ( int i = 0; i < e.SpawnPositions.Count; i++ )
		{
			var pin = d.SpawnPins is not null && i < d.SpawnPins.Count ? d.SpawnPins[i] : null;
			e.SpawnPins.Add( pin is null ? null : new EditorBlock( pin.Type ) { Phase = pin.Phase, Pressed = pin.PressedSides.ToList() } );
			e.SpawnAbilityTimers.Add( d.SpawnAbilityTimers is not null && i < d.SpawnAbilityTimers.Count ? d.SpawnAbilityTimers[i] : null );
			e.SpawnDirections.Add( d.SpawnDirections is not null && i < d.SpawnDirections.Count ? d.SpawnDirections[i] : Direction.None );
			e.SpawnTurnModes.Add( d.SpawnTurnModes is not null && i < d.SpawnTurnModes.Count ? d.SpawnTurnModes[i] : TurnMode.Free );
		}

		e.Obstacles = ( d.Obstacles ?? Array.Empty<RectF>() ).ToList();
		e.SpikedWalls = ( d.SpikedWalls ?? Array.Empty<Direction>() ).ToList();
		e.Coins = ( d.Coins ?? Array.Empty<Vector2>() ).ToList();

		// A LevelDef vision blocker is now the rect of an OBSTACLE flagged to block sight — match each
		// back to a DISTINCT obstacle index so duplicate rects don't all collapse onto the first match
		// (rects not matching an obstacle are dropped: standalone vision blockers no longer exist).
		foreach ( var vr in d.VisionBlockers ?? Array.Empty<RectF>() )
		{
			for ( int i = 0; i < e.Obstacles.Count; i++ )
				if ( RectEquals( e.Obstacles[i], vr ) && !e.VisionObstacles.Contains( i ) )
				{
					e.VisionObstacles.Add( i );
					break;
				}
		}

		// Fence / glass flags: same distinct-index matching as the vision blockers above.
		foreach ( var fr in d.Fences ?? Array.Empty<RectF>() )
		{
			for ( int i = 0; i < e.Obstacles.Count; i++ )
				if ( RectEquals( e.Obstacles[i], fr ) && !e.FenceObstacles.Contains( i ) )
				{
					e.FenceObstacles.Add( i );
					break;
				}
		}
		foreach ( var gr in d.Glass ?? Array.Empty<RectF>() )
		{
			for ( int i = 0; i < e.Obstacles.Count; i++ )
				if ( RectEquals( e.Obstacles[i], gr ) && !e.GlassObstacles.Contains( i ) && !e.FenceObstacles.Contains( i ) )
				{
					e.GlassObstacles.Add( i );
					break;
				}
		}

		foreach ( var spec in d.SpikedObstacleSides ?? Array.Empty<ObstacleSpikeSpec>() )
		{
			int idx = e.Obstacles.FindIndex( r => RectEquals( r, spec.Rect ) );
			if ( idx < 0 ) continue; // spec references a rect not in Obstacles — skip (matches game behaviour)
			e.SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = idx, Sides = spec.Sides.ToList() } );
		}

		if ( d.BackgroundBlockColors is { Count: 3 } bgc )
			e.BackgroundBlockColors = bgc.ToList();

		return e;
	}

	public LevelDef ToLevelDef()
	{
		var def = new LevelDef
		{
			Id = Id,
			Name = Name,
			Music = string.IsNullOrWhiteSpace( Music ) ? null : Music.Trim(),
			MusicVolume = MusicVolume,
			IsTest = IsTest,
			PlayerSpawns = PlayerSpawns.Count > 0 ? PlayerSpawns.ToArray() : new[] { new Vector2( 16f, 40f ) },
			ForcedCharacterId = ForcedCharacterId,
			SpawnPositions = SpawnPositions.ToArray(),
			SpawnDirections = SpawnDirections.ToArray(),
			SpawnTurnModes = SpawnTurnModes.ToArray(),
			Obstacles = Obstacles.ToArray(),
			SpikedWalls = SpikedWalls.ToArray(),
			Coins = Coins.ToArray(),
			// Vision blockers / fences / glass are the rects of the obstacles carrying each flag.
			VisionBlockers = VisionObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
			Fences = FenceObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
			Glass = GlassObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Select( i => Obstacles[i] ).ToArray(),
			ShuffleTypes = ShuffleTypes,
			WallColor = WallColor,
			OutOfBoundsColor = OutOfBoundsColor,
			CheckerboardColor = CheckerboardColor,
			CheckerboardSecondColor = CheckerboardSecondColor,
			BackgroundBlockColor = BackgroundBlockColor,
			FenceColor = FenceColor,
			GlassColor = GlassColor,
			BackgroundBlockColors = BackgroundBlockColors?.ToArray(),
			BackgroundBlockScale = BackgroundBlockScale,
			BackgroundBlockDensity = BackgroundBlockDensity,
			BackgroundBlockOpacity = BackgroundBlockOpacity,
			BackgroundDriftSpeed = BackgroundDriftSpeed,
			BackgroundDriftBias = BackgroundDriftBias,
			PlayfieldPattern = PlayfieldPattern,
			PlayfieldCellScale = PlayfieldCellScale,
			PlayfieldPatternRows = PlayfieldPatternRows?.ToArray(),
			AlternatePlayfieldEnabled = AlternatePlayfieldEnabled,
			AlternateCheckerboardColor = AlternateCheckerboardColor,
			AlternateCheckerboardSecondColor = AlternateCheckerboardSecondColor,
			AlternatePlayfieldPattern = AlternatePlayfieldPattern,
			AlternatePlayfieldCellScale = AlternatePlayfieldCellScale,
			AlternatePlayfieldPatternRows = AlternatePlayfieldPatternRows?.ToArray(),
			AlternatePlayfieldRects = AlternatePlayfieldRects.ToArray(),
			BackgroundParticlesEnabled = BackgroundParticlesEnabled,
			BackgroundParticleEdge = BackgroundParticleEdge,
			BackgroundParticleColor = BackgroundParticleColor,
			BackgroundParticleOpacity = BackgroundParticleOpacity,
			BackgroundParticleSizeMin = BackgroundParticleSizeMin,
			BackgroundParticleSizeMax = BackgroundParticleSizeMax,
			BackgroundParticleAngle = BackgroundParticleAngle,
			BackgroundParticleAngleRange = BackgroundParticleAngleRange,
			BackgroundParticleSpeedMin = BackgroundParticleSpeedMin,
			BackgroundParticleSpeedMax = BackgroundParticleSpeedMax,
			BackgroundParticleGravity = BackgroundParticleGravity,
			BackgroundParticleSpawnRate = BackgroundParticleSpawnRate,
			BackgroundParticleTrailLength = BackgroundParticleTrailLength,
			BackgroundParticleTrailColor = BackgroundParticleTrailColor,
			BackgroundParticleTrailFade = BackgroundParticleTrailFade,
			BackgroundParticleImpactEnabled = BackgroundParticleImpactEnabled,
			BackgroundParticleImpactCountMin = BackgroundParticleImpactCountMin,
			BackgroundParticleImpactCountMax = BackgroundParticleImpactCountMax,
			BackgroundParticleImpactSpeedMin = BackgroundParticleImpactSpeedMin,
			BackgroundParticleImpactSpeedMax = BackgroundParticleImpactSpeedMax,
			BackgroundParticleImpactSizeMin = BackgroundParticleImpactSizeMin,
			BackgroundParticleImpactSizeMax = BackgroundParticleImpactSizeMax,
			BackgroundParticleImpactGravity = BackgroundParticleImpactGravity,
			BackgroundParticleImpactAngleRange = BackgroundParticleImpactAngleRange,
			BackgroundParticleImpactColor = BackgroundParticleImpactColor,
		};

		if ( UsePool )
		{
			def.Pool = Pool.ToArray();
			def.BlockCount = BlockCount;
			def.PoolStartPhase = PoolStartPhase;
		}
		else
		{
			def.Blocks = Blocks.Select( b => b.Type ).ToArray();
			// Only attach per-block start configs when at least one block departs from the classic
			// phase-0 / no-pressed-sides default (keeps simple levels' data clean).
			if ( Blocks.Any( b => b.Phase != 0 || b.Pressed.Count > 0 ) )
				def.BlockStarts = Blocks
					.Select( b => new BlockStart { Phase = b.Phase, PressedSides = b.Pressed.ToArray() } )
					.ToArray();
		}

		// Pinned blocks — only when at least one slot is pinned.
		if ( SpawnPins.Any( p => p is not null ) )
		{
			var pins = new PinnedBlock[SpawnPositions.Count];
			for ( int i = 0; i < pins.Length; i++ )
			{
				var pin = i < SpawnPins.Count ? SpawnPins[i] : null;
				pins[i] = pin is null ? null : new PinnedBlock { Type = pin.Type, Phase = pin.Phase, PressedSides = pin.Pressed.ToArray() };
			}
			def.SpawnPins = pins;
		}

		if ( SpawnAbilityTimers.Any( t => t is not null ) )
		{
			var timers = new BlockAbilityTimer[SpawnPositions.Count];
			for ( int i = 0; i < timers.Length; i++ ) timers[i] = SpawnAbilityTimerAt( i );
			def.SpawnAbilityTimers = timers;
		}

		if ( SpawnDirections.Any( d => d != Direction.None ) )
			def.SpawnDirections = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnDirection ).ToArray();

		def.SpikedObstacleSides = SpikedObstacleSides
			.Where( s => s.Obstacle >= 0 && s.Obstacle < Obstacles.Count && s.Sides.Count > 0 )
			.Select( s => new ObstacleSpikeSpec( Obstacles[s.Obstacle], s.Sides.ToArray() ) )
			.ToArray();

		return def;
	}

	// ── JSON serialization (the Assets/levels/*.json files store this DTO shape) ─────────────────────

	/// <summary>Build the plain-DTO wire form (primitive fields only, enums as names) written to the
	/// level JSON files. <paramref name="fullAuthoringState"/> additionally emits fields the lean wire
	/// form drops as inactive (the non-active mode's block list, disabled alt-playfield settings,
	/// custom pattern rows under a non-Custom pattern) — used by editor undo snapshots, which must
	/// round-trip the whole authoring session, not just what the level currently plays with.</summary>
	public LevelJson ToJson( bool fullAuthoringState = false )
	{
		var j = new LevelJson
		{
			V = SCHEMA_VERSION,
			Id = Id,
			Name = Name,
			Music = string.IsNullOrWhiteSpace( Music ) ? null : Music.Trim(),
			Test = IsTest,
			Mode = UsePool ? "pool" : "exact",
			BlockCount = BlockCount,
			Players = PlayerSpawns.Select( p => new[] { p.x, p.y } ).ToArray(),
			Character = string.IsNullOrWhiteSpace( ForcedCharacterId ) ? null : ForcedCharacterId,
			Shuffle = ShuffleTypes,
			Spawns = SpawnPositions.Select( p => new[] { p.x, p.y } ).ToArray(),
		};
		if ( MathF.Abs( MusicVolume - 1f ) > 0.001f )
			j.MusicVolume = MusicVolume;
		if ( SpawnDirections.Any( d => d != Direction.None ) )
			j.SpawnDirections = Enumerable.Range( 0, SpawnPositions.Count ).Select( i => SpawnDirection( i ).ToString() ).ToArray();
		if ( SpawnTurnModes.Any( mode => mode != TurnMode.Free ) )
			j.SpawnTurnModes = Enumerable.Range( 0, SpawnPositions.Count ).Select( i => SpawnTurnMode( i ).ToString() ).ToArray();

		if ( UsePool || fullAuthoringState )
		{
			j.Pool = Pool.Select( b => b.ToString() ).ToArray();
			j.PoolStartPhase = PoolStartPhase;
		}
		if ( !UsePool || fullAuthoringState )
		{
			j.Blocks = Blocks.Select( b => b.Type.ToString() ).ToArray();
			// Parallel, aligned-with-Blocks arrays; emitted only when something is non-default so the
			// common case stays as a bare type list.
			if ( Blocks.Any( b => b.Phase != 0 ) )
				j.BlockPhases = Blocks.Select( b => b.Phase ).ToArray();
			if ( Blocks.Any( b => b.Pressed.Count > 0 ) )
				j.BlockPressed = Blocks.Select( b => b.Pressed.Select( d => d.ToString() ).ToArray() ).ToArray();
		}

		if ( Obstacles.Count > 0 ) j.Obstacles = Obstacles.Select( RectArr ).ToArray();
		if ( SpikedWalls.Count > 0 ) j.SpikedWalls = SpikedWalls.Select( d => d.ToString() ).ToArray();
		if ( Coins.Count > 0 ) j.Coins = Coins.Select( p => new[] { p.x, p.y } ).ToArray();

		var specs = SpikedObstacleSides.Where( s => s.Sides.Count > 0 ).ToList();
		if ( specs.Count > 0 )
			j.SpikedObstacleSides = specs
				.Select( s => new ObstacleSpikeJson { Obstacle = s.Obstacle, Sides = s.Sides.Select( d => d.ToString() ).ToArray() } )
				.ToArray();

		// Store the flagged obstacle indices directly — storing rects was lossy when two obstacles
		// shared a rect (every vision rect resolved to the first match on load).
		if ( VisionObstacles.Count > 0 )
		{
			var visionIdx = VisionObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
			if ( visionIdx.Length > 0 ) j.VisionIdx = visionIdx;
		}
		if ( FenceObstacles.Count > 0 )
		{
			var fenceIdx = FenceObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
			if ( fenceIdx.Length > 0 ) j.FenceIdx = fenceIdx;
		}
		if ( GlassObstacles.Count > 0 )
		{
			var glassIdx = GlassObstacles.Where( i => i >= 0 && i < Obstacles.Count ).Distinct().ToArray();
			if ( glassIdx.Length > 0 ) j.GlassIdx = glassIdx;
		}

		// Pinned blocks — a sparse list (spawn index + type/phase/pressed), emitted only when at
		// least one slot is pinned.
		if ( SpawnPins.Any( p => p is not null ) )
			j.Pins = Enumerable.Range( 0, SpawnPositions.Count )
				.Where( i => i < SpawnPins.Count && SpawnPins[i] is not null )
				.Select( i => new PinJson
				{
					Spawn = i,
					Type = SpawnPins[i].Type.ToString(),
					Phase = SpawnPins[i].Phase,
					Pressed = SpawnPins[i].Pressed.Count > 0 ? SpawnPins[i].Pressed.Select( d => d.ToString() ).ToArray() : null,
				} )
				.ToArray();
		if ( SpawnAbilityTimers.Any( t => t is not null && MathF.Abs( t.Interval - Block.DEFAULT_ABILITY_INTERVAL ) >= 0.001f ) )
			j.AbilityIntervals = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityInterval ).ToArray();
		if ( SpawnAbilityTimers.Any( t => t is not null && t.Randomness >= 0.001f ) )
			j.AbilityRandomness = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityRandomness ).ToArray();
		if ( SpawnAbilityTimers.Any( t => t is not null && t.StartOffset >= 0.001f ) )
			j.AbilityStartOffsets = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityStartOffset ).ToArray();
		if ( SpawnAbilityTimers.Any( t => t is not null && MathF.Abs( t.ClosedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) >= 0.001f ) )
			j.AbilityClosedTimes = Enumerable.Range( 0, SpawnPositions.Count ).Select( SpawnAbilityClosedTime ).ToArray();

		j.Wall = ColArr( WallColor );
		j.Oob = ColArr( OutOfBoundsColor );
		j.Checker = ColArr( CheckerboardColor );
		j.Checker2 = ColArr( CheckerboardSecondColor );
		j.BgBlock = ColArr( BackgroundBlockColor );
		j.FenceCol = ColArr( FenceColor );
		j.GlassCol = ColArr( GlassColor );
		if ( BackgroundBlockColors is { Count: 3 } bgc )
			j.BgBlocks = bgc.Select( c => ColArr( c ) ).ToArray();
		if ( MathF.Abs( BackgroundBlockScale - 1f ) > 0.001f )
			j.BgBlockScale = BackgroundBlockScale;
		if ( MathF.Abs( BackgroundBlockDensity - 1f ) > 0.001f )
			j.BgBlockDensity = BackgroundBlockDensity;
		if ( MathF.Abs( BackgroundBlockOpacity - 1f ) > 0.001f )
			j.BgBlockOpacity = BackgroundBlockOpacity;
		if ( MathF.Abs( BackgroundDriftSpeed - 1f ) > 0.001f )
			j.BgDriftSpeed = BackgroundDriftSpeed;
		if ( BackgroundDriftBias != BackgroundDriftBias.None )
			j.BgDriftBias = BackgroundDriftBias.ToString();
		if ( PlayfieldPattern != PlayfieldPattern.Checker )
			j.Pattern = PlayfieldPattern.ToString();
		if ( PlayfieldCellScale != 1 )
			j.CellScale = PlayfieldCellScale;
		if ( ( PlayfieldPattern == PlayfieldPattern.Custom || fullAuthoringState ) && PlayfieldPatternRows is { Count: > 0 } )
			j.PatternRows = PlayfieldPatternRows.Take( 8 ).ToArray();
		if ( AlternatePlayfieldEnabled || fullAuthoringState )
		{
			j.AltEnabled = AlternatePlayfieldEnabled;
			j.AltChecker = ColArr( AlternateCheckerboardColor );
			j.AltChecker2 = ColArr( AlternateCheckerboardSecondColor );
			j.AltPattern = AlternatePlayfieldPattern.ToString();
			j.AltCellScale = AlternatePlayfieldCellScale;
			if ( ( AlternatePlayfieldPattern == PlayfieldPattern.Custom || fullAuthoringState ) && AlternatePlayfieldPatternRows is { Count: > 0 } )
				j.AltPatternRows = AlternatePlayfieldPatternRows.Take( 8 ).ToArray();
			if ( AlternatePlayfieldRects.Count > 0 )
				j.AltRects = AlternatePlayfieldRects.Select( RectArr ).ToArray();
		}
		if ( BackgroundParticlesEnabled || fullAuthoringState )
		{
			j.BgParticles = BackgroundParticlesEnabled;
			j.BgPartEdge = BackgroundParticleEdge.ToString();
			j.BgPartCol = ColArr( BackgroundParticleColor );
			j.BgPartOpacity = BackgroundParticleOpacity;
			j.BgPartSizeMin = BackgroundParticleSizeMin;
			j.BgPartSizeMax = BackgroundParticleSizeMax;
			j.BgPartAngle = BackgroundParticleAngle;
			j.BgPartAngleRange = BackgroundParticleAngleRange;
			j.BgPartSpeedMin = BackgroundParticleSpeedMin;
			j.BgPartSpeedMax = BackgroundParticleSpeedMax;
			j.BgPartGravity = BackgroundParticleGravity;
			j.BgPartRate = BackgroundParticleSpawnRate;
			j.BgPartTrail = BackgroundParticleTrailLength;
			j.BgPartTrailCol = ColArr( BackgroundParticleTrailColor );
			j.BgPartTrailFade = BackgroundParticleTrailFade;
			j.BgPartImpact = BackgroundParticleImpactEnabled;
			j.BgPartImpactCountMin = BackgroundParticleImpactCountMin;
			j.BgPartImpactCountMax = BackgroundParticleImpactCountMax;
			j.BgPartImpactSpeedMin = BackgroundParticleImpactSpeedMin;
			j.BgPartImpactSpeedMax = BackgroundParticleImpactSpeedMax;
			j.BgPartImpactSizeMin = BackgroundParticleImpactSizeMin;
			j.BgPartImpactSizeMax = BackgroundParticleImpactSizeMax;
			j.BgPartImpactGravity = BackgroundParticleImpactGravity;
			j.BgPartImpactAngleRange = BackgroundParticleImpactAngleRange;
			j.BgPartImpactCol = ColArr( BackgroundParticleImpactColor );
		}

		return j;
	}

	/// <summary>Rebuild an editable level from the JSON DTO (unknown enum names skipped leniently).</summary>
	public static EditorLevel FromJson( LevelJson j )
	{
		var e = new EditorLevel
		{
			// Blank id/name survive the round trip: they mark an unnamed document (Save/Delete inert),
			// and an undo snapshot of one must not resurrect as a saveable "my-level".
			Id = j.Id ?? "",
			Name = j.Name ?? "",
			Music = j.Music ?? "",
			MusicVolume = Math.Clamp( j.MusicVolume ?? 1f, 0f, 1.5f ),
			IsTest = j.Test,
			UsePool = string.Equals( j.Mode, "pool", StringComparison.OrdinalIgnoreCase ),
			BlockCount = j.BlockCount,
			PoolStartPhase = j.PoolStartPhase,
			ForcedCharacterId = string.IsNullOrWhiteSpace( j.Character ) ? null : j.Character,
			ShuffleTypes = j.Shuffle,
			Pool = ParseBlockNames( j.Pool ),
			SpikedWalls = ParseDirNames( j.SpikedWalls ),
		};

		// Exact blocks: iterate the raw name array so BlockPhases/BlockPressed stay index-aligned even
		// if a bad name is skipped.
		e.Blocks = new List<EditorBlock>();
		var rawBlocks = j.Blocks ?? Array.Empty<string>();
		for ( int i = 0; i < rawBlocks.Length; i++ )
		{
			if ( !Enum.TryParse<BlockType>( rawBlocks[i], true, out var bt ) ) continue;
			var eb = new EditorBlock( bt );
			if ( j.BlockPhases is not null && i < j.BlockPhases.Length ) eb.Phase = j.BlockPhases[i];
			if ( j.BlockPressed is not null && i < j.BlockPressed.Length ) eb.Pressed = ParseDirNames( j.BlockPressed[i] );
			e.Blocks.Add( eb );
		}

		// Candidate player spawns — a single-spawn level is just a one-entry list.
		var players = ( j.Players ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();
		if ( players.Count > 0 ) e.PlayerSpawns = players;
		e.SpawnPositions = ( j.Spawns ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();

		// Pinned blocks (sparse list of spawn index + type/phase/pressed), plus per-slot ability timers.
		e.SpawnPins = new List<EditorBlock>();
		e.SpawnAbilityTimers = new List<BlockAbilityTimer>();
		e.SpawnDirections = new List<Direction>();
		e.SpawnTurnModes = new List<TurnMode>();
		for ( int i = 0; i < e.SpawnPositions.Count; i++ )
		{
			e.SpawnPins.Add( null );
			e.SpawnDirections.Add( j.SpawnDirections is not null && i < j.SpawnDirections.Length
				&& Enum.TryParse<Direction>( j.SpawnDirections[i], true, out var direction )
				&& Enum.IsDefined( direction )
				? direction
				: Direction.None );
			e.SpawnTurnModes.Add( j.SpawnTurnModes is not null && i < j.SpawnTurnModes.Length
				&& Enum.TryParse<TurnMode>( j.SpawnTurnModes[i], true, out var turnMode )
				&& Enum.IsDefined( turnMode )
				? turnMode
				: TurnMode.Free );
			float interval = j.AbilityIntervals is not null && i < j.AbilityIntervals.Length
				? MathF.Max( 0.05f, j.AbilityIntervals[i] )
				: Block.DEFAULT_ABILITY_INTERVAL;
			float randomness = j.AbilityRandomness is not null && i < j.AbilityRandomness.Length
				? Math.Clamp( j.AbilityRandomness[i], 0f, MathF.Max( 0f, interval - 0.05f ) )
				: 0f;
			float startOffset = j.AbilityStartOffsets is not null && i < j.AbilityStartOffsets.Length
				? Math.Clamp( j.AbilityStartOffsets[i], 0f, interval )
				: 0f;
			float closedTime = j.AbilityClosedTimes is not null && i < j.AbilityClosedTimes.Length
				? MathF.Max( 0f, j.AbilityClosedTimes[i] )
				: Block.DEFAULT_ABILITY_CLOSED_TIME;
			e.SpawnAbilityTimers.Add( MathF.Abs( interval - Block.DEFAULT_ABILITY_INTERVAL ) < 0.001f
				&& randomness < 0.001f && startOffset < 0.001f
				&& MathF.Abs( closedTime - Block.DEFAULT_ABILITY_CLOSED_TIME ) < 0.001f
				? null
				: new BlockAbilityTimer { Interval = interval, Randomness = randomness, StartOffset = startOffset, ClosedTime = closedTime } );
		}
		foreach ( var pin in j.Pins ?? Array.Empty<PinJson>() )
		{
			if ( pin is null || pin.Spawn < 0 || pin.Spawn >= e.SpawnPins.Count ) continue;
			if ( !Enum.TryParse<BlockType>( pin.Type, true, out var pt ) ) continue;
			e.SpawnPins[pin.Spawn] = new EditorBlock( pt )
			{
				Phase = Math.Clamp( pin.Phase, 0, Block.NUM_PHASES - 1 ),
				Pressed = ParseDirNames( pin.Pressed ),
			};
		}

		e.Obstacles = ( j.Obstacles ?? Array.Empty<float[]>() ).Where( a => a is { Length: 4 } ).Select( ArrRect ).ToList();
		e.Coins = ( j.Coins ?? Array.Empty<float[]>() ).Where( a => a is { Length: 2 } ).Select( a => new Vector2( a[0], a[1] ) ).ToList();

		// Vision blockers / fences: the indices of obstacles carrying each flag.
		foreach ( var idx in j.VisionIdx ?? Array.Empty<int>() )
			if ( idx >= 0 && idx < e.Obstacles.Count && !e.VisionObstacles.Contains( idx ) )
				e.VisionObstacles.Add( idx );
		foreach ( var idx in j.FenceIdx ?? Array.Empty<int>() )
			if ( idx >= 0 && idx < e.Obstacles.Count && !e.FenceObstacles.Contains( idx ) )
				e.FenceObstacles.Add( idx );
		foreach ( var idx in j.GlassIdx ?? Array.Empty<int>() )
			if ( idx >= 0 && idx < e.Obstacles.Count && !e.GlassObstacles.Contains( idx ) && !e.FenceObstacles.Contains( idx ) )
				e.GlassObstacles.Add( idx );

		foreach ( var s in j.SpikedObstacleSides ?? Array.Empty<ObstacleSpikeJson>() )
			e.SpikedObstacleSides.Add( new ObstacleSpike { Obstacle = s.Obstacle, Sides = ParseDirNames( s.Sides ) } );

		e.WallColor = ArrCol( j.Wall );
		e.OutOfBoundsColor = ArrCol( j.Oob );
		e.CheckerboardColor = ArrCol( j.Checker );
		e.CheckerboardSecondColor = ArrCol( j.Checker2 );
		e.BackgroundBlockColor = ArrCol( j.BgBlock );
		e.FenceColor = ArrCol( j.FenceCol );
		e.GlassColor = ArrCol( j.GlassCol );
		e.BackgroundBlockScale = Math.Clamp( j.BgBlockScale ?? 1f, 0.25f, 1.5f );
		e.BackgroundBlockDensity = Math.Clamp( j.BgBlockDensity ?? 1f, 0.25f, 2f );
		e.BackgroundBlockOpacity = Math.Clamp( j.BgBlockOpacity ?? 1f, 0f, 1f );
		e.BackgroundDriftSpeed = Math.Clamp( j.BgDriftSpeed ?? 1f, 0f, 3f );
		if ( Enum.TryParse<BackgroundDriftBias>( j.BgDriftBias, true, out var driftBias ) )
			e.BackgroundDriftBias = driftBias;
		if ( Enum.TryParse<PlayfieldPattern>( j.Pattern, true, out var pattern ) )
			e.PlayfieldPattern = pattern;
		e.PlayfieldCellScale = Math.Clamp( j.CellScale ?? 1, 1, 8 );
		e.PlayfieldPatternRows = NormalizePatternRows( j.PatternRows );
		e.AlternatePlayfieldEnabled = j.AltEnabled;
		e.AlternateCheckerboardColor = ArrCol( j.AltChecker );
		e.AlternateCheckerboardSecondColor = ArrCol( j.AltChecker2 );
		if ( Enum.TryParse<PlayfieldPattern>( j.AltPattern, true, out var alternatePattern ) )
			e.AlternatePlayfieldPattern = alternatePattern;
		e.AlternatePlayfieldCellScale = Math.Clamp( j.AltCellScale ?? 1, 1, 8 );
		e.AlternatePlayfieldPatternRows = NormalizePatternRows( j.AltPatternRows );
		e.AlternatePlayfieldRects = ( j.AltRects ?? Array.Empty<float[]>() )
			.Where( a => a is { Length: 4 } ).Select( ArrRect ).ToList();
		if ( j.BgBlocks is { Length: 3 } )
		{
			var cols = j.BgBlocks.Select( ArrCol ).Where( c => c.HasValue ).Select( c => c.Value ).ToList();
			if ( cols.Count == 3 ) e.BackgroundBlockColors = cols;
		}

		e.BackgroundParticlesEnabled = j.BgParticles;
		if ( Enum.TryParse<Direction>( j.BgPartEdge, true, out var particleEdge )
			&& particleEdge is Direction.Up or Direction.Down or Direction.Left or Direction.Right )
			e.BackgroundParticleEdge = particleEdge;
		e.BackgroundParticleColor = ArrCol( j.BgPartCol );
		e.BackgroundParticleOpacity = Math.Clamp( j.BgPartOpacity ?? 1f, 0.05f, 1f );
		e.BackgroundParticleSizeMin = Math.Clamp( j.BgPartSizeMin ?? 1, 1, 8 );
		e.BackgroundParticleSizeMax = Math.Clamp( j.BgPartSizeMax ?? 2, e.BackgroundParticleSizeMin, 8 );
		e.BackgroundParticleAngle = Math.Clamp( j.BgPartAngle ?? 0f, -75f, 75f );
		e.BackgroundParticleAngleRange = Math.Clamp( j.BgPartAngleRange ?? 0f, 0f, 90f );
		e.BackgroundParticleSpeedMin = Math.Clamp( j.BgPartSpeedMin ?? 90f, 5f, 300f );
		e.BackgroundParticleSpeedMax = Math.Clamp( j.BgPartSpeedMax ?? 140f, e.BackgroundParticleSpeedMin, 300f );
		e.BackgroundParticleGravity = Math.Clamp( j.BgPartGravity ?? 60f, -400f, 400f );
		e.BackgroundParticleSpawnRate = Math.Clamp( j.BgPartRate ?? 12f, 0f, 60f );
		e.BackgroundParticleTrailLength = Math.Clamp( j.BgPartTrail ?? 0, 0, 8 );
		e.BackgroundParticleTrailColor = ArrCol( j.BgPartTrailCol );
		e.BackgroundParticleTrailFade = j.BgPartTrailFade;
		e.BackgroundParticleImpactEnabled = j.BgPartImpact;
		e.BackgroundParticleImpactCountMin = Math.Clamp( j.BgPartImpactCountMin ?? 2, 0, 10 );
		e.BackgroundParticleImpactCountMax = Math.Clamp( j.BgPartImpactCountMax ?? 4, e.BackgroundParticleImpactCountMin, 10 );
		e.BackgroundParticleImpactSpeedMin = Math.Clamp( j.BgPartImpactSpeedMin ?? 15f, 5f, 150f );
		e.BackgroundParticleImpactSpeedMax = Math.Clamp( j.BgPartImpactSpeedMax ?? 40f, e.BackgroundParticleImpactSpeedMin, 150f );
		e.BackgroundParticleImpactSizeMin = Math.Clamp( j.BgPartImpactSizeMin ?? 1, 1, 8 );
		e.BackgroundParticleImpactSizeMax = Math.Clamp( j.BgPartImpactSizeMax ?? 2, e.BackgroundParticleImpactSizeMin, 8 );
		e.BackgroundParticleImpactGravity = Math.Clamp( j.BgPartImpactGravity ?? 150f, -400f, 400f );
		e.BackgroundParticleImpactAngleRange = Math.Clamp( j.BgPartImpactAngleRange ?? 60f, 0f, 90f );
		e.BackgroundParticleImpactColor = ArrCol( j.BgPartImpactCol );

		return e;
	}

	/// <summary>Serialize to a JSON string (minified — used for the editor clipboard / paste import).
	/// File writes go through the Editor-assembly writer, which pretty-prints the same DTO.</summary>
	public string ToJsonString( bool fullAuthoringState = false ) => Json.Serialize( ToJson( fullAuthoringState ) );

	/// <summary>Parse the canonical <see cref="LevelJson"/> wire format. Returns null +
	/// <paramref name="error"/> on malformed JSON or a refused schema version.</summary>
	public static EditorLevel FromJsonString( string text, out string error )
	{
		error = "";
		if ( string.IsNullOrWhiteSpace( text ) ) { error = "empty"; return null; }
		string json = text.Trim();
		if ( json.EndsWith( ",", StringComparison.Ordinal ) )
			json = json[..^1].TrimEnd();
		try
		{
			var j = Json.Deserialize<LevelJson>( json );
			if ( j is null ) { error = "parse failed"; return null; }
			return FromVersionedJson( j, out error );
		}
		catch ( Exception ex )
		{
			error = ex.Message;
			return null;
		}
	}

	/// <summary><see cref="FromJson"/> behind the schema-version gate — the entry point for every
	/// DTO that came from outside this process (disk files, share codes, the local-replay snapshot).
	/// V is REQUIRED (no-legacy-compat: the shipped files were hand-stamped when the field was added;
	/// older unstamped data is simply invalid). Newer-than-known versions are refused so the lenient
	/// parser can't silently load a degraded layout — a replay-determinism hazard.</summary>
	public static EditorLevel FromVersionedJson( LevelJson j, out string error )
	{
		error = "";
		if ( j is null ) { error = "parse failed"; return null; }
		if ( j.V is not int fileVersion ) { error = "level file has no schema version (V)"; return null; }
		if ( fileVersion > SCHEMA_VERSION ) { error = "level requires a newer game version"; return null; }
		return FromJson( j );
	}

	// ── formatting / parsing helpers ────────────────────────────────────────────────────────────────

	private static int Byte255( float v ) => (int)MathF.Round( Math.Clamp( v, 0f, 1f ) * 255f );

	private static List<string> NormalizePatternRows( IEnumerable<string> rows )
	{
		var normalized = (rows ?? Array.Empty<string>())
			.Take( 8 )
			.Select( row => new string( (row ?? "").Where( c => c == '0' || c == '1' ).Take( 8 ).ToArray() ) )
			.Where( row => row.Length > 0 )
			.ToList();
		if ( normalized.Count == 0 ) return null;
		int width = normalized.Max( row => row.Length );
		for ( int i = 0; i < normalized.Count; i++ )
			normalized[i] = normalized[i].PadRight( width, '0' );
		return normalized;
	}

	private static bool RectEquals( RectF a, RectF b )
		=> MathF.Abs( a.Left - b.Left ) < 0.01f && MathF.Abs( a.Bottom - b.Bottom ) < 0.01f
		&& MathF.Abs( a.Right - b.Right ) < 0.01f && MathF.Abs( a.Top - b.Top ) < 0.01f;

	// ── JSON mapping helpers ────────────────────────────────────────────────────────────────────────

	private static List<BlockType> ParseBlockNames( string[] names )
		=> ( names ?? Array.Empty<string>() )
			.Select( p => Enum.TryParse<BlockType>( p, true, out var b ) ? (BlockType?)b : null )
			.Where( b => b.HasValue ).Select( b => b.Value ).ToList();

	private static List<Direction> ParseDirNames( string[] names )
		=> ( names ?? Array.Empty<string>() )
			.Select( p => Enum.TryParse<Direction>( p, true, out var d ) ? (Direction?)d : null )
			.Where( d => d.HasValue && d.Value != Direction.None ).Select( d => d.Value ).ToList();

	private static float[] RectArr( RectF r ) => new[] { r.Left, r.Bottom, r.Right, r.Top };
	private static RectF ArrRect( float[] a ) => new RectF( a[0], a[1], a[2], a[3] );

	private static int[] ColArr( Color? c )
		=> c.HasValue ? new[] { Byte255( c.Value.r ), Byte255( c.Value.g ), Byte255( c.Value.b ) } : null;

	private static Color? ArrCol( int[] a )
		=> a is { Length: 3 } ? new Color( a[0] / 255f, a[1] / 255f, a[2] / 255f ) : (Color?)null;
}

/// <summary>Editor-side obstacle-face spike spec: the obstacle INDEX (into
/// <see cref="EditorLevel.Obstacles"/>) plus the outward normals that are spiked.</summary>
public sealed class ObstacleSpike
{
	public int Obstacle;
	public List<Direction> Sides = new();
}

/// <summary>One block in an <see cref="EditorLevel"/>'s exact list: its type plus an optional authored
/// starting phase and pre-pressed sides (see <see cref="LevelDef.BlockStarts"/>).</summary>
public sealed class EditorBlock
{
	public BlockType Type;

	/// <summary>Starting phase 0..<see cref="Block.NUM_PHASES"/>-1.</summary>
	public int Phase;

	/// <summary>Sides that start pre-pressed for the spawn phase.</summary>
	public List<Direction> Pressed = new();

	public EditorBlock() { }
	public EditorBlock( BlockType type ) { Type = type; }

	/// <summary>Independent deep copy (own <see cref="Pressed"/> list), so clipboard snapshots and
	/// pasted pins never share a mutable instance with their source.</summary>
	public EditorBlock Clone() => new( Type )
	{
		Phase = Phase,
		Pressed = Pressed is null ? new() : new List<Direction>( Pressed ),
	};
}

/// <summary>The on-disk JSON shape of a level (Assets/levels/*.json). Primitive fields only so it
/// serializes cleanly with any JSON serializer: enums as names, colours as [r,g,b] (0-255), points as
/// [x,y], rects as [left,bottom,right,top]. Mapped to/from <see cref="EditorLevel"/> via
/// <see cref="EditorLevel.ToJson"/> / <see cref="EditorLevel.FromJson"/>.</summary>
public sealed class LevelJson
{
	/// <summary>Wire-shape version (see <see cref="EditorLevel.SCHEMA_VERSION"/>). REQUIRED — the
	/// reader refuses files without it (nullable only so absence is detectable, not tolerated).</summary>
	public int? V { get; set; }

	public string Id { get; set; }
	public string Name { get; set; }
	public string Music { get; set; }
	public float? MusicVolume { get; set; }
	public bool Test { get; set; }
	public string Mode { get; set; }
	public string[] Blocks { get; set; }

	/// <summary>Exact-mode per-block starting phase, aligned with <see cref="Blocks"/>. Null/omitted =
	/// all phase 0.</summary>
	public int[] BlockPhases { get; set; }

	/// <summary>Exact-mode per-block pre-pressed sides (direction names), aligned with <see cref="Blocks"/>.
	/// Null/omitted = none.</summary>
	public string[][] BlockPressed { get; set; }

	public string[] Pool { get; set; }
	public int BlockCount { get; set; }

	/// <summary>Pool-mode starting phase for every drawn block. 0 = classic.</summary>
	public int PoolStartPhase { get; set; }

	public float[][] Spawns { get; set; }
	public string[] SpawnDirections { get; set; }
	/// <summary>Per-spawn <see cref="TurnMode"/> names, aligned with <see cref="Spawns"/>.
	/// Null/omitted = all <see cref="TurnMode.Free"/>.</summary>
	public string[] SpawnTurnModes { get; set; }

	/// <summary>Pinned blocks — a sparse list (spawn index + type/phase/pressed). Null/omitted = no
	/// pins. A pinned slot always spawns its authored block, independent of the random block count.</summary>
	public PinJson[] Pins { get; set; }
	public float[] AbilityIntervals { get; set; }
	public float[] AbilityRandomness { get; set; }
	public float[] AbilityStartOffsets { get; set; }
	public float[] AbilityClosedTimes { get; set; }

	/// <summary>Candidate player spawns [[x,y],...] — a single-spawn level writes a one-entry list;
	/// with more than one the run picks a spawn from the run Rng.</summary>
	public float[][] Players { get; set; }
	public string Character { get; set; }
	public float[][] Obstacles { get; set; }
	/// <summary>Collectible coin centres [[x,y],...]. Null/omitted = no coins.</summary>
	public float[][] Coins { get; set; }
	public string[] SpikedWalls { get; set; }
	public ObstacleSpikeJson[] SpikedObstacleSides { get; set; }
	/// <summary>Obstacle indices flagged to block vision. Indices, not rects — rects couldn't
	/// distinguish two obstacles sharing the same rect.</summary>
	public int[] VisionIdx { get; set; }
	/// <summary>Obstacle indices flagged as fences (solid only to blocks). Index-based like
	/// <see cref="VisionIdx"/>.</summary>
	public int[] FenceIdx { get; set; }
	/// <summary>Obstacle indices flagged as glass (solid only to players). Index-based like
	/// <see cref="VisionIdx"/>.</summary>
	public int[] GlassIdx { get; set; }
	/// <summary>Defaults true to match <see cref="EditorLevel.ShuffleTypes"/> — a level JSON that omits
	/// the key must load with shuffling on, not the CLR bool default.</summary>
	public bool Shuffle { get; set; } = true;
	public int[] Wall { get; set; }
	public int[] Oob { get; set; }
	public int[] Checker { get; set; }
	public int[] Checker2 { get; set; }
	public int[] BgBlock { get; set; }
	/// <summary>Fence border colour [r,g,b]; bars derive a brighter version. Null = default amber.</summary>
	public int[] FenceCol { get; set; }
	/// <summary>Glass colour [r,g,b]; pane/streaks/border/teeth tint derive from it. Null = default lavender.</summary>
	public int[] GlassCol { get; set; }
	public int[][] BgBlocks { get; set; }
	public float? BgBlockScale { get; set; }
	public float? BgBlockDensity { get; set; }
	public float? BgBlockOpacity { get; set; }
	public float? BgDriftSpeed { get; set; }
	public string BgDriftBias { get; set; }
	public string Pattern { get; set; }
	public int? CellScale { get; set; }
	public string[] PatternRows { get; set; }
	public bool AltEnabled { get; set; }
	public int[] AltChecker { get; set; }
	public int[] AltChecker2 { get; set; }
	public string AltPattern { get; set; }
	public int? AltCellScale { get; set; }
	public string[] AltPatternRows { get; set; }
	public float[][] AltRects { get; set; }
	/// <summary>Background particle ambience cluster (see <see cref="LevelDef.BackgroundParticlesEnabled"/>).
	/// The whole cluster is only emitted while enabled (or in full-authoring undo snapshots).</summary>
	public bool BgParticles { get; set; }
	public string BgPartEdge { get; set; }
	public int[] BgPartCol { get; set; }
	public float? BgPartOpacity { get; set; }
	public int? BgPartSizeMin { get; set; }
	public int? BgPartSizeMax { get; set; }
	public float? BgPartAngle { get; set; }
	public float? BgPartAngleRange { get; set; }
	public float? BgPartSpeedMin { get; set; }
	public float? BgPartSpeedMax { get; set; }
	public float? BgPartGravity { get; set; }
	public float? BgPartRate { get; set; }
	public int? BgPartTrail { get; set; }
	public int[] BgPartTrailCol { get; set; }
	/// <summary>Defaults true so a level JSON that omits the key keeps the fading trail (the
	/// original look), not the CLR bool default — same idiom as <see cref="Shuffle"/>.</summary>
	public bool BgPartTrailFade { get; set; } = true;
	public bool BgPartImpact { get; set; }
	public int? BgPartImpactCountMin { get; set; }
	public int? BgPartImpactCountMax { get; set; }
	public float? BgPartImpactSpeedMin { get; set; }
	public float? BgPartImpactSpeedMax { get; set; }
	public int? BgPartImpactSizeMin { get; set; }
	public int? BgPartImpactSizeMax { get; set; }
	public float? BgPartImpactGravity { get; set; }
	public float? BgPartImpactAngleRange { get; set; }
	public int[] BgPartImpactCol { get; set; }
}

public sealed class ObstacleSpikeJson
{
	public int Obstacle { get; set; }
	public string[] Sides { get; set; }
}

/// <summary>Wire form of one pinned block: the spawn slot it's bound to plus its authored state.</summary>
public sealed class PinJson
{
	public int Spawn { get; set; }
	public string Type { get; set; }
	public int Phase { get; set; }
	public string[] Pressed { get; set; }
}