/*global Rule, Check, RuleResult, commons: true */
const dotRegex = /\{\{.+?\}\}/g;

/*eslint no-unused-vars: 0*/
function getDefaultConfiguration(audit) {
	'use strict';
	var config;
	if (audit) {
		config = axe.utils.clone(audit);
		// Commons are configured into axe like everything else,
		// however because things go funky if we have multiple commons objects
		// we're not using the copy of that.
		config.commons = audit.commons;
	} else {
		config = {};
	}

	config.reporter = config.reporter || null;
	config.rules = config.rules || [];
	config.checks = config.checks || [];
	config.data = { checks: {}, rules: {}, ...config.data };
	return config;
}

function unpackToObject(collection, audit, method) {
	'use strict';

	var i, l;
	for (i = 0, l = collection.length; i < l; i++) {
		audit[method](collection[i]);
	}
}

/**
 * Constructor which holds configured rules and information about the document under test
 */
function Audit(audit) {
	// defaults
	this.brand = 'axe';
	this.application = 'axeAPI';
	this.tagExclude = ['experimental'];
	this.lang = 'en';

	this.defaultConfig = audit;
	this._init();

	// A copy of the "default" locale. This will be set if the user
	// provides a new locale to `axe.configure()` and used to undo
	// changes in `axe.reset()`.
	this._defaultLocale = null;
}

/**
 * Build and set the previous locale. Will noop if a previous
 * locale was already set, as we want the ability to "reset"
 * to the default ("first") configuration.
 */

Audit.prototype._setDefaultLocale = function() {
	if (this._defaultLocale) {
		return;
	}

	const locale = {
		checks: {},
		rules: {},
		failureSummaries: {},
		incompleteFallbackMessage: '',
		lang: this.lang
	};

	// XXX: unable to use `for-of` here, as doing so would
	// require us to polyfill `Symbol`.
	const checkIDs = Object.keys(this.data.checks);
	for (let i = 0; i < checkIDs.length; i++) {
		const id = checkIDs[i];
		const check = this.data.checks[id];
		const { pass, fail, incomplete } = check.messages;
		locale.checks[id] = {
			pass,
			fail,
			incomplete
		};
	}

	const ruleIDs = Object.keys(this.data.rules);
	for (let i = 0; i < ruleIDs.length; i++) {
		const id = ruleIDs[i];
		const rule = this.data.rules[id];
		const { description, help } = rule;
		locale.rules[id] = { description, help };
	}

	const failureSummaries = Object.keys(this.data.failureSummaries);
	for (let i = 0; i < failureSummaries.length; i++) {
		const type = failureSummaries[i];
		const failureSummary = this.data.failureSummaries[type];
		const { failureMessage } = failureSummary;
		locale.failureSummaries[type] = { failureMessage };
	}

	locale.incompleteFallbackMessage = this.data.incompleteFallbackMessage;

	this._defaultLocale = locale;
};

/**
 * Reset the locale to the "default".
 */

Audit.prototype._resetLocale = function() {
	// If the default locale has not already been set, we can exit early.
	const defaultLocale = this._defaultLocale;
	if (!defaultLocale) {
		return;
	}

	// Apply the default locale
	this.applyLocale(defaultLocale);
};

/**
 * Merge two check locales (a, b), favoring `b`.
 *
 * Both locale `a` and the returned shape resemble:
 *
 *    {
 *      impact: string,
 *      messages: {
 *        pass: string | function,
 *        fail: string | function,
 *        incomplete: string | {
 *          [key: string]: string | function
 *        }
 *      }
 *    }
 *
 * Locale `b` follows the `axe.CheckLocale` shape and resembles:
 *
 *    {
 *      pass: string,
 *      fail: string,
 *      incomplete: string | { [key: string]: string }
 *    }
 */

const mergeCheckLocale = (a, b) => {
	let { pass, fail } = b;
	// If the message(s) are Strings, they have not yet been run
	// thru doT (which will return a Function).
	if (typeof pass === 'string' && dotRegex.test(pass)) {
		pass = axe.imports.doT.compile(pass);
	}
	if (typeof fail === 'string' && dotRegex.test(fail)) {
		fail = axe.imports.doT.compile(fail);
	}
	return {
		...a,
		messages: {
			pass: pass || a.messages.pass,
			fail: fail || a.messages.fail,
			incomplete:
				typeof a.messages.incomplete === 'object'
					? // TODO: for compleness-sake, we should be running
					  // incomplete messages thru doT as well. This was
					  // out-of-scope for runtime localization, but should
					  // eventually be addressed.
					  { ...a.messages.incomplete, ...b.incomplete }
					: b.incomplete
		}
	};
};

