/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- */
/*
 * Copyright (C) 2019 Red Hat (www.redhat.com)
 *
 * This library is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation.
 *
 * This library is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
 * for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this library. If not, see <http://www.gnu.org/licenses/>.
 */

'use strict';

/* semi-convention: private functions start with lower-case letter,
   public functions start with upper-case letter. */

var EvoUndoRedo = {
	E_UNDO_REDO_STATE_NONE : 0,
	E_UNDO_REDO_STATE_CAN_UNDO : 1 << 0,
	E_UNDO_REDO_STATE_CAN_REDO : 1 << 1,

	stack : {
		// to not claim changes when none being made
		state : -1,
		undoOpType : "",
		redoOpType : "",

		maybeStateChanged : function() {
			var undoRecord, redoRecord, undoAvailable, undoOpType, redoAvailable, redoOpType;

			undoRecord = EvoUndoRedo.stack.getCurrentUndoRecord();
			redoRecord = EvoUndoRedo.stack.getCurrentRedoRecord();
			undoAvailable = undoRecord != null;
			undoOpType = undoRecord ? undoRecord.opType : "";
			redoAvailable = redoRecord != null;
			redoOpType = redoRecord ? redoRecord.opType : "";

			var state = EvoUndoRedo.E_UNDO_REDO_STATE_NONE;

			if (undoAvailable) {
				state |= EvoUndoRedo.E_UNDO_REDO_STATE_CAN_UNDO;
			}

			if (redoAvailable) {
				state |= EvoUndoRedo.E_UNDO_REDO_STATE_CAN_REDO;
			}

			if (EvoUndoRedo.state != state ||
			    EvoUndoRedo.undoOpType != (undoAvailable ? undoOpType : "") ||
			    EvoUndoRedo.redoOpType != (redoAvailable ? redoOpType : "")) {
				EvoUndoRedo.state = state;
				EvoUndoRedo.undoOpType = (undoAvailable ? undoOpType : "");
				EvoUndoRedo.redoOpType = (redoAvailable ? redoOpType : "");

				var params = {};

				params.state = EvoUndoRedo.state;
				params.undoOpType = EvoUndoRedo.undoOpType;
				params.redoOpType = EvoUndoRedo.redoOpType;

				window.webkit.messageHandlers.undoRedoStateChanged.postMessage(params);
			}
		},

		MAX_DEPTH : 10000 + 1, /* it's one item less, due to the 'bottom' being always ignored */

		array : [],
		bottom : 0,
		top : 0,
		current : 0,

		clampIndex : function(index) {
			index = (index) % EvoUndoRedo.stack.MAX_DEPTH;

			if (index < 0)
				index += EvoUndoRedo.stack.MAX_DEPTH;

			return index;
		},

		/* Returns currently active record for Undo operation, or null */
		getCurrentUndoRecord : function() {
			if (EvoUndoRedo.stack.current == EvoUndoRedo.stack.bottom || !EvoUndoRedo.stack.array.length ||
			    EvoUndoRedo.stack.current < 0 || EvoUndoRedo.stack.current > EvoUndoRedo.stack.array.length) {
				return null;
			}

			return EvoUndoRedo.stack.array[EvoUndoRedo.stack.current];
		},

		/* Returns currently active record for Redo operation, or null */
		getCurrentRedoRecord : function() {
			if (EvoUndoRedo.stack.current == EvoUndoRedo.stack.top) {
				return null;
			}

			var idx = EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current + 1);

			if (idx < 0 || idx > EvoUndoRedo.stack.array.length) {
				return null;
			}

			return EvoUndoRedo.stack.array[idx];
		},

		/* Clears the undo stack */
		clear : function() {
			EvoUndoRedo.stack.array.length = 0;
			EvoUndoRedo.stack.bottom = 0;
			EvoUndoRedo.stack.top = 0;
			EvoUndoRedo.stack.current = 0;

			EvoUndoRedo.stack.maybeStateChanged();
		},

		/* Adds a new record into the stack; if any undo had been made, then
		   those records are freed. It can also overwrite old undo steps, if
		   the stack size would overflow MAX_DEPTH. */
		push : function(record) {
			if (!EvoUndoRedo.stack.array.length) {
				EvoUndoRedo.stack.array[0] = null;
			}

			var next = EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current + 1);

			if (EvoUndoRedo.stack.current != EvoUndoRedo.stack.top) {
				var tt, bb, cc;

				tt = EvoUndoRedo.stack.top;
				bb = EvoUndoRedo.stack.bottom;
				cc = EvoUndoRedo.stack.current;

				if (bb > tt) {
					tt += EvoUndoRedo.stack.MAX_DEPTH;
					cc += EvoUndoRedo.stack.MAX_DEPTH;
				}

				while (cc + 1 <= tt) {
					EvoUndoRedo.stack.array[EvoUndoRedo.stack.clampIndex(cc + 1)] = null;
					cc++;
				}
			}

			if (next == EvoUndoRedo.stack.bottom) {
				EvoUndoRedo.stack.bottom = EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.bottom + 1);
				EvoUndoRedo.stack.array[EvoUndoRedo.stack.bottom] = null;
			}

			EvoUndoRedo.stack.current = next;
			EvoUndoRedo.stack.top = next;
			EvoUndoRedo.stack.array[next] = record;

			EvoUndoRedo.stack.maybeStateChanged();
		},

		/* Moves the 'current' index in the stack and returns the undo record
		   to be undone; or 'null', when there's no undo record available. */
		undo : function() {
			var record = EvoUndoRedo.stack.getCurrentUndoRecord();

			if (record) {
				EvoUndoRedo.stack.current = EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current - 1);
			}

			EvoUndoRedo.stack.maybeStateChanged();

			return record;
		},

		/* Moves the 'current' index in the stack and returns the redo record
		   to be redone; or 'null', when there's no redo record available. */
		redo : function() {
			var record = EvoUndoRedo.stack.getCurrentRedoRecord();

			if (record) {
				EvoUndoRedo.stack.current = EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current + 1);
			}

			EvoUndoRedo.stack.maybeStateChanged();

			return record;
		},

		pathMatches : function(path1, path2) {
			if (!path1)
				return !path2;
			else if (!path2 || path1.length != path2.length)
				return false;

			var ii;

			for (ii = 0; ii < path1.length; ii++) {
				if (path1[ii] != path2[ii])
					return false;
			}

			return true;
		},

		topInsertTextAtSamePlace : function() {
			if (EvoUndoRedo.stack.current != EvoUndoRedo.stack.top ||
			    EvoUndoRedo.stack.current == EvoUndoRedo.stack.bottom) {
				return false;
			}

			var curr, prev;

			curr = EvoUndoRedo.stack.array[EvoUndoRedo.stack.current];
			prev = EvoUndoRedo.stack.array[EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current - 1)];

			return curr && prev &&
				curr.kind == EvoUndoRedo.RECORD_KIND_EVENT &&
				curr.opType == "insertText" &&
				!curr.selectionBefore.focusElem &&
				prev.kind == EvoUndoRedo.RECORD_KIND_EVENT &&
				prev.opType == "insertText" &&
				!prev.selectionBefore.focusElem &&
				curr.firstChildIndex == prev.firstChildIndex &&
				curr.restChildrenCount == prev.restChildrenCount &&
				curr.selectionBefore.anchorOffset == prev.selectionAfter.anchorOffset &&
				EvoUndoRedo.stack.pathMatches(curr.path, prev.path) &&
				EvoUndoRedo.stack.pathMatches(curr.selectionBefore.anchorElem, prev.selectionAfter.anchorElem);
		},

		maybeMergeConsecutive : function(skipFirst, opType) {
			if (EvoUndoRedo.stack.current != EvoUndoRedo.stack.top ||
			    EvoUndoRedo.stack.current == EvoUndoRedo.stack.bottom) {
				return;
			}

			var ii, from, curr, keep = null;

			from = EvoUndoRedo.stack.current;
			curr = EvoUndoRedo.stack.array[from];

			if (skipFirst) {
				keep = curr;
				from = EvoUndoRedo.stack.clampIndex(from - 1);
				curr = EvoUndoRedo.stack.array[from];
			}

			if (!curr ||
			    curr.kind != EvoUndoRedo.RECORD_KIND_EVENT ||
			    curr.opType != opType ||
			    curr.selectionBefore.focusElem) {
				return;
			}

			for (ii = EvoUndoRedo.stack.clampIndex(from - 1);
			     ii != EvoUndoRedo.stack.bottom;
			     ii = EvoUndoRedo.stack.clampIndex(ii - 1)) {
				var prev;

				prev = EvoUndoRedo.stack.array[ii];

				if (prev.kind != EvoUndoRedo.RECORD_KIND_EVENT ||
				    prev.opType != opType ||
				    prev.selectionBefore.focusElem ||
				    curr.firstChildIndex != prev.firstChildIndex ||
				    curr.restChildrenCount != prev.restChildrenCount ||
				    curr.selectionBefore.anchorOffset != prev.selectionAfter.anchorOffset ||
				    !EvoUndoRedo.stack.pathMatches(curr.path, prev.path) ||
				    !EvoUndoRedo.stack.pathMatches(curr.selectionBefore.anchorElem, prev.selectionAfter.anchorElem)) {
					break;
				}

				if (opType == "insertText")
					prev.opType = opType + "::merged";
				prev.selectionAfter = curr.selectionAfter;
				prev.htmlAfter = curr.htmlAfter;

				curr = prev;
				EvoUndoRedo.stack.array[EvoUndoRedo.stack.clampIndex(ii + 1)] = keep;
				if (keep) {
					EvoUndoRedo.stack.array[EvoUndoRedo.stack.clampIndex(ii + 2)] = null;
				}

				EvoUndoRedo.stack.top = ii + (keep ? 1 : 0);
				EvoUndoRedo.stack.current = EvoUndoRedo.stack.top;
			}

			EvoUndoRedo.stack.maybeStateChanged();
		},

		maybeMergeInsertText : function(skipFirst) {
			EvoUndoRedo.stack.maybeMergeConsecutive(skipFirst, "insertText");
			EvoUndoRedo.stack.maybeMergeConsecutive(skipFirst, "insertText::WordDelim");
		},

		maybeMergeDragDrop : function() {
			if (EvoUndoRedo.stack.current != EvoUndoRedo.stack.top ||
			    EvoUndoRedo.stack.current == EvoUndoRedo.stack.bottom ||
			    EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current - 1) == EvoUndoRedo.stack.bottom) {
				return;
			}

			var curr, prev;

			curr = EvoUndoRedo.stack.array[EvoUndoRedo.stack.current];
			prev = EvoUndoRedo.stack.array[EvoUndoRedo.stack.clampIndex(EvoUndoRedo.stack.current - 1)];

			if (curr && prev &&
			    curr.kind == EvoUndoRedo.RECORD_KIND_EVENT &&
			    prev.kind == EvoUndoRedo.RECORD_KIND_EVENT &&
			    curr.opType == "insertFromDrop" &&
			    prev.opType == "deleteByDrag") {
				EvoUndoRedo.GroupTopRecords(2, "dragDrop::merged");
			}
		}
	},

	RECORD_KIND_EVENT	: 1, /* managed by EvoUndoRedo itself, in DOM events */
	RECORD_KIND_DOCUMENT	: 2, /* saving whole document */
	RECORD_KIND_GROUP	: 3, /* not saving anything, just grouping several records together */
	RECORD_KIND_CUSTOM	: 4, /* custom record */

	/*
	Record {
		int kind;		// RECORD_KIND_...
		string opType;		// operation type, like the one from oninput
		Array path;		// path to the common parent of the affteded elements
		int firstChildIndex;	// the index of the first children affeted/recorded
		int restChildrenCount;	// the Undo/Redo affects only some children, these are those which are unaffected after the path
		Object selectionBefore;	// stored selection as it was before the change
		string htmlBefore;	// affected children before the change; can be null, when inserting new nodes
		Object selectionAfter;	// stored selection as it was after the change
		string htmlAfter;	// affected children before the change; can be null, when removed old nodes

		Array records;		// nested records; can be null or undefined
	}

	The path, firstChildIndex and restChildrenCount together describe where the changes happened.
	That is, for example when changing node 'b' into 'x' and 'y':
	   <body>	|   <body>
	     <a/>	|     <a/>
	     <b/>	|     <x/>
	     <c/>	|     <y/>
	     <d/>	|     <c/>
	   </body>	|     <d/>
			|   </body>
	the 'path' points to 'body', the firstChildIndex=1 and restChildrenCount=2. Then undo/redo can
	delete all nodes between index >= firstChildIndex && index < children.length - restChildrenCount.
	*/

	dropTarget : null, // passed from dropCb() into beforeInputCb()/inputCb() for "insertFromDrop" event
	disabled : 0,
	ongoingRecordings : [] // the recordings can be nested
};

