'use strict';
var nodePath = require('path');
var debug = require('debug')('ava:watcher');
var diff = require('arr-diff');
var flatten = require('arr-flatten');
var union = require('array-union');
var uniq = require('array-uniq');
var AvaError = require('./ava-error');
var AvaFiles = require('./ava-files');

function requireChokidar() {
	try {
		return require('chokidar');
	} catch (err) {
		throw new AvaError('The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.');
	}
}

function rethrowAsync(err) {
	// Don't swallow exceptions. Note that any expected error should already have
	// been logged.
	setImmediate(function () {
		throw err;
	});
}

function Watcher(logger, api, files, sources) {
	this.debouncer = new Debouncer(this);
	this.avaFiles = new AvaFiles(files, sources);

	this.isTest = this.avaFiles.makeTestMatcher();

	this.clearLogOnNextRun = true;
	this.runVector = 0;
	this.run = function (specificFiles) {
		if (this.runVector > 0) {
			var cleared = this.clearLogOnNextRun && logger.clear();
			if (!cleared) {
				logger.reset();
				logger.section();
			}
			this.clearLogOnNextRun = true;

			logger.reset();
			logger.start();
		}

		var currentVector = this.runVector += 1;

		var runOnlyExclusive = false;

		if (specificFiles) {
			var exclusiveFiles = specificFiles.filter(function (file) {
				return this.filesWithExclusiveTests.indexOf(file) !== -1;
			}, this);

			runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length;

			if (runOnlyExclusive) {
				// The test files that previously contained exclusive tests are always
				// run, together with the remaining specific files.
				var remainingFiles = diff(specificFiles, exclusiveFiles);
				specificFiles = this.filesWithExclusiveTests.concat(remainingFiles);
			}
		}

		var self = this;
		this.busy = api.run(specificFiles || files, {
			runOnlyExclusive: runOnlyExclusive
		}).then(function (runStatus) {
			runStatus.previousFailCount = self.sumPreviousFailures(currentVector);
			logger.finish(runStatus);

			var badCounts = runStatus.failCount + runStatus.rejectionCount + runStatus.exceptionCount;
			self.clearLogOnNextRun = self.clearLogOnNextRun && badCounts === 0;
		}, rethrowAsync);
	};

	this.testDependencies = [];
	this.trackTestDependencies(api, sources);

	this.filesWithExclusiveTests = [];
	this.trackExclusivity(api);

	this.filesWithFailures = [];
	this.trackFailures(api);

	this.dirtyStates = {};
	this.watchFiles();
	this.rerunAll();
}

module.exports = Watcher;

Watcher.prototype.watchFiles = function () {
	var self = this;
	var patterns = this.avaFiles.getChokidarPatterns();

	requireChokidar().watch(patterns.paths, {
		ignored: patterns.ignored,
		ignoreInitial: true
	}).on('all', function (event, path) {
		if (event === 'add' || event === 'change' || event === 'unlink') {
			debug('Detected %s of %s', event, path);
			self.dirtyStates[path] = event;
			self.debouncer.debounce();
		}
	});
};

Watcher.prototype.trackTestDependencies = function (api) {
	var self = this;
	var isSource = this.avaFiles.makeSourceMatcher();

	var relative = function (absPath) {
		return nodePath.relative('.', absPath);
	};

	api.on('test-run', function (runStatus) {
		runStatus.on('dependencies', function (file, dependencies) {
			var sourceDeps = dependencies.map(relative).filter(isSource);
			self.updateTestDependencies(file, sourceDeps);
		});
	});
};

Watcher.prototype.updateTestDependencies = function (file, sources) {
	if (sources.length === 0) {
		this.testDependencies = this.testDependencies.filter(function (dep) {
			return dep.file !== file;
		});

		return;
	}

	var isUpdate = this.testDependencies.some(function (dep) {
		if (dep.file !== file) {
			return false;
		}

		dep.sources = sources;

		return true;
	});

	if (!isUpdate) {
		this.testDependencies.push(new TestDependency(file, sources));
	}
};

Watcher.prototype.trackExclusivity = function (api) {
	var self = this;

	api.on('stats', function (stats) {
		self.updateExclusivity(stats.file, stats.hasExclusive);
	});
};

Watcher.prototype.updateExclusivity = function (file, hasExclusiveTests) {
	var index = this.filesWithExclusiveTests.indexOf(file);

	if (hasExclusiveTests && index === -1) {
		this.filesWithExclusiveTests.push(file);
	} else if (!hasExclusiveTests && index !== -1) {
		this.filesWithExclusiveTests.splice(index, 1);
	}
};

