Game/ReplayRegression.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace BlockParty;

/// <summary>One per-sim-second checkpoint of a regression trace: the cumulative state hash at that
/// recorded-input step, plus the player position so a divergence report reads as "player was HERE,
/// is now THERE" instead of just a hash mismatch.</summary>
public sealed class RegressionCheckpoint
{
	public int Step { get; set; }
	public uint Hash { get; set; }
	public float PlayerX { get; set; }
	public float PlayerY { get; set; }
}

/// <summary>Golden trace of one replay simulated headless: per-second checkpoints plus how and where
/// the run ended. Two traces of the same replay on the same sim behaviour are bit-identical, so any
/// difference IS a behaviour change.</summary>
public sealed class RegressionTrace
{
	/// <summary>Human-readable identity for reports (submitter, score, level, length).</summary>
	public string Label { get; set; }

	/// <summary>Recorded-frame index the run ended at (game-over reproduced or input exhausted).</summary>
	public int FinishedAtStep { get; set; }

	/// <summary>Cumulative state hash over the whole run (covers the tail past the last checkpoint).</summary>
	public uint FinalHash { get; set; }

	/// <summary>Whether playback flagged a desync against the RECORDING itself (score mismatch /
	/// input exhausted early). A baseline recorded with this true is suspect — the replay didn't
	/// reproduce its original run even when blessed.</summary>
	public bool DesyncedVsRecording { get; set; }

	public List<RegressionCheckpoint> Checkpoints { get; set; } = new();
}

/// <summary>The persisted baseline file: every blessed trace keyed by replay identity.</summary>
public sealed class RegressionBaselines
{
	public int SimVersion { get; set; }
	public string BlessedAt { get; set; }
	public Dictionary<string, RegressionTrace> Traces { get; set; } = new();
}

/// <summary>
/// Replay-based physics regression harness. The sim is deterministic, so every saved replay doubles
/// as a physics test: <c>replay_bless</c> runs each replayable run in the local archive headless
/// (no rendering, no audio) and stores a golden trace of the sim state; after changing anything that
/// touches the simulation, <c>replay_verify</c> re-runs them and reports the first second at which
/// each run's state drifts from its blessed trace. An intentional behaviour change is accepted by
/// re-blessing. Favourited repro replays make ideal permanent fixtures (favourites are never trimmed
/// from the archive). The heavy lifting (driving the sim) lives in
/// <see cref="GameManager.RunReplayRegression"/> so it reuses the exact playback step path.
/// </summary>
public static class ReplayRegression
{
	public const string BaselineFilePath = "blockparty_replay_baselines.json";

	/// <summary>Checkpoint cadence in recorded steps: once per sim-second localises a divergence to
	/// the second it first appears while keeping a 10-minute run's trace to ≤600 checkpoints.</summary>
	public const int CHECKPOINT_EVERY = Arena.TICK_RATE;

	// FNV-1a, folded 32 bits at a time — we need cheap and deterministic, not cryptographic.
	public const uint FNV_OFFSET = 2166136261u;
	private const uint FNV_PRIME = 16777619u;

	/// <summary>Record the current sim behaviour of every replayable local run as the golden baselines.</summary>
	[ConCmd( "replay_bless" )]
	public static void BlessCmd()
	{
		if ( !Game.IsEditor ) return;
		GameManager.Instance?.RunReplayRegression( bless: true );
	}

	/// <summary>Re-run every replayable local run and report drift against the blessed baselines.</summary>
	[ConCmd( "replay_verify" )]
	public static void VerifyCmd()
	{
		if ( !Game.IsEditor ) return;
		GameManager.Instance?.RunReplayRegression( bless: false );
	}

	/// <summary>Delete every archived run recorded before <see cref="Sim.MIN_REPLAY_VERSION"/>. Those
	/// can never replay again (no gate reaches below the release version), yet the archive keeps them
	/// and the My Replays screen hides them, so they'd otherwise pad the harness's skip count forever.
	/// Favourites go too — a heart can't keep a run the sim no longer understands.</summary>
	[ConCmd( "replay_purge_stale" )]
	public static void PurgeStaleCmd()
	{
		if ( !Game.IsEditor ) return;
		int removed = LocalReplays.PurgeBelowMinVersion( out int favourites );
		string favNote = favourites > 0 ? $" ({favourites} favourite(s) among them)" : "";
		Log.Info( $"[replay-regression] purged {removed} archived run(s) recorded before sim v{Sim.MIN_REPLAY_VERSION}{favNote}; {LocalReplays.All.Count} remain." );
	}