EvoUndoRedo.Attach = function()
{
	if (document.documentElement) {
		document.documentElement.onbeforeinput = EvoUndoRedo.beforeInputCb;
		document.documentElement.oninput = EvoUndoRedo.inputCb;
		document.documentElement.ondrop = EvoUndoRedo.dropCb;
	}
}

EvoUndoRedo.Detach = function()
{
	if (document.documentElement) {
		document.documentElement.onbeforeinput = null;
		document.documentElement.oninput = null;
		document.documentElement.ondrop = null;
	}
}

EvoUndoRedo.Enable = function()
{
	if (!EvoUndoRedo.disabled) {
		throw "EvoUndoRedo:: Cannot Enable, when not disabled";
	}

	EvoUndoRedo.disabled--;
}

EvoUndoRedo.Disable = function()
{
	EvoUndoRedo.disabled++;

	if (!EvoUndoRedo.disabled) {
		throw "EvoUndoRedo:: Overflow in Disable";
	}
}

EvoUndoRedo.isWordDelimEvent = function(inputEvent)
{
	return inputEvent.inputType == "insertText" &&
		inputEvent.data &&
		inputEvent.data.length == 1 &&
		(inputEvent.data == " " || inputEvent.data == "\t");
}

EvoUndoRedo.beforeInputCb = function(inputEvent)
{
	if (EvoUndoRedo.disabled) {
		return;
	}

	var opType = inputEvent.inputType, record, startNode = null, endNode = null;

	if (EvoUndoRedo.isWordDelimEvent(inputEvent))
		opType += "::WordDelim";

	if (opType == "insertFromDrop")
		startNode = EvoUndoRedo.dropTarget;

	if (document.getSelection().isCollapsed) {
		if (opType == "deleteWordBackward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "backward", "word");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteWordForward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "forward", "word");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteSoftLineBackward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "backward", "line");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteSoftLineForward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "forward", "line");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteEntireSoftLine") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "backward", "line");
			startNode = document.getSelection().anchorNode;
			document.getSelection().modify("move", "forward", "line");
			endNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteHardLineBackward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "backward", "paragraph");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteHardLineForward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "forward", "paragraph");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteContentBackward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "backward", "paragraph");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		} else if (opType == "deleteContentForward") {
			var sel = EvoSelection.Store(document);
			document.getSelection().modify("move", "forward", "paragraph");
			startNode = document.getSelection().anchorNode;
			EvoSelection.Restore(document, sel);
		}
	}

	record = EvoUndoRedo.StartRecord(EvoUndoRedo.RECORD_KIND_EVENT, opType, startNode, endNode,
		EvoEditor.CLAIM_CONTENT_FLAG_SAVE_HTML | EvoEditor.CLAIM_CONTENT_FLAG_USE_PARENT_BLOCK_NODE);

	/* Changing format with collapsed selection doesn't change HTML structure immediately */
	if (record && opType.startsWith("format") && document.getSelection().isCollapsed) {
		record.ignore = true;
	}
}

