/*!
 * VisualEditor UserInterface DiffElement class.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Creates a ve.ui.DiffElement object.
 *
 * @class
 * @extends OO.ui.Element
 *
 * @constructor
 * @param {ve.dm.VisualDiff} [visualDiff] Diff to visualize
 * @param {Object} [config]
 */
ve.ui.DiffElement = function VeUiDiffElement( visualDiff, config ) {
	const diff = visualDiff.diff;

	// Parent constructor
	ve.ui.DiffElement.super.call( this, config );

	this.elementId = 0;

	this.$overlays = $( '<div>' ).addClass( 've-ui-diffElement-overlays' );
	this.$content = $( '<div>' ).addClass( 've-ui-diffElement-content' );
	this.$messages = $( '<div>' ).addClass( 've-ui-diffElement-messages' );
	this.$document = $( '<div>' ).addClass( 've-ui-diffElement-document' );
	this.$sidebar = $( '<div>' ).addClass( 've-ui-diffElement-sidebar' );

	this.descriptions = new ve.ui.ChangeDescriptionsSelectWidget();
	this.descriptions.connect( this, { highlight: 'onDescriptionsHighlight' } );
	// Set to an empty array before a series of diff computations to collect descriptions.
	// Set back to null after collecting values.
	this.descriptionItemsStack = null;

	this.$document.on( {
		mousemove: this.onDocumentMouseMove.bind( this )
	} );

	this.renderDiff( diff.docDiff, diff.internalListDiff, diff.metaListDiff, visualDiff.newDoc.getHtmlDocument() );

	if ( visualDiff.timedOut ) {
		const warning = new OO.ui.MessageWidget( {
			type: 'warning',
			classes: [ 've-ui-diffElement-warning' ],
			label: ve.msg( 'visualeditor-diff-timed-out' )
		} );
		this.$messages.append( warning.$element );
	}

	// DOM
	this.$element
		.append(
			this.$messages,
			this.$content.append( this.$document, this.$overlays ),
			this.$sidebar.append( this.descriptions.$element )
		)
		.addClass( 've-ui-diffElement' );
};

/* Inheritance */

OO.inheritClass( ve.ui.DiffElement, OO.ui.Element );

/* Static methods */

/**
 * Compare attribute sets between two elements
 *
 * @param {Object} oldAttributes Old attributes
 * @param {Object} newAttributes New attributes
 * @return {Object} Keyed set of attributes
 */
ve.ui.DiffElement.static.compareAttributes = function ( oldAttributes, newAttributes ) {
	function compareKeys( a, b ) {
		if ( typeof a === 'object' && typeof b === 'object' ) {
			return ve.compare( a, b );
		} else {
			return a === b;
		}
	}

	const attributeChanges = {};
	for ( const key in oldAttributes ) {
		if ( !compareKeys( oldAttributes[ key ], newAttributes[ key ] ) ) {
			attributeChanges[ key ] = { from: oldAttributes[ key ], to: newAttributes[ key ] };
		}
	}
	for ( const key in newAttributes ) {
		if ( !Object.prototype.hasOwnProperty.call( oldAttributes, key ) && newAttributes[ key ] !== undefined ) {
			attributeChanges[ key ] = { from: oldAttributes[ key ], to: newAttributes[ key ] };
		}
	}
	return attributeChanges;
};

/**
 * Get the original linear data from a node
 *
 * @param {ve.dm.Node} node Node
 * @return {Array} Linear data
 */
ve.ui.DiffElement.static.getDataFromNode = function ( node ) {
	const doc = node.getRoot().getDocument();
	return doc.getData( node.getOuterRange() );
};

/* Methods */

/**
 * Get a diff element in the document from its elementId
 *
 * @param {number} elementId ID
 * @return {jQuery} Element
 */
ve.ui.DiffElement.prototype.getDiffElementById = function ( elementId ) {
	return this.$document.find( '[data-diff-id=' + elementId + ']' );
};

/**
 * Handle description item hightlight events
 *
 * @param {OO.ui.OptionWidget} item Description item
 */
ve.ui.DiffElement.prototype.onDescriptionsHighlight = function ( item ) {
	if ( this.lastItem ) {
		this.getDiffElementById( this.lastItem.getData() ).css( 'outline', '' );
		this.$overlays.empty();
	}
	if ( item ) {
		const overlayRect = this.$overlays[ 0 ].getBoundingClientRect();
		const elementRects = ve.ce.FocusableNode.static.getRectsForElement( this.getDiffElementById( item.getData() ), overlayRect ).rects;
		for ( let i = 0, l = elementRects.length; i < l; i++ ) {
			this.$overlays.append(
				$( '<div>' ).addClass( 've-ui-diffElement-highlight' ).css( {
					top: elementRects[ i ].top,
					left: elementRects[ i ].left,
					width: elementRects[ i ].width,
					height: elementRects[ i ].height
				} )
			);
		}
		this.lastItem = item;
	}
};

/**
 * Handle document mouse move events
 *
 * @param {jQuery.Event} e Mouse move event
 */
ve.ui.DiffElement.prototype.onDocumentMouseMove = function ( e ) {
	const elementId = $( e.target ).closest( '[data-diff-id]' ).attr( 'data-diff-id' );
	if ( elementId !== undefined ) {
		this.descriptions.highlightItem(
			this.descriptions.findItemFromData( +elementId )
		);
	} else {
		this.descriptions.highlightItem();
	}
};

/**
 * Reposition the description items so they are not above their position in the document
 */
ve.ui.DiffElement.prototype.positionDescriptions = function () {
	this.descriptions.getItems().forEach( ( item ) => {
		item.$element.css( 'margin-top', '' );

		const itemRect = item.$element[ 0 ].getBoundingClientRect();
		const $element = this.getDiffElementById( item.getData() );
		if ( !$element.length ) {
			// Changed element isn't visible - probably shouldn't happen
			return;
		}
		const elementRect = ve.ce.FocusableNode.static.getRectsForElement( $element ).boundingRect;

		// elementRect can currently be null for meta items, e.g. <link>
		if ( elementRect && elementRect.top > itemRect.top ) {
			item.$element.css( 'margin-top', elementRect.top - itemRect.top - 5 );
		}
	} );
	this.$document.css( 'min-height', this.$sidebar.height() );
};

/**
 * Process a diff queue, skipping over sequential nodes with no changes
 *
 * @param {Array[]} queue Diff queue
 * @return {Array.<Array|null>}
 */
