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

/**
 * DataModel converter.
 *
 * Converts between HTML DOM and VisualEditor linear data.
 *
 * @class
 * @constructor
 * @param {ve.dm.ModelRegistry} modelRegistry
 * @param {ve.dm.NodeFactory} nodeFactory
 * @param {ve.dm.AnnotationFactory} annotationFactory
 */
ve.dm.Converter = function VeDmConverter( modelRegistry, nodeFactory, annotationFactory ) {
	// Properties
	this.modelRegistry = modelRegistry;
	this.nodeFactory = nodeFactory;
	this.annotationFactory = annotationFactory;
	this.doc = null;
	this.documentData = null;
	this.store = null;
	this.internalList = null;
	this.mode = null;
	this.fromClipboard = null;
	this.contextStack = null;

	// Whitespace regexes
	const whitespaceList = this.constructor.static.whitespaceList;

	this.leadingWhitespaceRegex = new RegExp( '^[' + whitespaceList + ']' );
	this.leadingWhitespacesRegex = new RegExp( '^[' + whitespaceList + ']+' );
	this.trailingWhitespaceRegex = new RegExp( '[' + whitespaceList + ']$' );
	this.trailingWhitespacesRegex = new RegExp( '[' + whitespaceList + ']+$' );
	this.onlyWhitespaceRegex = new RegExp( '^[' + whitespaceList + ']+$' );
	this.trimWhitespaceRegex = new RegExp( '^([' + whitespaceList + ']*)([\\s\\S]*?)([' + whitespaceList + ']*)$' );

};

/* Inheritance */

OO.initClass( ve.dm.Converter );

/* Static Properties */

/**
 * List of HTML attribute names that {#renderHtmlAttributeList} should use computed values for.
 *
 * @type {string[]}
 */
ve.dm.Converter.static.computedAttributes = [ 'href', 'src' ];

/**
 * Pattern matching 'white space characters' as defined by the HTML spec only.
 *
 * All other whitespace should be treated as text, e.g. non-breaking spaces.
 *
 * See https://www.w3.org/TR/html4/struct/text.html#h-9.1
 *
 * @type {RegExp}
 */
ve.dm.Converter.static.whitespaceList = ' \\t\\f\\u200b\\r\\n';

ve.dm.Converter.static.PARSER_MODE = 0;
ve.dm.Converter.static.CLIPBOARD_MODE = 1;
ve.dm.Converter.static.PREVIEW_MODE = 2;

/* Static Methods */

/**
 * Get linear model data from a string optionally applying annotations
 *
 * @static
 * @param {string} text Plain text to convert
 * @param {ve.dm.AnnotationSet} [annotations] Annotations to apply
 * @return {Array} Linear model data, one element per character
 */
ve.dm.Converter.static.getDataContentFromText = function ( text, annotations ) {
	const characters = text.split( '' );

	if ( !annotations || annotations.isEmpty() ) {
		return characters;
	}
	// Apply annotations to characters
	for ( let i = 0, len = characters.length; i < len; i++ ) {
		// Just store the annotations' hashes from the hash-value store
		characters[ i ] = [ characters[ i ], annotations.getHashes().slice() ];
	}
	return characters;
};

/**
 * Utility function for annotation rendering. Transforms one set of annotations into another
 * by opening and closing annotations. Each time an annotation is opened or closed, the associated
 * callback is called with the annotation passed as a parameter.
 *
 * Note that currentSet will be modified, and will be equal to targetSet once this function returns.
 *
 * @static
 * @param {ve.dm.AnnotationSet} currentSet The set of annotations currently opened. Will be modified.
 * @param {ve.dm.AnnotationSet} targetSet The set of annotations we want to have.
 * @param {Function} open Callback called when an annotation is opened. Passed a ve.dm.Annotation.
 * @param {Function} close Callback called when an annotation is closed. Passed a ve.dm.Annotation.
 */
ve.dm.Converter.static.openAndCloseAnnotations = function ( currentSet, targetSet, open, close ) {
	let hash, i, len;
	// Close annotations as needed
	// Go through annotationStack from bottom to top (low to high),
	// and find the first annotation that's not in annotations.
	if ( currentSet.getLength() ) {
		const targetSetOpen = targetSet.clone();
		let startClosingAt;
		for ( i = 0, len = currentSet.getLength(); i < len; i++ ) {
			hash = currentSet.getHash( i );
			// containsComparableForSerialization is expensive,
			// so do a simple contains check first
			if (
				targetSetOpen.containsHash( hash ) ||
				targetSetOpen.containsComparableForSerialization( currentSet.get( i ) )
			) {
				targetSetOpen.removeHash( hash );
			} else {
				startClosingAt = i;
				break;
			}
		}
		if ( startClosingAt !== undefined ) {
			// Close all annotations from top to bottom (high to low)
			// until we reach startClosingAt
			for ( i = currentSet.getLength() - 1; i >= startClosingAt; i-- ) {
				close( currentSet.get( i ) );
				// Remove from currentClone
				currentSet.removeAt( i );
			}
		}
	}

	if ( targetSet.getLength() ) {
		const currentSetOpen = currentSet.clone();
		// Open annotations as needed
		for ( i = 0, len = targetSet.getLength(); i < len; i++ ) {
			hash = targetSet.getHash( i );
			// containsComparableForSerialization is expensive,
			// so do a simple contains check first
			if (
				currentSetOpen.containsHash( hash ) ||
				currentSetOpen.containsComparableForSerialization( targetSet.get( i ) )
			) {
				// If an annotation is already open remove it from the currentSetOpen list
				// as it may exist multiple times in the targetSet, and so may need to be
				// opened again
				currentSetOpen.removeHash( hash );
			} else {
				open( targetSet.get( i ) );
				// Add to currentClone
				currentSet.pushHash( hash );
			}
		}
	}
};

/**
 * Copy attributes from one set of DOM elements to another.
 *
 * @static
 * @param {HTMLElement[]} originalDomElements Array of DOM elements to render from
 * @param {HTMLElement[]} targetDomElements Array of DOM elements to render onto
 * @param {boolean|Function} [filter=true] Attribute filter
 * @param {boolean} [computed=false] If true, use the computed values of attributes where available
 * @param {boolean} [deep=false] Recurse into child nodes
 */
ve.dm.Converter.static.renderHtmlAttributeList = function ( originalDomElements, targetDomElements, filter, computed, deep ) {
	if ( filter === undefined ) {
		filter = true;
	}
	if ( filter === false ) {
		return;
	}

	for ( let i = 0, ilen = originalDomElements.length; i < ilen; i++ ) {
		if ( !targetDomElements[ i ] ) {
			continue;
		}
		const attrs = originalDomElements[ i ].attributes;
		if ( !attrs ) {
			continue;
		}
		for ( let j = 0, jlen = attrs.length; j < jlen; j++ ) {
			if (
				targetDomElements[ i ].nodeType === Node.ELEMENT_NODE &&
				!targetDomElements[ i ].hasAttribute( attrs[ j ].name ) &&
				( filter === true || filter( attrs[ j ].name ) )
			) {
				let value;
				if ( computed && this.computedAttributes.indexOf( attrs[ j ].name ) !== -1 ) {
					value = originalDomElements[ i ][ attrs[ j ].name ];
				} else {
					value = attrs[ j ].value;
				}
				targetDomElements[ i ].setAttribute( attrs[ j ].name, value );
			}
		}

		// Descend into element children only (skipping text nodes, comment nodes and nodes we just created)
		if ( deep && !targetDomElements[ i ].veFromDataElement && originalDomElements[ i ].children.length > 0 ) {
			this.renderHtmlAttributeList(
				originalDomElements[ i ].children,
				targetDomElements[ i ].children,
				filter,
				computed,
				true
			);
		}
	}
};

/**
 * Modify linear model data in-place to move inline meta items out of content context
 *
 * All branch node start items must have item.internal.metaItems = []
 * All inline meta items must have item.internal.isInlineMeta set to true
 *
 * After the method completes, each inline meta item will be moved downward to the nearest legal
 * block position (i.e. just after the close meta parent item), and has these properties:
 * item.internal.loadMetaParentHash - corresponding meta parent's item.originalDomElementsHash
 * item.internal.loadMetaParentOffset - offset at load time within the meta parent (0 for start).
 * Each meta item is appended to the corresponding meta parent's item.internal.metaItems .
 *
 * @param {Array} data Linear model data to modify in place
 */
