/*!
 * VisualEditor DataModel Document class.
 *
 * @copyright See AUTHORS.txt
 */

/**
 * DataModel document.
 *
 * WARNING: The data parameter is passed by reference. Do not modify a data array after passing
 * it to this constructor, and do not construct multiple Documents with the same data array. If you
 * need to do these things, make a deep copy (ve.copy) of the data array and operate on the
 * copy.
 *
 * @class
 * @extends ve.Document
 * @constructor
 * @param {Array|ve.dm.ElementLinearData} data Raw linear model data or ElementLinearData
 * @param {HTMLDocument} [htmlDocument] HTML document the data was converted from, if any.
 *  If omitted, a new document will be created. If data is an HTMLDocument, this parameter is
 *  ignored.
 * @param {ve.dm.Document} [parentDocument] Document to use as root for created nodes, used when cloning
 * @param {ve.dm.InternalList} [internalList] Internal list to clone; passed when creating a document slice
 * @param {Array.<string|undefined>} [innerWhitespace] Inner whitespace to clone; passed when creating a document slice
 * @param {string} [lang] Language code
 * @param {string} [dir='ltr'] Directionality (ltr/rtl)
 * @param {ve.dm.Document} [originalDocument] Original document form which this was cloned.
 * @param {boolean} [sourceMode] Document is in source mode
 * @param {Object} [persistentStorage] Persistent storage object
 */
ve.dm.Document = function VeDmDocument( data, htmlDocument, parentDocument, internalList, innerWhitespace, lang, dir, originalDocument, sourceMode, persistentStorage ) {
	// Parent constructor
	ve.dm.Document.super.call( this, new ve.dm.DocumentNode() );

	// Initialization
	const doc = parentDocument || this;
	const root = this.documentNode;

	this.lang = lang || 'en';
	this.dir = dir || 'ltr';

	this.sourceMode = !!sourceMode;

	this.documentNode.setRoot( root );
	// ve.Document already called setDocument(), but it could be that doc !== this
	// so call it again
	this.documentNode.setDocument( doc );
	this.internalList = internalList ? internalList.clone( this ) : new ve.dm.InternalList( this );
	this.innerWhitespace = innerWhitespace ? ve.copy( innerWhitespace ) : new Array( 2 );
	this.metaList = new ve.dm.MetaList( this );

	// Properties
	this.parentDocument = parentDocument || null;
	this.originalDocument = originalDocument || null;
	this.nodesByType = {};
	this.origInternalListLength = null;
	this.readOnly = false;

	// Sparse array
	this.branchNodeFromOffsetCache = [];

	if ( data instanceof ve.dm.ElementLinearData ) {
		this.data = data;
	} else if ( data instanceof ve.dm.FlatLinearData ) {
		this.data = new ve.dm.ElementLinearData( data.getStore(), data.getData() );
	} else {
		this.data = new ve.dm.ElementLinearData(
			new ve.dm.HashValueStore(),
			Array.isArray( data ) ? data : []
		);
	}
	this.store = this.data.getStore();
	this.completeHistory = new ve.dm.Change();
	// Use the store by reference inside the completeHistory
	this.completeHistory.store = this.store;
	if ( this.store.getLength() > 0 ) {
		// Push an identity transaction and the initial store
		this.completeHistory.transactions.push( new ve.dm.Transaction( [ {
			type: 'retain',
			length: this.data.data.length
		} ] ) );
		this.completeHistory.storeLengthAtTransaction.push( this.store.getLength() );
	}
	this.htmlDocument = htmlDocument || ve.createDocumentFromHtml( '' );
	this.persistentStorage = persistentStorage || {};
};

/* Inheritance */

OO.inheritClass( ve.dm.Document, ve.Document );

/* Events */

/**
 * Emitted when a transaction is about to be committed.
 *
 * @event ve.dm.Document#precommit
 * @param {ve.dm.Transaction} tx Transaction that is about to be committed
 */

/**
 * Emitted when a transaction has been committed.
 *
 * @event ve.dm.Document#transact
 * @param {ve.dm.Transaction} tx Transaction that was just processed
 */

/**
 * A value in persistent storage has changed
 *
 * @event ve.dm.Document#storage
 */

/* Static methods */

/**
 * Apply annotations to content data.
 *
 * This method modifies data in place.
 *
 * @static
 * @param {ve.dm.ElementLinearData|Array} data Data to apply annotations to
 * @param {ve.dm.AnnotationSet} annotationSet Annotations to apply
 * @param {boolean} [replaceComparable] Whether to remove annotations from the data which are comparable to those in annotationSet
 * @param {ve.dm.HashValueStore} [store] Store associated with the data; only needs to be provided if that data is associated with a different store than annotationSet
 * @param {boolean} [prepend] Whether to prepend annotationSet to the existing annotations
 */
ve.dm.Document.static.addAnnotationsToData = function ( data, annotationSet, replaceComparable, store, prepend ) {
	const offset = prepend ? 0 : undefined;

	let ignoreChildrenDepth = 0;
	if ( annotationSet.isEmpty() ) {
		// Nothing to do
		return;
	}
	if ( store ) {
		store.merge( annotationSet.getStore() );
	} else {
		store = annotationSet.getStore();
	}
	if ( !( data instanceof ve.dm.ElementLinearData ) ) {
		data = new ve.dm.ElementLinearData( store, data );
	}

	// Apply annotations to data
	for ( let i = 0, length = data.getLength(); i < length; i++ ) {
		if ( data.isElementData( i ) && ve.dm.nodeFactory.shouldIgnoreChildren( data.getType( i ) ) ) {
			ignoreChildrenDepth += data.isOpenElementData( i ) ? 1 : -1;
		}
		if ( ignoreChildrenDepth ) {
			continue;
		}
		const allowedAnnotations = annotationSet.filter( ( ann ) => data.canTakeAnnotationAtOffset( i, ann, true ) );
		const existingAnnotations = data.getAnnotationsFromOffset( i, true );
		let newAnnotationSet;
		if ( !existingAnnotations.isEmpty() ) {
			newAnnotationSet = existingAnnotations;
			if ( replaceComparable ) {
				newAnnotationSet = newAnnotationSet.withoutComparableSet( allowedAnnotations );
			}
			newAnnotationSet.addSet( allowedAnnotations, offset );
		} else {
			newAnnotationSet = allowedAnnotations;
		}
		data.setAnnotationsAtOffset( i, newAnnotationSet );
	}
};

/**
 * Provide a new, empty Document.
 *
 * @param {string} [paragraphType='empty'] Paragraph type: 'empty', 'wrapper' or null for a regular paragraph
 * @return {ve.dm.Document}
 */
ve.dm.Document.static.newBlankDocument = function ( paragraphType ) {
	const paragraph = { type: 'paragraph' };

	paragraphType = paragraphType === undefined ? 'empty' : paragraphType;

	if ( paragraphType ) {
		ve.setProp( paragraph, 'internal', 'generated', paragraphType );
	}

	return new ve.dm.Document( [
		paragraph,
		{ type: '/paragraph' },
		{ type: 'internalList' },
		{ type: '/internalList' }
	] );
};

/* Methods */

/**
 * @inheritdoc
 */
ve.dm.Document.prototype.getDocumentNode = function () {
	if ( !this.documentNode.length && !this.documentNode.getDocument().buildingNodeTree ) {
		this.buildNodeTree();
	}
	return this.documentNode;
};

/**
 * Get a range that spans the entire document (excluding the internal list)
 *
 * @return {ve.Range} Document range
 */
ve.dm.Document.prototype.getDocumentRange = function () {
	return new ve.Range( 0, this.getInternalList().getListNode().getOuterRange().start );
};

/**
 * Build the node tree.
 */
