/**
 * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

/**
 * @module engine/model/documentselection
 */

import mix from '@ckeditor/ckeditor5-utils/src/mix';
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';

import Selection from './selection';
import LiveRange from './liverange';
import Text from './text';
import TextProxy from './textproxy';
import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
import Collection from '@ckeditor/ckeditor5-utils/src/collection';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import uid from '@ckeditor/ckeditor5-utils/src/uid';

const storePrefix = 'selection:';

/**
 * `DocumentSelection` is a special selection which is used as the
 * {@link module:engine/model/document~Document#selection document's selection}.
 * There can be only one instance of `DocumentSelection` per document.
 *
 * Document selection can only be changed by using the {@link module:engine/model/writer~Writer} instance
 * inside the {@link module:engine/model/model~Model#change `change()`} block, as it provides a secure way to modify model.
 *
 * `DocumentSelection` is automatically updated upon changes in the {@link module:engine/model/document~Document document}
 * to always contain valid ranges. Its attributes are inherited from the text unless set explicitly.
 *
 * Differences between {@link module:engine/model/selection~Selection} and `DocumentSelection` are:
 * * there is always a range in `DocumentSelection` - even if no ranges were added there is a "default range"
 * present in the selection,
 * * ranges added to this selection updates automatically when the document changes,
 * * attributes of `DocumentSelection` are updated automatically according to selection ranges.
 *
 * Since `DocumentSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
 * and is updated when {@link module:engine/model/document~Document document}
 * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
 * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
 * If you need to represent a selection in document fragment,
 * use {@link module:engine/model/selection~Selection Selection class} instead.
 *
 * @mixes module:utils/emittermixin~EmitterMixin
 */
export default class DocumentSelection {
	/**
	 * Creates an empty live selection for given {@link module:engine/model/document~Document}.
	 *
	 * @param {module:engine/model/document~Document} doc Document which owns this selection.
	 */
	constructor( doc ) {
		/**
		 * Selection used internally by that class (`DocumentSelection` is a proxy to that selection).
		 *
		 * @protected
		 */
		this._selection = new LiveSelection( doc );

		this._selection.delegate( 'change:range' ).to( this );
		this._selection.delegate( 'change:attribute' ).to( this );
		this._selection.delegate( 'change:marker' ).to( this );
	}

	/**
	 * Returns whether the selection is collapsed. Selection is collapsed when there is exactly one range which is
	 * collapsed.
	 *
	 * @readonly
	 * @type {Boolean}
	 */
	get isCollapsed() {
		return this._selection.isCollapsed;
	}

	/**
	 * Selection anchor. Anchor may be described as a position where the most recent part of the selection starts.
	 * Together with {@link #focus} they define the direction of selection, which is important
	 * when expanding/shrinking selection. Anchor is always {@link module:engine/model/range~Range#start start} or
	 * {@link module:engine/model/range~Range#end end} position of the most recently added range.
	 *
	 * Is set to `null` if there are no ranges in selection.
	 *
	 * @see #focus
	 * @readonly
	 * @type {module:engine/model/position~Position|null}
	 */
	get anchor() {
		return this._selection.anchor;
	}

	/**
	 * Selection focus. Focus is a position where the selection ends.
	 *
	 * Is set to `null` if there are no ranges in selection.
	 *
	 * @see #anchor
	 * @readonly
	 * @type {module:engine/model/position~Position|null}
	 */
	get focus() {
		return this._selection.focus;
	}

	/**
	 * Returns number of ranges in selection.
	 *
	 * @readonly
	 * @type {Number}
	 */
	get rangeCount() {
		return this._selection.rangeCount;
	}

	/**
	 * Describes whether `Documentselection` has own range(s) set, or if it is defaulted to
	 * {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
	 *
	 * @readonly
	 * @type {Boolean}
	 */
	get hasOwnRange() {
		return this._selection.hasOwnRange;
	}

	/**
	 * Specifies whether the {@link #focus}
	 * precedes {@link #anchor}.
	 *
	 * @readonly
	 * @type {Boolean}
	 */
	get isBackward() {
		return this._selection.isBackward;
	}

	/**
	 * Describes whether the gravity is overridden (using {@link module:engine/model/writer~Writer#overrideSelectionGravity}) or not.
	 *
	 * Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
	 *
	 * @readonly
	 * @returns {Boolean}
	 */
	get isGravityOverridden() {
		return this._selection.isGravityOverridden;
	}

	/**
	 * A collection of selection {@link module:engine/model/markercollection~Marker markers}.
	 * Marker is a selection marker when selection range is inside the marker range.
	 *
	 * **Note**: Only markers from {@link ~DocumentSelection#observeMarkers observed markers groups} are collected.
	 *
	 * @readonly
	 * @type {module:utils/collection~Collection}
	 */
	get markers() {
		return this._selection.markers;
	}

