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

/**
 * DataModel surface for a node within a document
 *
 * Methods do not check that ranges actually lie inside the surfaced node
 *
 * @class
 * @mixes OO.EventEmitter
 *
 * @constructor
 * @param {ve.dm.Document} doc Document model to create surface for
 * @param {ve.dm.BranchNode} [attachedRoot] Branch node which is editable; default is document node
 * @param {Object} [config] Configuration options
 * @param {boolean} [config.sourceMode=false] Source editing mode
 */
ve.dm.Surface = function VeDmSurface( doc, attachedRoot, config ) {
	// Support old (doc, config) argument order
	// TODO: Remove this once all callers are updated
	if ( !config && ve.isPlainObject( attachedRoot ) ) {
		config = attachedRoot;
		attachedRoot = undefined;
	}

	config = config || {};

	attachedRoot = attachedRoot || doc.getDocumentNode();
	if ( !( attachedRoot instanceof ve.dm.BranchNode ) ) {
		throw new Error( 'Expected ve.dm.BranchNode for attachedRoot' );
	}
	doc.setAttachedRoot( attachedRoot );

	// Mixin constructors
	OO.EventEmitter.call( this );

	// Properties
	this.documentModel = doc;
	// Deprecated: Use getDocument().getAttachedRoot()
	this.attachedRoot = attachedRoot;
	this.sourceMode = !!config.sourceMode;
	this.selection = new ve.dm.NullSelection();
	// The selection before the most recent stack of changes was applied
	this.selectionBefore = this.selection;
	this.translatedSelection = null;
	this.branchNodes = {};
	this.selectedNode = null;
	this.newTransactions = [];
	this.stagingStack = [];
	this.undoStack = [];
	this.undoIndex = 0;
	this.undoConflict = false;
	this.historyTrackingInterval = null;
	this.insertionAnnotations = new ve.dm.AnnotationSet( this.getDocument().getStore() );
	this.selectedAnnotations = new ve.dm.AnnotationSet( this.getDocument().getStore() );
	this.isCollapsed = null;
	this.multiUser = false;
	this.readOnly = false;
	this.transacting = false;
	this.queueingContextChanges = false;
	this.contextChangeQueued = false;
	this.authorId = null;
	this.lastStoredChange = doc.getCompleteHistoryLength();
	this.autosaveFailed = false;
	this.autosavePrefix = '';
	this.synchronizer = null;
	this.storing = false;
	this.setStorage( ve.init.platform.sessionStorage );

	// Events
	this.getDocument().connect( this, {
		transact: 'onDocumentTransact',
		precommit: 'onDocumentPreCommit'
	} );
	this.storeChangesListener = this.storeChanges.bind( this );
	this.storeDocStorageListener = this.storeDocStorage.bind( this );
};

/* Inheritance */

OO.mixinClass( ve.dm.Surface, OO.EventEmitter );

/* Events */

/**
 * @event ve.dm.Surface#select
 * @param {ve.dm.Selection} selection
 */

/**
 * The selection was just set to a non-null selection
 *
 * @event ve.dm.Surface#focus
 */

/**
 * The selection was just set to a null selection
 *
 * @event ve.dm.Surface#blur
 */

/**
 * Emitted when a transaction has been processed on the document and the selection has been
 * translated to account for that transaction. You should only use this event if you need
 * to access the selection; in most cases, you should use {ve.dm.Document#event-transact}.
 *
 * @event ve.dm.Surface#documentUpdate
 * @param {ve.dm.Transaction} tx Transaction that was processed on the document
 */

/**
 * @event ve.dm.Surface#contextChange
 */

/**
 * @event ve.dm.Surface#insertionAnnotationsChange
 * @param {ve.dm.AnnotationSet} insertionAnnotations AnnotationSet being inserted
 */

/**
 * Emitted when the history stacks change, or the ability to use them changes.
 *
 * @event ve.dm.Surface#history
 */

/**
 * Emitted when the main undo stack changes (this.undoStack or this.undoIndex).
 *
 * @event ve.dm.Surface#undoStackChange
 */

/**
 * Auto-save failed to store a change
 *
 * @event ve.dm.Surface#autosaveFailed
 */

/* Methods */

/**
 * Set the read-only state of the surface
 *
 * @param {boolean} readOnly Make surface read-only
 */
ve.dm.Surface.prototype.setReadOnly = function ( readOnly ) {
	if ( !!readOnly !== this.readOnly ) {
		this.readOnly = !!readOnly;
		if ( readOnly ) {
			this.stopHistoryTracking();
		} else {
			this.startHistoryTracking();
		}
		this.getDocument().setReadOnly( readOnly );
		this.emit( 'contextChange' );
	}
};

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

/**
 * Initialize the surface model
 *
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.initialize = function () {
	this.startHistoryTracking();
	this.emit( 'contextChange' );
};

/**
 * Get the DOM representation of the surface's current state.
 *
 * @return {HTMLDocument|string} HTML document (visual mode) or text (source mode)
 */
ve.dm.Surface.prototype.getDom = function () {
	if ( this.sourceMode ) {
		return ve.dm.sourceConverter.getSourceTextFromModel( this.getDocument() );
	} else {
		return ve.dm.converter.getDomFromModel( this.getDocument() );
	}
};

/**
 * Get the HTML representation of the surface's current state.
 *
 * @return {string} HTML
 */
ve.dm.Surface.prototype.getHtml = function () {
	return this.sourceMode ?
		this.getDom() :
		ve.properInnerHtml( this.getDom().body );
};

/**
 * Set the surface multi-user mode
 *
 * @param {boolean} multiUser Multi-user mode
 */
ve.dm.Surface.prototype.setMultiUser = function ( multiUser ) {
	this.multiUser = multiUser;
};

/**
 * Check if the surface is in multi-user mode
 *
 * @return {boolean} Surface is in multi-user mode
 */
ve.dm.Surface.prototype.isMultiUser = function () {
	return this.multiUser;
};

/**
 * Create a surface synchronizer.
 *
 * Must be created before the surface model is added to a view.
 *
 * @param {string} documentId Document ID
 * @param {Object} [config] Configuration options
 */
