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

@* A miniature schematic of a level's arena, drawn to fill a level-select node so the node reads as
   the level it launches: the play-field colour as the base, a few static drifting-block squares, the
   coloured arena walls, and the obstacle layout with its combined walls, elbow patches and starting
   spikes. Pure display; built once per level by LevelPreview.Build. *@
<root class="level-preview" style="background-color: @Css( PreviewBackground )">
	<div class="playfield-pattern" style="@PatternStyle( Level?.PlayfieldPattern ?? PlayfieldPattern.Checker, Level?.PlayfieldCellScale ?? 1, Level?.PlayfieldPatternRows, PreviewBackground, Level?.CheckerboardSecondColor, null )"></div>
	@if ( Level?.AlternatePlayfieldEnabled == true )
	{
		@foreach ( RectF rect in Level.AlternatePlayfieldRects ?? Array.Empty<RectF>() )
		{
			<div class="playfield-pattern alternate" style="@PatternStyle( Level.AlternatePlayfieldPattern, Level.AlternatePlayfieldCellScale, Level.AlternatePlayfieldPatternRows, Level.AlternateCheckerboardColor ?? PreviewBackground, Level.AlternateCheckerboardSecondColor, rect )"></div>
		}
	}
	@if ( StaticThumbnail )
	{
		<Image class="static-thumbnail" Texture=@Thumbnail />
	}
	else
	{
		@* Drifting background-block squares. Created + animated imperatively (see Tick / BuildBlocks) under
		   this layer, so they can move every frame without rebuilding the panel. Sits behind the walls. *@
		<div class="bg-layer" @ref="BgLayer"></div>
		@foreach ( var block in LevelPreview.BuildSpawnBlocks( Level ) )
		{
			<div class="game-block" style="@BlockStyle( block )"></div>
		}
		@foreach ( var r in Model.Rects )
		{
			<div class="prect" style="@RectStyle( r )"></div>
		}
	}
	@* Semitransparent map tag in the top-left corner (e.g. "3" or "2B"); hidden for off-map levels. *@
	@if ( !string.IsNullOrEmpty( Tag ) )
	{
		<label class="tag" style="@TagStyle()">@Tag</label>
	}
</root>