Watcher.prototype.trackFailures = function (api) {
	var self = this;

	api.on('test-run', function (runStatus, files) {
		files.forEach(function (file) {
			self.pruneFailures(nodePath.relative('.', file));
		});

		var currentVector = self.runVector;
		runStatus.on('error', function (err) {
			self.countFailure(err.file, currentVector);
		});
		runStatus.on('test', function (result) {
			if (result.error) {
				self.countFailure(result.file, currentVector);
			}
		});
	});
};

Watcher.prototype.pruneFailures = function (file) {
	this.filesWithFailures = this.filesWithFailures.filter(function (state) {
		return state.file !== file;
	});
};

Watcher.prototype.countFailure = function (file, vector) {
	var isUpdate = this.filesWithFailures.some(function (state) {
		if (state.file !== file) {
			return false;
		}

		state.count++;
		return true;
	});

	if (!isUpdate) {
		this.filesWithFailures.push({
			file: file,
			vector: vector,
			count: 1
		});
	}
};

Watcher.prototype.sumPreviousFailures = function (beforeVector) {
	var total = 0;

	this.filesWithFailures.forEach(function (state) {
		if (state.vector < beforeVector) {
			total += state.count;
		}
	});

	return total;
};

Watcher.prototype.cleanUnlinkedTests = function (unlinkedTests) {
	unlinkedTests.forEach(function (testFile) {
		this.updateTestDependencies(testFile, []);
		this.updateExclusivity(testFile, false);
		this.pruneFailures(testFile);
	}, this);
};

Watcher.prototype.observeStdin = function (stdin) {
	var self = this;

	stdin.resume();
	stdin.setEncoding('utf8');

	stdin.on('data', function (data) {
		data = data.trim().toLowerCase();
		if (data !== 'r' && data !== 'rs') {
			return;
		}

		// Cancel the debouncer, it might rerun specific tests whereas *all* tests
		// need to be rerun.
		self.debouncer.cancel();
		self.busy.then(function () {
			// Cancel the debouncer again, it might have restarted while waiting for
			// the busy promise to fulfil.
			self.debouncer.cancel();
			self.clearLogOnNextRun = false;
			self.rerunAll();
		});
	});
};

Watcher.prototype.rerunAll = function () {
	this.dirtyStates = {};
	this.run();
};

Watcher.prototype.runAfterChanges = function () {
	var dirtyStates = this.dirtyStates;
	this.dirtyStates = {};

	var dirtyPaths = Object.keys(dirtyStates);
	var dirtyTests = dirtyPaths.filter(this.isTest);
	var dirtySources = diff(dirtyPaths, dirtyTests);
	var addedOrChangedTests = dirtyTests.filter(function (path) {
		return dirtyStates[path] !== 'unlink';
	});
	var unlinkedTests = diff(dirtyTests, addedOrChangedTests);

	this.cleanUnlinkedTests(unlinkedTests);
	// No need to rerun tests if the only change is that tests were deleted.
	if (unlinkedTests.length === dirtyPaths.length) {
		return;
	}

	if (dirtySources.length === 0) {
		// Run any new or changed tests.
		this.run(addedOrChangedTests);
		return;
	}

	// Try to find tests that depend on the changed source files.
	var testsBySource = dirtySources.map(function (path) {
		return this.testDependencies.filter(function (dep) {
			return dep.contains(path);
		}).map(function (dep) {
			debug('%s is a dependency of %s', path, dep.file);
			return dep.file;
		});
	}, this).filter(function (tests) {
		return tests.length > 0;
	});

	// Rerun all tests if source files were changed that could not be traced to
	// specific tests.
	if (testsBySource.length !== dirtySources.length) {
		debug('Sources remain that cannot be traced to specific tests. Rerunning all tests');
		this.run();
		return;
	}

	// Run all affected tests.
	this.run(union(addedOrChangedTests, uniq(flatten(testsBySource))));
};

function Debouncer(watcher) {
	this.watcher = watcher;
	this.timer = null;
	this.repeat = false;
}

Debouncer.prototype.debounce = function () {
	if (this.timer) {
		this.again = true;
		return;
	}

	var self = this;

	var timer = this.timer = setTimeout(function () {
		self.watcher.busy.then(function () {
			// Do nothing if debouncing was canceled while waiting for the busy
			// promise to fulfil.
			if (self.timer !== timer) {
				return;
			}

			if (self.again) {
				self.timer = null;
				self.again = false;
				self.debounce();
			} else {
				self.watcher.runAfterChanges();
				self.timer = null;
				self.again = false;
			}
		});
	}, 10);
};

Debouncer.prototype.cancel = function () {
	if (this.timer) {
		clearTimeout(this.timer);
		this.timer = null;
		this.again = false;
	}
};

function TestDependency(file, sources) {
	this.file = file;
	this.sources = sources;
}

TestDependency.prototype.contains = function (source) {
	return this.sources.indexOf(source) !== -1;
};