ve.ui.DiffElement.prototype.processQueue = function processQueue( queue ) {
	let hasChanges = false,
		lastItemSpacer = false,
		needsSpacer = false,
		headingContext = null,
		headingContextSpacer = false;
	const processedQueue = [];

	function isUnchanged( item ) {
		return !item || ( item[ 2 ] === 'none' && !item[ 3 ] );
	}

	function addSpacer() {
		processedQueue.push( null );
		lastItemSpacer = true;
	}

	function addItem( item ) {
		processedQueue.push( item );
		lastItemSpacer = false;
	}

	function isHeading( item ) {
		switch ( item[ 0 ] ) {
			case 'getNodeData':
			case 'getNodeElements':
				return item[ 1 ] instanceof ve.dm.HeadingNode;
			case 'getChangedNodeData':
			case 'getChangedNodeElements':
				return item[ 3 ] instanceof ve.dm.HeadingNode;
		}
	}

	for ( let k = 0, klen = queue.length; k < klen; k++ ) {
		if (
			!isUnchanged( queue[ k - 1 ] ) ||
			!isUnchanged( queue[ k ] ) ||
			!isUnchanged( queue[ k + 1 ] )
		) {
			hasChanges = true;
			if ( headingContext ) {
				// Don't render headingContext if current or next node is a heading
				if ( !isHeading( queue[ k ] ) && !isHeading( queue[ k + 1 ] ) ) {
					if ( headingContextSpacer ) {
						addSpacer();
					}
					addItem( headingContext );
				} else if ( isHeading( queue[ k + 1 ] ) ) {
					// Skipping the context header becuase the next node is a heading
					// so reinstate the spacer.
					needsSpacer = true;
				}
				headingContext = null;
			}
			if ( needsSpacer && !lastItemSpacer ) {
				addSpacer();
				needsSpacer = false;
			}
			addItem( queue[ k ] );

			if ( isHeading( queue[ k ] ) ) {
				// Heading was rendered, no need to show it as context
				headingContext = null;
			}
		} else {
			// Heading skipped, maybe show as context later
			if ( isHeading( queue[ k ] ) ) {
				headingContext = isUnchanged( queue[ k ] ) ? queue[ k ] : null;
				headingContextSpacer = needsSpacer;
				needsSpacer = false;
			} else {
				needsSpacer = true;
			}
		}
	}

	// Trailing spacer
	if ( hasChanges && needsSpacer && !lastItemSpacer ) {
		addSpacer();
	}

	return processedQueue;
};

/**
 * @param {Array.<Array|null>} queue Diff queue
 * @param {HTMLElement} parentNode Parent node to render to
 * @param {HTMLElement} spacerNode Spacer node template
 */
ve.ui.DiffElement.prototype.renderQueue = function ( queue, parentNode, spacerNode ) {
	queue.forEach( ( item ) => {
		if ( item ) {
			const elements = this[ item[ 0 ] ].apply( this, item.slice( 1 ) );
			while ( elements.length ) {
				parentNode.appendChild(
					parentNode.ownerDocument.adoptNode( elements[ 0 ] )
				);
				elements.shift();
			}
		} else {
			parentNode.appendChild(
				parentNode.ownerDocument.adoptNode( spacerNode.cloneNode( true ) )
			);
		}
	} );
};

/**
 * Render the diff
 *
 * @param {Object} diff Object describing the diff
 * @param {Object} internalListDiff Object describing the diff of the internal list
 * @param {Object} metaListDiff Object describing the diff of the meta list
 * @param {HTMLDocument} newHtmlDocument HTML document of context, for resolving attributes
 */
ve.ui.DiffElement.prototype.renderDiff = function ( diff, internalListDiff, metaListDiff, newHtmlDocument ) {
	const documentNode = this.$document[ 0 ],
		diffQueue = [];

	let internalListDiffQueue = [];

	const documentSpacerNode = document.createElement( 'div' );
	documentSpacerNode.setAttribute( 'class', 've-ui-diffElement-spacer' );
	documentSpacerNode.appendChild( document.createTextNode( '⋮' ) );

	const internalListSpacerNode = document.createElement( 'li' );
	internalListSpacerNode.setAttribute( 'class', 've-ui-diffElement-internalListSpacer' );
	internalListSpacerNode.appendChild( documentSpacerNode.cloneNode( true ) );

	const referencesListDiffs = {};
	Object.keys( internalListDiff.groups ).forEach( ( group ) => {
		const referencesListContainer = document.createElement( 'ol' );
		const internalListGroup = internalListDiff.groups[ group ];

		this.iterateDiff( internalListGroup, {
			insert: function ( newNode, newIndex ) {
				internalListDiffQueue.push( [ 'getInternalListNodeElements', newNode, 'insert', null, newIndex ] );
			},
			remove: function ( oldNode, oldIndex ) {
				internalListDiffQueue.push( [ 'getInternalListNodeElements', oldNode, 'remove', null, oldIndex ] );
			},
			move: function ( newNode, move, newIndex ) {
				internalListDiffQueue.push( [ 'getInternalListNodeElements', newNode, 'none', move, newIndex ] );
			},
			changed: function ( nodeDiff, oldNode, newNode, move, oldIndex, newIndex ) {
				internalListDiffQueue.push( [ 'getInternalListChangedNodeElements', nodeDiff, oldNode, newNode, move, newIndex ] );
			}
		} );

		this.descriptionItemsStack = [];
		this.renderQueue(
			this.processQueue( internalListDiffQueue ),
			referencesListContainer, internalListSpacerNode
		);
		referencesListDiffs[ group ] = {
			element: referencesListContainer,
			action: internalListGroup.changes ? 'change' : 'none',
			descriptionItemsStack: this.descriptionItemsStack,
			shown: false
		};
		this.descriptionItemsStack = null;

		internalListDiffQueue = [];
	} );

	this.descriptionItemsStack = [];
	let referencesListDiff;

	function handleRefList( node, move ) {
		if (
			node.type === 'mwReferencesList' &&
			( referencesListDiff = referencesListDiffs[ node.element.attributes.listGroup ] )
		) {
			// New node is a references list node. If a reference has
			// changed, the references list nodes appear unchanged,
			// because of how the internal list works. However, we
			// already have the HTML for the diffed references list,
			// (which contains details of changes if there are any) so
			// just get that.
			diffQueue.push( [ 'getRefListNodeElements', referencesListDiff.element, referencesListDiff.action, move, referencesListDiff.descriptionItemsStack ] );
			referencesListDiff.shown = true;
			return true;
		}
		return false;
	}

	this.iterateDiff( diff, {
		insert: function ( newNode ) {
			if ( !handleRefList( newNode, null ) ) {
				diffQueue.push( [ 'getNodeElements', newNode, 'insert', null ] );
			}
		},
		remove: function ( oldNode ) {
			if ( !handleRefList( oldNode, null ) ) {
				diffQueue.push( [ 'getNodeElements', oldNode, 'remove', null ] );
			}
		},
		move: function ( newNode, move ) {
			diffQueue.push( [ 'getNodeElements', newNode, 'none', move ] );
		},
		preChanged: function ( oldNode, newNode, move ) {
			return handleRefList( newNode, move );
		},
		changed: function ( nodeDiff, oldNode, newNode, move ) {
			diffQueue.push( [ 'getChangedNodeElements', nodeDiff, oldNode, newNode, move ] );
		}
	} );

	// Show any ref list diffs that weren't picked up by the main diff loop above,
	// e.g. during a section diff.
	Object.keys( referencesListDiffs ).forEach( ( group ) => {
		referencesListDiff = referencesListDiffs[ group ];
		if ( !referencesListDiff.shown ) {
			diffQueue.push( [ 'getRefListNodeElements', referencesListDiff.element, referencesListDiff.action, null, referencesListDiff.descriptionItemsStack ] );
		}
	} );

	this.renderQueue(
		this.processQueue( diffQueue ),
		documentNode, documentSpacerNode
	);

	this.renderMetaListDiff( metaListDiff, documentNode, documentSpacerNode );

	this.descriptions.addItems( this.descriptionItemsStack );
	this.descriptionItemsStack = null;

	ve.resolveAttributes( documentNode, newHtmlDocument, ve.dm.Converter.static.computedAttributes );

	if ( !documentNode.children.length ) {
		const noChanges = document.createElement( 'div' );
		noChanges.setAttribute( 'class', 've-ui-diffElement-no-changes' );
		noChanges.appendChild( document.createTextNode( ve.msg( 'visualeditor-diff-no-changes' ) ) );
		documentNode.innerHTML = '';
		documentNode.appendChild( noChanges );
	}

	this.$element
		.toggleClass( 've-ui-diffElement-hasDescriptions', !this.descriptions.isEmpty() );
};

