define(["wc/array/toArray",
	"wc/dom/diagnostic",
	"wc/dom/classList",
	"wc/dom/messageBox",
	"wc/dom/tag",
	"wc/dom/wrappedInput",
	"wc/dom/Widget",
	"wc/ui/icon",
	"wc/dom/getLabelsForElement",
	"wc/config"],
	function(toArray, diagnostic, classList, messageBox, tag, wrappedInput, Widget, icon, getLabelsForElement, wcconfig) {
		"use strict";

		function Feedback() {
			var writeOutsideThese = [tag.INPUT, tag.SELECT, tag.TEXTAREA],
				BEFORE_END = "beforeend",
				VALIDATION_ERRORS,
				ERROR_LINK;

			/**
			 * Remove a link to a component which was in an error state when the page was loaded (using
			 * WValidationErrors) but which was subsequently corrected.
			 * @function
			 * @private
			 * @param {Element} element The HTML element which was in an error state
			 */
			function removeWValidationErrorLink(element) {
				var validationErrors,
					errorLink,
					target;

				if (!(element && element.nodeType === Node.ELEMENT_NODE)) {
					return;
				}

				if ((validationErrors = messageBox.getErrorBoxes(document.body, true))) {
					if (!ERROR_LINK) {
						VALIDATION_ERRORS = VALIDATION_ERRORS || messageBox.getErrorBoxWidget().clone;
						ERROR_LINK = new Widget("a");
						ERROR_LINK.descendFrom(VALIDATION_ERRORS);
					}

					target = wrappedInput.isWrappedInput(element) ? wrappedInput.getWrapper(element) : element;

					// NOTE: cannot use Widget for #id because we hwant exact matches [href='#id'] not include matches [href!='#id']
					errorLink = "#" + target.id;
					Array.prototype.forEach.call(ERROR_LINK.findDescendants(document.body), function (link) {
						if (link.getAttribute("href") === errorLink) {
							link.parentNode.removeChild(link);
						}
					});

					Array.prototype.forEach.call(validationErrors, function (validErr) {
						if (!ERROR_LINK.findDescendant(validErr)) {
							validErr.parentNode.removeChild(validErr);
						}
					});
				}
			}

			/**
			 * For the convenience of consuming UI modules.
			 */
			this.LEVEL = diagnostic.LEVEL;
			this.isOneOfMe = function(element, level) {
				return diagnostic.isOneOfMe(element, level);
			};

			/**
			 * Type check for diagnostic boxes.
			 * @param {Element} diag the element to test
			 * @param {type} lenient if `true` do not error on a failed test, instread return false
			 * @returns {Boolean} `true` if `diag` is a diagnostic box, otherwise `false` if `lenient` is `true`.
			 * @throws {TypeError} if `diag` is not a diagnostic box and `lenient` is not `true`.
			 */
			function check(diag, lenient) {
				if (!(diag && diagnostic.isOneOfMe(diag))) {
					if (lenient) {
						return false;
					}
					throw new TypeError("Argument must be a feedback box");
				}
				return true;
			}

			function changeIcon(diag, fromLevel, toLevel) {
				var oldClass = getIconName(fromLevel),
					newClass = getIconName(toLevel);
				icon.change(diag, newClass, oldClass);
			}

			/**
			 * Mark/unmark a component as invalid.
			 * @function
			 * @private
			 * @param {Element} target the component to mark
			 * @param {boolean} [clear] if `true` set the component to be valid **and** remove its `aria-describedby` attribute
			 */
			function toggleValidity(target, clear) {
				var INVALID_ATTRIB = "aria-invalid",
					DESCRIBED_ATTRIB = "aria-describedby",
					element,
					diag;
				if (!(target && target.tagName)) {
					return;
				}
				element =  wrappedInput.getInput(target) || target;
				if (element) {
					if (clear) {
						element.removeAttribute(INVALID_ATTRIB);
						element.removeAttribute(DESCRIBED_ATTRIB);
						return;
					}
					if ((diag = instance.getBox(target, diagnostic.LEVEL.ERROR))) {
						element.setAttribute(INVALID_ATTRIB, "true");
						element.setAttribute(DESCRIBED_ATTRIB, diag.id);
					} else if ((diag = instance.getBox(target, -1))) {
						element.removeAttribute(INVALID_ATTRIB);
						element.setAttribute(DESCRIBED_ATTRIB, diag.id);
					}
				}
			}

			/**
			 * Get the font awesome icon name for a diagnostic box of a given level.
			 * @param {type} level
			 * @returns {String}
			 */
			function getIconName(level) {
				var defaultIcon = "fa-times-circle",
					config = wcconfig.get("wc/ui/feedback");

				if (config && config.errorIcon) {
					defaultIcon = config.errorIcon;
				}
				if (!level || level === diagnostic.LEVEL.ERROR) {
					return defaultIcon;
				}
				switch (level) {
					case diagnostic.LEVEL.WARN:
						if (config && config.warnIcon) {
							return config.warnIcon;
						}
						return "fa-exclamation-triangle";
					case diagnostic.LEVEL.INFO:
						if (config && config.infoIcon) {
							return config.infoIcon;
						}
						return "fa-info-circle";
					case diagnostic.LEVEL.SUCCESS:
						if (config && config.successIcon) {
							return config.successIcon;
						}
						return "fa-check-circle";
					default:
						return defaultIcon;
				}
			}

			function getMessageHTML(message) {
				var tagName,
					attrib = "class='",
					className,
					widget;
				if (!message) {
					throw new TypeError("Message must not be falsey");
				}
				if (message.constructor !== String) {
					if (message.toString) {
						message = message.toString();
					} else {
						throw new TypeError("Message must be a string");
					}
				}
				widget = diagnostic.getMessage();
				tagName = widget.tagName;
				className = widget.className;
				if (Array.isArray(className)) {
					className = className.join(" ");
				}
				attrib += className + "'";
				return tag.toTag(tagName, false, attrib) + message + tag.toTag(tagName, true);
			}

			function addHelper(box, message) {
				var i,
					current;
				if (!(message && message.constructor === String)) {
					throw new TypeError("Message must be a string");
				}
				if ((current = instance.getMessages(box))) {
					for (i = 0; i < current.length; ++i) {
						if (message.toLocaleLowerCase() === current[i].innerHTML.toLocaleLowerCase()) {
							// already have this message
							return;
						}
					}
				}
				box.insertAdjacentHTML(BEFORE_END, getMessageHTML(message));
			}

			/**
			 * Generate the HTML to create a diagnostic box.
			 * @function
			 * @private
			 * @param {Object} args
			 * @param {Element} [args.el] The element which is the diagnostic target if not set then args.id must be set.
			 * @param {String} [args.id] The base id for the diagnostic box. If not set then args.el must be an element with an id.
			 * @param {int} [args.level=1] the diagnostic level, defaults to ERROR
			 * @param {String|String[]|NodeList} [args.messages] If `falsey` then the diagnostic box will be empty. If a String the diagnostic will
			 *   contain one message containing this String. If a NodeList then the diagnostic messages will be the innerHTML of each element node
			 *   in the NodeList and the textContent of each text node in the NodeList. If something else the messages are treated as a single
			 *   "thing" and the diagnostic box will attempt to call toString() on it.
			 * @returns {Object} property html: The HTML which creates a complete diagnostic box, property id: the id of the added box
			 */
			function getHTML(args) {
				var el = args.el,
					level = args.level || diagnostic.LEVEL.ERROR,
					messages = args.messages,
					boxWidget,
					id,
					tagName,
					classAttrib = "class='",
					className,
					idAttrib = "id='",
					roleAttrib = "role='alert'",
					html,
					levelIcon;

				id = args.id || (el ? el.id : null);
				if (!id) {
					throw new TypeError("Cannot get error box without an id.");
				}
				id += diagnostic.getIdExtension(level);

				boxWidget = diagnostic.getByType(level);
				tagName = boxWidget.tagName;
				idAttrib += id + "'";
				className = boxWidget.className;
				if (Array.isArray(className)) {
					className = className.join(" ");
				}
				classAttrib += className + "'";
				html = tag.toTag(tagName, false, [idAttrib, classAttrib, roleAttrib].join(" "));
				if ((levelIcon = getIconName(level))) {
					html += "<i aria-hidden='true' class='fa " + levelIcon + "'></i>";
				}
				if (messages) {
					if (messages.constructor === NodeList) {
						messages = (toArray(messages)).map(function(next) {
							if (next.nodeType === Node.ELEMENT_NODE) {
								return next.innerHTML;
							}
							if (next.textContent) {
								return next.textContent;
							}
							return null;
						});
					}
					if (Array.isArray(messages)) {
						messages.forEach(function(next) {
							html += getMessageHTML(next);
						});
					} else {
						html += getMessageHTML(messages);
					}
				}
				html += tag.toTag(tagName, true);
				return {html: html, id: id};
			}

			/**
			 * Change the doagnostic level of an existing diagnostic box.
			 * @function
			 * @public
			 * @param {Element} box the diagnostic box to change
			 * @param {module:wc/dom/diagnostic.LEVEL} toLevel the level to change to
			 * @param {Element} [target] the diagnostic box's target element if already known - saves us finding it twice.
			 */
			this.change = function(box, toLevel, target) {
				var oldLevel,
					oldClass,
					newClass,
					realTarget,
					oldIdExtension,
					newIdExtension,
					testId;
				check(box);
				if (!toLevel || toLevel < 1) {
					console.log("twit");
					return;
				}
				oldLevel = diagnostic.getLevel(box);
				if (oldLevel === toLevel) {
					return; // nothing to do
				}
				newClass = diagnostic.getBoxClass(toLevel);
				oldClass = diagnostic.getBoxClass(oldLevel);
				if (oldClass === newClass) {
					// we probably tried to get to a non-existent level.
					return;
				}
				newIdExtension = diagnostic.getIdExtension(toLevel);
				oldIdExtension = diagnostic.getIdExtension(oldLevel);
				testId = box.id.replace(oldIdExtension, newIdExtension);
				// if we already have a diagnostic box at the requested level we cannot create a new one
				if (document.getElementById(testId)) {
					console.log("cannot create diagnostic box with duplicate id");
					// this.remove(diag, target);
					this.clear(box);
					return;
				}
				classList.add(box, newClass);
				classList.remove(box, oldClass);
				box.id = testId;
				this.clear(box);
				// now change the icon
				changeIcon(box, oldLevel, toLevel);
				if ((realTarget = target || diagnostic.getTarget(box))) {
					removeWValidationErrorLink(realTarget);
					toggleValidity(realTarget);
				}
			};

			/**
			 * Remove all messages from a diagnostic box.
			 * @function
			 * @public
			 * @param {Element} box the diagnostic box to change
			 * @throws {TypeError} if `diag` is not a diagnostic box
			 */
			this.clear = function(box) {
				var messages;
				check(box);
				if ((messages = this.getMessages(box))) {
					Array.prototype.forEach.call(messages, function(next) {
						box.removeChild(next);
					});
				}
			};

			/**
			 * Add messages to an existing diagnostic box.
			 * @function
			 * @public
			 * @param {Element} box the disgnostic box
			 * @param {String|String[]} messages the message(s) to add
			 */
			this.addMessages = function(box, messages) {
				check(box);
				if (Array.isArray(messages)) {
					messages.forEach(function(next) {
						addHelper(box, next);
					});
				} else {
					addHelper(box, messages);
				}
			};

			/**
			 * Gets the messages already inside a given diagnostic box.
			 * @function
			 * @public
			 * @param {Element} box the diagnostic box
			 * @returns {NodeList?} messages inside the diagnostic box, if any
			 */
			this.getMessages = function(box) {
				if (!check(box, true)) {
					return null;
				}
				return diagnostic.getMessage().findDescendants(box);
			};

			/**
			 * Set the messages inside an existing message box to a new message or set of messages.
			 * @function
			 * @public
			 * @param {Element} box the diagnostic box
			 * @param {String|String[]} messages
			 */
			this.set = function(box, messages) {
				check(box);
				this.clear(box);
				if (messages) {
					this.addMessages(box, messages);
				}
			};

			/**
			 * Get the HTML which creates a diagnostic box.
			 * @function
			 * @private
			 * @param {String} targetId the id of the component to which the message box is added
			 * @param {String|String[]} messages the message(s) to add
			 * @param {int} [level=1] the diagnostic level
			 * @returns {String} the error box HTML
			 */
			function getBoxHTML(targetId, messages, level) {
				var msgArray;
				if (!(targetId && messages)) {
					return null;
				}
				msgArray = Array.isArray(messages) ? messages : [messages];
				return getHTML({
					id: targetId,
					messages: msgArray,
					level: level
				});
			}

			/**
			 * Find all diagnostics belonging to an element.
			 * @function
			 * @public
			 * @param {Element|String} element the element being diagnosed (or its id)
			 * @param {int} [ofLevel=1] the diagnostic level, if not set get ERROR diagnostic box. Set to -1 to get one of any type.
			 * @returns {Element?} the diagnostic box of the required level (if any).
			 */
			this.getBox = function (element, ofLevel) {
				var target,
					id,
					result,
					level = ofLevel || this.LEVEL.ERROR,
					lvl;
				if (!element) {
					throw new TypeError("element must not be falsey");
				}

				target = (element.constructor === String) ? document.getElementById(element) : element;

				if (!(target && target.tagName)) {
					throw new TypeError("element does not represent an HTML Element");
				}

				if (!target.id) {
					return null;
				}

				if (wrappedInput.isWrappedInput(target)) {
					target = wrappedInput.getWrapper(target);
				}

				if (level === -1) {
					if ((result = diagnostic.getWidget().findDescendant(target))) { // fast but insufficient
						return result;
					}
					for (lvl in this.LEVEL) {
						if ((result = this.getBox(element, this.LEVEL[lvl]))) {
							return result;
						}
					}
					return null;
				}
				id = target.id + diagnostic.getIdExtension(level);
				return document.getElementById(id);
			};

			/**
			 * Get the last diagnostic box WITHIN (or withing the wrapper of) a
			 * @param {Element|String} element the element being tested or an id of an element
			 * @returns {Element?} the last diagnostic box if any.
			 */
			this.getLast = function(element) {
				var target,
					candidates;
				if (!element) {
					throw new TypeError("element must not be falsey");
				}

				target = (element.constructor === String) ? document.getElementById(element) : element;

				if (!(target && target.tagName)) {
					throw new TypeError("element does not represent an HTML Element");
				}

				if (!target.id) {
					return null;
				}

				if (wrappedInput.isWrappedInput(target)) {
					target = wrappedInput.getWrapper(target);
				}
				candidates = diagnostic.getWidget().findDescendants(target);
				return (candidates && candidates.length) ? candidates[candidates.length - 1] : null;
			};

			this.add = function(args) {
				var AFTER_END = "afterend",
					writeWhere = args.position,
					target = args.target,
					messages = args.messages,
					level = args.level || diagnostic.LEVEL.ERROR,
					flagTarget,
					html,
					boxId;
				if (!(messages && target && target.nodeType === Node.ELEMENT_NODE)) {
					// no messages or target for the messages
					// don't throw: just do nothing
					console.warn("trying to add nothing or to nothing");
					return null;
				}

				if (wrappedInput.isWrappedInput(target)) {
					target = wrappedInput.getWrapper(target);
				}

				removeWValidationErrorLink(target);

				if (target.tagName === tag.INPUT && (target.type === "radio" || target.type === "checkbox")) {
					flagTarget = getLabelsForElement(target);
					flagTarget = (flagTarget && flagTarget.length) ? flagTarget[0] : null;
					if (flagTarget) {
						writeWhere = BEFORE_END;
					} else {
						writeWhere = AFTER_END;
					}
				} else if (wrappedInput.isOneOfMe(target)) {
					writeWhere = BEFORE_END;
				}
				if (!writeWhere) {
					writeWhere = ~writeOutsideThese.indexOf(target.tagName) ? AFTER_END : BEFORE_END;
				}
				flagTarget = flagTarget || target;
				if ((html = getBoxHTML(target.id, messages, level))) {
					boxId = html.id;
					html = html.html;
					flagTarget.insertAdjacentHTML(writeWhere, html);
					toggleValidity(target);
					return boxId;
				}
				return null;
			};

			/**
			 * Remove an existing diagnostic box.
			 * @function
			 * @public
			 * @param {Element} [diag] the box to remove if not set then target must be set
			 * @param {Element} [target] the diagnostic's target if diag is not set or if we already have it. If not set and `diag` is set we can
			 *   calculate the target element.
			 */
			function removeDiagnostic(diag, target) {
				var realTarget, realDiag, parent;
				if (!(diag || target)) {
					console.error("What! I am magic now? What do you want to remove you womble?");
					throw new TypeError("You forgot the args");
				}

				if (diag && check(diag, true)) {
					realTarget = target || diagnostic.getTarget(diag);
					realDiag = diag;
				} else if (target && !diag) {
					realDiag = instance.getBox(target, -1);
					realTarget = target;
				}

				if (realDiag) {
					if ((parent = realDiag.parentNode)) {
						parent.removeChild(realDiag);
					}
					if (realTarget) {
						toggleValidity(realTarget, true);
						removeWValidationErrorLink(realTarget);
					}
				}
			}

			/**
			 * Public for testing
			 * @ignore
			 */
			this._removeDiagnostic = removeDiagnostic;

			/**
			 * Remove an error diagnostic.
			 * @function
			 * @public
			 * @param {Element} element either an error diagnostic or an element with an error diagnostic
			 * @param {Element} [target] an element with a diagnostic **if** element is a diagnostic and we have already found its "owner".
			 * @param {int} [level=1] the diagnostic level to remove if element is not a diagnostic box
			 * @returns {boolean} `true` if a diagnostic box was found and removed.
			 */
			this.remove = function(element, target, level) {
				var errorContainer,
					lvl = level || this.LEVEL.ERROR;
				if (!(element && element.nodeType === Node.ELEMENT_NODE)) {
					return false;
				}
				// read carefully before you try merging these two to be more 'efficient'/
				if (this.isOneOfMe(element)) {
					removeDiagnostic(element, target);
					return true;
				}
				if ((errorContainer = this.getBox(element, lvl))) {
					removeDiagnostic(errorContainer, element);
					return true;
				}
				return false;
			};

			/**
			 * Flag a component with a message.
			 * @function
			 * @private
			 * @param {module:wc/ui/feedback~flagDto} args a config dto
			 * @returns {String?} the id of the message container (if one is present/created)
			 */
			function flag(args) {
				var target,
					messages,
					level,
					errorContainer;
				if (!args) {
					return null;
				}
				target = args.element;
				messages = args.message;
				level = args.level;
				if (!(target && messages && level)) {
					return null;
				}
				if (target.constructor === String) {
					target = document.getElementById(target);
					if (!target) {
						return null;
					}
				}

				// if the target already has an appropriate box then use it
				if ((errorContainer = instance.getBox(target, level))) {
					instance.change(errorContainer, level);
					instance.addMessages(errorContainer, messages);
					return errorContainer.id;
				} // Success and failure are mutually exclusive
				if ((level === diagnostic.LEVEL.ERROR && (errorContainer = instance.getBox(target, diagnostic.LEVEL.SUCCESS))) ||
					(level === diagnostic.LEVEL.SUCCESS && (errorContainer = instance.getBox(target, diagnostic.LEVEL.ERROR)))) {
					instance.change(errorContainer, level);
					instance.addMessages(errorContainer, messages);
					return errorContainer.id;
				}

				return instance.add({
					target: target,
					messages: messages,
					level: level
				});
			}

			/**
			 * Public for testing.
			 * @ignore
			 */
			this._flag = flag;

			/**
			 * Flag a component with an error message.
			 * @function
			 * @public
			 * @param {module:wc/ui/feedback~flagDto} args a config dto
			 * @returns {String?} the id of the error container (if one is present/created)
			 */
			this.flagError = function(args) {
				var dto = args;
				dto.level = this.LEVEL.ERROR;
				return flag(dto);
			};

			/**
			 * Flag a component with a warning message.
			 * @function
			 * @public
			 * @param {module:wc/ui/feedback~flagDto} args a config dto
			 * @returns {String?} the id of the message container (if one is present/created)
			 */
			this.flagWarning = function (args) {
				var dto = args;
				dto.level = this.LEVEL.WARN;
				return flag(dto);
			};

			/**
			 * Flag a component with an info message.
			 * @function
			 * @public
			 * @param {module:wc/ui/feedback~flagDto} args a config dto
			 * @returns {String?} the id of the message container (if one is present/created)
			 */
			this.flagInfo = function (args) {
				var dto = args;
				dto.level = this.LEVEL.INFO;
				return flag(dto);
			};

			/**
			 * Flag a component with a success message.
			 * @function
			 * @public
			 * @param {module:wc/ui/feedback~flagDto} args a config dto
			 * @returns {String?} the id of the message container (if one is present/created)
			 */
			this.flagSuccess = function (args) {
				var dto = args;
				dto.level = this.LEVEL.SUCCESS;
				return flag(dto);
			};
		}

		var instance = new Feedback();
		return instance;

		/**
		 * @typedef {Object} module:wc/ui/feedback~flagDto The properties used to describe a custom error message.
		 * @property {String|String[]} message The message to display.
		 * @property {Element} element The element which is to be flagged with the error message.
		 *
		 * @typedef {Object} module:wc/ui/feedback~config Optional run-time configuration for this module.
		 * @property {String} [icon=fa-times-circle] The font-awesome classname for the icon to display in the error box.
		 */
	});