EvoUndoRedo.inputCb = function(inputEvent)
{
	var isWordDelim = EvoUndoRedo.isWordDelimEvent(inputEvent);

	if (EvoUndoRedo.disabled) {
		EvoEditor.EmitContentChanged();
		EvoEditor.AfterInputEvent(inputEvent, isWordDelim);
		return;
	}

	var opType = inputEvent.inputType;

	if (isWordDelim)
		opType += "::WordDelim";

	if (EvoUndoRedo.StopRecord(EvoUndoRedo.RECORD_KIND_EVENT, opType)) {
		EvoEditor.EmitContentChanged();

		EvoEditor.maybeUpdateFormattingState(EvoEditor.FORCE_MAYBE);
	}

	if (!EvoUndoRedo.ongoingRecordings.length && opType == "insertText" &&
	    !EvoUndoRedo.stack.topInsertTextAtSamePlace()) {
		EvoUndoRedo.stack.maybeMergeInsertText(true);
	}

	EvoEditor.forceFormatStateUpdate = EvoEditor.forceFormatStateUpdate || opType == "" || opType.startsWith("format");

	if (opType == "insertFromDrop") {
		EvoUndoRedo.dropTarget = null;
		EvoUndoRedo.stack.maybeMergeDragDrop();
	}

	EvoEditor.AfterInputEvent(inputEvent, isWordDelim);
}