ve.ui.DiffElement.prototype.renderMetaListDiff = function ( metaListDiff, documentNode, documentSpacerNode ) {
	Object.keys( metaListDiff ).forEach( ( group ) => {
		const handler = ve.ui.metaListDiffRegistry.lookup( group );
		if ( handler ) {
			const diffQueue = [];
			this.iterateDiff( metaListDiff[ group ], {
				insert: ( newNode ) => {
					diffQueue.push( [ 'getNodeElements', newNode, 'insert', null ] );
				},
				remove: ( oldNode ) => {
					diffQueue.push( [ 'getNodeElements', oldNode, 'remove', null ] );
				},
				move: ( newNode, move ) => {
					diffQueue.push( [ 'getNodeElements', newNode, 'none', move ] );
				},
				changed: ( nodeDiff, oldNode, newNode, move ) => {
					diffQueue.push( [ 'getChangedNodeElements', nodeDiff, oldNode, newNode, move ] );
				}
			} );
			handler( this, diffQueue, documentNode, documentSpacerNode );
		}
	} );
};

/**
 * Get the HTML for the diff of a removed, inserted, or unchanged-but-moved node.
 *
 * @param {ve.dm.Node} node The node being diffed. Will be from the old
 * document if it has been removed, or the new document if it has been inserted
 * or moved
 * @param {string} action 'remove', 'insert' or, if moved, 'none'
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @return {HTMLElement[]} Elements (not owned by window.document)
 */
ve.ui.DiffElement.prototype.getNodeElements = function ( node, action, move ) {
	const nodeData = this.getNodeData( node, action, move );

	return this.wrapNodeData( node.getRoot().getDocument(), nodeData );
};

/**
 * Get the DOM from linear data and wrap it for the diff.
 *
 * @param {ve.dm.Document} nodeDoc Node's document model
 * @param {Array} nodeData Linear data for the diff
 * @return {HTMLElement[]} Elements (not owned by window.document)
 */
ve.ui.DiffElement.prototype.wrapNodeData = function ( nodeDoc, nodeData ) {
	const documentSlice = nodeDoc.cloneWithData( nodeData );
	documentSlice.getStore().merge( nodeDoc.getStore() );
	const nodeElements = ve.dm.converter.getDomFromModel( documentSlice, ve.dm.Converter.static.PREVIEW_MODE ).body;

	// Convert NodeList to real array
	return Array.prototype.slice.call( nodeElements.childNodes );
};

/**
 * Get the linear data for the diff of a removed, inserted, or
 * unchanged-but-moved node.
 *
 * @param {ve.dm.Node} node
 * @param {string} action  'remove', 'insert' or, if moved, 'none'
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @return {Array} Linear Data
 */
ve.ui.DiffElement.prototype.getNodeData = function ( node, action, move ) {
	// Get the linear model for the node
	const nodeData = this.constructor.static.getDataFromNode( node );

	// Add the classes to the outer element
	this.addAttributesToElement( nodeData, 0, { 'data-diff-action': action } );
	this.markMove( move, nodeData );

	return nodeData;
};

/**
 * Get the HTML for the diff of a node that has been changed.
 *
 * @param {Object} diff Object describing the diff
 * @param {ve.dm.Node} oldNode Node from the old document
 * @param {ve.dm.Node} newNode Corresponding node from the new document
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @return {HTMLElement[]} Elements (not owned by window.document)
 */
ve.ui.DiffElement.prototype.getChangedNodeElements = function ( diff, oldNode, newNode, move ) {
	const nodeData = this.getChangedNodeData( diff, oldNode, newNode, move );

	return this.wrapNodeData( newNode.getRoot().getDocument(), nodeData );
};

/**
 * Get the linear data for the diff of a node that has been changed.
 *
 * @param {Object} diff Object describing the diff
 * @param {ve.dm.Node} oldNode Node from the old document
 * @param {ve.dm.Node} newNode Corresponding node from the new document
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @param {boolean} [noTreeDiff] Don't perform a tree diff of the nodes (used internally to avoid recursion)
 * @return {Array|boolean} Linear data for the diff, or false
 */
ve.ui.DiffElement.prototype.getChangedNodeData = function ( diff, oldNode, newNode, move, noTreeDiff ) {
	let nodeData;

	// Choose the appropriate method for the type of node
	if ( newNode.isDiffedAsLeaf() ) {
		nodeData = this.getChangedLeafNodeData( newNode, diff, move );
	} else if ( newNode.isDiffedAsList() ) {
		nodeData = this.getChangedListNodeData( newNode, diff );
	} else if ( newNode.isDiffedAsDocument() ) {
		nodeData = this.getChangedDocListData( newNode, diff );
	} else if ( !noTreeDiff ) {
		nodeData = this.getChangedTreeNodeData( oldNode, newNode, diff );
	} else {
		return false;
	}

	this.markMove( move, nodeData );

	return nodeData;
};

/**
 * Get the linear data for the diff of a leaf-like node that has been changed.
 *
 * @param {ve.dm.Node} newNode Corresponding node from the new document
 * @param {Object} diff Object describing the diff
 * @return {Array} Linear data for the diff
 */
ve.ui.DiffElement.prototype.getChangedLeafNodeData = function ( newNode, diff ) {
	const nodeData = this.constructor.static.getDataFromNode( newNode ),
		linearDiff = diff.linearDiff,
		attributeChange = diff.attributeChange;

	if ( linearDiff ) {
		// If there is a content change, splice it in
		const annotatedData = this.annotateNode( linearDiff, newNode );
		ve.batchSplice( nodeData, 1, newNode.length, annotatedData );
	}
	if ( attributeChange ) {
		// If there is no content change, just add change class
		this.addAttributesToElement(
			nodeData, 0, { 'data-diff-action': 'structural-change' }
		);
		const item = this.compareNodeAttributes( nodeData, 0, attributeChange );
		if ( item ) {
			this.descriptionItemsStack.push( item );
		}
	}

	return nodeData;
};

/**
 * Append list item
 *
 * @private
 * @param {Array} diffData
 * @param {number} insertIndex
 * @param {ve.dm.ListNode} listNode
 * @param {Array} listNodeData List node opening
 * @param {Array} listItemData
 * @param {number} depthChange
 * @return {number}
 */