/**
 * Merge two rule locales (a, b), favoring `b`.
 */

const mergeRuleLocale = (a, b) => {
	let { help, description } = b;
	// If the message(s) are Strings, they have not yet been run
	// thru doT (which will return a Function).
	if (typeof help === 'string' && dotRegex.test(help)) {
		help = axe.imports.doT.compile(help);
	}
	if (typeof description === 'string' && dotRegex.test(description)) {
		description = axe.imports.doT.compile(description);
	}
	return {
		...a,
		help: help || a.help,
		description: description || a.description
	};
};

/**
 * Merge two failure messages (a, b), favoring `b`.
 */

const mergeFailureMessage = (a, b) => {
	let { failureMessage } = b;
	// If the message(s) are Strings, they have not yet been run
	// thru doT (which will return a Function).
	if (typeof failureMessage === 'string' && dotRegex.test(failureMessage)) {
		failureMessage = axe.imports.doT.compile(failureMessage);
	}
	return {
		...a,
		failureMessage: failureMessage || a.failureMessage
	};
};

/**
 * Merge two incomplete fallback messages (a, b), favoring `b`.
 */

const mergeFallbackMessage = (a, b) => {
	if (typeof b === 'string' && dotRegex.test(b)) {
		b = axe.imports.doT.compile(b);
	}
	return b || a;
};

/**
 * Apply locale for the given `checks`.
 */

Audit.prototype._applyCheckLocale = function(checks) {
	const keys = Object.keys(checks);
	for (let i = 0; i < keys.length; i++) {
		const id = keys[i];
		if (!this.data.checks[id]) {
			throw new Error(`Locale provided for unknown check: "${id}"`);
		}

		this.data.checks[id] = mergeCheckLocale(this.data.checks[id], checks[id]);
	}
};

/**
 * Apply locale for the given `rules`.
 */

Audit.prototype._applyRuleLocale = function(rules) {
	const keys = Object.keys(rules);
	for (let i = 0; i < keys.length; i++) {
		const id = keys[i];
		if (!this.data.rules[id]) {
			throw new Error(`Locale provided for unknown rule: "${id}"`);
		}
		this.data.rules[id] = mergeRuleLocale(this.data.rules[id], rules[id]);
	}
};

/**
 * Apply locale for the given failureMessage
 */

Audit.prototype._applyFailureSummaries = function(messages) {
	const keys = Object.keys(messages);
	for (let i = 0; i < keys.length; i++) {
		const key = keys[i];
		if (!this.data.failureSummaries[key]) {
			throw new Error(`Locale provided for unknown failureMessage: "${key}"`);
		}
		this.data.failureSummaries[key] = mergeFailureMessage(
			this.data.failureSummaries[key],
			messages[key]
		);
	}
};

/**
 * Apply the given `locale`.
 *
 * @param {axe.Locale}
 */

Audit.prototype.applyLocale = function(locale) {
	this._setDefaultLocale();

	if (locale.checks) {
		this._applyCheckLocale(locale.checks);
	}

	if (locale.rules) {
		this._applyRuleLocale(locale.rules);
	}

	if (locale.failureSummaries) {
		this._applyFailureSummaries(locale.failureSummaries, 'failureSummaries');
	}

	if (locale.incompleteFallbackMessage) {
		this.data.incompleteFallbackMessage = mergeFallbackMessage(
			this.data.incompleteFallbackMessage,
			locale.incompleteFallbackMessage
		);
	}

	if (locale.lang) {
		this.lang = locale.lang;
	}
};

/**
 * Initializes the rules and checks
 */
Audit.prototype._init = function() {
	var audit = getDefaultConfiguration(this.defaultConfig);

	axe.commons = commons = audit.commons;

	this.lang = audit.lang || 'en';
	this.reporter = audit.reporter;
	this.commands = {};
	this.rules = [];
	this.checks = {};

	unpackToObject(audit.rules, this, 'addRule');
	unpackToObject(audit.checks, this, 'addCheck');

	this.data = {};
	this.data.checks = (audit.data && audit.data.checks) || {};
	this.data.rules = (audit.data && audit.data.rules) || {};
	this.data.failureSummaries =
		(audit.data && audit.data.failureSummaries) || {};
	this.data.incompleteFallbackMessage =
		(audit.data && audit.data.incompleteFallbackMessage) || '';

	this._constructHelpUrls(); // create default helpUrls
};