	/// <summary>Stable identity of a replay payload: level + seed + a hash of the full deterministic
	/// context (input stream, length, character, daily, post-release sim version, and — for test-play
	/// replays — the embedded level itself). Survives archive re-ordering and renames.</summary>
	public static string KeyFor( RunData run )
	{
		uint h = FNV_OFFSET;
		h = FoldString( h, run.InputDeltas );
		h = Fold( h, run.Seed );
		h = Fold( h, run.StepCount );
		h = FoldString( h, run.LevelId );
		h = FoldString( h, run.DailyId );
		h = FoldString( h, run.CharacterId );
		// A test-play replay's geometry is the embedded (mid-edit) level, not the on-disk one its id
		// names — fold its canonical JSON so two replays sharing an id but different embedded geometry
		// can't collide on one baseline. Folded only when present, so normal replays' keys are unchanged.
		if ( run.LevelData is not null )
			h = FoldString( h, EditorLevel.FromLevelDef( run.LevelData ).ToJsonString() );
		// The recorded sim version picks the physics path, so it's identity too: the same payload on two
		// versions must not share a trace. Folded only past the release version so every v40 key — and
		// every baseline blessed at release — stays valid.
		if ( run.SimVersion > Sim.MIN_REPLAY_VERSION )
			h = Fold( h, run.SimVersion );
		return $"{run.LevelId ?? "classic"}-s{run.Seed}-{h:X8}";
	}

	public static string LabelFor( LocalReplayEntry entry )
	{
		var run = entry.Run;
		string who = string.IsNullOrWhiteSpace( entry.DisplayName ) ? "?" : entry.DisplayName;
		string level = string.IsNullOrEmpty( run.LevelId ) ? "classic" : run.LevelId;
		return $"{who}: {run.FinalScore} on {level}, {run.StepCount} steps, seed {run.Seed}";
	}

	/// <summary>Fold one just-ticked sim step's gameplay state into the rolling hash: player and
	/// impostor kinematics plus every block's position and phase. Deliberately excludes cosmetic
	/// state (particles, shakes, sprites) — the trace tracks the SIMULATION.</summary>
	public static uint FoldStep( GameStage gs, uint h )
	{
		if ( gs.Players.Count <= 1 )
		{
			var p = gs.Player;
			if ( p is not null )
			{
				h = Fold( h, BitConverter.SingleToInt32Bits( p.X ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.Y ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.VelX ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.VelY ) );
				// Death timing matters to the trace like it already does in the multi-player branch.
				// NOTE: changed every baseline hash — re-bless.
				h = Fold( h, p.IsDead ? 1 : 0 );
			}
			else
			{
				h = Fold( h, unchecked((int)0xDEADDEAD) );
			}
		}
		else
		{
			foreach ( var p in gs.Players )
			{
				h = Fold( h, BitConverter.SingleToInt32Bits( p.X ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.Y ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.VelX ) );
				h = Fold( h, BitConverter.SingleToInt32Bits( p.VelY ) );
				h = Fold( h, p.IsDead ? 1 : 0 );
			}
			h = Fold( h, gs.ActivePlayerIndex );
		}

		foreach ( var imp in gs.Impostors )
		{
			h = Fold( h, BitConverter.SingleToInt32Bits( imp.X ) );
			h = Fold( h, BitConverter.SingleToInt32Bits( imp.Y ) );
		}

		foreach ( var b in gs.GetBlocks() )
		{
			h = Fold( h, BitConverter.SingleToInt32Bits( b.X ) );
			h = Fold( h, BitConverter.SingleToInt32Bits( b.Y ) );
			h = Fold( h, b.Phase );
			// Side state is score-bearing sim state that phase alone misses: the ws3790638117 desyncs were a
			// single press differing with IDENTICAL positions/phases, invisible to the checkpoints until the
			// pressed sides were folded. Folds each side's full mode + suspended-press flag. NOTE: changes
			// every baseline hash — re-bless.
			h = Fold( h, b.SideStateCode() );
		}

		// Coins are sim state too (pickup order/timing feeds the score): fold the running counter and
		// each surviving coin's position. NOTE: adding this changed every baseline hash — re-bless
		// (replay_bless) after this change.
		h = Fold( h, gs.CoinsCollected );
		foreach ( var c in gs.Coins )
		{
			if ( c.Dead ) continue;
			h = Fold( h, BitConverter.SingleToInt32Bits( c.X ) );
			h = Fold( h, BitConverter.SingleToInt32Bits( c.Y ) );
		}

		return h;
	}

