'use strict';

var Promise = global.Promise || require('promise');

var fs = require('graceful-fs');
var path = require('path');
var EventEmitter = require('events').EventEmitter;
var pify = require('pify');
var mkdirp = require('mkdirp');
var rimraf = require('rimraf');
var junk = require('junk');
var errno = require('errno');
var maximatch = require('maximatch');
var slash = require('slash');

var CopyError = errno.custom.createError('CopyError');

var EVENT_ERROR = 'error';
var EVENT_COMPLETE = 'complete';
var EVENT_CREATE_DIRECTORY_START = 'createDirectoryStart';
var EVENT_CREATE_DIRECTORY_ERROR = 'createDirectoryError';
var EVENT_CREATE_DIRECTORY_COMPLETE = 'createDirectoryComplete';
var EVENT_CREATE_SYMLINK_START = 'createSymlinkStart';
var EVENT_CREATE_SYMLINK_ERROR = 'createSymlinkError';
var EVENT_CREATE_SYMLINK_COMPLETE = 'createSymlinkComplete';
var EVENT_COPY_FILE_START = 'copyFileStart';
var EVENT_COPY_FILE_ERROR = 'copyFileError';
var EVENT_COPY_FILE_COMPLETE = 'copyFileComplete';

var mkdir = pify(mkdirp, Promise);
var stat = pify(fs.stat, Promise);
var lstat = pify(fs.lstat, Promise);
var readlink = pify(fs.readlink, Promise);
var symlink = pify(fs.symlink, Promise);
var readdir = pify(fs.readdir, Promise);
var remove = pify(rimraf, Promise);

module.exports = function(src, dest, options, callback) {
	if ((arguments.length === 3) && (typeof options === 'function')) {
		callback = options;
		options = undefined;
	}
	options = options || {};

	var parentDirectory = path.dirname(dest);
	var shouldExpandSymlinks = Boolean(options.expand);

	var emitter;
	var hasFinished = false;
	if (options.debug) { log('Ensuring output directory exists…'); }
	var promise = ensureDirectoryExists(parentDirectory)
		.then(function() {
			if (options.debug) { log('Fetching source paths…'); }
			return getFilePaths(src, shouldExpandSymlinks)
		})
		.then(function(filePaths) {
			if (options.debug) { log('Filtering source paths…'); }
			var relativePaths = filePaths.map(function(filePath) {
				return path.relative(src, filePath);
			});
			var filteredPaths = getFilteredPaths(relativePaths, options.filter, {
				dot: options.dot,
				junk: options.junk
			});
			return filteredPaths.map(function(relativePath) {
				var inputPath = relativePath;
				var outputPath = options.rename ? options.rename(inputPath) : inputPath;
				return {
					src: path.join(src, inputPath),
					dest: path.join(dest, outputPath)
				};
			})
		})
		.then(function(operations) {
			if (options.debug) { log('Copying files…'); }
			var hasFinishedGetter = function() { return hasFinished; };
			var emitEvent = function() { emitter.emit.apply(emitter, arguments); };
			return batch(operations, function(operation) {
				return copy(operation.src, operation.dest, hasFinishedGetter, emitEvent, options);
			}, {
				results: options.results !== false,
				concurrency: options.concurrency || 255
			});
		})
		.catch(function(error) {
			if (options.debug) { log('Copy failed'); }
			if (error instanceof CopyError) {
				emitter.emit(EVENT_ERROR, error.error, error.data);
				throw error.error;
			} else {
				throw error;
			}
		})
		.then(function(results) {
			if (options.debug) { log('Copy complete'); }
			emitter.emit(EVENT_COMPLETE, results);
			return results;
		})
		.then(function(results) {
			hasFinished = true;
			return results;
		})
		.catch(function(error) {
			hasFinished = true;
			throw error;
		});

	if (typeof callback === 'function') {
		promise.then(function(results) {
			callback(null, results);
		})
		.catch(function(error) {
			callback(error);
		});
		emitter = new EventEmitter();
	} else {
		emitter = withEventEmitter(promise);
	}

	return emitter;
};

function batch(inputs, iteratee, options) {
	var results = options.results ? [] : undefined;
	if (inputs.length === 0) { return Promise.resolve(results); }
	return new Promise(function(resolve, reject) {
		var currentIndex = -1;
		var activeWorkers = 0;
		while (currentIndex < Math.min(inputs.length, options.concurrency) - 1) {
			startWorker(inputs[++currentIndex]);
		}

		function startWorker(input) {
			++activeWorkers;
			iteratee(input).then(function(result) {
				--activeWorkers;
				if (results) { results.push(result); }
				if (currentIndex < inputs.length - 1) {
					startWorker(inputs[++currentIndex]);
				} else if (activeWorkers === 0) {
					resolve(results);
				}
			}).catch(reject);
		}
	});
}