EvoUndoRedo.dropCb = function(event)
{
	EvoUndoRedo.dropTarget = event.toElement;
}

EvoUndoRedo.applyRecord = function(record, isUndo, withSelection)
{
	if (!record) {
		return;
	}

	var kind = record.kind;

	if (kind == EvoUndoRedo.RECORD_KIND_GROUP) {
		var ii, records;

		records = record.records;

		if (records && records.length) {
			for (ii = 0; ii < records.length; ii++) {
				EvoUndoRedo.applyRecord(records[isUndo ? (records.length - ii - 1) : ii], isUndo, false);
			}
		}

		if (withSelection) {
			EvoSelection.Restore(document, isUndo ? record.selectionBefore : record.selectionAfter);
		}

		return;
	}

	EvoUndoRedo.Disable();

	try {
		if (kind == EvoUndoRedo.RECORD_KIND_DOCUMENT) {
			if (isUndo) {
				document.documentElement.innerHTML = record.htmlBefore;
			} else {
				document.documentElement.innerHTML = record.htmlAfter;
			}

			if (record.apply != null) {
				record.apply(record, isUndo);
			}
		} else if (kind == EvoUndoRedo.RECORD_KIND_CUSTOM && record.apply != null) {
			record.apply(record, isUndo);
		} else {
			var commonParent;

			commonParent = EvoSelection.FindElementByPath(document.body, record.path);
			if (!commonParent) {
				throw "EvoUndoRedo::applyRecord: Cannot find parent at path " + record.path;
			}

			EvoUndoRedo.RestoreChildren(record, commonParent, isUndo);
		}

		if (withSelection) {
			EvoSelection.Restore(document, isUndo ? record.selectionBefore : record.selectionAfter);
		}
	} finally {
		EvoUndoRedo.Enable();
	}
}

