UI/LevelBrowserOverlay.razor
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "LevelBrowserOverlay.razor.scss" )]

@* Shared level-browser overlay. Editor mode offers four toggleable, locally-persisted views:
	1. MAP       — only the mapped levels, laid out in the true level-select map layout with connector
	               lines (scrollable + zoomable via the search row's zoom bar), every node shown unlocked.
	2. OTHER     — unflagged levels not present on the map.
	3. TEST      — test-flagged levels not present on the map.
	4. TEMPLATES — the daily-challenge obstacle templates, previewed in the CURRENT editor level's
	               colour scheme (a pick keeps that scheme too — templates are layout-only). Sortable
	               A-Z / newest first / highest weight first; a pick loads the template into the editor.
	The Other/Test views can be sorted alphabetically or by newest file first.
	Leaderboard mode always uses the map view. Clicking a level reports its id and closes the overlay.

	The map view also has an EDIT mode (editor picker + Game.IsEditor only — never Leaderboard mode):
	clicking a node replaces its level, + buttons on empty grid cells adjacent to a node add a new
	side level there, and + buttons ON the connection lines insert a level between two nodes (the
	nodes beyond shift over to make room). Each opens an inner picker (the same tile grid, over
	unmapped levels); the pick is written to Assets/levelmap.json by the editor-assembly map writer
	and the map hot-rebuilds. *@
<root class="level-browser">
	@* Opaque backdrop: clicking it (outside the window) backs out of the edit picker, else dismisses
	   the overlay. *@
	<div class="backdrop" onclick=@Click( BackdropClicked )></div>
	<PixelTooltip @ref="TooltipLayer"></PixelTooltip>

	<div class="window">
		<div class="topbar">
			<label class="title">@TitleText</label>
			@if ( _editAction != EditAction.None )
			{
				@* Inner edit picker: choose which unmapped pool to pick from, or back out to the map. *@
				<div class="modes">
					<button class="mode @(!_editPickTest ? "on" : "")" onclick=@Click( () => SetEditPickTest( false ) )
						onmouseover=@Tip( "Pick from unmapped normal levels" )>Other</button>
					<button class="mode @(_editPickTest ? "on" : "")" onclick=@Click( () => SetEditPickTest( true ) )
						onmouseover=@Tip( "Pick from unmapped test levels" )>Test</button>
					<button class="mode back" onclick=@Click( CancelEdit )
						onmouseover=@Tip( "Back to the map" )>Back</button>
				</div>
			}
			else if ( Mode == PickerMode.Editor && Game.IsEditor )
			{
				@* Dev views: the map plus unmapped project levels, test levels and daily templates.
				   A standalone build shows NO tabs — the picker is always the grid of the player's
				   local levels (see CurrentView). *@
				<div class="modes">
					<button class="mode @(CurrentView == BrowserView.Map ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Map ) )
						onmouseover=@Tip( "Map levels" )>Map</button>
					<button class="mode @(CurrentView == BrowserView.Other ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Other ) )
						onmouseover=@Tip( "Levels not on map" )>Other</button>
					<button class="mode @(CurrentView == BrowserView.Test ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Test ) )
						onmouseover=@Tip( "Test levels" )>Test</button>
					<button class="mode @(CurrentView == BrowserView.Templates ? "on" : "")" onclick=@Click( () => SetMode( BrowserView.Templates ) )
						onmouseover=@Tip( "Daily-challenge obstacle templates" )>Templates</button>
					@if ( MapMode )
					{
						<button class="mode edit @(_editMode ? "on" : "")" onclick=@Click( ToggleEditMode )
							onmouseover=@Tip( () => _editMode
								? "Stop editing map"
								: "Edit map" )>Edit</button>
					}
				</div>
			}
			<button class="close" onclick=@Click( Close )>
                <div class="icon">close</div>
            </button>
		</div>

		@* Search row (every view, both picker modes): a fixed-size filter box at the top-left under
		   the title, live case-insensitive substring match on level name or id (templates: id only).
		   Typing a character's exact id or display name ALSO matches levels forcing that character
		   (and tints the text); in the editor picker a block type's exact name ("sad", "wind") likewise
		   matches levels spawning that block (its own tint — character wins when a name is both, e.g.
		   "mimic"). A red X clears it. The sort toggle shares this row on the sortable grid views. *@
		<div class="searchbar">
			<div class="search-box">
				<EditorTextEntry class=@("search-entry" + ( SearchCharacter is not null ? " character-match" : SearchBlockType is not null ? " block-match" : "" )) Value=@SearchText Placeholder="SEARCH"
					OnTextEdited=@((string v) => OnSearchEdited( v )) @ref="SearchEntry"></EditorTextEntry>
				@if ( HasSearch )
				{
					<button class="search-clear" onclick=@Click( ClearSearchBox )
						onmouseover=@Tip( "Clear search" )><div class="icon">close</div></button>
				}
			</div>
			@if ( ZoomVisible )
			{
				@* Map-mode zoom bar: scales the whole map layout so it can be taken in at once (out)
				   or read block-by-block (in). Shares the search row's right side with the sort
				   toggle — never both at once (the sort only shows on grid views). *@
				<div class="zoombar">
					<button class="zoom-btn" onclick=@Click( () => SetZoom( MapZoom - ZOOM_STEP ) )
						onmouseover=@Tip( "Zoom the map out" )><div class="icon">remove</div></button>
					<PixelSlider Compact=@true Min=@(ZOOM_MIN * 100f) Max=@(ZOOM_MAX * 100f) Step=@(5f)
						Value=@(MapZoom * 100f) OnValueChanged=@((float v) => SetZoom( v / 100f ))></PixelSlider>
					<button class="zoom-btn" onclick=@Click( () => SetZoom( MapZoom + ZOOM_STEP ) )
						onmouseover=@Tip( "Zoom the map in" )><div class="icon">add</div></button>
					<label class="zoom-label">@(( MapZoom * 100f ).ToString( "0" ))%</label>
				</div>
			}
			else if ( SortVisible )
			{
				@if ( TemplatesVisible )
				{
					@* One button cycling the three template sorts (A-Z → Recent → Weight); shows the
					   CURRENT mode, like the Other/Test toggle beside it shows the current order. *@
					<button class="sort on" onclick=@Click( CycleTemplateSort )
						onmouseover=@Tip( () => TemplateSortTip )>@TemplateSortLabel</button>
				}
				else
				{
					<button class="sort on" onclick=@Click( ToggleRecentFirst )
						onmouseover=@Tip( () => RecentFirst ? "Sorted newest first" : "Sorted alphabetically" )>@(RecentFirst ? "Recent" : "A-Z")</button>
				}
			}
		</div>

		@if ( GridVisible )
		{
			@* Windowed tiles (big grids only — see TileWindowingActive): every tile renders as a
			   fixed-size SHELL (face frame + caption), but the expensive face contents (the
			   LevelNodePreview subtree) only exist for shells inside the measured scroll window
			   (see UpdateTileWindow) — the engine tick/render walks visit every live panel every
			   frame, so panel COUNT is the cost, not what's drawn. *@
			<div class="grid" @ref="Grid">
				@{ int tileIndex = 0; }
				@if ( TemplatesVisible )
				{
					@foreach ( var template in GridTemplates )
					{
						var t = template;
						bool tileVisible = TileBodyVisible( tileIndex++ );
						<div class="tile" onclick=@Click( () => TemplatePicked( t.Id ), SfxType.MenuStart )
							 onmouseover=@Tip( () => TemplateTip( t ) )>
							<div class="face">
								@if ( tileVisible )
								{
									<LevelNodePreview Level=@TemplateDef( t ) CullOffscreen=@true></LevelNodePreview>
									@* Pick weight in the preview's top-right corner (kept off the name line);
									   mirror-capable templates (AllowMirrorX) get a swap glyph in the top-left;
									   always-spiked templates (AuthoredSpikes) get a spike glyph in the bottom-right;
									   templates with too few free spawn slots for the biggest daily block roll
									   get a red warning in the bottom-left. *@
									<label class="weight">@t.Weight</label>
									@if ( t.AllowMirrorX )
									{
										<label class="mirror">swap_horiz</label>
									}
									@if ( t.AuthoredSpikes )
									{
										<label class="authored-spikes">change_history</label>
									}
									@if ( FreeSpawnSlots( t ) < MaxDailyBlockCount )
									{
										<label class="slots-warning">warning</label>
									}
								}
							</div>
							<div class="caption">
								<label class="name">@t.Id</label>
							</div>
						</div>
					}
				}
				else
				{
					@foreach ( var lvl in GridLevels )
					{
						var l = lvl;
						bool tileVisible = TileBodyVisible( tileIndex++ );
						@* Editor picker only: hovering a level tile names its ID (the caption shows the
						   display name). The leaderboard picker's search grid stays tooltip-free. *@
						<div class="tile" onclick=@Click( () => TilePicked( l.Id ), SfxType.MenuStart )
							 onmouseover=@Tip( Mode == PickerMode.Editor ? l.Id : null )>
							<div class="face">
								@if ( tileVisible )
								{
									<LevelNodePreview Level=@l CullOffscreen=@true></LevelNodePreview>
									@if ( Characters.TryGet( l.ForcedCharacterId, out var forcedCharacter ) )
									{
										<div class="tile-forced-character @(forcedCharacter.Partner is null ? "" : "paired")" style="@TileForcedCharacterStyle( forcedCharacter )">
											<div class="icon-glow"></div>
											@if ( forcedCharacter.Partner is CharacterDef tilePartner )
											{
												<div class="sprite rear" style="@ForcedCharacterSpriteStyle( tilePartner )"></div>
											}
											<div class="sprite front" style="@ForcedCharacterSpriteStyle( forcedCharacter )"></div>
										</div>
									}
								}
							</div>
							<div class="caption">
								<label class="name" style="@GridNameStyle( l.Name )">@l.Name</label>
							</div>
						</div>
					}
				}
				@if ( NoResults )
				{
					<div class="no-results"><label>NO RESULTS</label></div>
				}
			</div>
		}
		else
		{
			<div class="map-scroll" @ref="MapScroll">
				<div class="map" style="@MapCanvasStyle()" @ref="MapCanvas">
					@* Connector lines first so the opaque node squares draw over them. *@
					@foreach ( var conn in LevelMap.Connections )
					{
						<div class="line" style="@LineStyle( conn )"></div>
					}
					@{ int mapNodeIndex = 0; }
					@foreach ( var node in LevelMap.Nodes )
					{
						var n = node;
						bool previewVisible = _mapPreviewWindow.Contains( mapNodeIndex++ );
						@* Map-node tooltip: EDIT mode explains the swap; otherwise the EDITOR picker
						   shows the node's level ID (the label above shows the display name). The
						   leaderboard picker's map stays tooltip-free. *@
						<div class="node-wrap @(n.IsMajor ? "major" : "side")" style="@NodeStyle( n )"
							 onclick=@Click( () => NodeClicked( n ), SfxType.MenuStart )
							 onmouseover=@Tip( () => EditModeActive ? $"Swap {n.Name} out for another level"
								: Mode == PickerMode.Editor ? n.LevelId : null )>
							<div class="node-name" style="@NodeNameWrapStyle()"><label style="@NodeNameStyle( n )">@n.Name</label></div>
							<div class="face" style="@NodeFaceStyle()">
								@if ( previewVisible )
								{
									<LevelNodePreview [email protected] CullOffscreen=@true></LevelNodePreview>
								}
								@* Forced character as a small badge inside the preview's lower-left (same
								   presentation as the Other/Test tiles), not beside the node like the
								   level-select screen — keeps the map compact in both picker modes. *@
								@if ( Characters.TryGet( n.Level?.ForcedCharacterId, out var forcedCharacter ) )
								{
									<div class="tile-forced-character @(forcedCharacter.Partner is null ? "" : "paired")" style="@NodeForcedCharacterStyle( n, forcedCharacter )">
										<div class="icon-glow"></div>
										@if ( forcedCharacter.Partner is CharacterDef nodePartner )
										{
											<div class="sprite rear" style="@ForcedCharacterSpriteStyle( nodePartner )"></div>
										}
										<div class="sprite front" style="@ForcedCharacterSpriteStyle( forcedCharacter )"></div>
									</div>
								}
								@* Music-override badge in the preview's lower-right (editor map only), red when
								   another mapped level plays the same track. *@
								@if ( MusicBadgesVisible && !string.IsNullOrWhiteSpace( n.Level?.Music ) )
								{
									<label class="music-badge @(MusicShared( n ) ? "dupe" : "")" style="@MusicIconStyle">music_note</label>
								}
							</div>
						</div>
					}
					@* EDIT mode: + buttons sit near-flush against every empty side of every node — a
					   cell shared by several nodes gets one + on EACH, and the added level CONNECTS to
					   the + button's anchor node; plus one above the top major to extend the spine.
					   X buttons straddle the top-right corner of deletable nodes. *@
					@if ( EditModeActive )
					{
						@foreach ( var cell in EmptyAdjacentCells() )
						{
							var c = cell;
							<div class="add-cell" style="@AddCellStyle( c.Node, c.Dx, c.Dy )"
								 onclick=@Click( () => BeginAddSide( c.Node, c.X, c.Y ) )
								 onmouseover=@Tip( () => $"Add a side level here, connected to {c.Node.Name}" )>
								<div class="icon" style="@AddIconStyle">add</div>
							</div>
						}
						@if ( LevelMap.Nodes.LastOrDefault( m => m.IsMajor ) is MapNode topMajor )
						{
							<div class="add-cell" style="@AddCellStyle( topMajor, 0, 1 )"
								 onclick=@Click( BeginAddMajor )
								 onmouseover=@Tip( "Extend the spine with a new major level" )>
								<div class="icon" style="@AddIconStyle">add</div>
							</div>
						}
						@* + buttons ON the connection lines: insert a level BETWEEN the two nodes. A
						   spine segment inserts a new major (everything above shifts up); a side
						   connection shifts the outward half of the group one cell over and the new
						   level takes the vacated cell. Only shown where the insert will succeed. *@
						@foreach ( var conn in LevelMap.Connections )
						{
							var c = conn;
							if ( CanInsertOn( c ) )
							{
								<div class="add-cell insert" style="@InsertStyle( c )"
									 onclick=@Click( () => BeginInsert( c ) )
									 onmouseover=@Tip( () => InsertTip( c ) )>
									<div class="icon" style="@AddIconStyle">add</div>
								</div>
							}
						}
						@foreach ( var node in LevelMap.Nodes )
						{
							var n = node;
							if ( CanDeleteNode( n ) )
							{
								<div class="delete-node" style="@DeleteNodeStyle( n )"
									 onclick=@Click( () => DeleteNode( n ) )
									 onmouseover=@Tip( () => $"Take {n.Name} off the map (the level file is kept)" )>
									<div class="icon" style="@DeleteIconStyle">close</div>
								</div>
							}
						}
					}
				</div>
			</div>
		}
	</div>