ve.ui.DiffElement.prototype.appendListItem = function ( diffData, insertIndex, listNode, listNodeData, listItemData, depthChange ) {
	if ( depthChange === 0 ) {
		// Current list item belongs to the same list as the previous list item
		ve.batchSplice( diffData, insertIndex, 0, listItemData );
		insertIndex += listItemData.length;

	} else if ( depthChange > 0 ) {
		// Begin a new nested list, with this node's ancestor nodes
		const linearData = [];
		linearData.unshift( listNodeData[ 0 ] );

		let k, klen;
		const doc = listNode.getRoot().getDocument();
		// Nested list may be nested by multiple levels
		for ( k = 0, klen = depthChange - 1; k < klen; k++ ) {
			const listItemNode = listNode.parent;
			linearData.unshift( doc.data.data[ listItemNode.getOuterRange().from ] );
			listNode = listItemNode.parent;
			linearData.unshift( doc.data.data[ listNode.getOuterRange().from ] );
		}

		// Splice in the content, and splice that into the diff data
		ve.batchSplice( linearData, linearData.length, 0, listItemData );
		for ( k = 0, klen = depthChange - 1; k < klen; k++ ) {
			linearData.push( { type: '/list' } );
			linearData.push( { type: '/listItem' } );
		}
		linearData.push( { type: '/list' } );

		// Splice into previous list item
		insertIndex -= 1;
		ve.batchSplice( diffData, insertIndex, 0, linearData );

		// Adjust the insertIndex to be at the end of this content
		insertIndex += 2 * ( depthChange - 1 ) + 1 + listItemData.length;

	} else if ( depthChange < 0 ) {
		// Skip over close elements to get out of nested list(s)
		insertIndex += 2 * -depthChange;

		// Splice in the data and adjust the insert index
		ve.batchSplice( diffData, insertIndex, 0, listItemData );
		insertIndex += listItemData.length;
	}

	return insertIndex;
};

/**
 * Get the linear data for a document-like node that has been changed
 *
 * @param {ve.dm.Node} newDoclistNode Node from new document
 * @param {Object} diff Object describing the diff
 * @param {boolean} neverProcess Never process the diffQueue (always show the whole document)
 * @return {Array} Linear data for the diff
 */
ve.ui.DiffElement.prototype.getChangedDocListData = function ( newDoclistNode, diff, neverProcess ) {
	const diffData = [];
	let diffQueue = [];

	const spacerData = [ { type: 'div' }, '⋮', { type: '/div' } ];
	this.addAttributesToElement( spacerData, 0, { class: 've-ui-diffElement-spacer' } );

	this.iterateDiff( diff, {
		insert: ( newNode ) => {
			diffQueue.push( [ 'getNodeData', newNode, 'insert', null ] );
		},
		remove: ( oldNode ) => {
			diffQueue.push( [ 'getNodeData', oldNode, 'remove', null ] );
		},
		move: ( newNode, move ) => {
			diffQueue.push( [ 'getNodeData', newNode, 'none', move ] );
		},
		changed: ( nodeDiff, oldNode, newNode, move ) => {
			diffQueue.push( [ 'getChangedNodeData', nodeDiff, oldNode, newNode, move ] );
		}
	} );

	let hasAttributeChanges = false;
	const newDoclistNodeData = this.constructor.static.getDataFromNode( newDoclistNode );
	if ( diff.attributeChange ) {
		const item = this.compareNodeAttributes( newDoclistNodeData, 0, diff.attributeChange );
		if ( item ) {
			this.descriptionItemsStack.push( item );
			hasAttributeChanges = true;
		}
	}

	// When the doc cotainer has attribute changes, show the whole node.
	// Otherwise use processQueue to filter out unchanged context
	if ( !neverProcess && !hasAttributeChanges ) {
		diffQueue = this.processQueue( diffQueue );
	}
	diffQueue.forEach( ( diffItem ) => {
		if ( diffItem ) {
			ve.batchPush( diffData, this[ diffItem[ 0 ] ].apply( this, diffItem.slice( 1 ) ) );
		} else {
			ve.batchPush( diffData, spacerData.slice() );
		}
	} );

	// Wrap in newDocListNode
	diffData.unshift( newDoclistNodeData[ 0 ] );
	diffData.push( newDoclistNodeData[ newDoclistNodeData.length - 1 ] );

	return diffData;
};

/**
 * Iterate over a diff object and run more meaningful callbacks
 *
 * @param {Object|Array} diff Diff object, or array (InternalListDiff)
 * @param {Object} callbacks Callbacks
 * @param {Function} callbacks.insert Node inserted, arguments:
 *  {ve.dm.Node} newNode
 *  {number} newIndex
 * @param {Function} callbacks.remove Node removed, arguments:
 *  {ve.dm.Node} oldNode
 *  {number} oldIndex
 * @param {Function} callbacks.move Node moved, arguments:
 *  {ve.dm.Node} newNode
 *  {number} newIndex
 *  {string|null} move
 * @param {Function} callbacks.changed Node changed, arguments:
 *  {Object} nodeDiff
 *  {ve.dm.Node} oldNode
 *  {ve.dm.Node} newNode
 *  {number} oldIndex
 *  {number} newIndex
 *  {string|null} move
 */
ve.ui.DiffElement.prototype.iterateDiff = function ( diff, callbacks ) {
	// Internal list diffs set 'diff' to a number to shortcut computing the list diff
	// for fully inserted/removed lists.
	// TODO: Remove this special case and use a regular list diff
	if ( Array.isArray( diff ) ) {
		diff.forEach( ( item ) => {
			let node;
			switch ( item.diff ) {
				case 1:
					node = diff.newList.children[ item.nodeIndex ];
					callbacks.insert( node, item.indexOrder );
					break;
				case -1:
					node = diff.oldList.children[ item.nodeIndex ];
					callbacks.remove( node, item.indexOrder );
					break;
			}
		} );
		return;
	}

	const len = Math.max( diff.oldNodes.length, diff.newNodes.length );

	for ( let i = 0, j = 0; i < len || j < len; i++, j++ ) {
		const move = diff.moves[ j ] === 0 ? null : diff.moves[ j ];

		if ( diff.oldNodes[ i ] === undefined ) {
			// Everything else in the new doc list is an insert
			while ( j < diff.newNodes.length ) {
				callbacks.insert( diff.newNodes[ j ], j );
				j++;
			}
		} else if ( diff.newNodes[ j ] === undefined ) {
			// Everything else in the old doc is a remove
			while ( i < diff.oldNodes.length ) {
				callbacks.remove( diff.oldNodes[ i ], i );
				i++;
			}
		} else if ( diff.remove.indexOf( i ) !== -1 ) {
			// The old node is a remove. Decrement the new node index
			// to compare the same new node to the next old node
			callbacks.remove( diff.oldNodes[ i ], i );
			j--;
		} else if ( diff.insert.indexOf( j ) !== -1 ) {
			// The new node is an insert. Decrement the old node index
			// to compare the same old node to the next new node
			callbacks.insert( diff.newNodes[ j ], j );
			i--;
		} else if (
			callbacks.preChanged &&
			callbacks.preChanged( diff.oldNodes[ i ], diff.newNodes[ j ], move, i, j )
		) {
			// preChanged ran
		} else if ( typeof diff.newToOld[ j ] === 'number' ) {
			// The old and new node are exactly the same
			callbacks.move( diff.newNodes[ j ], move, j );
		} else {
			const oldNodeIndex = diff.newToOld[ j ].node;
			const oldNode = diff.oldNodes[ oldNodeIndex ];
			const newNode = diff.newNodes[ diff.oldToNew[ oldNodeIndex ].node ];
			const nodeDiff = diff.oldToNew[ oldNodeIndex ].diff;

			// The new node is modified from the old node
			callbacks.changed( nodeDiff, oldNode, newNode, move, i, j );
		}
	}
};