EvoUndoRedo.StartRecord = function(kind, opType, startNode, endNode, flags)
{
	if (EvoUndoRedo.disabled) {
		return null;
	}

	var record = {};

	record.kind = kind;
	record.opType = opType;
	record.selectionBefore = EvoSelection.Store(document);

	if (kind == EvoUndoRedo.RECORD_KIND_DOCUMENT) {
		record.htmlBefore = document.documentElement.innerHTML;
	} else if (kind != EvoUndoRedo.RECORD_KIND_GROUP) {
		var affected;

		affected = EvoEditor.ClaimAffectedContent(startNode, endNode, flags);

		record.path = affected.path;
		record.firstChildIndex = affected.firstChildIndex;
		record.restChildrenCount = affected.restChildrenCount;

		if ((flags & EvoEditor.CLAIM_CONTENT_FLAG_SAVE_HTML) != 0)
			record.htmlBefore = affected.html;
	}

	EvoUndoRedo.ongoingRecordings[EvoUndoRedo.ongoingRecordings.length] = record;

	return record;
}

EvoUndoRedo.StopRecord = function(kind, opType)
{
	if (EvoUndoRedo.disabled) {
		return false;
	}

	if (!EvoUndoRedo.ongoingRecordings.length) {
		// Workaround WebKitGTK+ bug not sending beforeInput event when deleting with backspace
		// https://bugs.webkit.org/show_bug.cgi?id=206341
		if (opType == "deleteContentBackward")
			return false;

		throw "EvoUndoRedo:StopRecord: Nothing is recorded for kind:" + kind + " opType:'" + opType + "'";
	}

	var record = EvoUndoRedo.ongoingRecordings[EvoUndoRedo.ongoingRecordings.length - 1];

	// Events can overlap sometimes, like when doing drag&drop inside web view
	if (record.kind != kind || record.opType != opType) {
		var ii;

		for (ii = EvoUndoRedo.ongoingRecordings.length - 2; ii >= 0; ii--) {
			record = EvoUndoRedo.ongoingRecordings[ii];

			if (record.kind == kind && record.opType == opType) {
				var jj;

				for (jj = ii + 1; jj < EvoUndoRedo.ongoingRecordings.length; jj++) {
					EvoUndoRedo.ongoingRecordings[jj - 1] = EvoUndoRedo.ongoingRecordings[jj];
				}

				EvoUndoRedo.ongoingRecordings[EvoUndoRedo.ongoingRecordings.length - 1] = record;
				break;
			}
		}
	}

	if (record.kind != kind || record.opType != opType) {
		// The "InsertContent", especially when inserting plain text, can receive multiple 'input' events
		// with "insertParagraph", "insertText" and similar, which do not have its counterpart beforeInput event,
		// thus ignore those

		if (record.opType == "InsertContent" && opType.startsWith("insert"))
			return;

		throw "EvoUndoRedo:StopRecord: Mismatch in record kind, expected " + record.kind + " (" + record.opType + "), but received " +
			kind + "(" + opType + "); ongoing recordings:" + EvoUndoRedo.ongoingRecordings.length;
	}

	EvoUndoRedo.ongoingRecordings.length = EvoUndoRedo.ongoingRecordings.length - 1;

	// ignore empty group records
	if (kind == EvoUndoRedo.RECORD_KIND_GROUP && (!record.records || !record.records.length)) {
		record.ignore = true;
	}

	if (record.ignore) {
		if (!EvoUndoRedo.ongoingRecordings.length &&
		    (record.kind != EvoUndoRedo.RECORD_KIND_EVENT || record.opType != "insertText")) {
			EvoUndoRedo.stack.maybeMergeInsertText(false);
		}

		return false;
	}

	if (kind == EvoUndoRedo.RECORD_KIND_DOCUMENT) {
		record.htmlAfter = document.documentElement.innerHTML;
	} else if (record.htmlBefore != window.undefined) {
		var commonParent;

		commonParent = EvoSelection.FindElementByPath(document.body, record.path);

		if (!commonParent) {
			throw "EvoUndoRedo.StopRecord:: Failed to stop '" + opType + "', cannot find common parent";
		}

		EvoUndoRedo.BackupChildrenAfter(record, commonParent);

		// some formatting commands do not change HTML structure immediately, thus ignore those
		if (kind == EvoUndoRedo.RECORD_KIND_EVENT && record.htmlBefore == record.htmlAfter) {
			if (!EvoUndoRedo.ongoingRecordings.length && record.opType != "insertText") {
				EvoUndoRedo.stack.maybeMergeInsertText(false);
			}

			return false;
		}
	}

	record.selectionAfter = EvoSelection.Store(document);

	if (EvoUndoRedo.ongoingRecordings.length && EvoUndoRedo.ongoingRecordings[EvoUndoRedo.ongoingRecordings.length - 1].kind == EvoUndoRedo.RECORD_KIND_GROUP) {
		var parentRecord = EvoUndoRedo.ongoingRecordings[EvoUndoRedo.ongoingRecordings.length - 1];
		var records = parentRecord.records;

		if (!records) {
			records = [];
		}

		records[records.length] = record;
		parentRecord.records = records;
	} else {
		EvoUndoRedo.stack.push(record);

		if (record.kind == EvoUndoRedo.RECORD_KIND_EVENT && record.opType == "insertText::WordDelim") {
			EvoUndoRedo.stack.maybeMergeConsecutive(true, "insertText");
			EvoUndoRedo.stack.maybeMergeConsecutive(false, "insertText::WordDelim");
		} else if (record.kind != EvoUndoRedo.RECORD_KIND_EVENT || record.opType != "insertText") {
			EvoUndoRedo.stack.maybeMergeInsertText(true);
		}
	}

	return true;
}