</root>

@code
{
	public enum PickerMode { Editor, Leaderboard }
	private enum BrowserView { Other, Map, Test, Templates }
	private enum TemplateSortMode { AtoZ, Recent, Weight }

	[Parameter] public PickerMode Mode { get; set; } = PickerMode.Editor;
	[Parameter] public Action<string> OnPick { get; set; }
	/// <summary>Editor picker only: a Templates-view pick reports the template id here instead of
	/// <see cref="OnPick"/> (the HUD loads it into the open level).</summary>
	[Parameter] public Action<string> OnPickTemplate { get; set; }
	[Parameter] public Action OnClose { get; set; }

	// Wraps a button action with a snappy UI click sound (mode toggles / close use the short MenuBlip,
	// picking a level passes MenuStart). Returns the wrapped Action for onclick=@Click(...).
	private static Action Click( Action a, SfxType sfx = SfxType.MenuBlip )
		=> () => { Audio.PlaySfx( sfx, 0.7f ); a?.Invoke(); };

	// ── hover tooltips (same pattern as LevelEditorHud) ───────────────────────────────────────────────
	// Controls record themselves + their text via onmouseover=@Tip( "..." ); the Tick poll below just
	// validates the stored panel is still hovered, so no mouseout handler is needed and text that
	// depends on state (edit mode, sort order) is picked up on the next rebuild. A null/empty tip means
	// "no tooltip here" — used by the map nodes, which only explain themselves in EDIT mode.
	// Named TooltipLayer, not Tooltip — Panel already has a Tooltip member of its own.
	private PixelTooltip TooltipLayer { get; set; }
	private Panel _tipPanel;
	private Func<string> _tipText;

	/// <summary>Fixed tooltip text.</summary>
	private Action<PanelEvent> Tip( string text )
		=> e => { _tipPanel = e.This; _tipText = () => text; };

	/// <summary>Tooltip text that can change while the control STAYS hovered, resolved per frame rather
	/// than snapshotted on hover — mouseover only fires when the hovered panel changes, so clicking a
	/// hovered toggle (Edit, the sort order) re-attaches this handler without re-running it and a
	/// snapshot would keep describing the state just left. Any non-literal tip belongs here.</summary>
	private Action<PanelEvent> Tip( Func<string> text )
		=> e => { _tipPanel = e.This; _tipText = text; };

	private void UpdateTooltipHover()
	{
		string text = _tipPanel.IsValid() && _tipPanel.HasHovered ? _tipText?.Invoke() : null;
		if ( string.IsNullOrEmpty( text ) ) TooltipLayer?.Hide();
		else TooltipLayer?.Show( text );
	}

	// The map-mode scroll region (null while in grid mode). Its offset is remembered for THIS play
	// session on the GameManager (GameManager.LevelBrowserScroll), so moving between screens restores it,
	// while a NEW play session (a fresh GameManager) opens centred + at the bottom.
	private Panel MapScroll { get; set; }
	private bool _scrollApplied;
	private bool _recentTimestampsRefreshed;
	private bool _templateTimestampsRefreshed;
	// Re-apply the initial offset for a few frames once the viewport is laid out (not latched on the first
	// valid frame) so a one-frame layout settle can't leave it half-applied.
	private int _scrollSettleFrames;
	private const int SCROLL_SETTLE_FRAMES = 4;

	// Map-layout constants in 1080-reference px (this ScreenPanel uses AutoScreenScale=ConsistentHeight,
	// so the same coordinate space as LevelSelectScreen — node positions/sizes carry over 1:1 at
	// MapZoom 1; the zoom bar multiplies everything below through the layout maths).
	private const float MAJOR_SIZE = 132f;
	private const float SIDE_SIZE = 84f;
	private const float LINE_THICKNESS = 10f;
	// Forced-character badge base sizes: grid tiles (150px) and map nodes (132/84px), scaled to fit.
	private const float TILE_CHAR_SIZE = 40f;
	private const float MAJOR_NODE_CHAR_SIZE = 36f;
	private const float SIDE_NODE_CHAR_SIZE = 26f;
	// Padding around the map bounds so edge nodes (and their labels) aren't clipped by the scroll area.
	private const float MAP_PAD = 120f;

	// Map-mode zoom (editor picker only — the leaderboard map stays 1:1): one factor multiplied
	// through ALL the layout maths (node sizes, spacing, lines, labels, edit buttons), 1 = the
	// level-select scale. Out far enough to take in the whole map, in far enough to read a level's
	// block types off its preview. Panel-local like the search string — the overlay unmounts on
	// close, so every open starts back at 100% (deliberately NOT persisted).
	private const float ZOOM_MIN = 0.4f;
	private const float ZOOM_MAX = 2f;
	private const float ZOOM_STEP = 0.1f;

	private float _mapZoom = 1f;

	private float MapZoom => Mode == PickerMode.Editor && Game.IsEditor ? _mapZoom : 1f;

	// The zoom bar shows whenever the map LAYOUT itself is on screen: any grid takeover (the inner
	// edit picker, a map-view search) hides it along with the map.
	private bool ZoomVisible => Mode == PickerMode.Editor && Game.IsEditor && MapMode && !GridVisible;

	/// <summary>Zoom-bar change: rescale the map around the viewport CENTRE — the centre's map point
	/// is converted back to unzoomed map coords with the OLD factor, banked as the (zoom-1) session
	/// scroll target, and applied by Tick's settle loop at the new zoom (a direct ScrollOffset write
	/// would clamp against the not-yet-relaid-out canvas when zooming in).</summary>
	private void SetZoom( float zoom )
	{
		zoom = Math.Clamp( zoom, ZOOM_MIN, ZOOM_MAX );
		float oldZoom = MapZoom;
		if ( MathF.Abs( zoom - oldZoom ) < 0.001f ) return;

		var mgr = GameManager.Instance;
		if ( MapScroll.IsValid() && mgr is not null )
		{
			float scale = MapScroll.ScaleFromScreen;
			float viewW = MapScroll.Box.Rect.Width * scale;
			float viewH = MapScroll.Box.Rect.Height * scale;
			if ( scale > 0f && viewW > 1f )
			{
				var offset = MapScroll.ScrollOffset * scale;   // screen px → reference px
				mgr.LevelBrowserScroll = NormalizeScroll( offset, viewW, viewH, oldZoom );
				mgr.LevelBrowserMapShown = true;
				_scrollApplied = false;
				_scrollSettleFrames = 0;
			}
		}
		_mapZoom = zoom;
		StateHasChanged();
	}

	// The session scroll bank (GameManager.LevelBrowserScroll) always holds the offset AS IF the map
	// were at zoom 1 — really "which map point sits at the viewport centre". The zoom resets to 100%
	// every open, so a raw zoomed offset would restore to the wrong part of the map; normalising at
	// save time and re-projecting at restore time keeps the centred point fixed across zoom changes,
	// remounts AND reopens alike.
	private Vector2 NormalizeScroll( Vector2 offset, float viewW, float viewH, float zoom )
	{
		float centreX = ( offset.x + viewW * 0.5f - HalfSpanAt( zoom ) ) / zoom;
		float centreY = ( offset.y + viewH * 0.5f - MapPad ) / zoom;
		return new Vector2( centreX + HalfSpanAt( 1f ) - viewW * 0.5f, centreY + MapPad - viewH * 0.5f );
	}

	private Vector2 DenormalizeScroll( Vector2 banked, float viewW, float viewH, float zoom )
	{
		float centreX = banked.x + viewW * 0.5f - HalfSpanAt( 1f );
		float centreY = banked.y + viewH * 0.5f - MapPad;
		return new Vector2( centreX * zoom + HalfSpanAt( zoom ) - viewW * 0.5f, centreY * zoom + MapPad - viewH * 0.5f );
	}

	private BrowserView CurrentView
	{
		get
		{
			if ( Mode == PickerMode.Leaderboard )
				return BrowserView.Map;
			// The standalone editor picker has no tabs at all: it's ALWAYS the grid of the player's
			// local levels (BrowserView.Other lists them there) — the map and the dev views are
			// editor-only, whatever view preference an editor session may have persisted.
			if ( !Game.IsEditor )
				return BrowserView.Other;
			return EditorPrefs.Current.LevelBrowserMapMode ? BrowserView.Map
				: EditorPrefs.Current.LevelBrowserTemplatesMode ? BrowserView.Templates
				: EditorPrefs.Current.LevelBrowserTestMode ? BrowserView.Test : BrowserView.Other;
		}
	}
	private bool MapMode => CurrentView == BrowserView.Map;
	private bool RecentFirst => EditorPrefs.Current.LevelBrowserOtherRecentFirst;
	private TemplateSortMode TemplateSort
		=> (TemplateSortMode)Math.Clamp( EditorPrefs.Current.LevelBrowserTemplateSort, 0, 2 );

	// ── map EDIT mode (editor picker + Game.IsEditor only) ────────────────────────────────────────────
	// _editMode toggles the map's edit affordances (node click = replace, + cells = add, + on a
	// connection line = insert between). A pending action switches the window to the inner tile-grid
	// picker over unmapped levels (Other/Test pools via _editPickTest); picking dispatches the
	// editor-assembly map writer ConCmd, which rewrites Assets/levelmap.json and hot-rebuilds LevelMap.
	private enum EditAction { None, Replace, Add, AddMajor, InsertMajor, InsertSide }
	private bool _editMode;
	private EditAction _editAction;
	private string _editTargetId;              // Replace: the node being replaced / InsertMajor: the major above the insertion
	private string _editMajorId;               // Add + InsertSide: the owning major...
	private int _editCellX, _editCellY;        // ...and the target cell (Add) or the connection's FROM cell (InsertSide)
	private int _editDx, _editDy;              // InsertSide: the connection's step from the FROM cell
	private int _editFromDx, _editFromDy;      // Add: step from the new cell toward the + button's anchor node (its predecessor)
	private bool _editPickTest;                // inner picker pool: Other (false) or Test (true)

	private bool EditModeActive => _editMode && MapMode && Mode == PickerMode.Editor && Game.IsEditor
		&& _editAction == EditAction.None && !SearchOverridesMap;
	// The tile grid shows for the Other/Test/Templates views, for the edit picker (which overrides
	// the map) AND for a map-view search (which lists the matching mapped levels as tiles — a map
	// layout with filtered-out nodes would be full of holes). The edit picker always picks from the
	// LEVEL pools, never the templates.
	private bool GridVisible => _editAction != EditAction.None || !MapMode || SearchOverridesMap;
	// Sorting applies to the unmapped Other/Test pools and the templates; the map's search grid keeps
	// map order and the leaderboard picker has no sort at all.
	private bool SortVisible => Mode == PickerMode.Editor && GridVisible && !SearchOverridesMap;
	private bool TemplatesVisible => _editAction == EditAction.None && CurrentView == BrowserView.Templates;

	// ── search filter (every view, both picker modes) ─────────────────────────────────────────────────
	// Live, case-insensitive substring match on level NAME or ID (templates match on id only — their
	// id is their display name; the LEADERBOARD picker matches names only, ids being dev-facing).
	// The string lives on this panel, which unmounts on close — so every open (either picker mode)
	// starts blank, including after picking a level. Within one open it survives tab switches and the
	// map ↔ search-grid swap; leaving the inner edit picker and turning EDIT mode on clear it
	// explicitly (see ClearSearchBox) so the map view can come back.
	private string SearchText { get; set; } = "";
	private EditorTextEntry SearchEntry { get; set; }

	/// <summary>Clear the search filter — the state AND the box itself. Every clear (the red X, edit
	/// picker exits, the EDIT toggle) comes through here. The state change routes through
	/// <see cref="OnSearchEdited"/> so the map view's scroll bank/restore applies; the box then needs
	/// stomping separately because TextEntry.Value refuses to apply while the entry HAS FOCUS (engine
	/// behaviour: a bound value never stomps text mid-edit) and clicking a tile or button doesn't
	/// blur it — .Text writes unconditionally, and the Blur stops the next keystroke re-filtering.</summary>
	private void ClearSearchBox()
	{
		OnSearchEdited( "" );
		if ( SearchEntry.IsValid() )
		{
			SearchEntry.Text = "";
			SearchEntry.Blur();
		}
	}

	private bool HasSearch => !string.IsNullOrEmpty( SearchText );
	/// <summary>Searching the map view swaps the map layout for a plain tile grid of the MATCHING
	/// mapped levels; clearing the box restores the map (and its banked scroll offset).</summary>
	private bool SearchOverridesMap => MapMode && _editAction == EditAction.None && HasSearch;

	/// <summary>The character the search text names EXACTLY (case-insensitive id or display name),
	/// or null. While live, levels forcing that character match too (both picker modes) and the
	/// entry text tints via .character-match.</summary>
	private CharacterDef SearchCharacter
		=> Characters.TryGetByIdOrName( SearchText, out var character ) ? character : null;

	/// <summary>The block type the search text names EXACTLY (case-insensitive enum name — "sad",
	/// "wind", "straightlaser"), or null. While live, levels spawning that block (exact list, pool
	/// or pinned) match too and the entry text tints via .block-match. EDITOR picker only — type
	/// names are dev-facing, like ids.</summary>
	private BlockType? SearchBlockType
	{
		get
		{
			if ( Mode != PickerMode.Editor || !HasSearch ) return null;
			foreach ( var type in System.Enum.GetValues<BlockType>() )
				if ( string.Equals( type.ToString(), SearchText.Trim(), StringComparison.OrdinalIgnoreCase ) )
					return type;
			return null;
		}
	}

	/// <summary>Whether the level can spawn the block type: in its exact list, its random pool, or
	/// pinned to a spawn slot.</summary>
	private static bool LevelHasBlock( LevelDef l, BlockType type )
		=> ( l.Blocks?.Contains( type ) ?? false )
		|| ( l.Pool?.Contains( type ) ?? false )
		|| ( l.SpawnPins?.Any( p => p is not null && p.Type == type ) ?? false );

	// Ids only count for the EDITOR picker — they're dev-facing, so the leaderboard picker matches
	// display names alone (a player searching "cave" shouldn't hit a level whose ID happens to match).
	private bool MatchesSearch( LevelDef l ) => !HasSearch
		|| ( l.Name?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false )
		|| ( Mode == PickerMode.Editor && ( l.Id?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false ) )
		|| ( SearchCharacter is not null && l.ForcedCharacterId == SearchCharacter.Id )
		|| ( SearchBlockType is BlockType blockType && LevelHasBlock( l, blockType ) );

	private bool MatchesSearch( string id )
		=> !HasSearch || ( id?.Contains( SearchText, StringComparison.OrdinalIgnoreCase ) ?? false );

	private bool NoResults => HasSearch && ( TemplatesVisible ? !GridTemplates.Any() : !GridLevels.Any() );

	private void OnSearchEdited( string value )
	{
		value ??= "";
		if ( value == SearchText ) return;
		if ( MapMode && _editAction == EditAction.None )
		{
			// The map panel unmounts while a search is active (the grid takes over) — bank its scroll
			// on the way out and re-apply it when the search clears, same as the inner edit picker.
			if ( !HasSearch && value.Length > 0 ) SaveScroll();
			else if ( HasSearch && value.Length == 0 ) { _scrollApplied = false; _scrollSettleFrames = 0; }
		}
		SearchText = value;
		StateHasChanged();
	}


	private string TitleText => _editAction switch
	{
		EditAction.Replace => "REPLACE LEVEL",
		EditAction.Add => "ADD LEVEL",
		EditAction.AddMajor => "ADD MAJOR LEVEL",
		EditAction.InsertMajor or EditAction.InsertSide => "INSERT LEVEL",
		_ => Mode != PickerMode.Editor ? "SELECT LEVEL"
			: TemplatesVisible ? "LOAD TEMPLATE" : "LOAD LEVEL",
	};

	private IEnumerable<LevelDef> GridLevels
	{
		get
		{
			// Map view + search: the grid lists the MAPPED levels that match, in map order (this is
			// only reached through SearchOverridesMap — without a search the map layout renders instead).
			if ( _editAction == EditAction.None && MapMode )
				return LevelMap.Nodes.Select( n => n.Level ).Where( l => l is not null && MatchesSearch( l ) );
			return NonMapLevelsFor( _editAction != EditAction.None ? _editPickTest : CurrentView == BrowserView.Test )
				.Where( MatchesSearch );
		}
	}

	private IEnumerable<LevelDef> NonMapLevelsFor( bool test )
	{
		// Dev picker: unmapped project levels split by the test flag. Standalone: the grid is the
		// LOCAL view — only the player's own data-folder levels, whatever their test flag (unmapped
		// shipped levels are dev work-in-progress and stay hidden).
		var levels = Levels.BrowserLevels.Where( l => l is not null && LevelMap.Find( l.Id ) is null
			&& ( Game.IsEditor ? l.IsTest == test : l.IsLocal ) );
		return RecentFirst
			? levels.OrderByDescending( l => l.SourceFileTimeUtc ).ThenBy( l => l.Name ?? l.Id, StringComparer.OrdinalIgnoreCase )
			: levels.OrderBy( l => l.Name ?? l.Id, StringComparer.OrdinalIgnoreCase );
	}

	private void SetMode( BrowserView view )
	{
		if ( Mode != PickerMode.Editor ) return;
		if ( CurrentView == view ) return;
		if ( MapMode ) SaveScroll();
		EditorPrefs.Current.LevelBrowserMapMode = view == BrowserView.Map;
		EditorPrefs.Current.LevelBrowserTestMode = view == BrowserView.Test;
		EditorPrefs.Current.LevelBrowserTemplatesMode = view == BrowserView.Templates;
		EditorPrefs.Save();
		_scrollApplied = false;                   // re-restore the offset when the map panel remounts
		_scrollSettleFrames = 0;
		StateHasChanged();
	}

	private void ToggleRecentFirst()
	{
		if ( Mode != PickerMode.Editor ) return;
		var recentFirst = !EditorPrefs.Current.LevelBrowserOtherRecentFirst;
		if ( EditorPrefs.Current.LevelBrowserOtherRecentFirst == recentFirst ) return;
		EditorPrefs.Current.LevelBrowserOtherRecentFirst = recentFirst;
		EditorPrefs.Save();
		if ( recentFirst ) RefreshRecentTimestamps();
		StateHasChanged();
	}

	// ── Templates view: sorting + previews ────────────────────────────────────────────────────────────

	private string TemplateSortLabel => TemplateSort switch
	{
		TemplateSortMode.Recent => "Recent",
		TemplateSortMode.Weight => "Weight",
		_ => "A-Z",
	};

	private string TemplateSortTip => TemplateSort switch
	{
		TemplateSortMode.Recent => "Sorted newest first — click for highest weight first",
		TemplateSortMode.Weight => "Sorted highest weight first — click for A-Z",
		_ => "Sorted A-Z — click for newest first",
	};

	private void CycleTemplateSort()
	{
		if ( Mode != PickerMode.Editor ) return;
		var next = (TemplateSortMode)( ( (int)TemplateSort + 1 ) % 3 );
		EditorPrefs.Current.LevelBrowserTemplateSort = (int)next;
		EditorPrefs.Save();
		if ( next == TemplateSortMode.Recent ) RefreshTemplateTimestamps();
		StateHasChanged();
	}

	private IEnumerable<ObstacleTemplate> GridTemplates => ( TemplateSort switch
	{
		TemplateSortMode.Recent => DailyTemplates.All
			.OrderByDescending( t => TemplateTime( t.Id ) ).ThenBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
		TemplateSortMode.Weight => DailyTemplates.All
			.OrderByDescending( t => t.Weight ).ThenBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
		_ => DailyTemplates.All.OrderBy( t => t.Id, StringComparer.OrdinalIgnoreCase ),
	} ).Where( t => MatchesSearch( t.Id ) );

	private static long TemplateTime( string id )
		=> EditorPrefs.Current.LevelBrowserTemplateTimes is { } times && times.TryGetValue( id, out var time ) ? time : 0L;

	// The largest block count a daily can roll: the top BlockCountWeights entry plus the ExtraBlocks
	// modifier's +1 (see DailyLevelGenerator's count roll). The generator clamps the roll to a
	// template's FREE spawn slots, so a template below this silently swallows the big rolls — the
	// grid flags those with a red warning (pinned slots don't count; pins spawn on top of the roll).
	private static int MaxDailyBlockCount => DailyGenConfig.BlockCountWeights.Max( e => e.Count ) + 1;

	private static int FreeSpawnSlots( ObstacleTemplate t )
		=> t.SpawnPositions.Length - t.SpawnPins.Count( p => p is not null );

	private static string TemplateTip( ObstacleTemplate t )
	{
		int free = FreeSpawnSlots( t );
		return free < MaxDailyBlockCount
			? $"Only {free} free spawn slot{( free == 1 ? "" : "s" )} — a daily can roll up to {MaxDailyBlockCount} blocks"
			: "Load this layout; blocks and colours are kept";
	}

	// Per-template preview defs: the template's LAYOUT dressed in the CURRENT editor level's colour
	// scheme (templates are layout-only, and a pick keeps the open level's cosmetics — so the tiles
	// show exactly what a pick produces). The base def and per-template defs are cached for the
	// overlay's lifetime: the editor level can't change while the overlay covers the editor, and
	// LevelNodePreview rebuilds per reference anyway.
	private readonly Dictionary<string, LevelDef> _templateDefs = new();
	private LevelDef _templateBase;

	private LevelDef TemplateBase => _templateBase ??=
		( GameManager.Instance?.Stage as LevelEditorStage )?.Level?.ToLevelDef() ?? new LevelDef();

	private LevelDef TemplateDef( ObstacleTemplate t )
	{
		if ( _templateDefs.TryGetValue( t.Id, out var cached ) ) return cached;

		var b = TemplateBase;
		var def = new LevelDef
		{
			Id = $"daily-template-{t.Id}",
			Name = t.Id,

			// Layout from the template (same mapping as DailyTemplates.ApplyToEditor + ToLevelDef).
			Obstacles = t.Obstacles,
			VisionBlockers = t.VisionObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
			Fences = t.FenceObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
			Glass = t.GlassObstacles.Where( i => i >= 0 && i < t.Obstacles.Length ).Select( i => t.Obstacles[i] ).ToArray(),
			SpikedWalls = t.SpikeableWalls,
			SpikedObstacleSides = t.SpikeableObstacleSides
				.Where( s => s.Obstacle >= 0 && s.Obstacle < t.Obstacles.Length )
				.Select( s => new ObstacleSpikeSpec( t.Obstacles[s.Obstacle], s.Sides ) )
				.ToArray(),
			SpawnPositions = t.SpawnPositions,
			SpawnPins = t.SpawnPins,
			PlayerSpawns = t.PlayerSpawns,
			Coins = t.Coins,
			AlternatePlayfieldRects = t.AlternatePlayfieldRects,
			AlternatePlayfieldEnabled = t.AlternatePlayfieldRects.Length > 0,

			// Cosmetics from the open editor level.
			WallColor = b.WallColor,
			OutOfBoundsColor = b.OutOfBoundsColor,
			CheckerboardColor = b.CheckerboardColor,
			CheckerboardSecondColor = b.CheckerboardSecondColor,
			BackgroundBlockColor = b.BackgroundBlockColor,
			BackgroundBlockColors = b.BackgroundBlockColors,
			FenceColor = b.FenceColor,
			GlassColor = b.GlassColor,
			BackgroundBlockScale = b.BackgroundBlockScale,
			BackgroundBlockDensity = b.BackgroundBlockDensity,
			BackgroundBlockOpacity = b.BackgroundBlockOpacity,
			BackgroundDriftSpeed = b.BackgroundDriftSpeed,
			BackgroundDriftBias = b.BackgroundDriftBias,
			PlayfieldPattern = b.PlayfieldPattern,
			PlayfieldCellScale = b.PlayfieldCellScale,
			PlayfieldPatternRows = b.PlayfieldPatternRows,
			AlternateCheckerboardColor = b.AlternateCheckerboardColor,
			AlternateCheckerboardSecondColor = b.AlternateCheckerboardSecondColor,
			AlternatePlayfieldPattern = b.AlternatePlayfieldPattern,
			AlternatePlayfieldCellScale = b.AlternatePlayfieldCellScale,
			AlternatePlayfieldPatternRows = b.AlternatePlayfieldPatternRows,
		};
		_templateDefs[t.Id] = def;
		return def;
	}

	private void TemplatePicked( string id )
	{
		OnPickTemplate?.Invoke( id );
		OnClose?.Invoke();
	}

	private void RefreshRecentTimestamps()
	{
		if ( _recentTimestampsRefreshed ) return;
		_recentTimestampsRefreshed = true;
		// The file-stat command lives in the editor assembly; standalone local levels get their
		// timestamps stamped into EditorPrefs at save time instead (see LocalLevels.Save).
		if ( Game.IsEditor ) ConsoleSystem.Run( "level_refresh_timestamps" );
		StateHasChanged();
	}

	private void RefreshTemplateTimestamps()
	{
		if ( _templateTimestampsRefreshed ) return;
		_templateTimestampsRefreshed = true;
		if ( Game.IsEditor ) ConsoleSystem.Run( "daily_template_refresh_timestamps" );
		StateHasChanged();
	}

	private void Pick( string id )
	{
		SaveScroll();
		OnPick?.Invoke( id );
		OnClose?.Invoke();
	}

	public void RequestClose() => Close();

	private void Close()
	{
		SaveScroll();
		OnClose?.Invoke();
	}

	// ── EDIT-mode handlers ────────────────────────────────────────────────────────────────────────────
	private void ToggleEditMode()
	{
		_editMode = !_editMode;
		// Turning EDIT mode ON with a map-tab search active would look dead: the edit affordances only
		// exist on the map layout, which the search grid replaces. Clear it so the editable map
		// appears immediately.
		if ( _editMode )
			ClearSearchBox();
		StateHasChanged();
	}

	/// <summary>Map node click: in EDIT mode it opens the replacement picker; otherwise it picks the
	/// level as before.</summary>
	private void NodeClicked( MapNode n )
	{
		if ( !EditModeActive )
		{
			Pick( n.LevelId );
			return;
		}

		SaveScroll();   // the map unmounts while the inner picker shows; restore its scroll on return
		_editAction = EditAction.Replace;
		_editTargetId = n.LevelId;
		StateHasChanged();
	}

	/// <summary>A + cell click: open the add picker for grid cell (<paramref name="x"/>,<paramref name="y"/>)
	/// of <paramref name="anchor"/>'s major. The anchor (the node the + hugs) becomes the new level's
	/// predecessor, so the drawn connection follows the button that was clicked.</summary>
	private void BeginAddSide( MapNode anchor, int x, int y )
	{
		SaveScroll();
		_editAction = EditAction.Add;
		_editMajorId = anchor.Major.LevelId;
		_editCellX = x;
		_editCellY = y;
		_editFromDx = anchor.CellX - x;
		_editFromDy = anchor.CellY - y;
		StateHasChanged();
	}

	/// <summary>The + above the top major: open the add picker for a new spine-extending major.</summary>
	private void BeginAddMajor()
	{
		SaveScroll();
		_editAction = EditAction.AddMajor;
		StateHasChanged();
	}

	/// <summary>A connection-line + click: open the insert picker for that connection — a spine
	/// segment inserts a new major below the upper one, a side connection inserts into the side grid
	/// (the outward half shifts a cell over).</summary>
	private void BeginInsert( MapConnection conn )
	{
		SaveScroll();
		if ( conn.To.IsMajor )
		{
			_editAction = EditAction.InsertMajor;
			_editTargetId = conn.To.LevelId;
		}
		else
		{
			_editAction = EditAction.InsertSide;
			_editMajorId = conn.To.Major.LevelId;
			_editCellX = conn.From.CellX;
			_editCellY = conn.From.CellY;
			_editDx = conn.To.CellX - conn.From.CellX;
			_editDy = conn.To.CellY - conn.From.CellY;
		}
		StateHasChanged();
	}

	private void CancelEdit()
	{
		_editAction = EditAction.None;
		// Any search text was typed for the INNER picker's grid — an edit action can only start from
		// the rendered map, which a map-view search replaces with the grid, so the search was empty on
		// the way in. Clear it so the map actually returns (whether the edit committed or backed out);
		// otherwise SearchOverridesMap keeps the window stuck in grid mode.
		ClearSearchBox();
		_scrollApplied = false;   // re-restore the saved map offset when the map panel remounts
		_scrollSettleFrames = 0;
		StateHasChanged();
	}

	private void SetEditPickTest( bool test )
	{
		if ( _editPickTest == test ) return;
		_editPickTest = test;
		StateHasChanged();
	}

	/// <summary>Tile click in the grid: normally picks the level to load; with a pending edit it
	/// commits the edit instead — the ConCmd lives in the editor assembly (LevelMapJsonWriter), the
	/// only place that can write Assets/levelmap.json. Level ids are single console-safe tokens, so
	/// the string command form is fine (same pattern as daily_template_save).</summary>
	private void TilePicked( string id )
	{
		switch ( _editAction )
		{
			case EditAction.None:
				Pick( id );
				return;
			case EditAction.Replace:
				ConsoleSystem.Run( $"map_replace_level {_editTargetId} {id}" );
				break;
			case EditAction.Add:
				ConsoleSystem.Run( $"map_add_side {_editMajorId} {_editCellX} {_editCellY} {id} {_editFromDx} {_editFromDy}" );
				break;
			case EditAction.AddMajor:
				ConsoleSystem.Run( $"map_add_major {id}" );
				break;
			case EditAction.InsertMajor:
				ConsoleSystem.Run( $"map_insert_major {_editTargetId} {id}" );
				break;
			case EditAction.InsertSide:
				ConsoleSystem.Run( $"map_insert_side {_editMajorId} {_editCellX} {_editCellY} {_editDx} {_editDy} {id}" );
				break;
		}
		CancelEdit();
	}

	/// <summary>Backdrop click: back out of the inner edit picker if it's open, else dismiss.</summary>
	private void BackdropClicked()
	{
		if ( _editAction != EditAction.None )
		{
			CancelEdit();
			return;
		}
		Close();
	}

	/// <summary>One + button per (node, empty adjacent cell) pair, rendered near-flush against the
	/// node's edge. A cell shared by several nodes gets a + on EACH of them — they target the same
	/// cell but the anchor node becomes the new level's predecessor, so which + you click decides
	/// which connection is drawn. Column 0 is reserved for the spine, and adjacency never crosses
	/// into another major's group (cells are per-major, matching how the map builds).</summary>
	private static IEnumerable<(MapNode Node, int X, int Y, int Dx, int Dy)> EmptyAdjacentCells()
	{
		var occupied = new HashSet<(MapNode major, int x, int y)>();
		foreach ( var n in LevelMap.Nodes )
			occupied.Add( (n.Major, n.CellX, n.CellY) );

		foreach ( var n in LevelMap.Nodes )
		{
			foreach ( var (dx, dy) in CELL_NEIGHBOURS )
			{
				int x = n.CellX + dx, y = n.CellY + dy;
				if ( x == 0 ) continue;   // spine column
				if ( occupied.Contains( (n.Major, x, y) ) ) continue;
				yield return (n, x, y, dx, dy);
			}
		}
	}

	private static readonly (int dx, int dy)[] CELL_NEIGHBOURS = { (1, 0), (-1, 0), (0, 1), (0, -1) };

	/// <summary>Whether EDIT mode offers an insert + on this connection. Spine segments always take
	/// one (the majors above just shift up); a side connection needs the outward half of the group to
	/// shift a cell over without landing on the spine column or stranding anyone — mirrors the
	/// map_insert_side validation (shared <see cref="LevelMap.CanInsertSideCell"/>), so the + only
	/// shows where the insert will actually succeed.</summary>
	private static bool CanInsertOn( MapConnection conn )
	{
		if ( conn.To.IsMajor )
			return true;

		var cells = LevelMap.Nodes
			.Where( m => !m.IsMajor && m.Major == conn.To.Major )
			.Select( m => (m.CellX, m.CellY) )
			.ToList();
		return LevelMap.CanInsertSideCell( cells, conn.From.CellX, conn.From.CellY,
			conn.To.CellX - conn.From.CellX, conn.To.CellY - conn.From.CellY, out _, out _ );
	}

	private static string InsertTip( MapConnection conn ) => conn.To.IsMajor
		? $"Insert a new major level between {conn.From.Name} and {conn.To.Name} — the spine above shifts up"
		: $"Insert a level between {conn.From.Name} and {conn.To.Name} — the levels beyond shift over";

	/// <summary>Whether EDIT mode offers an X on this node. A side level can go whenever the gap it
	/// leaves can be closed — mid-chain deletes slide the outward half of the group one cell back
	/// (shared <see cref="LevelMap.CanRemoveSideCell"/>, the inverse of the insert shift, preferring
	/// the node's own predecessor axis so junctions close along the drawn connection); a major must
	/// have no side levels left and never be the last one on the spine. Mirrors the map_remove_level
	/// validation — and the candidate SET is preference-independent — so the X only shows where the
	/// delete will actually succeed.</summary>
	private static bool CanDeleteNode( MapNode n )
	{
		if ( n.IsMajor )
			return LevelMap.Nodes.Count( m => m.IsMajor ) > 1
				&& !LevelMap.Nodes.Any( m => !m.IsMajor && m.Major == n );

		var cells = LevelMap.Nodes
			.Where( m => !m.IsMajor && m.Major == n.Major && m != n )
			.Select( m => (m.CellX, m.CellY) )
			.ToList();
		var pred = n.Predecessor;
		return LevelMap.CanRemoveSideCell( cells, n.CellX, n.CellY,
			pred is null ? 0 : n.CellX - pred.CellX, pred is null ? 0 : n.CellY - pred.CellY, out _, out _ );
	}

	private static void DeleteNode( MapNode n )
		=> ConsoleSystem.Run( $"map_remove_level {n.LevelId}" );

	// Persist the live map scroll offset for THIS play session on the GameManager (in-memory, reference
	// px, normalised to zoom 1 — see NormalizeScroll). ScrollOffset is SCREEN px, so × ScaleFromScreen
	// → reference px (window-size independent).
	private void SaveScroll()
	{
		if ( MapScroll is null ) return;
		var mgr = GameManager.Instance;
		if ( mgr is null ) return;
		float scale = MapScroll.ScaleFromScreen;
		float viewW = MapScroll.Box.Rect.Width * scale;
		float viewH = MapScroll.Box.Rect.Height * scale;
		mgr.LevelBrowserScroll = NormalizeScroll( MapScroll.ScrollOffset * scale, viewW, viewH, MapZoom );
		mgr.LevelBrowserMapShown = true;
	}

	// Set the map scroll once the viewport is laid out. FIRST view of a play session (a fresh
	// GameManager) → centre the spine (it sits at CanvasW/2) + pin to the BOTTOM; LATER views this
	// session → restore the in-session offset. Re-applied for a few frames so a late layout settle can't
	// strand a half-applied offset.
	public override void Tick()
	{
		base.Tick();
		UpdateTooltipHover();
		UpdateTileWindow();
		UpdateMapPreviewWindow();
		if ( Mode == PickerMode.Editor && GridVisible )
		{
			if ( TemplatesVisible && TemplateSort == TemplateSortMode.Recent ) RefreshTemplateTimestamps();
			else if ( !TemplatesVisible && RecentFirst ) RefreshRecentTimestamps();
		}
		if ( !MapMode || MapScroll is null || _scrollApplied ) return;

		// Wait until the scroll VIEWPORT is laid out: its width drives the horizontal-centre maths and,
		// once it (and its explicitly-sized .map child) are laid out, the panel's scroll range is known so
		// a bottom-clamp actually takes. Before that, any offset we set clamps against a stale (0) range.
		float scale = MapScroll.ScaleFromScreen;              // screen px × scale = reference px
		float viewW = MapScroll.Box.Rect.Width * scale;       // viewport width in reference px
		if ( scale <= 0f || viewW <= 1f ) return;

		var mgr = GameManager.Instance;

		// IMPORTANT: ScrollOffset is in SCREEN px, but the canvas / centre maths are in REFERENCE px, so
		// divide the reference-px target by the scale before assigning. A large Y clamps to the max scroll
		// (bottom = start of the progression). The banked offset is stored at zoom 1 (see NormalizeScroll)
		// and re-projected here at the CURRENT zoom.
		bool firstShow = mgr is null || !mgr.LevelBrowserMapShown;
		float viewH = MapScroll.Box.Rect.Height * scale;
		float targetXRef = MathF.Max( 0f, CanvasW * 0.5f - viewW * 0.5f );
		Vector2 target = firstShow
			? new Vector2( targetXRef / scale, 1_000_000f )
			: DenormalizeScroll( mgr.LevelBrowserScroll, viewW, viewH, MapZoom ) / scale;

		MapScroll.ScrollOffset = target;

		// Re-apply for a few frames so any one-frame layout settle can't leave it half-applied, then latch.
		if ( ++_scrollSettleFrames >= SCROLL_SETTLE_FRAMES )
		{
			_scrollApplied = true;
			if ( mgr is not null ) mgr.LevelBrowserMapShown = true;
		}
	}

	// ── windowed tiles: only shells near the scroll viewport instantiate their preview contents ──────
	// The engine's per-frame tick and render-descriptor walks visit EVERY live panel, scrolled out of
	// view or not, so a 250-template grid at ~60 panels per preview stutters on panel count alone.
	// The grid measures its tile SHELLS (always rendered, fixed size) against the viewport and keeps
	// a contiguous index window. Windowing only pays off on BIG grids — small ones build fully in a
	// frame anyway and windowing costs a re-render per scroll step — so it arms for the template
	// library always, for the Other view (the standalone player-level library) past 100 levels, and
	// never for Test, the leaderboard picker, the edit picker, or a map search. The map uses its
	// own two-dimensional preview window below.

	private Panel MapCanvas { get; set; }
	private HashSet<int> _mapPreviewWindow = new();
	private HashSet<int> _nextMapPreviewWindow = new();

	// Keep node shells, labels and edit controls mounted so layout and scroll restoration do not
	// depend on previews. As with the grid, only the expensive preview subtrees are windowed.
	private void UpdateMapPreviewWindow()
	{
		_nextMapPreviewWindow.Clear();
		// Wait for the initial scroll restore before creating thumbnails at the opening position.
		if ( !GridVisible && _scrollApplied && MapScroll.IsValid() && MapCanvas.IsValid()
			&& MapScroll.Box.Rect.Height > 0f )
		{
			float margin = TILE_PREFETCH * MapScroll.ScaleToScreen;
			var view = MapScroll.Box.Rect.Grow( margin, margin );
			int index = 0;
			foreach ( var child in MapCanvas.Children )
			{
				if ( !child.HasClass( "node-wrap" ) ) continue;
				if ( child.Box.Rect.Width > 0f && view.IsInside( child.Box.Rect ) )
					_nextMapPreviewWindow.Add( index );
				index++;
			}
		}
		if ( _mapPreviewWindow.SetEquals( _nextMapPreviewWindow ) ) return;
		( _mapPreviewWindow, _nextMapPreviewWindow ) = ( _nextMapPreviewWindow, _mapPreviewWindow );
		StateHasChanged();
	}

	// One row of grid tiles, in reference px: contents build this far outside the viewport so they
	// exist by the time they scroll in.
	private const float TILE_PREFETCH = 200f;
	private const int TILE_WINDOW_MIN_LEVELS = 100;

	private Panel Grid { get; set; }
	private int _tileWindowFirst, _tileWindowLast = -1;   // inclusive shell-index window; empty until measured
	private bool _tileWindowingActive;                    // cached once per tick — see TileWindowingActive

	// Evaluated once per tick (the Other branch counts the whole pool): the cached flag is what
	// TileBodyVisible reads per tile at render time. A view switch renders one frame on the stale
	// flag/window and corrects on the next tick's measure.
	private bool TileWindowingActive => TemplatesVisible
		|| ( Mode == PickerMode.Editor && _editAction == EditAction.None && !MapMode
			&& CurrentView == BrowserView.Other && OtherLevelCount > TILE_WINDOW_MIN_LEVELS );

	// The Other view's pool size (same filter as NonMapLevelsFor, minus the sort — this runs per tick).
	private static int OtherLevelCount => Levels.BrowserLevels.Count( l => l is not null
		&& LevelMap.Find( l.Id ) is null && ( Game.IsEditor ? !l.IsTest : l.IsLocal ) );

	private bool TileBodyVisible( int index )
		=> !_tileWindowingActive || ( index >= _tileWindowFirst && index <= _tileWindowLast );

	// The empty window on a fresh grid (nothing measured until the shells are laid out) is what makes
	// opening fast: the first render is shells only, the visible previews build on the next tick.
	private void UpdateTileWindow()
	{
		bool active = TileWindowingActive;
		int first = 0, last = -1;
		if ( GridVisible && active && Grid is { } grid && grid.Box.Rect.Height > 0f )
		{
			float margin = TILE_PREFETCH * grid.ScaleToScreen;
			var view = grid.Box.Rect.Grow( 0f, margin );
			int index = 0;
			foreach ( var child in grid.Children )
			{
				if ( !child.HasClass( "tile" ) ) continue;
				if ( child.Box.Rect.Height > 0f && view.IsInside( child.Box.Rect ) )
				{
					if ( last < 0 ) first = index;
					last = index;
				}
				index++;
			}
		}
		if ( active == _tileWindowingActive && first == _tileWindowFirst && last == _tileWindowLast ) return;
		_tileWindowingActive = active;
		_tileWindowFirst = first;
		_tileWindowLast = last;
		StateHasChanged();
	}

	// ── map-mode layout (map space is +Y up; flip to CSS top-down and offset by the map bounds) ──────
	// The canvas is centred on the SPINE (the major-node column): its half-width is the larger of the
	// two side-chain extents plus padding, so the spine sits at the horizontal CANVAS CENTRE even when
	// one side chain is far longer than the other. This leaves empty room on the shorter side so the
	// spine can always be scrolled to the viewport centre (otherwise a long left chain pins the spine
	// to the right, un-centrable). Y is unchanged (top-anchored flip).
	// EDIT mode pads a little extra so near-flush + buttons (and corner X buttons) on the outermost
	// nodes aren't clipped by the canvas edge.
	private float MapPad => EditModeActive ? MAP_PAD + 48f : MAP_PAD;

	private float SpineX => LevelMap.First?.Pos.x ?? ( ( LevelMap.MinX + LevelMap.MaxX ) * 0.5f );
	private float HalfSpanAt( float zoom ) => MathF.Max( SpineX - LevelMap.MinX, LevelMap.MaxX - SpineX ) * zoom + MapPad;
	private float HalfSpan => HalfSpanAt( MapZoom );
	private float CanvasW => HalfSpan * 2f;
	private float CanvasH => ( LevelMap.MaxY - LevelMap.MinY ) * MapZoom + MapPad * 2f;

	private float LocalX( float mapX ) => ( mapX - SpineX ) * MapZoom + HalfSpan;         // spine → canvas centre
	private float LocalY( float mapY ) => ( LevelMap.MaxY - mapY ) * MapZoom + MapPad;    // flip Y

	// Explicit opacity also clears the old reveal gate on panels retained through hot reload.
	private string MapCanvasStyle() => $"width:{Px( CanvasW )};height:{Px( CanvasH )};opacity:1;";

	private string NodeStyle( MapNode n )
	{
		float size = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) * MapZoom;
		float left = LocalX( n.Pos.x ) - size / 2f;
		float top = LocalY( n.Pos.y ) - size / 2f;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
	}

	// The node frame's border lives in the stylesheet at 1:1 (4px); zoom re-emits it inline, floored
	// so a far-out zoom on a small window can't thin the frame away entirely.
	private string NodeFaceStyle() => $"border-width:{Px( MathF.Max( 2f, 4f * MapZoom ) )};";

	private const float ADD_CELL_SIZE = 44f;
	private const float ADD_CELL_GAP = 4f;   // near-flush: a hairline gap off the node's edge
	private const float DELETE_SIZE = 32f;

	/// <summary>A + button hugging <paramref name="n"/>'s edge on the (<paramref name="dx"/>,
	/// <paramref name="dy"/>) side (cell space, +Y up — flipped into canvas Y here).</summary>
	private string AddCellStyle( MapNode n, int dx, int dy )
	{
		float half = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) / 2f * MapZoom;
		float size = ADD_CELL_SIZE * MapZoom;
		float dist = half + ADD_CELL_GAP * MapZoom + size / 2f;
		float left = LocalX( n.Pos.x ) + dx * dist - size / 2f;
		float top = LocalY( n.Pos.y ) - dy * dist - size / 2f;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
	}

	/// <summary>An insert + button centred on the connection line between two nodes.</summary>
	private string InsertStyle( MapConnection conn )
	{
		float size = ADD_CELL_SIZE * MapZoom;
		float cx = ( LocalX( conn.From.Pos.x ) + LocalX( conn.To.Pos.x ) ) / 2f;
		float cy = ( LocalY( conn.From.Pos.y ) + LocalY( conn.To.Pos.y ) ) / 2f;
		return $"left:{Px( cx - size / 2f )};top:{Px( cy - size / 2f )};width:{Px( size )};height:{Px( size )};";
	}

	/// <summary>An X button straddling the node's top-right corner.</summary>
	private string DeleteNodeStyle( MapNode n )
	{
		float half = ( n.IsMajor ? MAJOR_SIZE : SIDE_SIZE ) / 2f * MapZoom;
		float size = DELETE_SIZE * MapZoom;
		float left = LocalX( n.Pos.x ) + half - size / 2f;
		float top = LocalY( n.Pos.y ) - half - size / 2f;
		return $"left:{Px( left )};top:{Px( top )};width:{Px( size )};height:{Px( size )};";
	}

	// The EDIT-mode button glyphs are sized in the stylesheet at 1:1 (30/20px); zoom re-emits them
	// inline so they track their (scaled) buttons.
	private string AddIconStyle => $"font-size:{Px( 30f * MapZoom )};";
	private string DeleteIconStyle => $"font-size:{Px( 20f * MapZoom )};";

	// Music-override badges: EDITOR map only (the leaderboard map stays clean). Glyph sized in the
	// stylesheet at 1:1 (18px) and re-emitted inline so it tracks the node's zoom.
	private bool MusicBadgesVisible => Mode == PickerMode.Editor && Game.IsEditor;
	private string MusicIconStyle => $"font-size:{Px( 18f * MapZoom )};";

	/// <summary>True when another mapped level overrides its music with the SAME track — usually an
	/// accident, so both badges go red.</summary>
	private static bool MusicShared( MapNode n )
	{
		string music = n.Level?.Music?.Trim();
		if ( string.IsNullOrWhiteSpace( music ) ) return false;
		int count = 0;
		foreach ( var other in LevelMap.Nodes )
		{
			if ( !string.Equals( other.Level?.Music?.Trim(), music, StringComparison.OrdinalIgnoreCase ) ) continue;
			if ( ++count > 1 ) return true;
		}
		return false;
	}

	/// <summary>Forced-character badge inside a map node's preview (lower-left, like the grid tiles);
	/// sized to the node tier. The character image goes on the badge's .sprite child (see
	/// <see cref="ForcedCharacterSpriteStyle"/>) so the .icon-glow sibling can render behind it.</summary>
	private string NodeForcedCharacterStyle( MapNode n, CharacterDef character )
	{
		float size = ( n.IsMajor ? MAJOR_NODE_CHAR_SIZE : SIDE_NODE_CHAR_SIZE ) * character.PreviewScale * MapZoom;
		return $"width:{Px( size )};height:{Px( size )};";
	}

	private static string ForcedCharacterSpriteStyle( CharacterDef character )
		=> $"background-image:url( {character.PreviewImage} );";

	private string NodeNameStyle( MapNode n )
	{
		// Estimate Roboto's proportional glyph widths so labels fit on first paint without a resize pop.
		// The fit is computed at 1:1 (the label and its node scale together), THEN zoomed — with the
		// zoomed line-height alongside so a zoomed-in font isn't clipped by the stylesheet's 21px.
		const float minimumFontSize = 9f;
		const float horizontalPadding = 12f;
		float maximumFontSize = n.IsMajor ? 18f : 12f;
		float availableWidth = (n.IsMajor ? MAJOR_SIZE : SIDE_SIZE) - horizontalPadding;
		float textWidthAtOnePixel = (n.Name ?? "").Sum( CharacterWidthEm );
		float fontSize = textWidthAtOnePixel > 0f
			? Math.Clamp( availableWidth / textWidthAtOnePixel, minimumFontSize, maximumFontSize )
			: maximumFontSize;
		fontSize *= MapZoom;
		return $"font-size:{Px( fontSize )};line-height:{Px( fontSize * 1.17f )};";
	}

	// The name pill's geometry lives in the stylesheet at 1:1 (top -29 / height 25 / padding 2 6);
	// zoom re-emits it inline so the pill tracks its node instead of dwarfing a zoomed-out one.
	private string NodeNameWrapStyle()
		=> $"top:{Px( -29f * MapZoom )};height:{Px( 25f * MapZoom )};padding:{Px( 2f * MapZoom )} {Px( 6f * MapZoom )};";

	private static string GridNameStyle( string name )
	{
		const float minimumFontSize = 9f;
		const float maximumFontSize = 18f;
		const float availableWidth = 150f;
		float textWidthAtOnePixel = (name ?? "").Sum( CharacterWidthEm );
		float fontSize = textWidthAtOnePixel > 0f
			? Math.Clamp( availableWidth / textWidthAtOnePixel, minimumFontSize, maximumFontSize )
			: maximumFontSize;
		return $"font-size:{Px( fontSize )};";
	}

	private static float CharacterWidthEm( char character )
	{
		if ( char.IsWhiteSpace( character ) ) return 0.28f;
		if ( "ilI.,'!:;|".Contains( character ) ) return 0.28f;
		if ( "mwMW@%&".Contains( character ) ) return 0.88f;
		if ( char.IsUpper( character ) ) return 0.66f;
		return 0.54f;
	}

	private static string TileForcedCharacterStyle( CharacterDef character )
	{
		float markerSize = TILE_CHAR_SIZE * character.PreviewScale;
		return $"width:{Px( markerSize )};height:{Px( markerSize )};";
	}

	private string LineStyle( MapConnection conn )
	{
		float thickness = MathF.Max( 4f, LINE_THICKNESS * MapZoom );
		float ax = LocalX( conn.From.Pos.x ), ay = LocalY( conn.From.Pos.y );
		float bx = LocalX( conn.To.Pos.x ), by = LocalY( conn.To.Pos.y );

		if ( conn.From.Pos.x == conn.To.Pos.x )
		{
			// Vertical (spine).
			float top = MathF.Min( ay, by ), height = MathF.Abs( ay - by );
			return $"left:{Px( ax - thickness / 2f )};top:{Px( top )};width:{Px( thickness )};height:{Px( height )};";
		}

		// Horizontal (side chain).
		float leftH = MathF.Min( ax, bx ), width = MathF.Abs( ax - bx );
		return $"left:{Px( leftH )};top:{Px( ay - thickness / 2f )};width:{Px( width )};height:{Px( thickness )};";
	}

	private static string Px( float v ) => v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture ) + "px";

	// Repaint when the mode toggles, the root-level set changes (a reload), the map is rebuilt by an
	// edit (Revision — a replace keeps the node COUNT unchanged), the edit-mode state moves, the
	// search string edits, or the template set / sort / timestamps change. The lists are otherwise
	// static for the overlay's lifetime.
	protected override int BuildHash() => System.HashCode.Combine( Mode, CurrentView, RecentFirst, OtherLevelsHash(), Levels.BrowserLevels.Count, LevelMap.Nodes.Count,
		System.HashCode.Combine( LevelMap.Revision, _editMode, _editAction, _editPickTest, SearchText, MapZoom ),
		System.HashCode.Combine( TemplateSort, TemplatesHash() ) );

	private static long OtherLevelsHash()
	{
		long hash = 17;
		foreach ( var level in Levels.BrowserLevels )
		{
			if ( level is null || LevelMap.Find( level.Id ) is not null ) continue;
			hash = hash * 31 + level.SourceFileTimeUtc;
		}
		return hash;
	}

	private static long TemplatesHash()
	{
		long hash = 17;
		foreach ( var template in DailyTemplates.All )
		{
			hash = hash * 31 + ( template.Id?.GetHashCode() ?? 0 );
			hash = hash * 31 + template.Weight.GetHashCode();
			hash = hash * 31 + TemplateTime( template.Id );
		}
		return hash;
	}
}