ve.dm.Surface.prototype.createSynchronizer = function ( documentId, config ) {
	if ( this.synchronizer ) {
		throw new Error( 'Synchronizer already set' );
	}

	this.setNullSelection();
	this.setMultiUser( true );

	this.synchronizer = new ve.dm.SurfaceSynchronizer( this, documentId, config );
};

/**
 * Start tracking state changes in history.
 */
ve.dm.Surface.prototype.startHistoryTracking = function () {
	if ( this.readOnly ) {
		return;
	}
	if ( this.historyTrackingInterval === null ) {
		this.historyTrackingInterval = setInterval( this.breakpoint.bind( this ), 3000 );
	}
};

/**
 * Stop tracking state changes in history.
 */
ve.dm.Surface.prototype.stopHistoryTracking = function () {
	if ( this.readOnly ) {
		return;
	}
	if ( this.historyTrackingInterval !== null ) {
		clearInterval( this.historyTrackingInterval );
		this.historyTrackingInterval = null;
	}
};

/**
 * Reset the timer for automatic history-tracking
 */
ve.dm.Surface.prototype.resetHistoryTrackingInterval = function () {
	this.stopHistoryTracking();
	this.startHistoryTracking();
};

/**
 * @typedef {Object} UndoStackItem
 * @memberof ve.dm.Surface
 * @property {number} start
 * @property {ve.dm.Transaction[]} transactions
 * @property {ve.dm.Selection} selection
 * @property {ve.dm.Selection} selectionBefore
 */

/**
 * Get a list of all applied history states.
 *
 * @return {ve.dm.Surface.UndoStackItem[]} List of applied transaction stacks
 */
ve.dm.Surface.prototype.getHistory = function () {
	const appliedUndoStack = this.undoStack.slice( 0, this.undoStack.length - this.undoIndex );
	if ( this.newTransactions.length > 0 ) {
		appliedUndoStack.push( { transactions: this.newTransactions.slice( 0 ) } );
	}
	return appliedUndoStack;
};

/**
 * If the surface in staging mode.
 *
 * @return {boolean} The surface in staging mode
 */
ve.dm.Surface.prototype.isStaging = function () {
	return this.stagingStack.length > 0;
};

/**
 * @typedef {Object} StagingState
 * @memberof ve.dm.Surface
 * @property {ve.dm.Transaction[]} transactions Staging transactions
 * @property {ve.dm.Selection} selectionBefore Selection before transactions were applied
 * @property {boolean} allowUndo Allow undo while staging
 */

/**
 * Get the staging state at the current staging stack depth
 *
 * @return {ve.dm.Surface.StagingState|undefined} staging Staging state object, or undefined if not staging
 */
ve.dm.Surface.prototype.getStaging = function () {
	return this.stagingStack[ this.stagingStack.length - 1 ];
};

/**
 * Undo is allowed at the current staging stack depth
 *
 * @return {boolean|undefined} Undo is allowed, or undefined if not staging
 */
ve.dm.Surface.prototype.doesStagingAllowUndo = function () {
	const staging = this.getStaging();
	return staging && staging.allowUndo;
};

/**
 * Get the staging transactions at the current staging stack depth
 *
 * The array is returned by reference so it can be pushed to.
 *
 * @return {ve.dm.Transaction[]|undefined} Staging transactions, or undefined if not staging
 */
ve.dm.Surface.prototype.getStagingTransactions = function () {
	const staging = this.getStaging();
	return staging && staging.transactions;
};

/**
 * Push another level of staging to the staging stack
 *
 * @param {boolean} [allowUndo=false] Allow undo while staging
 * @fires ve.dm.Surface#history
 */
ve.dm.Surface.prototype.pushStaging = function ( allowUndo ) {
	// If we're starting staging stop history tracking
	if ( !this.isStaging() ) {
		if ( this.synchronizer ) {
			this.synchronizer.pauseChanges();
		}
		// Set a breakpoint to make sure newTransactions is clear
		this.breakpoint();
		this.stopHistoryTracking();
		this.emit( 'history' );
	}
	this.stagingStack.push( {
		transactions: [],
		// Will get overridden after the first transaction, but while the
		// stack is empty, should be equal to the previous selectionBefore.
		selectionBefore: this.isStaging() ? this.getStaging().selectionBefore : this.selectionBefore,
		allowUndo: !!allowUndo
	} );
};

/**
 * Pop a level of staging from the staging stack
 *
 * @fires ve.dm.Surface#history
 * @return {ve.dm.Transaction[]|undefined} Staging transactions, or undefined if not staging
 */
ve.dm.Surface.prototype.popStaging = function () {
	if ( !this.isStaging() ) {
		return;
	}

	const staging = this.stagingStack.pop();
	const transactions = staging.transactions;
	const reverseTransactions = [];

	// Not applying, so rollback transactions
	for ( let i = transactions.length - 1; i >= 0; i-- ) {
		const transaction = transactions[ i ].reversed();
		reverseTransactions.push( transaction );
	}
	this.changeInternal( reverseTransactions, staging.selectionBefore, true );

	if ( !this.isStaging() ) {
		if ( this.synchronizer ) {
			this.synchronizer.resumeChanges();
		}
		this.startHistoryTracking();
		this.emit( 'history' );
	}

	return transactions;
};

/**
 * Apply a level of staging from the staging stack
 *
 * @fires ve.dm.Surface#history
 */
ve.dm.Surface.prototype.applyStaging = function () {
	if ( !this.isStaging() ) {
		return;
	}

	const staging = this.stagingStack.pop();

	if ( this.isStaging() ) {
		// Merge popped transactions into the current item in the staging stack
		ve.batchPush( this.getStagingTransactions(), staging.transactions );
		// If the current level has a null selectionBefore, copy that over too
		if ( this.getStaging().selectionBefore.isNull() ) {
			this.getStaging().selectionBefore = staging.selectionBefore;
		}
	} else {
		this.truncateUndoStack();
		// Move transactions to the undo stack
		this.newTransactions = staging.transactions;
		this.selectionBefore = staging.selectionBefore;
		this.breakpoint();
	}

	if ( !this.isStaging() ) {
		if ( this.synchronizer ) {
			this.synchronizer.resumeChanges();
		}
		this.startHistoryTracking();
		this.emit( 'history' );
	}
};