ve.dm.Converter.static.moveInlineMetaItems = function ( data ) {
	const ancestors = [],
		pendingMetaItems = [];

	function closestMetaParent() {
		for ( let n = ancestors.length - 1; n >= 0; n-- ) {
			const ancestor = ancestors[ n ];
			if ( ancestor.isMetaParent ) {
				return ancestor;
			}
		}
		return null;
	}

	let metaParent;
	for ( let i = 0; i < data.length; i++ ) {
		let item = data[ i ];
		if ( Array.isArray( item ) ) {
			// Ignore annotations
			item = item[ 0 ];
		}
		if ( !item.type ) {
			// Item is not a node
			continue;
		}
		if ( item.type[ 0 ] !== '/' ) {
			// Item is a node start
			if ( ve.getProp( item, 'internal', 'isInlineMeta' ) ) {
				// This is an inline meta item: move it
				delete item.internal.isInlineMeta;
				metaParent = closestMetaParent();
				if ( metaParent ) {
					metaParent.item.internal.metaItems.push( item );
					pendingMetaItems.push( {
						item: item,
						closeItem: data[ i + 1 ],
						metaParent: metaParent,
						offset: i - metaParent.offset - 1
					} );
					// Remove this item and the immediately following close item
					data.splice( i, 2 );
					// Prepare to rescan this index
					i--;
				} else {
					// Inline meta outside meta parent. This can happen if, say,
					// the document starts with a comment then a meta item.
					// Skip this item and the immediately following close item
					i++;
				}
			} else {
				ancestors.push( {
					item: item,
					offset: i,
					isMetaParent: !!ve.getProp( item, 'internal', 'metaItems' )
				} );
			}
		} else {
			// Item is a node end
			metaParent = ancestors.pop();
			if ( metaParent.isMetaParent ) {
				for ( let j = 0; j < pendingMetaItems.length; j++ ) {
					const pending = pendingMetaItems[ j ];
					if ( pending.metaParent.item !== metaParent.item ) {
						continue;
					}
					pending.item.internal.loadMetaParentHash = metaParent.item.originalDomElementsHash;
					pending.item.internal.loadMetaParentOffset = pending.offset;
					pendingMetaItems.splice( j, 1 );
					j--;
					// This will drop annotations on meta items; fine
					data.splice( i + 1, 0, pending.item, pending.closeItem );
					i += 2;
				}
			}
		}
	}
};

/* Methods */

/**
 * Check whether this converter instance is currently inside a getModelFromDom() conversion.
 *
 * @return {boolean} Whether we're converting
 */
ve.dm.Converter.prototype.isConverting = function () {
	return this.contextStack !== null;
};

/**
 * Get the HashValueStore used for the current conversion.
 *
 * @return {ve.dm.HashValueStore|null} Current store, or null if not converting
 */
ve.dm.Converter.prototype.getStore = function () {
	return this.store;
};

/**
 * Get the HTML document currently being converted
 *
 * @return {HTMLDocument|null} HTML document being converted, or null if not converting
 */
ve.dm.Converter.prototype.getHtmlDocument = function () {
	return this.doc;
};

/**
 * Get the HTML document we are converting data for
 *
 * @return {HTMLDocument|null} HTML document being converted for, or null if not converting
 */
ve.dm.Converter.prototype.getTargetHtmlDocument = function () {
	return this.targetDoc;
};

/**
 * Get the converter mode, one of PARSER_MODE, CLIPBOARD_MODE or PREVIEW_MODE
 *
 * @return {number} Converter mode
 */
ve.dm.Converter.prototype.getMode = function () {
	return this.mode;
};

/**
 * Checks if the current mode needs a full view rendering in the HTML
 *
 * @return {boolean} Mode needs a rendering
 */
ve.dm.Converter.prototype.doesModeNeedRendering = function () {
	return this.getMode() !== this.constructor.static.PARSER_MODE;
};

/**
 * Is the current conversion for the parser
 *
 * @return {boolean} The conversion is for the paser
 */
ve.dm.Converter.prototype.isForParser = function () {
	return this.getMode() === this.constructor.static.PARSER_MODE;
};

/**
 * Is the current conversion for the clipboard
 *
 * @return {boolean} The conversion is for the clipboard
 */
ve.dm.Converter.prototype.isForClipboard = function () {
	return this.getMode() === this.constructor.static.CLIPBOARD_MODE;
};

/**
 * Is the current conversion for the preview
 *
 * @return {boolean} The conversion is for the preview
 */
ve.dm.Converter.prototype.isForPreview = function () {
	return this.getMode() === this.constructor.static.PREVIEW_MODE;
};

/**
 * Is the current conversion from the clipboard
 *
 * @return {boolean|null} The conversion is from the clipboard, or null if not converting
 */
ve.dm.Converter.prototype.isFromClipboard = function () {
	return this.fromClipboard;
};

/**
 * Get the current conversion context. This is the recursion state of getDataFromDomSubtree().
 *
 * @return {Object|null} Context object, or null if not converting
 */
ve.dm.Converter.prototype.getCurrentContext = function () {
	return this.contextStack === null ? null : this.contextStack[ this.contextStack.length - 1 ];
};

/**
 * Get the annotations currently being applied by the converter. Note that this is specific to
 * the current recursion level.
 *
 * @return {ve.dm.AnnotationSet|null} Annotation set, or null if not converting
 */
ve.dm.Converter.prototype.getActiveAnnotations = function () {
	const context = this.getCurrentContext();
	return context ? context.annotations : null;
};

/**
 * Whether the converter is currently expecting content. Note that this is specific to the current
 * recursion level.
 *
 * @return {boolean|null} Boolean indicating whether content is expected, or null if not converting
 */
ve.dm.Converter.prototype.isExpectingContent = function () {
	const context = this.getCurrentContext();
	return context ? context.expectingContent : null;
};

/**
 * Whether the converter can currently accept a child node with the given type.
 *
 * @param {string} nodeType
 * @return {boolean|null} Whether the node type is valid, or null if not converting
 */
ve.dm.Converter.prototype.isValidChildNodeType = function ( nodeType ) {
	const context = this.getCurrentContext();
	if ( !context ) {
		return null;
	}
	const childTypes = this.nodeFactory.getChildNodeTypes( context.branchType );
	return ( childTypes === null || childTypes.indexOf( nodeType ) !== -1 );
};

/**
 * Whether the conversion is currently inside a wrapper paragraph generated by the converter.
 * Note that this is specific to the current recursion level.
 *
 * @return {boolean|null} Boolean indicating whether we're wrapping, or null if not converting
 */
ve.dm.Converter.prototype.isInWrapper = function () {
	const context = this.getCurrentContext();
	return context ? context.inWrapper : null;
};

/**
 * Whether the active wrapper can be closed. Note that this is specific to the current recursion
 * level. If there is no active wrapper, this returns false.
 *
 * @return {boolean|null} Boolean indicating whether the wrapper can be closed, or null if not converting
 */
ve.dm.Converter.prototype.canCloseWrapper = function () {
	const context = this.getCurrentContext();
	return context ? context.canCloseWrapper : null;
};

/**
 * Get the DOM element for a given linear model element.
 *
 * This invokes the toDomElements function registered for the element type.
 *
 * @param {Object|Array} dataElements Linear model element or data slice
 * @param {HTMLDocument} doc Document to create DOM elements in
 * @param {Node[]} [childDomElements] Array of child DOM elements to pass in (annotations only)
 * @return {Node[]|boolean} DOM elements, or false if the element cannot be converted.
 *  If the first DOMelement has a 'handledOwnChildren' property set, the converter treats it as if it
 *  were a handlesOwnChildren node.
 */
