Stages/LevelEditorStage.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace BlockParty;

/// <summary>
/// EDITOR-ONLY level editor. Started with the <c>level_editor</c> ConCmd (see
/// <see cref="OpenLevelEditor"/>), it swaps to a self-contained stage where a level is authored
/// visually: drag obstacles, place block-spawn positions and the player spawn, toggle wall + obstacle-
/// face spikes, flag which obstacles block vision, pick the block set, and edit the palette — then
/// export a compact round-trip string and/or a paste-ready C# <c>LevelDef</c> for <c>Levels.cs</c>.
///
/// Rendering is a LIGHTWEIGHT SCHEMATIC (flat overlay quads at 1:1 arena scale via
/// <see cref="StageBase.CreateChild"/> + the shared pixel sprite), not the full game arena — it is
/// an authoring aid, not a preview of exact in-game visuals. The simulation is frozen
/// (<see cref="BlocksSimulation"/> = true); all interaction runs in <see cref="TickMenu"/>, driven
/// by pointer state the <see cref="LevelEditorHud"/> forwards from a full-screen canvas panel.
/// </summary>
public sealed class LevelEditorStage : StageBase
{
	public enum EditTool { Select, Obstacle, Spawn, Player, Coin, Spike, AlternatePattern }
	public enum SelKind { None, Obstacle, Spawn, Player, Coin, AlternatePattern }
	private readonly record struct Selection( SelKind Kind, int Index );

	/// <summary>The level being authored (mutable). The HUD mutates this directly and calls
	/// <see cref="Rebuild"/> / <see cref="RefreshPalette"/>.</summary>
	public EditorLevel Level { get; set; } = new();
	public int LevelRevision { get; private set; }

	private EditTool _tool = EditTool.Select;
	public EditTool Tool
	{
		get => _tool;
		set
		{
			if ( _tool == value ) return;
			bool crossedAlternateBoundary = _tool == EditTool.AlternatePattern || value == EditTool.AlternatePattern;
			_tool = value;
			if ( crossedAlternateBoundary ) Select( SelKind.None, -1 );
		}
	}

	/// <summary>Mirror-editing toggles (HUD tools panel): MirrorX doubles edits across the VERTICAL
	/// centre line (left↔right), MirrorY across the horizontal one (up↕down); both = 4-way. Affects
	/// place/create, click-select, move, resize, delete and spike toggles for obstacles and the
	/// block-spawn/player/coin markers.</summary>
	public bool MirrorX { get; set; }
	public bool MirrorY { get; set; }
	private bool MirrorActive => MirrorX || MirrorY;

	/// <summary>The character selected in the editor. Seeded from the persisted global choice when the
	/// editor opens (see <see cref="OpenLevelEditor"/> / the test-play round-trip); the HUD picker also
	/// saves its changes as the player's most recent selection. The player-spawn marker renders as this
	/// character and a Test Play run is played as this character.</summary>
	public string SelectedCharacterId { get; set; } = Settings.Current.SelectedCharacterId;

	private readonly HashSet<Selection> _selection = new();
	private Selection _primarySelection = new( SelKind.None, -1 );

	/// <summary>The selected item exposed to single-item inspectors. Multi-selection intentionally
	/// reports no single kind/index so per-object controls cannot accidentally mutate only one item.</summary>
	public SelKind SelectedKind => _selection.Count == 1 ? _primarySelection.Kind : SelKind.None;
	public int SelectedIndex => _selection.Count == 1 ? _primarySelection.Index : -1;
	public int SelectionCount => _selection.Count;
	public bool HasSelection => _selection.Count > 0;

	/// <summary>Last non-fatal import error, surfaced by the HUD.</summary>
	public string LastError { get; private set; } = "";

	// ── undo/redo ───────────────────────────────────────────────────────────────────────────────────
	// Whole-level JSON snapshots in the manager-owned history (so it survives test-play round trips).
	// Explicit checkpoints cover the stage's own operations; a slow poll in TickMenu catches the many
	// scattered HUD edits (inspector fields, palette buttons, block list) without touching each handler.
	private EditorUndoHistory UndoHistory => Manager.LevelEditorUndoHistory;
	private const float UNDO_POLL_INTERVAL = 0.25f;
	private float _undoPollDelay;
	// One physical Ctrl+Z can reach us through BOTH the focused shortcut panel and the raw-keyboard
	// hotkey path (mirroring the duplicated Ctrl+C/V handling); a short debounce prevents double-steps.
	private const float UNDO_ACTION_DEBOUNCE = 0.1f;
	private float _editorClock;
	private float _lastUndoActionTime = float.MinValue;

	// ── schematic geometry / palette ────────────────────────────────────────────────────────────────
	private GameObject _schematicRoot;
	// Animated sprite markers live OUTSIDE _schematicRoot (which is destroyed wholesale on every
	// Rebuild) and are reused, so redrawing the schematic can't restart their animations. See MarkerPool.
	private GameObject _markerRoot;
	private readonly MarkerPool _coinMarkers = new( "coin" );
	private readonly MarkerPool _playerMarkers = new( "player" );
	private readonly MarkerPool _blockMarkers = new( "block" );
	private LevelEditorHud _hud;

	private const float MIN_RECT = 4f;
	// Resize is easy to grab from outside the obstacle, while only a narrow strip inside each face is
	// reserved for it. That leaves a usable move target through the middle of even a 4px-thin obstacle.
	private const float RESIZE_FACE_OUTER_HIT_DISTANCE = 5f;
	private const float RESIZE_FACE_INNER_HIT_DISTANCE = 2f;
	// Half-extent of a block-spawn's 40px block (matches the DrawSpawns / HitSpawn markers), used to
	// edge-snap a spawn like a 40×40 obstacle.
	private const float SPAWN_HALF = 20f;
	// Player-spawn snapping / border / hit-test use the ACTUAL player hitbox (COLLISION_SIZE 8×10) so the
	// player snaps flush to walls exactly where it will rest in game (the 12px visual sprite is bigger).
	private static readonly float PLAYER_HALF_W = Player.COLLISION_SIZE.x / 2f;   // 4
	private static readonly float PLAYER_HALF_H = Player.COLLISION_SIZE.y / 2f;   // 5
	// Coin half-extents (the 4x6 pickup art IS the hitbox). Hit-testing uses a wider tolerance —
	// the marker is tiny.
	private const float COIN_HALF_W = Coin.ART_W / 2f;   // 2
	private const float COIN_HALF_H = Coin.ART_H / 2f;   // 3
	private const float COIN_HIT_TOLERANCE = 4f;
	private const int DEPTH_FILL = Globals.DEPTH_BLOCK;          // obstacle fills
	private const int DEPTH_MARKER = Globals.DEPTH_PLAYER;       // spawn / player markers
	private const int DEPTH_WALLS = Globals.DEPTH_WALL;          // walls, borders, spike strips
	private const float DEPTH_VISION = Globals.DEPTH_VISION_COVER; // vision-blocking obstacle cue (fractional layer — see Globals)
	private const int DEPTH_OVERLAY = 20;                        // selection / drag preview (above walls)

	private static readonly Color WallGrey = LevelCosmeticsRandomizer.DefaultWallColor;
	private static readonly Color SpikeRed = new( 0.86f, 0.22f, 0.22f );
	// Highlights a spiked OBSTACLE face whose along-length isn't a multiple of the 5px spike tile
	// (uneven teeth / corner z-fighting — see FaceSizeUneven / the HUD warning).
	private static readonly Color SpikeOrange = new( 1f, 0.55f, 0.1f );
	private static readonly Color SpikeYellow = new( 1f, 0.9f, 0.15f );

	// Thickness of the schematic spike-indicator strips (thin so they don't swamp the wall borders).
	private const float SPIKE_STRIP_THICK = 2f;
	private const int SPIKE_CHILD_ORDER = -1;
	private const float SPIKE_PULSE_SPEED = 6f;
	private readonly record struct SpikeIndicator( SpriteRenderer Renderer, Color LowColor );
	private readonly List<SpikeIndicator> _spikeIndicators = new();
	private float _spikePulseTime;
	private static readonly Color SelectionBlue = new( 0.5f, 0.78f, 1f, 0.5f );
	private static readonly Color SelectionWhite = new( 0.88f, 0.96f, 1f, 0.82f );
	private const float SELECTION_HALO_PADDING_MIN = 2f;
	private const float SELECTION_HALO_PADDING_MAX = 2.6f;
	private const float SELECTION_HALO_THICK = 0.5f;
	private const int SELECTION_CHILD_ORDER = -2;
	private const float SELECTION_PULSE_SPEED = 3.5f;
	private readonly record struct SelectionIndicator( SpriteRenderer Renderer, RectF Bounds, Direction Side );
	private readonly List<SelectionIndicator> _selectionIndicators = new();
	private float _selectionPulseTime;
	private static readonly Color SpawnFill = new( 1f, 1f, 1f, 0.16f );
	private static readonly Color SpawnOutline = new( 1f, 1f, 1f, 0.55f );
	private static readonly Color CoinYellow = new( 221 / 255f, 221 / 255f, 153 / 255f );
	private static readonly Color VisionFill = new( 0.2f, 0.8f, 0.95f, 0.16f );
	// Thin border around the exact player-spawn hitbox, independent of the larger selection halo.
	private static readonly Color PlayerBorderColor = new( 1f, 1f, 1f, 0.15f );
	private static readonly Color VisionOutline = new( 0.32f, 0.85f, 1f, 0.85f );
	private static readonly Color PreviewFill = new( 0.4f, 1f, 0.6f, 0.35f );
	private static readonly Color PreviewOutline = new( 0.4f, 1f, 0.6f, 0.9f );
	private static readonly Color MoveHoverFill = new( 0.25f, 1f, 0.58f, 0.16f );
	private static readonly Color MoveHoverOutline = new( 0.35f, 1f, 0.68f, 0.95f );
	private static readonly Color ResizeHoverColor = new( 1f, 0.72f, 0.18f, 0.95f );

	// ── pointer state (forwarded from the HUD canvas) ───────────────────────────────────────────────
	private enum ObstacleHoverIntent { None, Move, Resize }

	private bool _primaryHeld, _prevPrimaryHeld, _hovering;
	private bool _snapInvertHeld;
	private bool _cardinalDragHeld;
	private Vector2 _hoverLogical;
	private Vector2 _pointerDownLogical;
	private bool _pointerDragged;
	private bool _spikeToggledOnPointerDown;

	private bool _dragging;
	private bool _creatingRect;      // dragging out a new obstacle rect
	private bool _creatingAlternateRect;
	private bool _marqueeSelecting;
	private bool _marqueeAdditive;
	// The element an additive (Shift/Ctrl) click landed on, and whether it was already selected —
	// releasing without dragging then TOGGLES it out of the selection (see PointerUp).
	private Selection _additiveClickSelection = new( SelKind.None, -1 );
	private bool _additiveClickWasSelected;
	private Vector2 _dragStart, _dragCurrent;
	private SelKind _moveKind = SelKind.None;
	private int _moveIndex = -1;
	private Vector2 _grabOffset;
	private Direction _resizeSide = Direction.None;
	private RectF _resizeOriginRect;
	private float _resizeGrabOffset;
	private ObstacleHoverIntent _obstacleHoverIntent;
	private SelKind _hoverRectKind = SelKind.None;
	private int _hoverObstacleIndex = -1;
	private Direction _hoverResizeSide = Direction.None;
	private readonly Dictionary<Selection, Vector2> _moveOriginCenters = new();
	private readonly Dictionary<Selection, RectF> _moveOriginRects = new();
	// Mirror bookkeeping for the operation in flight: selected obstacles that sit exactly at a mirror
	// image of the move anchor (delta gets flipped per axis), and the twins a group resize re-syncs.
	private readonly Dictionary<Selection, (bool FlipX, bool FlipY)> _moveMirrorRelations = new();
	private readonly List<(int Index, bool FlipX, bool FlipY)> _resizeMirrorPartners = new();

	private readonly record struct CopiedObstacle( RectF Rect, Direction[] Spikes, bool BlocksVision, bool IsFence, bool IsGlass );
	private readonly record struct CopiedSpawn( Vector2 Position, EditorBlock Pin, float AbilityInterval, float AbilityRandomness,
		float AbilityStartOffset, float AbilityClosedTime, Direction Direction, TurnMode TurnMode );
	private readonly List<CopiedObstacle> _copiedObstacles = new();
	private readonly List<CopiedSpawn> _copiedSpawns = new();
	private readonly List<Vector2> _copiedPlayers = new();
	private readonly List<Vector2> _copiedCoins = new();
	private readonly List<RectF> _copiedAlternateRects = new();
	// Separate bounds per clipboard: the two survive alongside each other, so one paste's anchor must
	// not be left pointing at the other's copied extent.
	private RectF _copiedBounds;
	private RectF _copiedAlternateBounds;

	public LevelEditorStage( GameManager manager ) : base( manager ) { }

	/// <summary>Console command: open the level editor on an optional level id. Dev-only — players
	/// reach the editor through the menus (<see cref="OpenLevelEditor"/>), never the console.</summary>
	[ConCmd( "level_editor" )]
	public static void OpenLevelEditorCmd( string levelId = "" )
	{
		if ( !Game.IsEditor ) return;
		OpenLevelEditor( levelId );
	}

	/// <summary>Open the level editor. Optional level id loads an existing level for editing.</summary>
	public static void OpenLevelEditor( string levelId = "" )
	{
		var mgr = GameManager.Instance;
		if ( mgr is null || mgr.IsTransitioning ) return;

		var stage = new LevelEditorStage( mgr );
		bool explicitLevel = !string.IsNullOrWhiteSpace( levelId );
		if ( !explicitLevel && mgr.TryRestoreLevelEditorSession( out var sessionLevel, out var sessionTool, out var sessionCharacterId ) )
		{
			stage.Level = sessionLevel;
			stage.Tool = sessionTool;
			stage.SelectedCharacterId = sessionCharacterId ?? Settings.Current.SelectedCharacterId;
		}
		else
		{
			var requestedLevelId = explicitLevel ? levelId : EditorPrefs.Current.LastLevelId;
			bool loaded = false;
			if ( !string.IsNullOrWhiteSpace( requestedLevelId ) )
			{
				var def = Levels.Get( requestedLevelId );
				if ( def is not null )
				{
					stage.Level = EditorLevel.FromLevelDef( def );
					RememberOpenedLevel( def.Id );
					loaded = true;
				}
			}
			// Nothing to restore or load: the editor opens on a BRAND-NEW empty level, so roll its
			// cosmetics + song (as Clear Level does) instead of always starting on the same default teal.
			if ( !loaded ) stage.RandomizeEnvironment();
			// A fresh document (no restored session): undo history never crosses documents.
			mgr.LevelEditorUndoHistory.Reset( stage.Level.ToJsonString( fullAuthoringState: true ), stage.Level.ProjectSourceId );
		}

		mgr.TransitionToStage( stage );
	}

	// ── palette (read the model; camera clear updates live via RefreshPalette) ───────────────────────
	protected override Color EffectiveClearColor => Level.OutOfBoundsColor ?? Manager.ClearColor;
	protected override string PlayfieldSprite => Level.CheckerboardColor.HasValue ? "sprites/background_neutral.sprite" : "sprites/background.sprite";
	protected override Color PlayfieldTint => Level.CheckerboardColor.HasValue ? GammaToLinear( Level.CheckerboardColor.Value ) : Color.White;
	protected override PlayfieldPattern PlayfieldPatternKind => Level.PlayfieldPattern;
	protected override int PlayfieldCellScale => Level.PlayfieldCellScale;
	protected override System.Collections.Generic.IReadOnlyList<string> PlayfieldPatternRows => Level.PlayfieldPatternRows;
	protected override Color PlayfieldPatternColor => Level.CheckerboardColor ?? base.PlayfieldPatternColor;
	protected override Color? PlayfieldPatternSecondColor => Level.CheckerboardSecondColor;
	protected override bool AlternatePlayfieldEnabled => Level.AlternatePlayfieldEnabled;
	protected override Color AlternatePlayfieldColor => Level.AlternateCheckerboardColor ?? PlayfieldPatternColor;
	protected override Color? AlternatePlayfieldSecondColor => Level.AlternateCheckerboardSecondColor;
	protected override PlayfieldPattern AlternatePlayfieldPatternKind => Level.AlternatePlayfieldPattern;
	protected override int AlternatePlayfieldCellScale => Level.AlternatePlayfieldCellScale;
	protected override System.Collections.Generic.IReadOnlyList<string> AlternatePlayfieldPatternRows => Level.AlternatePlayfieldPatternRows;
	protected override System.Collections.Generic.IReadOnlyList<RectF> AlternatePlayfieldRects => Level.AlternatePlayfieldRects;
	protected override Color? BackgroundBlockBaseColor => Level.BackgroundBlockColor;
	protected override System.Collections.Generic.IReadOnlyList<Color> BackgroundBlockColors => Level.BackgroundBlockColors;
	protected override float BackgroundBlockScale => Level.BackgroundBlockScale;
	protected override float BackgroundBlockDensity => Level.BackgroundBlockDensity;
	protected override float BackgroundBlockOpacity => Level.BackgroundBlockOpacity;
	protected override float BackgroundDriftSpeed => Level.BackgroundDriftSpeed;
	protected override BackgroundDriftBias BackgroundBlockDriftBias => Level.BackgroundDriftBias;

	// --- background particle ambience: previewed live in the editor (ticked from TickMenu, since
	// the editor's fixed-step sim is frozen). Solids come straight from the editable level, so a
	// dragged obstacle re-shapes the weather immediately. --------------------------------------------
	protected override BackgroundParticleSettings BuildBackgroundParticleSettings()
		=> BackgroundParticleSettings.FromEditorLevel( Level );

	protected override void CollectBackgroundParticleStaticSolids( System.Collections.Generic.List<RectF> solids )
	{
		// Ordinary obstacles AND glass, never fences — mirrors GameStage's split of Level.Obstacles.
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
			if ( !Level.FenceObstacles.Contains( i ) )
				solids.Add( Level.Obstacles[i] );
	}

	/// <summary>Re-read the particle settings without restarting the live weather (or re-rolling the
	/// drifting background blocks, which a full <see cref="StageBase.RefreshEnvironment"/> would do) —
	/// the HUD calls this per slider tick while the particle settings are being dragged.</summary>
	public void RefreshBackgroundParticles() => InvalidateBackgroundParticleSettings();

	public override bool BlocksSimulation => true;