/**
 * Pop the staging stack until empty
 *
 * @return {ve.dm.Transaction[]|undefined} Staging transactions, or undefined if not staging
 */
ve.dm.Surface.prototype.popAllStaging = function () {
	if ( !this.isStaging() ) {
		return;
	}

	const transactions = [];
	while ( this.isStaging() ) {
		ve.batchSplice( transactions, 0, 0, this.popStaging() );
	}
	return transactions;
};

/**
 * Apply the staging stack until empty
 */
ve.dm.Surface.prototype.applyAllStaging = function () {
	while ( this.isStaging() ) {
		this.applyStaging();
	}
};

/**
 * Get annotations that will be used upon insertion.
 *
 * @return {ve.dm.AnnotationSet} Insertion annotations
 */
ve.dm.Surface.prototype.getInsertionAnnotations = function () {
	return this.insertionAnnotations.clone();
};

/**
 * Set annotations that will be used upon insertion.
 *
 * @param {ve.dm.AnnotationSet|null} annotations Insertion annotations to use or null to disable them
 * @fires ve.dm.Surface#insertionAnnotationsChange
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.setInsertionAnnotations = function ( annotations ) {
	if ( this.readOnly ) {
		return;
	}
	this.insertionAnnotations = annotations !== null ?
		annotations.clone() :
		new ve.dm.AnnotationSet( this.getDocument().getStore() );

	this.emit( 'insertionAnnotationsChange', this.insertionAnnotations );
	this.emit( 'contextChange' );
};

/**
 * Add an annotation to be used upon insertion.
 *
 * @param {ve.dm.Annotation|ve.dm.AnnotationSet} annotations Insertion annotation to add
 * @fires ve.dm.Surface#insertionAnnotationsChange
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.addInsertionAnnotations = function ( annotations ) {
	if ( this.readOnly ) {
		return;
	}
	if ( annotations instanceof ve.dm.Annotation ) {
		this.insertionAnnotations.push( annotations );
	} else if ( annotations instanceof ve.dm.AnnotationSet ) {
		this.insertionAnnotations.addSet( annotations );
	} else {
		throw new Error( 'Invalid annotations' );
	}

	this.emit( 'insertionAnnotationsChange', this.insertionAnnotations );
	this.emit( 'contextChange' );
};

/**
 * Remove an annotation from those that will be used upon insertion.
 *
 * @param {ve.dm.Annotation|ve.dm.AnnotationSet} annotations Insertion annotation to remove
 * @fires ve.dm.Surface#insertionAnnotationsChange
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.removeInsertionAnnotations = function ( annotations ) {
	if ( this.readOnly ) {
		return;
	}
	if ( annotations instanceof ve.dm.Annotation ) {
		this.insertionAnnotations.remove( annotations );
	} else if ( annotations instanceof ve.dm.AnnotationSet ) {
		this.insertionAnnotations.removeSet( annotations );
	} else {
		throw new Error( 'Invalid annotations' );
	}

	this.emit( 'insertionAnnotationsChange', this.insertionAnnotations );
	this.emit( 'contextChange' );
};

/**
 * Check if redo is allowed in the current state.
 *
 * @return {boolean} Redo is allowed
 */
ve.dm.Surface.prototype.canRedo = function () {
	return this.undoIndex > 0 && !this.readOnly;
};

/**
 * Check if undo is allowed in the current state.
 *
 * @return {boolean} Undo is allowed
 */
ve.dm.Surface.prototype.canUndo = function () {
	return this.hasBeenModified() && !this.readOnly && ( !this.isStaging() || this.doesStagingAllowUndo() ) && !this.undoConflict;
};

/**
 * Check if the surface has been modified.
 *
 * This only checks if there are transactions which haven't been undone.
 *
 * @return {boolean} The surface has been modified
 */
ve.dm.Surface.prototype.hasBeenModified = function () {
	return this.undoStack.length - this.undoIndex > 0 || !!this.newTransactions.length;
};

/**
 * Get the document model.
 *
 * @return {ve.dm.Document} Document model of the surface
 */
ve.dm.Surface.prototype.getDocument = function () {
	return this.documentModel;
};

/**
 * Get the surfaced node
 *
 * @deprecated Use getDocument().getAttachedRoot()
 * @return {ve.dm.BranchNode} The surfaced node
 */
ve.dm.Surface.prototype.getAttachedRoot = function () {
	OO.ui.warnDeprecation( 've.dm.Surface.getAttachedRoot() is deprecated. Use ve.dm.Surface.getDocument().getAttachedRoot() instead.' );
	return this.attachedRoot;
};

/**
 * Get the selection.
 *
 * @return {ve.dm.Selection} Current selection
 */
ve.dm.Surface.prototype.getSelection = function () {
	return this.selection;
};

/**
 * Get the selection translated for the transaction that's being committed, if any.
 *
 * @return {ve.dm.Selection} Current selection translated for new transaction
 */
ve.dm.Surface.prototype.getTranslatedSelection = function () {
	return this.translatedSelection || this.selection;
};

/**
 * Get a fragment for a selection.
 *
 * @param {ve.dm.Selection} [selection] Selection within target document, current selection used by default
 * @param {boolean} [noAutoSelect] Don't update the surface's selection when making changes
 * @param {boolean} [excludeInsertions] Exclude inserted content at the boundaries when updating range
 * @return {ve.dm.SurfaceFragment} Surface fragment
 */
ve.dm.Surface.prototype.getFragment = function ( selection, noAutoSelect, excludeInsertions ) {
	selection = selection || this.selection;
	// TODO: Use a factory pattern to generate fragments
	return this.sourceMode ?
		new ve.dm.SourceSurfaceFragment( this, selection, noAutoSelect, excludeInsertions ) :
		new ve.dm.SurfaceFragment( this, selection, noAutoSelect, excludeInsertions );
};

/**
 * Get a fragment for a linear selection's range.
 *
 * @param {ve.Range} range Selection's range
 * @param {boolean} [noAutoSelect] Don't update the surface's selection when making changes
 * @param {boolean} [excludeInsertions] Exclude inserted content at the boundaries when updating range
 * @return {ve.dm.SurfaceFragment} Surface fragment
 */