ve.dm.Document.prototype.buildNodeTree = function () {
	let textLength = 0,
		inTextNode = false;

	// Build a tree of nodes and nodes that will be added to them after a full scan is complete,
	// then from the bottom up add nodes to their potential parents. This avoids massive length
	// updates being broadcast upstream constantly while building is underway.
	let currentStack = [];
	let parentStack = [ this.documentNode ];
	// Stack of stacks
	const nodeStack = [ parentStack, currentStack ];
	let currentNode = this.documentNode;
	const doc = this.documentNode.getDocument();

	for ( let i = 0, len = this.data.getLength(); i < len; i++ ) {
		if ( !this.data.isElementData( i ) ) {
			// Text node opening
			if ( !inTextNode ) {
				// Create a lengthless text node
				const node = new ve.dm.TextNode();
				// Put the node on the current inner stack
				currentStack.push( node );
				currentNode = node;
				// Set a flag saying we're inside a text node
				inTextNode = true;
			}
			// Track the length
			textLength++;
		} else {
			// Text node closing
			if ( inTextNode ) {
				// Finish the text node by setting the length
				currentNode.setLength( textLength );
				// Put the state variables back as they were
				currentNode = parentStack[ parentStack.length - 1 ];
				inTextNode = false;
				textLength = 0;
			}
			// Element open/close
			if ( this.data.isOpenElementData( i ) ) {
				// Branch or leaf node opening
				// Create a childless node
				const node = ve.dm.nodeFactory.createFromElement( this.data.getData( i ) );
				// Put the childless node on the current inner stack
				currentStack.push( node );
				if ( ve.dm.nodeFactory.canNodeHaveChildren( node.getType() ) ) {
					// Create a new inner stack for this node
					parentStack = currentStack;
					currentStack = [];
					nodeStack.push( currentStack );
					currentNode = node;
				} else {
					// Assert that the next element is a closing element for this node,
					// and skip over it.
					if (
						!this.data.isCloseElementData( i + 1 ) ||
						this.data.getType( i + 1 ) !== this.data.getType( i )
					) {
						throw new Error( 'Opening element for node that cannot have children must be followed by closing element' );
					}
					i++;
				}
			} else {
				// Branch or leaf node closing
				if ( this.data.getType( i ) !== currentNode.getType() ) {
					throw new Error( 'Expected closing for ' + currentNode.getType() +
						' but got closing for ' + this.data.getType( i ) );
				}
				// Pop this node's inner stack from the outer stack. It'll have all of the
				// node's child nodes fully constructed
				const children = nodeStack.pop();
				currentStack = parentStack;
				parentStack = nodeStack[ nodeStack.length - 2 ];
				if ( !parentStack ) {
					// This can only happen if we got unbalanced data
					throw new Error( 'Unbalanced input passed to document' );
				}
				// Attach the children to the node
				ve.batchSplice( currentNode, 0, 0, children );
				currentNode = parentStack[ parentStack.length - 1 ];
			}
		}
	}

	if ( inTextNode ) {
		// Text node ended by end-of-input rather than by an element
		currentNode.setLength( textLength );
		// Don't bother updating currentNode et al, we don't use them below
	}

	// State variable that allows nodes to know that they are being
	// appended in order. Used by ve.dm.InternalList.
	doc.buildingNodeTree = true;

	// The end state is nodeStack = [ [this.documentNode], [ array, of, its, children ] ]
	// so attach all nodes in currentStack to the root node
	if ( nodeStack.length > 2 ) {
		// A node was opened but never closed
		throw new Error( 'Unbalanced input passed to document' );
	}
	ve.batchSplice( this.documentNode, 0, 0, currentStack );
	this.updateNodesByType( [ this.documentNode ], [ this.documentNode ] );

	doc.buildingNodeTree = false;
};

/**
 * Get the length of the document. This is also the highest valid offset in the document.
 *
 * @return {number} Length of the document
 */
ve.dm.Document.prototype.getLength = function () {
	return this.data.getLength();
};

/**
 * Get the meta list.
 *
 * @return {ve.dm.MetaList} Meta list of the surface
 */
ve.dm.Document.prototype.getMetaList = function () {
	// Ensure the DM tree has been built
	this.getDocumentNode();
	return this.metaList;
};

/**
 * Set the read-only state of the document
 *
 * Actual locking of the model is done by the surface, but
 * we pass through this flag so we can do some optimizations
 * in read-only mode, such as caching node offsets.
 *
 * TODO: It might just be easier for Documents to know which
 * Surface they belong to, although we should make sure that
 * this doesn't violate the direction of data flow.
 *
 * @param {boolean} readOnly Mark document as read-only
 */
ve.dm.Document.prototype.setReadOnly = function ( readOnly ) {
	this.readOnly = !!readOnly;
	if ( !this.readOnly ) {
		// Clear offset cache when leaving read-only mode
		this.getDocumentNode().traverse( ( node ) => {
			node.offset = null;
		} );
	}
};

/**
 * Check if the document is read-only
 *
 * @return {boolean}
 */
ve.dm.Document.prototype.isReadOnly = function () {
	return this.readOnly;
};

/**
 * Apply a transaction's effects on the content data.
 *
 * @param {ve.dm.Transaction} transaction Transaction to apply
 * @param {boolean} isStaging Transaction is being applied in staging mode
 * @fires ve.dm.Document#precommit
 * @fires ve.dm.Document#transact
 * @throws {Error} Cannot commit a transaction that has already been committed
 */
ve.dm.Document.prototype.commit = function ( transaction, isStaging ) {
	if ( transaction.hasBeenApplied() ) {
		throw new Error( 'Cannot commit a transaction that has already been committed' );
	}
	this.emit( 'precommit', transaction );
	this.branchNodeFromOffsetCache = [];
	new ve.dm.TransactionProcessor( this, transaction, isStaging ).process();
	this.completeHistory.pushTransaction( transaction, this.store.getLength() );
	this.emit( 'transact', transaction );
};

/**
 * Get a slice or copy of the document data.
 *
 * @param {ve.Range} [range] Range of data to get, all data will be given by default
 * @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow)
 * @return {Array} Slice or copy of document data
 */
ve.dm.Document.prototype.getData = function ( range, deep ) {
	return this.data.getDataSlice( range, deep );
};

/**
 * Get a copy of the document metadata.
 *
 * This is just a sparse shallow copy of the document data, with meta-item open tags only (i.e.
 * with blanks in place of non-meta-items and meta-item close tags).
 *
 * @param {ve.Range} [range] Range of metadata to get, all metadata will be given by default
 * @return {ve.dm.MetaItem[]} Sparse array of ve.dm.MetaItems.
 */
ve.dm.Document.prototype.getMetadata = function ( range ) {
	if ( arguments.length > 1 ) {
		throw new Error( 'Argument "deep" is no longer supported' );
	}
	const documentNode = this.getDocumentNode();
	if ( !range ) {
		range = new ve.Range( 0, documentNode.length );
	}
	const data = [];
	documentNode.traverse( ( node ) => {
		let offset;
		if ( node instanceof ve.dm.MetaItem ) {
			offset = node.getOffset();
			if ( range.start <= offset && offset < range.end ) {
				data[ offset - range.start ] = node;
			}
		}
	} );
	data.length = range.end - range.start;
	return data;
};

/**
 * Get the HTMLDocument associated with this document.
 *
 * @return {HTMLDocument} Associated document
 */
ve.dm.Document.prototype.getHtmlDocument = function () {
	return this.htmlDocument;
};

/**
 * Get the document model form which this document was cloned.
 *
 * @return {ve.dm.Document|null} Original document
 */
ve.dm.Document.prototype.getOriginalDocument = function () {
	return this.originalDocument;
};

/**
 * Get the document's hash-value store
 *
 * @return {ve.dm.HashValueStore} The document's hash-value store
 */