ve.dm.Converter.prototype.getDomElementsFromDataElement = function ( dataElements, doc, childDomElements ) {
	const dataElement = Array.isArray( dataElements ) ? dataElements[ 0 ] : dataElements,
		nodeClass = this.modelRegistry.lookup( dataElement.type );

	if ( !nodeClass ) {
		throw new Error( 'Attempting to convert unknown data element type ' + dataElement.type );
	}
	if ( nodeClass.static.isInternal ) {
		return false;
	}
	const domElements = nodeClass.static.toDomElements( dataElements, doc, this, childDomElements );
	if ( !Array.isArray( domElements ) && !( nodeClass.prototype instanceof ve.dm.Annotation ) ) {
		throw new Error( 'toDomElements() failed to return an array when converting element of type ' + dataElement.type );
	}
	const originalDomElements = this.store.value( dataElement.originalDomElementsHash );
	// Optimization: don't call renderHtmlAttributeList if returned domElements are equal to the originals
	if ( originalDomElements && !ve.isEqualDomElements( domElements, originalDomElements ) ) {
		this.constructor.static.renderHtmlAttributeList(
			originalDomElements,
			domElements,
			nodeClass.static.preserveHtmlAttributes,
			// computed
			false,
			// deep
			(
				!this.nodeFactory.lookup( dataElement.type ) ||
				!this.nodeFactory.canNodeHaveChildren( dataElement.type ) ||
				this.nodeFactory.doesNodeHandleOwnChildren( dataElement.type )
			)
		);
	}
	// TODO: This is only for the diff. Eventually should make a DiffConverter subclass
	if ( dataElement.internal && dataElement.internal.diff ) {
		Array.prototype.forEach.call( domElements, ( domElement ) => {
			for ( const key in dataElement.internal.diff ) {
				// toDomElements is a misnomer, it can actually return other nodes,
				// such as comment nodes or text nodes.
				if ( domElement.setAttribute ) {
					domElement.setAttribute( key, dataElement.internal.diff[ key ] );
				}
			}
		} );
	}
	// Mark branch nodes as generated from dataElement, so we don't try and descend into them in a deep renderHtmlAttributeList call
	if ( this.nodeFactory.lookup( dataElement.type ) && this.nodeFactory.canNodeHaveChildren( dataElement.type ) ) {
		const hasSignificantWhitespace = this.nodeFactory.doesNodeHaveSignificantWhitespace( dataElement.type );
		domElements.forEach( ( domElement ) => {
			domElement.veFromDataElement = true;
			if ( hasSignificantWhitespace ) {
				domElement.veHasSignificantWhitespace = true;
			}
		} );
	}
	return domElements;
};

/**
 * Create a data element from a DOM element.
 *
 * @param {ve.dm.Model} modelClass Model class to use for conversion
 * @param {Node[]} domElements DOM elements to convert
 * @return {Object|Array|null} Data element or array of linear model data, or null to alienate
 */
ve.dm.Converter.prototype.createDataElements = function ( modelClass, domElements ) {
	let dataElements = modelClass.static.toDataElement( domElements, this );

	if ( !dataElements ) {
		return null;
	}
	if ( !Array.isArray( dataElements ) ) {
		dataElements = [ dataElements ];
	}
	if ( dataElements.length ) {
		let serializer;
		if ( modelClass.prototype instanceof ve.dm.Annotation ) {
			serializer = function ( node ) {
				// Do not include childNodes; see T160839
				return node.cloneNode( false ).outerHTML;
			};
		} else {
			serializer = ve.getNodeHtml;
		}
		dataElements[ 0 ].originalDomElementsHash = this.store.hash(
			domElements,
			domElements.map( serializer ).join( '' )
		);
		if ( modelClass.prototype instanceof ve.dm.BranchNode && modelClass.static.childNodeTypes === null ) {
			// Set this item up as a meta parent
			ve.setProp( dataElements[ 0 ], 'internal', 'metaItems', [] );
			ve.setProp( dataElements[ 0 ], 'internal', 'changesSinceLoad', 0 );
		}
	}
	return dataElements;
};

/**
 * Build an HTML DOM node for a linear model annotation.
 *
 * @param {Object} dataAnnotation
 * @param {HTMLDocument} doc HTML document to create element with
 * @return {HTMLElement} HTML DOM node
 */
ve.dm.Converter.prototype.getDomElementFromDataAnnotation = function ( dataAnnotation, doc ) {
	const htmlData = dataAnnotation.toHtml(),
		domElement = doc.createElement( htmlData.tag );

	ve.setDomAttributes( domElement, htmlData.attributes );
	return domElement;
};

/**
 * Convert an HTML document to a document model.
 *
 * @param {HTMLDocument} doc HTML document to convert
 * @param {Object} [options] Conversion options
 * @param {HTMLDocument} [options.targetDoc=doc] Target HTML document we are converting for, if different from doc
 * @param {boolean} [options.fromClipboard=false] Conversion is from clipboard
 * @param {string} [options.lang] Document language code
 * @param {string} [options.dir] Document directionality (ltr/rtl)
 * @param {ve.dm.HashValueStore} [store] Hash value store
 * @return {ve.dm.Document} Document model
 */
ve.dm.Converter.prototype.getModelFromDom = function ( doc, options, store ) {
	const tmpDoc = new ve.dm.Document();
	const internalList = new ve.dm.InternalList( tmpDoc );

	store = store || new ve.dm.HashValueStore();
	options = options || {};

	// Set up the converter state
	this.doc = doc;
	this.targetDoc = options.targetDoc || doc;
	this.fromClipboard = options.fromClipboard;
	this.store = store;
	this.internalList = internalList;
	this.contextStack = [];
	// Possibly do things with doc and the head in the future

	// Generate data
	const data = this.getDataFromDomSubtree( doc.body );
	this.constructor.static.moveInlineMetaItems( data );

	const linearData = new ve.dm.ElementLinearData( store, data );
	const refData = this.internalList.convertToData( this, doc );
	linearData.batchSplice( linearData.getLength(), 0, refData );
	const innerWhitespace = this.getInnerWhitespace( linearData );

	// Clear the state
	this.doc = null;
	this.targetDoc = null;
	this.fromClipboard = null;
	this.store = null;
	this.internalList = null;
	this.contextStack = null;

	return new ve.dm.Document( linearData, doc, undefined, internalList, innerWhitespace, options.lang, options.dir, null, null, tmpDoc.getStorage() );
};

/**
 * Wrapper for getDataFromDom which resets contextStack before the call
 * and then set it back after the call.
 *
 * TODO: This is kind of a hack, better implementation would be more appropriate in near future.
 *
 * @param {HTMLElement} domElement HTML element to convert
 * @param {Object} [wrapperElement] Data element to wrap the returned data in
 * @param {ve.dm.AnnotationSet} [annotationSet] Override the set of annotations to use
 * @return {Array} Linear model data
 */
ve.dm.Converter.prototype.getDataFromDomClean = function ( domElement, wrapperElement, annotationSet ) {
	const contextStack = this.contextStack;
	this.contextStack = [];
	const result = this.getDataFromDomSubtree( domElement, wrapperElement, annotationSet );
	this.contextStack = contextStack;
	return result;
};

/**
 * Get linear model data from a DOM node. Called recursively. For internal use
 * and ve.dm.Model.static.toDataElement() implementations.
 *
 * @param {HTMLElement} domElement HTML element to convert
 * @param {Object} [wrapperElement] Data element to wrap the returned data in
 * @param {ve.dm.AnnotationSet} [annotationSet] Override the set of annotations to use
 * @return {Array} Linear model data
 */