ve.dm.Surface.prototype.getLinearFragment = function ( range, noAutoSelect, excludeInsertions ) {
	return this.getFragment( new ve.dm.LinearSelection( range ), noAutoSelect, excludeInsertions );
};

/**
 * Prevent future states from being redone.
 *
 * Callers should eventually emit a 'history' event after using this method.
 *
 * @fires ve.dm.Surface#undoStackChange
 */
ve.dm.Surface.prototype.truncateUndoStack = function () {
	if ( this.undoIndex ) {
		this.undoStack = this.undoStack.slice( 0, this.undoStack.length - this.undoIndex );
		this.undoIndex = 0;
		this.emit( 'undoStackChange' );
	}
};

/**
 * Start queueing up calls to #emitContextChange until #stopQueueingContextChanges is called.
 * While queueing is active, contextChanges are also collapsed, so if #emitContextChange is called
 * multiple times, only one contextChange event will be emitted by #stopQueueingContextChanges.
 *
 *     this.emitContextChange(); // emits immediately
 *     this.startQueueingContextChanges();
 *     this.emitContextChange(); // doesn't emit
 *     this.emitContextChange(); // doesn't emit
 *     this.stopQueueingContextChanges(); // emits one contextChange event
 *
 * @private
 */
ve.dm.Surface.prototype.startQueueingContextChanges = function () {
	if ( !this.queueingContextChanges ) {
		this.queueingContextChanges = true;
		this.contextChangeQueued = false;
	}
};

/**
 * Emit a contextChange event. If #startQueueingContextChanges has been called, then the event
 * is deferred until #stopQueueingContextChanges is called.
 *
 * @private
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.emitContextChange = function () {
	if ( this.queueingContextChanges ) {
		this.contextChangeQueued = true;
	} else {
		this.emit( 'contextChange' );
	}
};

/**
 * Stop queueing contextChange events. If #emitContextChange was called previously, a contextChange
 * event will now be emitted. Any future calls to #emitContextChange will once again emit the
 * event immediately.
 *
 * @private
 * @fires ve.dm.Surface#contextChange
 */
ve.dm.Surface.prototype.stopQueueingContextChanges = function () {
	if ( this.queueingContextChanges ) {
		this.queueingContextChanges = false;
		if ( this.contextChangeQueued ) {
			this.contextChangeQueued = false;
			this.emit( 'contextChange' );
		}
	}
};

/**
 * Set a linear selection at a specified range on the model
 *
 * @param {ve.Range} range Range to create linear selection at
 */
ve.dm.Surface.prototype.setLinearSelection = function ( range ) {
	this.setSelection( new ve.dm.LinearSelection( range ) );
};

/**
 * Set a null selection on the model
 */
ve.dm.Surface.prototype.setNullSelection = function () {
	this.setSelection( new ve.dm.NullSelection() );
};

/**
 * Grows a range so that any partially selected links are totally selected
 *
 * @param {ve.Range} range The range to regularize
 * @return {ve.Range} Regularized range, possibly object-identical to the original
 */
ve.dm.Surface.prototype.fixupRangeForLinks = function ( range ) {
	if ( range.isCollapsed() ) {
		return range;
	}

	const linearData = this.getDocument().data;

	function getLinks( offset ) {
		return linearData.getAnnotationsFromOffset( offset ).filter( ( ann ) => ann.name === 'link' );
	}

	// Search for links at start/end that don't cover the whole range.
	// Assume at most one such link at each end.
	let start = range.start;
	let end = range.end;
	const rangeAnnotations = linearData.getAnnotationsFromRange( range );
	const startLink = getLinks( start ).diffWith( rangeAnnotations ).getHash( 0 );
	const endLink = getLinks( end ).diffWith( rangeAnnotations ).getHash( 0 );

	if ( startLink === undefined && endLink === undefined ) {
		return range;
	}

	if ( startLink !== undefined ) {
		while ( start > 0 && getLinks( start - 1 ).containsHash( startLink ) ) {
			start--;
		}
	}
	if ( endLink !== undefined ) {
		while ( end < linearData.getLength() && getLinks( end ).containsHash( endLink ) ) {
			end++;
		}
	}

	if ( range.isBackwards() ) {
		return new ve.Range( end, start );
	} else {
		return new ve.Range( start, end );
	}
};

/**
 * Change the selection
 *
 * @param {ve.dm.Selection} selection New selection
 *
 * @fires ve.dm.Surface#select
 * @fires ve.dm.Surface#focus
 * @fires ve.dm.Surface#blur
 */