function getFilePaths(src, shouldExpandSymlinks) {
	return (shouldExpandSymlinks ? stat : lstat)(src)
		.then(function(stats) {
			if (stats.isDirectory()) {
				return getFileListing(src, shouldExpandSymlinks)
					.then(function(filenames) {
						return [src].concat(filenames);
					});
			} else {
				return [src];
			}
		});
}

function getFilteredPaths(paths, filter, options) {
	var useDotFilter = !options.dot;
	var useJunkFilter = !options.junk;
	if (!filter && !useDotFilter && !useJunkFilter) { return paths; }
	return paths.filter(function(path) {
		return (!useDotFilter || dotFilter(path)) && (!useJunkFilter || junkFilter(path)) && (!filter || (maximatch(slash(path), filter, options).length > 0));
	});
}

function dotFilter(relativePath) {
	var filename = path.basename(relativePath);
	return filename.charAt(0) !== '.';
}

function junkFilter(relativePath) {
	var filename = path.basename(relativePath);
	return !junk.is(filename);
}

function ensureDirectoryExists(path) {
	return mkdir(path);
}

function getFileListing(srcPath, shouldExpandSymlinks) {
	return readdir(srcPath)
		.then(function(filenames) {
			return Promise.all(
				filenames.map(function(filename) {
					var filePath = path.join(srcPath, filename);
					return (shouldExpandSymlinks ? stat : lstat)(filePath)
						.then(function(stats) {
							if (stats.isDirectory()) {
								return getFileListing(filePath, shouldExpandSymlinks)
									.then(function(childPaths) {
										return [filePath].concat(childPaths);
									});
							} else {
								return [filePath];
							}
						});
				})
			)
			.then(function mergeArrays(arrays) {
				return Array.prototype.concat.apply([], arrays);
			});
		});
}

function copy(srcPath, destPath, hasFinished, emitEvent, options) {
	if (options.debug) { log('Preparing to copy ' + srcPath + '…'); }
	return prepareForCopy(srcPath, destPath, options)
		.then(function(stats) {
			if (options.debug) { log('Copying ' + srcPath + '…'); }
			var copyFunction = getCopyFunction(stats, hasFinished, emitEvent);
			return copyFunction(srcPath, destPath, stats, options);
		})
		.catch(function(error) {
			if (error instanceof CopyError) {
				throw error;
			}
			var copyError = new CopyError(error.message);
			copyError.error = error;
			copyError.data = {
				src: srcPath,
				dest: destPath
			};
			throw copyError;
		})
		.then(function(result) {
			if (options.debug) { log('Copied ' + srcPath); }
			return result;
		});
}

function prepareForCopy(srcPath, destPath, options) {
	var shouldExpandSymlinks = Boolean(options.expand);
	var shouldOverwriteExistingFiles = Boolean(options.overwrite);
	return (shouldExpandSymlinks ? stat : lstat)(srcPath)
		.then(function(stats) {
			return ensureDestinationIsWritable(destPath, stats, shouldOverwriteExistingFiles)
				.then(function() {
					return stats;
				});
		});
}

function ensureDestinationIsWritable(destPath, srcStats, shouldOverwriteExistingFiles) {
	return lstat(destPath)
		.catch(function(error) {
			var shouldIgnoreError = error.code === 'ENOENT';
			if (shouldIgnoreError) { return null; }
			throw error;
		})
		.then(function(destStats) {
			var destExists = Boolean(destStats);
			if (!destExists) { return true; }

			var isMergePossible = srcStats.isDirectory() && destStats.isDirectory();
			if (isMergePossible) { return true; }

			if (shouldOverwriteExistingFiles) {
				return remove(destPath)
					.then(function(paths) {
						return true;
					});
			} else {
				throw fsError('EEXIST', destPath);
			}
		});
}

