/*!
 * ${copyright}
 */

(function () {
	"use strict";
	/*global _$blanket, blanket, falafel, Map, QUnit */
	/*eslint no-alert: 0, no-warning-comments: 0 */

	var aFileNames = [], // maps a file's index to its name
		rParentheses = /[()]/g,
		oScript = getScriptTag(),
		aStatistics = [], // maps a file's index to its "hits" array (and statistics record)
		iThreshold,
		rWordChar = /\w/; // a "word" (= identifier) character

	/**
	 * Keep track of branch coverage.
	 *
	 * @param {number} iFileIndex
	 *   The current file's index
	 * @param {number} iBranchIndex
	 *   The current branch's index
	 * @param {any} vCondition
	 *   The current branch's condition
	 * @param {number} [iLine]
	 *   The current branch's line number (for statement coverage)
	 * @returns {any}
	 *   <code>vCondition</code>
	 */
	function branchTracking(iFileIndex, iBranchIndex, vCondition, iLine) {
		if (iBranchIndex >= 0) {
			if (vCondition) {
				aStatistics[iFileIndex].branchTracking[iBranchIndex].truthy += 1;
			} else {
				aStatistics[iFileIndex].branchTracking[iBranchIndex].falsy += 1;
			}
		}

		if (iLine >= 0) {
			lineTracking(iFileIndex, iLine);
		}

		return vCondition;
	}

	/**
	 * Returns the element's attribute as an integer.
	 *
	 * @param {Element} oElement The element
	 * @param {string} sAttributeName The attribute name
	 * @param {number} iDefault The default value
	 * @returns {number} The attribute value or the default value if the attribute value is not a
	 *   positive number
	 */
	function getAttributeAsInteger(oElement, sAttributeName, iDefault) {
		var iValue = parseInt(oElement.getAttribute(sAttributeName));

		// Note: if the value is not a number, the result is NaN which is not greater than 0
		return iValue > 0 ? iValue : iDefault;
	}

	/**
	 * Determines the script tag that loaded this script.
	 *
	 * @return {Element}
	 *   The script tag that loaded this script
	 */
	function getScriptTag() {
		var oScript,
			aScripts = document.getElementsByTagName("script"),
			i, n;

		for (i = 0, n = aScripts.length; i < n; i += 1) {
			oScript = aScripts[i];

			if (/BranchTracking.js$/.test(oScript.getAttribute("src"))) {
				return oScript;
			}
		}
	}

	/**
	 * Replacement for Blanket.js' <code>instrument</code> function.
	 *
	 * @param {object} oConfiguration
	 *   Configuration object
	 * @param {string} oConfiguration.inputFile
	 *   The current file's original source code
	 * @param {string} oConfiguration.inputFileName
	 *   The current file's name
	 * @param {function} fnSuccess
	 *   Success handler, called with resulting instrumented source code
	 */
	function instrument(oConfiguration, fnSuccess){
		var bBranchTracking = blanket.options("branchTracking"),
			iFileIndex = aFileNames.length,
			sFileName = oConfiguration.inputFileName,
			iNoOfOutputLines,
			sScriptInput = oConfiguration.inputFile,
			sScriptOutput;

		aFileNames.push(sFileName);
		aStatistics[iFileIndex] = _$blanket[sFileName] = []; // hits
		if (bBranchTracking) {
			_$blanket[sFileName].branchTracking = [];
		}
		_$blanket[sFileName].source = sScriptInput.split("\n");
		_$blanket[sFileName].source.unshift(""); // line 0 does not exist!
		_$blanket[sFileName].warnings = [];

		sScriptOutput = "" + falafel(sScriptInput, {
//				attachComment : true, // interesting for meta comments!
//				comment : true,
				loc : true,
				range : true,
				source : sScriptInput // is simply attached to each Location
//				tokens : false,
//				tolerant : false
			}, visit.bind(null, bBranchTracking, iFileIndex));

		iNoOfOutputLines = sScriptOutput.split("\n").length + 1; // account for line 0 here as well
		if (iNoOfOutputLines !== _$blanket[sFileName].source.length) {
			warn(sFileName, "Line length mismatch! " + _$blanket[sFileName].source.length + " vs. "
				+ iNoOfOutputLines);
		}

		fnSuccess(sScriptOutput);
	}

	/**
	 * Keep track of statement coverage.
	 *
	 * @param {number} iFileIndex
	 *   The current file's index
	 * @param {number} iLine
	 *   The current line number
	 */
	function lineTracking(iFileIndex, iLine) {
		aStatistics[iFileIndex][iLine] += 1;
	}

	/**
	 * Visit the given node, maybe instrument it.
	 *
	 * Note: "The recursive walk is a pre-traversal, so children get called before their parents."
	 *
	 * Note: We make no attempt to check that the global variable <code>blanket</code> is not
	 * redefined. We don't rely on <code>window</code>. No support for labeled statements
	 * (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label).
	 * We rely on ESLint, e.g. http://eslint.org/docs/rules/curly.
	 *
	 * @param {boolean} bBranchTracking
	 *   Whether branch tracking is on
	 * @param {number} iFileIndex
	 *   The current file's index
	 * @param {object} oNode
	 *   AST node
	 * @returns {boolean}
	 *   Whether <code>oNode.update()</code> has been used (Note: works only once!). This is just
	 *   meant to keep track for future internal usage and is actually ignored by Falafel's
	 *   <code>walk</code>.
	 */
	function visit(bBranchTracking, iFileIndex, oNode) {
		var aHits = aStatistics[iFileIndex],
			aBranchTracking = aHits.branchTracking,
			iLine = oNode.loc.start.line,
			sNewSource;

		/*
		 * Adds line tracking instrumentation to the current node.
		 *
		 * @returns {boolean}
		 *   <code>true</code> because <code>oNode.update()</code> has been used
		 */
		function addLineTracking() {
			oNode.update("blanket.$l(" + iFileIndex + ", " + iLine + "); " + oNode.source());
			initHits();
			return true;
		}

		/*
		 * Initialize hits count for current line, double check for duplicates.
		 */
		function initHits() {
			if (iLine in aHits) {
				warn(iFileIndex, "Multiple statements on same line detected"
					+ " – minified code not supported! Line number " + iLine);
			}
			aHits[iLine] = 0;
		}

		/*
		 * Try to preserve operator's source code incl. comments and line breaks, but avoid leading
		 * closing or trailing opening parentheses. Note: this also removes parentheses inside a
		 * comment, but who cares ;-)
		 *
		 * Note: outer parentheses are absorbed by operators and do not appear in operand's source!
		 *
		 * @returns {string}
		 */
		function operator() {
			return oNode.loc.source.slice(oNode.left.range[1], oNode.right.range[0])
				.replace(rParentheses, "");
		}

		switch (oNode.type) {
			case "FunctionDeclaration":
			case "FunctionExpression":
				if (oNode.body.body[0] && iLine === oNode.body.body[0].loc.start.line) {
					warn(iFileIndex, "Function body must not start on same line! Line number "
						+ iLine);
//					aHits[iLine] = NaN; //TODO find an easy way to mark this line as "missed"
				}
				break;

			default:
		}

		switch (oNode.type) {
			case "AssignmentExpression":
			case "ArrayExpression":
			case "BlockStatement":
			case "BinaryExpression":
			case "Block": // block comment
			case "CallExpression":
			case "CatchClause": //TODO coverage for empty blocks?
			case "DebuggerStatement":
			case "EmptyStatement": //TODO coverage?!
			case "FunctionExpression":
			case "Identifier":
			case "Line": // end-of-line comment
			case "Literal":
			case "MemberExpression":
			case "NewExpression":
			case "ObjectExpression":
			case "Program":
			case "Property":
			case "SequenceExpression":
			case "SwitchCase": //TODO (statement) coverage!
			case "ThisExpression":
			case "UnaryExpression":
			case "UpdateExpression":
			case "VariableDeclarator":
				return false;

			case "ConditionalExpression":
				if (!bBranchTracking) {
					return false;
				}
				oNode.test.update("blanket.$b(" + iFileIndex + ", " + aBranchTracking.length + ", "
					+ oNode.test.source() + ")"
				);
				aBranchTracking.push({
					alternate : oNode.alternate.loc,
					consequent : oNode.consequent.loc,
					falsy : 0,
					truthy : 0
				});
				return true;

			case "ExpressionStatement":
				if (oNode.expression.type === "Literal"
					&& oNode.expression.value === "use strict") {
					return false; // do not instrument "use strict"; it would break it!
				}
				// fall through
			case "DoWhileStatement":
			case "ForInStatement":
			case "ForStatement":
			case "WhileStatement":
			case "WithStatement":
				// Note: we assume block statements only (@see blanket._blockifyIf)
			case "BreakStatement":
			case "ContinueStatement":
			case "FunctionDeclaration":
			case "ReturnStatement":
			case "SwitchStatement":
			case "ThrowStatement":
			case "TryStatement":
				return addLineTracking(oNode);

			case "IfStatement":
				// Note: we assume block statements only (@see blanket._blockifyIf)
				oNode.test.update("blanket.$b(" + iFileIndex + ", "
					+ (bBranchTracking ? aBranchTracking.length : -1) + ", "
					+ oNode.test.source() + ", " + iLine + ")"
				);
				initHits();
				if (bBranchTracking) {
					aBranchTracking.push({
						// Note: in case of missing "else" we blame it on the condition
						alternate : (oNode.alternate || oNode.test).loc,
						consequent : oNode.consequent.loc,
						falsy : 0,
						truthy : 0
					});
				} // else would like to fall through to line tracking, but that does not work for
				// "else if" unless s.th. like blanket._blockifyIf is used!
				return true;

			case "LogicalExpression":
				if (!bBranchTracking) {
					return false;
				}
				if (oNode.operator === "||" || oNode.operator === "&&") {
					// Note: (...) around right source!
					sNewSource = "blanket.$b(" + iFileIndex + ", " + aBranchTracking.length + ", "
						+ oNode.left.source() + ") "
						+ operator() + " (" + oNode.right.source() + ")";
					if (!rWordChar.test(oNode.loc.source[oNode.range[0]])) {
						// Note: handle minified code like "return!x||y;"
						sNewSource = " " + sNewSource;
					}
					oNode.update(sNewSource);
					aBranchTracking.push({
						alternate : oNode.operator === "&&"
							? oNode.left.loc
							: oNode.right.loc,
						consequent : oNode.operator === "&&"
							? oNode.right.loc
							: oNode.left.loc,
						falsy : 0,
						truthy : 0
					});
				}
				return true;

			case "VariableDeclaration":
				if (oNode.parent.type === "ForInStatement"
					|| oNode.parent.type === "ForStatement") {
					return false;
				}
				return addLineTracking(oNode);

			case "LabeledStatement":
			default:
				throw new Error(oNode.source());
		}
	}

	/**
	 * Logs the given message related to the given file both as a warning on console and as a
	 * warning to be reported inside QUnit.module's "before" hook.
	 *
	 * @param {number|string} vFile - the affected file's index or name
	 * @param {string} sMessage - a message
	 */
	function warn(vFile, sMessage) {
		var sFileName = typeof vFile === "string"
				? vFile
				: aFileNames[vFile];

		jQuery.sap.log.warning(sMessage, sFileName, "sap.ui.test.BranchTracking");
		_$blanket[sFileName].warnings.push(sMessage);
	}

	/**
	 * Listens on QUnit and delivers a function that returns the tested modules.
	 *
	 * @returns {function} A function that delivers the tested modules or <code>undefined</code> if
	 *   all modules have been tested
	 */
	function listenOnQUnit() {
		var mModules = {},
			aTestedModules = [],
			iTotalModules;

		QUnit.begin(function (oDetails) {
			iTotalModules = oDetails.modules.length;
			oDetails.modules.forEach(function (oModule) {
				mModules[oModule.name] = oModule;
			});
		});

		QUnit.moduleStart(function (oModule) {
			// Why, oh why, is the module name different here?
			aTestedModules = aTestedModules.concat(
				Object.keys(mModules).filter(function (sModuleName) {
					return mModules[sModuleName].tests === oModule.tests;
				})
			);
		});

		return function () {
			return aTestedModules.length < iTotalModules ? aTestedModules : undefined;
		};
	}

	if (window.blanket) {
		window._$blanket = {}; // maps a file's name to its statistics array
		blanket.$b = branchTracking;
		blanket.$l = lineTracking;
		blanket.instrument = instrument; // self-made "plug-in" ;-)

		var fnGetTestedModules = listenOnQUnit(),
			iLinesOfContext = getAttributeAsInteger(oScript, "data-lines-of-context", 3);

		iThreshold = Math.min(getAttributeAsInteger(oScript, "data-threshold", 0), 100);

		// Note: instrument() MUST have been replaced before!
		sap.ui.require(["sap/ui/test/BlanketReporter"], function (BlanketReporter) {
			blanket.options("reporter",
				BlanketReporter.bind(null, iLinesOfContext, iThreshold, fnGetTestedModules));
		});
	}

	//**********************************************************************************************
	// Code for tracking "Uncaught (in promise)" for sap.ui.base.SyncPromise inside QUnit tests
	// and for checking isolated code coverage (that is, each "class" by its corresponding test)
	//**********************************************************************************************
	var bInfo,
		sClassName = "sap.ui.base.SyncPromise",
		mFileName2InitialHits = {},
		sFilter,
		fnModule,
		iNo = 0,
		sTestId,
		mUncaughtById = {},
		mUncaughtPromise2Reason = new Map();

	/**
	 * Check isolated line/branch coverage for the given test.
	 *
	 * @param {object} oTest - QUnit's test environment
	 * @param {object} assert - QUnit's object with the assertion methods
	 */
	function checkIsolatedCoverage(oTest, assert) {
		var aBranchesWithUnchangedHits,
			aHits = _$blanket[oTest.$currentFileName],
			aInitialHits = mFileName2InitialHits[oTest.$currentFileName],
			aLinesWithUnchangedHits;

		if (oTest.$oldHits) {
			aLinesWithUnchangedHits = Object.keys(aHits).filter(function (iLine) {
				return !(aInitialHits && aInitialHits[iLine])
					&& aHits[iLine] === oTest.$oldHits[iLine];
			});
			assert.notOk(aLinesWithUnchangedHits.length,
				"Some lines have not been covered by this module in isolation: "
					+ aLinesWithUnchangedHits);
		}
		if (oTest.$oldBranchTracking) {
			aBranchesWithUnchangedHits = Object.keys(aHits.branchTracking).filter(function (i) {
				return aHits.branchTracking[i].falsy === oTest.$oldBranchTracking[i].falsy
					|| aHits.branchTracking[i].truthy === oTest.$oldBranchTracking[i].truthy;
			});
			assert.notOk(aBranchesWithUnchangedHits.length,
				"Some branches have not been fully covered by this module in isolation: "
					+ aBranchesWithUnchangedHits);
		}
	}

	/**
	 * Check for uncaught errors in sync promises and provide an appropriate report to the given
	 * optional reporter.
	 *
	 * @param {function} [fnReporter]
	 *   Optional reporter to receive a report
	 */
	function checkUncaught(fnReporter) {
		var sId,
			iLength = Object.keys(mUncaughtById).length
				+ (mUncaughtPromise2Reason ? mUncaughtPromise2Reason.size : 0),
			sMessage = "Uncaught (in promise): " + iLength + " times\n",
			oPromise,
			vReason,
			oResult,
			itValues;

		if (iLength) {
			for (sId in mUncaughtById) {
				oPromise = mUncaughtById[sId];
				if (oPromise.getResult() && oPromise.getResult().stack) {
					sMessage += oPromise.getResult().stack;
				} else {
					sMessage += oPromise.getResult();
				}
				if (oPromise.$error.stack) {
					sMessage += "\n>>> SyncPromise rejected with above reason...\n"
						+ oPromise.$error.stack.split("\n").slice(2).join("\n"); // hide listener
				}
				sMessage += "\n\n";
			}
			mUncaughtById = {};

			//TODO for (let vReason of mUncaughtPromise2Reason.values()) {...}
			if (mUncaughtPromise2Reason && mUncaughtPromise2Reason.size) {
				itValues = mUncaughtPromise2Reason.values();
				for (;;) {
					oResult = itValues.next();
					if (oResult.done) {
						break;
					}
					vReason = oResult.value;
					sMessage += (vReason && vReason.stack || vReason) + "\n\n";
				}
				mUncaughtPromise2Reason.clear();
			}

			if (fnReporter) {
				fnReporter(sMessage);
			} else if (bInfo) {
				jQuery.sap.log.info("Clearing " + iLength + " uncaught promises", sMessage,
					sClassName);
			}
		}
	}

	if (oScript.getAttribute("data-uncaught-in-promise") !== "true") {
		/*
		 * Listener for "unhandledrejection" events to keep track of "Uncaught (in promise)".
		 */
		window.addEventListener("unhandledrejection", function (oEvent) {
			if (oEvent.reason && oEvent.reason.$uncaughtInPromise) { // ignore exceptional cases
				return;
			}

			if (mUncaughtPromise2Reason) {
				mUncaughtPromise2Reason.set(oEvent.promise, oEvent.reason);
				oEvent.preventDefault(); // do not report on console
			} else { // QUnit already done
				alert("Uncaught (in promise) " + oEvent.reason);
			}
		});

		/*
		 * Listener for "rejectionhandled" events to keep track of "Uncaught (in promise)".
		 */
		window.addEventListener("rejectionhandled", function (oEvent) {
			if (mUncaughtPromise2Reason) {
				mUncaughtPromise2Reason.delete(oEvent.promise);
			}
		});
	}

	/**
	 * Listener for sync promises which become (un)caught.
	 *
	 * @param {sap.ui.base.SyncPromise} oPromise
	 *   A sync promise
	 * @param {boolean} bCaught
	 *   Tells whether the given sync promise became caught
	 */
	function listener(oPromise, bCaught) {
		if (bCaught) {
			delete mUncaughtById[oPromise.$id];
			if (bInfo) {
				jQuery.sap.log.info("Promise " + oPromise.$id + " caught",
					Object.keys(mUncaughtById), sClassName);
			}
			return;
		}

		oPromise.$id = iNo++;
		oPromise.$error = new Error();
		mUncaughtById[oPromise.$id] = oPromise;
		if (bInfo) {
			jQuery.sap.log.info("Promise " + oPromise.$id + " rejected with "
				+ oPromise.getResult(), Object.keys(mUncaughtById), sClassName);
		}
	}

	/**
	 * Wrapper for <code>QUnit.module</code> to check for uncaught errors in sync promises and
	 * for 100% isolated test coverage.
	 *
	 * @param {string} sTitle
	 *   The module's title
	 * @param {object} [mHooks]
	 *   Optional map of hooks, e.g. "beforeEach"
	 */
	function module(sTitle, mHooks) {
		var fnAfter, fnAfterEach, fnBefore, fnBeforeEach;

		mHooks = mHooks || {};
		fnAfter = mHooks.after || function () {};
		fnAfterEach = mHooks.afterEach || function () {};
		fnBefore = mHooks.before || function () {};
		fnBeforeEach = mHooks.beforeEach || function () {};

		mHooks.after = function (assert) {
			if (window.blanket && !sFilter && !sTestId && !this.__ignoreIsolatedCoverage__
					&& iThreshold >= 100 && !assert.test.module.stats.bad) {
				checkIsolatedCoverage(this, assert);
			}

			return fnAfter.apply(this, arguments);
		};

		mHooks.afterEach = function (assert) {
			var fnCheckUncaught = checkUncaught.bind(null, assert.ok.bind(assert, false));

			function error(oError) {
				fnCheckUncaught();
				throw oError;
			}

			function success(vResult) {
				if (vResult && typeof vResult.then === "function") {
					return vResult.then(success, error);
				}
				fnCheckUncaught();
				return vResult;
			}

			try {
				return success(fnAfterEach.apply(this, arguments));
			} catch (oError) {
				error(oError);
			}
		};

		mHooks.before = function (assert) {
			var aHits;

			this.$currentFileName = jQuery.sap.getResourceName(assert.test.module.name);
			aHits = window.blanket && _$blanket[this.$currentFileName];
			if (aHits) {
				this.$oldHits = aHits.slice();
				if (aHits.branchTracking) {
					this.$oldBranchTracking = JSON.parse(
						JSON.stringify(aHits.branchTracking, ["falsy", "truthy"]));
				}
				aHits.warnings.forEach(function (sMessage) {
					assert.ok(false, sMessage);
				});
			}

			return fnBefore.apply(this, arguments);
		};

		mHooks.beforeEach = function (assert) {
			checkUncaught(); // cleans up what happened before
			return fnBeforeEach.apply(this, arguments);
		};

		fnModule(sTitle, mHooks);
	}

	if (QUnit.module !== module) {
		fnModule = QUnit.module.bind(QUnit);
		QUnit.module = module;
		sap.ui.require([
			"sap/base/Log",
			"sap/base/util/UriParameters",
			"sap/ui/base/SyncPromise"
		], function (Log, UriParameters, SyncPromise) {
			var oUriParameters = UriParameters.fromQuery(window.location.search);

			bInfo = Log.isLoggable(Log.Level.INFO, sClassName);
			sFilter = oUriParameters.get("filter");
			sTestId = oUriParameters.get("testId");
			SyncPromise.listener = listener;
		});

		// allow easier module selection: larger list, one click selection
		QUnit.begin(function () {
			var sFileName, aHits;

			jQuery("#qunit-modulefilter-dropdown-list").css("max-height", "none");

			jQuery("#qunit-modulefilter-dropdown").on("click", function (oMouseEvent) {
				if (oMouseEvent.target.tagName === "LABEL") {
					setTimeout(function () {
						// click on label instead of checkbox triggers "Apply" automatically
						jQuery("#qunit-modulefilter-actions").children().first().trigger("click");
					});
				}
			});

			if (window.blanket) {
				// remember which lines have been covered initially, at load time
				for (sFileName in _$blanket) {
					aHits = _$blanket[sFileName];
					mFileName2InitialHits[sFileName] = aHits.slice();
				}
				// Note: for SyncPromise, a lot of lines are already covered!
			}
		});

		QUnit.done(function () {
			mUncaughtPromise2Reason = null; // no use to keep track anymore
		});
	}
}());
//TODO add tooltips to highlighting to explain rules

