'use strict';
const nodePath = require('path');
const debug = require('debug')('ava:watcher');
const chokidar = require('chokidar');
const diff = require('lodash/difference');
const flatten = require('lodash/flatten');
const chalk = require('./chalk').get();
const {applyTestFileFilter, classify, getChokidarIgnorePatterns} = require('./globs');
const {levels: providerLevels} = require('./provider-manager');

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

const MIN_DEBOUNCE_DELAY = 10;
const INITIAL_DEBOUNCE_DELAY = 100;
const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n');

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

	debounce(delay) {
		if (this.timer) {
			this.again = true;
			return;
		}

		delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY;

		const timer = setTimeout(async () => {
			await this.watcher.busy;
			// Do nothing if debouncing was canceled while waiting for the busy
			// promise to fulfil
			if (this.timer !== timer) {
				return;
			}

			if (this.again) {
				this.timer = null;
				this.again = false;
				this.debounce(delay / 2);
			} else {
				this.watcher.runAfterChanges();
				this.timer = null;
				this.again = false;
			}
		}, delay);

		this.timer = timer;
	}

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

class TestDependency {
	constructor(file, dependencies) {
		this.file = file;
		this.dependencies = dependencies;
	}

	contains(dependency) {
		return this.dependencies.includes(dependency);
	}
}

class Watcher {
	constructor({api, filter = [], globs, projectDir, providers, reporter}) {
		this.debouncer = new Debouncer(this);

		this.clearLogOnNextRun = true;
		this.runVector = 0;
		this.previousFiles = [];
		this.globs = {cwd: projectDir, ...globs};

		const patternFilters = filter.map(({pattern}) => pattern);

		this.providers = providers.filter(({level}) => level >= providerLevels.pathRewrites);
		this.run = (specificFiles = [], updateSnapshots = false) => {
			const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0;
			if (this.runVector > 0) {
				this.clearLogOnNextRun = true;
			}

			this.runVector++;

			let runOnlyExclusive = false;
			if (specificFiles.length > 0) {
				const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file));
				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.
					const remainingFiles = diff(specificFiles, exclusiveFiles);
					specificFiles = this.filesWithExclusiveTests.concat(remainingFiles);
				}

				if (filter.length > 0) {
					specificFiles = applyTestFileFilter({cwd: projectDir, filter: patternFilters, testFiles: specificFiles});
				}