ve.dm.Document.prototype.getStore = function () {
	return this.store;
};

/**
 * Get the document's internal list
 *
 * @return {ve.dm.InternalList} The document's internal list
 */
ve.dm.Document.prototype.getInternalList = function () {
	return this.internalList;
};

/**
 * Get the document's inner whitespace
 *
 * @return {Array.<string|undefined>} The document's inner whitespace
 */
ve.dm.Document.prototype.getInnerWhitespace = function () {
	return this.innerWhitespace;
};

/**
 * Clone a sub-document from a shallow copy of this document.
 *
 * The new document's elements, internal list and store will only contain references to data within the slice.
 *
 * @param {ve.dm.Selection} selection Selection to create sub-document from
 * @return {ve.dm.DocumentSlice} New document
 */
ve.dm.Document.prototype.shallowCloneFromSelection = function ( selection ) {
	if ( selection instanceof ve.dm.LinearSelection ) {
		return this.shallowCloneFromRange( selection.getRange() );
	} else if ( selection instanceof ve.dm.TableSelection ) {
		const data = [];
		const ranges = selection.getTableSliceRanges( this );
		for ( let i = 0, l = ranges.length; i < l; i++ ) {
			ve.batchPush( data, this.data.slice( ranges[ i ].start, ranges[ i ].end ) );
		}
		const linearData = new ve.dm.ElementLinearData( this.getStore(), data );

		const tableRange = new ve.Range( 0, data.length );

		// Copy over the internal list
		ve.batchSplice(
			linearData.data, linearData.getLength(), 0,
			this.getData( this.getInternalList().getListNode().getOuterRange(), true )
		);

		// The internalList is rebuilt by the document constructor
		return new ve.dm.TableSlice(
			linearData, this.getHtmlDocument(), undefined, this.getInternalList(), tableRange, this
		);
	} else {
		return this.shallowCloneFromRange( new ve.Range( 0 ) );
	}
};

/**
 * Clone a sub-document from a shallow copy of this document.
 *
 * The new document's elements, internal list and store will only contain references to data within the slice.
 *
 * @param {ve.Range} [range] Range of data to slice; defaults to whole document
 * @return {ve.dm.DocumentSlice} New document
 */
ve.dm.Document.prototype.shallowCloneFromRange = function ( range ) {
	const balanceOpenings = [],
		balanceClosings = [],
		contextOpenings = [],
		contextClosings = [];

	let linearData, originalRange, balancedRange;
	if ( !range ) {
		// Default to the whole document
		linearData = this.data.sliceObject();
		originalRange = balancedRange = this.getDocumentRange();
	} else {
		const selection = this.selectNodes( range, 'siblings' );
		const first = selection[ 0 ];
		const last = selection[ selection.length - 1 ];
		let firstNode = first.node;
		let lastNode = last.node;

		// Use first/lastNode if they are non-content branch nodes, otherwise use getBranchNodeFromOffset.
		let startNode = !firstNode.hasChildren() && !firstNode.isContent() ? firstNode : this.getBranchNodeFromOffset( range.start );
		let endNode = !lastNode.hasChildren() && !lastNode.isContent() ? lastNode : this.getBranchNodeFromOffset( range.end );

		// Fix up selection to remove empty items in unwrapped nodes
		// TODO: fix this is selectNodes
		while ( selection[ 0 ] && selection[ 0 ].range && selection[ 0 ].range.isCollapsed() && !selection[ 0 ].node.isWrapped() ) {
			selection.shift();
		}

		let i = selection.length - 1;
		while ( selection[ i ] && selection[ i ].range && selection[ i ].range.isCollapsed() && !selection[ i ].node.isWrapped() ) {
			selection.pop();
			i--;
		}

		let balancedNodes;
		if ( selection.length === 0 || range.isCollapsed() ) {
			// Nothing selected
			linearData = new ve.dm.ElementLinearData( this.getStore(), [
				{ type: 'paragraph', internal: { generated: 'empty' } },
				{ type: '/paragraph' }
			] );
			originalRange = balancedRange = new ve.Range( 1 );
		} else if ( startNode === endNode ) {
			// Nothing to balance
			balancedNodes = selection;
		} else {
			// Selection is not balanced
			while ( !firstNode.isWrapped() ) {
				firstNode = firstNode.getParent();
			}
			while ( !lastNode.isWrapped() ) {
				lastNode = lastNode.getParent();
			}

			if ( first.range ) {
				while ( true ) {
					while ( !startNode.isWrapped() ) {
						startNode = startNode.getParent();
					}
					balanceOpenings.push( startNode.getClonedElement() );
					if ( startNode === firstNode ) {
						break;
					}
					startNode = startNode.getParent();
				}
			}

			if ( last !== first && last.range ) {
				while ( true ) {
					while ( !endNode.isWrapped() ) {
						endNode = endNode.getParent();
					}
					balanceClosings.push( { type: '/' + endNode.getType() } );
					if ( endNode === lastNode ) {
						break;
					}
					endNode = endNode.getParent();
				}
			}

			balancedNodes = this.selectNodes(
				new ve.Range( firstNode.getOuterRange().start, lastNode.getOuterRange().end ),
				'covered'
			);
		}

		// eslint-disable-next-line no-inner-declarations
		function nodeNeedsContext( node ) {
			return node.getParentNodeTypes() !== null || node.isContent();
		}

		if ( !balancedRange ) {
			// Check if any of the balanced siblings need more context for insertion anywhere
			let needsContext = false;
			for ( i = balancedNodes.length - 1; i >= 0; i-- ) {
				if ( nodeNeedsContext( balancedNodes[ i ].node ) ) {
					needsContext = true;
					break;
				}
			}

			if ( needsContext ) {
				startNode = balancedNodes[ 0 ].node;
				// Keep wrapping until the outer node can be inserted anywhere
				while ( startNode.getParent() && nodeNeedsContext( startNode ) ) {
					const isContent = startNode.isContent();
					startNode = startNode.getParent();
					const contextElement = startNode.getClonedElement();
					if ( isContent ) {
						ve.setProp( contextElement, 'internal', 'generated', 'wrapper' );
					}
					contextOpenings.push( contextElement );
					contextClosings.push( { type: '/' + contextElement.type } );
				}
			}

			// Final data:
			//  contextOpenings + balanceOpenings + data slice + balanceClosings + contextClosings
			linearData = new ve.dm.ElementLinearData(
				this.getStore(),
				[].concat(
					contextOpenings.reverse(),
					balanceOpenings.reverse(),
					this.data.slice( range.start, range.end ),
					balanceClosings,
					contextClosings
				)
			);
			originalRange = new ve.Range(
				contextOpenings.length + balanceOpenings.length,
				contextOpenings.length + balanceOpenings.length + range.getLength()
			);
			balancedRange = new ve.Range(
				contextOpenings.length,
				contextOpenings.length + balanceOpenings.length + range.getLength() + balanceClosings.length
			);
		}

		// Shallow copy over the internal list
		ve.batchSplice(
			linearData.data, linearData.getLength(), 0,
			this.getData( this.getInternalList().getListNode().getOuterRange() )
		);
	}

	// The internalList is rebuilt by the document constructor
	const slice = new ve.dm.DocumentSlice(
		linearData, this.getHtmlDocument(), undefined, this.getInternalList(), originalRange, balancedRange, this
	);
	return slice;
};

/**
 * Clone a sub-document from a range in this document. The new document's elements, store and internal list
 * will be clones of the ones in this document.
 *
 * @param {ve.Range} [range] Range of data to clone, clones the whole document if ommitted.
 * @param {boolean} [detachedCopy] The copy is not intended to be merged into the original
 * @param {string} [mode] Mode for getting data, see #getFullData
 * @return {ve.dm.Document} New document
 */