/**
 * Adds a new command to the audit
 */

Audit.prototype.registerCommand = function(command) {
	'use strict';
	this.commands[command.id] = command.callback;
};

/**
 * Adds a new rule to the Audit.  If a rule with specified ID already exists, it will be overridden
 * @param {Object} spec Rule specification object
 */
Audit.prototype.addRule = function(spec) {
	'use strict';

	if (spec.metadata) {
		this.data.rules[spec.id] = spec.metadata;
	}

	let rule = this.getRule(spec.id);
	if (rule) {
		rule.configure(spec);
	} else {
		this.rules.push(new Rule(spec, this));
	}
};

/**
 * Adds a new check to the Audit.  If a Check with specified ID already exists, it will be
 * reconfigured
 *
 * @param {Object} spec Check specification object
 */
Audit.prototype.addCheck = function(spec) {
	/*eslint no-eval: 0 */
	'use strict';
	let metadata = spec.metadata;

	if (typeof metadata === 'object') {
		this.data.checks[spec.id] = metadata;
		// Transform messages into functions:
		if (typeof metadata.messages === 'object') {
			Object.keys(metadata.messages)
				.filter(
					prop =>
						metadata.messages.hasOwnProperty(prop) &&
						typeof metadata.messages[prop] === 'string'
				)
				.forEach(prop => {
					if (metadata.messages[prop].indexOf('function') === 0) {
						metadata.messages[prop] = new Function(
							'return ' + metadata.messages[prop] + ';'
						)();
					}
				});
		}
	}

	if (this.checks[spec.id]) {
		this.checks[spec.id].configure(spec);
	} else {
		this.checks[spec.id] = new Check(spec);
	}
};

/**
 * Splits a given array of rules to two, with rules that can be run immediately and one's that are dependent on preloadedAssets
 * @method getRulesToRun
 * @param {Array<Object>} rules complete list of rules
 * @param {Object} context
 * @param {Object} options
 * @return {Object} out, an object containing two arrays, one being list of rules to run now and list of rules to run later
 * @private
 */
function getRulesToRun(rules, context, options) {
	// entry object for reduce function below
	const base = {
		now: [],
		later: []
	};

	// iterate through rules and separate out rules that need to be run now vs later
	const splitRules = rules.reduce((out, rule) => {
		// ensure rule can run
		if (!axe.utils.ruleShouldRun(rule, context, options)) {
			return out;
		}

		// does rule require preload assets - push to later array
		if (rule.preload) {
			out.later.push(rule);
			return out;
		}

		// default to now array
		out.now.push(rule);

		// return
		return out;
	}, base);

	// return
	return splitRules;
}

/**
 * Convenience method, that consturcts a rule `run` function that can be deferred
 * @param {Object} rule rule to be deferred
 * @param {Object} context context object essential to be passed into rule `run`
 * @param {Object} options normalised options to be passed into rule `run`
 * @param {Object} assets (optional) preloaded assets to be passed into rule and checks (if the rule is preload dependent)
 * @return {Function} a deferrable function for rule
 */
function getDefferedRule(rule, context, options) {
	// init performance timer of requested via options
	if (options.performanceTimer) {
		axe.utils.performanceTimer.mark('mark_rule_start_' + rule.id);
	}

	return (resolve, reject) => {
		// invoke `rule.run`
		rule.run(
			context,
			options,
			// resolve callback for rule `run`
			ruleResult => {
				// resolve
				resolve(ruleResult);
			},
			// reject callback for rule `run`
			err => {
				// if debug - construct error details
				if (!options.debug) {
					const errResult = Object.assign(new RuleResult(rule), {
						result: axe.constants.CANTTELL,
						description: 'An error occured while running this rule',
						message: err.message,
						stack: err.stack,
						error: err,
						// Add a serialized reference to the node the rule failed on for easier debugging.
						// See https://github.com/dequelabs/axe-core/issues/1317.
						errorNode: err.errorNode
					});
					// resolve
					resolve(errResult);
				} else {
					// reject
					reject(err);
				}
			}
		);
	};
}