ve.dm.Converter.prototype.getDataFromDomSubtree = function ( domElement, wrapperElement, annotationSet ) {
	const modelRegistry = this.modelRegistry,
		data = [],
		context = {},
		prevContext = this.contextStack.length ?
			this.contextStack[ this.contextStack.length - 1 ] : null;
	let wrappingParagraph,
		nextWhitespace = '',
		wrappedWhitespace = '',
		wrappedWhitespaceIndex,
		wrappedMetaItems = [];

	/**
	 * Add whitespace to an element at a specific offset.
	 *
	 * @private
	 * @param {Array} element Data element
	 * @param {number} index Whitespace index, 0-3
	 * @param {string} whitespace Whitespace content
	 */
	const addWhitespace = ( element, index, whitespace ) => {
		if ( !whitespace ) {
			return;
		}
		if ( !element.internal ) {
			element.internal = {};
		}
		// whitespace = [ outerPre, innerPre, innerPost, outerPost ]
		//         <tag>        text         </tag>         <nextTag>
		// ^^^^^^^^     ^^^^^^^^    ^^^^^^^^^      ^^^^^^^^^
		// outerPre     innerPre    innerPost      outerPost
		if ( !element.internal.whitespace ) {
			element.internal.whitespace = [];
		}
		element.internal.whitespace[ index ] = whitespace;
	};
	const processNextWhitespace = ( element ) => {
		// This function uses and changes nextWhitespace in the outer function's scope,
		// which means it's not really a function but more of a shortcut.
		if ( nextWhitespace !== '' ) {
			addWhitespace( element, 0, nextWhitespace );
			nextWhitespace = '';
		}
	};
	// FIXME rewrite this horrible meta item / whitespace queueing/wrapping business
	const outputWrappedMetaItems = ( whitespaceTreatment ) => {
		const toInsert = [];
		let prev = wrappingParagraph;

		for ( let j = 0, len = wrappedMetaItems.length; j < len; j++ ) {
			if ( wrappedMetaItems[ j ].type && wrappedMetaItems[ j ].type.charAt( 0 ) !== '/' ) {
				if ( wrappedMetaItems[ j ].internal && wrappedMetaItems[ j ].internal.whitespace ) {
					if ( whitespaceTreatment === 'restore' ) {
						ve.batchPush( toInsert, this.constructor.static.getDataContentFromText(
							wrappedMetaItems[ j ].internal.whitespace[ 0 ], context.annotations
						) );
						delete wrappedMetaItems[ j ].internal;
					} else if ( whitespaceTreatment === 'fixup' ) {
						addWhitespace( prev, 3, wrappedMetaItems[ j ].internal.whitespace[ 0 ] );
					}
				}
				prev = wrappedMetaItems[ j ];
			}
			toInsert.push( wrappedMetaItems[ j ] );
		}
		if ( wrappedWhitespace !== '' && whitespaceTreatment === 'restore' ) {
			// If we have wrapped whitespace, insert the wrapped meta items before it
			// This is horrible and this whole system desperately needs to be rewritten
			ve.batchSplice( data, wrappedWhitespaceIndex, 0, toInsert );
		} else {
			ve.batchPush( data, toInsert );
		}
		wrappedMetaItems = [];
	};
	const startWrapping = () => {
		// Mark this paragraph as having been generated by
		// us, so we can strip it on the way out
		wrappingParagraph = {
			type: 'paragraph',
			internal: { generated: 'wrapper', metaItems: [] }
		};
		data.push( wrappingParagraph );
		context.inWrapper = true;
		context.canCloseWrapper = true;
		context.expectingContent = true;
		processNextWhitespace( wrappingParagraph );
	};
	const stopWrapping = () => {
		if ( wrappedWhitespace !== '' ) {
			// Remove wrappedWhitespace from data
			data.splice( wrappedWhitespaceIndex, wrappedWhitespace.length );
			// Add whitespace to the last sibling: either the last meta item or the wrapper paragraph
			addWhitespace( wrappedMetaItems.length > 0 ? wrappedMetaItems[ wrappedMetaItems.length - 2 ] : wrappingParagraph, 3, wrappedWhitespace );
			nextWhitespace = wrappedWhitespace;
		}
		data.push( { type: '/paragraph' } );
		outputWrappedMetaItems( 'fixup' );
		wrappingParagraph = undefined;
		context.inWrapper = false;
		context.canCloseWrapper = false;
		context.expectingContent = context.originallyExpectingContent;
	};
	const getAboutGroup = ( node ) => {
		const group = [ node ];

		if ( node.nodeType !== Node.ELEMENT_NODE || node.getAttribute( 'about' ) === null ) {
			return group;
		}
		const about = node.getAttribute( 'about' );
		while ( ( node = node.nextSibling ) !== null ) {
			if ( node.nodeType === Node.ELEMENT_NODE && node.getAttribute( 'about' ) === about ) {
				group.push( node );
			} else {
				break;
			}
		}
		return group;
	};
	const isAllInstanceOf = ( linearData, targetClass ) => {
		for ( let j = linearData.length - 1; j >= 0; j-- ) {
			const type = ve.dm.LinearData.static.getType( linearData[ j ] );
			if ( type ) {
				const itemClass = modelRegistry.lookup( type ) || ve.dm.AlienNode;
				if ( !( itemClass === targetClass || itemClass.prototype instanceof targetClass ) ) {
					return false;
				}
			} else {
				return false;
			}
		}
		return true;
	};

	context.annotations = annotationSet || (
		prevContext ? prevContext.annotations.clone() : new ve.dm.AnnotationSet( this.store )
	);
	context.branchType = wrapperElement ? wrapperElement.type : (
		prevContext ? prevContext.branchType : 'document'
	);
	context.branchHasContent = this.nodeFactory.canNodeContainContent( context.branchType );
	context.originallyExpectingContent = context.branchHasContent || !context.annotations.isEmpty();
	context.expectingContent = context.originallyExpectingContent;
	context.inWrapper = prevContext ? prevContext.inWrapper : false;
	context.canCloseWrapper = false;
	this.contextStack.push( context );

	// Open element
	if ( wrapperElement ) {
		data.push( wrapperElement );
	}
	// Add contents
	function setInlineMeta( element ) {
		ve.setProp( element, 'internal', 'isInlineMeta', true );
	}

	let prevElement;
	for ( let i = 0; i < domElement.childNodes.length; i++ ) {
		const childNode = domElement.childNodes[ i ];
		switch ( childNode.nodeType ) {
			case Node.ELEMENT_NODE:
			case Node.COMMENT_NODE: {
				if (
					childNode.getAttribute &&
					childNode.getAttribute( 'data-ve-ignore' )
				) {
					continue;
				}
				const aboutGroup = getAboutGroup( childNode );
				const modelName = this.modelRegistry.matchElement( childNode, aboutGroup.length > 1 );
				let modelClass = this.modelRegistry.lookup( modelName ) || ve.dm.AlienNode;
				let childNodes;
				if ( modelClass.prototype instanceof ve.dm.Annotation ) {
					childNodes = [ childNode ];
				} else {
					// Node or meta item
					childNodes = modelClass.static.enableAboutGrouping ?
						aboutGroup : [ childNode ];
				}
				let childDataElements = this.createDataElements( modelClass, childNodes );

				if ( !childDataElements ) {
					// Alienate
					modelClass = ve.dm.AlienNode;
					childNodes = modelClass.static.enableAboutGrouping ?
						aboutGroup : [ childNode ];
					childDataElements = this.createDataElements( modelClass, childNodes );
				} else if ( childDataElements.length ) {
					// Update modelClass to reflect the type we got back
					modelClass = this.modelRegistry.lookup( childDataElements[ 0 ].type );
				} else {
					continue;
				}

				// If we're about to start wrapping for an annotation,
				// check paragraphs are actually allowed here.
				if (
					!context.inWrapper && !context.expectingContent &&
					modelClass.prototype instanceof ve.dm.Annotation &&
					!this.isValidChildNodeType( 'paragraph' )
				) {
					// Alienate (force block mode as we are replacing a wrapper)
					modelClass = ve.dm.AlienBlockNode;
					childNodes = modelClass.static.enableAboutGrouping ?
						aboutGroup : [ childNode ];
					childDataElements = this.createDataElements( modelClass, childNodes );
				}

				// Now take the appropriate action based on that
				if ( modelClass.prototype instanceof ve.dm.Annotation ) {
					const annotation = this.annotationFactory.createFromElement( childDataElements[ 0 ], this.store );
					// Start wrapping if needed
					if ( !context.inWrapper && !context.expectingContent ) {
						startWrapping();
						prevElement = wrappingParagraph;
					}
					// Append child element data
					const childAnnotations = context.annotations.clone();
					childAnnotations.push( annotation );

					childDataElements = this.getDataFromDomSubtree( childNode, undefined, childAnnotations );
					if ( !childDataElements.length || isAllInstanceOf( childDataElements, ve.dm.AlienMetaItem ) ) {
						// Empty annotation, create a meta item
						if ( !childDataElements.length || isAllInstanceOf( childDataElements, ve.dm.RemovableAlienMetaItem ) ) {
							childDataElements = this.createDataElements( ve.dm.RemovableAlienMetaItem, childNodes );
						} else {
							childDataElements = this.createDataElements( ve.dm.AlienMetaItem, childNodes );
						}
						childDataElements.push( { type: '/' + childDataElements[ 0 ].type } );
						// Annotate meta item
						if ( !context.annotations.isEmpty() ) {
							childDataElements[ 0 ].annotations = context.annotations.getHashes().slice();
						}
						// Mark meta items to be moved outside of content context, as we can't handle them here
						// (context.expectingContent is always true at this point)
						setInlineMeta( childDataElements[ 0 ] );
					}
					outputWrappedMetaItems( 'restore' );
					ve.batchPush( data, childDataElements );
					// Clear wrapped whitespace
					wrappedWhitespace = '';
				} else {
					// Node or meta item
					if ( modelClass.prototype instanceof ve.dm.MetaItem ) {
						if ( context.expectingContent ) {
							// Mark meta items to be moved outside of content context, as we can't handle them here
							childDataElements.forEach( setInlineMeta );
						}

						// No additional processing needed
						// Write to data and continue
						if ( childDataElements.length === 1 ) {
							childDataElements.push( { type: '/' + childDataElements[ 0 ].type } );
						}
						// Annotate meta item
						if ( !context.annotations.isEmpty() ) {
							childDataElements[ 0 ].annotations = context.annotations.getHashes().slice();
						}
						// Queue wrapped meta items only if it's actually possible for us to move them out
						// of the wrapper
						if ( context.inWrapper && context.canCloseWrapper ) {
							ve.batchPush( wrappedMetaItems, childDataElements );
							if ( wrappedWhitespace !== '' ) {
								data.splice( wrappedWhitespaceIndex, wrappedWhitespace.length );
								addWhitespace( childDataElements[ 0 ], 0, wrappedWhitespace );
								nextWhitespace = wrappedWhitespace;
								wrappedWhitespace = '';
							}
						} else {
							outputWrappedMetaItems( 'restore' );
							ve.batchPush( data, childDataElements );
							processNextWhitespace( childDataElements[ 0 ] );
							prevElement = childDataElements[ 0 ];
						}
						// In case we consumed multiple childNodes, adjust i accordingly
						i += childNodes.length - 1;
						break;
					}

					let childIsContent = this.nodeFactory.canNodeSerializeAsContent( childDataElements[ 0 ].type );

					// If childIsContent isn't what we expect, adjust
					if ( !context.expectingContent && childIsContent ) {
						startWrapping();
						prevElement = wrappingParagraph;
					} else if ( context.expectingContent && !childIsContent ) {
						if ( context.inWrapper && context.canCloseWrapper ) {
							stopWrapping();
						} else {
							// Alienate
							modelClass = ve.dm.AlienNode;
							childNodes = modelClass.static.enableAboutGrouping ?
								aboutGroup : [ childNode ];
							childDataElements = this.createDataElements( modelClass, childNodes );
							childIsContent = this.nodeFactory.canNodeSerializeAsContent( childDataElements[ 0 ].type );
						}
					}

					// If we're inserting content into a wrapper, any wrapped whitespace and meta
					// items up until this point are here to stay
					if ( context.inWrapper && childIsContent ) {
						outputWrappedMetaItems( 'restore' );
						wrappedWhitespace = '';
						// Don't record the wrapped whitespace as the child node's outer whitespace
						nextWhitespace = '';
					}

					// Annotate child
					if ( childIsContent && !context.annotations.isEmpty() ) {
						childDataElements[ 0 ].annotations = context.annotations.getHashes().slice();
					}

					// Output child and process children if needed
					if (
						childDataElements.length === 1 &&
						childNodes.length === 1 &&
						this.nodeFactory.canNodeHaveChildren( childDataElements[ 0 ].type ) &&
						!this.nodeFactory.doesNodeHandleOwnChildren( childDataElements[ 0 ].type )
					) {
						// Recursion
						// Opening and closing elements are added by the recursion too
						outputWrappedMetaItems( 'restore' );
						ve.batchPush( data,
							this.getDataFromDomSubtree( childNode, childDataElements[ 0 ],
								new ve.dm.AnnotationSet( this.store )
							)
						);
					} else {
						if ( childDataElements.length === 1 ) {
							childDataElements.push( { type: '/' + childDataElements[ 0 ].type } );
						}
						// Write childDataElements directly
						outputWrappedMetaItems( 'restore' );
						ve.batchPush( data, childDataElements );
					}
					processNextWhitespace( childDataElements[ 0 ] );
					prevElement = childDataElements[ 0 ];

					// In case we consumed multiple childNodes, adjust i accordingly
					i += childNodes.length - 1;
				}
				break;
			}
			case Node.TEXT_NODE: {
				let text = childNode.data;
				if ( text === '' ) {
					// Empty text node?!?
					break;
				}
				if ( !context.originallyExpectingContent ) {
					// Strip and store outer whitespace
					if ( this.onlyWhitespaceRegex.test( text ) ) {
						// This text node is whitespace only
						if ( context.inWrapper ) {
							// We're already wrapping, so output this whitespace
							// and store it in wrappedWhitespace (see
							// comment about wrappedWhitespace below)
							wrappedWhitespace = text;
							wrappedWhitespaceIndex = data.length;
							ve.batchPush( data,
								this.constructor.static.getDataContentFromText( wrappedWhitespace, context.annotations )
							);
						} else {
							// We're not in wrapping mode, store this whitespace
							if ( !prevElement ) {
								if ( wrapperElement ) {
									// First child, store as inner
									// whitespace in the parent
									addWhitespace( wrapperElement, 1, text );
								}
								// Else, WTF?!? This is not supposed to
								// happen, but it's not worth
								// throwing an exception over.
							} else {
								addWhitespace( prevElement, 3, text );
							}
							nextWhitespace = text;
							wrappedWhitespace = '';
							outputWrappedMetaItems( 'restore' );
						}
						// We're done, no actual text left to process
						break;
					} else {
						// This text node contains actual text
						// Separate the real text from the whitespace
						// HACK: '.' doesn't match newlines in JS, so use
						// [\s\S] to match any character
						const matches = text.match( this.trimWhitespaceRegex );
						if ( !context.inWrapper ) {
							// Wrap the text in a paragraph and output it
							startWrapping();

							// Only store leading whitespace if we just
							// started wrapping
							if ( matches[ 1 ] !== '' ) {
								if ( !prevElement ) {
									if ( wrapperElement ) {
										// First child, store as inner
										// whitespace in the parent
										addWhitespace( wrapperElement, 1, matches[ 1 ] );
									}
									// Else, WTF?!? This is not supposed to
									// happen, but it's not worth
									// throwing an exception over.
								} else {
									addWhitespace( prevElement, 3, matches[ 1 ] );
								}
								addWhitespace( wrappingParagraph, 0, matches[ 1 ] );
							}
						} else {
							outputWrappedMetaItems( 'restore' );
							// We were already wrapping in a paragraph,
							// so the leading whitespace must be output
							ve.batchPush( data,
								this.constructor.static.getDataContentFromText( matches[ 1 ], context.annotations )
							);
						}
						// Output the text sans whitespace
						ve.batchPush( data,
							this.constructor.static.getDataContentFromText( matches[ 2 ], context.annotations )
						);

						// Don't store this in wrappingParagraph.internal.whitespace[3]
						// and nextWhitespace just yet. Instead, store it
						// in wrappedWhitespace. There might be more text
						// nodes after this one, so we output wrappedWhitespace
						// for now and undo that if it turns out this was
						// the last text node. We can't output it later
						// because we have to apply the correct annotations.
						wrappedWhitespace = matches[ 3 ];
						wrappedWhitespaceIndex = data.length;
						ve.batchPush( data,
							this.constructor.static.getDataContentFromText( wrappedWhitespace, context.annotations )
						);
						prevElement = wrappingParagraph;
						break;
					}
				}

				// Strip leading and trailing inner whitespace
				// (but only in non-annotation nodes)
				// and store it so it can be restored later.
				if (
					context.annotations.isEmpty() && i === 0 && wrapperElement &&
					!this.nodeFactory.doesNodeHaveSignificantWhitespace( wrapperElement.type )
				) {
					// Strip leading whitespace from the first child
					const matches = text.match( this.leadingWhitespacesRegex );
					if ( matches && matches[ 0 ] !== '' ) {
						addWhitespace( wrapperElement, 1, matches[ 0 ] );
						text = text.slice( matches[ 0 ].length );
					}
				}
				if (
					context.annotations.isEmpty() &&
					i === domElement.childNodes.length - 1 &&
					wrapperElement &&
					!this.nodeFactory.doesNodeHaveSignificantWhitespace( wrapperElement.type )
				) {
					// Strip trailing whitespace from the last child
					const matches = text.match( this.trailingWhitespacesRegex );
					if ( matches && matches[ 0 ] !== '' ) {
						addWhitespace( wrapperElement, 2, matches[ 0 ] );
						text = text.slice( 0, text.length - matches[ 0 ].length );
					}
				}

				// Annotate the text and output it
				ve.batchPush( data,
					this.constructor.static.getDataContentFromText( text, context.annotations )
				);
				break;
			}
		}
	}
	// End auto-wrapping of bare content
	if ( context.inWrapper && context.canCloseWrapper ) {
		stopWrapping();
		// HACK: don't set context.inWrapper = false here because it's checked below
		context.inWrapper = true;
	}

	// If we're closing a node that doesn't have any children, but could contain a paragraph,
	// add a paragraph. This prevents things like empty list items
	if ( context.branchType !== 'paragraph' && wrapperElement && data[ data.length - 1 ] === wrapperElement &&
		!context.inWrapper && !this.nodeFactory.canNodeContainContent( context.branchType ) &&
		!this.nodeFactory.isNodeContent( context.branchType ) &&
		this.isValidChildNodeType( 'paragraph' )
	) {
		const wrapperParagraph = { type: 'paragraph', internal: { generated: 'wrapper' } };
		processNextWhitespace( wrapperParagraph );
		data.push( wrapperParagraph );
		data.push( { type: '/paragraph' } );
	}

	// Close element
	if ( wrapperElement ) {
		// Add the whitespace after the last child to the parent as innerPost
		// But don't do this if the parent is empty, because in that case we've already put that
		// whitespace in innerPre
		if ( nextWhitespace !== '' && data[ data.length - 1 ] !== wrapperElement ) {
			addWhitespace( wrapperElement, 2, nextWhitespace );
			nextWhitespace = '';
		}
		data.push( { type: '/' + wrapperElement.type } );
	}
	// Don't return an empty document
	if ( context.branchType === 'document' && isAllInstanceOf( data, ve.dm.MetaItem ) && !annotationSet ) {
		const emptyParagraph = { type: 'paragraph', internal: { generated: 'empty' } };
		processNextWhitespace( emptyParagraph );
		data.push( emptyParagraph );
		data.push( { type: '/paragraph' } );
	}

	this.contextStack.pop();
	return data;
};