				this.pruneFailures(specificFiles);
			}

			this.touchedFiles.clear();
			this.previousFiles = specificFiles;
			this.busy = api.run({
				files: specificFiles,
				filter,
				runtimeOptions: {
					clearLogOnNextRun,
					previousFailures: this.sumPreviousFailures(this.runVector),
					runOnlyExclusive,
					runVector: this.runVector,
					updateSnapshots: updateSnapshots === true
				}
			})
				.then(runStatus => { // eslint-disable-line promise/prefer-await-to-then
					reporter.endRun();
					reporter.lineWriter.writeLine(END_MESSAGE);

					if (this.clearLogOnNextRun && (
						runStatus.stats.failedHooks > 0 ||
						runStatus.stats.failedTests > 0 ||
						runStatus.stats.failedWorkers > 0 ||
						runStatus.stats.internalErrors > 0 ||
						runStatus.stats.timeouts > 0 ||
						runStatus.stats.uncaughtExceptions > 0 ||
						runStatus.stats.unhandledRejections > 0
					)) {
						this.clearLogOnNextRun = false;
					}
				})
				.catch(rethrowAsync);
		};

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

		this.touchedFiles = new Set();
		this.trackTouchedFiles(api);

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

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

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

	watchFiles() {
		chokidar.watch(['**/*'], {
			cwd: this.globs.cwd,
			ignored: getChokidarIgnorePatterns(this.globs),
			ignoreInitial: true
		}).on('all', (event, path) => {
			if (event === 'add' || event === 'change' || event === 'unlink') {
				debug('Detected %s of %s', event, path);
				this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event;
				this.debouncer.debounce();
			}
		});
	}

	trackTestDependencies(api) {
		api.on('run', plan => {
			plan.status.on('stateChange', evt => {
				if (evt.type !== 'dependencies') {
					return;
				}

				const dependencies = evt.dependencies.filter(filePath => {
					const {isIgnoredByWatcher} = classify(filePath, this.globs);
					return !isIgnoredByWatcher;
				});
				this.updateTestDependencies(evt.testFile, dependencies);
			});
		});
	}

	updateTestDependencies(file, dependencies) {
		// Ensure the rewritten test file path is included in the dependencies,
		// since changes to non-rewritten paths are ignored.
		for (const {main} of this.providers) {
			const rewritten = main.resolveTestFile(file);
			if (!dependencies.includes(rewritten)) {
				dependencies = [rewritten, ...dependencies];
			}
		}

		if (dependencies.length === 0) {
			this.testDependencies = this.testDependencies.filter(dep => dep.file !== file);
			return;
		}

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

			dep.dependencies = dependencies;

			return true;
		});

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

	trackTouchedFiles(api) {
		api.on('run', plan => {
			plan.status.on('stateChange', evt => {
				if (evt.type !== 'touched-files') {
					return;
				}

				for (const file of evt.files) {
					this.touchedFiles.add(file);
				}
			});
		});
	}

	trackExclusivity(api) {
		api.on('run', plan => {
			plan.status.on('stateChange', evt => {
				if (evt.type !== 'worker-finished') {
					return;
				}

				const fileStats = plan.status.stats.byFile.get(evt.testFile);
				const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests;
				this.updateExclusivity(evt.testFile, ranExclusiveTests);
			});
		});
	}

	updateExclusivity(file, hasExclusiveTests) {
		const index = this.filesWithExclusiveTests.indexOf(file);

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

	trackFailures(api) {
		api.on('run', plan => {
			this.pruneFailures(plan.files);

			const currentVector = this.runVector;
			plan.status.on('stateChange', evt => {
				if (!evt.testFile) {
					return;
				}

				switch (evt.type) {
					case 'hook-failed':
					case 'internal-error':
					case 'test-failed':
					case 'uncaught-exception':
					case 'unhandled-rejection':
					case 'worker-failed':
						this.countFailure(evt.testFile, currentVector);
						break;
					default:
						break;
				}
			});
		});
	}

	pruneFailures(files) {
		const toPrune = new Set(files);
		this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
	}

	countFailure(file, vector) {
		const isUpdate = this.filesWithFailures.some(state => {
			if (state.file !== file) {
				return false;
			}

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

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

	sumPreviousFailures(beforeVector) {
		let total = 0;

		for (const state of this.filesWithFailures) {
			if (state.vector < beforeVector) {
				total += state.count;
			}
		}

		return total;
	}

	cleanUnlinkedTests(unlinkedTests) {
		for (const testFile of unlinkedTests) {
			this.updateTestDependencies(testFile, []);
			this.updateExclusivity(testFile, false);
			this.pruneFailures([testFile]);
		}
	}

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

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

			// Cancel the debouncer, it might rerun specific tests whereas *all* tests
			// need to be rerun
			this.debouncer.cancel();
			await this.busy;
			// Cancel the debouncer again, it might have restarted while waiting for
			// the busy promise to fulfil
			this.debouncer.cancel();
			this.clearLogOnNextRun = false;
			if (data === 'u') {
				this.updatePreviousSnapshots();
			} else {
				this.rerunAll();
			}
		});
	}

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

	updatePreviousSnapshots() {
		this.dirtyStates = {};
		this.run(this.previousFiles, true);
	}

	runAfterChanges() {
		const {dirtyStates} = this;
		this.dirtyStates = {};

		let dirtyPaths = Object.keys(dirtyStates).filter(path => {
			if (this.touchedFiles.has(path)) {
				debug('Ignoring known touched file %s', path);
				this.touchedFiles.delete(path);
				return false;
			}

			return true;
		});

		for (const {main} of this.providers) {
			dirtyPaths = dirtyPaths.filter(path => {
				if (main.ignoreChange(path)) {
					debug('Ignoring changed file %s', path);
					return false;
				}

				return true;
			});
		}

		const dirtyHelpersAndSources = [];
		const dirtyTests = [];
		for (const filePath of dirtyPaths) {
			const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs);
			if (!isIgnoredByWatcher) {
				if (isTest) {
					dirtyTests.push(filePath);
				} else {
					dirtyHelpersAndSources.push(filePath);
				}
			}
		}

		const addedOrChangedTests = dirtyTests.filter(path => dirtyStates[path] !== 'unlink');
		const 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 (dirtyHelpersAndSources.length === 0) {
			// Run any new or changed tests
			this.run(addedOrChangedTests);
			return;
		}

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

		// Rerun all tests if source files were changed that could not be traced to
		// specific tests
		if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) {
			debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources);
			debug('Rerunning all tests');
			this.run();
			return;
		}

		// Run all affected tests
		this.run([...new Set(addedOrChangedTests.concat(flatten(testsByHelpersOrSource)))]);
	}
}

module.exports = Watcher;