ve.dm.Document.prototype.cloneFromRange = function ( range, detachedCopy, mode ) {
	const listRange = this.getInternalList().getListNode().getOuterRange();
	const data = ve.copy( this.getFullData( range, mode || 'roundTrip' ) );
	if ( range && ( range.start > listRange.start || range.end < listRange.end ) ) {
		// The range does not include the entire internal list, so add it
		ve.batchPush( data, this.getFullData( listRange ) );
	}
	return this.cloneWithData( data, true, detachedCopy );
};

/**
 * Create a sub-document associated with this document like #cloneFromRange, but without cloning
 * any data from a range in this document: instead, use the specified data.
 *
 * @param {Array|ve.dm.ElementLinearData} data Raw linear model data or ElementLinearData
 * @param {boolean} [copyInternalList] Copy the internal list
 * @param {boolean} [detachedCopy] The copy is not intended to be merged into the original
 * @return {ve.dm.Document} New document
 */
ve.dm.Document.prototype.cloneWithData = function ( data, copyInternalList, detachedCopy ) {
	if ( Array.isArray( data ) ) {
		data = new ve.dm.ElementLinearData( this.getStore().slice(), data );
	}

	const newDoc = new this.constructor(
		data,
		// htmlDocument
		this.getHtmlDocument(),
		// parentDocument
		undefined,
		// internalList
		copyInternalList ? this.getInternalList() : undefined,
		// innerWhitespace
		undefined,
		// lang+dir
		this.getLang(), this.getDir(),
		// originalDocument
		this,
		// sourceMode
		this.sourceMode,
		// persistentStorage
		this.getStorage()
	);
	if ( copyInternalList && !detachedCopy ) {
		// Record the length of the internal list at the time the slice was created so we can
		// reconcile additions properly
		newDoc.origInternalListLength = this.internalList.getItemNodeCount();
	}
	return newDoc;
};

/**
 * Get document data, possibly with inline MetaItem load offsets restored, possibly without metadata
 *
 * @param {ve.Range} [range] Range to get full data for. If omitted, all data will be returned
 * @param {string} [mode] If 'roundTrip', restore load offsets of inlined meta items from unchanged
 * branches. If 'noMetadata', don't include metadata items.
 * @return {Array} Data, with load offset info removed (some items are referenced, others copied)
 */
ve.dm.Document.prototype.getFullData = function ( range, mode ) {
	const insertedMetaItems = [],
		insertions = {},
		iLen = range ? range.end : this.data.getLength(),
		result = [];

	function stripMetaLoadInfo( element ) {
		if ( !element || !element.internal ) {
			return element;
		}
		element = ve.copy( element );
		delete element.internal.changesSinceLoad;
		delete element.internal.metaItems;
		delete element.internal.loadMetaParentHash;
		delete element.internal.loadMetaParentOffset;
		if ( Object.keys( element.internal ).length === 0 ) {
			delete element.internal;
		}
		return element;
	}

	for ( let i = range ? range.start : 0; i < iLen; i++ ) {
		const item = this.data.getData( i );
		if (
			ve.dm.LinearData.static.isOpenElementData( item ) &&
			ve.dm.nodeFactory.isMetaData( item.type ) &&
			(
				mode === 'noMetadata' ||
				mode === 'roundTrip' && (
					// Already inserted
					insertedMetaItems.indexOf( item.originalDomElementsHash ) !== -1 ||
					// Removable meta item that was not handled yet, which means that its entire branch node
					// must have been removed, so it's out of place and should be removed too
					ve.dm.nodeFactory.isRemovableMetaData( item.type ) && ve.getProp( item, 'internal', 'loadMetaParentOffset' )
				)
			)
		) {
			// Skip this item and its matching close tag
			i += 1;
			continue;
		}
		let metaItem, metaItems, internal;
		if (
			mode === 'roundTrip' &&
			( internal = item.internal ) &&
			( metaItems = internal.metaItems )
		) {
			if ( !internal.changesSinceLoad ) {
				this.data.modifyData( i, ( dataItem ) => {
					// Re-fetch unfrozen metaItems.
					metaItems = dataItem.internal.metaItems;
					// No changes, so restore meta item offsets
					for ( let j = 0, jLen = metaItems.length; j < jLen; j++ ) {
						metaItem = metaItems[ j ];
						const offset = i + metaItem.internal.loadMetaParentOffset;
						if ( !insertions[ offset ] ) {
							insertions[ offset ] = [];
						}

						delete metaItem.internal.loadBranchNodeHash;
						delete metaItem.internal.loadBranchNodeOffset;
						if ( Object.keys( metaItem.internal ).length === 0 ) {
							delete metaItem.internal;
						}
						insertions[ offset ].push( stripMetaLoadInfo( metaItem ) );
						insertedMetaItems.push( metaItem.originalDomElementsHash );
					}
				} );
			} else {
				// Had changes, so remove removable meta items that are out of place now
				for ( let j = 0, jLen = metaItems.length; j < jLen; j++ ) {
					metaItem = metaItems[ j ];
					if ( ve.dm.nodeFactory.isRemovableMetaData( metaItem.type ) ) {
						insertedMetaItems.push( metaItem.originalDomElementsHash );
					}
				}
			}
		}
		result.push( stripMetaLoadInfo( item ) );
		if ( mode === 'roundTrip' && insertions[ i ] ) {
			for ( let j = 0, jLen = insertions[ i ].length; j < jLen; j++ ) {
				metaItem = insertions[ i ][ j ];
				result.push( metaItem );
				result.push( { type: '/' + metaItem.type } );
			}
		}
	}
	return result;
};

/**
 * Get the nearest word boundary.
 *
 * @param {number} offset Offset to start from
 * @param {number} [direction] Direction to prefer matching offset in, -1 for left and 1 for right
 * @return {number} Nearest word boundary
 */
ve.dm.Document.prototype.getSiblingWordBoundary = function ( offset, direction ) {
	const dataString = new ve.dm.DataString( this.getData() );
	return unicodeJS.wordbreak.moveBreakOffset( direction, dataString, offset, true );
};

/**
 * Get the relative word or character boundary.
 *
 * @param {number} offset Offset to start from
 * @param {number} direction Direction to prefer matching offset in, -1 for left and 1 for right
 * @param {string} [unit] Unit [word|character]
 * @return {number} Relative offset
 */
ve.dm.Document.prototype.getRelativeOffset = function ( offset, direction, unit ) {
	const data = this.data;
	if ( unit === 'word' ) { // Word
		// Method getSiblingWordBoundary does not "move/jump" over element data. If passed offset is
		// an element data offset then the same offset is returned - and in such case this method
		// fallback to the other path (character) which does "move/jump" over element data.
		let newOffset = this.getSiblingWordBoundary( offset, direction );
		if ( offset === newOffset ) {
			newOffset = this.getRelativeOffset( offset, direction, 'character' );
		}
		return newOffset;
	} else { // Character
		// Check if we are adjacent to a focusable node
		const adjacentDataOffset = offset + ( direction > 0 ? 0 : -1 );
		if (
			data.isElementData( adjacentDataOffset ) &&
			ve.dm.nodeFactory.isNodeFocusable( data.getType( adjacentDataOffset ) )
		) {
			// We are adjacent to a focusableNode, move inside it
			return offset + direction;
		}
		const relativeContentOffset = data.getRelativeContentOffset( offset, direction );
		let relativeStructuralOffset = data.getRelativeStructuralOffset( offset, direction, true );
		let isFocusable;
		// Check the structural offset is not in the wrong direction
		if ( ( relativeStructuralOffset - offset < 0 ? -1 : 1 ) !== direction ) {
			relativeStructuralOffset = offset;
		} else {
			isFocusable = ( relativeStructuralOffset - offset < 0 ? -1 : 1 ) === direction &&
				data.isElementData( relativeStructuralOffset + direction ) &&
				ve.dm.nodeFactory.isNodeFocusable( data.getType( relativeStructuralOffset + direction ) );
		}
		// Check if we've moved into a slug or a focusableNode
		if ( isFocusable || this.hasSlugAtOffset( relativeStructuralOffset ) ) {
			if ( isFocusable ) {
				relativeStructuralOffset += direction;
			}
			// Check if the relative content offset is in the opposite direction we are trying to go
			if (
				relativeContentOffset === offset ||
				( relativeContentOffset - offset < 0 ? -1 : 1 ) !== direction
			) {
				return relativeStructuralOffset;
			}
			// There's a slug nearby, go into it if it's closer
			return direction > 0 ?
				Math.min( relativeContentOffset, relativeStructuralOffset ) :
				Math.max( relativeContentOffset, relativeStructuralOffset );
		} else {
			// Don't allow the offset to move in the wrong direction
			return direction > 0 ?
				Math.max( relativeContentOffset, offset ) :
				Math.min( relativeContentOffset, offset );
		}
	}
};