/**
 * Get inner whitespace from linear data
 *
 * @param {ve.dm.ElementLinearData} data Linear model data
 * @return {Array.<string|undefined>} Sparse array of whitespace strings: [ innerLeft, innerRight ]
 */
ve.dm.Converter.prototype.getInnerWhitespace = function ( data ) {
	const innerWhitespace = new Array( 2 );
	let stack = 0,
		last = data.getLength() - 1;

	let whitespace;
	if ( data.isOpenElementData( 0 ) ) {
		whitespace = ve.getProp( data.getData( 0 ), 'internal', 'whitespace' );
		innerWhitespace[ 0 ] = whitespace ? whitespace[ 0 ] : undefined;
	}
	if ( data.isCloseElementData( last ) ) {
		// Find matching opening tag of the last close tag
		stack++;
		while ( --last ) {
			if ( data.isCloseElementData( last ) ) {
				stack++;
			} else if ( data.isOpenElementData( last ) ) {
				stack--;
				if ( stack === 0 && data.getType( last ) !== 'internalList' ) {
					break;
				}
			}
		}
		whitespace = ve.getProp( data.getData( last ), 'internal', 'whitespace' );
		innerWhitespace[ 1 ] = whitespace ? whitespace[ 3 ] : undefined;
	}
	return innerWhitespace;
};