	/**
	 * Used for the compatibility with the {@link module:engine/model/selection~Selection#isEqual} method.
	 *
	 * @protected
	 */
	get _ranges() {
		return this._selection._ranges;
	}

	/**
	 * Returns an iterable that iterates over copies of selection ranges.
	 *
	 * @returns {Iterable.<module:engine/model/range~Range>}
	 */
	getRanges() {
		return this._selection.getRanges();
	}

	/**
	 * Returns the first position in the selection.
	 * First position is the position that {@link module:engine/model/position~Position#isBefore is before}
	 * any other position in the selection.
	 *
	 * Returns `null` if there are no ranges in selection.
	 *
	 * @returns {module:engine/model/position~Position|null}
	 */
	getFirstPosition() {
		return this._selection.getFirstPosition();
	}

	/**
	 * Returns the last position in the selection.
	 * Last position is the position that {@link module:engine/model/position~Position#isAfter is after}
	 * any other position in the selection.
	 *
	 * Returns `null` if there are no ranges in selection.
	 *
	 * @returns {module:engine/model/position~Position|null}
	 */
	getLastPosition() {
		return this._selection.getLastPosition();
	}

	/**
	 * Returns a copy of the first range in the selection.
	 * First range is the one which {@link module:engine/model/range~Range#start start} position
	 * {@link module:engine/model/position~Position#isBefore is before} start position of all other ranges
	 * (not to confuse with the first range added to the selection).
	 *
	 * Returns `null` if there are no ranges in selection.
	 *
	 * @returns {module:engine/model/range~Range|null}
	 */
	getFirstRange() {
		return this._selection.getFirstRange();
	}

	/**
	 * Returns a copy of the last range in the selection.
	 * Last range is the one which {@link module:engine/model/range~Range#end end} position
	 * {@link module:engine/model/position~Position#isAfter is after} end position of all other ranges (not to confuse with the range most
	 * recently added to the selection).
	 *
	 * Returns `null` if there are no ranges in selection.
	 *
	 * @returns {module:engine/model/range~Range|null}
	 */
	getLastRange() {
		return this._selection.getLastRange();
	}

	/**
	 * Gets elements of type {@link module:engine/model/schema~Schema#isBlock "block"} touched by the selection.
	 *
	 * This method's result can be used for example to apply block styling to all blocks covered by this selection.
	 *
	 * **Note:** `getSelectedBlocks()` returns blocks that are nested in other non-block elements
	 * but will not return blocks nested in other blocks.
	 *
	 * In this case the function will return exactly all 3 paragraphs (note: `<blockQuote>` is not a block itself):
	 *
	 *		<paragraph>[a</paragraph>
	 *		<blockQuote>
	 *			<paragraph>b</paragraph>
	 *		</blockQuote>
	 *		<paragraph>c]d</paragraph>
	 *
	 * In this case the paragraph will also be returned, despite the collapsed selection:
	 *
	 *		<paragraph>[]a</paragraph>
	 *
	 * In such a scenario, however, only blocks A, B & E will be returned as blocks C & D are nested in block B:
	 *
	 *		[<blockA></blockA>
	 *		<blockB>
	 *			<blockC></blockC>
	 *			<blockD></blockD>
	 *		</blockB>
	 *		<blockE></blockE>]
	 *
	 * If the selection is inside a block all the inner blocks (A & B) are returned:
	 *
	 * 		<block>
	 *			<blockA>[a</blockA>
	 * 			<blockB>b]</blockB>
	 * 		</block>
	 *
	 * **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective
	 * this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details.
	 *
	 *		<paragraph>[a</paragraph>
	 *		<paragraph>b</paragraph>
	 *		<paragraph>]c</paragraph> // this block will not be returned
	 *
	 * @returns {Iterable.<module:engine/model/element~Element>}
	 */
	getSelectedBlocks() {
		return this._selection.getSelectedBlocks();
	}

	/**
	 * Returns the selected element. {@link module:engine/model/element~Element Element} is considered as selected if there is only
	 * one range in the selection, and that range contains exactly one element.
	 * Returns `null` if there is no selected element.
	 *
	 * @returns {module:engine/model/element~Element|null}
	 */
	getSelectedElement() {
		return this._selection.getSelectedElement();
	}

	/**
	 * Checks whether the selection contains the entire content of the given element. This means that selection must start
	 * at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
	 * touching the element's end.
	 *
	 * By default, this method will check whether the entire content of the selection's current root is selected.
	 * Useful to check if e.g. the user has just pressed <kbd>Ctrl</kbd> + <kbd>A</kbd>.
	 *
	 * @param {module:engine/model/element~Element} [element=this.anchor.root]
	 * @returns {Boolean}
	 */
	containsEntireContent( element ) {
		return this._selection.containsEntireContent( element );
	}

	/**
	 * Unbinds all events previously bound by document selection.
	 */
	destroy() {
		this._selection.destroy();
	}