/**
 * Get the linear data for the diff of a list-like node that has been changed.
 *
 * @param {ve.dm.Node} newListNode Corresponding node from the new document
 * @param {Object} diff Object describing the diff
 * @return {Array} Linear data for the diff
 */
ve.ui.DiffElement.prototype.getChangedListNodeData = function ( newListNode, diff ) {
	const diffData = [];

	// These will be adjusted for each item
	let insertIndex = 1;
	let depth = -1;
	const listNodesInfoCache = new Map();

	const listDiffItems = [];

	this.iterateDiff( diff, {
		insert: ( newNode, index ) => {
			listDiffItems.push( {
				node: newNode,
				metadata: diff.newList.metadata[ index ],
				action: 'insert'
			} );
		},
		remove: ( oldNode, index ) => {
			listDiffItems.push( {
				node: oldNode,
				metadata: diff.oldList.metadata[ index ],
				action: 'remove'
			} );
		},
		move: ( newNode, move, index ) => {
			listDiffItems.push( {
				node: newNode,
				metadata: diff.newList.metadata[ index ],
				action: 'none',
				move: move
			} );
		},
		changed: ( nodeDiff, oldNode, newNode, move, oldIndex, newIndex ) => {
			listDiffItems.push( {
				node: newNode,
				metadata: diff.newList.metadata[ newIndex ],
				diff: nodeDiff,
				move: move
			} );
		}
	} );

	const processedListDiffItems = [];

	let lastItemSpacer = false;
	let lastShownDepth = 0;
	const lastItemAtDepth = {};
	listDiffItems.forEach( ( item, i ) => {
		function isUnchanged( queueItem ) {
			return !queueItem || ( queueItem.action === 'none' && !queueItem.move );
		}

		if (
			isUnchanged( item ) &&
			isUnchanged( listDiffItems[ i - 1 ] ) &&
			isUnchanged( listDiffItems[ i + 1 ] )
		) {
			if ( !lastItemSpacer ) {
				processedListDiffItems.push( {
					node: null,
					metadata: item.metadata,
					action: 'none'
				} );
				lastItemSpacer = true;
			}
		} else {
			while ( lastShownDepth < item.metadata.depth - 1 ) {
				lastShownDepth++;
				if ( lastItemAtDepth[ lastShownDepth ] ) {
					processedListDiffItems.push( {
						node: null,
						metadata: lastItemAtDepth[ lastShownDepth ].metadata,
						action: 'none'
					} );
				}
			}
			processedListDiffItems.push( item );
			lastItemSpacer = false;
			lastShownDepth = item.metadata.depth;
		}

		lastItemAtDepth[ item.metadata.depth ] = item;
	} );

	// Splice in each item with its diff annotations
	processedListDiffItems.forEach( ( item ) => {
		let contentData;
		let isSpacer = false;

		if ( !item.diff ) {
			if ( !item.node ) {
				contentData = [ { type: 'paragraph' }, '…', { type: '/paragraph' } ];
				isSpacer = true;
				this.addAttributesToElement( contentData, 0, { 'data-diff-action': 'none' } );
			} else {
				// Get the linear data for the list item's content
				contentData = this.getNodeData( item.node, item.action, item.move || null );
			}
		} else {
			// Item is changed. Get the linear data for the diff
			contentData = this.getChangedNodeData(
				item.diff,
				null,
				item.node,
				item.move || null
			);
		}

		// Calculate the change in depth
		const newDepth = item.metadata.depth;
		const depthChange = newDepth - depth;

		// Get linear data. Also get list node, since may need ancestors
		const listNode = item.metadata.listNode;
		let listNodeInfo;
		if ( !listNodesInfoCache.has( listNode ) ) {
			// Only re-fetch list node data once per list
			listNodeInfo = {
				data: [ this.constructor.static.getDataFromNode( listNode )[ 0 ] ],
				changeDone: false
			};
			listNodesInfoCache.set( listNode, listNodeInfo );
		} else {
			listNodeInfo = listNodesInfoCache.get( listNode );
		}

		const listItemNode = item.metadata.listItem;
		// Get linear data of list item
		let listItemData = this.constructor.static.getDataFromNode( listItemNode );
		if ( isSpacer ) {
			this.addAttributesToElement( listItemData, 0, { 'data-diff-list-spacer': '' } );
		} else {
			// TODO: Make this a node property, instead of a magic attribute
			if ( listNode.getAttribute( 'style' ) === 'number' ) {
				// Manually number list items for <ol>'s which contain removals
				// TODO: Consider if the <ol> contains a `start` attribute (not currently handled by DM)
				const indexInOwnList = listNode.children.indexOf( listItemNode );
				this.addAttributesToElement( listItemData, 0, { value: indexInOwnList + 1 } );
			}
			if ( item.action === 'none' && !item.move ) {
				this.addAttributesToElement( listItemData, 0, {
					'data-diff-list-none': ''
				} );
			}
		}

		// e.g. AlienBlockNode, content node is same as 'listItem', so don't duplicate content
		if ( item.node === listItemNode ) {
			listItemData = contentData;
		} else {
			ve.batchSplice( listItemData, 1, listItemData.length - 2, contentData );
		}

		// Check for attribute changes
		if ( item.diff && item.diff.attributeChange ) {
			const attributeChange = {
				oldAttributes: {},
				newAttributes: {}
			};

			[ 'listNodeAttributeChange', 'depthChange', 'listItemAttributeChange' ].forEach( ( listChangeType ) => {
				if ( item.diff.attributeChange[ listChangeType ] ) {
					if ( listChangeType === 'listNodeAttributeChange' && depthChange > 0 ) {
						const change = this.compareNodeAttributes( listNodeInfo.data, 0, item.diff.attributeChange[ listChangeType ] );
						if ( change ) {
							this.descriptionItemsStack.push( change );
							listNodeInfo.changeDone = true;
						}
					} else if ( listChangeType !== 'listNodeAttributeChange' || !listNodeInfo.changeDone ) {
						ve.extendObject( attributeChange.oldAttributes, item.diff.attributeChange[ listChangeType ].oldAttributes );
						ve.extendObject( attributeChange.newAttributes, item.diff.attributeChange[ listChangeType ].newAttributes );
					}
				}
			} );

			const listItemChange = this.compareNodeAttributes( listItemData, 0, attributeChange );
			if ( listItemChange ) {
				this.descriptionItemsStack.push( listItemChange );
			}
		}

		if ( item.metadata.isContinued ) {
			ve.batchSplice( diffData, insertIndex - 1, 0, contentData );
			insertIndex += contentData.length;

		} else {
			// Record the index to splice in the next list item data into the diffData
			insertIndex = this.appendListItem(
				diffData, insertIndex, listNode, listNodeInfo.data, listItemData, depthChange
			);
		}

		depth = newDepth;
	} );

	return diffData;
};

/**
 * Get the linear data for the diff of a tree-like node that has been changed.
 * Any node that is not leaf-like or list-like is treated as tree-like.
 *
 * @param {ve.dm.Node} oldTreeNode Node from the old document
 * @param {ve.dm.Node} newTreeNode Corresponding node from the new document
 * @param {Object} diff Object describing the diff
 * @return {Array} Linear data for the diff
 */