/**
 * Convert document model to an HTML DOM
 *
 * @param {ve.dm.Document} model Document model
 * @param {number} [mode=PARSER_MODE] Conversion mode, defaults to PARSER_MODE
 * @return {HTMLDocument} Document containing the resulting HTML
 */
ve.dm.Converter.prototype.getDomFromModel = function ( model, mode ) {
	// Backwards compatibility with 'forClipboard' argument
	if ( typeof mode === 'boolean' ) {
		mode = mode ? this.constructor.static.CLIPBOARD_MODE : this.constructor.static.PARSER_MODE;
	}
	mode = mode || this.constructor.static.PARSER_MODE;

	const doc = ve.createDocumentFromHtml( '' );
	this.getDomSubtreeFromModel( model, doc.body, mode );

	return doc;
};

/**
 * Convert model node to an HTML DOM
 *
 * @param {ve.dm.Node} node Model node
 * @param {number} [mode=PARSER_MODE] Conversion mode, defaults to PARSER_MODE
 * @return {HTMLDocument} Document containing the resulting HTML
 */
ve.dm.Converter.prototype.getDomFromNode = function ( node, mode ) {
	// Backwards compatibility with 'forClipboard' argument
	if ( typeof mode === 'boolean' ) {
		mode = mode ? this.constructor.static.CLIPBOARD_MODE : this.constructor.static.PARSER_MODE;
	}
	mode = mode || this.constructor.static.PARSER_MODE;
	return this.getDomFromModel(
		node.getDocument().shallowCloneFromRange( node.isInternal() ? node.getRange() : node.getOuterRange() ),
		mode
	);
};

/**
 * Convert document model to an HTML DOM subtree and add it to a container element.
 *
 * @param {ve.dm.Document} model Document model
 * @param {HTMLElement} container DOM element to add the generated elements to. Should be empty.
 * @param {number} [mode=PARSER_MODE] Conversion mode, defaults to PARSER_MODE
 */
ve.dm.Converter.prototype.getDomSubtreeFromModel = function ( model, container, mode ) {
	if ( typeof mode === 'boolean' ) {
		mode = mode ? this.constructor.static.CLIPBOARD_MODE : this.constructor.static.PARSER_MODE;
	}
	mode = mode || this.constructor.static.PARSER_MODE;
	// Set up the converter state
	this.documentData = model.getFullData( undefined, 'roundTrip' );
	this.store = model.getStore();
	this.internalList = model.getInternalList();
	// Internal list of the doc this was cloned from, or itself if not cloned
	this.originalDocInternalList = model.getOriginalDocument() ? model.getOriginalDocument().getInternalList() : this.internalList;
	this.mode = mode;

	this.getDomSubtreeFromData( this.documentData, container, model.getInnerWhitespace() );

	// Clear the state
	this.documentData = null;
	this.store = null;
	this.internalList = null;
	this.originalDocInternalList = null;
	this.mode = null;
};

/**
 * Convert linear model data to an HTML DOM subtree and add it to a container element.
 *
 * @param {Array} data Linear model data
 * @param {HTMLElement} container DOM element to add the generated elements to. Should be empty.
 * @param {Array.<string|undefined>} [innerWhitespace] Inner whitespace if the container is the body
 * @throws Unbalanced data: looking for closing /type
 */