	/**
	 * Returns iterable that iterates over this selection's attribute keys.
	 *
	 * @returns {Iterable.<String>}
	 */
	getAttributeKeys() {
		return this._selection.getAttributeKeys();
	}

	/**
	 * Returns iterable that iterates over this selection's attributes.
	 *
	 * Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
	 * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
	 *
	 * @returns {Iterable.<*>}
	 */
	getAttributes() {
		return this._selection.getAttributes();
	}

	/**
	 * Gets an attribute value for given key or `undefined` if that attribute is not set on the selection.
	 *
	 * @param {String} key Key of attribute to look for.
	 * @returns {*} Attribute value or `undefined`.
	 */
	getAttribute( key ) {
		return this._selection.getAttribute( key );
	}

	/**
	 * Checks if the selection has an attribute for given key.
	 *
	 * @param {String} key Key of attribute to check.
	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
	 */
	hasAttribute( key ) {
		return this._selection.hasAttribute( key );
	}

	/**
	 * Refreshes selection attributes and markers according to the current position in the model.
	 */
	refresh() {
		this._selection._updateMarkers();
		this._selection._updateAttributes( false );
	}

	/**
	 * Registers a marker group prefix or a marker name to be collected in the
	 * {@link ~DocumentSelection#markers selection markers collection}.
	 *
	 * See also {@link module:engine/model/markercollection~MarkerCollection#getMarkersGroup `MarkerCollection#getMarkersGroup()`}.
	 *
	 * @param {String} prefixOrName The marker group prefix or marker name.
	 */
	observeMarkers( prefixOrName ) {
		this._selection.observeMarkers( prefixOrName );
	}

	/**
	 * Checks whether this object is of the given type.
	 *
	 *		selection.is( 'selection' ); // -> true
	 *		selection.is( 'documentSelection' ); // -> true
	 *		selection.is( 'model:selection' ); // -> true
	 *		selection.is( 'model:documentSelection' ); // -> true
	 *
	 *		selection.is( 'view:selection' ); // -> false
	 *		selection.is( 'element' ); // -> false
	 *		selection.is( 'node' ); // -> false
	 *
	 * {@link module:engine/model/node~Node#is Check the entire list of model objects} which implement the `is()` method.
	 *
	 * @param {String} type
	 * @returns {Boolean}
	 */
	is( type ) {
		return type === 'selection' ||
			type == 'model:selection' ||
			type == 'documentSelection' ||
			type == 'model:documentSelection';
	}

	/**
	 * Moves {@link module:engine/model/documentselection~DocumentSelection#focus} to the specified location.
	 * Should be used only within the {@link module:engine/model/writer~Writer#setSelectionFocus} method.
	 *
	 * The location can be specified in the same form as
	 * {@link module:engine/model/writer~Writer#createPositionAt writer.createPositionAt()} parameters.
	 *
	 * @see module:engine/model/writer~Writer#setSelectionFocus
	 * @protected
	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
	 * @param {Number|'end'|'before'|'after'} [offset] Offset or one of the flags. Used only when
	 * first parameter is a {@link module:engine/model/item~Item model item}.
	 */
	_setFocus( itemOrPosition, offset ) {
		this._selection.setFocus( itemOrPosition, offset );
	}

	/**
	 * Sets this selection's ranges and direction to the specified location based on the given
	 * {@link module:engine/model/selection~Selectable selectable}.
	 * Should be used only within the {@link module:engine/model/writer~Writer#setSelection} method.
	 *
	 * @see module:engine/model/writer~Writer#setSelection
	 * @protected
	 * @param {module:engine/model/selection~Selectable} selectable
	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
	 * @param {Object} [options]
	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
	 */
	_setTo( selectable, placeOrOffset, options ) {
		this._selection.setTo( selectable, placeOrOffset, options );
	}

	/**
	 * Sets attribute on the selection. If attribute with the same key already is set, it's value is overwritten.
	 * Should be used only within the {@link module:engine/model/writer~Writer#setSelectionAttribute} method.
	 *
	 * @see module:engine/model/writer~Writer#setSelectionAttribute
	 * @protected
	 * @param {String} key Key of the attribute to set.
	 * @param {*} value Attribute value.
	 */
	_setAttribute( key, value ) {
		this._selection.setAttribute( key, value );
	}

	/**
	 * Removes an attribute with given key from the selection.
	 * If the given attribute was set on the selection, fires the {@link module:engine/model/selection~Selection#event:change:range}
	 * event with removed attribute key.
	 * Should be used only within the {@link module:engine/model/writer~Writer#removeSelectionAttribute} method.
	 *
	 * @see module:engine/model/writer~Writer#removeSelectionAttribute
	 * @protected
	 * @param {String} key Key of the attribute to remove.
	 */
	_removeAttribute( key ) {
		this._selection.removeAttribute( key );
	}