ve.dm.Surface.prototype.setSelection = function ( selection ) {
	const oldSelection = this.selection;
	let maxOffset;
	if (
		selection instanceof ve.dm.LinearSelection &&
		( maxOffset = this.getDocument().getDocumentRange().end ) &&
		maxOffset < selection.getRange().end
	) {
		// Selection is out of range
		ve.error( 'Attempted to set an out of bounds selection: ' + JSON.stringify( selection ) + ', adjusting' );
		// Fix up the selection so things don't break if the caller subsequently
		// tries to use the selection
		selection = new ve.dm.LinearSelection( new ve.Range(
			Math.min( maxOffset, selection.getRange().start ),
			maxOffset
		) );
		// TODO: Check table selections too
	}
	this.translatedSelection = null;

	if ( this.transacting ) {
		// Update the selection but don't do any processing
		this.selection = selection;
		return;
	}

	let selectionChange = false;
	// this.selection needs to be updated before we call setInsertionAnnotations
	if ( !oldSelection.equals( selection ) ) {
		selectionChange = true;
		this.selection = selection;
	}

	function intersectRanges( rangeA, rangeB ) {
		const rangeStart = Math.max( rangeA.start, rangeB.start );
		const rangeEnd = Math.min( rangeA.end, rangeB.end );

		return new ve.Range(
			rangeStart,
			// If rangeStart > rangeEnd there is no overlap, just return
			// a collapsed range at rangeStart (this shouldn't happen here)
			Math.max( rangeStart, rangeEnd )
		);
	}

	let range;
	let selectedNode;
	const branchNodes = {};
	let contextChange = false;
	if ( selection instanceof ve.dm.LinearSelection ) {
		range = selection.getRange();

		// Update branch nodes
		branchNodes.start = this.getDocument().getBranchNodeFromOffset( range.start );
		branchNodes.startRange = intersectRanges( branchNodes.start.getRange(), range );
		if ( !range.isCollapsed() ) {
			branchNodes.end = this.getDocument().getBranchNodeFromOffset( range.end );
			branchNodes.endRange = intersectRanges( branchNodes.end.getRange(), range );
		} else {
			branchNodes.end = branchNodes.start;
			branchNodes.endRange = branchNodes.startRange;
		}
		selectedNode = this.getSelectedNodeFromSelection( selection );

		// Source mode optimization
		if ( !this.sourceMode ) {
			const linearData = this.getDocument().data;
			// Reset insertionAnnotations based on the neighbouring document data
			const insertionAnnotations = linearData.getInsertionAnnotationsFromRange( range );
			// If there's *any* difference in insertion annotations (even order), then:
			// * emit insertionAnnotationsChange
			// * emit contextChange (TODO: is this desirable?)
			if ( !insertionAnnotations.equalsInOrder( this.insertionAnnotations ) ) {
				this.setInsertionAnnotations( insertionAnnotations );
			}

			let selectedAnnotations;
			// Reset selectedAnnotations
			if ( range.isCollapsed() ) {
				selectedAnnotations = linearData.getAnnotationsFromOffset( range.start );
			} else {
				selectedAnnotations = linearData.getAnnotationsFromRange( range, true );
			}
			if ( !selectedAnnotations.compareTo( this.selectedAnnotations ) ) {
				this.selectedAnnotations = selectedAnnotations;
				contextChange = true;
			}

			// Did the annotations at the focus point of a non-collapsed selection
			// change? (i.e. did the selection move in/out of an annotation as it
			// expanded?)
			if ( selectionChange && !range.isCollapsed() && oldSelection instanceof ve.dm.LinearSelection ) {
				const rangeFocus = new ve.Range( range.to );
				const oldRangeFocus = new ve.Range( oldSelection.getRange().to );
				const focusRangeMovingBack = rangeFocus.to < oldRangeFocus.to;
				// If we're moving back in the document, getInsertionAnnotationsFromRange
				// needs to be told to fetch the annotations after the cursor, otherwise
				// it'll trigger one position too soon.
				if (
					!linearData.getInsertionAnnotationsFromRange( rangeFocus, focusRangeMovingBack ).compareTo( linearData.getInsertionAnnotationsFromRange( oldRangeFocus, focusRangeMovingBack ) )
				) {
					contextChange = true;
				}
			}
		}
	} else if ( selection instanceof ve.dm.TableSelection ) {
		selectedNode = selection.getMatrixCells( this.getDocument() )[ 0 ].node;
		contextChange = true;
	} else if ( selection.isNull() ) {
		contextChange = true;
	}

	if ( range && range.isCollapsed() !== this.isCollapsed ) {
		// selectedAnnotations won't have changed if going from insertion annotations to
		// selection of the same annotations, but some tools will consider that a context change
		// (e.g. ClearAnnotationTool).
		this.isCollapsed = range.isCollapsed();
		contextChange = true;
	}

	// If branchNodes or selectedNode changed emit a contextChange
	if (
		selectedNode !== this.selectedNode ||
		branchNodes.start !== this.branchNodes.start ||
		branchNodes.end !== this.branchNodes.end
	) {
		this.branchNodes = branchNodes;
		this.selectedNode = selectedNode;
		contextChange = true;
	} else if (
		branchNodes.startRange && this.branchNodes.startRange && (
			branchNodes.startRange.isCollapsed() !== this.branchNodes.startRange.isCollapsed() ||
			branchNodes.endRange.isCollapsed() !== this.branchNodes.endRange.isCollapsed()
		)
	) {
		// If the collapsed-ness of the selection within an edge node changes, the result of
		// ve.dm.Surface#getSelectedLeafNodes could change, so emit a contextChange
		this.branchNodes = branchNodes;
		contextChange = true;
	}

	// If selection changed emit a select
	if ( selectionChange ) {
		this.emit( 'select', this.selection );
		if ( oldSelection.isNull() ) {
			this.emit( 'focus' );
		}
		if ( selection.isNull() ) {
			this.emit( 'blur' );
		}
	}

	if ( contextChange ) {
		this.emitContextChange();
	}

};

/**
 * Apply a transactions and selection changes to the document.
 *
 * @param {ve.dm.Transaction|ve.dm.Transaction[]|null} transactions One or more transactions to
 *  process, or null to process none
 * @param {ve.dm.Selection} [selection] Selection to apply
 */
ve.dm.Surface.prototype.change = function ( transactions, selection ) {
	this.changeInternal( transactions, selection, false );
};

/**
 * Internal implementation of change(). Do not use this, use change() instead.
 *
 * @private
 * @param {ve.dm.Transaction|ve.dm.Transaction[]|null} transactions
 * @param {ve.dm.Selection} [selection]
 * @param {boolean} [skipUndoStack=false] If true, do not modify the undo stack. Used by undo/redo
 * @fires ve.dm.Surface#select
 * @fires ve.dm.Surface#history
 */