/**
 * Get the relative range.
 *
 * @param {ve.Range} range Input range
 * @param {number} direction Direction to look in, +1 or -1
 * @param {string} unit Unit [word|character]
 * @param {boolean} expand Expanding range
 * @param {ve.Range} [limit] Optional limiting range. If the relative range is not in this range
 *                           the input range is returned instead.
 * @return {ve.Range} Relative range
 */
ve.dm.Document.prototype.getRelativeRange = function ( range, direction, unit, expand, limit ) {
	let to = range.to;

	// If you have a non-collapsed range and you move, collapse to the end
	// in the direction you moved, provided you end up at a content or slug offset
	if ( !range.isCollapsed() && !expand ) {
		const newOffset = direction > 0 ? range.end : range.start;
		if ( this.data.isContentOffset( newOffset ) || this.hasSlugAtOffset( newOffset ) ) {
			return new ve.Range( newOffset );
		} else {
			to = newOffset;
		}
	}

	const contentOrSlugOffset = this.getRelativeOffset( to, direction, unit );

	const focusableNode = this.getNearestFocusableNode( to, direction, contentOrSlugOffset );
	let newRange;
	if ( focusableNode ) {
		newRange = focusableNode.getOuterRange( direction === -1 );
	} else {
		newRange = new ve.Range( contentOrSlugOffset );
	}
	if ( limit && !limit.containsRange( newRange ) ) {
		return range;
	}
	if ( expand ) {
		return new ve.Range( range.from, newRange.to );
	} else {
		return newRange;
	}
};

/**
 * Get the nearest node matching a test.
 *
 * @param {Function} test Function to test whether a node matches, called with the nodeType
 * @param {number} offset Offset to start looking at
 * @param {number} direction Direction to look in, +1 or -1
 * @param {number} limit Stop looking after reaching certain offset
 * @return {ve.dm.Node|null} Nearest matching node, or null if not found
 */
ve.dm.Document.prototype.getNearestNodeMatching = function ( test, offset, direction, limit ) {
	// It is never an offset of the node, but just an offset for which getNodeFromOffset should
	// return that node. Usually it would be node offset + 1 or offset of node closing tag.
	let coveredOffset;
	this.data.getRelativeOffset(
		offset,
		direction === 1 ? 0 : -1,
		function ( index, lim ) {
			// Our result must be between offset and limit
			if ( index >= Math.max( offset, lim ) || index < Math.min( offset, lim ) ) {
				return true;
			}
			if (
				this.isOpenElementData( index ) &&
				test( this.getType( index ) )
			) {
				coveredOffset = index + 1;
				return true;
			}
			if (
				this.isCloseElementData( index ) &&
				test( this.getType( index ) )
			) {
				coveredOffset = index;
				return true;
			}
		},
		limit
	);
	if ( coveredOffset ) {
		return this.getDocumentNode().getNodeFromOffset( coveredOffset );
	} else {
		return null;
	}
};

/**
 * Get the nearest focusable node.
 *
 * @param {number} offset Offset to start looking at
 * @param {number} direction Direction to look in, +1 or -1
 * @param {number} limit Stop looking after reaching certain offset
 * @return {ve.dm.Node|null} Nearest focusable node, or null if not found
 */
ve.dm.Document.prototype.getNearestFocusableNode = function ( offset, direction, limit ) {
	return this.getNearestNodeMatching( ( nodeType ) => ve.dm.nodeFactory.isNodeFocusable( nodeType ), offset, direction, limit );
};

/**
 * Get the nearest offset that a cursor can be placed at.
 *
 * Note that an offset in the other direction can be returned if there are no valid offsets in the
 * preferred direction.
 *
 * @param {number} offset Offset to start looking at
 * @param {number} [direction=-1] Direction to check first, +1 or -1; if 0, find the closest offset
 * @return {number} Nearest offset a cursor can be placed at, or -1 if there are no valid offsets in
 *     data
 */
ve.dm.Document.prototype.getNearestCursorOffset = function ( offset, direction ) {
	if ( direction === 0 ) {
		const left = this.getNearestCursorOffset( offset, -1 );
		const right = this.getNearestCursorOffset( offset, 1 );
		// If only one of `left` and `right` is valid, return the valid one.
		// If neither is valid, this returns -1.
		if ( right === -1 ) {
			return left;
		} else if ( left === -1 ) {
			return right;
		}
		return offset - left < right - offset ? left : right;
	}

	direction = direction > 0 ? 1 : -1;
	if (
		this.data.isContentOffset( offset ) ||
		this.hasSlugAtOffset( offset )
	) {
		return offset;
	}

	const contentOffset = this.data.getNearestContentOffset( offset, direction );
	const structuralOffset = this.data.getNearestStructuralOffset( offset, direction, true );

	// If only one of `contentOffset` and `structuralOffset` is valid, return the valid one.
	// If neither is valid, this returns -1.
	if ( structuralOffset === -1 || !this.hasSlugAtOffset( structuralOffset ) ) {
		return contentOffset;
	} else if ( contentOffset === -1 ) {
		return structuralOffset;
	}

	if ( direction === 1 ) {
		if ( contentOffset < offset ) {
			return structuralOffset;
		} else {
			return Math.min( contentOffset, structuralOffset );
		}
	} else {
		if ( contentOffset > offset ) {
			return structuralOffset;
		} else {
			return Math.max( contentOffset, structuralOffset );
		}
	}
};

/**
 * @inheritdoc
 */
ve.dm.Document.prototype.getBranchNodeFromOffset = function ( offset ) {
	if ( offset < 0 || offset > this.data.getLength() ) {
		throw new Error( 've.dm.Document.getBranchNodeFromOffset(): offset ' + offset + ' is out of bounds' );
	}
	if ( !this.branchNodeFromOffsetCache[ offset ] ) {
		this.branchNodeFromOffsetCache[ offset ] = ve.Document.prototype.getBranchNodeFromOffset.call( this, offset );
	}
	return this.branchNodeFromOffsetCache[ offset ];
};

/**
 * Check if there is a slug at an offset.
 *
 * @param {number} offset Offset to check for a slug at
 * @return {boolean} There is a slug at the offset
 */
ve.dm.Document.prototype.hasSlugAtOffset = function ( offset ) {
	let node;
	try {
		node = this.getBranchNodeFromOffset( offset );
	} catch ( e ) {
		// Offset was out of bounds
	}
	return node ? node.hasSlugAtOffset( offset ) : false;
};

/**
 * Get the content data of a node.
 *
 * @param {ve.dm.Node} node Node to get content data for
 * @return {Array|null} List of content and elements inside node or null if node is not found
 */
