Game/EditorUndoHistory.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Snapshot-based undo/redo history for the level editor (Ctrl+Z / Ctrl+Shift+Z in
/// <see cref="LevelEditorStage"/>). Entries are whole-level <see cref="EditorLevel.ToJsonString"/>
/// snapshots (a level is a few KB, so a deep history is cheap) — no per-operation command objects.
/// Each entry also carries the document's session-only <see cref="EditorLevel.ProjectSourceId"/>, so
/// undoing a document-replacing operation (reset, paste-import, template load) restores the right
/// to update the original file along with its layout, plus the document number those operations bump
/// — that's what tells two never-saved documents (both source-less) apart when one is saved.
/// Owned by <see cref="GameManager"/> rather than the stage so the history survives test-play round
/// trips and stage rebuilds, like the remembered editor session; never persisted to disk.
/// </summary>
public sealed class EditorUndoHistory
{
	/// <summary>One history state: the full-authoring level JSON, the project file it may update, and
	/// which document it belongs to (see <see cref="_document"/>).</summary>
	public readonly record struct Entry( string Json, string SourceId, int Document );

	private const int MAX_ENTRIES = 100;

	/// <summary>Rapid same-key checkpoints (typing in a HUD field, repeated nudges) within this window
	/// merge into the entry started by the first of them, so one undo reverts the whole burst.</summary>
	private const float COALESCE_SECONDS = 1.0f;

	private readonly List<Entry> _undo = new();
	private readonly List<Entry> _redo = new();

	/// <summary>Snapshot of the level as of the last checkpoint — the state undo steps back FROM.</summary>
	private Entry? _current;

	private string _lastKey;
	private float _lastTime;
	private float _time;

	/// <summary>Which document the entries being recorded belong to — bumped by every operation that
	/// replaces the open document. Identity can't be the source id: a never-saved document has none,
	/// so two of them in one history would share the null tag and a save would retag both.</summary>
	private int _document;

	public bool CanUndo => _undo.Count > 0;
	public bool CanRedo => _redo.Count > 0;

	/// <summary>Advance the coalescing clock (real seconds; called once per editor frame). Monotonic
	/// across stage rebuilds because this object outlives the stage.</summary>
	public void Advance( float dt ) => _time += dt;

	/// <summary>Start a fresh history for a newly opened level (undo never crosses documents).</summary>
	public void Reset( string snapshot, string sourceId )
	{
		_undo.Clear();
		_redo.Clear();
		_current = new Entry( snapshot, sourceId, ++_document );
		_lastKey = null;
	}

	/// <summary>Seed the baseline when the history is brand-new, without disturbing an existing one
	/// (a restored session keeps its undo chain).</summary>
	public void EnsureSeeded( string snapshot, string sourceId ) => _current ??= new Entry( snapshot, sourceId, _document );

	/// <summary>Re-anchor the baseline to the ACTUAL live state after a restore, without recording an
	/// entry or clearing the redo stack. Deserializing a snapshot normalizes a few fields (empty
	/// id/name defaults, range clamps), so the restored level can re-serialize slightly differently
	/// from the stored string — without this, the editor's poll would read that drift as a fresh edit
	/// and kill the redo stack right after an undo.</summary>
	public void Rebase( string snapshot, string sourceId )
	{
		// Re-anchoring is never a document change: keep the restored entry's document number.
		_current = new Entry( snapshot, sourceId, _current?.Document ?? _document );
		_lastKey = null;
	}

	/// <summary>Record the state after a discrete edit. No-ops only when the level AND its source file
	/// are both unchanged: a document swap can re-serialize identically (re-pasting the open level), and
	/// that still has to record an entry so the replaced document's file claim leaves the baseline.
	/// Calling this generously is safe — a MISSED call never corrupts anything, two edits just merge
	/// into one undo step. A non-null <paramref name="coalesceKey"/> merges rapid same-key edits.
	/// <paramref name="newDocument"/> marks the operations that REPLACE the open document (reset,
	/// paste-import, template load): those always record, and never merge into the previous entry.</summary>
	public void Checkpoint( string snapshot, string sourceId, string coalesceKey = null, bool newDocument = false )
	{
		if ( _current is null ) { _current = new Entry( snapshot, sourceId, _document ); return; }
		if ( !newDocument && snapshot == _current.Value.Json && SameSource( sourceId, _current.Value.SourceId ) ) return;

		int document = newDocument ? ++_document : _current.Value.Document;
		bool coalesce = !newDocument && coalesceKey is not null && coalesceKey == _lastKey && _time - _lastTime <= COALESCE_SECONDS;
		if ( !coalesce )
		{
			_undo.Add( _current.Value );
			if ( _undo.Count > MAX_ENTRIES ) _undo.RemoveAt( 0 );
		}

		_current = new Entry( snapshot, sourceId, document );
		_redo.Clear();
		_lastKey = coalesceKey;
		_lastTime = _time;
	}

	/// <summary>Step back one entry, or null when there's nothing to undo. Any not-yet-checkpointed
	/// edits are captured first so they become the redo target instead of being lost; because that
	/// internal Checkpoint no-ops on an unchanged state, an undo chain never clears the redo stack.</summary>
	public Entry? Undo( string snapshot, string sourceId )
	{
		Checkpoint( snapshot, sourceId );
		if ( _undo.Count == 0 ) return null;

		_redo.Add( _current.Value );
		_current = _undo[^1];
		_undo.RemoveAt( _undo.Count - 1 );
		_lastKey = null;
		return _current;
	}

	/// <summary>Step forward one entry, or null when there's nothing to redo. A genuinely new edit
	/// since the last undo clears the redo stack via the internal Checkpoint — standard semantics.</summary>
	public Entry? Redo( string snapshot, string sourceId )
	{
		Checkpoint( snapshot, sourceId );
		if ( _redo.Count == 0 ) return null;

		_undo.Add( _current.Value );
		_current = _redo[^1];
		_redo.RemoveAt( _redo.Count - 1 );
		_lastKey = null;
		return _current;
	}

	/// <summary>A save is not an edit, but it does rename the document: every state of the document
	/// just written — the entries carrying the LIVE document number, whatever source id they had — now
	/// belongs to <paramref name="to"/>, so undoing an ordinary edit after a first save still updates
	/// that file. States of a different document (before a reset/import/template load) keep theirs.</summary>
	public void RetagSource( string to )
	{
		if ( _current is not { } cur ) return;
		Retag( _undo, cur.Document, to );
		Retag( _redo, cur.Document, to );
		if ( !SameSource( cur.SourceId, to ) ) _current = cur with { SourceId = to };
	}

	private static void Retag( List<Entry> list, int document, string to )
	{
		for ( int i = 0; i < list.Count; i++ )
			if ( list[i].Document == document ) list[i] = list[i] with { SourceId = to };
	}

	private static bool SameSource( string a, string b )
		=> string.IsNullOrEmpty( a ) ? string.IsNullOrEmpty( b ) : string.Equals( a, b, System.StringComparison.OrdinalIgnoreCase );
}