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

namespace BlockParty;

/// <summary>One generated daily challenge: the level plus the daily-only rules around it.</summary>
public sealed class DailyDef
{
	public string DailyId { get; init; }
	public LevelDef Level { get; init; }
	/// <summary>Allowed attempts for the day; null = unlimited (the common case).</summary>
	public int? MaxAttempts { get; init; }
	/// <summary>Which obstacle template the day rolled (for daily_dump / debugging).</summary>
	public string TemplateId { get; init; }
	/// <summary>Human-readable twist tags for editor diagnostics and daily_dump.</summary>
	public IReadOnlyList<string> Modifiers { get; init; }
}

/// <summary>Generate-once cache in front of <see cref="DailyLevelGenerator"/>. Deliberately never
/// touches the <see cref="Levels"/> registry — daily levels are transient, regenerated from the id.</summary>
public static class DailyLevels
{
	private static readonly Dictionary<string, DailyDef> _cache = new();

	public static DailyDef Get( string dailyId )
	{
		if ( string.IsNullOrEmpty( dailyId ) ) return null;
		if ( _cache.TryGetValue( dailyId, out var def ) )
			return WithMusic( dailyId, def );

		// Refuse to generate while the template library isn't up. At boot the mounted filesystem
		// can lag (see DailyTemplates.EnsureLoaded); a def generated before then is the WRONG layout
		// for the day — playing it would burn an attempt on (and submit a permanently-desynced
		// replay to) the shared board. Every caller treats null as "not playable/previewable yet",
		// and IsLoaded retries the scan, so this self-heals on a later access.
		if ( !DailyTemplates.IsLoaded )
			return null;

		def = DailyLevelGenerator.Generate( dailyId );
		_cache[dailyId] = def;
		return def;
	}

	// The song comes from a filesystem scan that can lag at boot like the templates do, but it's
	// cosmetic — rather than hold the whole def out of the cache over it, a cached day with no song
	// yet picks one up on a later access (before anything plays it).
	private static DailyDef WithMusic( string dailyId, DailyDef def )
	{
		if ( def?.Level is not null && def.Level.Music is null )
			def.Level.Music = DailyMusic.For( dailyId );
		return def;
	}

	public static string LevelIdFor( string dailyId ) => $"daily-{dailyId}";

	/// <summary>Parse a "daily-yyyyMMdd" level id back to its daily id.</summary>
	public static bool TryParseDailyLevelId( string levelId, out string dailyId )
	{
		dailyId = null;
		if ( levelId is null || !levelId.StartsWith( "daily-", StringComparison.Ordinal ) ) return false;
		var id = levelId["daily-".Length..];
		if ( !DailyChallenge.TryParseId( id, out _ ) ) return false;
		dailyId = id;
		return true;
	}

	/// <summary>Drop cached defs (hotload hygiene while iterating on the generator/config).</summary>
	public static void ClearCache() => _cache.Clear();
	public static void Remove( string dailyId ) => _cache.Remove( dailyId ?? "" );
}

/// <summary>
/// Deterministically generates each day's unique daily-challenge level from the day's seed.
///
/// DETERMINISM CONTRACT: for a given id the output must be byte-identical across machines, builds
/// and runs within one LEADERBOARD_VERSION — every player plays the same layout and stored replays
/// regenerate it. All draws come from one <see cref="Random"/> seeded by
/// <see cref="DailyChallenge.GenSeedFor"/>, in a FIXED pipeline order (append new draws at the end
/// only), from plain arrays (never dictionary iteration). Any change to this file, the tables in
/// <see cref="DailyGenConfig"/>, or <see cref="LevelCosmeticsRandomizer"/>'s roll methods changes
/// every past and future day — such changes ship together with a LEADERBOARD_VERSION bump (the
/// daily seed folds the version in, which re-rolls the per-day boards; old daily replays retire
/// via the level-hash gate, since the regenerated layout no longer matches their recorded
/// <see cref="RunData.LevelHash"/>).
/// </summary>
public static class DailyLevelGenerator
{
	/// <summary>The seed-dependent choices that shape a day, rolled before the level is built so the
	/// anti-repetition pass can compare/adjust against yesterday's plan cheaply.</summary>
	private sealed class DayPlan
	{
		public Random Rng;
		public List<DailyModifier> Modifiers = new();
		public ObstacleTemplate Template;
		public bool Mirrored;
		public string ForcedCharacterId;
		public int? MaxAttempts;