	public static void SaveBaselines( Dictionary<string, RegressionTrace> traces, double seconds )
	{
		var data = new RegressionBaselines
		{
			SimVersion = Sim.VERSION,
			BlessedAt = DateTimeOffset.Now.ToString( "u" ),
			Traces = traces,
		};
		FileSystem.Data.WriteJson( BaselineFilePath, data );

		int suspect = 0;
		foreach ( var t in traces.Values )
			if ( t.DesyncedVsRecording )
				suspect++;
		string suspectNote = suspect > 0
			? $" WARNING: {suspect} run(s) desynced against their own recording while blessing — those baselines capture already-drifted behaviour."
			: "";
		Log.Info( $"[replay-regression] BLESSED {traces.Count} run(s) as golden baselines (sim v{data.SimVersion}, {seconds:F1}s).{suspectNote} After a sim change, run replay_verify." );
	}

	/// <summary>"61 skipped: 60 below sim v40, 1 unusable" — one clause per non-zero reason, in enum
	/// order. Empty when nothing was skipped.</summary>
	public static string DescribeSkipped( List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped )
	{
		if ( skipped.Count == 0 ) return "";

		var parts = skipped
			.GroupBy( s => s.Why )
			.OrderBy( g => g.Key )
			.Select( g => g.Key switch
			{
				ReplayUnavailableReason.TooOld => $"{g.Count()} below sim v{Sim.MIN_REPLAY_VERSION} (replay_purge_stale removes these)",
				ReplayUnavailableReason.TooNew => $"{g.Count()} newer than sim v{Sim.VERSION}",
				ReplayUnavailableReason.LevelChanged => $"{g.Count()} level changed since recording",
				ReplayUnavailableReason.Unusable => $"{g.Count()} unusable (no input, corrupt, or removed level)",
				_ => $"{g.Count()} {g.Key}",
			} );
		return $"{skipped.Count} skipped: {string.Join( ", ", parts )}";
	}

	/// <summary>One line per skipped run that ISN'T merely pre-release — those are bulk noise the
	/// purge handles; the rest are individually worth a look (a level edit, an empty recording).</summary>
	private static void LogSkippedDetails( List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped )
	{
		foreach ( var (entry, why) in skipped )
		{
			if ( why == ReplayUnavailableReason.TooOld ) continue;
			string reason = why switch
			{
				ReplayUnavailableReason.LevelChanged => entry.Run.ReplayOutdatedReason,
				ReplayUnavailableReason.TooNew => entry.Run.ReplayOutdatedReason,
				ReplayUnavailableReason.Unusable when string.IsNullOrEmpty( entry.Run.InputDeltas ) => "no recorded input",
				ReplayUnavailableReason.Unusable => "corrupt payload or removed level",
				_ => why.ToString(),
			};
			Log.Info( $"[replay-regression]   skipped '{LabelFor( entry )}' — {reason}" );
		}
	}