function getCopyFunction(stats, hasFinished, emitEvent) {
	if (stats.isDirectory()) {
		return createCopyFunction(copyDirectory, stats, hasFinished, emitEvent, {
			startEvent: EVENT_CREATE_DIRECTORY_START,
			completeEvent: EVENT_CREATE_DIRECTORY_COMPLETE,
			errorEvent: EVENT_CREATE_DIRECTORY_ERROR
		});
	} else if (stats.isSymbolicLink()) {
		return createCopyFunction(copySymlink, stats, hasFinished, emitEvent, {
			startEvent: EVENT_CREATE_SYMLINK_START,
			completeEvent: EVENT_CREATE_SYMLINK_COMPLETE,
			errorEvent: EVENT_CREATE_SYMLINK_ERROR
		});
	} else {
		return createCopyFunction(copyFile, stats, hasFinished, emitEvent, {
			startEvent: EVENT_COPY_FILE_START,
			completeEvent: EVENT_COPY_FILE_COMPLETE,
			errorEvent: EVENT_COPY_FILE_ERROR
		});
	}
}

function createCopyFunction(fn, stats, hasFinished, emitEvent, events) {
	var startEvent = events.startEvent;
	var completeEvent = events.completeEvent;
	var errorEvent = events.errorEvent;
	return function(srcPath, destPath, stats, options) {
		// Multiple chains of promises are fired in parallel,
		// so when one fails we need to prevent any future
		// copy operations
		if (hasFinished()) { return Promise.reject(); }
		var metadata = {
			src: srcPath,
			dest: destPath,
			stats: stats
		};
		emitEvent(startEvent, metadata);
		var parentDirectory = path.dirname(destPath);
		return ensureDirectoryExists(parentDirectory)
			.then(function() {
				return fn(srcPath, destPath, stats, options);
			})
			.then(function() {
				if (!hasFinished()) { emitEvent(completeEvent, metadata); }
				return metadata;
			})
			.catch(function(error) {
				if (!hasFinished()) { emitEvent(errorEvent, error, metadata); }
				throw error;
			});
	};
}

function copyFile(srcPath, destPath, stats, options) {
	return new Promise(function(resolve, reject) {
		var hasFinished = false;

		var read = fs.createReadStream(srcPath);
		read.on('error', handleCopyFailed);

		var write = fs.createWriteStream(destPath, {
			flags: 'w',
			mode: stats.mode
		});
		write.on('error', handleCopyFailed);
		write.on('finish', function() {
			fs.utimes(destPath, stats.atime, stats.mtime, function() {
				hasFinished = true;
				resolve();
			});
		});

		var transformStream = null;
		if (options.transform) {
			transformStream = options.transform(srcPath, destPath, stats);
			if (transformStream) {
				transformStream.on('error', handleCopyFailed);
				read.pipe(transformStream).pipe(write);
			} else {
				read.pipe(write);
			}
		} else {
			read.pipe(write);
		}


		function handleCopyFailed(error) {
			if (hasFinished) { return; }
			hasFinished = true;
			if (typeof read.close === 'function') {
				read.close();
			}
			if (typeof write.close === 'function') {
				write.close();
			}
			return reject(error);
		}
	});
}

function copySymlink(srcPath, destPath, stats, options) {
	return readlink(srcPath)
		.then(function(link) {
			return symlink(link, destPath);
		});
}

function copyDirectory(srcPath, destPath, stats, options) {
	return mkdir(destPath)
		.catch(function(error) {
			var shouldIgnoreError = error.code === 'EEXIST';
			if (shouldIgnoreError) { return; }
			throw error;
		});
}

function fsError(code, path) {
	var errorType = errno.code[code];
	var message = errorType.code + ', ' + errorType.description + ' ' + path;
	var error = new Error(message);
	error.errno = errorType.errno;
	error.code = errorType.code;
	error.path = path;
	return error;
}

function log(message) {
	process.stdout.write(message + '\n');
}

function withEventEmitter(target) {
	for (var key in EventEmitter.prototype) {
		target[key] = EventEmitter.prototype[key];
	}
	EventEmitter.call(target);
	return target;
}

module.exports.events = {
	ERROR: EVENT_ERROR,
	COMPLETE: EVENT_COMPLETE,
	CREATE_DIRECTORY_START: EVENT_CREATE_DIRECTORY_START,
	CREATE_DIRECTORY_ERROR: EVENT_CREATE_DIRECTORY_ERROR,
	CREATE_DIRECTORY_COMPLETE: EVENT_CREATE_DIRECTORY_COMPLETE,
	CREATE_SYMLINK_START: EVENT_CREATE_SYMLINK_START,
	CREATE_SYMLINK_ERROR: EVENT_CREATE_SYMLINK_ERROR,
	CREATE_SYMLINK_COMPLETE: EVENT_CREATE_SYMLINK_COMPLETE,
	COPY_FILE_START: EVENT_COPY_FILE_START,
	COPY_FILE_ERROR: EVENT_COPY_FILE_ERROR,
	COPY_FILE_COMPLETE: EVENT_COPY_FILE_COMPLETE
};