		public bool Has( DailyModifier m ) => Modifiers.Contains( m );
	}

	public static DailyDef Generate( string dailyId )
	{
		var plan = RollPlan( dailyId );

		// Anti-repetition, BEST-EFFORT BY DESIGN: if yesterday's RAW roll used the same
		// template or forced the same character, deterministically re-pick so consecutive days don't
		// feel samey. Comparing against yesterday's raw roll (not its final, post-adjustment plan)
		// means a day whose collision was itself rerolled can still be repeated — deliberately
		// accepted: sweeps show that slips through roughly once every couple of YEARS, and full
		// correctness needs a fingerprint chain over all history (hidden state, and it makes
		// EarliestId affect generation). Each day stays a pure function of exactly two seeds.
		var yesterday = RollPlan( DailyChallenge.AddDays( dailyId, -1 ) );
		if ( plan.Template.Id == yesterday.Template.Id )
		{
			plan.Template = PickTemplate( plan.Rng, excludeId: plan.Template.Id ) ?? plan.Template;
			plan.Mirrored &= plan.Template.AllowMirrorX;
		}
		if ( plan.ForcedCharacterId is not null && plan.ForcedCharacterId == yesterday.ForcedCharacterId )
			plan.ForcedCharacterId = PickForcedCharacter( plan.Rng, excludeId: plan.ForcedCharacterId ) ?? plan.ForcedCharacterId;

		return Build( dailyId, plan );
	}

	// ── plan (budget → modifiers → template → character → attempts) ─────────────────────────────────

	private static DayPlan RollPlan( string dailyId )
	{
		var plan = new DayPlan { Rng = new Random( DailyChallenge.GenSeedFor( dailyId ) ) };
		var rng = plan.Rng;

		int budget = WeightedPick( rng, DailyGenConfig.SpiceBudgetWeights, e => e.Weight ).Points;
		var candidates = new List<SpiceModifier>( DailyGenConfig.Modifiers );
		while ( budget > 0 )
		{
			var available = candidates.Where( m => m.Cost <= budget && !ExcludedByDrawn( plan, m.Id ) ).ToList();
			if ( available.Count == 0 ) break;

			var pick = WeightedPick( rng, available, m => m.Weight );
			plan.Modifiers.Add( pick.Id );
			candidates.Remove( pick );
			budget -= pick.Cost;
		}

		plan.Template = PickTemplate( rng, excludeId: null ) ?? DailyGenConfig.FallbackTemplate;
		if ( plan.Template.AllowMirrorX && rng.Next( 2 ) == 1 )
			plan.Mirrored = true;

		if ( rng.Next( 100 ) < DailyGenConfig.ForcedCharacterChancePercent )
			plan.ForcedCharacterId = PickForcedCharacter( rng, excludeId: null );

		if ( rng.Next( 100 ) < DailyGenConfig.LimitedAttemptsChancePercent )
			plan.MaxAttempts = WeightedPick( rng, DailyGenConfig.AttemptWeights, e => e.Weight ).Attempts;

		return plan;
	}

	private static bool ExcludedByDrawn( DayPlan plan, DailyModifier candidate )
	{
		foreach ( var (a, b) in DailyGenConfig.ExclusiveModifiers )
		{
			if ( candidate == a && plan.Has( b ) ) return true;
			if ( candidate == b && plan.Has( a ) ) return true;
		}
		return false;
	}

	private static ObstacleTemplate PickTemplate( Random rng, string excludeId )
	{
		var pool = DailyTemplates.All
			.Where( t => t.Id != excludeId && t.Weight > 0 )
			.ToList();
		return pool.Count == 0 ? null : WeightedPick( rng, pool, t => t.Weight );
	}

	private static string PickForcedCharacter( Random rng, string excludeId )
	{
		var pool = DailyGenConfig.CharacterWeights
			.Where( e => e.CharacterId != excludeId && e.Weight > 0 && Characters.TryGet( e.CharacterId, out _ ) )
			.ToList();
		return pool.Count == 0 ? null : WeightedPick( rng, pool, e => e.Weight ).CharacterId;
	}