ve.dm.Surface.prototype.changeInternal = function ( transactions, selection, skipUndoStack ) {
	const selectionBefore = this.selection;
	let contextChange = false;

	this.startQueueingContextChanges();

	// Process transactions
	if ( transactions && !this.readOnly ) {
		if ( transactions instanceof ve.dm.Transaction ) {
			transactions = [ transactions ];
		}
		this.transacting = true;
		for ( let i = 0, len = transactions.length; i < len; i++ ) {
			if ( !transactions[ i ].isNoOp() ) {
				let committed;
				// The .commit() call below indirectly invokes setSelection()
				try {
					committed = false;
					this.getDocument().commit( transactions[ i ], this.isStaging() );
					committed = true;
				} finally {
					if ( !committed ) {
						this.stopQueueingContextChanges();
					}
				}
				if ( !skipUndoStack ) {
					if ( this.isStaging() ) {
						if ( !this.getStagingTransactions().length ) {
							this.getStaging().selectionBefore = selectionBefore;
						}
						this.getStagingTransactions().push( transactions[ i ] );
					} else {
						this.truncateUndoStack();
						if ( !this.newTransactions.length ) {
							this.selectionBefore = selectionBefore;
						}
						this.newTransactions.push( transactions[ i ] );
					}
				}
				if ( transactions[ i ].hasElementAttributeOperations() ) {
					contextChange = true;
				}
			}
		}
		this.transacting = false;
		this.undoConflict = false;
		this.emit( 'history' );
	}
	const selectionAfter = this.selection;

	// Apply selection change
	if ( selection ) {
		this.setSelection( selection );
	} else if ( transactions ) {
		// Call setSelection() to trigger selection processing that was bypassed earlier
		this.setSelection( this.selection );
	}

	// If the selection changed while applying the transactions but not while applying the
	// selection change, setSelection() won't have emitted a 'select' event. We don't want that
	// to happen, so emit one anyway.
	if (
		!selectionBefore.equals( selectionAfter ) &&
		selectionAfter.equals( this.selection )
	) {
		this.emit( 'select', this.selection );
	}

	if ( contextChange ) {
		this.emitContextChange();
	}

	this.stopQueueingContextChanges();
};

/**
 * Set a history state breakpoint.
 *
 * @return {boolean} A breakpoint was added
 * @fires ve.dm.Surface#undoStackChange
 */
ve.dm.Surface.prototype.breakpoint = function () {
	if ( this.readOnly ) {
		return false;
	}
	let breakpointSet = false;
	this.resetHistoryTrackingInterval();
	if ( this.newTransactions.length > 0 ) {
		this.undoStack.push( {
			start: this.getDocument().getCompleteHistoryLength() - this.newTransactions.length,
			transactions: this.newTransactions,
			selection: this.selection,
			selectionBefore: this.selectionBefore
		} );
		this.newTransactions = [];
		this.emit( 'undoStackChange' );
		breakpointSet = true;
	}
	// Update selectionBefore even if nothing has changed
	this.selectionBefore = this.selection;
	return breakpointSet;
};

/**
 * Step backwards in history.
 *
 * @fires ve.dm.Surface#undoStackChange
 * @fires ve.dm.Surface#history
 */
ve.dm.Surface.prototype.undo = function () {
	if ( !this.canUndo() ) {
		return;
	}

	if ( this.isStaging() ) {
		this.popAllStaging();
	}

	this.breakpoint();
	this.undoIndex++;

	const transactions = [];
	let item;
	if ( !this.isMultiUser() ) {
		item = this.undoStack[ this.undoStack.length - this.undoIndex ];
		if ( item ) {
			// Apply reversed transactions in reversed order
			for ( let i = item.transactions.length - 1; i >= 0; i-- ) {
				const transaction = item.transactions[ i ].reversed();
				transactions.push( transaction );
			}
			this.changeInternal( transactions, item.selectionBefore, true );
			this.emit( 'undoStackChange' );
		}
	} else {
		// Find the most recent stack item by this user
		while ( this.undoIndex <= this.undoStack.length ) {
			item = this.undoStack[ this.undoStack.length - this.undoIndex ];
			// Assume every transaction in the stack item has the same author (see ve.dm.Change#applyTo)
			const authorId = item.transactions[ 0 ].authorId;
			if ( authorId === null || authorId === this.getAuthorId() ) {
				break;
			}
			item = null;
			this.undoIndex++;
		}
		if ( item ) {
			const history = this.getDocument().getChangeSince( item.start + item.transactions.length );
			const done = new ve.dm.Change(
				item.start,
				item.transactions,
				// Undo cannot add store items, so we don't need to worry here
				item.transactions.map( () => new ve.dm.HashValueStore() ),
				{}
			);
			const result = ve.dm.Change.static.rebaseUncommittedChange( history, done.reversed() );
			if ( result.rejected ) {
				// Rebasing conflict: move pointer back and don't try again until next transaction
				this.undoIndex--;
				this.undoConflict = true;
				// Undo stack didn't change, but ability to undo did
				this.emit( 'history' );
			} else {
				const selection = item.selectionBefore.translateByChange( result.transposedHistory );
				// Undo cannot add store items, so we can safely apply just transactions
				this.changeInternal( result.rebased.transactions, selection, true );
				this.emit( 'undoStackChange' );
			}
		} else {
			// Undo stack didn't change, but ability to undo did
			this.emit( 'history' );
		}
	}
};

/**
 * Step forwards in history.
 *
 * @fires ve.dm.Surface#undoStackChange
 */
ve.dm.Surface.prototype.redo = function () {
	if ( !this.canRedo() ) {
		return;
	}

	this.breakpoint();

	const item = this.undoStack[ this.undoStack.length - this.undoIndex ];
	if ( item ) {
		this.undoIndex--;
		// ve.copy( item.transactions ) invokes .clone() on each transaction in item.transactions
		this.changeInternal( ve.copy( item.transactions ), item.selection, true );
		this.emit( 'undoStackChange' );
	}
};

/**
 * Respond to transactions processed on the document by translating the selection and updating
 * other state.
 *
 * @param {ve.dm.Transaction} tx Transaction that was processed
 * @fires ve.dm.Surface#documentUpdate
 */
ve.dm.Surface.prototype.onDocumentTransact = function ( tx ) {
	this.setSelection( this.getSelection().translateByTransactionWithAuthor( tx, this.authorId ) );
	this.emit( 'documentUpdate', tx );
};

/**
 * Get the cached selected node covering the current selection, or null
 *
 * @return {ve.dm.Node|null} Selected node
 */
ve.dm.Surface.prototype.getSelectedNode = function () {
	return this.selectedNode;
};

/**
 * Get the selected node covering a specific selection, or null
 *
 * @param {ve.dm.Selection} [selection] Selection, defaults to the current selection
 * @return {ve.dm.Node|null} Selected node
 */
ve.dm.Surface.prototype.getSelectedNodeFromSelection = function ( selection ) {
	selection = selection || this.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return null;
	}

	let selectedNode = null;
	const range = selection.getRange();
	if ( !range.isCollapsed() ) {
		const startNode = this.getDocument().documentNode.getNodeFromOffset( range.start + 1 );
		if ( startNode && startNode.getOuterRange().equalsSelection( range ) ) {
			selectedNode = startNode;
		}
	}
	return selectedNode;
};