@code
{
	[Parameter] public LevelDef Level { get; set; }

	// When set (e.g. the local-replays list), nudge the corner tag up a couple extra pixels to sit
	// better in that context's smaller preview.
	[Parameter] public bool CompactTag { get; set; }

	/// <summary>Replay lists share baked geometry and keep background squares stationary.</summary>
	[Parameter] public bool StaticThumbnail { get; set; }

	private Color PreviewBackground => Level?.CheckerboardColor ?? new Color( 0f, 189f / 255f, 196f / 255f );
	private ReplayPreviewCache.Entry _thumbnail;
	private LevelDef _thumbnailLevel;
	private Texture Thumbnail
	{
		get
		{
			if ( _thumbnail is null || !ReferenceEquals( _thumbnailLevel, Level ) )
			{
				var next = ReplayPreviewCache.Acquire( Level );
				ReplayPreviewCache.Release( _thumbnail );
				_thumbnail = next;
				_thumbnailLevel = Level;
			}
			return _thumbnail.Texture;
		}
	}

	public override void OnDeleted()
	{
		base.OnDeleted();
		ReplayPreviewCache.Release( _thumbnail );
		_thumbnail = null;
	}

	// Grid tiles opt in: previews scrolled out of their scrolling ancestor's viewport skip the
	// per-frame drift animation entirely (and defer building the drift-block panels until first
	// visible), so a 250-tile template grid only pays for the visible window.
	[Parameter] public bool CullOffscreen { get; set; }

	// The level's ordinal tag on the level-select map (major = "1","2"…; side = "1A","1B"…), resolved
	// from the map by level id. Empty for levels not on the map (test/editor previews).
	private string Tag => LevelMap.Find( Level?.Id )?.Tag ?? "";

	// Whether this level is a MAJOR (spine) node — spine tags render a touch larger and sit slightly
	// lower than side-chain tags, which are nudged up a pixel.
	private bool TagIsMajor => LevelMap.Find( Level?.Id )?.IsMajor ?? false;

	// Per-context corner-tag tuning: spine tags are slightly larger; side tags sit 1px higher; and the
	// local-replays list nudges everything up another 2px.
	private string TagStyle()
	{
		float size = TagIsMajor ? 42f : 30f;
		int offset = ( TagIsMajor ? -2 : -1 );

		// local-replays list
		if ( CompactTag )
		{
			size = 20f;
			offset = -1;
		}

		return $"font-size:{Num( size )}px;margin-top:{offset}px;";
	}

	// One tooth of a spike strip, in logical arena units (exaggerated from the 5px baked tile so the
	// teeth read at node size).
	private const float SPIKE_TOOTH = 12f;

	private LevelPreviewModel _model;
	private LevelDef _builtLevel;

	// Reuse static geometry across map visits and previews recreated while scrolling. Definitions
	// are immutable authored data; editing/reloading replaces the object, so it gets a fresh entry.
	// Bound retained definitions (including old editor revisions) rather than caching indefinitely.
	private const int MODEL_CACHE_LIMIT = 128;
	private static readonly Dictionary<LevelDef, LevelPreviewModel> ModelCache = new();
	private static readonly Queue<LevelDef> ModelCacheOrder = new();

	private static LevelPreviewModel GetModel( LevelDef level )
	{
		if ( level is null ) return LevelPreview.Build( null );
		if ( ModelCache.TryGetValue( level, out var model ) ) return model;

		model = LevelPreview.Build( level );
		while ( ModelCache.Count >= MODEL_CACHE_LIMIT )
			ModelCache.Remove( ModelCacheOrder.Dequeue() );
		ModelCache.Add( level, model );
		ModelCacheOrder.Enqueue( level );
		return model;
	}

	// Lazily (re)built when the level changes; the schematic is otherwise static.
	private LevelPreviewModel Model
	{
		get
		{
			if ( _model is null || !ReferenceEquals( _builtLevel, Level ) )
			{
				_model = GetModel( Level );
				_builtLevel = Level;
			}
			return _model;
		}
	}

	// The decorative drifting-block squares are managed imperatively (created once per level, then
	// moved every frame in Tick) rather than emitted as static markup, so they can animate without
	// rebuilding the panel each frame. Layout (count / positions / sizes / start direction) is seeded
	// deterministically from the level Id so a node keeps a stable arrangement; the colours are the
	// level's real drifting-block palette (front -> back).
	private Panel BgLayer { get; set; }

	private sealed class BgBlock
	{
		public Panel Panel;
		public Vector2 Pos;      // logical arena space (origin bottom-left, +Y up)
		public Vector2 Vel;      // logical units / second
		public float Size;
		public Color Color;
		public int Depth;
	}

	private readonly List<BgBlock> _blocks = new();
	private bool _blocksBuilt;
	private LevelDef _blocksLevel;

	// Per-frame hook (Panel.Tick): advance each block along its cardinal velocity and bounce it off the
	// preview square's bounds. Lightweight — only inline left/top are mutated, no panel rebuild.
	public override void Tick()
	{
		base.Tick();
		if ( StaticThumbnail ) return;
		if ( BgLayer is null ) return;
		if ( CullOffscreen && !InScrollViewport() ) return;
		// Rebuild when the level changes, or when a panel re-render (hot-reload / reconcile) has WIPED the
		// imperatively-created child panels — they live under BgLayer but aren't in the declarative
		// template, so a render pass can delete them while _blocks still holds stale (deleted) refs.
		if ( !_blocksBuilt || !ReferenceEquals( _blocksLevel, Level ) || BgLayer.ChildrenCount != _blocks.Count )
			BuildBlocks();

		float dt = Time.Delta;
		foreach ( var b in _blocks )
		{
			var p = b.Pos + b.Vel * dt;
			float maxX = Arena.WIDTH - b.Size;
			float maxY = Arena.HEIGHT - b.Size;

			// Bounce off the square's bounds (not obstacles) by flipping the offending axis.
			if ( p.x < 0f ) { p.x = 0f; b.Vel = new Vector2( MathF.Abs( b.Vel.x ), b.Vel.y ); }
			else if ( p.x > maxX ) { p.x = maxX; b.Vel = new Vector2( -MathF.Abs( b.Vel.x ), b.Vel.y ); }

			if ( p.y < 0f ) { p.y = 0f; b.Vel = new Vector2( b.Vel.x, MathF.Abs( b.Vel.y ) ); }
			else if ( p.y > maxY ) { p.y = maxY; b.Vel = new Vector2( b.Vel.x, -MathF.Abs( b.Vel.y ) ); }

			b.Pos = p;
			PositionBlock( b );
		}
	}

	// Whether this preview's screen rect intersects every scrolling ancestor's viewport. Unlaid-out
	// panels (zero rect, e.g. the frame the grid is created) count as offscreen — they're invisible,
	// and it defers all 250 tiles' BuildBlocks past the open frame.
	private bool InScrollViewport()
	{
		var rect = Box.Rect;
		if ( rect.Width <= 0f || rect.Height <= 0f ) return false;
		for ( var p = Parent; p is not null; p = p.Parent )
		{
			if ( p.ComputedStyle?.Overflow == OverflowMode.Scroll && !p.Box.Rect.IsInside( rect ) )
				return false;
		}
		return true;
	}

	// (Re)create the block panels for the current level. Layout comes from the SHARED generator
	// (LevelPreview.BuildBgBlocks — also consumed by the baked workshop thumbnail, so the two can't
	// drift apart); this side only owns the panels and their animation.
	private void BuildBlocks()
	{
		_blocks.Clear();
		BgLayer.DeleteChildren( true );
		_blocksBuilt = true;
		_blocksLevel = Level;

		foreach ( var block in LevelPreview.BuildBgBlocks( Level, Model ) )
		{
			var b = new BgBlock
			{
				Pos = block.Pos,
				Vel = block.Vel,
				Size = block.Size,
				Color = block.Color,
				Depth = block.Depth,
			};
			b.Panel = new Panel( BgLayer, "bg-block" );
			b.Panel.Style.Width = Length.Percent( b.Size / Arena.WIDTH * 100f );
			b.Panel.Style.Height = Length.Percent( b.Size / Arena.HEIGHT * 100f );
			b.Panel.Style.BackgroundColor = b.Color;
			_blocks.Add( b );
			PositionBlock( b );
		}
	}

	private static void PositionBlock( BgBlock b )
	{
		if ( b.Panel is null ) return;
		// Logical (bottom-left origin, +Y up) -> CSS percentages of the 240x240 arena.
		b.Panel.Style.Left = Length.Percent( b.Pos.x / Arena.WIDTH * 100f );
		b.Panel.Style.Top = Length.Percent( ( Arena.HEIGHT - b.Pos.y - b.Size ) / Arena.HEIGHT * 100f );
	}

	// The blocks on their spawn slots come from the shared generator too (see LevelPreview.BuildSpawnBlocks).
	private static string BlockStyle( (BlockType Type, Vector2 Pos) block )
	{
		const float size = 40f;
		return $"left:{Pct( block.Pos.x - size * 0.5f )};"
			+ $"top:{Pct( Arena.HEIGHT - block.Pos.y - size * 0.5f )};"
			+ $"width:{Pct( size )};height:{Pct( size )};"
			+ $"background-color:{Css( LevelPreview.BlockFallbackColor( block.Type ) )};"
			+ $"background-image:url( sprites/ui/blocks/{LevelPreview.BlockPreviewImage( block.Type )}.png );";
	}

	private static string PatternStyle( PlayfieldPattern pattern, int cellScale, IReadOnlyList<string> customRows, Color light, Color? second, RectF? bounds )
	{
		RectF rect = bounds ?? new RectF( 0f, 0f, Arena.WIDTH, Arena.HEIGHT );
		float width = MathF.Max( 1f, rect.Width );
		float height = MathF.Max( 1f, rect.Height );
		float scale = Math.Clamp( cellScale, 1, 8 );
		Color dark = second ?? new Color( light.r * (235f / 255f), light.g * (235f / 255f), light.b * (235f / 255f), light.a );
		string box = $"left:{Pct( rect.Left )};top:{Pct( Arena.HEIGHT - rect.Top )};width:{Pct( width )};height:{Pct( height )};";
		string colors = $"background-color:{Css( light )};";

		if ( pattern == PlayfieldPattern.Checker )
		{
			float imageWidth = Arena.WIDTH * scale / width * 100f;
			float imageHeight = Arena.HEIGHT * scale / height * 100f;
			return box + colors + "background-image:url( sprites/background_neutral.png );background-repeat:repeat;"
				+ $"background-size:{Num( imageWidth )}% {Num( imageHeight )}%;background-tint:{Css( light )};image-rendering:pixelated;";
		}

		float tileX = MathF.Max( 0.1f, scale / width * 100f );
		float tileY = MathF.Max( 0.1f, scale / height * 100f );
		string lightCss = Css( light );
		string darkCss = Css( dark );
		// The checker branches set background-tint/image-rendering; the gradient branches must reset
		// both to their defaults, or a reconciled panel reused across patterns (the template grid
		// re-sorting) keeps the checker's stale tint/pixelation — Styles.Set never clears absentees.
		const string gradientReset = "background-tint:white;image-rendering:auto;";
		return pattern switch
		{
			PlayfieldPattern.HorizontalStripes => box + colors + gradientReset
				+ $"background-image:linear-gradient(to bottom,{lightCss} 0%,{lightCss} 50%,{darkCss} 50%,{darkCss} 100%);background-size:100% {Num( tileY * 2f )}%;background-repeat:repeat;",
			PlayfieldPattern.VerticalStripes => box + colors + gradientReset
				+ $"background-image:linear-gradient(to right,{lightCss} 0%,{lightCss} 50%,{darkCss} 50%,{darkCss} 100%);background-size:{Num( tileX * 2f )}% 100%;background-repeat:repeat;",
			PlayfieldPattern.Diagonal => box + colors + gradientReset
				+ $"background-image:linear-gradient(to bottom right,{lightCss} 0%,{lightCss} 25%,{darkCss} 25%,{darkCss} 50%,{lightCss} 50%,{lightCss} 75%,{darkCss} 75%,{darkCss} 100%);background-size:{Num( tileX * 4f )}% {Num( tileY * 4f )}%;background-repeat:repeat;",
			PlayfieldPattern.Grid => box + colors + gradientReset
				+ $"background-image:linear-gradient(to bottom right,{darkCss} 0%,{darkCss} 18%,{lightCss} 18%,{lightCss} 50%,{darkCss} 50%,{darkCss} 68%,{lightCss} 68%,{lightCss} 100%);background-size:{Num( tileX * 4f )}% {Num( tileY * 4f )}%;background-repeat:repeat;",
			PlayfieldPattern.Dots => CheckerFallback( box, colors, light, width, height, scale * 4f ),
			_ => CheckerFallback( box, colors, light, width, height,
				scale * Math.Max( 1, Math.Max( customRows?.Count ?? 0, customRows is { Count: > 0 } ? customRows[0]?.Length ?? 0 : 0 ) ) ),
		};
	}

	private static string CheckerFallback( string box, string colors, Color light, float width, float height, float scale )
	{
		float imageWidth = Arena.WIDTH * scale / width * 100f;
		float imageHeight = Arena.HEIGHT * scale / height * 100f;
		return box + colors + "background-image:url( sprites/background_neutral.png );background-repeat:repeat;"
			+ $"background-size:{Num( imageWidth )}% {Num( imageHeight )}%;background-tint:{Css( light )};image-rendering:pixelated;";
	}

	private string RectStyle( PreviewRect r )
	{
		// Logical (bottom-left origin, +Y up) -> CSS percentages of the 240x240 arena.
		// Slightly overdraw rectangles so node-scale animations cannot reveal sub-pixel seams between
		// adjacent obstacle fill/wall pieces, or between a flush fill and the clipped preview edge.
		const float seamPad = 0.75f;
		float x = Math.Clamp( r.X - seamPad, -seamPad, Arena.WIDTH + seamPad );
		float y = Math.Clamp( r.Y - seamPad, -seamPad, Arena.HEIGHT + seamPad );
		float right = Math.Clamp( r.X + r.W + seamPad, -seamPad, Arena.WIDTH + seamPad );
		float top = Math.Clamp( r.Y + r.H + seamPad, -seamPad, Arena.HEIGHT + seamPad );
		string box = $"left:{Pct( x )};top:{Pct( Arena.HEIGHT - top )};width:{Pct( right - x )};height:{Pct( top - y )};";

		// IMPORTANT: both branches must set the UNION of the properties either can set. Razor
		// reconciliation reuses these .prect panels across re-renders (a template-grid re-sort hands a
		// tile a different level), and the engine's inline-style update only APPLIES the new string's
		// properties — it never clears ones that go missing (Styles.Set parses into the existing
		// collection). A plain fill reused from a teeth panel kept the teeth art (white stripes inside
		// obstacles); a teeth panel reused from a coin rect kept the yellow fill behind its teeth.
		if ( r.Spike == PreviewSpike.None )
			return box + $"background-color: {Css( r.Color )}; background-image: none;";

		// Teeth OVERLAY: this rect sits on top of a separate flat-coloured band rect, so it carries ONLY
		// the band-removed teeth art (tile_*_teeth_4), drawn WHITE with no fill. The transparent band
		// region shows the coloured band beneath, and the gaps between teeth stay transparent (the arena
		// shows through) — matching the in-game WallFace exactly. Horizontal edges tile along X, vertical
		// along Y. The teeth always read white regardless of the level's wall colour, like the game.
		bool horizontal = r.Spike == PreviewSpike.Up || r.Spike == PreviewSpike.Down;
		float span = horizontal ? r.W : r.H;
		float toothPct = span > 0f ? SPIKE_TOOTH / span * 100f : 100f;
		string size = horizontal ? $"{Num( toothPct )}% 100%" : $"100% {Num( toothPct )}%";
		string repeat = horizontal ? "repeat-x" : "repeat-y";
		return box
			+ "background-color: transparent;"
			+ $"background-image: url( sprites/ui/arena/tile_{TileDir( r.Spike )}_teeth_4.png );"
			+ $"background-repeat: {repeat}; background-size: {size};"
			+ $"background-tint: {Css( r.Tint )}; image-rendering: pixelated;";
	}

	// The baked tile whose spikes point in the growth direction: the pre-rotated art is named for the
	// wall it belongs to, which is the OPPOSITE side of the growth direction (e.g. spikes-up = the
	// bottom "down" wall art).
	private static string TileDir( PreviewSpike s ) => s switch
	{
		PreviewSpike.Up => "down",
		PreviewSpike.Down => "up",
		PreviewSpike.Left => "right",
		PreviewSpike.Right => "left",
		_ => "down",
	};

	private static string Pct( float logical ) => Num( logical / Arena.WIDTH * 100f ) + "%";
	private static string Num( float v ) => v.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );

	private static string Css( Color c ) =>
		$"rgba( {(int)( c.r * 255f )}, {(int)( c.g * 255f )}, {(int)( c.b * 255f )}, {Num( c.a )} )";

	protected override int BuildHash() => System.HashCode.Combine( Level, Level?.PlayfieldPattern, Level?.PlayfieldCellScale,
		Level?.AlternatePlayfieldEnabled, Level?.AlternatePlayfieldPattern, Level?.AlternatePlayfieldCellScale,
		HashCode.Combine( Level?.AlternatePlayfieldRects?.Count ?? 0, StaticThumbnail, CompactTag ) );
}