ve.dm.Document.prototype.getDataFromNode = function ( node ) {
	let offset = node.getOffset();
	if ( offset >= 0 ) {
		// FIXME T126023: If the node is wrapped in an element than we should increment
		// the offset by one so we only return the content inside the element.
		if ( node.isWrapped() ) {
			offset++;
		}
		return this.data.slice( offset, offset + node.getLength() );
	}
	return null;
};

/**
 * Rebuild the entire node tree from linear model data.
 */
ve.dm.Document.prototype.rebuildTree = function () {
	// Never rebuild above the attachedRoot node as that would destroy
	// that node, and invalidate all references to it (T293254)
	const rootNode = this.attachedRoot || this.getDocumentNode();
	const range = rootNode.getRange();
	const data = this.data.sliceObject( range.start, range.end );
	// Build document fragment from data
	// Use plain ve.dm.Document, instead of whatever this.constructor is.
	const documentFragment = new ve.dm.Document( data, this.htmlDocument, this );
	// Get generated child nodes from the document fragment
	const addedNodes = documentFragment.getDocumentNode().getChildren();
	// Replace nodes in the model tree
	const removedNodes = ve.batchSplice( rootNode, 0, rootNode.getChildren().length, addedNodes );

	this.updateNodesByType( addedNodes, removedNodes );

	// Clear branch node cache
	this.branchNodeFromOffsetCache = [];
};

/**
 * Update the nodes-by-type index
 *
 * @param {ve.dm.Node[]} addedNodes Added nodes
 * @param {ve.dm.Node[]} removedNodes Removed nodes
 */
ve.dm.Document.prototype.updateNodesByType = function ( addedNodes, removedNodes ) {
	const remove = ( node ) => {
		const type = node.getType(),
			nodes = this.nodesByType[ type ] || [],
			index = nodes.indexOf( node );

		if ( index !== -1 ) {
			nodes.splice( index, 1 );
			if ( !nodes.length ) {
				delete this.nodesByType[ type ];
			}
		}
	};

	const add = ( node ) => {
		const type = node.getType(),
			nodes = this.nodesByType[ type ] = this.nodesByType[ type ] || [];

		nodes.push( node );
	};

	const traverse = ( nodes, action ) => {
		nodes.forEach( ( node ) => {
			if ( node.hasChildren() ) {
				node.traverse( action );
			}
			action( node );
		} );
	};

	traverse( removedNodes, remove );
	traverse( addedNodes, add );
};

/**
 * Get all nodes in the tree for a specific type
 *
 * If a string type is passed only nodes of that exact type will be returned,
 * if a node class is passed, all sub types will be matched.
 *
 * String type matching will be faster than class matching.
 *
 * @param {string|Function} type Node type name or node constructor
 * @param {boolean} sort Sort nodes by document order
 * @return {ve.dm.Node[]} Nodes of a specific type
 */
ve.dm.Document.prototype.getNodesByType = function ( type, sort ) {
	if ( !this.documentNode.length && !this.documentNode.getDocument().buildingNodeTree ) {
		this.buildNodeTree();
	}
	let nodes;
	if ( type instanceof Function ) {
		nodes = [];
		for ( const t in this.nodesByType ) {
			const nodeType = ve.dm.nodeFactory.lookup( t );
			if ( nodeType === type || nodeType.prototype instanceof type ) {
				ve.batchPush( nodes, this.getNodesByType( t ) );
			}
		}
	} else {
		nodes = this.nodesByType[ type ] || [];
	}

	if ( sort ) {
		nodes.sort( ( a, b ) => a.getOffset() - b.getOffset() );
	}
	return nodes;
};

/**
 * @typedef FixedInsertion
 * @memberof ve.dm.Document
 * @property {Array} data Possibly modified copy of `data`
 * @property {number} offset Possibly modified offset
 * @property {number} remove Number of elements to remove after the modified `offset`
 * @property {number} [insertedDataOffset] Offset of intended insertion within fixed up data
 * @property {number} [insertedDataLength] Length of intended insertion within fixed up data
 */

/**
 * Fix up data so it can safely be inserted into the document data at an offset.
 *
 * TODO: this function needs more work but it seems to work, mostly
 *
 * @param {Array} data Snippet of linear model data to insert
 * @param {number} offset Offset in the linear model where the caller wants to insert data
 * @return {ve.dm.Document.FixedInsertion}
 */