ve.ui.DiffElement.prototype.getChangedTreeNodeData = function ( oldTreeNode, newTreeNode, diff ) {
	const nodeData = this.constructor.static.getDataFromNode( newTreeNode ),
		nodeRange = newTreeNode.getOuterRange(),
		treeDiff = diff.treeDiff,
		diffInfo = diff.diffInfo,
		oldNodes = diff.oldTreeOrderedNodes,
		newNodes = diff.newTreeOrderedNodes,
		correspondingNodes = diff.correspondingNodes,
		structuralRemoves = [],
		highestRemovedAncestors = {};

	/**
	 * Splice in the removed data for the subtree rooted at this node, from the old
	 * document.
	 *
	 * @param {number} nodeIndex The index of this node in the subtree rooted at
	 * this document child
	 */
	const highlightRemovedNode = ( nodeIndex ) => {

		const findRemovedAncestor = ( n ) => {
			if ( !n.parent || structuralRemoves.indexOf( n.parent.index ) === -1 ) {
				return n.index;
			} else {
				return findRemovedAncestor( n.parent );
			}
		};

		const getRemoveData = ( n, index ) => {
			const data = this.constructor.static.getDataFromNode( n.node );
			this.addAttributesToElement( data, 0, {
				'data-diff-action': 'remove'
			} );

			while ( n && n.index !== index ) {
				n = n.parent;
				const tempData = this.constructor.static.getDataFromNode( n.node );
				data.unshift( tempData[ 0 ] );
				data.push( tempData[ tempData.length - 1 ] );
				this.addAttributesToElement( data, 0, {
					'data-diff-action': 'structural-remove'
				} );
			}

			return data;
		};

		const orderedNode = oldNodes[ nodeIndex ];
		const node = orderedNode.node;

		if ( node.isDiffedAsTree() && node.hasChildren() ) {
			// Record that the node has been removed, but don't display it, for now
			// TODO: describe the change for the attribute diff
			structuralRemoves.push( nodeIndex );

		} else {
			// Display the removed node, and all its ancestors, up to the first ancestor that
			// hasn't been removed.
			const highestRemovedAncestor = oldNodes[ findRemovedAncestor( orderedNode ) ];
			const removeData = getRemoveData( orderedNode, highestRemovedAncestor.index );
			let insertIndex;

			// Work out where to insert the removed subtree
			if ( highestRemovedAncestor.index in highestRemovedAncestors ) {
				// The highest removed ancestor has already been spliced into nodeData, so remove
				// it from this subtree and splice the rest of this subtree in
				removeData.shift();
				removeData.pop();
				insertIndex = highestRemovedAncestors[ highestRemovedAncestor.index ];

			} else if ( !highestRemovedAncestor.parent ) {
				// If this node is a child of the document node, then it won't have a "previous
				// node" (see below), in which case, insert it just before its corresponding
				// node in the new document.
				insertIndex = newNodes[ correspondingNodes.oldToNew[ highestRemovedAncestor.index ] ]
					.node.getOuterRange().from - nodeRange.from;

			} else {
				// Find the node that corresponds to the "previous node" of this node. The
				// "previous node" is either:
				// - the rightmost left sibling that corresponds to a node in the new document
				// - or if there isn't one, then this node's parent (which must correspond to
				// a node in the new document, or this node would have been marked already
				// processed)
				const siblingNodes = highestRemovedAncestor.parent.children;
				let newPreviousNodeIndex;
				for ( let x = 0, xlen = siblingNodes.length; x < xlen; x++ ) {
					if ( siblingNodes[ x ].index === highestRemovedAncestor.index ) {
						break;
					} else {
						const oldPreviousNodeIndex = siblingNodes[ x ].index;
						if ( correspondingNodes.oldToNew[ oldPreviousNodeIndex ] !== undefined ) {
							newPreviousNodeIndex = correspondingNodes.oldToNew[ oldPreviousNodeIndex ];
						}
					}
				}

				// If previous node was found among siblings, insert the removed subtree just
				// after its corresponding node in the new document. Otherwise insert the
				// removed subtree just inside its parent node's corresponding node.
				if ( newPreviousNodeIndex !== undefined ) {
					insertIndex = newNodes[ newPreviousNodeIndex ].node.getOuterRange().to - nodeRange.from;
				} else {
					newPreviousNodeIndex = correspondingNodes.oldToNew[ highestRemovedAncestor.parent.index ];
					insertIndex = newNodes[ newPreviousNodeIndex ].node.getRange().from - nodeRange.from;
				}

				// If more content branch node descendants of the highest removed node have
				// also been removed, record the index where their subtrees will need to be
				// spliced in.
				highestRemovedAncestors[ highestRemovedAncestor.index ] = insertIndex + 1;
			}

			ve.batchSplice( nodeData, insertIndex, 0, removeData );
		}
	};

	/**
	 * Mark this node as inserted.
	 *
	 * @param {number} nodeIndex The index of this node in the subtree rooted at
	 * this document child
	 */
	const highlightInsertedNode = ( nodeIndex ) => {
		// Find index of first data element for this node
		const node = newNodes[ nodeIndex ].node;
		const nodeRangeStart = node.getOuterRange().from - nodeRange.from;

		// Add insert class
		this.addAttributesToElement(
			nodeData, nodeRangeStart, {
				'data-diff-action': ( node.isDiffedAsTree() && node.hasChildren() ) ? 'structural-insert' : 'insert'
			}
		);
	};

	/**
	 * Mark this node as changed and, if it is a content branch node, splice in
	 * the diff data.
	 *
	 * @param {number} oldIdx Old node index
	 * @param {number} newIdx New node index
	 * @param {Object} info Diff information relating to this node's change
	 */
	const highlightChangedNode = ( oldIdx, newIdx, info ) => {
		// The new node was changed.
		// Get data for this node
		const oldNode = oldNodes[ oldIdx ].node;
		const newNode = newNodes[ newIdx ].node;
		const nodeRangeStart = newNode.getOuterRange().from - nodeRange.from;

		let nodeDiffData = this.getChangedNodeData( info, oldNode, newNode, null, true );
		if ( nodeDiffData ) {
			// Diff was handled e.g. by leaf/list/doc differ
			ve.batchSplice( nodeData, nodeRangeStart, newNode.getOuterLength(), nodeDiffData );
			// TODO: Check if there were actually changes in the sub-diff
		} else {
			if ( info.linearDiff ) {
				// If there is a content change, splice it in
				nodeDiffData = info.linearDiff;
				const annotatedData = this.annotateNode( nodeDiffData, newNode );
				ve.batchSplice( nodeData, nodeRangeStart + 1, newNode.getLength(), annotatedData );
			}
			if ( info.attributeChange ) {
				// If there is no content change, just add change class
				this.addAttributesToElement(
					nodeData, nodeRangeStart, { 'data-diff-action': 'structural-change' }
				);
				const item = this.compareNodeAttributes( nodeData, nodeRangeStart, info.attributeChange );
				if ( item ) {
					this.descriptionItemsStack.push( item );
				}
			}
		}
	};

	// Iterate backwards over trees so that changes are made from right to left
	// of the data, to avoid having to update ranges
	const len = Math.max( oldNodes.length, newNodes.length );

	for ( let i = 0, j = 0; i < len && j < len; i++, j++ ) {
		const newIndex = newNodes.length - 1 - i;
		const oldIndex = oldNodes.length - 1 - j;

		if ( newIndex < 0 ) {
			// The rest of the nodes have been removed
			highlightRemovedNode( oldIndex );

		} else if ( oldIndex < 0 ) {
			// The rest of the nodes have been inserted
			highlightInsertedNode( newIndex );

		} else if ( correspondingNodes.newToOld[ newIndex ] === oldIndex ) {
			// The new node was changed.
			for ( let k = 0, klen = treeDiff.length; k < klen; k++ ) {
				if ( treeDiff[ k ][ 0 ] === oldIndex && treeDiff[ k ][ 1 ] === newIndex ) {
					if ( !diffInfo[ k ] ) {
						// We are treating these nodes as removed and inserted
						highlightInsertedNode( newIndex );
						highlightRemovedNode( oldIndex );
					} else {
						// There could be any combination of content, attribute and type changes
						highlightChangedNode( oldIndex, newIndex, diffInfo[ k ] );
					}
				}
			}

		} else if ( correspondingNodes.newToOld[ newIndex ] === undefined ) {
			// The new node was inserted.
			highlightInsertedNode( newIndex );
			j--;

		} else if ( correspondingNodes.newToOld[ newIndex ] < oldIndex ) {
			// The old node was removed.
			highlightRemovedNode( oldIndex );
			i--;
		}
	}

	// Push new description items from the queue
	this.descriptions.addItems( this.descriptionItemsStack );
	this.descriptionItemsStack = [];

	return nodeData;
};

