Game/DailyDebugCommands.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sandbox;

namespace BlockParty;

/// <summary>
/// TEMP DEBUG console commands for evaluating the daily-challenge generator: play any past/future
/// day without touching progress, sweep-dump generation summaries to eyeball distributions, and
/// validate the curated obstacle templates.
/// </summary>
public static class DailyDebugCommands
{
	/// <summary>Play any day's generated daily (past or future). Never marks progress, counts an
	/// attempt, or submits — see <see cref="GameStage.IsDebugRun"/>.</summary>
	[ConCmd( "daily_play" )]
	public static void PlayCmd( string dailyId = "" )
	{
		if ( !Game.IsEditor ) return;
		var gm = GameManager.Instance;
		if ( gm is null )
		{
			Log.Warning( "[BlockParty] daily_play: no GameManager in the scene." );
			return;
		}

		dailyId = string.IsNullOrWhiteSpace( dailyId ) ? DailyChallenge.TodayId : dailyId.Trim();
		if ( !DailyChallenge.TryParseId( dailyId, out _ ) )
		{
			Log.Warning( $"[BlockParty] daily_play: '{dailyId}' is not a yyyyMMdd day id." );
			return;
		}

		// Regenerate fresh so config/generator edits under iteration are picked up.
		DailyLevels.Remove( dailyId );
		var daily = DailyLevels.Get( dailyId );
		Log.Info( $"[BlockParty] daily_play {dailyId}: {Describe( daily )}" );
		gm.StartDailyChallengeDebug( dailyId );
	}