	/**
	 * Returns an iterable that iterates through all selection attributes stored in current selection's parent.
	 *
	 * @protected
	 * @returns {Iterable.<*>}
	 */
	_getStoredAttributes() {
		return this._selection._getStoredAttributes();
	}

	/**
	 * Temporarily changes the gravity of the selection from the left to the right.
	 *
	 * The gravity defines from which direction the selection inherits its attributes. If it's the default left
	 * gravity, the selection (after being moved by the the user) inherits attributes from its left hand side.
	 * This method allows to temporarily override this behavior by forcing the gravity to the right.
	 *
	 * It returns an unique identifier which is required to restore the gravity. It guarantees the symmetry
	 * of the process.
	 *
	 * @see module:engine/model/writer~Writer#overrideSelectionGravity
	 * @protected
	 * @returns {String} The unique id which allows restoring the gravity.
	 */
	_overrideGravity() {
		return this._selection.overrideGravity();
	}

	/**
	 * Restores the {@link ~DocumentSelection#_overrideGravity overridden gravity}.
	 *
	 * Restoring the gravity is only possible using the unique identifier returned by
	 * {@link ~DocumentSelection#_overrideGravity}. Note that the gravity remains overridden as long as won't be restored
	 * the same number of times it was overridden.
	 *
	 * @see module:engine/model/writer~Writer#restoreSelectionGravity
	 * @protected
	 * @param {String} uid The unique id returned by {@link #_overrideGravity}.
	 */
	_restoreGravity( uid ) {
		this._selection.restoreGravity( uid );
	}

	/**
	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
	 *
	 * @protected
	 * @param {String} key Attribute key to convert.
	 * @returns {String} Converted attribute key, applicable for selection store.
	 */
	static _getStoreAttributeKey( key ) {
		return storePrefix + key;
	}

	/**
	 * Checks whether the given attribute key is an attribute stored on an element.
	 *
	 * @protected
	 * @param {String} key
	 * @returns {Boolean}
	 */
	static _isStoreAttributeKey( key ) {
		return key.startsWith( storePrefix );
	}
}

mix( DocumentSelection, EmitterMixin );

/**
 * Fired when selection range(s) changed.
 *
 * @event change:range
 * @param {Boolean} directChange In case of {@link module:engine/model/selection~Selection} class it is always set
 * to `true` which indicates that the selection change was caused by a direct use of selection's API.
 * The {@link module:engine/model/documentselection~DocumentSelection}, however, may change because its position
 * was directly changed through the {@link module:engine/model/writer~Writer writer} or because its position was
 * changed because the structure of the model has been changed (which means an indirect change).
 * The indirect change does not occur in case of normal (detached) selections because they are "static" (as "not live")
 * which mean that they are not updated once the document changes.
 */

/**
 * Fired when selection attribute changed.
 *
 * @event change:attribute
 * @param {Boolean} directChange In case of {@link module:engine/model/selection~Selection} class it is always set
 * to `true` which indicates that the selection change was caused by a direct use of selection's API.
 * The {@link module:engine/model/documentselection~DocumentSelection}, however, may change because its attributes
 * were directly changed through the {@link module:engine/model/writer~Writer writer} or because its position was
 * changed in the model and its attributes were refreshed (which means an indirect change).
 * The indirect change does not occur in case of normal (detached) selections because they are "static" (as "not live")
 * which mean that they are not updated once the document changes.
 * @param {Array.<String>} attributeKeys Array containing keys of attributes that changed.
 */

/**
 * Fired when selection marker(s) changed.
 *
 * @event change:marker
 * @param {Boolean} directChange This is always set to `false` in case of `change:marker` event as there is no possibility
 * to change markers directly through {@link module:engine/model/documentselection~DocumentSelection} API.
 * See also {@link module:engine/model/documentselection~DocumentSelection#event:change:range} and
 * {@link module:engine/model/documentselection~DocumentSelection#event:change:attribute}.
 * @param {Array.<module:engine/model/markercollection~Marker>} oldMarkers Markers in which the selection was before the change.
 */

