define([
	'dojo/_base/declare',
	'dojo/dom-class',
	'dojo/on',
	'dojo/has',
	'dojo/aspect',
	'./List',
	'dojo/has!touch?./util/touch',
	'dojo/query',
	'dojo/_base/sniff',
	'dojo/dom' // for has('css-user-select') in 1.8.2+
], function (declare, domClass, on, has, aspect, List, touchUtil) {

	has.add('dom-comparedocumentposition', function (global, doc, element) {
		return !!element.compareDocumentPosition;
	});

	// Add a feature test for the onselectstart event, which offers a more
	// graceful fallback solution than node.unselectable.
	has.add('dom-selectstart', typeof document.onselectstart !== 'undefined');

	var ctrlEquiv = has('mac') ? 'metaKey' : 'ctrlKey',
		hasUserSelect = has('css-user-select'),
		hasPointer = has('pointer'),
		hasMSPointer = hasPointer && hasPointer.slice(0, 2) === 'MS',
		downType = hasPointer ? hasPointer + (hasMSPointer ? 'Down' : 'down') : 'mousedown',
		upType = hasPointer ? hasPointer + (hasMSPointer ? 'Up' : 'up') : 'mouseup';

	if (hasUserSelect === 'WebkitUserSelect' && typeof document.documentElement.style.msUserSelect !== 'undefined') {
		// Edge defines both webkit and ms prefixes, rendering feature detects as brittle as UA sniffs...
		hasUserSelect = false;
	}

	function makeUnselectable(node, unselectable) {
		// Utility function used in fallback path for recursively setting unselectable
		var value = node.unselectable = unselectable ? 'on' : '',
			elements = node.getElementsByTagName('*'),
			i = elements.length;

		while (--i) {
			if (elements[i].tagName === 'INPUT' || elements[i].tagName === 'TEXTAREA') {
				continue; // Don't prevent text selection in text input fields.
			}
			elements[i].unselectable = value;
		}
	}

	function setSelectable(grid, selectable) {
		// Alternative version of dojo/dom.setSelectable based on feature detection.

		// For FF < 21, use -moz-none, which will respect -moz-user-select: text on
		// child elements (e.g. form inputs).  In FF 21, none behaves the same.
		// See https://developer.mozilla.org/en-US/docs/CSS/user-select
		var node = grid.bodyNode,
			value = selectable ? 'text' : has('ff') < 21 ? '-moz-none' : 'none';

		// In IE10+, -ms-user-select: none will block selection from starting within the
		// element, but will not block an existing selection from entering the element.
		// When using a modifier key, IE will select text inside of the element as well
		// as outside of the element, because it thinks the selection started outside.
		// Therefore, fall back to other means of blocking selection for IE10+.
		// Newer versions of Dojo do not even report msUserSelect (see https://github.com/dojo/dojo/commit/7ae2a43).
		if (hasUserSelect && hasUserSelect !== 'msUserSelect') {
			node.style[hasUserSelect] = value;
		}
		else if (has('dom-selectstart')) {
			// For browsers that don't support user-select but support selectstart (IE<10),
			// we can hook up an event handler as necessary.  Since selectstart bubbles,
			// it will handle any child elements as well.
			// Note, however, that both this and the unselectable fallback below are
			// incapable of preventing text selection from outside the targeted node.
			if (!selectable && !grid._selectstartHandle) {
				grid._selectstartHandle = on(node, 'selectstart', function (evt) {
					var tag = evt.target && evt.target.tagName;

					// Prevent selection except where a text input field is involved.
					if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
						evt.preventDefault();
					}
				});
			}
			else if (selectable && grid._selectstartHandle) {
				grid._selectstartHandle.remove();
				delete grid._selectstartHandle;
			}
		}
		else {
			// For browsers that don't support either user-select or selectstart (Opera),
			// we need to resort to setting the unselectable attribute on all nodes
			// involved.  Since this doesn't automatically apply to child nodes, we also
			// need to re-apply it whenever rows are rendered.
			makeUnselectable(node, !selectable);
			if (!selectable && !grid._unselectableHandle) {
				grid._unselectableHandle = aspect.after(grid, 'renderRow', function (row) {
					makeUnselectable(row, true);
					return row;
				});
			}
			else if (selectable && grid._unselectableHandle) {
				grid._unselectableHandle.remove();
				delete grid._unselectableHandle;
			}
		}
	}

	return declare(null, {
		// summary:
		//		Add selection capabilities to a grid. The grid will have a selection property and
		//		fire "dgrid-select" and "dgrid-deselect" events.

		// selectionDelegate: String
		//		Selector to delegate to as target of selection events.
		selectionDelegate: '.dgrid-row',

		// selectionEvents: String|Function
		//		Event (or comma-delimited events, or extension event) to listen on
		//		to trigger select logic.
		selectionEvents: downType + ',' + upType + ',dgrid-cellfocusin',

		// selectionTouchEvents: String|Function
		//		Event (or comma-delimited events, or extension event) to listen on
		//		in addition to selectionEvents for touch devices.
		selectionTouchEvents: has('touch') ? touchUtil.tap : null,

		// deselectOnRefresh: Boolean
		//		If true, the selection object will be cleared when refresh is called.
		deselectOnRefresh: true,

		// allowSelectAll: Boolean
		//		If true, allow ctrl/cmd+A to select all rows.
		//		Also consulted by the selector plugin for showing select-all checkbox.
		allowSelectAll: false,

		// selection:
		//		An object where the property names correspond to
		//		object ids and values are true or false depending on whether an item is selected
		selection: {},

		// selectionMode: String
		//		The selection mode to use, can be "none", "multiple", "single", or "extended".
		selectionMode: 'extended',

		// allowTextSelection: Boolean
		//		Whether to still allow text within cells to be selected.  The default
		//		behavior is to allow text selection only when selectionMode is none;
		//		setting this property to either true or false will explicitly set the
		//		behavior regardless of selectionMode.
		allowTextSelection: undefined,

		// _selectionTargetType: String
		//		Indicates the property added to emitted events for selected targets;
		//		overridden in CellSelection
		_selectionTargetType: 'rows',

		create: function () {
			this.selection = {};
			return this.inherited(arguments);
		},
		postCreate: function () {
			this.inherited(arguments);

			this._initSelectionEvents();

			// Force selectionMode setter to run
			var selectionMode = this.selectionMode;
			this.selectionMode = '';
			this._setSelectionMode(selectionMode);
		},

		destroy: function () {
			this.inherited(arguments);

			// Remove any extra handles added by Selection.
			if (this._selectstartHandle) {
				this._selectstartHandle.remove();
			}
			if (this._unselectableHandle) {
				this._unselectableHandle.remove();
			}
			if (this._removeDeselectSignals) {
				this._removeDeselectSignals();
			}
		},

		_setSelectionMode: function (mode) {
			// summary:
			//		Updates selectionMode, resetting necessary variables.

			if (mode === this.selectionMode) {
				return;
			}

			// Start selection fresh when switching mode.
			this.clearSelection();

			this.selectionMode = mode;

			// Compute name of selection handler for this mode once
			// (in the form of _fooSelectionHandler)
			this._selectionHandlerName = '_' + mode + 'SelectionHandler';

			// Also re-run allowTextSelection setter in case it is in automatic mode.
			this._setAllowTextSelection(this.allowTextSelection);
		},

		_setAllowTextSelection: function (allow) {
			if (typeof allow !== 'undefined') {
				setSelectable(this, allow);
			}
			else {
				setSelectable(this, this.selectionMode === 'none');
			}
			this.allowTextSelection = allow;
		},

		_handleSelect: function (event, target) {
			// Don't run if selection mode doesn't have a handler (incl. "none"), target can't be selected,
			// or if coming from a dgrid-cellfocusin from a mousedown
			if (!this[this._selectionHandlerName] || !this.allowSelect(this.row(target)) ||
					(event.type === 'dgrid-cellfocusin' && event.parentType === 'mousedown') ||
					(event.type === upType && target !== this._waitForMouseUp)) {
				return;
			}
			this._waitForMouseUp = null;
			this._selectionTriggerEvent = event;

			// Don't call select handler for ctrl+navigation
			if (!event.keyCode || !event.ctrlKey || event.keyCode === 32) {
				// If clicking a selected item, wait for mouseup so that drag n' drop
				// is possible without losing our selection
				if (!event.shiftKey && event.type === downType && this.isSelected(target)) {
					this._waitForMouseUp = target;
				}
				else {
					this[this._selectionHandlerName](event, target);
				}
			}
			this._selectionTriggerEvent = null;
		},

		_singleSelectionHandler: function (event, target) {
			// summary:
			//		Selection handler for "single" mode, where only one target may be
			//		selected at a time.

			var ctrlKey = event.keyCode ? event.ctrlKey : event[ctrlEquiv];
			if (this._lastSelected === target) {
				// Allow ctrl to toggle selection, even within single select mode.
				this.select(target, null, !ctrlKey || !this.isSelected(target));
			}
			else {
				this.clearSelection();
				this.select(target);
				this._lastSelected = target;
			}
		},

		_multipleSelectionHandler: function (event, target) {
			// summary:
			//		Selection handler for "multiple" mode, where shift can be held to
			//		select ranges, ctrl/cmd can be held to toggle, and clicks/keystrokes
			//		without modifier keys will add to the current selection.

			var lastRow = this._lastSelected,
				ctrlKey = event.keyCode ? event.ctrlKey : event[ctrlEquiv],
				value;

			if (!event.shiftKey) {
				// Toggle if ctrl is held; otherwise select
				value = ctrlKey ? null : true;
				lastRow = null;
			}
			this.select(target, lastRow, value);

			if (!lastRow) {
				// Update reference for potential subsequent shift+select
				// (current row was already selected above)
				this._lastSelected = target;
			}
		},

		_extendedSelectionHandler: function (event, target) {
			// summary:
			//		Selection handler for "extended" mode, which is like multiple mode
			//		except that clicks/keystrokes without modifier keys will clear
			//		the previous selection.

			// Clear selection first for right-clicks outside selection and non-ctrl-clicks;
			// otherwise, extended mode logic is identical to multiple mode
			if (event.button === 2 ? !this.isSelected(target) :
					!(event.keyCode ? event.ctrlKey : event[ctrlEquiv])) {
				this.clearSelection(null, true);
			}
			this._multipleSelectionHandler(event, target);
		},

		_toggleSelectionHandler: function (event, target) {
			// summary:
			//		Selection handler for "toggle" mode which simply toggles the selection
			//		of the given target.  Primarily useful for touch input.

			this.select(target, null, null);
		},

		_initSelectionEvents: function () {
			// summary:
			//		Performs first-time hookup of event handlers containing logic
			//		required for selection to operate.

			var grid = this,
				contentNode = this.contentNode,
				selector = this.selectionDelegate;

			this._selectionEventQueues = {
				deselect: [],
				select: []
			};

			if (has('touch') && !has('pointer') && this.selectionTouchEvents) {
				// Listen for taps, and also for mouse/keyboard, making sure not
				// to trigger both for the same interaction
				on(contentNode, touchUtil.selector(selector, this.selectionTouchEvents), function (evt) {
					grid._handleSelect(evt, this);
					grid._ignoreMouseSelect = this;
				});
				on(contentNode, on.selector(selector, this.selectionEvents), function (event) {
					if (grid._ignoreMouseSelect !== this) {
						grid._handleSelect(event, this);
					}
					else if (event.type === upType) {
						grid._ignoreMouseSelect = null;
					}
				});
			}
			else {
				// Listen for mouse/keyboard actions that should cause selections
				on(contentNode, on.selector(selector, this.selectionEvents), function (event) {
					grid._handleSelect(event, this);
				});
			}

			// Also hook up spacebar (for ctrl+space)
			if (this.addKeyHandler) {
				this.addKeyHandler(32, function (event) {
					grid._handleSelect(event, event.target);
				});
			}

			// If allowSelectAll is true, bind ctrl/cmd+A to (de)select all rows,
			// unless the event was received from an editor component.
			// (Handler further checks against _allowSelectAll, which may be updated
			// if selectionMode is changed post-init.)
			if (this.allowSelectAll) {
				this.on('keydown', function (event) {
					if (event[ctrlEquiv] && event.keyCode === 65 &&
							!/\bdgrid-input\b/.test(event.target.className)) {
						event.preventDefault();
						grid[grid.allSelected ? 'clearSelection' : 'selectAll']();
					}
				});
			}

			// Update aspects if there is a collection change
			if (this._setCollection) {
				aspect.before(this, '_setCollection', function (collection) {
					grid._updateDeselectionAspect(collection);
				});
			}
			this._updateDeselectionAspect();
		},

		_updateDeselectionAspect: function (collection) {
			// summary:
			//		Hooks up logic to handle deselection of removed items.
			//		Aspects to a trackable collection's notify method if applicable,
			//		or to the list/grid's removeRow method otherwise.

			var self = this,
				signals;

			function ifSelected(rowArg, methodName) {
				// Calls a method if the row corresponding to the object is selected.
				var row = self.row(rowArg),
					selection = row && self.selection[row.id];
				// Is the row currently in the selection list.
				if (selection) {
					self[methodName](row);
				}
			}

			// Remove anything previously configured
			if (this._removeDeselectSignals) {
				this._removeDeselectSignals();
			}

			if (collection && collection.track && this._observeCollection) {
				signals = [
					aspect.before(this, '_observeCollection', function (collection) {
						signals.push(
							collection.on('delete', function (event) {
								if (typeof event.index === 'undefined') {
									// Call deselect on the row if the object is being removed.  This allows the
									// deselect event to reference the row element while it still exists in the DOM.
									ifSelected(event.id, 'deselect');
								}
							})
						);
					}),
					aspect.after(this, '_observeCollection', function (collection) {
						signals.push(
							collection.on('update', function (event) {
								if (typeof event.index !== 'undefined') {
									// When List updates an item, the row element is removed and a new one inserted.
									// If at this point the object is still in grid.selection,
									// then call select on the row so the element's CSS is updated.
									ifSelected(collection.getIdentity(event.target), 'select');
								}
							})
						);
					}, true)
				];
			}
			else {
				signals = [
					aspect.before(this, 'removeRow', function (rowElement, preserveDom) {
						var row;
						if (!preserveDom) {
							row = this.row(rowElement);
							// if it is a real row removal for a selected item, deselect it
							if (row && (row.id in this.selection)) {
								this.deselect(row);
							}
						}
					})
				];
			}

			this._removeDeselectSignals = function () {
				for (var i = signals.length; i--;) {
					signals[i].remove();
				}
				signals = [];
			};
		},

		allowSelect: function () {
			// summary:
			//		A method that can be overriden to determine whether or not a row (or
			//		cell) can be selected. By default, all rows (or cells) are selectable.
			// target: Object
			//		Row object (for Selection) or Cell object (for CellSelection) for the
			//		row/cell in question
			return true;
		},

		_fireSelectionEvent: function (type) {
			// summary:
			//		Fires an event for the accumulated rows once a selection
			//		operation is finished (whether singular or for a range)

			var queue = this._selectionEventQueues[type],
				triggerEvent = this._selectionTriggerEvent,
				eventObject;

			eventObject = {
				bubbles: true,
				grid: this
			};
			if (triggerEvent) {
				eventObject.parentType = triggerEvent.type;
			}
			eventObject[this._selectionTargetType] = queue;

			// Clear the queue so that the next round of (de)selections starts anew
			this._selectionEventQueues[type] = [];

			on.emit(this.contentNode, 'dgrid-' + type, eventObject);
		},

		_fireSelectionEvents: function () {
			var queues = this._selectionEventQueues,
				type;

			for (type in queues) {
				if (queues[type].length) {
					this._fireSelectionEvent(type);
				}
			}
		},

		_select: function (row, toRow, value) {
			// summary:
			//		Contains logic for determining whether to select targets, but
			//		does not emit events.  Called from select, deselect, selectAll,
			//		and clearSelection.

			var selection,
				previousValue,
				element,
				toElement,
				direction;

			if (typeof value === 'undefined') {
				// default to true
				value = true;
			}
			if (!row.element) {
				row = this.row(row);
			}

			// Check whether we're allowed to select the given row before proceeding.
			// If a deselect operation is being performed, this check is skipped,
			// to avoid errors when changing column definitions, and since disabled
			// rows shouldn't ever be selected anyway.
			if (value === false || this.allowSelect(row)) {
				selection = this.selection;
				previousValue = !!selection[row.id];
				if (value === null) {
					// indicates a toggle
					value = !previousValue;
				}
				element = row.element;
				if (!value && !this.allSelected) {
					delete this.selection[row.id];
				}
				else {
					selection[row.id] = value;
				}
				if (element) {
					// add or remove classes as appropriate
					if (value) {
						domClass.add(element, 'dgrid-selected' +
							(this.addUiClasses ? ' ui-state-active' : ''));
					}
					else {
						domClass.remove(element, 'dgrid-selected ui-state-active');
					}
				}
				if (value !== previousValue && element) {
					// add to the queue of row events
					this._selectionEventQueues[(value ? '' : 'de') + 'select'].push(row);
				}

				if (toRow) {
					if (!toRow.element) {
						toRow = this.row(toRow);
					}

					if (!toRow) {
						this._lastSelected = element;
						console.warn('The selection range has been reset because the ' +
							'beginning of the selection is no longer in the DOM. ' +
							'If you are using OnDemandList, you may wish to increase ' +
							'farOffRemoval to avoid this, but note that keeping more nodes ' +
							'in the DOM may impact performance.');
						return;
					}

					toElement = toRow.element;
					if (toElement) {
						direction = this._determineSelectionDirection(element, toElement);
						if (!direction) {
							// The original element was actually replaced
							toElement = document.getElementById(toElement.id);
							direction = this._determineSelectionDirection(element, toElement);
						}
						while (row.element !== toElement && (row = this[direction](row))) {
							this._select(row, null, value);
						}
					}
				}
			}
		},

		// Implement _determineSelectionDirection differently based on whether the
		// browser supports element.compareDocumentPosition; use sourceIndex for IE<9
		_determineSelectionDirection: has('dom-comparedocumentposition') ? function (from, to) {
			var result = to.compareDocumentPosition(from);
			if (result & 1) {
				return false; // Out of document
			}
			return result === 2 ? 'down' : 'up';
		} : function (from, to) {
			if (to.sourceIndex < 1) {
				return false; // Out of document
			}
			return to.sourceIndex > from.sourceIndex ? 'down' : 'up';
		},

		select: function (row, toRow, value) {
			// summary:
			//		Selects or deselects the given row or range of rows.
			// row: Mixed
			//		Row object (or something that can resolve to one) to (de)select
			// toRow: Mixed
			//		If specified, the inclusive range between row and toRow will
			//		be (de)selected
			// value: Boolean|Null
			//		Whether to select (true/default), deselect (false), or toggle
			//		(null) the row

			this._select(row, toRow, value);
			this._fireSelectionEvents();
		},
		deselect: function (row, toRow) {
			// summary:
			//		Deselects the given row or range of rows.
			// row: Mixed
			//		Row object (or something that can resolve to one) to deselect
			// toRow: Mixed
			//		If specified, the inclusive range between row and toRow will
			//		be deselected

			this.select(row, toRow, false);
		},

		clearSelection: function (exceptId, dontResetLastSelected) {
			// summary:
			//		Deselects any currently-selected items.
			// exceptId: Mixed?
			//		If specified, the given id will not be deselected.

			this.allSelected = false;
			for (var id in this.selection) {
				if (exceptId !== id) {
					this._select(id, null, false);
				}
			}
			if (!dontResetLastSelected) {
				this._lastSelected = null;
			}
			this._fireSelectionEvents();
		},
		selectAll: function () {
			this.allSelected = true;
			this.selection = {}; // we do this to clear out pages from previous sorts
			for (var i in this._rowIdToObject) {
				var row = this.row(this._rowIdToObject[i]);
				this._select(row.id, null, true);
			}
			this._fireSelectionEvents();
		},

		isSelected: function (object) {
			// summary:
			//		Returns true if the indicated row is selected.

			if (typeof object === 'undefined' || object === null) {
				return false;
			}
			if (!object.element) {
				object = this.row(object);
			}

			// First check whether the given row is indicated in the selection hash;
			// failing that, check if allSelected is true (testing against the
			// allowSelect method if possible)
			return (object.id in this.selection) ? !!this.selection[object.id] :
				this.allSelected && (!object.data || this.allowSelect(object));
		},

		refresh: function () {
			if (this.deselectOnRefresh) {
				this.clearSelection();
			}
			this._lastSelected = null;
			return this.inherited(arguments);
		},

		renderArray: function () {
			var rows = this.inherited(arguments),
				selection = this.selection,
				i,
				row,
				selected;

			for (i = 0; i < rows.length; i++) {
				row = this.row(rows[i]);
				selected = row.id in selection ? selection[row.id] : this.allSelected;
				if (selected) {
					this.select(row, null, selected);
				}
			}
			this._fireSelectionEvents();
			return rows;
		}
	});
});