ve.dm.Converter.prototype.getDomSubtreeFromData = function ( data, container, innerWhitespace ) {
	const whitespaceHtmlChars = ve.visibleWhitespaceCharacters,
		isForPreview = this.isForPreview(),
		dataLen = data.length,
		doc = container.ownerDocument;
	let text, annotatedDomElements, annotatedDomElementStack,
		domElement = container;

	// TODO this whole function should be rewritten with a domElementStack and ascend() and
	// descend() functions, to build the whole DOM bottom-up rather than top-down. That would make
	// unwrapping easier and will hopefully result in fewer DOM operations.

	const openAnnotation = () => {
		// Add text if needed
		if ( text.length > 0 ) {
			annotatedDomElements.push( doc.createTextNode( text ) );
			text = '';
		}
		annotatedDomElements = [];
		annotatedDomElementStack.push( annotatedDomElements );
	};

	const closeAnnotation = ( annotation ) => {
		const originalDomElements = annotation.getOriginalDomElements( this.store ),
			origElementText = originalDomElements[ 0 ] &&
				originalDomElements[ 0 ].textContent ||
				'';
		let leading = '',
			trailing = '';

		// Add text if needed
		if ( text.length > 0 ) {
			annotatedDomElements.push( doc.createTextNode( text ) );
			text = '';
		}

		const annotatedChildDomElements = annotatedDomElementStack.pop();
		annotatedDomElements = annotatedDomElementStack[ annotatedDomElementStack.length - 1 ];

		// HACK: Move any leading and trailing whitespace out of the annotation, but only if the
		// annotation didn't originally have leading/trailing whitespace
		if ( annotation.constructor.static.trimWhitespace ) {
			let matches;
			let first = annotatedChildDomElements[ 0 ];
			while (
				first &&
				first.nodeType === Node.TEXT_NODE &&
				( matches = first.data.match( this.leadingWhitespacesRegex ) ) &&
				!this.leadingWhitespaceRegex.test( origElementText )
			) {
				leading += matches[ 0 ];
				first.deleteData( 0, matches[ 0 ].length );
				if ( first.data.length !== 0 ) {
					break;
				}
				// Remove empty text node
				annotatedChildDomElements.shift();
				// Process next text node to see if it also has whitespace
				first = annotatedChildDomElements[ 0 ];
			}
			let last = annotatedChildDomElements[ annotatedChildDomElements.length - 1 ];
			while (
				last &&
				last.nodeType === Node.TEXT_NODE &&
				( matches = last.data.match( this.trailingWhitespacesRegex ) ) &&
				!this.trailingWhitespaceRegex.test( origElementText )
			) {
				trailing = matches[ 0 ] + trailing;
				last.deleteData( last.data.length - matches[ 0 ].length, matches[ 0 ].length );
				if ( last.data.length !== 0 ) {
					break;
				}
				// Remove empty text node
				annotatedChildDomElements.pop();
				// Process next text node to see if it also has whitespace
				last = annotatedChildDomElements[ annotatedChildDomElements.length - 1 ];
			}
		}

		let annotationElement;
		if ( annotatedChildDomElements.length ) {
			annotationElement = this.getDomElementsFromDataElement(
				annotation.getElement(), doc, annotatedChildDomElements
			)[ 0 ];
		}

		if ( leading ) {
			annotatedDomElements.push( doc.createTextNode( leading ) );
		}
		let n, len;
		if ( annotationElement ) {
			for ( n = 0, len = annotatedChildDomElements.length; n < len; n++ ) {
				annotationElement.appendChild( annotatedChildDomElements[ n ] );
			}
			annotatedDomElements.push( annotationElement );
		} else {
			for ( n = 0, len = annotatedChildDomElements.length; n < len; n++ ) {
				annotatedDomElements.push( annotatedChildDomElements[ n ] );
			}
		}
		if ( trailing ) {
			annotatedDomElements.push( doc.createTextNode( trailing ) );
		}
	};

	const findEndOfNode = ( k ) => {
		let n, depth;
		for ( n = k + 1, depth = 1; n < dataLen && depth > 0; n++ ) {
			if ( data[ n ].type ) {
				depth += data[ n ].type.charAt( 0 ) === '/' ? -1 : 1;
			}
		}
		if ( depth !== 0 ) {
			throw new Error( 'Unbalanced data: ' + depth + ' element(s) left open.' );
		}
		return n;
	};

	const getDataElementOrSlice = ( i ) => {
		let dataSlice;
		if (
			ve.dm.nodeFactory.lookup( data[ i ].type ) &&
			ve.dm.nodeFactory.doesNodeHandleOwnChildren( data[ i ].type )
		) {
			dataSlice = data.slice( i, findEndOfNode( i ) );
		} else {
			dataSlice = data[ i ];
		}
		return dataSlice;
	};

	const getChar = ( char ) => {
		if (
			isForPreview &&
			!domElement.veHasSignificantWhitespace &&
			Object.prototype.hasOwnProperty.call( whitespaceHtmlChars, char )
		) {
			char = whitespaceHtmlChars[ char ];
		}
		return char;
	};

	const annotationStack = new ve.dm.AnnotationSet( this.store );

	for ( let i = 0; i < dataLen; i++ ) {
		if ( typeof data[ i ] === 'string' ) {
			// Text
			text = '';
			let isStart = i > 0 &&
				ve.dm.LinearData.static.isOpenElementData( data[ i - 1 ] ) &&
				!ve.dm.nodeFactory.doesNodeHaveSignificantWhitespace(
					ve.dm.LinearData.static.getType( data[ i - 1 ] )
				);
			// Continue forward as far as the plain text goes
			while ( typeof data[ i ] === 'string' ) {
				// HACK: Skip over leading whitespace (T53462/T142132) in non-whitespace-preserving tags
				// This should possibly be handled by Parsoid or in the UI.
				if ( !( isStart && this.onlyWhitespaceRegex.test( data[ i ] ) && this.isForParser() ) ) {
					text += getChar( data[ i ] );
					isStart = false;
				}
				i++;
			}
			// i points to the first non-text thing, go back one so we don't skip this later
			i--;
			// Add text
			if ( text.length > 0 ) {
				domElement.appendChild( doc.createTextNode( text ) );
			}
		} else if (
			Array.isArray( data[ i ] ) ||
			(
				data[ i ].annotations !== undefined &&
				this.nodeFactory.canNodeSerializeAsContent( data[ i ].type )
			)
		) {
			// Annotated text, nodes or meta
			text = '';
			annotatedDomElements = [];
			annotatedDomElementStack = [ annotatedDomElements ];
			while (
				data[ i ] !== undefined && (
					Array.isArray( data[ i ] ) ||
					(
						data[ i ].annotations !== undefined &&
						this.nodeFactory.canNodeSerializeAsContent( data[ i ].type )
					)
				)
			) {
				const annotations = new ve.dm.AnnotationSet(
					this.store, data[ i ].annotations || data[ i ][ 1 ]
				);
				this.constructor.static.openAndCloseAnnotations( annotationStack, annotations,
					openAnnotation, closeAnnotation
				);

				if ( data[ i ].annotations === undefined ) {
					// Annotated text
					text += getChar( data[ i ][ 0 ] );
				} else {
					// Annotated node
					// Add text if needed
					if ( text.length > 0 ) {
						annotatedDomElements.push( doc.createTextNode( text ) );
						text = '';
					}
					// Insert the elements
					const dataElementOrSlice = getDataElementOrSlice( i );
					const childDomElements = this.getDomElementsFromDataElement( dataElementOrSlice, doc );
					for ( let j = 0; j < childDomElements.length; j++ ) {
						annotatedDomElements.push( childDomElements[ j ] );
					}
					if ( Array.isArray( dataElementOrSlice ) ) {
						i += dataElementOrSlice.length - 1;
					} else {
						i++; // Skip the closing
					}
				}
				i++;
			}
			// We're now at the first non-annotated thing, go back one so we don't skip this later
			i--;

			// Add any gathered text
			if ( text.length > 0 ) {
				annotatedDomElements.push( doc.createTextNode( text ) );
				text = '';
			}
			// Close any remaining annotations
			this.constructor.static.openAndCloseAnnotations( annotationStack, new ve.dm.AnnotationSet( this.store ),
				openAnnotation, closeAnnotation
			);
			// Put the annotated nodes in the DOM
			for ( let j = 0; j < annotatedDomElements.length; j++ ) {
				domElement.appendChild( annotatedDomElements[ j ] );
			}
		} else if ( data[ i ].type !== undefined ) {
			const dataElement = data[ i ];
			// Element
			if ( dataElement.type.charAt( 0 ) === '/' ) {
				// Close element
				const parentDomElement = domElement.parentNode;
				const type = data[ i ].type.slice( 1 );
				const isContentNode = this.nodeFactory.isNodeContent( type );
				// Process whitespace
				// whitespace = [ outerPre, innerPre, innerPost, outerPost ]
				const oldLastOuterPost = parentDomElement.lastOuterPost;
				if (
					!isContentNode &&
					domElement.veInternal &&
					domElement.veInternal.whitespace
				) {
					// Process inner whitespace. innerPre is for sure legitimate
					// whitespace that should be inserted; if it was a duplicate
					// of our child's outerPre, we would have cleared it.
					const pre = domElement.veInternal.whitespace[ 1 ];
					if ( pre ) {
						if (
							domElement.firstChild &&
							domElement.firstChild.nodeType === Node.TEXT_NODE
						) {
							// First child is a TextNode, prepend to it
							domElement.firstChild.insertData( 0, pre );
						} else {
							// Prepend a TextNode
							const textNode = doc.createTextNode( pre );
							textNode.veIsWhitespace = true;
							domElement.insertBefore(
								textNode,
								domElement.firstChild
							);
						}
					}
					const lastChild = domElement.veInternal.childDomElements ?
						domElement.veInternal
							.childDomElements[ domElement.veInternal.childDomElements.length - 1 ]
							.lastChild :
						domElement.lastChild;
					const ours = domElement.veInternal.whitespace[ 2 ];
					let theirs;
					if ( domElement.lastOuterPost === undefined ) {
						// This node didn't have any structural children
						// (i.e. it's a content-containing node), so there's
						// nothing to check innerPost against
						theirs = ours;
					} else {
						theirs = domElement.lastOuterPost;
					}
					if ( ours && ours === theirs ) {
						if ( lastChild && lastChild.nodeType === Node.TEXT_NODE ) {
							// Last child is a TextNode, append to it
							domElement.lastChild.appendData( ours );
						} else {
							// Append a TextNode
							const textNode = doc.createTextNode( ours );
							textNode.veIsWhitespace = true;
							domElement.appendChild(
								textNode
							);
						}
					}
					// Tell the parent about our outerPost
					parentDomElement.lastOuterPost = domElement.veInternal.whitespace[ 3 ] || '';
				} else if ( !isContentNode ) {
					// Use empty string, because undefined means there were no
					// structural children
					parentDomElement.lastOuterPost = '';
				}
				// else don't touch lastOuterPost

				// Logic to unwrap empty & wrapper nodes.
				// It would be nicer if we could avoid generating in the first
				// place, but then remembering where we have to skip ascending
				// to the parent would be tricky.
				let doUnwrap = false;
				if ( domElement.veInternal ) {
					switch ( domElement.veInternal.generated ) {
						case 'slug':
							// 'slug' elements - remove if they are still empty
							if ( domElement.childNodes.length === 0 ) {
								doUnwrap = true;
							}
							break;
						case 'empty':
							// 'empty' elements - first ensure they are actually empty
							if (
								domElement.childNodes.length === 0 &&
								(
									// then check that we are the last child
									// before unwrapping (and therefore destroying)
									data[ i + 1 ] === undefined ||
									data[ i + 1 ].type.charAt( 0 ) === '/' ||
									// Document ends when we encounter the internal list
									(
										data[ i + 1 ].type &&
										this.nodeFactory.isNodeInternal( data[ i + 1 ].type )
									)
								)
							) {
								doUnwrap = true;
							}
							break;
						case 'wrapper': {
							// 'wrapper' elements - ensure there is a block level
							// element between this element and the previous sibling
							// wrapper or parent node
							doUnwrap = true;
							const previousSiblings = domElement.parentNode.childNodes;
							// Note: previousSiblings includes the current element
							// so we only go up to length - 2
							for ( let j = previousSiblings.length - 2; j >= 0; j-- ) {
								const sibling = previousSiblings[ j ];
								if ( ve.isBlockElement( sibling ) ) {
									// Stop searching early when we get to a block element.
									break;
								}
								// If we find content, don't unwrap.
								if (
									// Text node content (non-whitespace)
									( sibling.nodeType === Node.TEXT_NODE && !sibling.veIsWhitespace ) ||
									// Inline content tag
									( sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName !== 'META' && sibling.tagName !== 'LINK' )
								) {
									// we've found unwrapped content so don't unwrap
									doUnwrap = false;
									break;
								}
							}
							break;
						}
					}
				}
				if ( doUnwrap ) {
					if ( domElement.childNodes.length ) {
						// If domElement has children, append them to parentDomElement
						while ( domElement.firstChild ) {
							parentDomElement.insertBefore(
								domElement.firstChild,
								domElement
							);
						}
					} else {
						// If domElement has no children, it's as if it was never there at all,
						// so set lastOuterPost back to what it was, except that we need to
						// change undefined to '' , since undefined means there were no children.
						parentDomElement.lastOuterPost = oldLastOuterPost || '';
					}
					parentDomElement.removeChild( domElement );
				}

				delete domElement.veInternal;
				delete domElement.lastOuterPost;
				// Ascend to parent node, except if this is an internal node
				// TODO: It's not covered with unit tests.
				if ( !ve.dm.nodeFactory.lookup( type ) || !ve.dm.nodeFactory.isNodeInternal( type ) ) {
					domElement = parentDomElement;
				}
			} else {
				// Create node from data
				if ( this.nodeFactory.isNodeInternal( data[ i ].type ) ) {
					// Reached the internal list, finish
					break;
				}
				const isContentNode = this.nodeFactory.isNodeContent( data[ i ].type );

				const dataElementOrSlice = getDataElementOrSlice( i );
				const childDomElements = this.getDomElementsFromDataElement( dataElementOrSlice, doc );
				if ( childDomElements && !childDomElements.length ) {
					// Support toDomElements returning an empty array
					i = findEndOfNode( i ) - 1;
					continue;
				} else if ( childDomElements ) {
					// Add clone of internal data; we use a clone rather than a reference because
					// we modify .veInternal.whitespace[1] in some cases
					childDomElements[ 0 ].veInternal = ve.extendObject(
						{ childDomElements: childDomElements },
						dataElement.internal ? ve.copy( dataElement.internal ) : {}
					);
					// Add elements
					for ( let j = 0; j < childDomElements.length; j++ ) {
						domElement.appendChild( childDomElements[ j ] );
					}
					// Descend into the first child node
					const parentDomElement = domElement;
					domElement = childDomElements[ 0 ];

					// Process outer whitespace
					// Every piece of outer whitespace is duplicated somewhere:
					// each node's outerPost is duplicated as the next node's
					// outerPre, the first node's outerPre is the parent's
					// innerPre, and the last node's outerPost is the parent's
					// innerPost. For each piece of whitespace, we verify that
					// the duplicate matches. If it doesn't, we take that to
					// mean the user has messed with it and don't output any
					// whitespace.
					if ( domElement.veInternal && domElement.veInternal.whitespace ) {
						// Process this node's outerPre
						const ours = domElement.veInternal.whitespace[ 0 ];
						let theirs;
						if ( domElement.previousSibling ) {
							// Get previous sibling's outerPost
							theirs = parentDomElement.lastOuterPost;
						} else if ( parentDomElement === container ) {
							// outerPre of the very first node in the document, check against body innerWhitespace
							theirs = innerWhitespace ? innerWhitespace[ 0 ] : ours;
						} else {
							// First child, get parent's innerPre
							if (
								parentDomElement.veInternal &&
								parentDomElement.veInternal.whitespace
							) {
								theirs = parentDomElement.veInternal.whitespace[ 1 ];
								// Clear parent's innerPre so it's not used again
								parentDomElement.veInternal.whitespace[ 1 ] = undefined;
							}
							// else theirs=undefined
						}
						if ( ours && ours === theirs ) {
							// Matches the duplicate, insert a TextNode
							const textNode = doc.createTextNode( ours );
							textNode.veIsWhitespace = true;
							parentDomElement.insertBefore(
								textNode,
								domElement
							);
						}
					} else if (
						!isContentNode &&
						!domElement.previousSibling &&
						parentDomElement.veInternal &&
						parentDomElement.veInternal.whitespace
					) {
						// The parent's innerPre should not be used, because it doesn't match
						// outerPre (since we didn't have any whitespace set at all).
						// Except if this is a content node, because content nodes
						// don't have whitespace annotated on them *sigh*
						parentDomElement.veInternal.whitespace[ 1 ] = undefined;
					}
				}

				if ( Array.isArray( dataElementOrSlice ) ) {
					i += dataElementOrSlice.length - 2;
				} else if ( childDomElements && childDomElements.length && childDomElements[ 0 ].handledOwnChildren ) {
					i = findEndOfNode( i ) - 2;
				}
			}
		}
	}
	// Check outerPost whitespace of the very last node against body innerWhitespace
	if (
		container.lastOuterPost !== undefined &&
		( !innerWhitespace || container.lastOuterPost === innerWhitespace[ 1 ] )
	) {
		if ( container.lastChild && container.lastChild.nodeType === Node.TEXT_NODE ) {
			// Last child is a TextNode, append to it
			container.lastChild.appendData( container.lastOuterPost );
		} else if ( container.lastOuterPost.length > 0 ) {
			// Append a TextNode
			container.appendChild( doc.createTextNode( container.lastOuterPost ) );
		}
		delete container.lastOuterPost;
	}
	// Get rid of excess text nodes
	container.normalize();
};

/* Initialization */

ve.dm.converter = new ve.dm.Converter( ve.dm.modelRegistry, ve.dm.nodeFactory, ve.dm.annotationFactory );