// `LiveSelection` is used internally by {@link module:engine/model/documentselection~DocumentSelection} and shouldn't be used directly.
//
// LiveSelection` is automatically updated upon changes in the {@link module:engine/model/document~Document document}
// to always contain valid ranges. Its attributes are inherited from the text unless set explicitly.
//
// Differences between {@link module:engine/model/selection~Selection} and `LiveSelection` are:
// * there is always a range in `LiveSelection` - even if no ranges were added there is a "default range"
// present in the selection,
// * ranges added to this selection updates automatically when the document changes,
// * attributes of `LiveSelection` are updated automatically according to selection ranges.
//
// @extends module:engine/model/selection~Selection
//
class LiveSelection extends Selection {
	// Creates an empty live selection for given {@link module:engine/model/document~Document}.
	// @param {module:engine/model/document~Document} doc Document which owns this selection.
	constructor( doc ) {
		super();

		// List of selection markers.
		// Marker is a selection marker when selection range is inside the marker range.
		//
		// @type {module:utils/collection~Collection}
		this.markers = new Collection( { idProperty: 'name' } );

		// Document which owns this selection.
		//
		// @protected
		// @member {module:engine/model/model~Model}
		this._model = doc.model;

		// Document which owns this selection.
		//
		// @protected
		// @member {module:engine/model/document~Document}
		this._document = doc;

		// Keeps mapping of attribute name to priority with which the attribute got modified (added/changed/removed)
		// last time. Possible values of priority are: `'low'` and `'normal'`.
		//
		// Priorities are used by internal `LiveSelection` mechanisms. All attributes set using `LiveSelection`
		// attributes API are set with `'normal'` priority.
		//
		// @private
		// @member {Map} module:engine/model/liveselection~LiveSelection#_attributePriority
		this._attributePriority = new Map();

		// Position to which the selection should be set if the last selection range was moved to the graveyard.
		// @private
		// @member {module:engine/model/position~Position} module:engine/model/liveselection~LiveSelection#_selectionRestorePosition
		this._selectionRestorePosition = null;

		// Flag that informs whether the selection ranges have changed. It is changed on true when `LiveRange#change:range` event is fired.
		// @private
		// @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
		this._hasChangedRange = false;

		// Each overriding gravity adds an UID to the set and each removal removes it.
		// Gravity is overridden when there's at least one UID in the set.
		// Gravity is restored when the set is empty.
		// This is to prevent conflicts when gravity is overridden by more than one feature at the same time.
		// @private
		// @type {Set}
		this._overriddenGravityRegister = new Set();

		// Prefixes of marker names that should affect `LiveSelection#markers` collection.
		// @private
		// @type {Set}
		this._observedMarkers = new Set();

		// Ensure selection is correct after each operation.
		this.listenTo( this._model, 'applyOperation', ( evt, args ) => {
			const operation = args[ 0 ];

			if ( !operation.isDocumentOperation || operation.type == 'marker' || operation.type == 'rename' || operation.type == 'noop' ) {
				return;
			}

			// Fix selection if the last range was removed from it and we have a position to which we can restore the selection.
			if ( this._ranges.length == 0 && this._selectionRestorePosition ) {
				this._fixGraveyardSelection( this._selectionRestorePosition );
			}

			// "Forget" the restore position even if it was not "used".
			this._selectionRestorePosition = null;

			if ( this._hasChangedRange ) {
				this._hasChangedRange = false;
				this.fire( 'change:range', { directChange: false } );
			}
		}, { priority: 'lowest' } );

		// Ensure selection is correct and up to date after each range change.
		this.on( 'change:range', () => {
			for ( const range of this.getRanges() ) {
				if ( !this._document._validateSelectionRange( range ) ) {
					/**
					 * Range from {@link module:engine/model/documentselection~DocumentSelection document selection}
					 * starts or ends at incorrect position.
					 *
					 * @error document-selection-wrong-position
					 * @param {module:engine/model/range~Range} range
					 */
					throw new CKEditorError(
						'document-selection-wrong-position',
						this,
						{ range }
					);
				}
			}
		} );

		// Update markers data stored by the selection after each marker change.
		// This handles only marker changes done through marker operations (not model tree changes).
		this.listenTo( this._model.markers, 'update', ( evt, marker, oldRange, newRange ) => {
			this._updateMarker( marker, newRange );
		} );

		// Ensure selection is up to date after each change block.
		this.listenTo( this._document, 'change', ( evt, batch ) => {
			clearAttributesStoredInElement( this._model, batch );
		} );
	}

	get isCollapsed() {
		const length = this._ranges.length;

		return length === 0 ? this._document._getDefaultRange().isCollapsed : super.isCollapsed;
	}

	get anchor() {
		return super.anchor || this._document._getDefaultRange().start;
	}

	get focus() {
		return super.focus || this._document._getDefaultRange().end;
	}

	get rangeCount() {
		return this._ranges.length ? this._ranges.length : 1;
	}

	// Describes whether `LiveSelection` has own range(s) set, or if it is defaulted to
	// {@link module:engine/model/document~Document#_getDefaultRange document's default range}.
	//
	// @readonly
	// @type {Boolean}
	get hasOwnRange() {
		return this._ranges.length > 0;
	}

	// When set to `true` then selection attributes on node before the caret won't be taken
	// into consideration while updating selection attributes.
	//
	// @protected
	// @type {Boolean}
	get isGravityOverridden() {
		return !!this._overriddenGravityRegister.size;
	}

	// Unbinds all events previously bound by live selection.
	destroy() {
		for ( let i = 0; i < this._ranges.length; i++ ) {
			this._ranges[ i ].detach();
		}

		this.stopListening();
	}

	* getRanges() {
		if ( this._ranges.length ) {
			yield* super.getRanges();
		} else {
			yield this._document._getDefaultRange();
		}
	}