	// ── build (spikes → count → types → phase starts → cadence → cosmetics) ─────────────────────────

	private static DailyDef Build( string dailyId, DayPlan plan )
	{
		var rng = plan.Rng;
		var template = plan.Template;

		var obstacles = template.Obstacles.Select( r => plan.Mirrored ? MirrorRect( r ) : r ).ToArray();
		var spawnPositions = template.SpawnPositions.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray();
		var playerSpawns = template.PlayerSpawns.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray();

		// Template-authored pinned blocks ride along (positions follow their spawn slot; pre-pressed
		// sides swap Left/Right on a mirrored day).
		var spawnPins = template.SpawnPins.Select( pin => pin is null ? null : new PinnedBlock
		{
			Type = pin.Type,
			Phase = pin.Phase,
			PressedSides = plan.Mirrored ? pin.PressedSides.Select( MirrorSide ).ToArray() : pin.PressedSides,
		} ).ToArray();
		int pinnedCount = spawnPins.Count( p => p is not null );

		var level = new LevelDef
		{
			Id = DailyLevels.LevelIdFor( dailyId ),
			Name = $"DAILY {DailyChallenge.DisplayDate( dailyId )}",
			Obstacles = obstacles,
			SpawnPositions = spawnPositions,
			SpawnPins = pinnedCount > 0 ? spawnPins : null,
			PlayerSpawns = playerSpawns,
			VisionBlockers = template.VisionObstacles.Select( i => obstacles[i] ).ToArray(),
			Fences = template.FenceObstacles.Select( i => obstacles[i] ).ToArray(),
			Glass = template.GlassObstacles.Select( i => obstacles[i] ).ToArray(),
			// Coins ride along with the layout (mirror-aware). NO rng draws here — coins must not
			// shift any existing day's generation.
			Coins = template.Coins.Select( p => plan.Mirrored ? MirrorPoint( p ) : p ).ToArray(),
			// The day's song is hashed from the date, NOT drawn from the rng (see DailyMusic): songs
			// can be added to Assets/music without shifting a single generation draw. Null = the
			// library hasn't mounted yet; DailyLevels.Get fills it in on a later access.
			Music = DailyMusic.For( dailyId ),
			ForcedCharacterId = plan.ForcedCharacterId,
			ShuffleTypes = true,
		};

		var tags = new List<string>();

		// Spikes — AuthoredSpikes templates always spike every authored surface (that's layout, not a
		// twist: no rng draws, no tag). Everything else rolls each curated candidate independently
		// every day, walls first then obstacle faces, in listed order.
		{
			var candidates = new List<(int Obstacle, Direction Side)>();
			foreach ( var side in template.SpikeableWalls )
				candidates.Add( (-1, plan.Mirrored ? MirrorSide( side ) : side) );
			foreach ( var (obstacle, sides) in template.SpikeableObstacleSides )
				foreach ( var side in sides )
					candidates.Add( (obstacle, plan.Mirrored ? MirrorSide( side ) : side) );

			var picked = candidates;
			if ( !template.AuthoredSpikes )
			{
				picked = new List<(int Obstacle, Direction Side)>();
				foreach ( var candidate in candidates )
					if ( rng.Next( 100 ) < DailyGenConfig.SpikeChancePercent )
						picked.Add( candidate );
				if ( picked.Count > 0 )
					tags.Add( "SPIKES" );
			}

			level.SpikedWalls = picked.Where( p => p.Obstacle < 0 ).Select( p => p.Side ).ToArray();
			level.SpikedObstacleSides = picked
				.Where( p => p.Obstacle >= 0 )
				.GroupBy( p => p.Obstacle )
				.OrderBy( g => g.Key )
				.Select( g => new ObstacleSpikeSpec( obstacles[g.Key], g.Select( p => p.Side ).ToArray() ) )
				.ToArray();
		}

		// Block count, nudged by the count modifiers and clamped to the template's FREE spawn slots
		// (pinned blocks always spawn on top of this count from their own slots). The twist tags only
		// show when the nudge survived the clamp (a full spawn grid can swallow an ExtraBlocks roll —
		// advertising it would lie to the hub).
		int freeSlots = spawnPositions.Length - pinnedCount;
		int baseCount = WeightedPick( rng, DailyGenConfig.BlockCountWeights, e => e.Weight ).Count;
		int count = baseCount;
		if ( plan.Has( DailyModifier.ExtraBlocks ) ) count++;
		if ( plan.Has( DailyModifier.FewerBlocks ) ) count--;
		count = Math.Clamp( count, Math.Min( 2, freeSlots ), freeSlots );

		int unmodified = Math.Clamp( baseCount, Math.Min( 2, freeSlots ), freeSlots );
		if ( count > unmodified ) tags.Add( "EXTRA BLOCK" );
		if ( count < unmodified ) tags.Add( "FEWER BLOCKS" );

		level.Blocks = RollBlockTypes( rng, count, plan.Has( DailyModifier.Duplicates ), spawnPins, tags );

		if ( plan.Has( DailyModifier.PhaseStart ) )
		{
			var starts = new BlockStart[level.Blocks.Count];
			int phased = Math.Min( rng.Next( 1, 3 ), starts.Length );
			var slots = Enumerable.Range( 0, starts.Length ).ToList();
			for ( int i = 0; i < phased; i++ )
			{
				int slot = slots[rng.Next( slots.Count )];
				slots.Remove( slot );
				starts[slot] = new BlockStart { Phase = 1 };
			}
			level.BlockStarts = starts;
			tags.Add( "HEAD START" );
		}

		if ( plan.ForcedCharacterId is not null )
			tags.Add( $"PLAYS AS {(Characters.TryGet( plan.ForcedCharacterId, out var c ) ? c.Name : plan.ForcedCharacterId).ToUpperInvariant()}" );
		if ( plan.MaxAttempts is int attempts )
			tags.Add( attempts == 1 ? "1 ATTEMPT" : $"{attempts} ATTEMPTS" );

		// Cosmetics last, so gameplay draws stay aligned if the cosmetic rolls ever gain draws.
		// Alternate playfield: template-authored rects win; otherwise an independent cosmetic roll
		// paints a generated region (see BuildAltLayout) in a second pattern+colour.
		var altRects = template.AlternatePlayfieldRects.Select( r => plan.Mirrored ? MirrorRect( r ) : r ).ToArray();
		if ( altRects.Length == 0 && rng.Next( 100 ) < DailyGenConfig.AltPlayfieldChancePercent )
			altRects = RollAltPlayfieldRects( rng );
		bool alternate = altRects.Length > 0;
		if ( alternate )
		{
			level.AlternatePlayfieldEnabled = true;
			level.AlternatePlayfieldRects = altRects;
		}
		var colorRoll = LevelCosmeticsRandomizer.RollColors( rng, alternate, useThreeBgColors: false );
		colorRoll.ApplyTo( level );

		// Ambient particles: rolled every day so the draw count stays constant, applied only when the
		// enable gate wins; the tint anchors to the day's palette hue.
		var particleRoll = LevelCosmeticsRandomizer.RollBackgroundParticles(
			rng, colorRoll.Hue, DailyGenConfig.BackgroundParticlesChancePercent / 100f );
		if ( particleRoll.Enabled )
			particleRoll.ApplyTo( level );

		// Pattern LAST: its draw count varies (generated Custom tiles), so nothing may follow it.
		LevelCosmeticsRandomizer.RollPattern( rng, alternate ).ApplyTo( level );

		return new DailyDef
		{
			DailyId = dailyId,
			Level = level,
			MaxAttempts = plan.MaxAttempts,
			TemplateId = plan.Mirrored ? $"{template.Id}-mirrored" : template.Id,
			Modifiers = tags,
		};
	}