/**
 * Runs the Audit; which in turn should call `run` on each rule.
 * @async
 * @param  {Context}   context The scope definition/context for analysis (include/exclude)
 * @param  {Object}    options Options object to pass into rules and/or disable rules or checks
 * @param  {Function} fn       Callback function to fire when audit is complete
 */
Audit.prototype.run = function(context, options, resolve, reject) {
	'use strict';

	this.normalizeOptions(options);
	axe._selectCache = [];

	// get a list of rules to run NOW vs. LATER (later are preload assets dependent rules)
	const allRulesToRun = getRulesToRun(this.rules, context, options);
	const runNowRules = allRulesToRun.now;
	const runLaterRules = allRulesToRun.later;

	// init a NOW queue for rules to run immediately
	const nowRulesQueue = axe.utils.queue();
	// construct can run NOW rules into NOW queue
	runNowRules.forEach(rule => {
		nowRulesQueue.defer(getDefferedRule(rule, context, options));
	});

	// init a PRELOADER queue to start preloading assets
	const preloaderQueue = axe.utils.queue();
	// defer preload if preload dependent rules exist
	if (runLaterRules.length) {
		preloaderQueue.defer(resolve => {
			// handle both success and fail of preload
			// and resolve, to allow to run all checks
			axe.utils
				.preload(options)
				.then(assets => resolve(assets))
				.catch(err => {
					/**
					 * Note:
					 * we do not reject, to allow other (non-preload) rules to `run`
					 * -> instead we resolve as `undefined`
					 */
					console.warn(`Couldn't load preload assets: `, err);
					resolve(undefined);
				});
		});
	}

	// defer now and preload queue to run immediately
	const queueForNowRulesAndPreloader = axe.utils.queue();
	queueForNowRulesAndPreloader.defer(nowRulesQueue);
	queueForNowRulesAndPreloader.defer(preloaderQueue);

	// invoke the now queue
	queueForNowRulesAndPreloader
		.then(nowRulesAndPreloaderResults => {
			// interpolate results into separate variables
			const assetsFromQueue = nowRulesAndPreloaderResults.pop();
			if (assetsFromQueue && assetsFromQueue.length) {
				// result is a queue (again), hence the index resolution
				// assets is either an object of key value pairs of asset type and values
				// eg: cssom: [stylesheets]
				// or undefined if preload failed
				const assets = assetsFromQueue[0];
				// extend context with preloaded assets
				if (assets) {
					context = {
						...context,
						...assets
					};
				}
			}

			// the reminder of the results are RuleResults
			const nowRulesResults = nowRulesAndPreloaderResults[0];

			// if there are no rules to run LATER - resolve with rule results
			if (!runLaterRules.length) {
				// remove the cache
				axe._selectCache = undefined;
				// resolve
				resolve(nowRulesResults.filter(result => !!result));
				return;
			}

			// init a LATER queue for rules that are dependant on preloaded assets
			const laterRulesQueue = axe.utils.queue();
			runLaterRules.forEach(rule => {
				const deferredRule = getDefferedRule(rule, context, options);
				laterRulesQueue.defer(deferredRule);
			});
			// invoke the later queue
			laterRulesQueue
				.then(laterRuleResults => {
					// remove the cache
					axe._selectCache = undefined;
					// resolve
					resolve(
						nowRulesResults.concat(laterRuleResults).filter(result => !!result)
					);
				})
				.catch(reject);
		})
		.catch(reject);
};

/**
 * Runs Rule `after` post processing functions
 * @param  {Array} results  Array of RuleResults to postprocess
 * @param  {Mixed} options  Options object to pass into rules and/or disable rules or checks
 */
Audit.prototype.after = function(results, options) {
	'use strict';

	var rules = this.rules;

	return results.map(function(ruleResult) {
		var rule = axe.utils.findBy(rules, 'id', ruleResult.id);
		if (!rule) {
			// If you see this, you're probably running the Mocha tests with the axe extension installed
			throw new Error(
				'Result for unknown rule. You may be running mismatch axe-core versions'
			);
		}

		return rule.after(ruleResult, options);
	});
};

/**
 * Get the rule with a given ID
 * @param  {string}
 * @return {Rule}
 */
Audit.prototype.getRule = function(ruleId) {
	return this.rules.find(rule => rule.id === ruleId);
};