	getFirstRange() {
		return super.getFirstRange() || this._document._getDefaultRange();
	}

	getLastRange() {
		return super.getLastRange() || this._document._getDefaultRange();
	}

	setTo( selectable, optionsOrPlaceOrOffset, options ) {
		super.setTo( selectable, optionsOrPlaceOrOffset, options );
		this._updateAttributes( true );
		this._updateMarkers();
	}

	setFocus( itemOrPosition, offset ) {
		super.setFocus( itemOrPosition, offset );
		this._updateAttributes( true );
		this._updateMarkers();
	}

	setAttribute( key, value ) {
		if ( this._setAttribute( key, value ) ) {
			// Fire event with exact data.
			const attributeKeys = [ key ];
			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
		}
	}

	removeAttribute( key ) {
		if ( this._removeAttribute( key ) ) {
			// Fire event with exact data.
			const attributeKeys = [ key ];
			this.fire( 'change:attribute', { attributeKeys, directChange: true } );
		}
	}

	overrideGravity() {
		const overrideUid = uid();

		// Remember that another overriding has been requested. It will need to be removed
		// before the gravity is to be restored.
		this._overriddenGravityRegister.add( overrideUid );

		if ( this._overriddenGravityRegister.size === 1 ) {
			this._updateAttributes( true );
		}

		return overrideUid;
	}

	restoreGravity( uid ) {
		if ( !this._overriddenGravityRegister.has( uid ) ) {
			/**
			 * Restoring gravity for an unknown UID is not possible. Make sure you are using a correct
			 * UID obtained from the {@link module:engine/model/writer~Writer#overrideSelectionGravity} to restore.
			 *
			 * @error document-selection-gravity-wrong-restore
			 * @param {String} uid The unique identifier returned by
			 * {@link module:engine/model/documentselection~DocumentSelection#_overrideGravity}.
			 */
			throw new CKEditorError(
				'document-selection-gravity-wrong-restore',
				this,
				{ uid }
			);
		}

		this._overriddenGravityRegister.delete( uid );

		// Restore gravity only when all overriding have been restored.
		if ( !this.isGravityOverridden ) {
			this._updateAttributes( true );
		}
	}

	observeMarkers( prefixOrName ) {
		this._observedMarkers.add( prefixOrName );
		this._updateMarkers();
	}

	_popRange() {
		this._ranges.pop().detach();
	}

	_pushRange( range ) {
		const liveRange = this._prepareRange( range );

		// `undefined` is returned when given `range` is in graveyard root.
		if ( liveRange ) {
			this._ranges.push( liveRange );
		}
	}

	// Prepares given range to be added to selection. Checks if it is correct,
	// converts it to {@link module:engine/model/liverange~LiveRange LiveRange}
	// and sets listeners listening to the range's change event.
	//
	// @private
	// @param {module:engine/model/range~Range} range
	_prepareRange( range ) {
		this._checkRange( range );

		if ( range.root == this._document.graveyard ) {
			// @if CK_DEBUG // console.warn( 'Trying to add a Range that is in the graveyard root. Range rejected.' );

			return;
		}

		const liveRange = LiveRange.fromRange( range );

		// If selection range is moved to the graveyard remove it from the selection object.
		// Also, save some data that can be used to restore selection later, on `Model#applyOperation` event.
		liveRange.on( 'change:range', ( evt, oldRange, data ) => {
			this._hasChangedRange = true;

			if ( liveRange.root == this._document.graveyard ) {
				this._selectionRestorePosition = data.deletionPosition;

				const index = this._ranges.indexOf( liveRange );
				this._ranges.splice( index, 1 );
				liveRange.detach();
			}
		} );

		return liveRange;
	}

	_updateMarkers() {
		if ( !this._observedMarkers.size ) {
			return;
		}

		const markers = [];
		let changed = false;

		for ( const marker of this._model.markers ) {
			const markerGroup = marker.name.split( ':', 1 )[ 0 ];

			if ( !this._observedMarkers.has( markerGroup ) ) {
				continue;
			}

			const markerRange = marker.getRange();

			for ( const selectionRange of this.getRanges() ) {
				if ( markerRange.containsRange( selectionRange, !selectionRange.isCollapsed ) ) {
					markers.push( marker );
				}
			}
		}

		const oldMarkers = Array.from( this.markers );

		for ( const marker of markers ) {
			if ( !this.markers.has( marker ) ) {
				this.markers.add( marker );

				changed = true;
			}
		}

		for ( const marker of Array.from( this.markers ) ) {
			if ( !markers.includes( marker ) ) {
				this.markers.remove( marker );

				changed = true;
			}
		}

		if ( changed ) {
			this.fire( 'change:marker', { oldMarkers, directChange: false } );
		}
	}

