/** @module delite/KeyNav */
define([
	"dcl/dcl",
	"ibm-decor/sniff",
	"./on",
	"./scrollIntoView",
	"./Widget"
], function (
	dcl,
	has,
	on,
	scrollIntoView,
	Widget
) {
	/**
	 * Dispatched after the user has selected a different descendant, by clicking, arrow keys,
	 * or keyboard search.
	 * @example
	 * widget.on("keynav-child-navigated", function (evt) {
	 *	console.log("old value: " + evt.oldValue);
	 *	console.log("new value: " + evt.newValue);
	 * }
	 * @event module:delite/KeyNav#keynav-child-navigated
	 * @property {number} oldValue - The previously selected item.
	 * @property {number} newValue - The new selected item.
	 */

	/**
	 * Return true if node is an `<input>` or similar that responds to keyboard input.
	 * @param {Element} node
	 * @returns {boolean}
	 */
	function takesInput (node) {
		var tag = node.nodeName.toLowerCase();

		return !node.readOnly && (tag === "textarea" || (tag === "input" &&
			/^(color|email|number|password|search|tel|text|url|range)$/.test(node.type)));
	}

	/**
	 * Return true if node is "clickable" via keyboard space / enter key:
	 *
	 * - buttons
	 * - links
	 * - checkboxes and radio buttons
	 *
	 * Does not [yet] handle aria equivalents (<div role=button> etc.).
	 *
	 * @param {Element} node
	 * @returns {boolean}
	 */
	function keyboardClickable (node) {
		return !node.readOnly && /^(button|a|input)$/i.test(node.nodeName);
	}

	// To workaround iOS VoiceOver bug, track when user has just closed a dropdown.
	var lastPopupHideTime = 0;
	if (has("ios")) {
		document.addEventListener("delite-after-hide", function () {
			lastPopupHideTime =  (new Date()).getTime();
		});
	}

	/**
	  * A mixin to allow arrow key and letter key navigation of child Elements.
	  * It can be used by delite/Container based widgets with a flat list of children,
	  * or more complex widgets like a Tree.
	  *
	  * To use this mixin, the subclass must:
	  *
	  * - Implement one method for each keystroke that the subclass wants to handle.
	  *   The methods for up and down arrow keys are `upKeyHandler() and `downKeyHandler()`.
	  *   For BIDI support, the left and right arrows are handled specially, mapped to the `previousKeyHandler()`
	  *   and `nextKeyHandler()` methods in LTR mode, or reversed in RTL mode.
	  *   Otherwise, the method name is based on the key names
	  *   defined by https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key, for example `homeKeyHandler()`.
	  *   The method takes two parameters: the event, and the currently navigated node.
	  *   Most subclasses will want to implement either `previousKeyHandler()`
	  *   and `nextKeyHandler()`, or `downKeyHandler()` and `upKeyHandler()`.
	  *   The method should return true if the keystroke was handled, or false if not.
	  *   For backwards compatibility, not returning any value also indicates that the keystroke was handled.
	  * - Set all navigable descendants' initial tabIndex to "-1"; both initial descendants and any
	  *   descendants added later, by for example `addChild()`.  Exception: if `focusDescendants` is false then the
	  *   descendants shouldn't have any tabIndex at all.
	  * - Define `descendantSelector` as a function or string that identifies navigable child Elements.
	  * - If the descendant elements contain text, they should have a label attribute.  KeyNav uses the label
	  *   attribute for letter key navigation.
	  *
	  * @mixin module:delite/KeyNav
	  * @augments module:delite/Widget
	  */
	return dcl(Widget, /** @lends module:delite/KeyNav# */ {
		declaredClass: "delite/KeyNav",

		/**
		 * When true, focus the descendant widgets as the user navigates to them via arrow keys or keyboard letter
		 * search.  When false, rather than focusing the widgets, it merely sets `navigatedDescendant`,
		 * and sets the `d-active-descendant` class on the descendant widget the user has navigated to.
		 *
		 * False mode is intended for widgets like ComboBox where the focus is somewhere outside this widget
		 * (typically on an `<input>`) and keystrokes are merely being forwarded to the KeyNav widget.
		 *
		 * When set to false:
		 *
		 * - Navigable descendants shouldn't have any tabIndex (as opposed to having tabIndex=-1).
		 * - The focused element should specify `aria-owns` to point to this KeyNav Element.
		 * - The focused Element must be kept synced so that `aria-activedescendant` points to the currently
		 *   navigated descendant.  Do this responding to the `keynav-child-navigated` event emitted by this widget,
		 *   or by calling `observe()` and monitoring changed to `navigatedDescendant`.
		 * - The focused Element must forward keystrokes by calling `emit("keydown", ...)` and/or
		 *   `emit("keypress", ...)` on this widget.
		 * - You must somehow set the initial navigated descendant, typically by calling `navigateToFirst()` either
		 *   when the the dropdown is opened, or on the first call to `downKeyHandler()`.
		 * - You must have some CSS styling so that the currently navigated node is apparent.
		 *
		 * See http://www.w3.org/WAI/GL/wiki/Using_aria-activedescendant_to_allow_changes_in_focus_within_widgets_to_be_communicated_to_Assistive_Technology#Example_1:_Combobox
		 * for details.
		 * @member {boolean}
		 * @default true
		 * @protected
		 */
		focusDescendants: true,

		/**
		 * The currently navigated descendant, or null if there isn't one.
		 * @member {Element}
		 * @readonly
		 * @protected
		 */
		navigatedDescendant: null,

		/**
		 * Selector to identify which descendant Elements are navigable via arrow keys or
		 * keyboard search.  Note that for subclasses like a Tree, one navigable node could be a descendant of another.
		 *
		 * It's either a function that takes an Element parameter and returns true/false,
		 * or a CSS selector string, for example ".list-item".
		 *
		 * By default, the direct DOM children of this widget are considered the selectable descendants.
		 *
		 * @member {string|Function}
		 * @protected
		 * @constant
		 */
		descendantSelector: null,

		/**
		 * The node to receive the KeyNav behavior.
		 * Can be set in a template via a `attach-point` assignment.
		 * If missing, then `this.containerNode` or `this` will be used.
		 * If set, then subclass must set the tabIndex on this node rather than the root node.
		 * @member {Element}
		 * @protected
		 */
		keyNavContainerNode: null,

		/**
		 * Figure out effective navigable descendant of this event.
		 * @param {Event} evt
		 * @private
		 */
		_getTargetElement: function (evt) {
			for (var child = evt.target; child !== this; child = child.parentNode) {
				if (this.isNavigable(child)) {
					return child;
				}
			}
			return null;
		},

		afterInitializeRendering: function () {
			// If keyNavContainerNode unspecified, set to default value.
			if (!this.keyNavContainerNode) {
				this.keyNavContainerNode = this.containerNode || this;
			}

			// containerNode.focus() called when this is the first element in a Dialog (a11y.getFirstInTabbingOrder()).
			if (this.containerNode && this.containerNode !== this) {
				this.containerNode.focus = function () {
					// Focus first navigable descendant.
					this.focus();
				}.bind(this);
			}

			this.on("keypress", this._keynavKeyPressHandler.bind(this), this.keyNavContainerNode);
			this.on("keydown", this._keynavKeyDownHandler.bind(this), this.keyNavContainerNode);

			this.on("pointerdown", this.pointerdownHandler.bind(this), this.keyNavContainerNode);
			this.on("pointerup", this.pointerupHandler.bind(this), this.keyNavContainerNode);
			this.on("focusin", this.focusinHandler.bind(this), this.keyNavContainerNode);
			this.on("focusout", this.focusoutHandler.bind(this), this.keyNavContainerNode);
		},

		/**
		 * Test if specified element is navigable.
		 */
		isNavigable: function (elem) {
			// Setup function to check which child nodes are navigable.
			if (typeof this.descendantSelector === "string") {
				return elem.matches(this.descendantSelector);
			} else if (this.descendantSelector) {
				return this.descendantSelector(elem);
			} else {
				return elem.parentNode === this.containerNode;
			}
		},

		connectedCallback: dcl.after(function () {
			// If the user hasn't specified a tabindex declaratively, then set to default value.
			var container = this.keyNavContainerNode;
			if (this.focusDescendants && !container.hasAttribute("tabindex")) {
				container.tabIndex = "0";
			}
		}),

		/**
		 * Called on pointerdown event (on container or child of container).
		 */
		pointerdownHandler: function (evt) {
			// Focusin handler needs to differentiate between focusin from pointer or tab/shift-tab.
			this._pointerOperation = true;

			// Navigation occurs on pointerdown, to match behavior of native elements.
			// Normally this handler isn't needed as it's redundant w/the focusin event.
			var target = this._getTargetElement(evt);
			if (target) {
				this._descendantNavigateHandler(target, evt);
			}
		},

		/**
		 * Called on pointerup event (on container or child of container).
		 */
		pointerupHandler: function () {
			// Clear _pointerOperation after focusinHandler() has been called.  Note that 0ms is not long
			// enough in some cases on Firefox, although that problem doesn't reproduce in the delite tests.
			this.defer(function () {
				delete this._pointerOperation;
			}, 10);
		},

		/**
		 * Called on focus of container or child of container.
		 */
		focusinHandler: function (evt) {
			var container = this.keyNavContainerNode;
			if (this.focusDescendants) {
				// Compute if user tabbed into this widget.  lastPopupHideTime condition is for when user clicks a
				// button in a TooltipDialog and VoiceOver (due to a bug) focuses the checkbox etc. behind the
				// TooltipDialog.  That should not be treated as focus by tab.
				var now = (new Date()).getTime();
				var focusByTab = !this._pointerOperation && !this._programmaticallyFocusing &&
					now > lastPopupHideTime + 1000;

				if (focusByTab && !container.contains(evt.relatedTarget)) {
					// When tabbing/shift-tabbing into this widget, focus the first child but do it on a delay so that
					// activationTracker sees my "focus" event before seeing the "focus" event on the child widget.
					// Note that shift-tab (from outside this widget) might go to an embedded widget rather than
					// keyNavContainerNode.
					this.defer(this.focus);
				} else {
					// When container's descendant gets focus,
					// remove the container's tabIndex so that tab or shift-tab
					// will go to the fields after/before the container, rather than the container itself.
					// Also avoids Safari and Firefox problems with nested focusable elements.
					if (container.hasAttribute("tabindex")) {
						this._savedTabIndex = container.tabIndex;
						container.removeAttribute("tabindex");
					}

					// Handling for when navigatedDescendant or a node inside a navigableDescendant gets focus.
					var navigatedDescendant = this._getTargetElement(evt);
					if (navigatedDescendant) {
						if (evt.target === navigatedDescendant) {
							// If the navigable descendant itself is focused, then set tabIndex=0 so that tab and
							// shift-tab work correctly.
							navigatedDescendant.tabIndex = this._savedTabIndex;
						}

						// Note: when focus is moved outside the navigable descendant,
						// focusoutHandler() resets its tabIndex to -1.

						this._descendantNavigateHandler(navigatedDescendant, evt);
					}
				}
			}
		},

		/**
		 * Called on blur of container or child of container.
		 */
		focusoutHandler: function (evt) {
			if (this.focusDescendants) {
				// Note: don't use this.navigatedDescendant because it may or may not have already been
				// updated to point to the new descendant, depending on if navigation was by mouse
				// or keyboard.
				var previouslyNavigatedDescendant = this._getTargetElement(evt);
				if (previouslyNavigatedDescendant) {
					if (previouslyNavigatedDescendant !== evt.relatedTarget) {
						// If focus has moved outside of the previously navigated descendant, then set its
						// tabIndex back to -1, for future time when navigable descendant is clicked.
						previouslyNavigatedDescendant.tabIndex = "-1";
						previouslyNavigatedDescendant.classList.remove("d-active-descendant");

						if (this.navigatedDescendant === previouslyNavigatedDescendant) {
							this.navigatedDescendant = null;
						}
					}
				}

				// If focus has moved outside of container, then restore container's tabindex.
				if ("_savedTabIndex" in this && !this.keyNavContainerNode.contains(evt.relatedTarget)) {
					this.keyNavContainerNode.setAttribute("tabindex", this._savedTabIndex);
					delete this._savedTabIndex;
				}
			}
		},

		/**
		 * Called on home key.
		 * @param {Event} evt
		 * @param {Element} navigatedDescendant
		 * @protected
		 */
		homeKeyHandler: function (evt) {
			this.navigateToFirst(evt);
		},

		/**
		 * Called on end key.
		 * @param {Event} evt
		 * @param {Element} navigatedDescendant
		 * @protected
		 */
		endKeyHandler: function (evt) {
			this.navigateToLast(evt);
		},

		/**

		/**
		 * Default focus() implementation: navigate to the first navigable descendant.
		 * Note that if `focusDescendants` is false, this will merely set the `d-active-descendant` class
		 * rather than actually focusing the descendant.
		 */
		focus: function () {
			this.navigateToFirst();
		},

		/**
		 * Navigate to the first navigable descendant.
		 * Note that if `focusDescendants` is false, this will merely set the `d-active-descendant` class
		 * rather than actually focusing the descendant.
		 * @param {Event} [triggerEvent] - The event that lead to the navigation, or `undefined`
		 *     if the navigation is triggered programmatically.
		 * @protected
		 */
		navigateToFirst: function (triggerEvent) {
			this.navigateTo(this.getNext(this.keyNavContainerNode, 1), triggerEvent);
		},

		/**
		 * Navigate to the last navigable descendant.
		 * Note that if `focusDescendants` is false, this will merely set the `d-active-descendant` class
		 * rather than actually focusing the descendant.
		 * @param {Event} [triggerEvent] - The event that lead to the navigation, or `undefined`
		 *     if the navigation is triggered programmatically.
		 * @protected
		 */
		navigateToLast: function (triggerEvent) {
			this.navigateTo(this.getNext(this.keyNavContainerNode, -1), triggerEvent);
		},

		/**
		 * Navigate to the specified descendant.
		 * Note that if `focusDescendants` is false, this will merely set the `d-active-descendant` class
		 * rather than actually focusing the descendant.
		 * @param {Element} child - Reference to the descendant.
		 * @param {Event} [triggerEvent] - The event that lead to the navigation, or `undefined`
		 *     if the navigation is triggered programmatically.
		 * @protected
		 */
		navigateTo: function (child, triggerEvent) {
			if (this.focusDescendants) {
				// For IE focus outline to appear, must set tabIndex before focus.
				// If this._savedTabIndex is set, use it instead of this.tabIndex, because it means
				// the container's tabIndex has already been changed to -1.
				child.tabIndex = "_savedTabIndex" in this ? this._savedTabIndex : this.keyNavContainerNode.tabIndex;
				this._programmaticallyFocusing = true;
				child.focus();
				delete this._programmaticallyFocusing;

				// _descendantNavigateHandler() will be called automatically from child's focus event.
			} else {
				scrollIntoView(child);
				this._descendantNavigateHandler(child, triggerEvent);
			}
		},

		/**
		 * Called when a child is navigated to, either by user clicking it, or programatically by arrow key handling
		 * code.  It marks that the specified child is the navigated one.
		 * @param {Element} child
		 * @param {Event} triggerEvent - The event that lead to the navigation, or `undefined`
		 *     if the navigation is triggered programmatically.
		 * @fires module:delite/KeyNav#keynav-child-navigated
		 * @private
		 */
		_descendantNavigateHandler: function (child, triggerEvent) {
			if (child && child !== this.navigatedDescendant) {
				if (this.navigatedDescendant) {
					this.navigatedDescendant.classList.remove("d-active-descendant");
					this.navigatedDescendant.tabIndex = "-1";
				}

				this.emit("keynav-child-navigated", {
					oldValue: this.navigatedDescendant,
					newValue: child,
					triggerEvent: triggerEvent
				});

				// mark that the new node is the currently navigated one
				this.navigatedDescendant = child;
				if (child) {
					child.classList.add("d-active-descendant");
				}
			}
		},

		_searchString: "",

		/**
		 * If multiple characters are typed where each keystroke happens within
		 * multiCharSearchDuration of the previous keystroke,
		 * search for nodes matching all the keystrokes.
		 *
		 * For example, typing "ab" will search for entries starting with
		 * "ab" unless the delay between "a" and "b" is greater than `multiCharSearchDuration`.
		 *
		 * @member {number} KeyNav#multiCharSearchDuration
		 * @default 1000
		 */
		multiCharSearchDuration: 1000,

		/**
		 * When a key is pressed that matches a child item,
		 * this method is called so that a widget can take appropriate action if necessary.
		 *
		 * @param {Element} item
		 * @param {Event} evt
		 * @param {string} searchString
		 * @param {number} numMatches
		 * @private
		 */
		_keyboardSearchHandler: function (item/*, evt, searchString, numMatches*/) {
			if (item) {
				this.navigateTo(item);
			}
		},

		/**
		 * Compares the searchString to the Element's text label, returning:
		 *
		 * - -1: a high priority match  and stop searching
		 * - 0: not a match
		 * - 1: a match but keep looking for a higher priority match
		 *
		 * @param {Element} item
		 * @param {string} searchString
		 * @returns {number}
		 * @private
		 */
		_keyboardSearchCompare: function (item, searchString) {
			var element = item,
				text = item.label || (element.focusNode ? element.focusNode.label : "") || element.textContent || "",
				currentString = text.replace(/^\s+/, "").substr(0, searchString.length).toLowerCase();

			// stop searching after first match by default
			return (!!searchString.length && currentString === searchString) ? -1 : 0;
		},

		/**
		 * Called when keydown.  Ignores key events inside <input>, <button>, etc.,
		 * and passes the other events to the _processKeyDown() method.
		 * @param {Event} evt
		 * @private
		 */
		_keynavKeyDownHandler: function (evt) {
			// Ignore left, right, home, end, and space on <input> controls.
			if (takesInput(evt.target) &&
				(evt.key === "ArrowLeft" || evt.key === "ArrowRight" ||
				evt.key === "Home" || evt.key === "End" || evt.key === "Spacebar")) {
				return;
			}

			// Ignore space and enter on <button> elements.
			if (keyboardClickable(evt.target) && (evt.key === "Enter" || evt.key === "Spacebar")) {
				return;
			}

			this._processKeyDown(evt);
		},

		/**
		 * Called when there's a keydown event that should be handled by the KeyNav class.
		 * @param evt
		 * @private
		 */
		_processKeyDown: function (evt) {
			if (evt.key === "Spacebar" && this._searchTimer && !(evt.ctrlKey || evt.altKey || evt.metaKey)) {
				// If the user types some string like "new york", interpret the space as part of the search rather
				// than to perform some action, even if there is a key handler method defined.

				// Stop IE from scrolling, and most browsers (except FF) from emitting keypress event.
				evt.preventDefault();

				this._keyboardSearch(evt, " ");
			} else {
				// Otherwise call the handler specified in this.keyHandlers.
				this._applyKeyHandler(evt);
			}
		},

		/**
		 * If the class has defined a method to handle the specified key, then call it.
		 * See the description of `KeyNav` for details on how to define methods.
		 * @param {Event} evt
		 * @private
		 */
		_applyKeyHandler: function (evt) {
			// Get name of method to call
			var methodName;
			switch (evt.key) {
			case "ArrowLeft":
				methodName = this.effectiveDir === "rtl" ? "nextKeyHandler" : "previousKeyHandler";
				break;
			case "ArrowRight":
				methodName = this.effectiveDir === "rtl" ? "previousKeyHandler" : "nextKeyHandler";
				break;
			case "ArrowUp":
			case "ArrowDown":
				methodName = evt.key.charAt(5).toLowerCase() + evt.key.substr(6) + "KeyHandler";
				break;
			default:
				methodName = evt.key.charAt(0).toLowerCase() + evt.key.substr(1) + "KeyHandler";
			}

			// Call it
			var func = this[methodName];
			if (func) {
				var handled = func.call(this, evt, this.navigatedDescendant);

				// Cancel the event if the handler function returns true, or, for backwards compatibility,
				// if it doesn't return a value at all.
				if (handled !== false) {
					evt.stopPropagation();
					evt.preventDefault();
				}

				this._searchString = ""; // so a DOWN_ARROW b doesn't search for ab
			}
		},

		/**
		 * When a printable key is pressed, it's handled here, searching by letter.
		 * @param {Event} evt
		 * @private
		 */
		_keynavKeyPressHandler: function (evt) {
			// Ignore:
			//		- keystrokes on <input> and <textarea>
			// 		- duplicate events on firefox (ex: arrow key that will be handled by keydown handler)
			//		- control sequences like CMD-Q.
			//		- the SPACE key (only occurs on FF)
			//
			// Note: if there's no search in progress, then SPACE should be ignored.   If there is a search
			// in progress, then SPACE is handled in _keynavKeyDownHandler.
			if (takesInput(evt.target) || evt.charCode <= 32 || evt.ctrlKey || evt.altKey || evt.metaKey) {
				return;
			}

			evt.preventDefault();
			evt.stopPropagation();

			this._keyboardSearch(evt, evt.key.toLowerCase());
		},

		/**
		 * Perform a search of the widget's options based on the user's keyboard activity.
		 *
		 * Called on keypress (and sometimes keydown), searches through this widget's children
		 * looking for items that match the user's typed search string.  Multiple characters
		 * typed within `multiCharSearchDuration` of each other are combined for multi-character searching.
		 * @param {Event} evt
		 * @param {string} keyChar
		 * @private
		 */
		_keyboardSearch: function (evt, keyChar) {
			var
				matchedItem = null,
				searchString,
				numMatches = 0;

			if (this._searchTimer) {
				this._searchTimer.remove();
			}
			this._searchString += keyChar;
			var allSameLetter = /^(.)\1*$/.test(this._searchString);
			var searchLen = allSameLetter ? 1 : this._searchString.length;
			searchString = this._searchString.substr(0, searchLen);
			this._searchTimer = this.defer(function () { // this is the "success" timeout
				this._searchTimer = null;
				this._searchString = "";
			}, this.multiCharSearchDuration);
			var currentItem = this.navigatedDescendant || null;
			if (searchLen === 1 || !currentItem) {
				currentItem = this.getNext(currentItem, 1); // skip current
				if (!currentItem) {
					return;
				} // no items
			}
			var stop = currentItem;
			do {
				var rc = this._keyboardSearchCompare(currentItem, searchString);
				if (!!rc && numMatches++ === 0) {
					matchedItem = currentItem;
				}
				if (rc === -1) { // priority match
					numMatches = -1;
					break;
				}
				currentItem = this.getNext(currentItem, 1);
			} while (currentItem !== stop);

			this._keyboardSearchHandler(matchedItem, evt, searchString, numMatches);
		},

		/**
		 * Returns the next or previous navigable descendant, relative to "child".
		 * If "child" is this, then it returns the first focusable descendant (when dir === 1)
		 * or last focusable descendant (when dir === -1).
		 * @param {Element} child - The current child Element.
		 * @param {number} dir - 1 = after, -1 = before
		 * @returns {Element}
		 * @protected
		 */
		getNext: function (child, dir) {
			var container = this.keyNavContainerNode, origChild = child;
			function dfsNext (node) {
				if (node.firstElementChild) { return node.firstElementChild; }
				while (node !== container) {
					if (node.nextElementSibling) { return node.nextElementSibling; }
					node = node.parentNode;
				}
				return container;	// loop around, plus corner case when no children
			}
			function dfsLast (node) {
				while (node.lastElementChild) { node = node.lastElementChild; }
				return node;
			}
			function dfsPrev (node) {
				return node === container ? dfsLast(container) : // loop around, plus corner case when no children
					(node.previousElementSibling && dfsLast(node.previousElementSibling)) || node.parentNode;
			}
			while (true) {
				child = dir > 0 ? dfsNext(child) : dfsPrev(child);
				if (child === origChild) {
					return null;	// looped back to original child
				} else if (this.isNavigable(child)) {
					return child;	// this child matches
				}
			}
		}
	});
});