/**
 * Add the relevant attributes to references list node HTML, whether changed or
 * unchanged (but not inserted or removed - in these cases we just use
 * getNodeElements).
 *
 * @param {HTMLElement} referencesListContainer Div containing the references list
 * @param {string} action 'change' or 'none'
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @param {OO.ui.OptionWidget[]} items Change descriptions for the reference list
 * @return {HTMLElement[]} Elements to display
 */
ve.ui.DiffElement.prototype.getRefListNodeElements = function ( referencesListContainer, action, move, items ) {
	this.markMove( move, referencesListContainer );
	this.descriptionItemsStack.push.apply( this.descriptionItemsStack, items );

	return [ referencesListContainer ];
};

/**
 * Get the HTML for the diff of a single internal list item that has been removed
 * from the old document, inserted into the new document, or that is unchanged.
 *
 * @param {ve.dm.InternalItemNode} internalListItemNode Internal list item node
 * @param {string} action 'remove', 'insert' or 'none'
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @param {number} index
 * @return {HTMLElement[]} Elements (not owned by window.document)
 */
ve.ui.DiffElement.prototype.getInternalListNodeElements = function ( internalListItemNode, action, move, index ) {
	const contents = internalListItemNode.children,
		listItemNode = document.createElement( 'li' );

	if ( contents.length ) {
		contents.forEach( ( node ) => {
			const elements = this.getNodeElements( node, action, move );

			listItemNode.appendChild(
				listItemNode.ownerDocument.adoptNode( elements[ 0 ] )
			);
		} );
	} else {
		// TODO: This is MW-Cite-specific behaviour that VE core
		// should know nothing about. Move to MWDiffElement?
		$( listItemNode ).append(
			$( '<span>' )
				.addClass( 've-ce-mwReferencesListNode-muted' )
				.text( ve.msg( 'cite-ve-referenceslist-missingref-in-list' ) )
		).attr( 'data-diff-action', action );
	}
	listItemNode.setAttribute( 'value', index + 1 );

	return [ listItemNode ];
};

/**
 * Get the HTML for the linear diff of a single internal list item that has changed
 * from the old document to the new document.
 *
 * @param {Object} diff List item diff
 * @param {ve.dm.InternalItemNode} oldNode
 * @param {ve.dm.InternalItemNode} newNode
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @param {number} newIndex
 * @return {HTMLElement[]} HTML elements to display the linear diff
 */
ve.ui.DiffElement.prototype.getInternalListChangedNodeElements = function ( diff, oldNode, newNode, move, newIndex ) {
	const listItemNode = document.createElement( 'li' );
	let data = this.getChangedDocListData( newNode, diff, true );

	// Remove internal list wrapper
	data = data.slice( 1, data.length - 2 );

	this.markMove( move, listItemNode );
	const newDoc = newNode.getRoot().getDocument();
	const documentSlice = newDoc.cloneWithData( data, true, true );
	const body = ve.dm.converter.getDomFromModel( documentSlice, ve.dm.Converter.static.PREVIEW_MODE ).body;
	while ( body.childNodes.length ) {
		listItemNode.appendChild(
			listItemNode.ownerDocument.adoptNode( body.childNodes[ 0 ] )
		);
	}

	listItemNode.setAttribute( 'value', newIndex + 1 );

	return [ listItemNode ];
};

/**
 * Compare attributes of two nodes
 *
 * @param {Array} data Linear data containing new node
 * @param {number} offset Offset in data
 * @param {Object} attributeChange Attribute change object containing oldAttributes and newAttributes
 * @return {OO.ui.OptionWidget|null} Change description item, or null if nothing to describe
 */
ve.ui.DiffElement.prototype.compareNodeAttributes = function ( data, offset, attributeChange ) {
	const attributeChanges = this.constructor.static.compareAttributes( attributeChange.oldAttributes, attributeChange.newAttributes );

	const changes = ve.dm.modelRegistry.lookup( data[ offset ].type ).static.describeChanges( attributeChanges, attributeChange.newAttributes, data[ offset ] );

	// Don't describe the same change twice
	if ( changes.length && (
		!( data[ offset ].internal ) ||
		!( data[ offset ].internal.diff ) ||
		data[ offset ].internal.diff[ 'data-diff-id' ] === undefined )
	) {
		const item = this.getChangeDescriptionItem( changes );
		this.addAttributesToElement( data, offset, { 'data-diff-id': item.getData() } );
		return item;
	}

	return null;
};

/**
 * Get a change description item from a set of changes
 *
 * @param {Array} changes List of changes, each change being either text or a Node array
 * @param {string[]} classes Additional classes
 * @return {OO.ui.OptionWidget} Change description item
 */
ve.ui.DiffElement.prototype.getChangeDescriptionItem = function ( changes, classes ) {
	const elementId = this.elementId;
	let $label = $( [] );

	for ( let i = 0, l = changes.length; i < l; i++ ) {
		const $change = $( '<div>' );
		if ( typeof changes[ i ] === 'string' ) {
			$change.text( changes[ i ] );
		} else {
			// changes[ i ] is definitely not an HTML string in this branch
			// eslint-disable-next-line no-jquery/no-append-html
			$change.append( changes[ i ] );
		}
		$label = $label.add( $change );
	}
	// eslint-disable-next-line mediawiki/class-doc
	const item = new OO.ui.OptionWidget( {
		label: $label,
		data: elementId,
		classes: [ 've-ui-diffElement-attributeChange', ...classes || [] ]
	} );
	this.elementId++;
	return item;
};

/**
 * Mark an element with attributes to be added later by the converter.
 *
 * @param {Array} data Data containing element to be marked
 * @param {number} offset Offset of element to be marked
 * @param {Object} attributes Attributes to set
 */
ve.ui.DiffElement.prototype.addAttributesToElement = function ( data, offset, attributes ) {
	const newElement = ve.copy( data[ offset ] );

	// NB we modify the linear data here, but then this is a cloned document.
	for ( const key in attributes ) {
		if ( attributes[ key ] !== undefined ) {
			ve.setProp( newElement, 'internal', 'diff', key, attributes[ key ] );
		}
	}

	// Don't let any nodes get unwrapped
	ve.deleteProp( newElement, 'internal', 'generated' );

	data.splice( offset, 1, newElement );
};