/**
 * Ensure all rules that are expected to run exist
 * @throws {Error} If any tag or rule specified in options is unknown
 * @param  {Object} options  Options object
 * @return {Object}          Validated options object
 */
Audit.prototype.normalizeOptions = function(options) {
	'use strict';
	var audit = this;

	const tags = [];
	const ruleIds = [];
	audit.rules.forEach(rule => {
		ruleIds.push(rule.id);
		rule.tags.forEach(tag => {
			if (!tags.includes(tag)) {
				tags.push(tag);
			}
		});
	});

	// Validate runOnly
	if (typeof options.runOnly === 'object') {
		if (Array.isArray(options.runOnly)) {
			const hasTag = options.runOnly.find(value => tags.includes(value));
			const hasRule = options.runOnly.find(value => ruleIds.includes(value));

			if (hasTag && hasRule) {
				throw new Error('runOnly cannot be both rules and tags');
			}
			if (hasRule) {
				options.runOnly = {
					type: 'rule',
					values: options.runOnly
				};
			} else {
				options.runOnly = {
					type: 'tag',
					values: options.runOnly
				};
			}
		}
		const only = options.runOnly;
		if (only.value && !only.values) {
			only.values = only.value;
			delete only.value;
		}

		if (!Array.isArray(only.values) || only.values.length === 0) {
			throw new Error('runOnly.values must be a non-empty array');
		}

		// Check if every value in options.runOnly is a known rule ID
		if (['rule', 'rules'].includes(only.type)) {
			only.type = 'rule';
			only.values.forEach(function(ruleId) {
				if (!ruleIds.includes(ruleId)) {
					throw new Error('unknown rule `' + ruleId + '` in options.runOnly');
				}
			});

			// Validate 'tags' (e.g. anything not 'rule')
		} else if (['tag', 'tags', undefined].includes(only.type)) {
			only.type = 'tag';
			const unmatchedTags = only.values.filter(tag => !tags.includes(tag));

			if (unmatchedTags.length !== 0) {
				axe.log('Could not find tags `' + unmatchedTags.join('`, `') + '`');
			}
		} else {
			throw new Error(`Unknown runOnly type '${only.type}'`);
		}
	}

	if (typeof options.rules === 'object') {
		Object.keys(options.rules).forEach(function(ruleId) {
			if (!ruleIds.includes(ruleId)) {
				throw new Error('unknown rule `' + ruleId + '` in options.rules');
			}
		});
	}

	return options;
};

/*
 * Updates the default options and then applies them
 * @param  {Mixed} options  Options object
 */

Audit.prototype.setBranding = function(branding) {
	'use strict';
	let previous = {
		brand: this.brand,
		application: this.application
	};
	if (
		branding &&
		branding.hasOwnProperty('brand') &&
		branding.brand &&
		typeof branding.brand === 'string'
	) {
		this.brand = branding.brand;
	}
	if (
		branding &&
		branding.hasOwnProperty('application') &&
		branding.application &&
		typeof branding.application === 'string'
	) {
		this.application = branding.application;
	}
	this._constructHelpUrls(previous);
};

/**
 * For all the rules, create the helpUrl and add it to the data for that rule
 */
function getHelpUrl({ brand, application, lang }, ruleId, version) {
	return (
		axe.constants.helpUrlBase +
		brand +
		'/' +
		(version || axe.version.substring(0, axe.version.lastIndexOf('.'))) +
		'/' +
		ruleId +
		'?application=' +
		encodeURIComponent(application) +
		(lang && lang !== 'en' ? '&lang=' + encodeURIComponent(lang) : '')
	);
}

Audit.prototype._constructHelpUrls = function(previous = null) {
	var version = (axe.version.match(/^[1-9][0-9]*\.[0-9]+/) || ['x.y'])[0];
	this.rules.forEach(rule => {
		if (!this.data.rules[rule.id]) {
			this.data.rules[rule.id] = {};
		}
		let metaData = this.data.rules[rule.id];
		if (
			typeof metaData.helpUrl !== 'string' ||
			(previous && metaData.helpUrl === getHelpUrl(previous, rule.id, version))
		) {
			metaData.helpUrl = getHelpUrl(this, rule.id, version);
		}
	});
};

/**
 * Reset the default rules, checks and meta data
 */

Audit.prototype.resetRulesAndChecks = function() {
	'use strict';
	this._init();
	this._resetLocale();
};