EvoUndoRedo.IsRecording = function()
{
	return !EvoUndoRedo.disabled && EvoUndoRedo.ongoingRecordings.length > 0;
}

EvoUndoRedo.Undo = function()
{
	var record = EvoUndoRedo.stack.undo();

	if (!record)
		return;

	EvoUndoRedo.applyRecord(record, true, true);
	EvoEditor.maybeUpdateFormattingState(EvoEditor.FORCE_YES);
	EvoEditor.EmitContentChanged();
}

EvoUndoRedo.Redo = function()
{
	var record = EvoUndoRedo.stack.redo();

	if (!record)
		return;

	EvoUndoRedo.applyRecord(record, false, true);
	EvoEditor.maybeUpdateFormattingState(EvoEditor.FORCE_YES);
	EvoEditor.EmitContentChanged();
}

EvoUndoRedo.Clear = function()
{
	EvoUndoRedo.stack.clear();
}

EvoUndoRedo.GroupTopRecords = function(nRecords, opType)
{
	if (EvoUndoRedo.disabled)
		return;

	if (EvoUndoRedo.ongoingRecordings.length)
		throw "EvoUndoRedo.GroupTopRecords: Cannot be called when there are ongoing recordings";

	var group = {};

	group.kind = EvoUndoRedo.RECORD_KIND_GROUP;
	group.opType = opType;
	group.selectionBefore = null;
	group.selectionAfter = null;
	group.records = [];

	while (nRecords >= 1) {
		nRecords--;

		var record = EvoUndoRedo.stack.undo();

		if (!record)
			break;

		group.records[group.records.length] = record;
		group.selectionBefore = record.selectionBefore;

		if (!group.selectionAfter)
			group.selectionAfter = record.selectionAfter;

		if (!group.opType)
			group.opType = record.opType + "::Grouped";
	}

	if (group.records.length) {
		group.records = group.records.reverse();

		EvoUndoRedo.stack.push(group);
	}

	EvoUndoRedo.stack.maybeStateChanged();
}

