'use strict';
var EventEmitter = require('events').EventEmitter;
var path = require('path');
var util = require('util');
var Promise = require('bluebird');
var objectAssign = require('object-assign');
var commonPathPrefix = require('common-path-prefix');
var resolveCwd = require('resolve-cwd');
var uniqueTempDir = require('unique-temp-dir');
var findCacheDir = require('find-cache-dir');
var debounce = require('lodash.debounce');
var ms = require('ms');
var AvaError = require('./lib/ava-error');
var fork = require('./lib/fork');
var CachingPrecompiler = require('./lib/caching-precompiler');
var AvaFiles = require('./lib/ava-files');
var RunStatus = require('./lib/run-status');

function Api(options) {
	if (!(this instanceof Api)) {
		throw new TypeError('Class constructor Api cannot be invoked without \'new\'');
	}

	EventEmitter.call(this);

	this.options = options || {};
	this.options.match = this.options.match || [];
	this.options.require = (this.options.require || []).map(function (moduleId) {
		var ret = resolveCwd(moduleId);
		if (ret === null) {
			throw new Error('Could not resolve required module \'' + moduleId + '\'');
		}

		return ret;
	});

	Object.keys(Api.prototype).forEach(function (key) {
		this[key] = this[key].bind(this);
	}, this);
}

util.inherits(Api, EventEmitter);
module.exports = Api;

Api.prototype._runFile = function (file, runStatus) {
	var hash = this.precompiler.precompileFile(file);
	var precompiled = {};
	precompiled[file] = hash;

	var options = objectAssign({}, this.options, {
		precompiled: precompiled
	});

	var emitter = fork(file, options);

	runStatus.observeFork(emitter);

	return emitter;
};

Api.prototype._onTimeout = function (runStatus) {
	var timeout = ms(this.options.timeout);
	var message = 'Exited because no new tests completed within the last ' + timeout + 'ms of inactivity';

	runStatus.handleExceptions({
		exception: new AvaError(message),
		file: undefined
	});

	runStatus.emit('timeout');
};

Api.prototype.run = function (files, options) {
	var self = this;

	return new AvaFiles(files)
		.findTestFiles()
		.then(function (files) {
			return self._run(files, options);
		});
};

Api.prototype._run = function (files, _options) {
	var self = this;
	var runStatus = new RunStatus({
		prefixTitles: this.options.explicitTitles || files.length > 1,
		runOnlyExclusive: _options && _options.runOnlyExclusive,
		base: path.relative('.', commonPathPrefix(files)) + path.sep
	});

	if (self.options.timeout) {
		var timeout = ms(self.options.timeout);
		runStatus._restartTimer = debounce(function () {
			self._onTimeout(runStatus);
		}, timeout);
		runStatus._restartTimer();
		runStatus.on('test', runStatus._restartTimer);
	}

	self.emit('test-run', runStatus, files);

	if (files.length === 0) {
		runStatus.handleExceptions({
			exception: new AvaError('Couldn\'t find any files to test'),
			file: undefined
		});

		return Promise.resolve(runStatus);
	}

	var cacheEnabled = self.options.cacheEnabled !== false;
	var cacheDir = (cacheEnabled && findCacheDir({name: 'ava', files: files})) ||
		uniqueTempDir();

	self.options.cacheDir = cacheDir;
	self.precompiler = new CachingPrecompiler(cacheDir, self.options.babelConfig);
	self.fileCount = files.length;

	var overwatch;
	if (this.options.concurrency > 0) {
		overwatch = this._runLimitedPool(files, runStatus, self.options.serial ? 1 : this.options.concurrency);
	} else {
		// _runNoPool exists to preserve legacy behavior, specifically around `.only`
		overwatch = this._runNoPool(files, runStatus);
	}

	return overwatch;
};