ve.dm.Document.prototype.fixupInsertion = function ( data, offset ) {
	const
		// Array where we build the return value
		newData = [],

		// Inserting block element into an empty content branch will replace it.
		remove = 0,

		// *** Stacks ***
		// Array of element openings (object). Openings in data are pushed onto this stack
		// when they are encountered and popped off when they are closed
		openingStack = [],
		// Array of node objects. Closings in data that close nodes that were
		// not opened in data (i.e. were already in the document) are pushed onto this stack
		// and popped off when balanced out by an opening in data
		closingStack = [];

	// Track the position of the original data in the fixed up data for range adjustments
	let insertedDataOffset = 0,
		insertedDataLength = data.length,

		// *** State persisting across iterations of the outer loop ***
		// The node (from the document) we're currently in. When in a node that was opened
		// in data, this is set to its first ancestor that is already in the document
		parentNode,
		// The type of the node we're currently in, even if that node was opened within data
		parentType,
		// Whether we are currently in a text node
		inTextNode,
		// Whether this is the first child of its parent
		// The test for last child isn't a loop so we don't need to cache it
		isFirstChild = null;

	/**
	 * Append a linear model element to newData and update the state.
	 *
	 * This function updates parentNode, parentType, openingStack and closingStack.
	 *
	 * @private
	 * @param {Object|Array|string} element Linear model element
	 * @param {number} index Index in data that the element came from (for error reporting only)
	 */
	function writeElement( element, index ) {
		if ( element.type !== undefined ) {
			// Content, do nothing
			if ( element.type.charAt( 0 ) !== '/' ) {
				// Opening
				// Check if this opening balances an earlier closing of a node that was already in
				// the document. This is only the case if openingStack is empty (otherwise we still
				// have unclosed nodes from within data) and if this opening matches the top of
				// closingStack
				if ( openingStack.length === 0 && closingStack.length > 0 &&
					closingStack[ closingStack.length - 1 ].getType() === element.type
				) {
					// The top of closingStack is now balanced out, so remove it
					// Also restore parentNode from closingStack. While this is technically not
					// entirely accurate (the current node is a new node that's a sibling of this
					// node), it's good enough for the purposes of this algorithm
					parentNode = closingStack.pop();
				} else {
					// This opens something new, put it on openingStack
					openingStack.push( element );
				}
				parentType = element.type;
			} else {
				let expectedType;
				// Closing
				// Make sure that this closing matches the currently opened node
				if ( openingStack.length > 0 ) {
					// The opening was on openingStack, so we're closing a node that was opened
					// within data. Don't track that on closingStack
					expectedType = openingStack.pop().type;
				} else {
					// openingStack is empty, so we're closing a node that was already in the
					// document. This means we have to reopen it later, so track this on
					// closingStack
					expectedType = parentNode.getType();
					closingStack.push( parentNode );
					parentNode = parentNode.getParent();
					if ( !parentNode ) {
						throw new Error( 'Inserted data is trying to close the root node ' +
							'(at index ' + index + ')' );
					}
					parentType = expectedType;

					// Validate
					// FIXME this breaks certain input, should fix it up, not scream and die
					// For now we fall back to inserting balanced data, but then we miss out on
					// a lot of the nice content adoption abilities of just fixing up the data in
					// the context of the insertion point - an example of how this will fail is if
					// you try to insert "b</p></li></ul><p>c" into "<p>a[cursor]d</p>"
					if (
						element.type !== '/' + expectedType &&
						(
							// Only throw an error if the content can't be adopted from one content
							// branch to another
							!ve.dm.nodeFactory.canNodeContainContent( element.type.slice( 1 ) ) ||
							!ve.dm.nodeFactory.canNodeContainContent( expectedType )
						)
					) {
						throw new Error( 'Cannot adopt content from ' + element.type +
							' nodes into ' + expectedType + ' nodes (at index ' + index + ')' );
					}
				}
			}
		}
		newData.push( element );
	}

	/**
	 * Close the current element on the stack and arrange for its later reopening
	 *
	 * This function updates parentNode, parentType, closingStack, reopenElements, and closings.
	 *
	 * @private
	 * @param {Array} closings Closing elements array to be appended to
	 * @param {Array} reopenElements Opening elements array to be appended to
	 * @param {string} type Current element type we're considering (for error reporting only)
	 * @param {number} index Current index (for error reporting only)
	 */
	function closeElement( closings, reopenElements, type, index ) {
		// Close the parent and try one level up
		closings.push( { type: '/' + parentType } );
		if ( openingStack.length > 0 ) {
			const element = openingStack.pop();
			parentType = element.type;
			reopenElements.push( ve.copy( element ) );
			// The opening was on openingStack, so we're closing a node that was opened
			// within data. Don't track that on closingStack
		} else {
			if ( !parentNode.getParent() ) {
				throw new Error( 'Cannot insert ' + type + ' even after closing ' +
					'all containing nodes (at index ' + index + ')' );
			}
			// openingStack is empty, so we're closing a node that was already in the
			// document. This means we have to reopen it later, so track this on
			// closingStack
			closingStack.push( parentNode );
			reopenElements.push( parentNode.getClonedElement() );
			parentNode = parentNode.getParent();
			parentType = parentNode.getType();
		}
	}

	parentNode = this.getBranchNodeFromOffset( offset );
	parentType = parentNode.getType();
	inTextNode = false;
	isFirstChild = this.data.isOpenElementData( offset - 1 );

	for ( let i = 0; i < data.length; i++ ) {
		if ( inTextNode && data[ i ].type !== undefined ) {
			parentType = openingStack.length > 0 ?
				openingStack[ openingStack.length - 1 ].type : parentNode.getType();
		}
		if ( data[ i ].type === undefined || data[ i ].type.charAt( 0 ) !== '/' ) {
			// The type of the node we're currently inserting. When the to-be-inserted node
			// is wrapped, this is set to the type of the outer wrapper.
			let childType = data[ i ].type || 'text';
			// Array of opening elements to insert (for wrapping the to-be-inserted element)
			const openings = [];
			// Array of closing elements to insert (for splitting nodes)
			const closings = [];
			// Array of opening elements matching the elements in closings (in the same order)
			const reopenElements = [];
			// Opening or content
			// Make sure that opening this element here does not violate the parent/children/content
			// rules. If it does, insert stuff to fix it

			// If this node is content, check that the containing node can contain content.
			// If not, add a wrapper paragraph
			if ( ve.dm.nodeFactory.isNodeContent( childType ) &&
				!ve.dm.nodeFactory.canNodeContainContent( parentType )
			) {
				childType = 'paragraph';
				const wrapper = ve.dm.nodeFactory.getDataElement( childType );
				ve.setProp( wrapper, 'internal', 'generated', 'wrapper' );
				openings.unshift( wrapper );
			}

			// Check that this node is allowed to have the containing node as its parent. If not,
			// wrap it until it's fixed
			let parentsOK;
			do {
				const allowedParents = ve.dm.nodeFactory.getParentNodeTypes( childType );
				parentsOK = allowedParents === null ||
					allowedParents.indexOf( parentType ) !== -1;
				if ( !parentsOK ) {
					// We can't have this as the parent
					if ( allowedParents.length === 0 ) {
						throw new Error( 'Cannot insert ' + childType + ' because it ' +
							' cannot have a parent (at index ' + i + ')' );
					}
					// Open an allowed node around this node
					childType = allowedParents[ 0 ];
					openings.unshift( ve.dm.nodeFactory.getDataElement( childType ) );
				}
			} while ( !parentsOK );

			// Check that the node is allowed to have the containing node as
			// its parent. If not, close surrounding nodes until the node is
			// contained in an acceptable parent.
			const suggestedParents = ve.dm.nodeFactory.getSuggestedParentNodeTypes( childType );
			let suggestedParentsOK;
			do {
				suggestedParentsOK = suggestedParents === null ||
					suggestedParents.indexOf( parentType ) !== -1;
				if ( !suggestedParentsOK ) {
					closeElement( closings, reopenElements, childType, i );
				}
			} while ( !suggestedParentsOK );

			// Check that the containing node can have this node as its child. If not, close nodes
			// until it's fixed
			let childrenOK;
			do {
				const allowedChildren = ve.dm.nodeFactory.getChildNodeTypes( parentType );
				childrenOK = allowedChildren === null ||
					allowedChildren.indexOf( childType ) !== -1;
				// Also check if we're trying to insert structure into a node that has to contain
				// content
				childrenOK = childrenOK && !(
					!ve.dm.nodeFactory.isNodeContent( childType ) &&
					ve.dm.nodeFactory.canNodeContainContent( parentType )
				);
				if ( !childrenOK ) {
					// We can't insert this into this parent
					if ( isFirstChild ) {
						// This element would be the first child of its parent, so
						// abandon this fix up and try again one offset to the left
						const insertion = this.fixupInsertion( data, offset - 1 );
						if ( this.data.isCloseElementData( offset ) && !ve.dm.nodeFactory.isNodeInternal( parentType ) ) {
							// This element would also be the last child, so that means parent is empty.
							// Remove it entirely. (Never remove the internal list though, ugh...)
							insertion.remove += 2;
						}
						return insertion;
					}

					// Close the parent and try one level up
					closeElement( closings, reopenElements, childType, i );
				}
			} while ( !childrenOK );

			for ( let j = 0; j < closings.length; j++ ) {
				// writeElement() would update openingStack/closingStack, but we've already done
				// that for closings
				if ( i === 0 ) {
					insertedDataOffset++;
				} else {
					insertedDataLength++;
				}
				newData.push( closings[ j ] );
			}
			for ( let j = 0; j < openings.length; j++ ) {
				if ( i === 0 ) {
					insertedDataOffset++;
				} else {
					insertedDataLength++;
				}
				writeElement( openings[ j ], i );
			}
			writeElement( data[ i ], i );
			if ( data[ i ].type === undefined ) {
				// Special treatment for text nodes
				inTextNode = true;
				if ( openings.length > 0 ) {
					// We wrapped the text node, update parentType
					parentType = childType;
				}
				// If we didn't wrap the text node, then the node we're inserting into can have
				// content, so we couldn't have closed anything
			} else {
				parentType = data[ i ].type;
			}
		} else {
			// Closing
			writeElement( data[ i ], i );
			parentType = openingStack.length > 0 ?
				openingStack[ openingStack.length - 1 ].type : parentNode.getType();
		}
	}

	if ( closingStack.length > 0 && this.data.isCloseElementData( offset ) ) {
		// This element would be the last child of its parent, so
		// abandon this fix up and try again one offset to the right
		return this.fixupInsertion( data, offset + 1 );
	}

	if ( inTextNode ) {
		parentType = openingStack.length > 0 ?
			openingStack[ openingStack.length - 1 ].type : parentNode.getType();
	}

	// Close unclosed openings
	while ( openingStack.length > 0 ) {
		const popped = openingStack[ openingStack.length - 1 ];
		// writeElement() will perform the actual pop() that removes
		// popped from openingStack
		writeElement( { type: '/' + popped.type }, data.length );
	}
	// Re-open closed nodes
	while ( closingStack.length > 0 ) {
		const popped = closingStack[ closingStack.length - 1 ];
		// writeElement() will perform the actual pop() that removes
		// popped from closingStack
		writeElement( popped.getClonedElement(), data.length );
	}

	return {
		offset: offset,
		data: newData,
		remove: remove,
		insertedDataOffset: insertedDataOffset !== 0 ? insertedDataOffset : undefined,
		insertedDataLength: insertedDataLength !== newData.length ? insertedDataLength : undefined
	};
};