/* Backs up all the children elements between firstChildIndex and lastChildIndex inclusive,
   saving their HTML content into record.htmlBefore; it stores only element children,
   not text or other nodes. Use also EvoUndoRedo.BackupChildrenAfter() to save all data
   needed by EvoUndoRedo.RestoreChildren(), which is used to restore saved data.
   The firstChildIndex can be -1, to back up parent's innerHTML.
*/
EvoUndoRedo.BackupChildrenBefore = function(record, parent, firstChildIndex, lastChildIndex)
{
	var currentElemsArray = EvoEditor.RemoveCurrentElementAttr();

	try {
		record.firstChildIndex = firstChildIndex;

		if (firstChildIndex == -1) {
			record.htmlBefore = parent.innerHTML;
		} else {
			record.htmlBefore = "";

			var ii;

			for (ii = firstChildIndex; ii < parent.children.length; ii++) {
				record.htmlBefore += parent.children[ii].outerHTML;

				if (ii == lastChildIndex) {
					ii++;
					break;
				}
			}

			record.restChildrenCount = parent.children.length - ii;
		}
	} finally {
		EvoEditor.RestoreCurrentElementAttr(currentElemsArray);
	}
}

EvoUndoRedo.BackupChildrenAfter = function(record, parent)
{
	if (record.firstChildIndex == undefined)
		throw "EvoUndoRedo.BackupChildrenAfter: 'record' doesn't contain 'firstChildIndex' property";
	if (record.firstChildIndex != -1 && record.restChildrenCount == undefined)
		throw "EvoUndoRedo.BackupChildrenAfter: 'record' doesn't contain 'restChildrenCount' property";
	if (record.htmlBefore == undefined)
		throw "EvoUndoRedo.BackupChildrenAfter: 'record' doesn't contain 'htmlBefore' property";

	var currentElemsArray = EvoEditor.RemoveCurrentElementAttr();

	try {
		if (record.firstChildIndex == -1) {
			record.htmlAfter = parent.innerHTML;
		} else {
			record.htmlAfter = "";

			var ii, first, last;

			first = record.firstChildIndex;

			// it can equal to the children.length, when the node had been removed
			if (first < 0 || first > parent.children.length) {
				throw "EvoUndoRedo.BackupChildrenAfter: firstChildIndex (" + first + ") out of bounds (" + parent.children.length + ")";
			}

			last = parent.children.length - record.restChildrenCount;
			if (last < 0 || last < first) {
				throw "EvoUndoRedo::BackupChildrenAfter: restChildrenCount (" + record.restChildrenCount + ") out of bounds (length:" +
					parent.children.length + " first:" + first + " last:" + last + ")";
			}

			for (ii = first; ii < last; ii++) {
				if (ii >= 0 && ii < parent.children.length) {
					record.htmlAfter += parent.children[ii].outerHTML;
				}
			}
		}
	} finally {
		EvoEditor.RestoreCurrentElementAttr(currentElemsArray);
	}
}