	protected override void OnEnter()
	{
		RefreshMusic();
		SpawnBackgroundBlocks();
		CreateHud();
		// Seed the undo baseline if the history is brand-new (a restored session / test-play return
		// keeps its existing chain — the manager owns the history precisely so it survives those).
		UndoHistory.EnsureSeeded( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
		Rebuild();
	}

	protected override void OnExit()
	{
		Manager.RememberLevelEditorSession( Level, Tool, SelectedCharacterId );
	}

	private void CreateHud()
	{
		var ui = CreateChild( "UI" );
		var screen = ui.Components.Create<ScreenPanel>();
		screen.ZIndex = 100;
		_hud = ui.Components.Create<LevelEditorHud>();
		_hud.Stage = this;
	}

	/// <summary>Exit the editor back to the title screen.</summary>
	public void ExitToTitle()
	{
		if ( Manager.IsTransitioning ) return;
		Manager.TransitionToStage( new TitleStage( Manager ) );
	}

	/// <summary>Exit the editor back to the workshop screen — where the editor is launched from.</summary>
	public void ExitToWorkshop()
	{
		if ( Manager.IsTransitioning ) return;
		Manager.TransitionToStage( new WorkshopStage( Manager ) );
	}

	/// <summary>Reset the editor to a fresh, empty level with rolled cosmetics (discards the current
	/// unsaved work). The roll lands BEFORE the undo checkpoint so the new document's baseline is the
	/// look on screen.</summary>
	public void ResetToDefault()
	{
		CheckpointOutgoingDocument();
		Level = new EditorLevel();
		RandomizeEnvironment();
		RefreshMusic();
		LevelRevision++;
		RememberOpenedLevel( "" );
		Select( SelKind.None, -1 );
		LastError = "";
		_hud?.ResetTextFields();
		RefreshPalette();
		RefreshEnvironment();
		Rebuild();
		CheckpointUndo( newDocument: true );   // a reset is undoable — Ctrl+Z rescues discarded work
	}

	/// <summary>Roll a random palette + background pattern + song onto <see cref="Level"/> — the pair the
	/// HUD's palette/shuffle buttons roll, plus a uniform pick from the music library (no "unused by
	/// other levels" preference, unlike the HUD's dice). Mutates the model only; callers that roll on a
	/// LIVE editor must refresh (palette, environment, music) afterwards.</summary>
	private void RandomizeEnvironment()
	{
		var rng = new Random();
		bool alternate = Level.AlternatePlayfieldEnabled;
		LevelCosmeticsRandomizer.RollColors( rng, alternate, Level.BackgroundBlockColors is { Count: 3 } ).ApplyTo( Level );
		LevelCosmeticsRandomizer.RollPattern( rng, alternate ).ApplyTo( Level );
		var songs = Audio.RandomizableMusicNames();   // empty until the library mounts: keep the default
		if ( songs.Count > 0 ) Level.Music = songs[rng.Next( songs.Count )];
	}

	// ── pointer plumbing ────────────────────────────────────────────────────────────────────────────

	/// <summary>Forwarded each frame by the HUD: whether the world canvas is being pressed and hovered.</summary>
	public void SetPointer( bool primaryHeld, bool hovering )
	{
		_primaryHeld = primaryHeld;
		_hovering = hovering;
	}

	public bool SnapInvertHeld => _snapInvertHeld;
	public bool SnapActive => EditorPrefs.Current.SnapEnabled != _snapInvertHeld;
	public void SetSnapInvertHeld( bool held ) => _snapInvertHeld = held;
	public void SetCardinalDragHeld( bool held ) => _cardinalDragHeld = held;

	public Vector2 ScreenToLogical( Vector2 screen )
	{
		if ( !Manager.Camera.IsValid() ) return Vector2.Zero;
		var ray = Manager.Camera.ScreenPixelToRay( screen );
		return new Vector2( ray.Position.x, ray.Position.y );
	}

	/// <summary>Project a logical (world) point to a normalised [0,1] screen fraction (0,0 = top-left)
	/// for the HUD to anchor a floating overlay to it. False if the camera is invalid.
	///
	/// Uses the no-out <c>PointToScreenNormal</c> (→ <c>ToScreenNormal</c> = WorldToView + Remap), NOT
	/// the <c>out bool isBehind</c> overload: that overload routes through <c>ToScreenWithDirection</c>,
	/// whose ORTHOGRAPHIC branch is broken (`(x+2)/2` instead of `(x+1)/2`) and lands the point in the
	/// wrong screen quadrant. Our top-down ortho camera has everything at z≈0 in front, so we don't
	/// need the behind flag.</summary>
	public bool WorldToScreenFraction( Vector2 world, out Vector2 frac )
	{
		frac = default;
		if ( !Manager.Camera.IsValid() ) return false;
		var n = Manager.Camera.PointToScreenNormal( new Vector3( world.x, world.y, 0f ) );
		frac = new Vector2( n.x, n.y );
		return true;
	}

	/// <summary>A width/height readout for the HUD's floating dimension label: the obstacle rect being
	/// dragged out (create), or the currently-selected obstacle (which also covers a move-in-progress,
	/// since the obstacle stays selected while dragged). Anchored at the rect's top-centre in world
	/// space. Not visible for spawn/player selections.</summary>
	public readonly record struct DimReadout( bool Visible, Vector2 WorldPos, float Width, float Height );

	public DimReadout CurrentDimReadout()
	{
		if ( _creatingRect || _creatingAlternateRect )
		{
			RectF cr = CreateRect();
			if ( cr.Width >= 1f && cr.Height >= 1f )
				return new DimReadout( true, new Vector2( ( cr.Left + cr.Right ) / 2f, cr.Top ), cr.Width, cr.Height );
			return default;
		}

		if ( SelectedKind == SelKind.Obstacle && SelectedIndex >= 0 && SelectedIndex < Level.Obstacles.Count )
		{
			var r = Level.Obstacles[SelectedIndex];
			return new DimReadout( true, new Vector2( ( r.Left + r.Right ) / 2f, r.Top ), r.Width, r.Height );
		}
		if ( SelectedKind == SelKind.AlternatePattern && SelectedIndex >= 0 && SelectedIndex < Level.AlternatePlayfieldRects.Count )
		{
			var r = Level.AlternatePlayfieldRects[SelectedIndex];
			return new DimReadout( true, new Vector2( ( r.Left + r.Right ) / 2f, r.Top ), r.Width, r.Height );
		}

		return default;
	}

	public override void TickMenu( float dt )
	{
		UpdateSpikePulse( dt );
		UpdateSelectionPulse( dt );
		_editorClock += dt;
		UndoHistory.Advance( dt );
		// Live ambience preview: the editor's fixed-step sim never runs, so drive the level's
		// background particles from the per-frame menu tick instead.
		TickBackgroundParticles( dt );
		if ( !Manager.Camera.IsValid() ) return;
		_hud?.TickShortcutRepeat( dt );

		_hoverLogical = ScreenToLogical( Mouse.Position );

		bool down = _primaryHeld;
		bool justDown = down && !_prevPrimaryHeld;
		bool justUp = !down && _prevPrimaryHeld;

		if ( justDown )
		{
			_pointerDownLogical = _hoverLogical;
			_pointerDragged = false;
			PointerDown( _hoverLogical );
		}
		else if ( down )
		{
			Vector2 delta = _hoverLogical - _pointerDownLogical;
			if ( delta.LengthSquared > 1f ) _pointerDragged = true;
			if ( _dragging && _pointerDragged ) PointerDrag( _hoverLogical );
		}
		else if ( justUp ) PointerUp( _hoverLogical );

		// Keyboard hotkeys use the RAW keyboard (no project input bindings). Gated on hovering the
		// world canvas + not dragging so they never fire while typing in an inspector TextEntry
		// (whose backspace/arrows must edit the text, not the selection).
		if ( !_dragging && _hovering )
			HandleHotkeys();

		UpdateObstacleHover();

		// Catch HUD-driven edits (inspector fields, palette buttons, block list, …) without touching
		// every handler: when no pointer operation is in flight, checkpoint any drift since the last
		// snapshot. Rapid bursts (typing in a field) coalesce into a single undo entry.
		_undoPollDelay -= dt;
		if ( !down && !_dragging && _undoPollDelay <= 0f )
		{
			_undoPollDelay = UNDO_POLL_INTERVAL;
			CheckpointUndo( "hud" );
		}

		_prevPrimaryHeld = down;
	}

	private void HandleHotkeys()
	{
		if ( CtrlHeld && Input.Keyboard.Pressed( "A" ) )
		{
			SelectAll();
			return;
		}

		if ( CtrlHeld && Input.Keyboard.Pressed( "C" ) )
		{
			CopySelection();
			return;
		}

		if ( CtrlHeld && Input.Keyboard.Pressed( "V" ) )
		{
			PasteSelection();
			return;
		}

		if ( CtrlHeld && Input.Keyboard.Pressed( "Z" ) )
		{
			if ( ShiftHeld ) Redo();
			else Undo();
			return;
		}

		if ( CtrlHeld && Input.Keyboard.Pressed( "Y" ) )
		{
			Redo();
			return;
		}

		if ( Input.Keyboard.Pressed( "DELETE" ) || Input.Keyboard.Pressed( "BACKSPACE" ) )
		{
			DeleteSelected();
			return;
		}

	}

	private void UpdateObstacleHover()
	{
		ObstacleHoverIntent intent = ObstacleHoverIntent.None;
		SelKind hoverKind = SelKind.None;
		int obstacleIndex = -1;
		Direction resizeSide = Direction.None;

		if ( _hovering && !_dragging && !AdditiveHeld && Tool is EditTool.Select or EditTool.AlternatePattern )
		{
			if ( TryHitSelectedRectFace( _hoverLogical, out hoverKind, out obstacleIndex, out resizeSide ) )
				intent = ObstacleHoverIntent.Resize;
			else if ( Tool == EditTool.AlternatePattern )
			{
				obstacleIndex = HitAlternateRect( _hoverLogical );
				if ( obstacleIndex >= 0 )
				{
					intent = ObstacleHoverIntent.Move;
					hoverKind = SelKind.AlternatePattern;
				}
			}
			else
			{
				var (kind, index) = HitAny( _hoverLogical );
				if ( kind == SelKind.Obstacle )
				{
					intent = ObstacleHoverIntent.Move;
					hoverKind = kind;
					obstacleIndex = index;
				}
			}
		}

		if ( intent == _obstacleHoverIntent && hoverKind == _hoverRectKind
			&& obstacleIndex == _hoverObstacleIndex && resizeSide == _hoverResizeSide )
			return;

		_obstacleHoverIntent = intent;
		_hoverRectKind = hoverKind;
		_hoverObstacleIndex = obstacleIndex;
		_hoverResizeSide = resizeSide;
		Rebuild();
	}

	/// <summary>Copy the current selection. The alternate-pattern tool keeps its OWN clipboard: its
	/// selection can never mix with obstacles/spawns/players/coins, so a copy (or a paste that falls back
	/// to copying) in one tool must not discard what the other tool put on the clipboard.</summary>
	public bool CopySelection()
	{
		if ( _selection.Count == 0 ) return false;
		return Tool == EditTool.AlternatePattern ? CopyAlternatePatternSelection() : CopyLevelObjectSelection();
	}

	private bool CopyAlternatePatternSelection()
	{
		_copiedAlternateRects.Clear();
		bool haveBounds = false;
		foreach ( var selected in _selection.Where( s => s.Kind == SelKind.AlternatePattern ).OrderBy( s => s.Index ) )
		{
			if ( selected.Index < 0 || selected.Index >= Level.AlternatePlayfieldRects.Count ) continue;
			_copiedAlternateRects.Add( Level.AlternatePlayfieldRects[selected.Index] );

			RectF bounds = SelectionBounds( selected );
			_copiedAlternateBounds = haveBounds ? Union( _copiedAlternateBounds, bounds ) : bounds;
			haveBounds = true;
		}
		Log.Info( $"Copied {_copiedAlternateRects.Count} alternate pattern rect(s)." );
		return haveBounds;
	}

	private bool CopyLevelObjectSelection()
	{
		_copiedObstacles.Clear();
		_copiedSpawns.Clear();
		_copiedPlayers.Clear();
		_copiedCoins.Clear();
		bool haveBounds = false;
		foreach ( var selected in _selection.OrderBy( s => s.Kind ).ThenBy( s => s.Index ) )
		{
			switch ( selected.Kind )
			{
				case SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count:
					_copiedObstacles.Add( new CopiedObstacle(
						Level.Obstacles[selected.Index],
						Level.SpikedObstacleSides.FirstOrDefault( spec => spec.Obstacle == selected.Index )?.Sides.ToArray() ?? Array.Empty<Direction>(),
						Level.ObstacleBlocksVision( selected.Index ),
						Level.ObstacleIsFence( selected.Index ),
						Level.ObstacleIsGlass( selected.Index ) ) );
					break;
				case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
					// Snapshot the pin at copy time — the clipboard must not track later edits to the
					// source pin (phase cycles / side toggles between copy and paste).
					_copiedSpawns.Add( new CopiedSpawn( Level.SpawnPositions[selected.Index], Level.SpawnPin( selected.Index )?.Clone(),
						Level.SpawnAbilityInterval( selected.Index ), Level.SpawnAbilityRandomness( selected.Index ),
						Level.SpawnAbilityStartOffset( selected.Index ), Level.SpawnAbilityClosedTime( selected.Index ),
						Level.SpawnDirection( selected.Index ), Level.SpawnTurnMode( selected.Index ) ) );
					break;
				case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
					_copiedPlayers.Add( Level.PlayerSpawns[selected.Index] );
					break;
				case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
					_copiedCoins.Add( Level.Coins[selected.Index] );
					break;
			}

			RectF bounds = SelectionBounds( selected );
			_copiedBounds = haveBounds ? Union( _copiedBounds, bounds ) : bounds;
			haveBounds = true;
		}
		Log.Info( $"Copied {_selection.Count} editor object(s)." );
		return haveBounds;
	}

	public bool CopySelectedObstacle() => CopySelection();

	public void PasteSelection()
	{
		if ( Tool == EditTool.AlternatePattern )
		{
			PasteAlternatePatternSelection();
			return;
		}

		if ( _copiedObstacles.Count == 0 && _copiedSpawns.Count == 0 && _copiedPlayers.Count == 0
			&& _copiedCoins.Count == 0 && !CopySelection() ) return;

		// Snap the delta to whole pixels: SnapPoint lands the hover on the grid, but subtracting the
		// copied CENTRE reintroduces a half-pixel for odd-sized rects (a 25px-wide obstacle has a *.5
		// centre), which would paste every copy off the pixel grid. Rounding the delta keeps integer
		// sources integer and preserves relative layout across a multi-object paste.
		Vector2 delta = ClampCopiedDelta( Snap( SnapPoint( _hoverLogical ) - RectCenter( _copiedBounds ) ) );
		Select( SelKind.None, -1 );
		foreach ( var copied in _copiedObstacles )
		{
			Level.Obstacles.Add( Translate( copied.Rect, delta.x, delta.y ) );
			int index = Level.Obstacles.Count - 1;
			foreach ( Direction side in copied.Spikes ) Level.ToggleObstacleSpike( index, side );
			if ( copied.BlocksVision ) Level.ToggleObstacleVision( index );
			if ( copied.IsFence ) Level.ToggleObstacleFence( index );
			if ( copied.IsGlass ) Level.ToggleObstacleGlass( index );
			AddSelection( new Selection( SelKind.Obstacle, index ) );
		}
		foreach ( var copied in _copiedSpawns )
		{
			int index = Level.AddSpawn( copied.Position + delta );
			// Clone per paste so repeated pastes (and the clipboard) don't share one editable
			// pinned-block instance.
			Level.SetSpawnPin( index, copied.Pin?.Clone() );
			Level.SetSpawnAbilityTimer( index, copied.AbilityInterval, copied.AbilityRandomness, copied.AbilityStartOffset,
				copied.AbilityClosedTime );
			Level.SetSpawnDirection( index, copied.Direction );
			Level.SetSpawnTurnMode( index, copied.TurnMode );
			AddSelection( new Selection( SelKind.Spawn, index ) );
		}
		foreach ( var copied in _copiedPlayers )
		{
			// Pasting a player spawn ADDS one (spawns are a multi-candidate list now).
			Level.PlayerSpawns.Add( copied + delta );
			AddSelection( new Selection( SelKind.Player, Level.PlayerSpawns.Count - 1 ) );
		}
		foreach ( var copied in _copiedCoins )
		{
			Level.Coins.Add( copied + delta );
			AddSelection( new Selection( SelKind.Coin, Level.Coins.Count - 1 ) );
		}

		Tool = EditTool.Select;
		Rebuild();
		CheckpointUndo();
	}

	private void PasteAlternatePatternSelection()
	{
		if ( _copiedAlternateRects.Count == 0 && !CopySelection() ) return;

		Vector2 delta = ClampCopiedRectDelta(
			Snap( SnapPoint( _hoverLogical ) - RectCenter( _copiedAlternateBounds ) ), _copiedAlternateBounds );
		Select( SelKind.None, -1 );
		foreach ( RectF copied in _copiedAlternateRects )
		{
			Level.AlternatePlayfieldRects.Add( ClampRect( Translate( copied, delta.x, delta.y ) ) );
			AddSelection( new Selection( SelKind.AlternatePattern, Level.AlternatePlayfieldRects.Count - 1 ) );
		}
		RefreshAlternatePlayfield();
		Rebuild();
		CheckpointUndo();
	}

	public void PasteObstacle() => PasteSelection();

	/// <summary>Keep a pasted rect group inside the arena. Used by the alternate-pattern clipboard, which
	/// is rects only — it must NOT fold in the marker insets <see cref="ClampCopiedDelta"/> reads, since
	/// those lists now hold a live level-object clipboard rather than being cleared by the alt copy.</summary>
	private static Vector2 ClampCopiedRectDelta( Vector2 delta, RectF bounds )
		=> new( Math.Clamp( delta.x, -bounds.Left, Arena.WIDTH - bounds.Right ),
			Math.Clamp( delta.y, -bounds.Bottom, Arena.HEIGHT - bounds.Top ) );

	private Vector2 ClampCopiedDelta( Vector2 delta )
	{
		float minDx = -_copiedBounds.Left, maxDx = Arena.WIDTH - _copiedBounds.Right;
		float minDy = -_copiedBounds.Bottom, maxDy = Arena.HEIGHT - _copiedBounds.Top;
		foreach ( var spawn in _copiedSpawns )
		{
			minDx = MathF.Max( minDx, Arena.WALL_SIZE + SPAWN_HALF - spawn.Position.x );
			maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - SPAWN_HALF - spawn.Position.x );
			minDy = MathF.Max( minDy, Arena.WALL_SIZE + SPAWN_HALF - spawn.Position.y );
			maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - SPAWN_HALF - spawn.Position.y );
		}
		foreach ( var copied in _copiedPlayers )
		{
			minDx = MathF.Max( minDx, Arena.WALL_SIZE + PLAYER_HALF_W - copied.x );
			maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - PLAYER_HALF_W - copied.x );
			minDy = MathF.Max( minDy, Arena.WALL_SIZE + PLAYER_HALF_H - copied.y );
			maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - PLAYER_HALF_H - copied.y );
		}
		foreach ( var copied in _copiedCoins )
		{
			minDx = MathF.Max( minDx, Arena.WALL_SIZE + COIN_HALF_W - copied.x );
			maxDx = MathF.Min( maxDx, Arena.WIDTH - Arena.WALL_SIZE - COIN_HALF_W - copied.x );
			minDy = MathF.Max( minDy, Arena.WALL_SIZE + COIN_HALF_H - copied.y );
			maxDy = MathF.Min( maxDy, Arena.HEIGHT - Arena.WALL_SIZE - COIN_HALF_H - copied.y );
		}
		return new Vector2( Math.Clamp( delta.x, minDx, maxDx ), Math.Clamp( delta.y, minDy, maxDy ) );
	}

	public void NudgeSelected( int dx, int dy )
	{
		if ( _dragging || _selection.Count == 0 ) return;
		// Nudges keep mirror twins mirrored: partners of the primary get the axis-flipped delta.
		RefreshMoveMirrorRelations( _primarySelection );
		Vector2 delta = ClampSelectionDelta( new Vector2( dx, dy ) );
		foreach ( var selected in _selection )
		{
			// Mirror twins of the primary follow with the axis-flipped delta, staying mirrored.
			Vector2 elementDelta = MirroredDelta( selected, delta );
			switch ( selected.Kind )
			{
				case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
					Level.SpawnPositions[selected.Index] += elementDelta;
					break;
				case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
					Level.PlayerSpawns[selected.Index] += elementDelta;
					break;
				case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
					Level.Coins[selected.Index] += elementDelta;
					break;
				case SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count:
					// Re-snap to the pixel grid after translating so arrow keys also RESCUE an
					// obstacle that's already off-grid (integer nudges alone can't leave a *.5 edge).
					Level.Obstacles[selected.Index] = SnapRectPixel(
						Translate( Level.Obstacles[selected.Index], elementDelta.x, elementDelta.y ) );
					break;
				case SelKind.AlternatePattern when Tool == EditTool.AlternatePattern
					&& selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count:
					Level.AlternatePlayfieldRects[selected.Index] = SnapRectPixel(
						Translate( Level.AlternatePlayfieldRects[selected.Index], delta.x, delta.y ) );
					break;
			}
		}
		_moveMirrorRelations.Clear();
		if ( Tool == EditTool.AlternatePattern ) RefreshAlternatePlayfield();
		Rebuild();
		// Coalesced: a burst of arrow presses reverts as ONE undo step (a >1s pause starts a new one).
		CheckpointUndo( "nudge" );
	}

	private Vector2 ClampSelectionDelta( Vector2 delta, bool useMoveOrigins = false )
	{
		float minDx = float.NegativeInfinity, maxDx = float.PositiveInfinity;
		float minDy = float.NegativeInfinity, maxDy = float.PositiveInfinity;
		foreach ( var selected in _selection )
		{
			RectF bounds = useMoveOrigins ? MoveOriginBounds( selected ) : SelectionBounds( selected );
			float insetX = selected.Kind == SelKind.Player ? Arena.WALL_SIZE : 0f;
			float insetY = selected.Kind == SelKind.Player ? Arena.WALL_SIZE : 0f;
			if ( selected.Kind is SelKind.Spawn or SelKind.Coin ) insetX = insetY = Arena.WALL_SIZE;
			// A mirror twin moves with the FLIPPED delta on a mirrored axis, and the arena is symmetric,
			// so the anchor's own clamp already covers it there — the unflipped clamp would be wrong.
			bool skipX = false, skipY = false;
			if ( _moveMirrorRelations.TryGetValue( selected, out var relation ) )
			{
				skipX = relation.FlipX;
				skipY = relation.FlipY;
			}
			if ( !skipX )
			{
				minDx = MathF.Max( minDx, insetX - bounds.Left );
				maxDx = MathF.Min( maxDx, Arena.WIDTH - insetX - bounds.Right );
			}
			if ( !skipY )
			{
				minDy = MathF.Max( minDy, insetY - bounds.Bottom );
				maxDy = MathF.Min( maxDy, Arena.HEIGHT - insetY - bounds.Top );
			}
		}
		return new Vector2( Math.Clamp( delta.x, minDx, maxDx ), Math.Clamp( delta.y, minDy, maxDy ) );
	}

	private RectF MoveOriginBounds( Selection selected )
	{
		if ( (selected.Kind is SelKind.Obstacle or SelKind.AlternatePattern) && _moveOriginRects.TryGetValue( selected, out var rect ) )
			return rect;
		if ( !_moveOriginCenters.TryGetValue( selected, out var center ) )
			return SelectionBounds( selected );
		return selected.Kind switch
		{
			SelKind.Spawn => BoxAt( center, SPAWN_HALF, SPAWN_HALF ),
			SelKind.Player => BoxAt( center, PLAYER_HALF_W, PLAYER_HALF_H ),
			SelKind.Coin => BoxAt( center, COIN_HALF_W, COIN_HALF_H ),
			_ => SelectionBounds( selected ),
		};
	}

	private static Vector2 Snap( Vector2 v ) => new( MathF.Round( v.x ), MathF.Round( v.y ) );

	// ── grid snapping (active while the alignment grid overlay is shown) ─────────────────────────────
	// When the editor grid is visible, points snap to the nearest grid LINE matching DrawGrid, so
	// dragged/created geometry lands on the lines the author sees. When off, use 1px integer snapping.
	private bool GridSnapActive => SnapActive && EditorPrefs.Current.ShowGrid;
	private static float GridLineOrigin( float gridSize, bool xAxis )
	{
		var settings = EditorPrefs.Current;
		float offset = xAxis ? settings.GridOffsetX : settings.GridOffsetY;
		offset = float.IsFinite( offset ) ? offset % gridSize : 0f;
		if ( !settings.GridCenterCell && settings.GridBottomLeftOrigin )
			return Arena.WALL_SIZE + offset;
		float dimension = xAxis ? Arena.WIDTH : Arena.HEIGHT;
		// Whole-pixel origin: "Center on Cell" with an ODD grid size lands lines on x.5, and snapped
		// geometry inherits those half-pixels (the game has no sub-pixel geometry anywhere else).
		return MathF.Round( dimension / 2f + ( settings.GridCenterCell ? gridSize / 2f : 0f ) + offset );
	}

	private static float GridSnapAxis( float v, bool xAxis )
	{
		var s = EditorPrefs.Current;
		float gs = MathF.Max( 2f, s.GridSize );
		if ( s.GridSmart ) return SmartGridSnapAxis( v, gs, xAxis );
		float origin = GridLineOrigin( gs, xAxis );
		// Rounded so a fractional grid size can't hand out sub-pixel snap targets (origin is already whole).
		float line = MathF.Round( origin + MathF.Round( ( v - origin ) / gs ) * gs );
		// Only pull to the line when the point is already CLOSE to it — don't force geometry to exactly
		// fill cells; otherwise keep the free 1px position so off-grid placement is still possible.
		return MathF.Abs( line - v ) <= GRID_SNAP_DIST ? line : MathF.Round( v );
	}

	// How close (logical px) a dragged point must be to a grid line before it snaps to it.
	private const float GRID_SNAP_DIST = 4f;

	// ── smart grid ────────────────────────────────────────────────────────────────────────────────────
	// Two lattices mirrored inward from the playable edges (the INNER wall faces), so a line always sits
	// wherever a block flush against either wall would land. Clamping keeps candidates inside the playable
	// span; both clamp targets are themselves lattice lines (each wall face is the other lattice's k=0).
	private static float SmartGridSnapAxis( float v, float gs, bool xAxis )
	{
		float dim = xAxis ? Arena.WIDTH : Arena.HEIGHT;
		float lo = Arena.WALL_SIZE, hi = dim - Arena.WALL_SIZE;
		float fromLo = Math.Clamp( lo + MathF.Round( ( v - lo ) / gs ) * gs, lo, hi );
		float fromHi = Math.Clamp( hi - MathF.Round( ( hi - v ) / gs ) * gs, lo, hi );
		// Rounded like GridSnapAxis — a fractional grid size must not produce sub-pixel snap targets.
		float line = MathF.Round( MathF.Abs( fromLo - v ) <= MathF.Abs( fromHi - v ) ? fromLo : fromHi );
		return MathF.Abs( line - v ) <= GRID_SNAP_DIST ? line : MathF.Round( v );
	}

	/// <summary>Sorted, deduplicated smart-grid line coordinates for one axis: multiples of the grid
	/// size walked inward from both inner wall faces, merged where the mirrored lattices coincide.</summary>
	private static List<float> SmartGridLines( float gs, bool xAxis )
	{
		float dim = xAxis ? Arena.WIDTH : Arena.HEIGHT;
		float lo = Arena.WALL_SIZE, hi = dim - Arena.WALL_SIZE;
		var raw = new List<float>();
		for ( float p = lo; p <= hi + 0.01f; p += gs ) raw.Add( p );
		for ( float p = hi; p >= lo - 0.01f; p -= gs ) raw.Add( p );
		raw.Sort();
		var lines = new List<float>();
		foreach ( float p in raw )
			if ( lines.Count == 0 || p - lines[^1] > 0.01f )
				lines.Add( p );
		return lines;
	}

	private static Vector2 SnapToGrid( Vector2 v ) => new( GridSnapAxis( v.x, true ), GridSnapAxis( v.y, false ) );

	/// <summary>Snap a point to the grid when it's shown, else to the 1px pixel grid. Holding Alt
	/// temporarily inverts the persisted snap setting.</summary>
	private Vector2 SnapPoint( Vector2 v )
	{
		if ( !SnapActive ) return Snap( v );
		return GridSnapActive ? SnapToGrid( v ) : Snap( v );
	}

	/// <summary>Whether Ctrl is held (raw keyboard). Used for additive selection and editor shortcuts.
	/// Checks several button-code spellings because the engine's name for
	/// the control keys isn't the Source-style LCONTROL/RCONTROL (the tools KeyBind widget + the default
	/// "Duck" action use "ctrl"/"CTRL"/"RCTRL"); unknown names resolve to INVALID and safely return false.</summary>
	private static bool CtrlHeld =>
		Input.Keyboard.Down( "CTRL" ) || Input.Keyboard.Down( "RCTRL" ) ||
		Input.Keyboard.Down( "LCONTROL" ) || Input.Keyboard.Down( "RCONTROL" );

	/// <summary>Whether Shift is held (raw keyboard). Same multiple-spellings defence as
	/// <see cref="CtrlHeld"/>; unknown names resolve to INVALID and safely return false.</summary>
	private static bool ShiftHeld =>
		Input.Keyboard.Down( "SHIFT" ) || Input.Keyboard.Down( "RSHIFT" ) || Input.Keyboard.Down( "LSHIFT" );

	// Modifier state forwarded by the HUD's focused shortcut panel (same pattern as Alt/X): the RAW
	// keyboard reads return false while UI input is suppressed/focused, so panel ButtonEvents are the
	// reliable source. The raw checks stay in as a fallback for when the panel doesn't have focus.
	private bool _shiftModifierHeld;
	private bool _ctrlModifierHeld;
	public void SetShiftModifierHeld( bool held ) => _shiftModifierHeld = held;
	public void SetCtrlModifierHeld( bool held ) => _ctrlModifierHeld = held;

	/// <summary>Additive-selection modifier: Shift or Ctrl. A modified click toggles the element in
	/// and out of the selection; a modified marquee toggles everything it touches.</summary>
	private bool AdditiveHeld => _shiftModifierHeld || _ctrlModifierHeld || CtrlHeld || ShiftHeld;

	private void PointerDown( Vector2 logical )
	{
		_spikeToggledOnPointerDown = false;
		switch ( Tool )
		{
			case EditTool.AlternatePattern:
				if ( !AdditiveHeld && TryHitSelectedRectFace( logical, out SelKind alternateResizeKind,
					out int alternateResizeIndex, out Direction alternateResizeSide ) )
				{
					BeginResize( alternateResizeKind, alternateResizeIndex, alternateResizeSide, logical );
					break;
				}

				int alternateHit = HitAlternateRect( logical );
				if ( alternateHit >= 0 )
				{
					var clicked = new Selection( SelKind.AlternatePattern, alternateHit );
					bool additive = AdditiveHeld;
					_additiveClickSelection = additive ? clicked : new Selection( SelKind.None, -1 );
					_additiveClickWasSelected = additive && _selection.Contains( clicked );
					if ( additive && !_additiveClickWasSelected ) AddSelection( clicked );
					BeginMove( clicked.Kind, clicked.Index, logical, preserveSelection: additive || _selection.Contains( clicked ) );
				}
				else
				{
					_dragging = true;
					_creatingAlternateRect = true;
					_dragStart = logical;
					_dragCurrent = logical;
					if ( !AdditiveHeld ) Select( SelKind.None, -1 );
					Rebuild();
				}
				break;

			case EditTool.Obstacle:
				_dragging = true;
				_creatingRect = true;
				// Keep the drag corners RAW; the whole rect is snapped (flush→grid→pixel) in CreateRect so
				// flush-to-wall wins over grid (a grid-snapped corner would read as an exact flush match).
				_dragStart = logical;
				_dragCurrent = logical;
				Rebuild();
				break;

			case EditTool.Spawn:
			{
				int hit = HitSpawn( logical );
				if ( hit < 0 )
				{
					hit = Level.AddSpawn( ClampSpawn( SnapBoxCenter( logical, SPAWN_HALF, SPAWN_HALF, -1 ) ) );
					AddMirroredMarkers( SelKind.Spawn, hit );
				}
				BeginMove( SelKind.Spawn, hit, logical );
				break;
			}

			case EditTool.Player:
			{
				// Like the Spawn tool: clicking an existing player spawn grabs it, clicking empty
				// space ADDS one (two are ordered for Twins; three or more are chosen randomly).
				int hit = HitPlayer( logical );
				if ( hit < 0 )
				{
					Level.PlayerSpawns.Add( ClampInside( SnapBoxCenter( logical, PLAYER_HALF_W, PLAYER_HALF_H, -1 ) ) );
					hit = Level.PlayerSpawns.Count - 1;
					AddMirroredMarkers( SelKind.Player, hit );
				}
				BeginMove( SelKind.Player, hit, logical );
				break;
			}

			case EditTool.Coin:
			{
				int hit = HitCoin( logical );
				if ( hit < 0 )
				{
					Level.Coins.Add( ClampCoin( SnapBoxCenter( logical, COIN_HALF_W, COIN_HALF_H, -1 ) ) );
					hit = Level.Coins.Count - 1;
					AddMirroredMarkers( SelKind.Coin, hit );
				}
				BeginMove( SelKind.Coin, hit, logical );
				break;
			}

			case EditTool.Spike:
				_spikeToggledOnPointerDown = ToggleNearestSpike( logical );
				Rebuild();
				break;

			case EditTool.Select:
			{
				if ( !AdditiveHeld && TryHitSelectedRectFace( logical, out SelKind resizeKind, out int resizeIndex,
					out Direction resizeSide ) )
				{
					BeginResize( resizeKind, resizeIndex, resizeSide, logical );
					break;
				}

				var (k, idx) = HitAny( logical );
				if ( k != SelKind.None )
				{
					var clicked = new Selection( k, idx );
					bool additive = AdditiveHeld;
					_additiveClickSelection = additive ? clicked : new Selection( SelKind.None, -1 );
					_additiveClickWasSelected = additive && _selection.Contains( clicked );
					if ( additive && !_additiveClickWasSelected ) AddSelection( clicked );
					BeginMove( k, idx, logical, preserveSelection: additive || _selection.Contains( clicked ) );
				}
				else
				{
					_dragging = true;
					_marqueeSelecting = true;
					_marqueeAdditive = AdditiveHeld;
					_dragStart = logical;
					_dragCurrent = logical;
					if ( !_marqueeAdditive ) Select( SelKind.None, -1 );
					Rebuild();
				}
				break;
			}
		}
	}

	private void BeginMove( SelKind kind, int index, Vector2 logical, bool preserveSelection = false )
	{
		if ( !preserveSelection ) Select( kind, index );
		// A plain (non-additive) click pulls the element's mirror twins into the selection so they
		// move, resize and delete together. Ctrl-clicks stay surgical.
		if ( !preserveSelection )
			foreach ( var partner in MirrorPartners( new Selection( kind, index ) ) )
				AddSelection( partner.Selection );
		_dragging = true;
		_moveKind = kind;
		_moveIndex = index;
		_grabOffset = ElementCenter( kind, index ) - logical;
		_moveOriginCenters.Clear();
		_moveOriginRects.Clear();
		foreach ( var selected in _selection )
		{
			_moveOriginCenters[selected] = ElementCenter( selected.Kind, selected.Index );
			if ( selected.Kind == SelKind.Obstacle && selected.Index >= 0 && selected.Index < Level.Obstacles.Count )
				_moveOriginRects[selected] = Level.Obstacles[selected.Index];
			if ( selected.Kind == SelKind.AlternatePattern && selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count )
				_moveOriginRects[selected] = Level.AlternatePlayfieldRects[selected.Index];
		}
		RefreshMoveMirrorRelations( new Selection( kind, index ) );
		Rebuild();
	}

	private void BeginResize( SelKind kind, int index, Direction side, Vector2 logical )
	{
		_dragging = true;
		_moveKind = kind;
		_moveIndex = index;
		_resizeSide = side;
		_resizeOriginRect = SelectionBounds( new Selection( kind, index ) );
		_resizeGrabOffset = side is Direction.Left or Direction.Right
			? ResizeEdgeCoord( _resizeOriginRect, side ) - logical.x
			: ResizeEdgeCoord( _resizeOriginRect, side ) - logical.y;
		// Mirror twins selected alongside the resized obstacle follow the resize as mirror images.
		_resizeMirrorPartners.Clear();
		if ( kind == SelKind.Obstacle )
			foreach ( var partner in MirrorPartnerObstacles( index ) )
				if ( _selection.Contains( new Selection( SelKind.Obstacle, partner.Index ) ) )
					_resizeMirrorPartners.Add( partner );
		Rebuild();
	}

	private void PointerDrag( Vector2 logical )
	{
		if ( _creatingRect || _creatingAlternateRect )
		{
			if ( logical != _dragCurrent ) { _dragCurrent = logical; Rebuild(); }
			return;
		}
		if ( _marqueeSelecting )
		{
			if ( logical != _dragCurrent ) { _dragCurrent = logical; Rebuild(); }
			return;
		}
		if ( _resizeSide != Direction.None )
		{
			ResizeSelectedRect( logical );
			Rebuild();
			return;
		}

		Vector2 rawCenter = logical + _grabOffset;
		bool cardinalMove = TryGetCardinalMoveAxis( rawCenter, out var moveOrigin, out bool horizontalMove );
		if ( cardinalMove ) rawCenter = ConstrainMoveCenter( rawCenter, moveOrigin, horizontalMove );
		if ( _selection.Count > 1 )
		{
			var anchor = new Selection( _moveKind, _moveIndex );
			if ( !_moveOriginCenters.TryGetValue( anchor, out var anchorOrigin ) ) return;
			// Whole-pixel delta: an odd-sized anchor has its centre at x.5, so the raw difference is
			// half-pixel-fractional and would shift EVERY selected element onto half-pixels.
			Vector2 delta = Snap( SnapPoint( rawCenter ) - anchorOrigin );
			if ( cardinalMove ) delta = horizontalMove ? new Vector2( delta.x, 0f ) : new Vector2( 0f, delta.y );
			delta = ClampSelectionDelta( delta, useMoveOrigins: true );
			foreach ( var selected in _selection )
			{
				if ( !_moveOriginCenters.TryGetValue( selected, out var center ) ) continue;
				// Mirror twins of the anchor follow with the axis-flipped delta, staying mirrored.
				Vector2 elementDelta = MirroredDelta( selected, delta );
				switch ( selected.Kind )
				{
					case SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count:
						Level.SpawnPositions[selected.Index] = center + elementDelta;
						break;
					case SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count:
						Level.PlayerSpawns[selected.Index] = center + elementDelta;
						break;
					case SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count:
						Level.Coins[selected.Index] = center + elementDelta;
						break;
					case SelKind.Obstacle when _moveOriginRects.TryGetValue( selected, out var rect):
						Level.Obstacles[selected.Index] = Translate( rect, elementDelta.x, elementDelta.y );
						break;
					case SelKind.AlternatePattern when _moveOriginRects.TryGetValue( selected, out var alternateRect ):
						Level.AlternatePlayfieldRects[selected.Index] = Translate( alternateRect, delta.x, delta.y );
						break;
				}
			}
			if ( _selection.Any( selected => selected.Kind == SelKind.AlternatePattern ) ) RefreshAlternatePlayfield();
			Rebuild();
			return;
		}

		switch ( _moveKind )
		{
			case SelKind.Spawn:
				if ( _moveIndex >= 0 && _moveIndex < Level.SpawnPositions.Count )
				{
					Vector2 center = ClampSpawn( SnapBoxCenter( rawCenter, SPAWN_HALF, SPAWN_HALF, _moveIndex ) );
					Level.SpawnPositions[_moveIndex] = cardinalMove
						? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
						: center;
				}
				break;
			case SelKind.Player:
				if ( _moveIndex >= 0 && _moveIndex < Level.PlayerSpawns.Count )
				{
					Vector2 center = ClampInside( SnapBoxCenter( rawCenter, PLAYER_HALF_W, PLAYER_HALF_H, -1 ) );
					Level.PlayerSpawns[_moveIndex] = cardinalMove
						? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
						: center;
				}
				break;
			case SelKind.Coin:
				if ( _moveIndex >= 0 && _moveIndex < Level.Coins.Count )
				{
					Vector2 center = ClampCoin( SnapBoxCenter( rawCenter, COIN_HALF_W, COIN_HALF_H, -1 ) );
					Level.Coins[_moveIndex] = cardinalMove
						? ConstrainMoveCenter( center, moveOrigin, horizontalMove )
						: center;
				}
				break;
			case SelKind.Obstacle:
				if ( _moveIndex >= 0 && _moveIndex < Level.Obstacles.Count )
				{
					// Snap PRIORITY: flush to a wall/obstacle edge FIRST, then grid, then pixel (G =
					// pixel only). Feed the RAW centre so grid pre-snapping can't pull the obstacle out of
					// a wall's flush range before the flush check runs.
					RectF moved = RecenteredRect( Level.Obstacles[_moveIndex], rawCenter );
					moved = SnapRectTranslate( moved, _moveIndex );
					moved = ClampRect( moved );
					Level.Obstacles[_moveIndex] = cardinalMove
						? RecenteredRect( moved, ConstrainMoveCenter( RectCenter( moved ), moveOrigin, horizontalMove ) )
						: moved;
				}
				break;
			case SelKind.AlternatePattern:
				if ( _moveIndex >= 0 && _moveIndex < Level.AlternatePlayfieldRects.Count )
				{
					RectF moved = RecenteredRect( Level.AlternatePlayfieldRects[_moveIndex], rawCenter );
					moved = SnapRectTranslate( moved, -1, ignoreAlternate: _moveIndex, includeAlternate: true );
					moved = ClampRect( moved );
					Level.AlternatePlayfieldRects[_moveIndex] = cardinalMove
						? RecenteredRect( moved, ConstrainMoveCenter( RectCenter( moved ), moveOrigin, horizontalMove ) )
						: moved;
					RefreshAlternatePlayfield();
				}
				break;
		}
		Rebuild();
	}

	private bool TryGetCardinalMoveAxis( Vector2 rawCenter, out Vector2 origin, out bool horizontal )
	{
		origin = default;
		var anchor = new Selection( _moveKind, _moveIndex );
		if ( !_cardinalDragHeld || !_moveOriginCenters.TryGetValue( anchor, out origin ) )
		{
			horizontal = false;
			return false;
		}

		Vector2 delta = rawCenter - origin;
		horizontal = MathF.Abs( delta.x ) >= MathF.Abs( delta.y );
		return true;
	}

	private static Vector2 ConstrainMoveCenter( Vector2 center, Vector2 origin, bool horizontal )
		=> horizontal ? new Vector2( center.x, origin.y ) : new Vector2( origin.x, center.y );

	private void PointerUp( Vector2 logical )
	{
		if ( _creatingAlternateRect )
		{
			_dragCurrent = logical;
			RectF rect = CreateRect();
			if ( rect.Width >= 1f && rect.Height >= 1f )
			{
				Level.AlternatePlayfieldRects.Add( ClampRect( rect ) );
				Select( SelKind.AlternatePattern, Level.AlternatePlayfieldRects.Count - 1 );
				RefreshAlternatePlayfield();
			}
		}
		else if ( _creatingRect )
		{
			_dragCurrent = logical;
			// Snap PRIORITY: flush to a nearby obstacle edge / arena wall FIRST (so composite obstacles
			// land EXACTLY edge-to-edge — the game only merges walls within FLUSH_EPS=0.5px), then grid,
			// then pixel. Snap disabled = pixel only.
			RectF r = CreateRect();
			if ( r.Width >= MIN_RECT && r.Height >= MIN_RECT )
			{
				Level.Obstacles.Add( ClampRect( r ) );
				Select( SelKind.Obstacle, Level.Obstacles.Count - 1 );
				AddMirroredObstacles( Level.Obstacles.Count - 1 );
			}
		}
		else if ( _pointerDragged && !_marqueeSelecting )
		{
			NormalizeDraggedRectEdges();
		}

		if ( _marqueeSelecting )
		{
			Tool = EditTool.Select;
			if ( _pointerDragged ) SelectMarquee( Normalize( _dragStart, logical ), _marqueeAdditive );
			else if ( !_marqueeAdditive ) Select( SelKind.None, -1 );
		}
		else if ( !_pointerDragged && _additiveClickSelection.Kind != SelKind.None && _additiveClickWasSelected )
		{
			RemoveSelection( _additiveClickSelection );
		}

		if ( Tool != EditTool.AlternatePattern && !_pointerDragged && !_spikeToggledOnPointerDown && HitAny( _pointerDownLogical ).Item1 == SelKind.None )
		{
			Tool = EditTool.Select;
			Select( SelKind.None, -1 );
		}

		_dragging = false;
		_creatingRect = false;
		_creatingAlternateRect = false;
		_marqueeSelecting = false;
		_marqueeAdditive = false;
		_additiveClickSelection = new Selection( SelKind.None, -1 );
		_additiveClickWasSelected = false;
		_spikeToggledOnPointerDown = false;
		_moveKind = SelKind.None;
		_moveIndex = -1;
		_resizeSide = Direction.None;
		_resizeOriginRect = default;
		_resizeGrabOffset = 0f;
		_moveOriginCenters.Clear();
		_moveOriginRects.Clear();
		_moveMirrorRelations.Clear();
		_resizeMirrorPartners.Clear();
		Rebuild();
		// One entry per pointer operation (create/move/resize/spike-toggle/spawn-add), not per frame.
		CheckpointUndo();
	}

	/// <summary>Right-click: delete whatever is under the cursor without changing the active tool.</summary>
	public void SecondaryClick()
	{
		if ( Tool == EditTool.AlternatePattern )
		{
			int alternate = HitAlternateRect( ScreenToLogical( Mouse.Position ) );
			if ( alternate < 0 ) return;
			Level.AlternatePlayfieldRects.RemoveAt( alternate );
			Select( SelKind.None, -1 );
			RefreshAlternatePlayfield();
			Rebuild();
			CheckpointUndo();
			return;
		}

		var (k, idx) = HitAny( ScreenToLogical( Mouse.Position ) );
		if ( k == SelKind.None ) return;
		// Right-click delete takes the element's mirror twins with it (descending so indices hold).
		var doomed = MirrorPartners( new Selection( k, idx ) ).Select( partner => partner.Selection.Index )
			.Append( idx ).OrderByDescending( i => i );
		foreach ( int index in doomed )
		{
			switch ( k )
			{
				case SelKind.Obstacle: Level.RemoveObstacleAt( index ); break;
				case SelKind.Spawn: Level.RemoveSpawnAt( index ); break;
				case SelKind.Player: Level.RemovePlayerSpawnAt( index ); break;   // refuses the last one
				case SelKind.Coin: Level.RemoveCoinAt( index ); break;
			}
		}
		Select( SelKind.None, -1 );
		Rebuild();
		CheckpointUndo();
	}

	public void DeleteSelected()
	{
		foreach ( int index in _selection.Where( s => s.Kind == SelKind.Obstacle ).Select( s => s.Index ).OrderByDescending( i => i ) )
			Level.RemoveObstacleAt( index );
		foreach ( int index in _selection.Where( s => s.Kind == SelKind.Spawn ).Select( s => s.Index ).OrderByDescending( i => i ) )
			Level.RemoveSpawnAt( index );
		// Player spawns delete like everything else, EXCEPT the last one: a level always keeps at
		// least one, even when every player spawn is selected at once (RemovePlayerSpawnAt refuses).
		foreach ( int index in _selection.Where( s => s.Kind == SelKind.Player ).Select( s => s.Index ).OrderByDescending( i => i ) )
			Level.RemovePlayerSpawnAt( index );
		foreach ( int index in _selection.Where( s => s.Kind == SelKind.Coin ).Select( s => s.Index ).OrderByDescending( i => i ) )
			Level.RemoveCoinAt( index );
		bool deletedAlternate = false;
		if ( Tool == EditTool.AlternatePattern )
		{
			foreach ( int index in _selection.Where( s => s.Kind == SelKind.AlternatePattern ).Select( s => s.Index ).OrderByDescending( i => i ) )
			{
				if ( index < 0 || index >= Level.AlternatePlayfieldRects.Count ) continue;
				Level.AlternatePlayfieldRects.RemoveAt( index );
				deletedAlternate = true;
			}
		}
		Select( SelKind.None, -1 );
		if ( deletedAlternate ) RefreshAlternatePlayfield();
		Rebuild();
		CheckpointUndo();
	}

	// ── undo/redo (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) ──────────────────────────────────────────────────

	/// <summary>Record the current level state as an undo entry. No-op when nothing changed, so the
	/// discrete-operation call sites and the HUD poll can both call it generously. Snapshots use the
	/// FULL authoring serialization (both modes' block lists, disabled alt-playfield settings) so a
	/// history step can't silently wipe inactive-mode work. The session-only ProjectSourceId rides along
	/// so undoing a reset/import/template load gives back the right to update the original file;
	/// <paramref name="newDocument"/> flags those three, which replace the document rather than edit it.</summary>
	private void CheckpointUndo( string coalesceKey = null, bool newDocument = false )
		=> UndoHistory.Checkpoint( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId, coalesceKey, newDocument );

	/// <summary>Capture the open document's LAST state right before a reset/paste-import/template load
	/// replaces it. Pointer and hotkey edits checkpoint themselves, so anything still unrecorded is HUD
	/// field drift the poll hasn't caught yet — without this, undoing the replacement would restore a
	/// snapshot up to a poll interval stale. Same "hud" key as the poll, so it merges into a typing burst.</summary>
	private void CheckpointOutgoingDocument() => CheckpointUndo( "hud" );

	public void Undo() => TryHistoryStep( redo: false );
	public void Redo() => TryHistoryStep( redo: true );

	private void TryHistoryStep( bool redo )
	{
		// Ignored mid-drag: the drag bookkeeping indexes the live lists, and the whole drag checkpoints
		// as one entry on pointer-up anyway. The debounce stops the two hotkey paths double-stepping.
		if ( _dragging || _editorClock - _lastUndoActionTime < UNDO_ACTION_DEBOUNCE ) return;
		_lastUndoActionTime = _editorClock;

		string json = Level.ToJsonString( fullAuthoringState: true );
		ApplyHistorySnapshot( redo ? UndoHistory.Redo( json, Level.ProjectSourceId ) : UndoHistory.Undo( json, Level.ProjectSourceId ) );
	}

	private void ApplyHistorySnapshot( EditorUndoHistory.Entry? entry )
	{
		if ( entry is null ) return;
		var restored = EditorLevel.FromJsonString( entry.Value.Json, out _ );
		if ( restored is null ) return;

		// The source-file identity travels with the entry: undoing a reset/paste-import/template load
		// hands back the original document, which must stay saveable over its own file.
		restored.ProjectSourceId = entry.Value.SourceId;
		// The daily-template authoring fields are session-only (never serialized), so carry the live
		// values across — undo/redo shouldn't blank the template name box.
		restored.DailyTemplateName = Level.DailyTemplateName;
		restored.DailyTemplateMirrorX = Level.DailyTemplateMirrorX;
		restored.DailyTemplateAuthoredSpikes = Level.DailyTemplateAuthoredSpikes;
		restored.DailyTemplateWeight = Level.DailyTemplateWeight;

		Level = restored;
		// The reopen pointer is part of the document identity too: it tracks the live source file
		// everywhere else (open, save, reset, import, template load), so a step has to move it back.
		RememberOpenedLevel( Level.ProjectSourceId ?? "" );
		RefreshMusic();
		LevelRevision++;   // refreshes the HUD's inspector fields, same as load/import
		Select( SelKind.None, -1 );
		LastError = "";
		RefreshPalette();
		RefreshEnvironment();
		Rebuild();

		// Deserializing normalizes a few fields (clamps and similar), so the live level may
		// serialize differently from the snapshot we just applied — re-anchor the history baseline to
		// the real state so the TickMenu poll doesn't read that drift as a new edit and clear redo.
		UndoHistory.Rebase( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
	}

	public void SelectAll()
	{
		Select( SelKind.None, -1 );
		if ( Tool == EditTool.AlternatePattern )
		{
			for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
				AddSelection( new Selection( SelKind.AlternatePattern, i ) );
			Rebuild();
			return;
		}
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
			AddSelection( new Selection( SelKind.Obstacle, i ) );
		for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
			AddSelection( new Selection( SelKind.Spawn, i ) );
		for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
			AddSelection( new Selection( SelKind.Player, i ) );
		for ( int i = 0; i < Level.Coins.Count; i++ )
			AddSelection( new Selection( SelKind.Coin, i ) );
		Tool = EditTool.Select;
		Rebuild();
	}

	private void Select( SelKind kind, int index )
	{
		_selection.Clear();
		_primarySelection = new Selection( kind, index );
		if ( kind != SelKind.None ) _selection.Add( _primarySelection );
	}

	private void AddSelection( Selection selected )
	{
		if ( selected.Kind == SelKind.None ) return;
		_selection.Add( selected );
		_primarySelection = selected;
	}

	private void RemoveSelection( Selection selected )
	{
		_selection.Remove( selected );
		_primarySelection = _selection.Count > 0 ? _selection.Last() : new Selection( SelKind.None, -1 );
	}

	private void SelectMarquee( RectF marquee, bool additive )
	{
		if ( !additive ) Select( SelKind.None, -1 );
		// An additive (Shift/Ctrl) marquee TOGGLES: unselected items inside it join the selection,
		// already-selected ones leave it — so a second sweep over a chunk deselects that chunk.
		void Toggle( Selection hit )
		{
			if ( additive && _selection.Contains( hit ) ) RemoveSelection( hit );
			else AddSelection( hit );
		}
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
			if ( RectsTouch( marquee, Level.Obstacles[i] ) ) Toggle( new Selection( SelKind.Obstacle, i ) );
		for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
			if ( RectsTouch( marquee, BoxAt( Level.SpawnPositions[i], SPAWN_HALF, SPAWN_HALF ) ) ) Toggle( new Selection( SelKind.Spawn, i ) );
		for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
			if ( RectsTouch( marquee, BoxAt( Level.PlayerSpawns[i], PLAYER_HALF_W, PLAYER_HALF_H ) ) )
				Toggle( new Selection( SelKind.Player, i ) );
		for ( int i = 0; i < Level.Coins.Count; i++ )
			if ( RectsTouch( marquee, BoxAt( Level.Coins[i], COIN_HALF_W, COIN_HALF_H ) ) )
				Toggle( new Selection( SelKind.Coin, i ) );
	}

	/// <summary>Indices of every selected obstacle (empty when none / other kinds selected).</summary>
	private int[] SelectedObstacleIndices()
		=> _selection.Where( s => s.Kind == SelKind.Obstacle && s.Index >= 0 && s.Index < Level.Obstacles.Count )
			.Select( s => s.Index ).ToArray();

	/// <summary>True when the selection is non-empty and contains only obstacles. Gates the HUD's
	/// obstacle-flag toggles, so they stay usable for a multi-selection (e.g. a mirror group).</summary>
	public bool SelectionIsObstacles
		=> _selection.Count > 0
		&& _selection.All( s => s.Kind == SelKind.Obstacle && s.Index >= 0 && s.Index < Level.Obstacles.Count );

	/// <summary>Toggle Blocks Vision across the selected obstacles. If any selected obstacle doesn't
	/// block vision, all start blocking; otherwise all stop (same any→all rule as Fence/Glass).</summary>
	public void ToggleSelectedObstacleVision()
	{
		int[] indices = SelectedObstacleIndices();
		if ( indices.Length == 0 ) return;
		bool enable = indices.Any( i => !Level.ObstacleBlocksVision( i ) );
		foreach ( int index in indices )
			if ( Level.ObstacleBlocksVision( index ) != enable ) Level.ToggleObstacleVision( index );
		Rebuild();
		CheckpointUndo();
	}

	/// <summary>Whether every selected obstacle is flagged to block vision (false when none selected).
	/// For the HUD toggle's on/off state.</summary>
	public bool SelectedObstacleBlocksVision
	{
		get
		{
			int[] indices = SelectedObstacleIndices();
			return indices.Length > 0 && indices.All( Level.ObstacleBlocksVision );
		}
	}

	/// <summary>Toggle Fence across the selected obstacles. If any selected obstacle is not a fence,
	/// all become fences; otherwise all fences are cleared. Turning it on strips spikes and the vision
	/// flag (see <see cref="EditorLevel.ToggleObstacleFence"/>).</summary>
	public void ToggleSelectedObstacleFence()
	{
		int[] indices = SelectedObstacleIndices();
		if ( indices.Length == 0 ) return;
		bool enable = indices.Any( i => !Level.ObstacleIsFence( i ) );
		foreach ( int index in indices )
			if ( Level.ObstacleIsFence( index ) != enable ) Level.ToggleObstacleFence( index );
		Rebuild();
		CheckpointUndo();
	}

	/// <summary>Whether every selected obstacle is flagged as a fence (false when none selected).
	/// For the HUD toggle's on/off state.</summary>
	public bool SelectedObstacleIsFence
	{
		get
		{
			int[] indices = SelectedObstacleIndices();
			return indices.Length > 0 && indices.All( Level.ObstacleIsFence );
		}
	}

	/// <summary>Toggle Glass across the selected obstacles. If any selected obstacle is not glass,
	/// all become glass; otherwise all glass flags are cleared. Enabling clears fence/vision flags;
	/// authored spikes are kept (see <see cref="EditorLevel.ToggleObstacleGlass"/>).</summary>
	public void ToggleSelectedObstacleGlass()
	{
		int[] indices = SelectedObstacleIndices();
		if ( indices.Length == 0 ) return;
		bool enable = indices.Any( i => !Level.ObstacleIsGlass( i ) );
		foreach ( int index in indices )
			if ( Level.ObstacleIsGlass( index ) != enable ) Level.ToggleObstacleGlass( index );
		Rebuild();
		CheckpointUndo();
	}

	/// <summary>Whether every selected obstacle is flagged as glass (false when none selected).
	/// For the HUD toggle's on/off state.</summary>
	public bool SelectedObstacleIsGlass
	{
		get
		{
			int[] indices = SelectedObstacleIndices();
			return indices.Length > 0 && indices.All( Level.ObstacleIsGlass );
		}
	}

	// ── hit testing / geometry helpers ──────────────────────────────────────────────────────────────

	private Vector2 ElementCenter( SelKind kind, int index ) => kind switch
	{
		SelKind.Spawn when index >= 0 && index < Level.SpawnPositions.Count => Level.SpawnPositions[index],
		SelKind.Player when index >= 0 && index < Level.PlayerSpawns.Count => Level.PlayerSpawns[index],
		SelKind.Coin when index >= 0 && index < Level.Coins.Count => Level.Coins[index],
		SelKind.Obstacle when index >= 0 && index < Level.Obstacles.Count => RectCenter( Level.Obstacles[index] ),
		SelKind.AlternatePattern when index >= 0 && index < Level.AlternatePlayfieldRects.Count => RectCenter( Level.AlternatePlayfieldRects[index] ),
		_ => Vector2.Zero,
	};

	private RectF SelectionBounds( Selection selected ) => selected.Kind switch
	{
		SelKind.Obstacle when selected.Index >= 0 && selected.Index < Level.Obstacles.Count => Level.Obstacles[selected.Index],
		SelKind.Spawn when selected.Index >= 0 && selected.Index < Level.SpawnPositions.Count => BoxAt( Level.SpawnPositions[selected.Index], SPAWN_HALF, SPAWN_HALF ),
		SelKind.Player when selected.Index >= 0 && selected.Index < Level.PlayerSpawns.Count => BoxAt( Level.PlayerSpawns[selected.Index], PLAYER_HALF_W, PLAYER_HALF_H ),
		SelKind.Coin when selected.Index >= 0 && selected.Index < Level.Coins.Count => BoxAt( Level.Coins[selected.Index], COIN_HALF_W, COIN_HALF_H ),
		SelKind.AlternatePattern when selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count => Level.AlternatePlayfieldRects[selected.Index],
		_ => default,
	};

	private static RectF BoxAt( Vector2 center, float halfWidth, float halfHeight )
		=> new( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );

	private int HitSpawn( Vector2 p )
	{
		for ( int i = Level.SpawnPositions.Count - 1; i >= 0; i-- )
		{
			var c = Level.SpawnPositions[i];
			if ( MathF.Abs( p.x - c.x ) <= 20f && MathF.Abs( p.y - c.y ) <= 20f )
				return i;
		}
		return -1;
	}

	private int HitPlayer( Vector2 p )
	{
		for ( int i = Level.PlayerSpawns.Count - 1; i >= 0; i-- )
		{
			var c = Level.PlayerSpawns[i];
			if ( MathF.Abs( p.x - c.x ) <= PLAYER_HALF_W && MathF.Abs( p.y - c.y ) <= PLAYER_HALF_H )
				return i;
		}
		return -1;
	}

	private int HitCoin( Vector2 p )
	{
		for ( int i = Level.Coins.Count - 1; i >= 0; i-- )
		{
			var c = Level.Coins[i];
			if ( MathF.Abs( p.x - c.x ) <= COIN_HIT_TOLERANCE && MathF.Abs( p.y - c.y ) <= COIN_HIT_TOLERANCE )
				return i;
		}
		return -1;
	}

	/// <summary>Hit-test the resize grab bands of the single selected RECT (obstacle or alternate-pattern
	/// region). Reports the kind alongside the index so callers resize whatever is actually selected
	/// rather than whatever their tool assumes — the two index different lists. On a miss everything is
	/// reported empty (None / -1 / None).</summary>
	private bool TryHitSelectedRectFace( Vector2 p, out SelKind kind, out int index, out Direction side )
	{
		kind = SelKind.None;
		index = -1;
		side = Direction.None;
		if ( SelectedKind is not (SelKind.Obstacle or SelKind.AlternatePattern) )
			return TryHitMirrorGroupFace( p, ref kind, ref index, ref side );
		if ( SelectedIndex < 0 || SelectedIndex >= RectCount( SelectedKind ) ) return false;

		// The alternate-pattern tool resizes AND draws, so its grab zone must not reach into empty space:
		// a press just outside the selected rect has to start a NEW rect — the usual flow is drawing a
		// band right up against the one just finished (which is still selected) — not resize the old one.
		// Obstacles keep the generous outward reach; the Select tool has no create-drag to conflict with.
		float outerReach = SelectedKind == SelKind.AlternatePattern ? 0f : RESIZE_FACE_OUTER_HIT_DISTANCE;
		RectF r = SelectionBounds( _primarySelection );
		float best = RESIZE_FACE_OUTER_HIT_DISTANCE;
		Direction hitSide = Direction.None;
		void Consider( Direction candidate, float distance )
		{
			if ( distance > best ) return;
			best = distance;
			hitSide = candidate;
		}

		Consider( Direction.Left, ResizeFaceDistance( p, r, Direction.Left, outerReach ) );
		Consider( Direction.Right, ResizeFaceDistance( p, r, Direction.Right, outerReach ) );
		Consider( Direction.Down, ResizeFaceDistance( p, r, Direction.Down, outerReach ) );
		Consider( Direction.Up, ResizeFaceDistance( p, r, Direction.Up, outerReach ) );
		if ( hitSide == Direction.None ) return false;

		kind = SelectedKind;
		index = SelectedIndex;
		side = hitSide;
		return true;
	}

	/// <summary>A mirror group is still resizable even though the multi-selection reports no single
	/// selected rect: hit-test every member's faces and resize whichever face is grabbed (the other
	/// twins follow as mirror images in <see cref="ResizeSelectedRect"/>).</summary>
	private bool TryHitMirrorGroupFace( Vector2 p, ref SelKind kind, ref int index, ref Direction side )
	{
		if ( !TryGetMirrorGroupIndices( out var members ) ) return false;
		float best = RESIZE_FACE_OUTER_HIT_DISTANCE;
		foreach ( int member in members )
		{
			RectF r = Level.Obstacles[member];
			foreach ( var candidate in Globals.GetAllDirections() )
			{
				float distance = ResizeFaceDistance( p, r, candidate, RESIZE_FACE_OUTER_HIT_DISTANCE );
				if ( distance > best ) continue;
				best = distance;
				kind = SelKind.Obstacle;
				index = member;
				side = candidate;
			}
		}
		return side != Direction.None;
	}

	/// <summary>How many rects a rect-shaped selection kind indexes into (0 for the marker kinds).</summary>
	private int RectCount( SelKind kind ) => kind switch
	{
		SelKind.Obstacle => Level.Obstacles.Count,
		SelKind.AlternatePattern => Level.AlternatePlayfieldRects.Count,
		_ => 0,
	};

	/// <summary>Distance from the point to one face's grab band, or MaxValue when it misses. The band
	/// runs <paramref name="outerReach"/> px outside the face and up to
	/// <see cref="RESIZE_FACE_INNER_HIT_DISTANCE"/> px inside it (never more than a quarter of the rect,
	/// so a thin rect keeps a draggable middle).</summary>
	private static float ResizeFaceDistance( Vector2 point, RectF rect, Direction side, float outerReach )
	{
		const float alongTolerance = 2f;
		float inwardDistance;
		float thickness;
		switch ( side )
		{
			case Direction.Left:
				if ( point.y < rect.Bottom - alongTolerance || point.y > rect.Top + alongTolerance ) return float.MaxValue;
				inwardDistance = point.x - rect.Left;
				thickness = rect.Width;
				break;
			case Direction.Right:
				if ( point.y < rect.Bottom - alongTolerance || point.y > rect.Top + alongTolerance ) return float.MaxValue;
				inwardDistance = rect.Right - point.x;
				thickness = rect.Width;
				break;
			case Direction.Down:
				if ( point.x < rect.Left - alongTolerance || point.x > rect.Right + alongTolerance ) return float.MaxValue;
				inwardDistance = point.y - rect.Bottom;
				thickness = rect.Height;
				break;
			case Direction.Up:
				if ( point.x < rect.Left - alongTolerance || point.x > rect.Right + alongTolerance ) return float.MaxValue;
				inwardDistance = rect.Top - point.y;
				thickness = rect.Height;
				break;
			default:
				return float.MaxValue;
		}

		float innerLimit = MathF.Min( RESIZE_FACE_INNER_HIT_DISTANCE, thickness * 0.25f );
		return inwardDistance >= -outerReach && inwardDistance <= innerLimit
			? MathF.Abs( inwardDistance )
			: float.MaxValue;
	}

	private (SelKind, int) HitAny( Vector2 p )
	{
		int sp = HitSpawn( p );
		if ( sp >= 0 ) return (SelKind.Spawn, sp);

		int player = HitPlayer( p );
		if ( player >= 0 ) return (SelKind.Player, player);

		int coin = HitCoin( p );
		if ( coin >= 0 ) return (SelKind.Coin, coin);

		for ( int i = Level.Obstacles.Count - 1; i >= 0; i-- )
			if ( Level.Obstacles[i].Touches( p ) ) return (SelKind.Obstacle, i);

		return (SelKind.None, -1);
	}

	private int HitAlternateRect( Vector2 point )
	{
		for ( int i = Level.AlternatePlayfieldRects.Count - 1; i >= 0; i-- )
			if ( Level.AlternatePlayfieldRects[i].Touches( point ) ) return i;
		return -1;
	}

	private static Vector2 RectCenter( RectF r ) => new( ( r.Left + r.Right ) / 2f, ( r.Bottom + r.Top ) / 2f );

	private static RectF Normalize( Vector2 a, Vector2 b )
		=> new( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ), MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ) );

	private static RectF RecenteredRect( RectF r, Vector2 center )
	{
		float hw = r.Width / 2f, hh = r.Height / 2f;
		return new RectF( center.x - hw, center.y - hh, center.x + hw, center.y + hh );
	}

	private static RectF Translate( RectF r, float dx, float dy ) => new( r.Left + dx, r.Bottom + dy, r.Right + dx, r.Top + dy );
	private static float ResizeEdgeCoord( RectF r, Direction side ) => side switch
	{
		Direction.Left => r.Left,
		Direction.Right => r.Right,
		Direction.Down => r.Bottom,
		Direction.Up => r.Top,
		_ => 0f,
	};
	private static RectF Union( RectF a, RectF b )
		=> new( MathF.Min( a.Left, b.Left ), MathF.Min( a.Bottom, b.Bottom ), MathF.Max( a.Right, b.Right ), MathF.Max( a.Top, b.Top ) );

	private static bool RectsTouch( RectF a, RectF b )
		=> a.Left <= b.Right && a.Right >= b.Left && a.Bottom <= b.Top && a.Top >= b.Bottom;

	// ── mirroring: optional symmetric editing across the arena centre lines ──────────────────────────
	// Nothing persistent links mirror twins — partners are re-found at each operation as the NEAREST
	// same-kind element within MIRROR_PAIR_DIST of the mirror image (an exact twin always wins), so
	// hand-built symmetry from before these tools that's a pixel or two off still pairs. Breaking
	// symmetry further (or toggling the modes off) simply dissolves the pairing.

	private const float MIRROR_MATCH_EPS = 0.25f;
	// Pairing tolerance for FINDING a twin (per edge / per axis). Creation dedupe stays strict
	// (MIRROR_MATCH_EPS) — a near-twin shouldn't suppress adding an exact one.
	private const float MIRROR_PAIR_DIST = 3f;

	private static RectF MirrorRectX( RectF r ) => new( Arena.WIDTH - r.Right, r.Bottom, Arena.WIDTH - r.Left, r.Top );
	private static RectF MirrorRectY( RectF r ) => new( r.Left, Arena.HEIGHT - r.Top, r.Right, Arena.HEIGHT - r.Bottom );

	private static RectF MirrorRect( RectF r, bool flipX, bool flipY )
	{
		if ( flipX ) r = MirrorRectX( r );
		if ( flipY ) r = MirrorRectY( r );
		return r;
	}

	/// <summary>The mirror images the active toggles produce: X, Y, and (with both on) the 180° combo.</summary>
	private IEnumerable<(bool FlipX, bool FlipY)> ActiveMirrorFlips()
	{
		if ( MirrorX ) yield return (true, false);
		if ( MirrorY ) yield return (false, true);
		if ( MirrorX && MirrorY ) yield return (true, true);
	}

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

	// Chebyshev-style distances used for loose twin pairing: the worst edge / axis offset.
	private static float RectMirrorDistance( RectF a, RectF b )
		=> MathF.Max( MathF.Max( MathF.Abs( a.Left - b.Left ), MathF.Abs( a.Right - b.Right ) ),
			MathF.Max( MathF.Abs( a.Bottom - b.Bottom ), MathF.Abs( a.Top - b.Top ) ) );

	private static float PointMirrorDistance( Vector2 a, Vector2 b )
		=> MathF.Max( MathF.Abs( a.x - b.x ), MathF.Abs( a.y - b.y ) );

	/// <summary>The obstacle best matching the (flipX,flipY) mirror image of <paramref name="rect"/>
	/// within <see cref="MIRROR_PAIR_DIST"/>, or -1. The closest match wins (exact beats loose); a
	/// rect straddling its centre line is its own twin and pairs with nothing.</summary>
	private int FindMirrorObstacle( RectF rect, bool flipX, bool flipY, int excluded, HashSet<int> claimed = null )
	{
		RectF mirrored = MirrorRect( rect, flipX, flipY );
		if ( RectsAlmostEqual( mirrored, rect ) ) return -1;   // sits on the centre line — its own twin
		int best = -1;
		float bestDistance = MIRROR_PAIR_DIST + 0.001f;
		for ( int i = Level.Obstacles.Count - 1; i >= 0; i-- )
		{
			if ( i == excluded || claimed?.Contains( i ) == true ) continue;
			float distance = RectMirrorDistance( Level.Obstacles[i], mirrored );
			if ( distance < bestDistance )
			{
				bestDistance = distance;
				best = i;
			}
		}
		return best;
	}

	/// <summary>Obstacles pairing as mirror twins of this obstacle under the active flips (nearest
	/// match within tolerance per flip; each obstacle claimed by at most one flip).</summary>
	private IEnumerable<(int Index, bool FlipX, bool FlipY)> MirrorPartnerObstacles( int index )
	{
		if ( !MirrorActive || index < 0 || index >= Level.Obstacles.Count ) yield break;
		RectF rect = Level.Obstacles[index];
		var claimed = new HashSet<int>();
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			int partner = FindMirrorObstacle( rect, flipX, flipY, index, claimed );
			if ( partner < 0 ) continue;
			claimed.Add( partner );
			yield return (partner, flipX, flipY);
		}
	}

	/// <summary>Add mirror copies of a just-created obstacle and pull them into the selection with it.
	/// A copy that would land exactly on an existing obstacle (the source included) is skipped.</summary>
	private void AddMirroredObstacles( int sourceIndex )
	{
		if ( !MirrorActive ) return;
		RectF source = Level.Obstacles[sourceIndex];
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			RectF mirrored = MirrorRect( source, flipX, flipY );
			bool exists = false;
			for ( int i = 0; i < Level.Obstacles.Count && !exists; i++ )
				exists = RectsAlmostEqual( Level.Obstacles[i], mirrored );
			if ( exists ) continue;
			Level.Obstacles.Add( mirrored );
			AddSelection( new Selection( SelKind.Obstacle, Level.Obstacles.Count - 1 ) );
		}
	}

	private static Vector2 MirrorPoint( Vector2 p, bool flipX, bool flipY )
		=> new( flipX ? Arena.WIDTH - p.x : p.x, flipY ? Arena.HEIGHT - p.y : p.y );

	private static bool PointsAlmostEqual( Vector2 a, Vector2 b )
		=> MathF.Abs( a.x - b.x ) <= MIRROR_MATCH_EPS && MathF.Abs( a.y - b.y ) <= MIRROR_MATCH_EPS;

	/// <summary>A face's direction under a mirror flip (X swaps Left↔Right, Y swaps Up↔Down).</summary>
	private static Direction MirrorDirection( Direction dir, bool flipX, bool flipY )
	{
		if ( flipX && dir == Direction.Left ) return Direction.Right;
		if ( flipX && dir == Direction.Right ) return Direction.Left;
		if ( flipY && dir == Direction.Up ) return Direction.Down;
		if ( flipY && dir == Direction.Down ) return Direction.Up;
		return dir;
	}

	/// <summary>The centre-position list a point-marker selection kind indexes (null for rect kinds).</summary>
	private List<Vector2> MarkerPositions( SelKind kind ) => kind switch
	{
		SelKind.Spawn => Level.SpawnPositions,
		SelKind.Player => Level.PlayerSpawns,
		SelKind.Coin => Level.Coins,
		_ => null,
	};

	/// <summary>Same-kind elements sitting exactly at one of the active mirror images of this one —
	/// obstacles by rect equality, spawn/player/coin markers by centre equality. Empty for other kinds.</summary>
	private IEnumerable<(Selection Selection, bool FlipX, bool FlipY)> MirrorPartners( Selection source )
	{
		if ( !MirrorActive ) yield break;
		if ( source.Kind == SelKind.Obstacle )
		{
			foreach ( var (index, flipX, flipY) in MirrorPartnerObstacles( source.Index ) )
				yield return (new Selection( SelKind.Obstacle, index ), flipX, flipY);
			yield break;
		}

		var positions = MarkerPositions( source.Kind );
		if ( positions is null || source.Index < 0 || source.Index >= positions.Count ) yield break;
		Vector2 p = positions[source.Index];
		var claimed = new HashSet<int>();
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			Vector2 mirrored = MirrorPoint( p, flipX, flipY );
			if ( PointsAlmostEqual( mirrored, p ) ) continue;   // sits on the centre line — its own twin
			int best = -1;
			float bestDistance = MIRROR_PAIR_DIST + 0.001f;
			for ( int i = positions.Count - 1; i >= 0; i-- )
			{
				if ( i == source.Index || claimed.Contains( i ) ) continue;
				float distance = PointMirrorDistance( positions[i], mirrored );
				if ( distance < bestDistance )
				{
					bestDistance = distance;
					best = i;
				}
			}
			if ( best < 0 ) continue;
			claimed.Add( best );
			yield return (new Selection( source.Kind, best ), flipX, flipY);
		}
	}

	/// <summary>Add mirror copies of a just-placed marker (block spawn / player spawn / coin). A copy
	/// landing exactly on an existing marker of the same kind is skipped. The placement's BeginMove
	/// runs right after and pulls the copies into the selection as regular mirror partners.</summary>
	private void AddMirroredMarkers( SelKind kind, int sourceIndex )
	{
		if ( !MirrorActive ) return;
		var positions = MarkerPositions( kind );
		if ( positions is null || sourceIndex < 0 || sourceIndex >= positions.Count ) return;
		Vector2 source = positions[sourceIndex];
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			Vector2 mirrored = MirrorPoint( source, flipX, flipY );
			bool exists = false;
			for ( int i = 0; i < positions.Count && !exists; i++ )
				exists = PointsAlmostEqual( positions[i], mirrored );
			if ( exists ) continue;
			// Spawns carry parallel pin/timer lists — always add through AddSpawn to keep them aligned.
			if ( kind == SelKind.Spawn ) Level.AddSpawn( mirrored );
			else positions.Add( mirrored );
		}
	}

	/// <summary>Rebuild the anchor-relative mirror relations that flip move deltas: a selected element
	/// sitting exactly at a mirror image of the anchor follows with the axis-flipped delta.</summary>
	private void RefreshMoveMirrorRelations( Selection anchor )
	{
		_moveMirrorRelations.Clear();
		foreach ( var (selection, flipX, flipY) in MirrorPartners( anchor ) )
			if ( _selection.Contains( selection ) ) _moveMirrorRelations[selection] = (flipX, flipY);
	}

	private Vector2 MirroredDelta( Selection selected, Vector2 delta )
		=> _moveMirrorRelations.TryGetValue( selected, out var relation )
			? new Vector2( relation.FlipX ? -delta.x : delta.x, relation.FlipY ? -delta.y : delta.y )
			: delta;

	/// <summary>Console command: dump the mirror toggles plus every obstacle's and marker's
	/// mirror-partner table (and which expected twins are MISSING and where) to the log — for
	/// diagnosing why a click didn't co-select an expected twin. Editor-only.</summary>
	[ConCmd( "mirror_debug" )]
	public static void MirrorDebugCmd()
	{
		if ( !Game.IsEditor ) return;
		if ( GameManager.Instance?.Stage is not LevelEditorStage stage )
		{
			Log.Info( "mirror_debug: level editor not open" );
			return;
		}
		stage.DumpMirrorDebug();
	}

	private void DumpMirrorDebug()
	{
		Log.Info( $"mirror_debug: MirrorX={MirrorX} MirrorY={MirrorY} selection={_selection.Count}" );

		string FlipTag( bool fx, bool fy ) => $"{(fx ? "X" : "")}{(fy ? "Y" : "")}";

		for ( int i = 0; i < Level.Obstacles.Count; i++ )
		{
			var r = Level.Obstacles[i];
			var partners = MirrorPartnerObstacles( i ).Select( p => $"#{p.Index}[{FlipTag( p.FlipX, p.FlipY )}]" ).ToArray();
			string missing = "";
			foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
			{
				RectF m = MirrorRect( r, flipX, flipY );
				if ( RectsAlmostEqual( m, r ) ) continue;
				bool found = false;
				for ( int j = 0; j < Level.Obstacles.Count && !found; j++ )
					found = j != i && RectsAlmostEqual( Level.Obstacles[j], m );
				if ( !found ) missing += $" MISSING {FlipTag( flipX, flipY )}-twin at ({m.Left},{m.Bottom},{m.Right},{m.Top})";
			}
			Log.Info( $"  obstacle #{i} ({r.Left},{r.Bottom},{r.Right},{r.Top}) -> {(partners.Length > 0 ? string.Join( " ", partners ) : "no partners")}{missing}" );
		}

		void DumpMarkers( SelKind kind )
		{
			var positions = MarkerPositions( kind );
			for ( int i = 0; i < positions.Count; i++ )
			{
				var partners = MirrorPartners( new Selection( kind, i ) )
					.Select( p => $"#{p.Selection.Index}[{FlipTag( p.FlipX, p.FlipY )}]" ).ToArray();
				Log.Info( $"  {kind} #{i} ({positions[i].x},{positions[i].y}) -> {(partners.Length > 0 ? string.Join( " ", partners ) : "no partners")}" );
			}
		}
		DumpMarkers( SelKind.Spawn );
		DumpMarkers( SelKind.Player );
		DumpMarkers( SelKind.Coin );
	}

	/// <summary>True when the multi-selection is exactly one obstacle plus its mirror twins — such a
	/// group keeps its resize bands (any member's face can be grabbed and the twins follow).</summary>
	private bool TryGetMirrorGroupIndices( out List<int> members )
	{
		members = null;
		if ( !MirrorActive || _selection.Count < 2 ) return false;
		foreach ( var s in _selection )
			if ( s.Kind != SelKind.Obstacle || s.Index < 0 || s.Index >= Level.Obstacles.Count ) return false;
		var partners = MirrorPartnerObstacles( _primarySelection.Index ).Select( partner => partner.Index ).ToHashSet();
		foreach ( var s in _selection )
			if ( s.Index != _primarySelection.Index && !partners.Contains( s.Index ) ) return false;
		members = _selection.Select( s => s.Index ).ToList();
		return true;
	}

	// ── edge snapping (obstacles): snap edges to nearby obstacle edges + arena walls so flush
	// composites land within FLUSH_EPS, which is what the game needs to merge their walls ─────────────
	private const float EDGE_SNAP = 4f;

	// Candidate edge coordinates to snap to: the arena inner walls (always) + only those OTHER
	// obstacles that actually overlap on the PERPENDICULAR axis, so a distant obstacle that merely
	// shares an edge coordinate (but is nowhere near) can't pull the drag toward it.
	private static bool SpansOverlap( float a0, float a1, float b0, float b1 )
		=> a0 <= b1 + EDGE_SNAP && b0 <= a1 + EDGE_SNAP;

	private System.Collections.Generic.List<float> EdgeXs( RectF r, int ignore, int ignoreSpawn = -1, bool includeSpawns = false,
		int ignoreAlternate = -1, bool includeAlternate = false )
	{
		// Inner wall faces FIRST so a tie prefers flush-in-playable-space over the outer arena boundary.
		var xs = new System.Collections.Generic.List<float> { Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE, 0f, Arena.WIDTH };
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
		{
			if ( i == ignore ) continue;
			var o = Level.Obstacles[i];
			if ( !SpansOverlap( o.Bottom, o.Top, r.Bottom, r.Top ) ) continue;
			xs.Add( o.Left );
			xs.Add( o.Right );
		}
		if ( includeAlternate )
		{
			for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
			{
				if ( i == ignoreAlternate ) continue;
				var alternate = Level.AlternatePlayfieldRects[i];
				if ( !SpansOverlap( alternate.Bottom, alternate.Top, r.Bottom, r.Top ) ) continue;
				xs.Add( alternate.Left );
				xs.Add( alternate.Right );
			}
		}
		// Block spawns snap flush to each other (and the player to them): add other spawn boxes' X edges.
		if ( includeSpawns )
		{
			for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
			{
				if ( i == ignoreSpawn ) continue;
				var c = Level.SpawnPositions[i];
				if ( !SpansOverlap( c.y - SPAWN_HALF, c.y + SPAWN_HALF, r.Bottom, r.Top ) ) continue;
				xs.Add( c.x - SPAWN_HALF );
				xs.Add( c.x + SPAWN_HALF );
			}
		}
		return xs;
	}

	private System.Collections.Generic.List<float> EdgeYs( RectF r, int ignore, int ignoreSpawn = -1, bool includeSpawns = false,
		int ignoreAlternate = -1, bool includeAlternate = false )
	{
		// Inner wall faces FIRST so a tie prefers flush-in-playable-space over the outer arena boundary.
		var ys = new System.Collections.Generic.List<float> { Arena.WALL_SIZE, Arena.HEIGHT - Arena.WALL_SIZE, 0f, Arena.HEIGHT };
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
		{
			if ( i == ignore ) continue;
			var o = Level.Obstacles[i];
			if ( !SpansOverlap( o.Left, o.Right, r.Left, r.Right ) ) continue;
			ys.Add( o.Bottom );
			ys.Add( o.Top );
		}
		if ( includeAlternate )
		{
			for ( int i = 0; i < Level.AlternatePlayfieldRects.Count; i++ )
			{
				if ( i == ignoreAlternate ) continue;
				var alternate = Level.AlternatePlayfieldRects[i];
				if ( !SpansOverlap( alternate.Left, alternate.Right, r.Left, r.Right ) ) continue;
				ys.Add( alternate.Bottom );
				ys.Add( alternate.Top );
			}
		}
		// Block spawns snap flush to each other (and the player to them): add other spawn boxes' Y edges.
		if ( includeSpawns )
		{
			for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
			{
				if ( i == ignoreSpawn ) continue;
				var c = Level.SpawnPositions[i];
				if ( !SpansOverlap( c.x - SPAWN_HALF, c.x + SPAWN_HALF, r.Left, r.Right ) ) continue;
				ys.Add( c.y - SPAWN_HALF );
				ys.Add( c.y + SPAWN_HALF );
			}
		}
		return ys;
	}

	// Try to snap a coordinate flush to the NEAREST candidate edge within EDGE_SNAP. Returns false when
	// none is close enough, so the caller can fall back to grid / pixel.
	private static bool TrySnapCoord( float v, System.Collections.Generic.List<float> cands, out float snapped )
	{
		snapped = v; float bestD = EDGE_SNAP; bool found = false;
		foreach ( var c in cands )
		{
			float d = MathF.Abs( c - v );
			if ( d < bestD ) { bestD = d; snapped = c; found = true; }
		}
		return found;
	}

	// Per-edge snap PRIORITY: flush to a wall/obstacle edge FIRST, then grid (if shown), then pixel.
	private float SnapEdgeCoord( float v, System.Collections.Generic.List<float> cands, bool xAxis )
	{
		if ( !SnapActive ) return MathF.Round( v );
		if ( TrySnapCoord( v, cands, out float e ) ) return e;
		return GridSnapActive ? GridSnapAxis( v, xAxis ) : MathF.Round( v );
	}

	private static RectF SnapRectPixel( RectF r )
		=> new RectF( MathF.Round( r.Left ), MathF.Round( r.Bottom ), MathF.Round( r.Right ), MathF.Round( r.Top ) );

	private const float PIXEL_NORMALIZE_EPSILON = 0.001f;

	private static float NormalizeNearPixel( float value )
	{
		float rounded = MathF.Round( value );
		return MathF.Abs( value - rounded ) <= PIXEL_NORMALIZE_EPSILON ? rounded : value;
	}

	private static RectF NormalizeRectEdges( RectF r )
		=> new( NormalizeNearPixel( r.Left ), NormalizeNearPixel( r.Bottom ),
			NormalizeNearPixel( r.Right ), NormalizeNearPixel( r.Top ) );

	private void NormalizeDraggedRectEdges()
	{
		bool normalizedAlternate = false;
		foreach ( var selected in _selection )
		{
			if ( selected.Kind == SelKind.Obstacle && selected.Index >= 0 && selected.Index < Level.Obstacles.Count )
				Level.Obstacles[selected.Index] = NormalizeRectEdges( Level.Obstacles[selected.Index] );
			if ( selected.Kind == SelKind.AlternatePattern && selected.Index >= 0 && selected.Index < Level.AlternatePlayfieldRects.Count )
			{
				Level.AlternatePlayfieldRects[selected.Index] = NormalizeRectEdges( Level.AlternatePlayfieldRects[selected.Index] );
				normalizedAlternate = true;
			}
		}
		// The drag rendered the pre-normalized rect, so re-point the renderers at the committed edges.
		if ( normalizedAlternate ) RefreshAlternatePlayfield();
	}

	// Snap each edge independently (used on CREATE — lets the new rect resize to meet a neighbour exactly).
	// Snap disabled = pixel only (no flush / no grid). Otherwise flush-to-edge FIRST, then grid, then pixel.
	private RectF SnapRectEdges( RectF r, int ignore, int ignoreAlternate = -1, bool includeAlternate = false )
	{
		if ( !SnapActive ) return SnapRectPixel( r );
		var xs = EdgeXs( r, ignore, ignoreAlternate: ignoreAlternate, includeAlternate: includeAlternate );
		var ys = EdgeYs( r, ignore, ignoreAlternate: ignoreAlternate, includeAlternate: includeAlternate );
		return new RectF( SnapEdgeCoord( r.Left, xs, true ), SnapEdgeCoord( r.Bottom, ys, false ),
			SnapEdgeCoord( r.Right, xs, true ), SnapEdgeCoord( r.Top, ys, false ) );
	}

	// Snap by TRANSLATION (used on MOVE — preserves size). When enabled, flush-to-edge
	// FIRST (either edge lands on a candidate), else grid, else pixel — per axis. When includeSpawns is
	// set, other block-spawn boxes are flush candidates too (ignoreSpawn excludes the one being moved).
	private RectF SnapRectTranslate( RectF r, int ignore, bool includeSpawns = false, int ignoreSpawn = -1,
		int ignoreAlternate = -1, bool includeAlternate = false )
	{
		if ( !SnapActive ) return SnapRectPixel( r );

		if ( !TryBestDelta( r.Left, r.Right,
			EdgeXs( r, ignore, ignoreSpawn, includeSpawns, ignoreAlternate, includeAlternate ), out float dx ) )
			dx = ( GridSnapActive ? GridSnapAxis( r.Left, true ) : MathF.Round( r.Left ) ) - r.Left;
		if ( !TryBestDelta( r.Bottom, r.Top,
			EdgeYs( r, ignore, ignoreSpawn, includeSpawns, ignoreAlternate, includeAlternate ), out float dy ) )
			dy = ( GridSnapActive ? GridSnapAxis( r.Bottom, false ) : MathF.Round( r.Bottom ) ) - r.Bottom;

		return new RectF( r.Left + dx, r.Bottom + dy, r.Right + dx, r.Top + dy );
	}

	private void ResizeSelectedRect( Vector2 logical )
	{
		bool alternate = _moveKind == SelKind.AlternatePattern;
		int count = alternate ? Level.AlternatePlayfieldRects.Count : Level.Obstacles.Count;
		if ( _moveIndex < 0 || _moveIndex >= count ) return;

		RectF origin = _resizeOriginRect;
		float minSize = alternate ? 1f : MIN_RECT;
		float raw = (_resizeSide is Direction.Left or Direction.Right ? logical.x : logical.y) + _resizeGrabOffset;
		RectF candidate = _resizeSide switch
		{
			Direction.Left => new RectF( Math.Clamp( raw, 0f, origin.Right - minSize ), origin.Bottom, origin.Right, origin.Top ),
			Direction.Right => new RectF( origin.Left, origin.Bottom, Math.Clamp( raw, origin.Left + minSize, Arena.WIDTH ), origin.Top ),
			Direction.Down => new RectF( origin.Left, Math.Clamp( raw, 0f, origin.Top - minSize ), origin.Right, origin.Top ),
			Direction.Up => new RectF( origin.Left, origin.Bottom, origin.Right, Math.Clamp( raw, origin.Bottom + minSize, Arena.HEIGHT ) ),
			_ => origin,
		};

		float snapped = _resizeSide is Direction.Left or Direction.Right
			? (!SnapActive ? MathF.Round( raw ) : SnapEdgeCoord( raw,
				EdgeXs( candidate, alternate ? -1 : _moveIndex, ignoreAlternate: alternate ? _moveIndex : -1, includeAlternate: alternate ), true ))
			: (!SnapActive ? MathF.Round( raw ) : SnapEdgeCoord( raw,
				EdgeYs( candidate, alternate ? -1 : _moveIndex, ignoreAlternate: alternate ? _moveIndex : -1, includeAlternate: alternate ), false ));

		RectF resized = _resizeSide switch
		{
			Direction.Left => new RectF( Math.Clamp( snapped, 0f, origin.Right - minSize ), origin.Bottom, origin.Right, origin.Top ),
			Direction.Right => new RectF( origin.Left, origin.Bottom, Math.Clamp( snapped, origin.Left + minSize, Arena.WIDTH ), origin.Top ),
			Direction.Down => new RectF( origin.Left, Math.Clamp( snapped, 0f, origin.Top - minSize ), origin.Right, origin.Top ),
			Direction.Up => new RectF( origin.Left, origin.Bottom, origin.Right, Math.Clamp( snapped, origin.Bottom + minSize, Arena.HEIGHT ) ),
			_ => origin,
		};
		if ( alternate )
		{
			Level.AlternatePlayfieldRects[_moveIndex] = resized;
			RefreshAlternatePlayfield();
		}
		else
		{
			Level.Obstacles[_moveIndex] = resized;
			// Mirror twins grabbed together follow the resize as exact mirror images.
			foreach ( var (partner, flipX, flipY) in _resizeMirrorPartners )
				if ( partner != _moveIndex && partner >= 0 && partner < Level.Obstacles.Count )
					Level.Obstacles[partner] = MirrorRect( resized, flipX, flipY );
		}
	}

	// The obstacle being dragged out, snapped from the RAW drag corners (flush→grid→pixel per edge).
	// Used for the live preview, the dimension readout, and the final placement so they all agree.
	private RectF CreateRect() => _creatingAlternateRect
		? SnapRectEdges( Normalize( _dragStart, _dragCurrent ), -1, includeAlternate: true )
		: SnapRectEdges( Normalize( _dragStart, _dragCurrent ), -1 );

	// Snap a marker CENTRE by treating it as a box of the given half-extents and edge-snapping it like
	// an obstacle (flush→grid→pixel, size preserved), then taking the centre back — so the marker's EDGES
	// align to walls/obstacles/other spawns rather than a grid line running through its centre.
	// ignoreSpawn excludes the spawn being moved from the flush candidates (-1 = none).
	private Vector2 SnapBoxCenter( Vector2 center, float halfW, float halfH, int ignoreSpawn )
	{
		var box = new RectF( center.x - halfW, center.y - halfH, center.x + halfW, center.y + halfH );
		box = SnapRectTranslate( box, -1, includeSpawns: true, ignoreSpawn: ignoreSpawn );
		return RectCenter( box );
	}

	// Best single translation that lands either edge (lo/hi) flush on a candidate within EDGE_SNAP.
	// False when none is close enough (so the caller can fall back to grid / pixel).
	private static bool TryBestDelta( float lo, float hi, System.Collections.Generic.List<float> cands, out float delta )
	{
		delta = 0f; float bestD = EDGE_SNAP; bool found = false;
		foreach ( var c in cands )
		{
			float d1 = c - lo;
			if ( MathF.Abs( d1 ) < bestD ) { bestD = MathF.Abs( d1 ); delta = d1; found = true; }
			float d2 = c - hi;
			if ( MathF.Abs( d2 ) < bestD ) { bestD = MathF.Abs( d2 ); delta = d2; found = true; }
		}
		return found;
	}

	private static RectF ClampRect( RectF r )
	{
		float l = Math.Clamp( r.Left, 0f, Arena.WIDTH );
		float b = Math.Clamp( r.Bottom, 0f, Arena.HEIGHT );
		float rt = Math.Clamp( r.Right, 0f, Arena.WIDTH );
		float t = Math.Clamp( r.Top, 0f, Arena.HEIGHT );
		return new RectF( MathF.Min( l, rt ), MathF.Min( b, t ), MathF.Max( l, rt ), MathF.Max( b, t ) );
	}

	private static Vector2 ClampInside( Vector2 v )
		=> new( Math.Clamp( v.x, Arena.WALL_SIZE, Arena.WIDTH - Arena.WALL_SIZE ),
				Math.Clamp( v.y, Arena.WALL_SIZE, Arena.HEIGHT - Arena.WALL_SIZE ) );

	private static Vector2 ClampCoin( Vector2 v )
		=> new( Math.Clamp( v.x, Arena.WALL_SIZE + COIN_HALF_W, Arena.WIDTH - Arena.WALL_SIZE - COIN_HALF_W ),
				Math.Clamp( v.y, Arena.WALL_SIZE + COIN_HALF_H, Arena.HEIGHT - Arena.WALL_SIZE - COIN_HALF_H ) );

	private static Vector2 ClampSpawn( Vector2 v )
		=> new( Math.Clamp( v.x, Arena.WALL_SIZE + 20f, Arena.WIDTH - Arena.WALL_SIZE - 20f ),
				Math.Clamp( v.y, Arena.WALL_SIZE + 20f, Arena.HEIGHT - Arena.WALL_SIZE - 20f ) );

	// ── spike toggling: pick the nearest spikeable face (arena wall or obstacle) ─────────────────────
	private bool ToggleNearestSpike( Vector2 p )
	{
		const float MAX = 12f;
		float best = MAX;
		Action apply = null;

		void Consider( float dist, Action act )
		{
			if ( dist < best ) { best = dist; apply = act; }
		}

		// Arena walls — inner edges.
		Consider( DistToVSeg( p, Arena.WALL_SIZE, 0, Arena.HEIGHT ), () => ToggleWallMirrored( Direction.Left ) );
		Consider( DistToVSeg( p, Arena.WIDTH - Arena.WALL_SIZE, 0, Arena.HEIGHT ), () => ToggleWallMirrored( Direction.Right ) );
		Consider( DistToHSeg( p, Arena.WALL_SIZE, 0, Arena.WIDTH ), () => ToggleWallMirrored( Direction.Down ) );
		Consider( DistToHSeg( p, Arena.HEIGHT - Arena.WALL_SIZE, 0, Arena.WIDTH ), () => ToggleWallMirrored( Direction.Up ) );

		// Obstacle faces. Fences can never be spiked, so the spike tool skips them entirely.
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
		{
			if ( Level.ObstacleIsFence( i ) ) continue;
			var r = Level.Obstacles[i];
			int oi = i;
			Consider( DistToVSeg( p, r.Left, r.Bottom, r.Top ), () => ToggleObstacleSpikeMirrored( oi, Direction.Left ) );
			Consider( DistToVSeg( p, r.Right, r.Bottom, r.Top ), () => ToggleObstacleSpikeMirrored( oi, Direction.Right ) );
			Consider( DistToHSeg( p, r.Bottom, r.Left, r.Right ), () => ToggleObstacleSpikeMirrored( oi, Direction.Down ) );
			Consider( DistToHSeg( p, r.Top, r.Left, r.Right ), () => ToggleObstacleSpikeMirrored( oi, Direction.Up ) );
		}

		if ( apply is null ) return false;
		apply();
		return true;
	}

	private void ToggleWall( Direction dir )
	{
		if ( Level.SpikedWalls.Contains( dir ) ) Level.SpikedWalls.Remove( dir );
		else Level.SpikedWalls.Add( dir );
	}

	/// <summary>Toggle a wall's spikes plus the walls the active mirrors map it to, each at most once
	/// (with both mirrors on, X and XY map a side wall to the same opposite wall — toggling it twice
	/// would cancel out).</summary>
	private void ToggleWallMirrored( Direction dir )
	{
		ToggleWall( dir );
		var done = new HashSet<Direction> { dir };
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			var mirrored = MirrorDirection( dir, flipX, flipY );
			if ( done.Add( mirrored ) ) ToggleWall( mirrored );
		}
	}

	/// <summary>Toggle an obstacle face's spikes plus the mirrored faces the active mirrors map it to:
	/// the matching face of each mirror-twin obstacle, or the opposite face of the SAME obstacle when
	/// it straddles the centre line (its mirror image is itself). Each face toggles at most once.</summary>
	private void ToggleObstacleSpikeMirrored( int index, Direction dir )
	{
		Level.ToggleObstacleSpike( index, dir );
		if ( !MirrorActive ) return;
		RectF rect = Level.Obstacles[index];
		var done = new HashSet<(int, Direction)> { (index, dir) };
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			Direction mirroredDir = MirrorDirection( dir, flipX, flipY );
			// A centre-straddling obstacle is its own twin: spike the mirrored face of ITSELF.
			int target = RectsAlmostEqual( MirrorRect( rect, flipX, flipY ), rect )
				? index
				: FindMirrorObstacle( rect, flipX, flipY, index );
			if ( target < 0 || Level.ObstacleIsFence( target ) ) continue;
			if ( done.Add( (target, mirroredDir) ) ) Level.ToggleObstacleSpike( target, mirroredDir );
		}
	}

	// Distance from point to a vertical segment at x=edge spanning [y0,y1] (large if the point projects
	// outside the span), and the horizontal analogue.
	private static float DistToVSeg( Vector2 p, float edge, float y0, float y1 )
		=> p.y < y0 - 2f || p.y > y1 + 2f ? float.MaxValue : MathF.Abs( p.x - edge );

	private static float DistToHSeg( Vector2 p, float edge, float x0, float x1 )
		=> p.x < x0 - 2f || p.x > x1 + 2f ? float.MaxValue : MathF.Abs( p.y - edge );

	// ── palette refresh ─────────────────────────────────────────────────────────────────────────────

	/// <summary>Re-apply the OOB colour to the camera clear and edge-blocker masks live.</summary>
	public void RefreshPalette()
	{
		RefreshClearColor();
	}

	/// <summary>Switch the live editor preview to the current level's song.</summary>
	public void RefreshMusic() => Audio.PlayMusic( Level.MusicVolume, Level.Music );

	/// <summary>Rebuild the checkerboard field + drifting background blocks from the current environment
	/// settings. Called live by every palette, pattern, size, density and drift editor control.</summary>
	public void RefreshEnvironmentColors() => RefreshEnvironment();

	// ── import / export ─────────────────────────────────────────────────────────────────────────────

	public void LoadFromLevel( string id )
	{
		var def = Levels.Get( id );
		if ( def is null ) { LastError = $"no level '{id}'"; return; }
		Level = EditorLevel.FromLevelDef( def );
		RefreshMusic();
		LevelRevision++;
		RememberOpenedLevel( def.Id );
		Select( SelKind.None, -1 );
		LastError = "";
		RefreshEnvironment();
		Rebuild();
		// Opening another level is a document switch — undo history never crosses documents.
		UndoHistory.Reset( Level.ToJsonString( fullAuthoringState: true ), Level.ProjectSourceId );
	}

	private static void RememberOpenedLevel( string id )
	{
		if ( EditorPrefs.Current.LastLevelId == id ) return;
		EditorPrefs.Current.LastLevelId = id;
		EditorPrefs.Save();
	}

	public bool ImportText( string text )
	{
		var e = EditorLevel.FromJsonString( text, out var err );
		if ( e is null ) { LastError = string.IsNullOrEmpty( err ) ? "parse failed" : err; return false; }
		CheckpointOutgoingDocument();
		Level = e;
		// A paste starts a NEW document, so it never inherits a file claim: LevelDef-shaped text (a
		// replay's embedded level) parses through FromLevelDef, which sets ProjectSourceId from the
		// pasted id — leaving it would let Save overwrite that project file past the exists guard.
		Level.ProjectSourceId = null;
		RememberOpenedLevel( "" );
		RefreshMusic();
		LevelRevision++;
		Select( SelKind.None, -1 );
		LastError = "";
		RefreshEnvironment();
		Rebuild();
		CheckpointUndo( newDocument: true );   // paste-import replaces the level in place — undoable
		return true;
	}

	public string ExportJson() => Level.ToJsonString();
	public void CopyJson() => Sandbox.UI.Clipboard.SetText( ExportJson() );

	/// <summary>Save the current LAYOUT as a daily-challenge obstacle template JSON
	/// (Assets/daily-templates/{name}.json). Writing the project folder needs the editor
	/// assembly, so this dispatches <c>daily_template_save</c> (same pattern as <see cref="SaveToProject"/>).
	/// Spiked walls/faces are spike CANDIDATES rolled per-side each day, unless the
	/// AuthoredSpikes toggle makes them spiked exactly as authored on every day.</summary>
	public void SaveDailyTemplate()
	{
		var id = DailyTemplates.SanitizeId( Level.DailyTemplateName );
		if ( string.IsNullOrEmpty( id ) ) { LastError = "template needs a name"; return; }

		LastError = "";
		Sandbox.ConsoleSystem.Run(
			$"daily_template_save {id} {(Level.DailyTemplateMirrorX ? "true" : "false")} {Math.Max( 0f, Level.DailyTemplateWeight ).ToString( System.Globalization.CultureInfo.InvariantCulture )} {(Level.DailyTemplateAuthoredSpikes ? "true" : "false")}" );
	}

	/// <summary>Replace the current LAYOUT with a saved daily template's (obstacles, spawns, pins,
	/// player, spikes, vision, alt rects) — blocks and cosmetics are kept, so a template can seed a
	/// map level too, but the level id/name are CLEARED so an absent-minded Save/Delete can't touch
	/// the previously open level. Syncs the template name/mirror/weight authoring fields so a
	/// save-after-tweak round-trips them.</summary>
	public void LoadDailyTemplate() => LoadDailyTemplate( DailyTemplates.SanitizeId( Level.DailyTemplateName ) );

	/// <summary>Same, for an explicit template id — the level browser's Templates tab loads its pick
	/// through here. Filling the authoring fields below also populates the inspector's template name
	/// box, so the picked template can be re-saved with one click.</summary>
	public void LoadDailyTemplate( string id )
	{
		var template = DailyTemplates.Find( id );
		if ( template is null )
		{
			LastError = string.IsNullOrEmpty( id ) ? "template needs a name" : $"no template '{id}'";
			return;
		}

		CheckpointOutgoingDocument();
		DailyTemplates.ApplyToEditor( template, Level );
		// Loading a template starts a new unnamed document: blank id/name (and no source-file claim)
		// grey out the project Save/Delete buttons until the result is deliberately named, and the
		// reopen pointer follows the claim so a later session doesn't reopen the replaced level.
		Level.Id = "";
		Level.Name = "";
		Level.ProjectSourceId = null;
		RememberOpenedLevel( "" );
		Level.DailyTemplateName = template.Id;
		Level.DailyTemplateMirrorX = template.AllowMirrorX;
		Level.DailyTemplateAuthoredSpikes = template.AuthoredSpikes;
		Level.DailyTemplateWeight = template.Weight;
		// Don't leave the alt-pattern tool live on a template that has no alt playfield.
		if ( !Level.AlternatePlayfieldEnabled && Tool == EditTool.AlternatePattern )
			Tool = EditTool.Select;
		LevelRevision++;
		Select( SelKind.None, -1 );
		LastError = "";
		RefreshEnvironment();
		Rebuild();
		CheckpointUndo( newDocument: true );   // template load replaces the layout in place — undoable
	}

	/// <summary>Delete the named daily template's project JSON. Deleting the project file needs the
	/// editor assembly, so this dispatches <c>daily_template_delete</c> (the inspector's Delete
	/// button confirms before calling this).</summary>
	public void DeleteDailyTemplate()
	{
		var id = DailyTemplates.SanitizeId( Level.DailyTemplateName );
		if ( string.IsNullOrEmpty( id ) ) { LastError = "template needs a name"; return; }
		if ( DailyTemplates.Find( id ) is null ) { LastError = $"no template '{id}'"; return; }

		LastError = "";
		Sandbox.ConsoleSystem.Run( $"daily_template_delete {id}" );
	}

	/// <summary>Test-play the current (unsaved) level without writing it to disk. Hands the live editor
	/// model to the manager, which runs it as a non-submitting test and returns to a fresh editor with
	/// this exact state when the run ends (death / win / Back). See <see cref="GameManager.StartLevelTest"/>.</summary>
	public void TestPlay()
	{
		if ( !ValidateBlockPool() ) return;
		Manager.StartLevelTest( Level, Tool, SelectedCharacterId );
	}

	/// <summary>Validate finite-pool authoring before test play or persistence and surface any error in the HUD.</summary>
	public bool ValidateBlockPool()
	{
		LastError = Level.PoolValidationError ?? "";
		return string.IsNullOrEmpty( LastError );
	}

	/// <summary>Surface an editor command failure in the inspector.</summary>
	public void ReportProjectError( string error )
	{
		LastError = error ?? "";
	}

	/// <summary>True when <paramref name="id"/> names the project file this document was loaded from
	/// or last saved as — the only file Save may overwrite and Delete may remove. An id that was typed
	/// or pasted in merely MATCHES a file; it has no claim on it (load the level to get one). Exact
	/// match, like every other id lookup (registry, timestamps, reopen pointer): a case-variant id would
	/// hit the file on a case-insensitive disk but miss the registry entry.</summary>
	public bool OwnsProjectFile( string id )
		=> !string.IsNullOrWhiteSpace( id ) && string.Equals( Level.ProjectSourceId, id, System.StringComparison.Ordinal );

	/// <summary>Associate this document with its successfully written project file. The undo entries of
	/// this document adopt the id too, so undoing a later edit can't drop the newly saved file's identity
	/// (which would make the next save refuse with "already exists").</summary>
	public void MarkSavedToProject( string id )
	{
		UndoHistory.RetagSource( id );
		Level.ProjectSourceId = id;
		LastError = "";
		PublishStatus = "";
		RememberOpenedLevel( id );
	}

	// ── workshop publish (standalone only) ─────────────────────────────────────────────────────────

	/// <summary>Status line for the Publish flow, shown under the save row. Set only when the engine's
	/// publish modal reports success (a cancelled modal reports nothing); any save clears it.</summary>
	public string PublishStatus { get; private set; } = "";

	/// <summary>Publish the open level to the Steam Workshop (see <see cref="WorkshopLevels.Publish"/>:
	/// saves locally first, then hands off to the engine's user-confirmed publish modal). Errors surface
	/// through <see cref="LastError"/> like every other project action.</summary>
	public void PublishToWorkshop()
	{
		PublishStatus = "";
		WorkshopLevels.Publish( this, fileId => PublishStatus = $"PUBLISHED #{fileId}" );
	}

	/// <summary>Save the current level to its <c>Assets/levels/{id}.json</c> file, then reload the
	/// registry so the edit takes effect immediately. Game code can't write the project folder, so this
	/// dispatches the editor-assembly <c>level_save</c> ConCmd (which reads this live stage's
	/// <see cref="Level"/> and writes the file). The editor command also applies the saved definition
	/// directly to the live registry because the mounted asset filesystem can lag behind the disk write.
	/// STANDALONE builds have no editor assembly — there the level saves as a local JSON in the game
	/// data folder instead (see <see cref="LocalLevels"/>).</summary>
	public void SaveToProject()
	{
		if ( !ValidateBlockPool() ) return;
		if ( LocalLevels.Enabled ) { LocalLevels.Save( this ); return; }
		Sandbox.ConsoleSystem.Run( "level_save" );
	}

	/// <summary>Delete the current level's project JSON through the editor assembly. The command
	/// refreshes the live level registry and resets this editor only after a file is removed.
	/// STANDALONE builds delete the local data-folder JSON instead (see <see cref="LocalLevels"/>).</summary>
	public void DeleteFromProject()
	{
		LastError = "";
		if ( LocalLevels.Enabled ) { LocalLevels.Delete( this ); return; }
		Sandbox.ConsoleSystem.Run( "level_delete" );
	}

	// ── schematic rendering ─────────────────────────────────────────────────────────────────────────

	/// <summary>Pool of reusable animated sprite markers for one editor role (coins, player spawns, block
	/// spawn previews). <see cref="Rebuild"/> claims them in draw order and <see cref="TrimUnused"/>
	/// destroys the leftovers, so a marker that survives a rebuild keeps its GameObject — and with it its
	/// animation playback — instead of restarting at frame 0. The engine's <c>Sprite</c> setter and
	/// <c>PlayAnimation</c> both no-op when the value is unchanged, so re-applying them every rebuild
	/// costs nothing and only a REAL change (pinned block type, character swap) resets one marker.</summary>
	private sealed class MarkerPool
	{
		private readonly List<SpriteRenderer> _renderers = new();
		private readonly string _role;
		private int _used;

		public MarkerPool( string role ) => _role = role;

		public void BeginSync() => _used = 0;

		/// <summary>Forget every tracked renderer WITHOUT destroying it — for when the stage root (and
		/// with it every marker GameObject) has already gone away.</summary>
		public void Forget()
		{
			_renderers.Clear();
			_used = 0;
		}

		public void Claim( GameObject parent, Vector2 center, float z, string spritePath, Vector2 size, string animation, Color? color = null )
		{
			if ( _used == _renderers.Count )
			{
				var added = SpriteLayer.Add( parent, spritePath, size, animation );
				added.GameObject.Name = _role;
				_renderers.Add( added );
			}

			var renderer = _renderers[_used++];
			renderer.Sprite = ResourceLibrary.Get<Sprite>( spritePath );
			renderer.Size = size;
			renderer.PlayAnimation( animation );
			// Always (re)assign the tint: a reused renderer may carry one from its previous claim.
			renderer.Color = color ?? Color.White;
			renderer.GameObject.WorldPosition = new Vector3( center.x, center.y, z );
		}

		public void TrimUnused()
		{
			for ( int i = _renderers.Count - 1; i >= _used; i-- )
			{
				_renderers[i].GameObject?.Destroy();
				_renderers.RemoveAt( i );
			}
		}
	}

	public void Rebuild()
	{
		if ( Root is null ) return;

		// Markers outlive the schematic. Recreate their root only when it isn't ours (first build, or a
		// root left behind by a previous Enter) and drop the now-dangling renderers with it. The test is
		// PARENTAGE, not IsValid/IsDestroyed: Destroy() only queues the object and doesn't mark its
		// children until it runs next frame, so a stale root still reports itself perfectly healthy.
		if ( _markerRoot is null || _markerRoot.Parent != Root )
		{
			_markerRoot = CreateChild( "Markers" );
			_coinMarkers.Forget();
			_playerMarkers.Forget();
			_blockMarkers.Forget();
		}
		_coinMarkers.BeginSync();
		_playerMarkers.BeginSync();
		_blockMarkers.BeginSync();

		_schematicRoot?.Destroy();
		_spikeIndicators.Clear();
		_selectionIndicators.Clear();
		_schematicRoot = CreateChild( "Schematic" );

		Color oob = Level.OutOfBoundsColor ?? Manager.ClearColor;
		Color wc = Level.WallColor ?? WallGrey;

		// Obstacle fills (out-of-bounds colour) — drawn under the walls. Fences and glass draw their
		// own see-through looks below instead.
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
			if ( !Level.ObstacleIsFence( i ) && !Level.ObstacleIsGlass( i ) )
				Quad( RectCenter( Level.Obstacles[i] ), RectSize( Level.Obstacles[i] ), oob, DEPTH_FILL );

		DrawArenaWalls( wc );

		// Obstacle borders + face spikes; fences get the bar look (never spiked), glass the pane
		// look (its spikes draw PALER — a player-only hazard blocks slide over).
		for ( int i = 0; i < Level.Obstacles.Count; i++ )
		{
			var r = Level.Obstacles[i];
			if ( Level.ObstacleIsFence( i ) )
			{
				DrawFence( r );
				continue;
			}
			if ( Level.ObstacleIsGlass( i ) )
			{
				DrawGlass( r );
				foreach ( var dir in Globals.GetAllDirections() )
					if ( Level.ObstacleSpiked( i, dir ) )
						SpikeStrip( r, dir, Pale( FaceSizeUneven( r, dir ) ? SpikeOrange : SpikeRed ) );
				continue;
			}
			Outline( r, wc, Arena.WALL_SIZE, DEPTH_WALLS, inset: true );
			foreach ( var dir in Globals.GetAllDirections() )
				if ( Level.ObstacleSpiked( i, dir ) )
					SpikeStrip( r, dir, FaceSizeUneven( r, dir ) ? SpikeOrange : SpikeRed );
		}

		DrawSpawns();
		DrawPlayer();
		DrawCoins();
		DrawVision();
		DrawAlternatePatternRects();
		DrawSelection();
		DrawObstacleHover();
		DrawCreatePreview();
		DrawMirrorGuides();
		DrawGrid();

		// Whatever the draws above didn't claim no longer exists in the level.
		_coinMarkers.TrimUnused();
		_playerMarkers.TrimUnused();
		_blockMarkers.TrimUnused();
	}

	/// <summary>White alignment-grid overlay (editor pref, persisted). Drawn last + at the overlay depth
	/// with a high child order so it sits over the whole schematic.</summary>
	private void DrawGrid()
	{
		var s = EditorPrefs.Current;
		if ( !s.ShowGrid ) return;

		float gs = MathF.Max( 2f, s.GridSize );
		float a = Math.Clamp( s.GridOpacity / 100f, 0f, 1f );
		if ( a <= 0f ) return;

		Color col = new( 1f, 1f, 1f, a );
		float cx = Arena.WIDTH / 2f, cy = Arena.HEIGHT / 2f;
		const int depth = DEPTH_OVERLAY;
		const int order = 30; // above selection / create-preview

		// The lattice reference is normally the centre (optionally shifted by half a cell), or the
		// playable bottom-left corner immediately inside the walls when that origin mode is selected.
		float lcx = GridLineOrigin( gs, true ), lcy = GridLineOrigin( gs, false );

		// Ultra-fine lines: one LOGICAL (in-game) pixel would render as several monitor pixels — the
		// ortho camera maps the 240-unit arena across the whole screen height — giving chunky bars. So
		// make the line width a small fraction of a logical unit, scaled by the screen height, so each
		// line renders roughly one monitor pixel thin and sits between/through the chunky game pixels.
		float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
		float lineW = MathF.Max( 0.02f, GRID_LINE_SCREEN_PX * Arena.HEIGHT / screenH );

		// Smart grid: symmetric lattices mirrored inward from both inner wall faces, so lines land where
		// blocks sit flush against either wall. Ignores the centre/offset origin options.
		if ( s.GridSmart )
		{
			foreach ( float x in SmartGridLines( gs, true ) )
				Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );
			foreach ( float y in SmartGridLines( gs, false ) )
				Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
			return;
		}

		// Vertical lines, radiating out from the (possibly offset) centre reference.
		for ( float x = lcx; x <= Arena.WIDTH + 0.01f; x += gs )
			Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );
		for ( float x = lcx - gs; x >= -0.01f; x -= gs )
			Quad( new Vector2( x, cy ), new Vector2( lineW, Arena.HEIGHT ), col, depth, translucent: true, childOrder: order );

		// Horizontal lines.
		for ( float y = lcy; y <= Arena.HEIGHT + 0.01f; y += gs )
			Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
		for ( float y = lcy - gs; y >= -0.01f; y -= gs )
			Quad( new Vector2( cx, y ), new Vector2( Arena.WIDTH, lineW ), col, depth, translucent: true, childOrder: order );
	}

	// Target on-screen thickness of a grid line, in monitor pixels (converted to a thin fraction of a
	// logical unit at draw time). Kept close to 1 for the "ultrafine" look; not an exact 1px measure.
	private const float GRID_LINE_SCREEN_PX = 1.25f;

	/// <summary>Centre-line guides while a mirror toggle is on: the line(s) geometry reflects across
	/// (ultra-fine like the grid, tinted like the create preview so they read as an editing aid).</summary>
	private void DrawMirrorGuides()
	{
		if ( !MirrorActive ) return;
		float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
		float lineW = MathF.Max( 0.02f, 1.5f * GRID_LINE_SCREEN_PX * Arena.HEIGHT / screenH );
		Color col = new( 0.4f, 1f, 0.6f, 0.45f );
		if ( MirrorX )
			Quad( new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f ), new Vector2( lineW, Arena.HEIGHT ), col,
				DEPTH_OVERLAY, translucent: true, childOrder: 29 );
		if ( MirrorY )
			Quad( new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f ), new Vector2( Arena.WIDTH, lineW ), col,
				DEPTH_OVERLAY, translucent: true, childOrder: 29 );
	}

	private void DrawArenaWalls( Color wc )
	{
		int W = Arena.WIDTH, H = Arena.HEIGHT, ws = Arena.WALL_SIZE;
		Quad( new Vector2( ws / 2f, H / 2f ), new Vector2( ws, H ), wc, Globals.DEPTH_ARENA_WALL );          // left
		Quad( new Vector2( W - ws / 2f, H / 2f ), new Vector2( ws, H ), wc, Globals.DEPTH_ARENA_WALL );      // right
		Quad( new Vector2( W / 2f, ws / 2f ), new Vector2( W, ws ), wc, Globals.DEPTH_ARENA_WALL );          // bottom
		Quad( new Vector2( W / 2f, H - ws / 2f ), new Vector2( W, ws ), wc, Globals.DEPTH_ARENA_WALL );      // top

		// Arena-wall spikes are a single untiled strip, so they never suffer the ÷5 uneven-spacing issue.
		// Pulse them from red like an evenly tiled obstacle face. Centre them on the inner face and draw
		// them behind the wall so only the playable half remains visible without obscuring the boundary.
		float off = ws;
		foreach ( var dir in Level.SpikedWalls )
		{
			switch ( dir )
			{
				case Direction.Left: SpikeQuad( new Vector2( off, H / 2f ), new Vector2( SPIKE_STRIP_THICK, H ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
				case Direction.Right: SpikeQuad( new Vector2( W - off, H / 2f ), new Vector2( SPIKE_STRIP_THICK, H ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
				case Direction.Down: SpikeQuad( new Vector2( W / 2f, off ), new Vector2( W, SPIKE_STRIP_THICK ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
				case Direction.Up: SpikeQuad( new Vector2( W / 2f, H - off ), new Vector2( W, SPIKE_STRIP_THICK ), SpikeRed, Globals.DEPTH_ARENA_WALL ); break;
			}
		}
	}

	// True when a spiked obstacle face needs an overlapping final tile. Exact multiples and a 1px
	// plain tail tile cleanly; remainders 2-4 clamp the final tooth and can look uneven.
	// Up/Down faces tile along WIDTH, Left/Right faces along HEIGHT.
	public static bool SpikeFaceSizeUneven( float length )
	{
		float nearestMultiple = MathF.Round( length / 5f ) * 5f;
		if ( MathF.Abs( length - nearestMultiple ) <= PIXEL_NORMALIZE_EPSILON ) return false;
		return length % 5f > 1.01f;
	}

	private static bool FaceSizeUneven( RectF r, Direction dir )
	{
		float len = ( dir == Direction.Up || dir == Direction.Down ) ? r.Width : r.Height;
		return SpikeFaceSizeUneven( len );
	}

	private void SpikeStrip( RectF r, Direction dir, Color color )
	{
		float cx = ( r.Left + r.Right ) / 2f, cy = ( r.Bottom + r.Top ) / 2f;
		float t = SPIKE_STRIP_THICK;
		switch ( dir )
		{
			case Direction.Left: SpikeQuad( new Vector2( r.Left, cy ), new Vector2( t, r.Height ), color ); break;
			case Direction.Right: SpikeQuad( new Vector2( r.Right, cy ), new Vector2( t, r.Height ), color ); break;
			case Direction.Down: SpikeQuad( new Vector2( cx, r.Bottom ), new Vector2( r.Width, t ), color ); break;
			case Direction.Up: SpikeQuad( new Vector2( cx, r.Top ), new Vector2( r.Width, t ), color ); break;
		}
	}

	private void SpikeQuad( Vector2 center, Vector2 size, Color lowColor, float depth = DEPTH_WALLS )
	{
		var renderer = Quad( center, size, SpikePulseColor( lowColor ), depth, childOrder: SPIKE_CHILD_ORDER );
		_spikeIndicators.Add( new SpikeIndicator( renderer, lowColor ) );
	}

	private void UpdateSpikePulse( float dt )
	{
		_spikePulseTime += dt;
		foreach ( var indicator in _spikeIndicators )
			indicator.Renderer.Color = GammaToLinear( SpikePulseColor( indicator.LowColor ) );
	}

	private Color SpikePulseColor( Color lowColor )
	{
		float amount = 0.5f - 0.5f * MathF.Cos( _spikePulseTime * SPIKE_PULSE_SPEED );
		return Color.Lerp( lowColor, SpikeYellow, amount );
	}

	private void UpdateSelectionPulse( float dt )
	{
		_selectionPulseTime += dt;
		float amount = SelectionPulseAmount();
		float padding = SELECTION_HALO_PADDING_MIN + (SELECTION_HALO_PADDING_MAX - SELECTION_HALO_PADDING_MIN) * amount;
		Color color = GammaToLinear( SelectionPulseColor( amount ) );
		foreach ( var indicator in _selectionIndicators )
		{
			var (center, size) = SelectionHaloGeometry( indicator.Bounds, indicator.Side, padding );
			var position = indicator.Renderer.GameObject.WorldPosition;
			indicator.Renderer.GameObject.WorldPosition = new Vector3( center.x, center.y, position.z );
			indicator.Renderer.Size = size;
			indicator.Renderer.Color = color;
		}
	}

	private float SelectionPulseAmount()
	{
		return 0.5f - 0.5f * MathF.Cos( _selectionPulseTime * SELECTION_PULSE_SPEED );
	}

	private static Color SelectionPulseColor( float amount ) => Color.Lerp( SelectionBlue, SelectionWhite, amount );

	private void DrawSpawns()
	{
		bool showType = !Level.UsePool && !Level.ShuffleTypes;
		for ( int i = 0; i < Level.SpawnPositions.Count; i++ )
		{
			var c = Level.SpawnPositions[i];
			var pin = Level.SpawnPin( i );
			if ( pin is not null )
				BlockMarker( c, pin );                      // pinned slot: always show the pinned block
			else if ( showType && i < Level.Blocks.Count )
				BlockMarker( c, Level.Blocks[i] );          // exact ordered placement preview
			else
				Quad( c, new Vector2( 40f, 40f ), SpawnFill, DEPTH_MARKER, translucent: true );

			var outlineCol = pin is not null ? new Color( 0.4f, 1f, 0.6f, 0.9f ) : SpawnOutline;
			Outline( new RectF( c.x - 20f, c.y - 20f, c.x + 20f, c.y + 20f ), outlineCol, 1f, DEPTH_MARKER, inset: true, childOrder: 8 );
		}
	}

	private void DrawCoins()
	{
		// Schematic coin: the real baked sprite at art size (tiny but exact), plus a faint outline
		// one pixel out so the 4x6 marker still reads and hit-targets at a glance.
		foreach ( var c in Level.Coins )
		{
			_coinMarkers.Claim( _markerRoot, c, Globals.DepthToZ( DEPTH_MARKER, 5 ),
				"sprites/coin.sprite", new Vector2( Coin.ART_H, Coin.ART_H ), "idle" );
			Outline( BoxAt( c, COIN_HALF_W + 1f, COIN_HALF_H + 1f ), CoinYellow.WithAlpha( 0.55f ), 1f,
				DEPTH_MARKER, inset: false, childOrder: 6 );
		}
	}

	private void DrawPlayer()
	{
		// Render the real player sprite at the player's art size so each marker looks exactly like
		// the in-game player — using the character currently selected in the editor (what a Test Play
		// uses), so the spawn markers preview the actual character that will play. Exactly two Twin
		// spawns preview Twin 1 then Twin 2; other multi-spawn sets remain random candidates.
		var character = Characters.TryGet( Level.ForcedCharacterId, out var forced )
			? forced
			: Characters.Get( SelectedCharacterId );
		for ( int i = 0; i < Level.PlayerSpawns.Count; i++ )
		{
			var c = Level.PlayerSpawns[i];
			var markerCharacter = i == 1 && Level.PlayerSpawns.Count == 2 && character.Partner is not null
				? character.Partner
				: character;
			_playerMarkers.Claim( _markerRoot, c, Globals.DepthToZ( DEPTH_MARKER, 7 ),
				markerCharacter.SpritePath, markerCharacter.ArtSize, "idle" );

			// Very thin border of the ACTUAL hitbox (8×10) so its exact bounds + snap position read clearly.
			// One logical px renders several monitor px under the ortho cam, so scale the thickness by the
			// screen height to keep it ~1 monitor px thin (same trick as the alignment grid). Selection uses a
			// separate expanded halo behind the marker, so this exact hitbox border never changes appearance.
			float screenH = Screen.Height > 1f ? Screen.Height : 1080f;
			float thin = MathF.Max( 0.12f, 1.25f * Arena.HEIGHT / screenH );
			var hitbox = new RectF( c.x - PLAYER_HALF_W, c.y - PLAYER_HALF_H, c.x + PLAYER_HALF_W, c.y + PLAYER_HALF_H );
			Outline( hitbox, PlayerBorderColor, thin, DEPTH_OVERLAY, inset: false, childOrder: 4 );
		}
	}

	private void DrawVision()
	{
		// Cue for obstacles flagged to block line of sight: a cyan fill + inner outline over the
		// obstacle (no standalone vision rects any more — vision is a per-obstacle flag).
		foreach ( var i in Level.VisionObstacles )
		{
			if ( i < 0 || i >= Level.Obstacles.Count ) continue;
			var r = Level.Obstacles[i];
			Quad( RectCenter( r ), RectSize( r ), VisionFill, DEPTH_VISION, translucent: true );
			Outline( r, VisionOutline, 1f, DEPTH_VISION, inset: true, childOrder: 1 );
		}
	}

	// Fence schematic: faint fill + 1px frame + vertical bars on the same world-aligned 5px grid the
	// game uses (GameStage.AddFence), so the editor pixel-matches the in-game look — including the
	// level's authored fence colour (border = the colour, bars = its brighter derivative).
	private void DrawFence( RectF r )
	{
		Color border = Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
		Color bars = LevelCosmeticsRandomizer.FenceBarColor( border );
		Quad( RectCenter( r ), RectSize( r ), border.WithAlpha( 0.10f ), DEPTH_FILL, translucent: true );
		// Bars edge-to-edge (the frame outline paints over their ends via its higher child order),
		// so the 5px rhythm continues across the frameless seam of two flush fences — like in-game.
		// Bar alpha matches the game's FENCE_BAR_ALPHA.
		const float bar = 1f, spacing = 5f;
		for ( float x = MathF.Ceiling( r.Left / spacing ) * spacing; x <= r.Right - bar; x += spacing )
			Quad( new Vector2( x + bar / 2f, (r.Bottom + r.Top) / 2f ), new Vector2( bar, r.Height ),
				bars.WithAlpha( 0.5f ), DEPTH_WALLS, translucent: true );
		// Frame alpha matches the game's FENCE_BORDER_ALPHA (Outline goes translucent when a < 1).
		Outline( r, border.WithAlpha( 0.85f ), 1f, DEPTH_WALLS, inset: true, childOrder: 1 );
	}

	// Glass schematic: translucent pane + 1px solid border + sparse continuous diagonal reflection
	// streaks — the SAME alphas, thickness and world-aligned rhythm as the game (GameStage's glass
	// section), so the editor pixel- and colour-matches the live look.
	private void DrawGlass( RectF r )
	{
		Color glass = Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor;
		Quad( RectCenter( r ), RectSize( r ), glass.WithAlpha( 0.30f ), DEPTH_FILL, translucent: true );
		DrawGlassStreaks( r, glass.WithAlpha( 0.50f ) );
		Outline( r, glass, 1f, DEPTH_WALLS, inset: true, childOrder: 1 );
	}

	// The band ∩ pane rasterized as abutting 1px columns, exactly like GameStage.AddGlassStreaks:
	// streaks run fully edge-to-edge with no missing end wedges, never leave the pane, and continue
	// seamlessly across composite-pane seams (adjacent panels rasterize the same world-space band).
	private void DrawGlassStreaks( RectF r, Color color )
	{
		const float P = 24f; // world-aligned diagonal rhythm, matching GameStage.GLASS_STREAK_PERIOD
		float h = 3f * MathF.Sqrt( 2f ) / 2f; // GameStage.GLASS_STREAK_WIDTH → band |x - y - c| <= h

		float cMin = r.Left - r.Top - h, cMax = r.Right - r.Bottom + h;
		for ( float c = MathF.Ceiling( cMin / P ) * P; c <= cMax; c += P )
		{
			float xLo = MathF.Max( r.Left, MathF.Floor( r.Bottom + c - h ) );
			float xHi = MathF.Min( r.Right, r.Top + c + h );
			for ( float x0 = xLo; x0 < xHi; x0 += 1f )
			{
				float x1 = MathF.Min( x0 + 1f, r.Right );
				float xc = (x0 + x1) / 2f;
				float yLo = MathF.Max( r.Bottom, xc - c - h );
				float yHi = MathF.Min( r.Top, xc - c + h );
				if ( yHi <= yLo ) continue;
				Quad( new Vector2( xc, (yLo + yHi) / 2f ), new Vector2( x1 - x0, yHi - yLo ),
					color, DEPTH_WALLS, translucent: true );
			}
		}
	}

	// The paler spike-strip tint for GLASS faces: still clearly a hazard, but visibly softer than a
	// real wall's spikes — the cue that blocks slide over these (they only kill the player).
	private static Color Pale( Color spike ) => Color.Lerp( spike, Color.White, 0.45f );

	private void DrawAlternatePatternRects()
	{
		if ( Tool != EditTool.AlternatePattern ) return;
		foreach ( RectF rect in Level.AlternatePlayfieldRects )
			Outline( rect, new Color( 1f, 0.72f, 0.22f, 0.9f ), 0.5f, DEPTH_OVERLAY, inset: true, childOrder: 8 );
	}

	private void DrawSelection()
	{
		foreach ( var selected in _selection )
			if ( selected.Kind != SelKind.AlternatePattern || Tool == EditTool.AlternatePattern )
				SelectionHalo( selected );

		if ( _marqueeSelecting && _pointerDragged )
		{
			RectF marquee = Normalize( _dragStart, _dragCurrent );
			Quad( RectCenter( marquee ), RectSize( marquee ), new Color( 0.45f, 0.72f, 1f, 0.12f ), DEPTH_OVERLAY, translucent: true, childOrder: 10 );
			Outline( marquee, new Color( 0.45f, 0.72f, 1f, 0.95f ), 0.5f, DEPTH_OVERLAY, inset: true, childOrder: 11 );
		}
	}

	private void DrawObstacleHover()
	{
		RectF rect;
		if ( _hoverRectKind == SelKind.Obstacle && _hoverObstacleIndex >= 0 && _hoverObstacleIndex < Level.Obstacles.Count )
			rect = Level.Obstacles[_hoverObstacleIndex];
		else if ( _hoverRectKind == SelKind.AlternatePattern && _hoverObstacleIndex >= 0
			&& _hoverObstacleIndex < Level.AlternatePlayfieldRects.Count )
			rect = Level.AlternatePlayfieldRects[_hoverObstacleIndex];
		else return;

		if ( _obstacleHoverIntent == ObstacleHoverIntent.Move )
		{
			Quad( RectCenter( rect ), RectSize( rect ), MoveHoverFill, DEPTH_OVERLAY, translucent: true, childOrder: 18 );
			Outline( rect, MoveHoverOutline, 0.75f, DEPTH_OVERLAY, inset: true, childOrder: 19 );
			return;
		}

		if ( _obstacleHoverIntent != ObstacleHoverIntent.Resize ) return;
		const float thickness = 1.5f;
		const float overhang = 2f;
		float centerX = ( rect.Left + rect.Right ) / 2f;
		float centerY = ( rect.Bottom + rect.Top ) / 2f;
		(Vector2 center, Vector2 size) = _hoverResizeSide switch
		{
			Direction.Left => (new Vector2( rect.Left, centerY ), new Vector2( thickness, rect.Height + overhang * 2f )),
			Direction.Right => (new Vector2( rect.Right, centerY ), new Vector2( thickness, rect.Height + overhang * 2f )),
			Direction.Down => (new Vector2( centerX, rect.Bottom ), new Vector2( rect.Width + overhang * 2f, thickness )),
			Direction.Up => (new Vector2( centerX, rect.Top ), new Vector2( rect.Width + overhang * 2f, thickness )),
			_ => (Vector2.Zero, Vector2.Zero),
		};
		if ( size != Vector2.Zero )
			Quad( center, size, ResizeHoverColor, DEPTH_OVERLAY, translucent: true, childOrder: 20 );
	}

	private void SelectionHalo( Selection selected )
	{
		RectF bounds = SelectionBounds( selected );
		int depth = selected.Kind is SelKind.Obstacle or SelKind.AlternatePattern ? DEPTH_WALLS : DEPTH_MARKER;

		SelectionHaloQuad( bounds, Direction.Down, depth );
		SelectionHaloQuad( bounds, Direction.Up, depth );
		SelectionHaloQuad( bounds, Direction.Left, depth );
		SelectionHaloQuad( bounds, Direction.Right, depth );
	}

	private void SelectionHaloQuad( RectF bounds, Direction side, int depth )
	{
		float amount = SelectionPulseAmount();
		float padding = SELECTION_HALO_PADDING_MIN + (SELECTION_HALO_PADDING_MAX - SELECTION_HALO_PADDING_MIN) * amount;
		var (center, size) = SelectionHaloGeometry( bounds, side, padding );
		var renderer = Quad( center, size, SelectionPulseColor( amount ), depth, translucent: true, childOrder: SELECTION_CHILD_ORDER );
		_selectionIndicators.Add( new SelectionIndicator( renderer, bounds, side ) );
	}

	private static (Vector2 Center, Vector2 Size) SelectionHaloGeometry( RectF bounds, Direction side, float padding )
	{
		float cx = ( bounds.Left + bounds.Right ) / 2f;
		float cy = ( bounds.Bottom + bounds.Top ) / 2f;
		float width = bounds.Width + 2f * padding;
		float height = bounds.Height + 2f * padding - SELECTION_HALO_THICK;
		return side switch
		{
			Direction.Down => (new Vector2( cx, bounds.Bottom - padding ), new Vector2( width, SELECTION_HALO_THICK )),
			Direction.Up => (new Vector2( cx, bounds.Top + padding ), new Vector2( width, SELECTION_HALO_THICK )),
			Direction.Left => (new Vector2( bounds.Left - padding, cy ), new Vector2( SELECTION_HALO_THICK, height )),
			Direction.Right => (new Vector2( bounds.Right + padding, cy ), new Vector2( SELECTION_HALO_THICK, height )),
			_ => (Vector2.Zero, Vector2.Zero),
		};
	}

	private void DrawCreatePreview()
	{
		if ( !_creatingRect && !_creatingAlternateRect ) return;
		RectF r = CreateRect();
		if ( r.Width < 1f || r.Height < 1f ) return;
		Color fill = _creatingAlternateRect ? new Color( 1f, 0.65f, 0.16f, 0.25f ) : PreviewFill;
		Color outline = _creatingAlternateRect ? new Color( 1f, 0.72f, 0.22f, 0.95f ) : PreviewOutline;
		Quad( RectCenter( r ), RectSize( r ), fill, DEPTH_OVERLAY, translucent: true );
		Outline( r, outline, 1f, DEPTH_OVERLAY, inset: true, childOrder: 1 );

		// Fainter previews of the mirror copies the release will create (mirrors of the CLAMPED rect,
		// matching what actually lands).
		if ( !_creatingRect || !MirrorActive ) return;
		RectF clamped = ClampRect( r );
		foreach ( var (flipX, flipY) in ActiveMirrorFlips() )
		{
			RectF mirrored = MirrorRect( clamped, flipX, flipY );
			if ( RectsAlmostEqual( mirrored, clamped ) ) continue;
			Quad( RectCenter( mirrored ), RectSize( mirrored ), fill.WithAlpha( fill.a * 0.5f ), DEPTH_OVERLAY, translucent: true );
			Outline( mirrored, outline.WithAlpha( outline.a * 0.5f ), 1f, DEPTH_OVERLAY, inset: true, childOrder: 1 );
		}
	}

	// ── low-level draw primitives (into _schematicRoot) ─────────────────────────────────────────────

	private static Vector2 RectSize( RectF r ) => new( r.Width, r.Height );

	private SpriteRenderer Quad( Vector2 center, Vector2 size, Color color, float depth, bool translucent = false, int childOrder = 0 )
	{
		var go = new GameObject();
		go.Name = "q";
		go.SetParent( _schematicRoot );
		go.WorldPosition = new Vector3( center.x, center.y, depth + childOrder * 0.01f );
		var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle" );
		// A sprite tint is consumed LINEAR; convert so the authored (gamma) colour shows as intended
		// and, for the out-of-bounds fill, matches the gamma-space camera clear exactly.
		sr.Color = GammaToLinear( color );
		if ( translucent )
		{
			sr.Opaque = false;
			sr.AlphaCutoff = 0f;
		}
		return sr;
	}

	/// <summary>Four thin quads outlining a rect. <paramref name="inset"/> draws the strips inside the
	/// edges (borders); otherwise centred on the edges.</summary>
	private void Outline( RectF r, Color color, float thick, float depth, bool inset = true, int childOrder = 0 )
	{
		float w = r.Width, h = r.Height;
		float half = thick / 2f;
		float bY = inset ? r.Bottom + half : r.Bottom;
		float tY = inset ? r.Top - half : r.Top;
		float lX = inset ? r.Left + half : r.Left;
		float rX = inset ? r.Right - half : r.Right;
		float cx = ( r.Left + r.Right ) / 2f, cy = ( r.Bottom + r.Top ) / 2f;

		Quad( new Vector2( cx, bY ), new Vector2( w, thick ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
		Quad( new Vector2( cx, tY ), new Vector2( w, thick ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
		Quad( new Vector2( lX, cy ), new Vector2( thick, h ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
		Quad( new Vector2( rX, cy ), new Vector2( thick, h ), color, depth, translucent: color.a < 1f, childOrder: childOrder );
	}

	/// <summary>Preview a block exactly as it will spawn: the authored phase's full face (face + mouth +
	/// eyes + eyebrows, mirroring <see cref="Block.CreateVisuals"/>/<see cref="Block.SetInitialPhase"/>)
	/// plus the four side buttons, pressed where the block authors them pre-pressed. Eyes use the tally's
	/// fixed forward "down" gaze (a live block's gaze follows its random move direction). Marker-depth
	/// sub-orders 0-4 mirror the block's own layer order; coin/player markers and the spawn outline sit
	/// above at 5+.</summary>
	private void BlockMarker( Vector2 center, EditorBlock block )
	{
		string sprite = $"sprites/blocks/{block.Type.ToString().ToLowerInvariant()}.sprite";
		var size = new Vector2( 40f, 40f );
		int phase = Math.Clamp( block.Phase, 0, Block.NUM_PHASES - 1 );
		bool max = phase == Block.NUM_PHASES - 1;

		void Layer( string path, string anim, int subOrder, Color? color = null )
			=> _blockMarkers.Claim( _markerRoot, center, Globals.DepthToZ( DEPTH_MARKER, subOrder ), path, size, anim, color );

		Layer( sprite, $"face_{phase}", 0 );
		foreach ( var dir in Globals.GetAllDirections() )
		{
			// Pre-pressed sides only exist below max phase (a maxed block has no pressable buttons) —
			// same rule as Block.SetInitialPhase. Unpressed phase-1 buttons get the live block's tint.
			bool pressed = !max && block.Pressed.Contains( dir );
			string state = pressed ? $"pressed{phase}" : max ? "max" : $"out{phase}";
			Color tint = !pressed && phase == 1 ? Block.PHASE_1_OUT_TINT : Color.White;
			Layer( "sprites/sides.sprite", $"{state}_{Globals.GetStringForDirection( dir )}", 1, tint );
		}
		Layer( sprite, $"mouth_{phase}", 2 );
		Layer( sprite, $"eyes_down_{phase}", 3 );
		Layer( sprite, $"eyebrows_{phase}", 4 );
	}
}