/**
 * Create a document given an HTML string or document.
 *
 * @param {string|HTMLDocument} html HTML string or document to insert
 * @param {Object} [importRules] The import rules with which to sanitize the HTML, if importing
 * @return {ve.dm.Document} New document
 */
ve.dm.Document.prototype.newFromHtml = function ( html, importRules ) {
	const htmlDoc = typeof html === 'string' ? ve.createDocumentFromHtml( html ) : html,
		doc = ve.dm.converter.getModelFromDom( htmlDoc, {
			targetDoc: this.getHtmlDocument(),
			fromClipboard: !!importRules
		} ),
		data = doc.data;

	if ( importRules ) {
		data.sanitize( importRules.external || {} );
		data.sanitize( importRules.all || {} );
	}

	data.remapInternalListKeys( this.getInternalList() );
	// Initialize node tree
	// BUG T75569: This shouldn't be needed
	doc.buildNodeTree();

	return doc;
};

/**
 * Find a text string within the document
 *
 * @param {string|RegExp} query Text to find, string or regex with no flags
 * @param {Object} [options] Search options
 * @param {boolean} [options.caseSensitiveString] Case sensitive search for a string query. Ignored by regexes (use 'i' flag).
 * @param {boolean} [options.diacriticInsensitiveString] Diacritic insensitive search for a string query. Ignored by regexes.
 *  Only works in browsers which support the Internationalization API
 * @param {boolean} [options.noOverlaps] Avoid overlapping matches
 * @param {boolean} [options.wholeWord] Only match whole-word occurrences
 * @return {ve.Range[]} List of ranges where the string was found
 */
ve.dm.Document.prototype.findText = function ( query, options ) {
	const data = this.data,
		documentRange = this.getDocumentRange();
	let ranges = [];

	options = options || {};

	if ( query instanceof RegExp ) {
		// Avoid multi-line matching by only matching within content (text or content elements)
		data.forEachRunOfContent( documentRange, ( off, line ) => {
			query.lastIndex = 0;
			let match;
			while ( ( match = query.exec( line ) ) !== null ) {
				let matchText = match[ 0 ];

				// Skip empty string matches (e.g. with .*)
				if ( matchText.length === 0 ) {
					// Set lastIndex to the next character to avoid an infinite
					// loop. Browsers differ in whether they do this for you
					// for empty matches; see
					// http://blog.stevenlevithan.com/archives/exec-bugs
					query.lastIndex = match.index + 1;
					continue;
				}

				// Content elements' open/close data is replaced by the replacement character U+FFFC.
				// Ensure that matches of U+FFFC contain the entire element (opening and closing data).
				// The U+FFFC placeholder is only used for elements which "are content" (.static.isContent
				// is true), and such elements are guaranteed to not contain content, so this is safe.
				// Note, however, that this character is allowed to appear in normal text (eww),
				// so we consult the actual document data to make sure we actually matched an element.

				// 1/2: If we matched opening U+FFFC at the end, extend the match forwards by 1.
				if (
					matchText[ matchText.length - 1 ] === '\uFFFC' &&
					data.isOpenElementData( off + match.index + matchText.length - 1 ) &&
					data.isCloseElementData( off + match.index + matchText.length )
				) {
					matchText += '\uFFFC';
					query.lastIndex += 1;
				}

				// 2/2: If we matched closing U+FFFC at the beginning, skip the match.
				// (We do not extend the match backwards to avoid overlapping matches.)
				if (
					matchText[ 0 ] === '\uFFFC' &&
					data.isOpenElementData( off + match.index - 1 ) &&
					data.isCloseElementData( off + match.index )
				) {
					// Continue matching at the next character, rather than the end of this match.
					query.lastIndex = match.index + 1;
					continue;
				}

				ranges.push( new ve.Range(
					off + match.index,
					off + match.index + matchText.length
				) );
				if ( !options.noOverlaps ) {
					query.lastIndex = match.index + 1;
				}
			}
		} );
	} else {
		const qLen = query.length;
		let sensitivity;
		if ( options.diacriticInsensitiveString ) {
			sensitivity = options.caseSensitiveString ? 'case' : 'base';
		} else {
			sensitivity = options.caseSensitiveString ? 'variant' : 'accent';
		}
		// Intl is only used browser clients
		const compare = new Intl.Collator( this.lang, { sensitivity: sensitivity } ).compare;
		// Iterate up to (and including) offset textLength - queryLength. Beyond that point
		// there is not enough room for the query to exist
		for ( let offset = 0, l = documentRange.getLength() - qLen; offset <= l; offset++ ) {
			let j = 0;
			while ( compare( data.getCharacterData( offset + j ), query[ j ] ) === 0 ) {
				j++;
				if ( j === qLen ) {
					ranges.push( new ve.Range( offset, offset + qLen ) );
					offset += options.noOverlaps ? qLen - 1 : 0;
					break;
				}
			}
		}
	}

	if ( options.wholeWord ) {
		const dataString = new ve.dm.DataString( this.getData() );
		ranges = ranges.filter( ( range ) => unicodeJS.wordbreak.isBreak( dataString, range.start ) &&
				unicodeJS.wordbreak.isBreak( dataString, range.end ) );
	}

	return ranges;
};

/**
 * Get the length of the complete history. This is also the current pointer.
 *
 * @return {number} Length of the complete history stack
 */
ve.dm.Document.prototype.getCompleteHistoryLength = function () {
	return this.completeHistory.getLength();
};

/**
 * Get all the transactions in the complete history since a specified pointer.
 *
 * @param {number} start Pointer from where to start the slice
 * @return {ve.dm.Transaction[]} Array of transaction objects
 */
ve.dm.Document.prototype.getCompleteHistorySince = function ( start ) {
	return this.completeHistory.transactions.slice( start );
};

/**
 * Single change containing most recent transactions in history stack
 *
 * @param {number} start Pointer from where to start slicing transactions
 * @return {ve.dm.Change} Single change containing transactions since pointer
 */
ve.dm.Document.prototype.getChangeSince = function ( start ) {
	const change = this.completeHistory.mostRecent( start );
	// Remove any selections that might have been added by e.g. ve.dm.Change#addToHistory
	change.selections = {};
	return change;
};

/**
 * Get the content language
 *
 * @return {string} Language code
 */
ve.dm.Document.prototype.getLang = function () {
	return this.lang;
};

/**
 * Get the content directionality
 *
 * @return {string} Directionality (ltr/rtl)
 */
ve.dm.Document.prototype.getDir = function () {
	return this.dir;
};

/**
 * Set a key/value pair in persistent static storage, or restore the whole store
 *
 * Storage is used for static variables related to document state,
 * such as InternalList's nextUniqueNumber.
 *
 * @param {string|Object} [keyOrStorage] Key, or storage object to restore
 * @param {any} [value] Serializable value, if key is set
 * @fires ve.dm.Document#storage
 */
ve.dm.Document.prototype.setStorage = function ( keyOrStorage, value ) {
	if ( typeof keyOrStorage === 'string' ) {
		this.persistentStorage[ keyOrStorage ] = value;
		this.emit( 'storage' );
	} else {
		this.persistentStorage = keyOrStorage;
	}
};

/**
 * Get a value from the persistent static storage, or the whole store
 *
 * @param {string} [key] Key
 * @return {any|Object} Value at key, or whole storage object if key not provided
 */
ve.dm.Document.prototype.getStorage = function ( key ) {
	if ( key ) {
		return this.persistentStorage[ key ];
	} else {
		return this.persistentStorage;
	}
};