// restores content of 'parent' based on the information saved by EvoUndoRedo.BackupChildrenBefore()
// and EvoUndoRedo.BackupChildrenAfter()
EvoUndoRedo.RestoreChildren = function(record, parent, isUndo)
{
	var first, last, ii;

	if (record.firstChildIndex == undefined)
		throw "EvoUndoRedo.RestoreChildren: 'record' doesn't contain 'firstChildIndex' property";
	if (record.firstChildIndex != -1 && record.restChildrenCount == undefined)
		throw "EvoUndoRedo.RestoreChildren: 'record' doesn't contain 'restChildrenCount' property";
	if (record.htmlBefore == undefined)
		throw "EvoUndoRedo.RestoreChildren: 'record' doesn't contain 'htmlBefore' property";
	if (record.htmlAfter == undefined)
		throw "EvoUndoRedo.RestoreChildren: 'record' doesn't contain 'htmlAfter' property";

	first = record.firstChildIndex;

	if (first == -1) {
		if (isUndo) {
			parent.innerHTML = record.htmlBefore;
		} else {
			parent.innerHTML = record.htmlAfter;
		}
	} else {
		// it can equal to the children.length, when the node had been removed
		if (first < 0 || first > parent.children.length) {
			throw "EvoUndoRedo::RestoreChildren: firstChildIndex (" + first + ") out of bounds (" + parent.children.length + ")";
		}

		last = parent.children.length - record.restChildrenCount;
		if (last < 0 || last < first) {
			throw "EvoUndoRedo::RestoreChildren: restChildrenCount (" + record.restChildrenCount + ") out of bounds (length:" +
				parent.children.length + " first:" + first + " last:" + last + ")";
		}

		for (ii = last - 1; ii >= first; ii--) {
			if (ii >= 0 && ii < parent.children.length) {
				parent.removeChild(parent.children[ii]);
			}
		}

		var tmpNode = document.createElement("evo-tmp");

		if (isUndo) {
			tmpNode.innerHTML = record.htmlBefore;
		} else {
			tmpNode.innerHTML = record.htmlAfter;
		}

		if (first < parent.children.length) {
			first = parent.children[first];

			while(tmpNode.firstElementChild) {
				parent.insertBefore(tmpNode.firstElementChild, first);
			}
		} else {
			while(tmpNode.firstElementChild) {
				parent.appendChild(tmpNode.firstElementChild);
			}
		}
	}
}

EvoUndoRedo.Attach();