/**
 * Mark an HTML element or data element as moved
 *
 * @param {string|null} move 'up' or 'down' if the node has moved
 * @param {HTMLElement|Array} elementOrData Linear data or HTMLElement
 * @param {number} [offset=0] Linear mode offset
 */
ve.ui.DiffElement.prototype.markMove = function ( move, elementOrData, offset ) {
	if ( !move ) {
		return;
	}
	// The following messages are used here:
	// * visualeditor-diff-moved-up
	// * visualeditor-diff-moved-down
	// The following classes are used here:
	// * ve-ui-diffElement-moved-up
	// * ve-ui-diffElement-moved-down
	const item = this.getChangeDescriptionItem( [ ve.msg( 'visualeditor-diff-moved-' + move ) ], [ 've-ui-diffElement-moved-' + move ] );
	if ( Array.isArray( elementOrData ) ) {
		this.addAttributesToElement( elementOrData, offset || 0, { 'data-diff-move': move, 'data-diff-id': item.getData() } );
	} else {
		elementOrData.setAttribute( 'data-diff-move', move );
		elementOrData.setAttribute( 'data-diff-id', item.getData() );
	}
	this.descriptionItemsStack.push( item );
};

/**
 * Annotate some data to highlight diff
 *
 * @param {Array} linearDiff Linear diff, mapping arrays of linear data to diff
 *  actions (remove, insert or retain)
 * @param {ve.dm.Node} newNode Node from the new document
 * @return {Array} Data with annotations added
 */
ve.ui.DiffElement.prototype.annotateNode = function ( linearDiff, newNode ) {
	const DIFF_DELETE = ve.DiffMatchPatch.static.DIFF_DELETE,
		DIFF_INSERT = ve.DiffMatchPatch.static.DIFF_INSERT,
		DIFF_CHANGE_DELETE = ve.DiffMatchPatch.static.DIFF_CHANGE_DELETE,
		DIFF_CHANGE_INSERT = ve.DiffMatchPatch.static.DIFF_CHANGE_INSERT,
		items = [],
		newDoc = newNode.getRoot().getDocument();
	let start = 0; // The starting index for a range for building an annotation

	// Make a new document from the diff
	const diffDocData = linearDiff[ 0 ][ 1 ].slice();
	const ilen = linearDiff.length;
	for ( let i = 1; i < ilen; i++ ) {
		ve.batchPush( diffDocData, linearDiff[ i ][ 1 ] );
	}
	const diffDoc = newDoc.cloneWithData( diffDocData );

	// Add spans with the appropriate attributes for removes and inserts
	// TODO: do insert and remove outside of loop
	for ( let i = 0; i < ilen; i++ ) {
		const end = start + linearDiff[ i ][ 1 ].length;
		if ( start !== end ) {
			const range = new ve.Range( start, end );
			const type = linearDiff[ i ][ 0 ];
			if ( type !== 0 ) {
				let typeAsString, domElementType, annType;
				switch ( type ) {
					case DIFF_DELETE:
						typeAsString = 'remove';
						domElementType = 'del';
						annType = 'textStyle/delete';
						break;
					case DIFF_INSERT:
						typeAsString = 'insert';
						domElementType = 'ins';
						annType = 'textStyle/insert';
						break;
					case DIFF_CHANGE_DELETE:
						typeAsString = 'change-remove';
						domElementType = 'span';
						annType = 'textStyle/span';
						break;
					case DIFF_CHANGE_INSERT:
						typeAsString = 'change-insert';
						domElementType = 'span';
						annType = 'textStyle/span';
						break;
				}
				const domElement = document.createElement( domElementType );
				domElement.setAttribute( 'data-diff-action', typeAsString );
				const domElements = [ domElement ];

				const changes = [];
				if ( linearDiff[ i ].annotationChanges ) {
					linearDiff[ i ].annotationChanges.forEach( ( annotationChange ) => {
						let attributeChanges;
						if ( annotationChange.oldAnnotation && annotationChange.newAnnotation ) {
							attributeChanges = this.constructor.static.compareAttributes(
								annotationChange.oldAnnotation.getAttributes(),
								annotationChange.newAnnotation.getAttributes()
							);
							ve.batchPush( changes, ve.dm.modelRegistry.lookup( annotationChange.newAnnotation.getType() ).static.describeChanges(
								attributeChanges, annotationChange.newAnnotation.getAttributes(), annotationChange.newAnnotation.getElement()
							) );
						} else if ( annotationChange.newAnnotation ) {
							ve.batchPush( changes, annotationChange.newAnnotation.describeAdded() );
						} else if ( annotationChange.oldAnnotation ) {
							ve.batchPush( changes, annotationChange.oldAnnotation.describeRemoved() );
						}
					} );
				}
				if ( linearDiff[ i ].attributeChanges ) {
					const element = linearDiff[ i ][ 1 ][ 0 ];
					linearDiff[ i ].attributeChanges.forEach( ( attributeChange ) => {
						const attributeChanges = this.constructor.static.compareAttributes(
							attributeChange.oldAttributes,
							attributeChange.newAttributes
						);
						ve.batchPush( changes, ve.dm.modelRegistry.lookup( element.type ).static.describeChanges(
							attributeChanges, element.attributes, element
						) );
					} );
				}
				if ( changes.length ) {
					const item = this.getChangeDescriptionItem( changes );
					domElement.setAttribute( 'data-diff-id', item.getData() );
					items.push( item );
				}

				const originalDomElementsHash = diffDoc.getStore().hash(
					domElements,
					domElements.map( ve.getNodeHtml ).join( '' )
				);
				const annHash = diffDoc.getStore().hash(
					ve.dm.annotationFactory.create( annType, {
						type: annType,
						originalDomElementsHash: originalDomElementsHash
					} )
				);

				// Insert annotation above annotations that span the entire range
				// and at least one character more
				const annHashLists = [];
				for (
					let j = Math.max( 0, range.start - 1 );
					j < Math.min( range.end + 1, diffDoc.data.getLength() );
					j++
				) {
					annHashLists[ j ] =
						diffDoc.data.getAnnotationHashesFromOffset( j );
				}
				const height = Math.min(
					ve.getCommonStartSequenceLength(
						annHashLists.slice(
							Math.max( 0, range.start - 1 ),
							range.end
						)
					),
					ve.getCommonStartSequenceLength(
						annHashLists.slice(
							range.start,
							Math.min( range.end + 1, diffDoc.data.getLength() )
						)
					)
				);
				for ( let j = range.start; j < range.end; j++ ) {
					annHashLists[ j ].splice( height, 0, annHash );
					diffDoc.data.setAnnotationHashesAtOffset(
						j,
						annHashLists[ j ]
					);
				}
			}
		}
		start = end;
	}
	this.descriptionItemsStack.push.apply( this.descriptionItemsStack, items );

	// Merge the stores and get the data
	newDoc.getStore().merge( diffDoc.getStore() );
	const annotatedLinearDiff = diffDoc.getData( { start: 0, end: diffDoc.getLength() } );

	return annotatedLinearDiff;
};

ve.ui.metaListDiffRegistry = new OO.Registry();