Api.prototype._runNoPool = function (files, runStatus) {
	var self = this;
	var tests = new Array(self.fileCount);

	// TODO: thid should be cleared at the end of the run
	runStatus.on('timeout', function () {
		tests.forEach(function (fork) {
			fork.exit();
		});
	});

	return new Promise(function (resolve) {
		function run() {
			if (self.options.match.length > 0 && !runStatus.hasExclusive) {
				runStatus.handleExceptions({
					exception: new AvaError('Couldn\'t find any matching tests'),
					file: undefined
				});

				resolve([]);
				return;
			}

			var method = self.options.serial ? 'mapSeries' : 'map';
			var options = {
				runOnlyExclusive: runStatus.hasExclusive
			};

			resolve(Promise[method](files, function (file, index) {
				return tests[index].run(options).catch(function (err) {
					// The test failed catastrophically. Flag it up as an
					// exception, then return an empty result. Other tests may
					// continue to run.
					runStatus.handleExceptions({
						exception: err,
						file: path.relative('.', file)
					});

					return getBlankResults();
				});
			}));
		}

		// receive test count from all files and then run the tests
		var unreportedFiles = self.fileCount;
		var bailed = false;

		files.every(function (file, index) {
			var tried = false;

			function tryRun() {
				if (!tried && !bailed) {
					tried = true;
					unreportedFiles--;

					if (unreportedFiles === 0) {
						run();
					}
				}
			}

			try {
				var test = tests[index] = self._runFile(file, runStatus);

				test.on('stats', tryRun);
				test.catch(tryRun);

				return true;
			} catch (err) {
				bailed = true;

				runStatus.handleExceptions({
					exception: err,
					file: path.relative('.', file)
				});

				resolve([]);

				return false;
			}
		});
	}).then(function (results) {
		if (results.length === 0) {
			// No tests ran, make sure to tear down the child processes.
			tests.forEach(function (test) {
				test.send('teardown');
			});
		}

		return results;
	}).then(function (results) {
		// cancel debounced _onTimeout() from firing
		if (self.options.timeout) {
			runStatus._restartTimer.cancel();
		}

		runStatus.processResults(results);
		return runStatus;
	});
};

function getBlankResults() {
	return {
		stats: {
			testCount: 0,
			passCount: 0,
			knownFailureCount: 0,
			skipCount: 0,
			todoCount: 0,
			failCount: 0
		},
		tests: []
	};
}

Api.prototype._runLimitedPool = function (files, runStatus, concurrency) {
	var self = this;
	var tests = {};

	runStatus.on('timeout', function () {
		Object.keys(tests).forEach(function (file) {
			var fork = tests[file];
			fork.exit();
		});
	});

	return Promise.map(files, function (file) {
		var handleException = function (err) {
			runStatus.handleExceptions({
				exception: err,
				file: path.relative('.', file)
			});
		};

		try {
			var test = tests[file] = self._runFile(file, runStatus);

			return new Promise(function (resolve, reject) {
				var runner = function () {
					var options = {
						// If we're looking for matches, run every single test process in exclusive-only mode
						runOnlyExclusive: self.options.match.length > 0
					};
					test.run(options)
						.then(resolve)
						.catch(reject);
				};

				test.on('stats', runner);
				test.on('exit', function () {
					delete tests[file];
				});
				test.catch(runner);
			}).catch(handleException);
		} catch (err) {
			handleException(err);
		}
	}, {concurrency: concurrency})
		.then(function (results) {
			// Filter out undefined results (usually result of caught exceptions)
			results = results.filter(Boolean);

			// cancel debounced _onTimeout() from firing
			if (self.options.timeout) {
				runStatus._restartTimer.cancel();
			}

			if (self.options.match.length > 0 && !runStatus.hasExclusive) {
				// Ensure results are empty
				results = [];
				runStatus.handleExceptions({
					exception: new AvaError('Couldn\'t find any matching tests'),
					file: undefined
				});
			}

			runStatus.processResults(results);
			return runStatus;
		});
};