	/// <summary>Weighted draws without replacement; drawing a type also removes its
	/// <see cref="DailyGenConfig.ForbiddenPairs"/> partners from the pool. Template-pinned types are
	/// pre-seeded as if already drawn (no random copy of a pinned type; forbidden partners of pinned
	/// types never appear). On a Duplicates day the last slot repeats one already-drawn
	/// (duplicate-eligible) type instead.</summary>
	private static List<BlockType> RollBlockTypes( Random rng, int count, bool allowDuplicate,
		IReadOnlyList<PinnedBlock> pins, List<string> tags )
	{
		var pool = DailyGenConfig.TypeWeights.ToList();
		foreach ( var pin in pins )
		{
			if ( pin is null ) continue;
			pool.RemoveAll( e => e.Type == pin.Type );
			foreach ( var (a, b) in DailyGenConfig.ForbiddenPairs )
			{
				if ( pin.Type == a ) pool.RemoveAll( e => e.Type == b );
				else if ( pin.Type == b ) pool.RemoveAll( e => e.Type == a );
			}
		}

		var blocks = new List<BlockType>();
		int distinct = allowDuplicate ? count - 1 : count;

		for ( int i = 0; i < distinct && pool.Count > 0; i++ )
		{
			var pick = WeightedPick( rng, pool, e => e.Weight ).Type;
			blocks.Add( pick );
			pool.RemoveAll( e => e.Type == pick );
			foreach ( var (a, b) in DailyGenConfig.ForbiddenPairs )
			{
				if ( pick == a ) pool.RemoveAll( e => e.Type == b );
				else if ( pick == b ) pool.RemoveAll( e => e.Type == a );
			}
		}

		if ( allowDuplicate )
		{
			var eligible = blocks.Where( t => !DailyGenConfig.DuplicateBannedTypes.Contains( t ) ).ToList();
			if ( eligible.Count > 0 )
			{
				blocks.Add( eligible[rng.Next( eligible.Count )] );
				tags.Add( "DUPLICATE" );
			}
			else if ( pool.Count > 0 )
			{
				blocks.Add( WeightedPick( rng, pool, e => e.Weight ).Type );
			}
		}

		return blocks;
	}