	_updateMarker( marker, markerRange ) {
		const markerGroup = marker.name.split( ':', 1 )[ 0 ];

		if ( !this._observedMarkers.has( markerGroup ) ) {
			return;
		}

		let changed = false;

		const oldMarkers = Array.from( this.markers );
		const hasMarker = this.markers.has( marker );

		if ( !markerRange ) {
			if ( hasMarker ) {
				this.markers.remove( marker );
				changed = true;
			}
		} else {
			let contained = false;

			for ( const selectionRange of this.getRanges() ) {
				if ( markerRange.containsRange( selectionRange, !selectionRange.isCollapsed ) ) {
					contained = true;

					break;
				}
			}

			if ( contained && !hasMarker ) {
				this.markers.add( marker );

				changed = true;
			} else if ( !contained && hasMarker ) {
				this.markers.remove( marker );

				changed = true;
			}
		}

		if ( changed ) {
			this.fire( 'change:marker', { oldMarkers, directChange: false } );
		}
	}

	// Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
	//
	// @protected
	// @param {Boolean} clearAll
	// @fires change:attribute
	_updateAttributes( clearAll ) {
		const newAttributes = toMap( this._getSurroundingAttributes() );
		const oldAttributes = toMap( this.getAttributes() );

		if ( clearAll ) {
			// If `clearAll` remove all attributes and reset priorities.
			this._attributePriority = new Map();
			this._attrs = new Map();
		} else {
			// If not, remove only attributes added with `low` priority.
			for ( const [ key, priority ] of this._attributePriority ) {
				if ( priority == 'low' ) {
					this._attrs.delete( key );
					this._attributePriority.delete( key );
				}
			}
		}

		this._setAttributesTo( newAttributes );

		// Let's evaluate which attributes really changed.
		const changed = [];

		// First, loop through all attributes that are set on selection right now.
		// Check which of them are different than old attributes.
		for ( const [ newKey, newValue ] of this.getAttributes() ) {
			if ( !oldAttributes.has( newKey ) || oldAttributes.get( newKey ) !== newValue ) {
				changed.push( newKey );
			}
		}

		// Then, check which of old attributes got removed.
		for ( const [ oldKey ] of oldAttributes ) {
			if ( !this.hasAttribute( oldKey ) ) {
				changed.push( oldKey );
			}
		}

		// Fire event with exact data (fire only if anything changed).
		if ( changed.length > 0 ) {
			this.fire( 'change:attribute', { attributeKeys: changed, directChange: false } );
		}
	}

	// Internal method for setting `LiveSelection` attribute. Supports attribute priorities (through `directChange`
	// parameter).
	//
	// @private
	// @param {String} key Attribute key.
	// @param {*} value Attribute value.
	// @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
	// is caused by `Batch` API.
	// @returns {Boolean} Whether value has changed.
	_setAttribute( key, value, directChange = true ) {
		const priority = directChange ? 'normal' : 'low';

		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
			// Priority too low.
			return false;
		}

		const oldValue = super.getAttribute( key );

		// Don't do anything if value has not changed.
		if ( oldValue === value ) {
			return false;
		}

		this._attrs.set( key, value );

		// Update priorities map.
		this._attributePriority.set( key, priority );