/**
 * Update translatedSelection early (before the commit actually occurs)
 *
 * This is so ve.ce.ContentBranchNode#getRenderedContents can consider the translated
 * selection for unicorn rendering.
 *
 * @param {ve.dm.Transaction} tx Transaction that's about to be committed
 */
ve.dm.Surface.prototype.onDocumentPreCommit = function ( tx ) {
	this.translatedSelection = this.selection.translateByTransaction( tx );
};

/**
 * Get a minimal set of ranges which have been modified by changes to the surface.
 *
 * @param {Object} [options] Options
 * @param {boolean} [options.includeCollapsed] Include collapsed ranges (removed content)
 * @param {boolean} [options.includeInternalList] Include changes within the internal list
 * @param {boolean} [options.excludeAnnotations] Exclude annotation-only changes
 * @param {boolean} [options.excludeAttributes] Exclude attribute changes
 * @return {ve.Range[]} Modified ranges
 */
ve.dm.Surface.prototype.getModifiedRanges = function ( options ) {
	const doc = this.getDocument();
	const ranges = [];

	options = options || {};

	this.getHistory().forEach( ( stackItem ) => {
		stackItem.transactions.forEach( ( tx ) => {
			const newRange = tx.getModifiedRange( doc, options );
			// newRange will by null for no-ops
			if ( newRange ) {
				// Translate previous ranges by the current transaction
				ranges.forEach( ( range, i, arr ) => {
					arr[ i ] = tx.translateRange( range, true );
				} );
				if ( options.includeCollapsed || !newRange.isCollapsed() ) {
					ranges.push( newRange );
				}
			}
		} );
	} );

	// Merge adjacent ranges
	const compactRanges = [];
	let lastRange = null;
	ranges
		.sort( ( a, b ) => a.start - b.start )
		.forEach( ( range ) => {
			if ( options.includeCollapsed || !range.isCollapsed() ) {
				if ( lastRange && lastRange.touchesRange( range ) ) {
					compactRanges.pop();
					range = lastRange.expand( range );
				}
				compactRanges.push( range );
				lastRange = range;
			}
		} );

	return compactRanges;
};

/**
 * Get a VE source-mode surface offset from a plaintext source offset.
 *
 * @param {number} offset Source text offset
 * @return {number} Surface offset
 * @throws {Error} Offset out of bounds
 */
ve.dm.Surface.prototype.getOffsetFromSourceOffset = function ( offset ) {
	if ( offset < 0 ) {
		throw new Error( 'Offset out of bounds' );
	}

	const lines = this.getDocument().getDocumentNode().getChildren();
	let lineOffset = 0,
		line = 0;

	while ( lineOffset < offset + 1 ) {
		if ( !lines[ line ] || lines[ line ].isInternal() ) {
			throw new Error( 'Offset out of bounds' );
		}
		lineOffset += lines[ line ].getLength() + 1;
		line++;
	}
	return offset + line;
};

/**
 * Get a plaintext source offset from a VE source-mode surface offset.
 *
 * @param {number} offset Surface offset
 * @return {number} Source text offset
 * @throws {Error} Offset out of bounds
 */
ve.dm.Surface.prototype.getSourceOffsetFromOffset = function ( offset ) {
	if ( offset < 0 ) {
		throw new Error( 'Offset out of bounds' );
	}

	const lines = this.getDocument().getDocumentNode().getChildren();
	let lineOffset = 0,
		line = 0;

	while ( lineOffset < offset ) {
		if ( !lines[ line ] || lines[ line ].isInternal() ) {
			throw new Error( 'Offset out of bounds' );
		}
		lineOffset += lines[ line ].getOuterLength();
		line++;
	}
	return offset - line;
};

/**
 * Get a VE source-mode surface range from plaintext source offsets.
 *
 * @param {number} from Source text from offset
 * @param {number} [to] Source text to offset, omit for a collapsed range
 * @return {ve.Range} Source surface offset
 */
ve.dm.Surface.prototype.getRangeFromSourceOffsets = function ( from, to ) {
	const fromOffset = this.getOffsetFromSourceOffset( from );
	return new ve.Range(
		fromOffset,
		// Skip toOffset calculation if collapsed
		to === undefined || to === from ?
			fromOffset :
			this.getOffsetFromSourceOffset( to )
	);
};

/**
 * Get the author ID
 *
 * @return {number} The author ID
 */
ve.dm.Surface.prototype.getAuthorId = function () {
	return this.authorId;
};

/**
 * Set the author ID
 *
 * @param {number} authorId The new author ID
 */
ve.dm.Surface.prototype.setAuthorId = function ( authorId ) {
	this.authorId = authorId;
};

/**
 * Store latest transactions into session storage
 *
 * @fires ve.dm.Surface#autosaveFailed
 */
ve.dm.Surface.prototype.storeChanges = function () {
	if ( this.autosaveFailed ) {
		return;
	}

	const dmDoc = this.getDocument();
	const change = dmDoc.getChangeSince( this.lastStoredChange );
	if ( !change.isEmpty() ) {
		const changes = this.storage.getObject( this.autosavePrefix + 've-changes' ) || [];
		changes.push( change );
		if ( this.storage.setObject( this.autosavePrefix + 've-changes', changes, this.storageExpiry ) ) {
			this.lastStoredChange = dmDoc.getCompleteHistoryLength();
			this.storage.setObject( this.autosavePrefix + 've-selection', this.getSelection(), this.storageExpiry );
			this.updateExpiry( [ 've-changes', 've-selection' ] );
		} else {
			// Auto-save failed probably because of memory limits
			// so flag it so we don't keep trying in vain.
			this.autosaveFailed = true;
			this.emit( 'autosaveFailed' );
		}
	}
};

/**
 * Store persistent document storage into session storage
 */
ve.dm.Surface.prototype.storeDocStorage = function () {
	if ( this.autosaveFailed ) {
		return;
	}

	const dmDoc = this.getDocument();
	this.storage.setObject( this.autosavePrefix + 've-docstorage', dmDoc.getStorage(), this.storageExpiry );
	this.updateExpiry( [ 've-docstorage' ] );
};