	/// <summary>A generated alternate-playfield region: one of <see cref="BuildAltLayout"/>'s families, then a 50/50
	/// horizontal and a 50/50 vertical mirror, so every asymmetric layout appears in up to four
	/// orientations. Edges snap to an 8-unit grid off the inner wall face. Purely cosmetic (the
	/// alternate pattern/colour are rolled with the cosmetics), so no gameplay-fairness constraints
	/// apply; layouts never overlap their own rects (two renderers on one spot would z-fight).</summary>
	private static RectF[] RollAltPlayfieldRects( Random rng )
	{
		bool mirrorX = rng.Next( 2 ) == 0, mirrorY = rng.Next( 2 ) == 0;
		// Drop degenerates BEFORE mirroring: a mirror flips an inverted rect into a valid-looking one.
		var rects = BuildAltLayout( rng.Next( AltLayoutCount ), rng ).Where( r => r.Width > 0f && r.Height > 0f ).ToArray();
		for ( int i = 0; i < rects.Length; i++ )
		{
			if ( mirrorX ) rects[i] = MirrorRect( rects[i] );
			if ( mirrorY ) rects[i] = MirrorRectY( rects[i] );
		}
		return rects;
	}

	private const float AltLo = Arena.WALL_SIZE, AltHi = Arena.WIDTH - Arena.WALL_SIZE, AltMid = Arena.WIDTH / 2f;

	/// <summary>Snap a coordinate to the 8-unit grid anchored at the inner wall face.</summary>
	private static float AltSnap( float v ) => Math.Clamp( AltLo + MathF.Round( ( v - AltLo ) / 8f ) * 8f, AltLo, AltHi );

	/// <summary>A random grid-snapped length in [min, max] (multiples of 8).</summary>
	private static float AltLen( Random rng, int min, int max ) => min + rng.Next( ( max - min ) / 8 + 1 ) * 8;

	/// <summary>A random grid-snapped start so that [start, start + length] fits inside the field.</summary>
	private static float AltStart( Random rng, float length ) => AltLo + rng.Next( (int)( ( AltHi - AltLo - length ) / 8 ) + 1 ) * 8;

	private static RectF AltBox( float left, float bottom, float right, float top )
		=> new( AltSnap( left ), AltSnap( bottom ), AltSnap( right ), AltSnap( top ) );

	/// <summary>How many alt-region layout families <see cref="BuildAltLayout"/> offers.</summary>
	private const int AltLayoutCount = 24;