	/// <summary>Dump one summary line per day plus aggregate distribution tables, to evaluate how the
	/// generator behaves over a date range. Usage: daily_dump [startId=today] [count=30].</summary>
	[ConCmd( "daily_dump" )]
	public static void DumpCmd( string startId = "", int count = 30 )
	{
		if ( !Game.IsEditor ) return;
		startId = string.IsNullOrWhiteSpace( startId ) ? DailyChallenge.TodayId : startId.Trim();
		if ( !DailyChallenge.TryParseId( startId, out _ ) )
		{
			Log.Warning( $"[BlockParty] daily_dump: '{startId}' is not a yyyyMMdd day id." );
			return;
		}
		count = Math.Clamp( count, 1, 3650 );

		var sb = new StringBuilder();
		var typeCounts = new Dictionary<BlockType, int>();
		var templateCounts = new Dictionary<string, int>();
		var attemptCounts = new Dictionary<string, int>();
		var modifierHistogram = new Dictionary<int, int>();
		var songCounts = new Dictionary<string, int>();
		var songLastDay = new Dictionary<string, int>();
		var patternCounts = new Dictionary<PlayfieldPattern, int>();
		var customTiles = new HashSet<string>();
		int altDays = 0, oneSlotCustomDays = 0, bothCustomDays = 0, noAltCustomDays = 0, anyCustomDays = 0, bgSplitDays = 0;
		int forcedDays = 0, spikeDays = 0, totalBlocks = 0;
		int minSongGap = int.MaxValue, closeRepeats = 0;

		string id = startId;
		for ( int i = 0; i < count; i++, id = DailyChallenge.AddDays( id, 1 ) )
		{
			var daily = DailyLevelGenerator.Generate( id );   // bypass the cache: always current code/config
			sb.AppendLine( $"{id} {Describe( daily )}" );

			foreach ( var t in daily.Level.Blocks )
			{
				typeCounts[t] = typeCounts.GetValueOrDefault( t ) + 1;
				totalBlocks++;
			}
			templateCounts[daily.TemplateId] = templateCounts.GetValueOrDefault( daily.TemplateId ) + 1;
			string attempts = daily.MaxAttempts?.ToString() ?? "inf";
			attemptCounts[attempts] = attemptCounts.GetValueOrDefault( attempts ) + 1;
			modifierHistogram[daily.Modifiers.Count] = modifierHistogram.GetValueOrDefault( daily.Modifiers.Count ) + 1;
			string song = daily.Level.Music ?? "-";
			songCounts[song] = songCounts.GetValueOrDefault( song ) + 1;
			if ( songLastDay.TryGetValue( song, out int lastDay ) )
			{
				int gap = i - lastDay;
				minSongGap = Math.Min( minSongGap, gap );
				if ( gap <= DailyMusic.RepeatWindowDays ) closeRepeats++;
			}
			songLastDay[song] = i;
			if ( daily.Level.ForcedCharacterId is not null ) forcedDays++;
			if ( daily.Level.SpikedWalls.Count > 0 || daily.Level.SpikedObstacleSides.Count > 0 ) spikeDays++;

			// Pattern usage, split by whether the day had an alternate region (the two slots roll
			// Custom on different odds — see LevelCosmeticsRandomizer.CustomPatternChance).
			var lvl = daily.Level;
			bool hasAlt = lvl.AlternatePlayfieldEnabled && lvl.AlternatePlayfieldRects.Count > 0;
			bool mainCustom = lvl.PlayfieldPattern == PlayfieldPattern.Custom;
			bool altCustom = hasAlt && lvl.AlternatePlayfieldPattern == PlayfieldPattern.Custom;
			patternCounts[lvl.PlayfieldPattern] = patternCounts.GetValueOrDefault( lvl.PlayfieldPattern ) + 1;
			if ( lvl.BackgroundBlockColors is { Count: 3 } ) bgSplitDays++;   // daily gen only sets 3 for the split scheme
			if ( hasAlt )
			{
				altDays++;
				patternCounts[lvl.AlternatePlayfieldPattern] = patternCounts.GetValueOrDefault( lvl.AlternatePlayfieldPattern ) + 1;
				if ( mainCustom && altCustom ) bothCustomDays++;
				else if ( mainCustom || altCustom ) oneSlotCustomDays++;
			}
			else if ( mainCustom ) noAltCustomDays++;
			if ( mainCustom || altCustom ) anyCustomDays++;
			// Distinct generated tiles, to confirm the families really vary day to day.
			if ( mainCustom && lvl.PlayfieldPatternRows is { Count: > 0 } mainRows )
				customTiles.Add( string.Join( "/", mainRows ) );
			if ( altCustom && lvl.AlternatePlayfieldPatternRows is { Count: > 0 } altRows )
				customTiles.Add( string.Join( "/", altRows ) );
		}

		sb.AppendLine( $"── {count} days from {startId} ──" );
		sb.AppendLine( "templates: " + string.Join( "  ", templateCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
		sb.AppendLine( "attempts:  " + string.Join( "  ", attemptCounts.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
		sb.AppendLine( "twists:    " + string.Join( "  ", modifierHistogram.OrderBy( kv => kv.Key ).Select( kv => $"{kv.Key}x:{kv.Value}" ) ) );
		sb.AppendLine( $"forced character days: {forcedDays}   spike days: {spikeDays}" );
		sb.AppendLine( $"songs: {songCounts.Count} of {DailyMusic.Pool.Count} used   " +
			$"closest repeat: {(minSongGap == int.MaxValue ? "never" : $"{minSongGap} days")}   " +
			$"repeats within {DailyMusic.RepeatWindowDays} days: {closeRepeats}   " +
			$"most played: {string.Join( "  ", songCounts.OrderByDescending( kv => kv.Value ).ThenBy( kv => kv.Key ).Take( 5 ).Select( kv => $"{kv.Key}:{kv.Value}" ) )}" );
		int noAltDays = count - altDays;
		string Pct( int n, int of ) => of > 0 ? $"{n * 100f / of:0.#}%" : "-";
		sb.AppendLine( "patterns (both slots): " + string.Join( "  ",
			patternCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value}" ) ) );
		sb.AppendLine( $"custom: {anyCustomDays} of {count} days   distinct generated tiles: {customTiles.Count}" );
		sb.AppendLine( $"  alt-playfield days {altDays}: one slot {oneSlotCustomDays} ({Pct( oneSlotCustomDays, altDays )}, " +
			$"target {LevelCosmeticsRandomizer.CustomPatternChance:P0})   both {bothCustomDays} " +
			$"({Pct( bothCustomDays, altDays )}, target {LevelCosmeticsRandomizer.BothCustomPatternChance:P0})" );
		sb.AppendLine( $"  no-alt days {noAltDays}: custom {noAltCustomDays} ({Pct( noAltCustomDays, noAltDays )}, " +
			$"target {LevelCosmeticsRandomizer.SoloCustomPatternChance:P0})" );
		sb.AppendLine( $"  split background blocks: {bgSplitDays} ({Pct( bgSplitDays, altDays )} of alt days, " +
			$"target {LevelCosmeticsRandomizer.ComplementaryAltPlayfieldChance * LevelCosmeticsRandomizer.AltBackgroundSplitChance:P0})" );
		sb.AppendLine( "block types (% of all blocks): " + string.Join( "  ",
			typeCounts.OrderByDescending( kv => kv.Value ).Select( kv => $"{kv.Key}:{kv.Value * 100 / Math.Max( 1, totalBlocks )}%" ) ) );

		Log.Info( $"[BlockParty] daily_dump {startId} x{count}\n{sb}" );
	}

	/// <summary>Sanity-check every obstacle template from Assets/daily-templates: spawn centres in
	/// bounds, no spawn footprint or player spawn intersecting an obstacle, no overlapping spawn
	/// footprints, valid spike references. Every rule is pure geometry under an x-reflection, so the
	/// mirrored variant can never disagree with the authored one and isn't checked separately.</summary>
	[ConCmd( "daily_validate_templates" )]
	public static void ValidateTemplatesCmd()
	{
		if ( !Game.IsEditor ) return;
		DailyTemplates.Reload();
		int errors = 0;
		var all = new List<ObstacleTemplate>( DailyTemplates.All );
		foreach ( var template in all )
		{
			errors += Validate( template );
		}

		if ( errors == 0 )
			Log.Info( $"[BlockParty] daily_validate_templates: {all.Count} templates OK." );
		else
			Log.Warning( $"[BlockParty] daily_validate_templates: {errors} problem(s) found (see above)." );
	}

	/// <summary>Drop all cached generated dailies and rescan the template and music folders (hotload
	/// hygiene while iterating on the generator/templates).</summary>
	[ConCmd( "daily_clear_cache" )]
	public static void ClearCacheCmd()
	{
		if ( !Game.IsEditor ) return;
		DailyLevels.ClearCache();
		DailyTemplates.Reload();
		DailyMusic.Reload();
		Log.Info( "[BlockParty] Cleared the generated-daily cache and reloaded the template + song libraries." );
	}

	/// <summary>Toggle hub paging into future/pre-earliest days, to preview upcoming dailies in place.</summary>
	[ConCmd( "daily_debug_nav" )]
	public static void DebugNavCmd()
	{
		if ( !Game.IsEditor ) return;
		DailyChallengeStage.DebugNavigation = !DailyChallengeStage.DebugNavigation;
		Log.Info( $"[BlockParty] Daily debug navigation {(DailyChallengeStage.DebugNavigation ? "ON — the hub can page into future days" : "off")}." );
	}

	internal static string Describe( DailyDef daily )
	{
		if ( daily is null ) return "(no def — daily templates not loaded yet)";
		var level = daily.Level;
		string blocks = string.Join( ",", level.Blocks );
		string spikes = "-";
		if ( level.SpikedWalls.Count > 0 || level.SpikedObstacleSides.Count > 0 )
		{
			var parts = level.SpikedWalls.Select( s => $"wall:{s}" )
				.Concat( level.SpikedObstacleSides.Select( s => $"obst:{string.Join( "+", s.Sides )}" ) );
			spikes = string.Join( " ", parts );
		}
		string mods = daily.Modifiers.Count > 0 ? string.Join( " | ", daily.Modifiers ) : "-";
		string pins = level.PinnedCount > 0 ? $" pins={level.PinnedCount}" : "";
		string alt = level.AlternatePlayfieldEnabled && level.AlternatePlayfieldRects.Count > 0
			? $" alt{level.AlternatePlayfieldRects.Count}=" + DescribePattern( level.AlternatePlayfieldPattern,
				level.AlternatePlayfieldCellScale, level.AlternatePlayfieldPatternRows )
			: "";

		return $"seed={DailyChallenge.SeedFor( daily.DailyId )} template={daily.TemplateId} " +
			$"blocks={level.Blocks.Count}[{blocks}]{pins} spikes={spikes} " +
			$"char={level.ForcedCharacterId ?? "-"} attempts={daily.MaxAttempts?.ToString() ?? "inf"} " +
			$"song={level.Music ?? "-"} " +
			$"pattern={DescribePattern( level.PlayfieldPattern, level.PlayfieldCellScale, level.PlayfieldPatternRows )}{alt}" +
			$"{( level.BackgroundBlockColors is { Count: 3 } ? " bg=split" : "" )} mods=[{mods}]";
	}

	/// <summary>One pattern slot as "Name:cellScale", plus the tile art for a generated Custom (top row
	/// FIRST so the text reads the way the pattern draws; '#' = the dark colour).</summary>
	internal static string DescribePattern( PlayfieldPattern pattern, int cellScale, IReadOnlyList<string> rows )
	{
		string text = $"{pattern}:{cellScale}";
		if ( pattern != PlayfieldPattern.Custom || rows is not { Count: > 0 } ) return text;
		int width = rows.Max( r => r?.Length ?? 0 );
		string art = string.Join( "/", rows.Reverse().Select( r => ( r ?? "" ).Replace( '1', '#' ).Replace( '0', '.' ) ) );
		return $"{text}({width}x{rows.Count}){art}";
	}

	private static int Validate( ObstacleTemplate template )
	{
		string name = template.Id;
		int errors = 0;
		void Fail( string message ) { Log.Warning( $"[BlockParty] template '{name}': {message}" ); errors++; }

		var obstacles = template.Obstacles;
		var spawns = template.SpawnPositions;
		var players = template.PlayerSpawns;

		const float blockSize = 40f;   // matches Block.BLOCK_SIZE (private there)
		const float half = blockSize / 2f;
		float min = Arena.WALL_SIZE + half, max = Arena.WIDTH - Arena.WALL_SIZE - half;
		float ceiling = Arena.HEIGHT - Arena.WALL_SIZE;
		float playerHalfW = Player.COLLISION_SIZE.x / 2f;
		static string R( RectF r ) => $"[{r.Left},{r.Bottom},{r.Right},{r.Top}]";
		bool IsFence( int i ) => template.FenceObstacles.Contains( i );
		bool IsGlass( int i ) => template.GlassObstacles.Contains( i );

		foreach ( var rect in obstacles )
			if ( rect.Left < 0 || rect.Bottom < 0 || rect.Right > Arena.WIDTH || rect.Top > Arena.HEIGHT || rect.Left >= rect.Right || rect.Bottom >= rect.Top )
				Fail( $"obstacle {R( rect )} is degenerate or outside the arena" );

		for ( int i = 0; i < spawns.Length; i++ )
		{
			var s = spawns[i];
			if ( s.x < min || s.x > max || s.y < min || s.y > max )
				Fail( $"spawn {s} centre outside the valid range [{min},{max}]" );

			// Glass is solid only to players — a block may sit in it. Fences ARE block-solid.
			for ( int o = 0; o < obstacles.Length; o++ )
				if ( !IsGlass( o ) && Overlaps( s.x - half, s.y - half, s.x + half, s.y + half, obstacles[o] ) )
					Fail( $"spawn {s} footprint intersects block-solid obstacle {R( obstacles[o] )}" );

			for ( int j = i + 1; j < spawns.Length; j++ )
				if ( Math.Abs( s.x - spawns[j].x ) < blockSize && Math.Abs( s.y - spawns[j].y ) < blockSize )
					Fail( $"spawns {s} and {spawns[j]} footprints overlap" );

			foreach ( var player in players )
			{
				if ( Math.Abs( s.x - player.x ) < half && Math.Abs( s.y - player.y ) < half )
					Fail( $"spawn {s} footprint contains player spawn {player}" );

			}
		}

		if ( players.Length == 0 )
			Fail( "no player spawns" );
		// Fences are solid only to blocks — a player may spawn inside one. Glass IS player-solid.
		foreach ( var player in players )
			for ( int o = 0; o < obstacles.Length; o++ )
				if ( !IsFence( o ) && player.x > obstacles[o].Left && player.x < obstacles[o].Right && player.y > obstacles[o].Bottom && player.y < obstacles[o].Top )
					Fail( $"player spawn {player} is inside player-solid obstacle {R( obstacles[o] )}" );

		// Wrap-character (WRAITH) spawn trace. A wrap character passes through everything but glass:
		// it sinks into any obstacle under it and emerges from the opposite face (chaining across flush
		// rects), falls out of the floor and re-enters at the ceiling, already falling. Emerging INTO a
		// spawn block flush with an exit face crushes; a spikeable exit face (the ceiling, an obstacle
		// underside) kills. Rolled slots may be empty on a given day, so only a PINNED block stops the
		// trace; "spikeable" covers rolled (live some days) and authored spikes alike.
		bool ceilingSpikeable = template.SpikeableWalls.Contains( Direction.Up );
		bool SideSpikeable( int o, Direction side )
			=> template.SpikeableObstacleSides.Any( e => e.Obstacle == o && e.Sides is not null && e.Sides.Contains( side ) );
		foreach ( var player in players )
		{
			float colL = player.x - playerHalfW, colR = player.x + playerHalfW;
			bool InColumn( float left, float right ) => right > colL && left < colR;
			float h = player.y - Player.COLLISION_SIZE.y / 2f;
			bool wrapped = false;
			bool exitFace = false;   // h is a face the wrapper EMERGES from (obstacle underside / ceiling), not air

			for ( int guard = 0; guard < 64; guard++ )
			{
				// The highest thing in the column at or below h. A block tied with an obstacle wins: a
				// block flush with the face being exited is exactly the crush.
				float best = float.NegativeInfinity; int bestOb = -1, bestSpawn = -1;
				for ( int o = 0; o < obstacles.Length; o++ )
				{
					var r = obstacles[o];
					if ( IsFence( o ) || !InColumn( r.Left, r.Right ) || r.Top > h + 0.5f || r.Top <= best ) continue;
					best = r.Top; bestOb = o; bestSpawn = -1;
				}
				for ( int i = 0; i < spawns.Length; i++ )
				{
					float top = spawns[i].y + half;
					if ( !InColumn( spawns[i].x - half, spawns[i].x + half ) || top > h + 0.5f || top < best ) continue;
					best = top; bestSpawn = i; bestOb = -1;
				}

				if ( bestSpawn >= 0 )
				{
					var sp = spawns[bestSpawn];
					if ( exitFace && best >= h - 0.5f )
					{
						Fail( $"spawn {sp} is flush under the {(wrapped ? "ceiling / obstacle" : "obstacle")} exit face in player spawn {player}'s column — a wrap character emerging there is crushed on spawn" );
						break;
					}
					if ( bestSpawn < template.SpawnPins.Length && template.SpawnPins[bestSpawn] is not null )
						break;                 // pinned: always there to land on
					h = sp.y - half;           // rolled: may be absent that day, keep falling (through air)
					exitFace = false;
					continue;
				}
				if ( bestOb >= 0 )
				{
					if ( IsGlass( bestOb ) )
						break;                 // lands on glass
					if ( SideSpikeable( bestOb, Direction.Down ) )
					{
						Fail( $"obstacle {R( obstacles[bestOb] )} in player spawn {player}'s column has a spikeable underside — a wrap character sinking through it is spiked on the exit face" );
						break;
					}
					h = obstacles[bestOb].Bottom;   // emerge from the underside
					exitFace = true;
					continue;
				}
				// Nothing below: out through the floor, in at the ceiling (once — a second pass loops).
				if ( wrapped )
					break;
				wrapped = true;
				h = ceiling;
				exitFace = true;
				if ( ceilingSpikeable )
				{
					Fail( $"ceiling is spikeable and nothing stops player spawn {player}'s fall — a wrap character falling out the bottom emerges into ceiling spikes" );
					break;
				}
			}
		}

		foreach ( var (obstacle, sides) in template.SpikeableObstacleSides )
		{
			if ( obstacle < 0 || obstacle >= template.Obstacles.Length )
				Fail( $"spikeable obstacle index {obstacle} out of range" );
			if ( sides is null || sides.Length == 0 || sides.Contains( Direction.None ) )
				Fail( $"spikeable obstacle {obstacle} has invalid sides" );
		}
		if ( template.SpikeableWalls.Contains( Direction.None ) )
			Fail( "spikeable walls contain None" );

		foreach ( var index in template.VisionObstacles )
			if ( index < 0 || index >= template.Obstacles.Length )
				Fail( $"vision obstacle index {index} out of range" );

		foreach ( var index in template.FenceObstacles )
		{
			if ( index < 0 || index >= template.Obstacles.Length )
				Fail( $"fence obstacle index {index} out of range" );
			// Fences can't be spiked (nothing lethal to a player who passes through them) and can't
			// also block vision — the editor enforces both; a hand-edited template might not.
			if ( template.VisionObstacles.Contains( index ) )
				Fail( $"obstacle {index} is flagged both fence and vision" );
			foreach ( var (obstacle, _) in template.SpikeableObstacleSides )
				if ( obstacle == index )
					Fail( $"fence obstacle {index} is listed as spikeable" );
		}

		foreach ( var index in template.GlassObstacles )
		{
			if ( index < 0 || index >= template.Obstacles.Length )
				Fail( $"glass obstacle index {index} out of range" );
			// Glass CAN be spiked (a player-only hazard), but the fence/vision flags are exclusive.
			if ( template.VisionObstacles.Contains( index ) )
				Fail( $"obstacle {index} is flagged both glass and vision" );
			if ( template.FenceObstacles.Contains( index ) )
				Fail( $"obstacle {index} is flagged both glass and fence" );
		}

		// Coins: inside the walls, and never buried in a player-solid obstacle (a normal obstacle or
		// glass makes the coin uncollectable; a fence is fine — players pass through fences).
		var coins = template.Coins;
		const float coinHalfW = Coin.ART_W / 2f, coinHalfH = Coin.ART_H / 2f;
		foreach ( var coin in coins )
		{
			if ( coin.x < Arena.WALL_SIZE + coinHalfW || coin.x > Arena.WIDTH - Arena.WALL_SIZE - coinHalfW
				|| coin.y < Arena.WALL_SIZE + coinHalfH || coin.y > Arena.HEIGHT - Arena.WALL_SIZE - coinHalfH )
				Fail( $"coin {coin} is outside the arena walls" );

			for ( int i = 0; i < obstacles.Length; i++ )
			{
				if ( template.FenceObstacles.Contains( i ) )
					continue;
				if ( Overlaps( coin.x - coinHalfW, coin.y - coinHalfH, coin.x + coinHalfW, coin.y + coinHalfH, obstacles[i] ) )
					Fail( $"coin {coin} intersects player-solid obstacle {R( obstacles[i] )} (unreachable)" );
			}
		}

		// Pins align index-wise with spawns; a longer pin list points at nothing.
		if ( template.SpawnPins.Length > template.SpawnPositions.Length )
			Fail( $"{template.SpawnPins.Length} pin slots but only {template.SpawnPositions.Length} spawns" );

		// Random block counts clamp to the FREE (unpinned) spawn slots; pinned blocks read as authored
		// geometry (enclosed, phase-started turrets etc.), not rolled blocks. The win only fires when
		// a block REACHES max phase: no rollable slot and no pin still below max means nothing can
		// ever get there (Build clamps the count to 0) — an unwinnable daily.
		int freeSlots = spawns.Length - template.SpawnPins.Count( p => p is not null );
		if ( freeSlots <= 0 && !template.SpawnPins.Any( p => p is not null && p.Phase < Block.NUM_PHASES - 1 ) )
			Fail( "no unpinned spawn slots and no pin below max phase — no block can ever reach max phase (unwinnable)" );
		else if ( freeSlots <= 0 )
			Log.Warning( $"[BlockParty] template '{name}': no unpinned spawn positions — nothing is rolled, only the pinned blocks play" );

		return errors;
	}

	private static bool Overlaps( float left, float bottom, float right, float top, RectF rect )
		=> left < rect.Right && right > rect.Left && bottom < rect.Top && top > rect.Bottom;
}