/**
 * Set an document ID for autosave.
 *
 * For session storage this is only required if there is more
 * than one document on the page.
 *
 * @param {string} docId Document ID.
 */
ve.dm.Surface.prototype.setAutosaveDocId = function ( docId ) {
	this.autosavePrefix = docId + '/';
};

/**
 * Set the storage interface for autosave
 *
 * @param {ve.init.ConflictableStorage} storage Storage interface
 * @param {number} [storageExpiry] Storage expiry time in seconds
 */
ve.dm.Surface.prototype.setStorage = function ( storage, storageExpiry ) {
	if ( this.storing ) {
		throw new Error( 'Can\'t change storage interface after auto-save has stared' );
	}
	this.storage = storage;
	this.storageExpiry = storageExpiry;

	let isLocalStorage = false;
	try {
		// Accessing window.localStorage can throw an exception when it is disabled
		// eslint-disable-next-line no-undef
		isLocalStorage = this.storage.store === window.localStorage;
	} catch ( e ) {}

	if ( isLocalStorage ) {
		const conflictableKeys = {};
		conflictableKeys[ this.autosavePrefix + 've-docstate' ] = true;
		conflictableKeys[ this.autosavePrefix + 've-dochtml' ] = true;
		conflictableKeys[ this.autosavePrefix + 've-selection' ] = true;
		conflictableKeys[ this.autosavePrefix + 've-changes' ] = true;
		this.storage.addConflictableKeys( conflictableKeys );
	}
};

/**
 * Start storing changes after every undoStackChange
 */
ve.dm.Surface.prototype.startStoringChanges = function () {
	this.storing = true;
	this.on( 'undoStackChange', this.storeChangesListener );
	this.getDocument().on( 'storage', this.storeDocStorageListener );
	this.updateExpiry();
};

/**
 * Stop storing changes
 */
ve.dm.Surface.prototype.stopStoringChanges = function () {
	this.storing = false;
	this.off( 'undoStackChange', this.storeChangesListener );
	this.getDocument().off( 'storage', this.storeDocStorageListener );
};

/**
 * Restore transactions from session storage
 *
 * @return {boolean} Some changes were restored
 * @throws {Error} Failed to restore auto-saved session
 */
ve.dm.Surface.prototype.restoreChanges = function () {
	const changes = this.storage.getObject( this.autosavePrefix + 've-changes' ) || [];
	let restored = false;

	try {
		changes.forEach( ( data ) => {
			const change = ve.dm.Change.static.unsafeDeserialize( data );
			change.applyTo( this, true );
			this.breakpoint();
		} );
		restored = !!changes.length;

		this.getDocument().setStorage(
			this.storage.getObject( this.autosavePrefix + 've-docstorage' ) || {}
		);

		let selection;
		try {
			selection = ve.dm.Selection.static.newFromJSON(
				this.storage.getObject( this.autosavePrefix + 've-selection' )
			);
		} catch ( e ) {
			// Didn't restore the selection, not a big deal.
		}
		if ( selection ) {
			// Wait for surface to observe selection change
			setTimeout( () => {
				this.setSelection( selection );
			} );
		}
	} catch ( e ) {
		throw new Error( 'Failed to restore auto-saved session: ' + e );
	}

	this.lastStoredChange = this.getDocument().getCompleteHistoryLength();

	return restored;
};

/**
 * Store a snapshot of the current document state.
 *
 * If custom HTML is provided, the caller must manually set the
 * lastStoredChange pointer to the correct value.
 *
 * @param {Object} [state] JSONable object describing document state
 * @param {string} [html] Document HTML, will generate from current state if not provided
 * @return {boolean} Doc state was successfully stored
 */
ve.dm.Surface.prototype.storeDocState = function ( state, html ) {
	// Clear any changes that may have stored up to this point
	this.removeDocStateAndChanges();
	if ( state ) {
		if ( !this.updateDocState( state ) ) {
			this.stopStoringChanges();
			return false;
		}
	}
	const useLatestHtml = html === undefined;
	// Store HTML separately to avoid wasteful JSON encoding
	if ( !this.storage.set( this.autosavePrefix + 've-dochtml', useLatestHtml ? this.getHtml() : html, this.storageExpiry ) ) {
		// If we failed to store the html, wipe the docstate
		this.storage.remove( this.autosavePrefix + 've-docstate' );
		this.stopStoringChanges();
		return false;
	}
	this.updateExpiry( [ 've-dochtml' ] );

	if ( useLatestHtml ) {
		// If storing the latest HTML, reset the lastStoreChange pointer,
		// otherwise assume this will be handled by the caller.
		this.lastStoredChange = this.getDocument().getCompleteHistoryLength();
	}

	return true;
};

/**
 * Update stored document state metadata, without changing the HTML
 *
 * @param {Object} state Document state
 * @return {boolean} Document metadata was successfully stored
 */
ve.dm.Surface.prototype.updateDocState = function ( state ) {
	if ( this.storage.set( this.autosavePrefix + 've-docstate', JSON.stringify( state ), this.storageExpiry ) ) {
		this.updateExpiry( [ 've-docstate' ] );
		return true;
	}
	return false;
};

/**
 * Update the expiry value of keys in use
 *
 * @param {string[]} [skipKeys] Keys to skip (because they have just been updated)
 */
ve.dm.Surface.prototype.updateExpiry = function ( skipKeys ) {
	if ( !this.storageExpiry ) {
		return;
	}
	skipKeys = skipKeys || [];
	[ 've-docstate', 've-dochtml', 've-selection', 've-changes' ].forEach( ( key ) => {
		if ( skipKeys.indexOf( key ) === -1 ) {
			this.storage.setExpires( this.autosavePrefix + key, this.storageExpiry );
		}
	} );
};

/**
 * Remove the auto-saved document state and stashed changes
 */
ve.dm.Surface.prototype.removeDocStateAndChanges = function () {
	this.storage.remove( this.autosavePrefix + 've-docstate' );
	this.storage.remove( this.autosavePrefix + 've-dochtml' );
	this.storage.remove( this.autosavePrefix + 've-selection' );
	this.storage.remove( this.autosavePrefix + 've-changes' );
};