	/// <summary>One alt-region layout family, in bottom-left-biased geometry; the roller adds the
	/// mirrors, so a family only needs ONE handedness.
	/// A switch, NOT an array of lambdas: s&amp;box remaps delegates across a hotload, and a lambda
	/// parked in a static field has no counterpart in the reloaded assembly (it throws "Unable to find
	/// matching substitution for a lambda method" when called).</summary>
	private static RectF[] BuildAltLayout( int layout, Random rng )
	{
		switch ( layout )
		{
			case 0:   // Horizontal band.
			{
				float h = AltLen( rng, 40, 104 ), b = AltStart( rng, h );
				return new[] { new RectF( AltLo, b, AltHi, b + h ) };
			}
			case 1:   // Vertical band.
			{
				float w = AltLen( rng, 40, 104 ), l = AltStart( rng, w );
				return new[] { new RectF( l, AltLo, l + w, AltHi ) };
			}
			case 2:   // Centred box.
			{
				float w = AltLen( rng, 80, 144 ), h = AltLen( rng, 80, 144 );
				return new[] { AltBox( AltMid - w / 2f, AltMid - h / 2f, AltMid + w / 2f, AltMid + h / 2f ) };
			}
			case 3:   // Free-floating box anywhere.
			{
				float w = AltLen( rng, 40, 120 ), h = AltLen( rng, 40, 120 );
				float l = AltStart( rng, w ), b = AltStart( rng, h );
				return new[] { new RectF( l, b, l + w, b + h ) };
			}
			case 4:   // One half (left; mirrors give right / bottom / top).
				return rng.Next( 2 ) == 0
					? new[] { new RectF( AltLo, AltLo, AltMid, AltHi ) }
					: new[] { new RectF( AltLo, AltLo, AltHi, AltMid ) };
			case 5:   // Opposite quadrants.
				return new[] { new RectF( AltLo, AltLo, AltMid, AltMid ), new RectF( AltMid, AltMid, AltHi, AltHi ) };
			case 6:   // Three quadrants (an L of three).
				return new[] { new RectF( AltLo, AltLo, AltMid, AltHi ), new RectF( AltMid, AltLo, AltHi, AltMid ) };
			case 7:   // Corner box.
			{
				float w = AltLen( rng, 48, 128 ), h = AltLen( rng, 48, 128 );
				return new[] { new RectF( AltLo, AltLo, AltLo + w, AltLo + h ) };
			}
			case 8:   // Two boxes in adjacent corners (bottom pair).
			{
				float w = AltLen( rng, 40, 96 ), h = AltLen( rng, 40, 112 );
				return new[] { new RectF( AltLo, AltLo, AltLo + w, AltLo + h ), new RectF( AltHi - w, AltLo, AltHi, AltLo + h ) };
			}
			case 9:   // Two boxes in opposite corners, unequal sizes.
			{
				float w1 = AltLen( rng, 40, 104 ), h1 = AltLen( rng, 40, 104 ), w2 = AltLen( rng, 40, 104 ), h2 = AltLen( rng, 40, 104 );
				return new[] { new RectF( AltLo, AltLo, AltLo + w1, AltLo + h1 ), new RectF( AltHi - w2, AltHi - h2, AltHi, AltHi ) };
			}
			case 10:   // Border frame (four non-overlapping strips).
			{
				float t = AltLen( rng, 8, 32 );
				return new[]
				{
					new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltHi - t, AltHi, AltHi ),
					new RectF( AltLo, AltLo + t, AltLo + t, AltHi - t ), new RectF( AltHi - t, AltLo + t, AltHi, AltHi - t ),
				};
			}
			case 11:   // Picture frame: a hollow box outline somewhere in the field.
			{
				float w = AltLen( rng, 96, 176 ), h = AltLen( rng, 96, 176 ), t = AltLen( rng, 8, 24 );
				float l = AltStart( rng, w ), b = AltStart( rng, h );
				return new[]
				{
					new RectF( l, b, l + w, b + t ), new RectF( l, b + h - t, l + w, b + h ),
					new RectF( l, b + t, l + t, b + h - t ), new RectF( l + w - t, b + t, l + w, b + h - t ),
				};
			}
			case 12:   // Target: border frame plus a centre box.
			{
				float t = AltLen( rng, 8, 24 ), c = AltLen( rng, 40, 96 );
				return new[]
				{
					new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltHi - t, AltHi, AltHi ),
					new RectF( AltLo, AltLo + t, AltLo + t, AltHi - t ), new RectF( AltHi - t, AltLo + t, AltHi, AltHi - t ),
					AltBox( AltMid - c / 2f, AltMid - c / 2f, AltMid + c / 2f, AltMid + c / 2f ),
				};
			}
			case 13:   // Plus / cross (vertical bar + two horizontal stubs, no overlap).
			{
				float t = AltLen( rng, 24, 64 ), cx = AltSnap( AltMid + ( rng.Next( 5 ) - 2 ) * 16 ), cy = AltSnap( AltMid + ( rng.Next( 5 ) - 2 ) * 16 );
				float l = cx - t / 2f, r = cx + t / 2f, b = cy - t / 2f, tp = cy + t / 2f;
				return new[] { new RectF( l, AltLo, r, AltHi ), new RectF( AltLo, b, l, tp ), new RectF( r, b, AltHi, tp ) };
			}
			case 14:   // L: a bottom band plus a left band.
			{
				float t = AltLen( rng, 24, 72 ), u = AltLen( rng, 24, 72 );
				return new[] { new RectF( AltLo, AltLo, AltHi, AltLo + t ), new RectF( AltLo, AltLo + t, AltLo + u, AltHi ) };
			}
			case 15:   // Evenly spaced parallel bands (2–4), horizontal or vertical.
			{
				int n = rng.Next( 2, 5 );
				float span = ( AltHi - AltLo ) / n;
				float t = AltLen( rng, 16, (int)( span / 2f ) / 8 * 8 );
				bool horizontal = rng.Next( 2 ) == 0;
				var rects = new RectF[n];
				for ( int i = 0; i < n; i++ )
				{
					float a = AltSnap( AltLo + span * i + ( span - t ) / 2f ), z = AltSnap( a + t );
					rects[i] = horizontal ? new RectF( AltLo, a, AltHi, z ) : new RectF( a, AltLo, z, AltHi );
				}
				return rects;
			}
			case 16:   // Pillars: two symmetric side bands. The inset is capped so the pair can meet but never overlap.
			{
				float w = AltLen( rng, 24, 72 );
				int maxInset = (int)MathF.Max( 0f, MathF.Floor( ( ( AltHi - AltLo ) / 2f - w ) / 8f ) * 8f );
				float inset = AltLen( rng, 0, Math.Min( 48, maxInset ) );
				bool vertical = rng.Next( 2 ) == 0;
				return vertical
					? new[] { new RectF( AltLo + inset, AltLo, AltLo + inset + w, AltHi ), new RectF( AltHi - inset - w, AltLo, AltHi - inset, AltHi ) }
					: new[] { new RectF( AltLo, AltLo + inset, AltHi, AltLo + inset + w ), new RectF( AltLo, AltHi - inset - w, AltHi, AltHi - inset ) };
			}
			case 17:   // Big checkerboard: 3x3 or 4x4 cells, alternating.
			{
				int n = rng.Next( 2 ) == 0 ? 3 : 4;
				float cell = ( AltHi - AltLo ) / n;
				var rects = new List<RectF>();
				for ( int y = 0; y < n; y++ )
					for ( int x = 0; x < n; x++ )
						if ( ( x + y ) % 2 == 0 )
							rects.Add( AltBox( AltLo + cell * x, AltLo + cell * y, AltLo + cell * ( x + 1 ), AltLo + cell * ( y + 1 ) ) );
				return rects.ToArray();
			}
			case 18:   // Diagonal chain of boxes, bottom-left to top-right.
			{
				int n = rng.Next( 3, 5 );
				float cell = ( AltHi - AltLo ) / n, shrink = AltLen( rng, 0, 16 );
				var rects = new RectF[n];
				for ( int i = 0; i < n; i++ )
					rects[i] = AltBox( AltLo + cell * i + shrink, AltLo + cell * i + shrink, AltLo + cell * ( i + 1 ) - shrink, AltLo + cell * ( i + 1 ) - shrink );
				return rects;
			}
			case 19:   // Staircase filling one corner: full-width bottom step, each step above shorter.
			{
				int n = rng.Next( 3, 6 );
				float step = AltSnap( ( AltHi - AltLo ) / n ) - AltLo;
				var rects = new RectF[n];
				for ( int i = 0; i < n; i++ )
					rects[i] = AltBox( AltLo, AltLo + step * i, AltHi - step * i, AltLo + step * ( i + 1 ) );
				return rects;
			}
			case 20:   // Scattered boxes: 2–4 cells of a 3x3 grid, each inset a little.
			{
				int count = rng.Next( 2, 5 );
				float cell = ( AltHi - AltLo ) / 3f, inset = AltLen( rng, 0, 16 );
				var picks = Enumerable.Range( 0, 9 ).OrderBy( _ => rng.Next() ).Take( count );
				return picks.Select( i => AltBox( AltLo + cell * ( i % 3 ) + inset, AltLo + cell * ( i / 3 ) + inset,
					AltLo + cell * ( i % 3 + 1 ) - inset, AltLo + cell * ( i / 3 + 1 ) - inset ) ).ToArray();
			}
			case 21:   // Half plus a thin strip along the far edge.
			{
				float t = AltLen( rng, 8, 32 );
				return new[] { new RectF( AltLo, AltLo, AltMid, AltHi ), new RectF( AltHi - t, AltLo, AltHi, AltHi ) };
			}
			case 22:   // Split band: a horizontal band with a gap in the middle.
			{
				float h = AltLen( rng, 32, 96 ), b = AltStart( rng, h ), gap = AltLen( rng, 24, 80 );
				return new[] { new RectF( AltLo, b, AltSnap( AltMid - gap / 2f ), b + h ), new RectF( AltSnap( AltMid + gap / 2f ), b, AltHi, b + h ) };
			}
			default:   // Nested corner squares (two concentric quarter-frames in one corner).
			{
				float s = AltLen( rng, 96, 160 ), t = AltLen( rng, 16, 32 );
				return new[]
				{
					new RectF( AltLo, AltLo + s - t, AltLo + s, AltLo + s ), new RectF( AltLo + s - t, AltLo, AltLo + s, AltLo + s - t ),
					new RectF( AltLo, AltLo, AltLo + s - 2 * t, AltLo + s - 2 * t ),
				};
			}
		}
	}

	private static T WeightedPick<T>( Random rng, IReadOnlyList<T> entries, Func<T, int> weight )
	{
		int total = 0;
		for ( int i = 0; i < entries.Count; i++ )
			total += Math.Max( 0, weight( entries[i] ) );

		int roll = rng.Next( total );
		for ( int i = 0; i < entries.Count; i++ )
		{
			roll -= Math.Max( 0, weight( entries[i] ) );
			if ( roll < 0 ) return entries[i];
		}
		return entries[^1];
	}

	// Float-weight variant (templates). Consumes exactly one rng draw, same as the int version.
	private static T WeightedPick<T>( Random rng, IReadOnlyList<T> entries, Func<T, float> weight )
	{
		double total = 0;
		for ( int i = 0; i < entries.Count; i++ )
			total += Math.Max( 0f, weight( entries[i] ) );

		double roll = rng.NextDouble() * total;
		for ( int i = 0; i < entries.Count; i++ )
		{
			roll -= Math.Max( 0f, weight( entries[i] ) );
			if ( roll < 0 ) return entries[i];
		}
		return entries[^1];
	}

	// Shared with daily_validate_templates so the validator checks the exact mirrored geometry.
	internal static RectF MirrorRect( RectF r ) => new( Arena.WIDTH - r.Right, r.Bottom, Arena.WIDTH - r.Left, r.Top );
	internal static RectF MirrorRectY( RectF r ) => new( r.Left, Arena.HEIGHT - r.Top, r.Right, Arena.HEIGHT - r.Bottom );
	internal static Vector2 MirrorPoint( Vector2 p ) => new( Arena.WIDTH - p.x, p.y );

	internal static Direction MirrorSide( Direction side ) => side switch
	{
		Direction.Left => Direction.Right,
		Direction.Right => Direction.Left,
		_ => side,
	};
}