Editor/HotCodeEditor/CodeView/UndoHistory.cs
using System;
using System.Collections.Generic;

/// <summary>
/// One change to a <see cref="TextBuffer"/>: at <see cref="Start"/>, <see cref="Removed"/> was replaced by <see cref="Inserted"/>.
/// </summary>
public readonly record struct BufferEdit( TextPos Start, string Removed, string Inserted );

/// <summary>
/// What kind of edit this was, which decides whether it can merge with the one before it.
/// </summary>
public enum EditKind
{
	/// <summary>Paste, cut, indent, new line... always its own undo step.</summary>
	Other,
	Typing,
	Deleting
}

/// <summary>
/// One undo step. Usually a single edit, but typing a word, backspacing, or a "replace all"
/// becomes one group.
/// </summary>
public class UndoGroup
{
	public int Id { get; init; }
	public EditKind Kind { get; init; }
	public List<BufferEdit> Edits { get; } = new();
	public TextPos CaretBefore { get; init; }
	public TextPos? AnchorBefore { get; init; }
	public TextPos CaretAfter { get; set; }
	public TextPos? AnchorAfter { get; set; }
	public double Time { get; set; }
}

/// <summary>
/// Undo/redo stacks with editor-style merging:
/// <list type="bullet">
/// <item>typing merges per word ("hello world" undoes as "world", then "hello ")</item>
/// <item>backspace / delete runs merge</item>
/// <item>moving the caret, saving, pausing, or doing anything else starts a new step</item>
/// </list>
/// Also tracks the save point, so undoing back to the saved text counts as unmodified.
/// </summary>
public class UndoHistory
{
	/// <summary>
	/// A pause longer than this starts a new undo step.
	/// </summary>
	public const double MergeTimeout = 3.0;

	const int MaxSteps = 1000;

	private readonly List<UndoGroup> _undo = new();
	private readonly List<UndoGroup> _redo = new();
	private int _nextId = 1;
	private int _savedId;
	private bool _breakNext = true;

	private UndoGroup _transaction;
	private int _transactionDepth;

	/// <summary>
	/// Id of the text as it is now; 0 is the text as loaded.
	/// </summary>
	public int CurrentId => _undo.Count > 0 ? _undo[^1].Id : 0;

	/// <summary>
	/// Differs from what was last loaded or saved.
	/// </summary>
	public bool IsModified => CurrentId != _savedId;

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

	public void Clear()
	{
		_undo.Clear();
		_redo.Clear();
		_savedId = 0;
		_breakNext = true;
	}

	public void MarkSaved()
	{
		_savedId = CurrentId;
		_breakNext = true;
	}

	/// <summary>
	/// The next edit starts a new undo step (call when the caret moves, etc).
	/// </summary>
	public void Break() => _breakNext = true;

	/// <summary>
	/// Everything recorded until the matching <see cref="EndTransaction"/> becomes one undo step.
	/// </summary>
	public void BeginTransaction( TextPos caret, TextPos? anchor, double now )
	{
		if ( _transactionDepth++ > 0 ) return;
		_transaction = new UndoGroup { Id = _nextId++, Kind = EditKind.Other, CaretBefore = caret, AnchorBefore = anchor, CaretAfter = caret, Time = now };
	}

	public void EndTransaction( TextPos caret, TextPos? anchor )
	{
		if ( _transactionDepth == 0 || --_transactionDepth > 0 ) return;

		var group = _transaction;
		_transaction = null;
		if ( group.Edits.Count == 0 ) return;

		group.CaretAfter = caret;
		group.AnchorAfter = anchor;
		Push( group );
		_breakNext = true;
	}

	public void Record( BufferEdit edit, EditKind kind, TextPos caretBefore, TextPos? anchorBefore, TextPos caretAfter, double now )
	{
		if ( _transaction is not null )
		{
			_transaction.Edits.Add( edit );
			_transaction.CaretAfter = caretAfter;
			return;
		}

		var top = _undo.Count > 0 ? _undo[^1] : null;
		if ( top is not null && CanMerge( top, edit, kind, now ) )
		{
			top.Edits.Add( edit );
			top.CaretAfter = caretAfter;
			top.AnchorAfter = null;
			top.Time = now;
			_redo.Clear();
			return;
		}

		var group = new UndoGroup { Id = _nextId++, Kind = kind, CaretBefore = caretBefore, AnchorBefore = anchorBefore, CaretAfter = caretAfter, Time = now };
		group.Edits.Add( edit );
		Push( group );

		// Typing and deleting stay open for merging; anything else stands alone
		_breakNext = kind == EditKind.Other;
	}

	private void Push( UndoGroup group )
	{
		_undo.Add( group );
		_redo.Clear();
		if ( _undo.Count > MaxSteps ) _undo.RemoveAt( 0 );
	}

	private bool CanMerge( UndoGroup top, BufferEdit edit, EditKind kind, double now )
	{
		if ( _breakNext || kind == EditKind.Other || top.Kind != kind ) return false;
		if ( now - top.Time > MergeTimeout ) return false;

		// Never merge into the saved state, or undo would step past it
		if ( top.Id == _savedId ) return false;

		var last = top.Edits[^1];

		if ( kind == EditKind.Typing )
		{
			if ( edit.Removed.Length != 0 || edit.Inserted.Contains( '\n' ) ) return false;
			if ( edit.Start != TextBuffer.EndOf( last.Start, last.Inserted ) ) return false;

			// Start a new step at each new word: "hello |world"
			var lastChar = last.Inserted.Length > 0 ? last.Inserted[^1] : ' ';
			return !(char.IsWhiteSpace( lastChar ) && !char.IsWhiteSpace( edit.Inserted[0] ));
		}

		if ( kind == EditKind.Deleting )
		{
			if ( edit.Inserted.Length != 0 || edit.Removed.Contains( '\n' ) ) return false;

			var isBackspace = TextBuffer.EndOf( edit.Start, edit.Removed ) == last.Start;
			var isForwardDelete = edit.Start == last.Start;
			return isBackspace || isForwardDelete;
		}

		return false;
	}

	/// <summary>
	/// Reverts the last step. Returns it so the caller can restore the caret, or null if there's nothing to undo.
	/// </summary>
	public UndoGroup Undo( TextBuffer buffer )
	{
		if ( _undo.Count == 0 ) return null;

		var group = _undo[^1];
		_undo.RemoveAt( _undo.Count - 1 );

		for ( int i = group.Edits.Count - 1; i >= 0; i-- )
		{
			var e = group.Edits[i];
			buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Inserted ) );
			buffer.Insert( e.Start, e.Removed );
		}

		_redo.Add( group );
		_breakNext = true;
		return group;
	}

	public UndoGroup Redo( TextBuffer buffer )
	{
		if ( _redo.Count == 0 ) return null;

		var group = _redo[^1];
		_redo.RemoveAt( _redo.Count - 1 );

		foreach ( var e in group.Edits )
		{
			buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Removed ) );
			buffer.Insert( e.Start, e.Inserted );
		}

		_undo.Add( group );
		_breakNext = true;
		return group;
	}
}