		return true;
	}

	// Internal method for removing `LiveSelection` attribute. Supports attribute priorities (through `directChange`
	// parameter).
	//
	// NOTE: Even if attribute is not present in the selection but is provided to this method, it's priority will
	// be changed according to `directChange` parameter.
	//
	// @private
	// @param {String} key Attribute key.
	// @param {Boolean} [directChange=true] `true` if the change is caused by `Selection` API, `false` if change
	// is caused by `Batch` API.
	// @returns {Boolean} Whether attribute was removed. May not be true if such attributes didn't exist or the
	// existing attribute had higher priority.
	_removeAttribute( key, directChange = true ) {
		const priority = directChange ? 'normal' : 'low';

		if ( priority == 'low' && this._attributePriority.get( key ) == 'normal' ) {
			// Priority too low.
			return false;
		}

		// Update priorities map.
		this._attributePriority.set( key, priority );

		// Don't do anything if value has not changed.
		if ( !super.hasAttribute( key ) ) {
			return false;
		}

		this._attrs.delete( key );

		return true;
	}

	// Internal method for setting multiple `LiveSelection` attributes. Supports attribute priorities (through
	// `directChange` parameter).
	//
	// @private
	// @param {Map.<String,*>} attrs Iterable object containing attributes to be set.
	// @returns {Set.<String>} Changed attribute keys.
	_setAttributesTo( attrs ) {
		const changed = new Set();

		for ( const [ oldKey, oldValue ] of this.getAttributes() ) {
			// Do not remove attribute if attribute with same key and value is about to be set.
			if ( attrs.get( oldKey ) === oldValue ) {
				continue;
			}

			// All rest attributes will be removed so changed attributes won't change .
			this._removeAttribute( oldKey, false );
		}

		for ( const [ key, value ] of attrs ) {
			// Attribute may not be set because of attributes or because same key/value is already added.
			const gotAdded = this._setAttribute( key, value, false );

			if ( gotAdded ) {
				changed.add( key );
			}
		}

		return changed;
	}

	// Returns an iterable that iterates through all selection attributes stored in current selection's parent.
	//
	// @protected
	// @returns {Iterable.<*>}
	* _getStoredAttributes() {
		const selectionParent = this.getFirstPosition().parent;

		if ( this.isCollapsed && selectionParent.isEmpty ) {
			for ( const key of selectionParent.getAttributeKeys() ) {
				if ( key.startsWith( storePrefix ) ) {
					const realKey = key.substr( storePrefix.length );

					yield [ realKey, selectionParent.getAttribute( key ) ];
				}
			}
		}
	}

	// Checks model text nodes that are closest to the selection's first position and returns attributes of first
	// found element. If there are no text nodes in selection's first position parent, it returns selection
	// attributes stored in that parent.
	//
	// @private
	// @returns {Iterable.<*>} Collection of attributes.
	_getSurroundingAttributes() {
		const position = this.getFirstPosition();
		const schema = this._model.schema;

		let attrs = null;

		if ( !this.isCollapsed ) {
			// 1. If selection is a range...
			const range = this.getFirstRange();

			// ...look for a first character node in that range and take attributes from it.
			for ( const value of range ) {
				// If the item is an object, we don't want to get attributes from its children.
				if ( value.item.is( 'element' ) && schema.isObject( value.item ) ) {
					break;
				}

				if ( value.type == 'text' ) {
					attrs = value.item.getAttributes();
					break;
				}
			}
		} else {
			// 2. If the selection is a caret or the range does not contain a character node...

			const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
			const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;

			// When gravity is overridden then don't take node before into consideration.
			if ( !this.isGravityOverridden ) {
				// ...look at the node before caret and take attributes from it if it is a character node.
				attrs = getAttrsIfCharacter( nodeBefore );
			}

			// 3. If not, look at the node after caret...
			if ( !attrs ) {
				attrs = getAttrsIfCharacter( nodeAfter );
			}

			// 4. If not, try to find the first character on the left, that is in the same node.
			// When gravity is overridden then don't take node before into consideration.
			if ( !this.isGravityOverridden && !attrs ) {
				let node = nodeBefore;

				while ( node && !schema.isInline( node ) && !attrs ) {
					node = node.previousSibling;
					attrs = getAttrsIfCharacter( node );
				}
			}

			// 5. If not found, try to find the first character on the right, that is in the same node.
			if ( !attrs ) {
				let node = nodeAfter;

				while ( node && !schema.isInline( node ) && !attrs ) {
					node = node.nextSibling;
					attrs = getAttrsIfCharacter( node );
				}
			}

			// 6. If not found, selection should retrieve attributes from parent.
			if ( !attrs ) {
				attrs = this._getStoredAttributes();
			}
		}

		return attrs;
	}

	// Fixes the selection after all its ranges got removed.
	//
	// @private
	// @param {module:engine/model/position~Position} deletionPosition Position where the deletion happened.
	_fixGraveyardSelection( deletionPosition ) {
		// Find a range that is a correct selection range and is closest to the position where the deletion happened.
		const selectionRange = this._model.schema.getNearestSelectionRange( deletionPosition );

		// If nearest valid selection range has been found - add it in the place of old range.
		if ( selectionRange ) {
			// Check the range, convert it to live range, bind events, etc.
			this._pushRange( selectionRange );
		}
		// If nearest valid selection range cannot be found don't add any range. Selection will be set to the default range.
	}
}

// Helper function for {@link module:engine/model/liveselection~LiveSelection#_updateAttributes}.
//
// It takes model item, checks whether it is a text node (or text proxy) and, if so, returns it's attributes. If not, returns `null`.
//
// @param {module:engine/model/item~Item|null}  node
// @returns {Boolean}
function getAttrsIfCharacter( node ) {
	if ( node instanceof TextProxy || node instanceof Text ) {
		return node.getAttributes();
	}

	return null;
}

// Removes selection attributes from element which is not empty anymore.
//
// @param {module:engine/model/model~Model} model
// @param {module:engine/model/batch~Batch} batch
function clearAttributesStoredInElement( model, batch ) {
	const differ = model.document.differ;

	for ( const entry of differ.getChanges() ) {
		if ( entry.type != 'insert' ) {
			continue;
		}

		const changeParent = entry.position.parent;
		const isNoLongerEmpty = entry.length === changeParent.maxOffset;

		if ( isNoLongerEmpty ) {
			model.enqueueChange( batch, writer => {
				const storedAttributes = Array.from( changeParent.getAttributeKeys() )
					.filter( key => key.startsWith( storePrefix ) );

				for ( const key of storedAttributes ) {
					writer.removeAttribute( key, changeParent );
				}
			} );
		}
	}
}