	public static void CompareAndReport( Dictionary<string, RegressionTrace> current, List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)> skipped, double seconds )
	{
		var baselines = FileSystem.Data.ReadJsonOrDefault<RegressionBaselines>( BaselineFilePath, null );
		if ( baselines?.Traces is null || baselines.Traces.Count == 0 )
		{
			Log.Warning( "[replay-regression] no baselines found — run replay_bless first (on a known-good build)." );
			return;
		}

		// Each run re-sims at its RECORDED version (its gates take the old paths), so baselines blessed
		// on an older build must still match — a divergence here is a broken gate, not an expected drift.
		if ( baselines.SimVersion != Sim.VERSION )
			Log.Info( $"[replay-regression] baselines were blessed on sim v{baselines.SimVersion}; this build is v{Sim.VERSION}. Runs recorded on older versions replay through their version gates and must still match — any divergence below means a gate leaks the new behaviour into old replays." );

		int ok = 0;
		var fresh = new List<string>();
		var diverged = new List<string>();
		foreach ( var (key, now) in current )
		{
			if ( !baselines.Traces.TryGetValue( key, out var was ) )
			{
				fresh.Add( now.Label );
				continue;
			}

			string drift = DescribeDivergence( was, now );
			if ( drift is null ) ok++;
			else diverged.Add( $"'{now.Label}' — {drift}" );
		}

		int missing = 0;
		foreach ( var key in baselines.Traces.Keys )
			if ( !current.ContainsKey( key ) )
				missing++;

		string skippedDesc = DescribeSkipped( skipped );
		string staleNote = skippedDesc.Length > 0 ? $", {skippedDesc}" : "";
		string missingNote = missing > 0 ? $", {missing} baseline(s) have no matching archived replay any more" : "";
		string freshNote = fresh.Count > 0 ? $", {fresh.Count} new (no baseline — bless to include)" : "";

		if ( diverged.Count == 0 )
			Log.Info( $"[replay-regression] VERIFY OK: {ok}/{current.Count} run(s) match their baselines ({seconds:F1}s{staleNote}{freshNote}{missingNote})." );
		else
		{
			Log.Warning( $"[replay-regression] VERIFY: {diverged.Count} of {current.Count} run(s) DIVERGED from baseline, {ok} OK ({seconds:F1}s{staleNote}{freshNote}{missingNote}):" );
			foreach ( var line in diverged )
				Log.Warning( $"[replay-regression]   {line}" );
		}

		LogSkippedDetails( skipped );
	}

	/// <summary>Null when the traces match; otherwise a one-line description of the earliest drift.</summary>
	private static string DescribeDivergence( RegressionTrace was, RegressionTrace now )
	{
		int common = Math.Min( was.Checkpoints.Count, now.Checkpoints.Count );
		for ( int i = 0; i < common; i++ )
		{
			var b = was.Checkpoints[i];
			var c = now.Checkpoints[i];
			if ( b.Hash == c.Hash )
				continue;

			float t = b.Step / (float)Arena.TICK_RATE;
			return $"first drift by step {b.Step} (t={t:F1}s): player was ({b.PlayerX:F2},{b.PlayerY:F2}), now ({c.PlayerX:F2},{c.PlayerY:F2})";
		}

		if ( was.Checkpoints.Count != now.Checkpoints.Count )
			return $"run length changed after the last matching checkpoint: {now.Checkpoints.Count} checkpoint(s) vs baseline {was.Checkpoints.Count} (finished at step {now.FinishedAtStep} vs {was.FinishedAtStep})";

		if ( now.FinishedAtStep != was.FinishedAtStep )
			return $"finished at step {now.FinishedAtStep} vs baseline {was.FinishedAtStep} (checkpoints all match — drift is in the final partial second)";

		if ( now.FinalHash != was.FinalHash )
			return "tail state diverged after the last checkpoint (final hash mismatch)";

		if ( now.DesyncedVsRecording != was.DesyncedVsRecording )
			return $"recording desync flag changed: now {now.DesyncedVsRecording}, baseline {was.DesyncedVsRecording}";

		return null;
	}

	private static uint Fold( uint h, int v )
	{
		unchecked
		{
			h ^= (uint)v;
			h *= FNV_PRIME;
		}
		return h;
	}

	private static uint FoldString( uint h, string s )
	{
		if ( string.IsNullOrEmpty( s ) )
			return Fold( h, -1 );
		foreach ( char c in s )
			h = Fold( h, c );
		return h;
	}
}