'use strict';
const path = require('path');
const fs = require('graceful-fs');
const decompressTar = require('decompress-tar');
const decompressTarbz2 = require('decompress-tarbz2');
const decompressTargz = require('decompress-targz');
const decompressUnzip = require('decompress-unzip');
const makeDir = require('make-dir');
const pify = require('pify');
const stripDirs = require('strip-dirs');

const fsP = pify(fs);

const runPlugins = (input, opts) => {
	if (opts.plugins.length === 0) {
		return Promise.resolve([]);
	}

	return Promise.all(opts.plugins.map(x => x(input, opts))).then(files => files.reduce((a, b) => a.concat(b)));
};

const safeMakeDir = (dir, realOutputPath) => {
	return fsP.realpath(dir)
		.catch(_ => {
			const parent = path.dirname(dir);
			return safeMakeDir(parent, realOutputPath);
		})
		.then(realParentPath => {
			if (realParentPath.indexOf(realOutputPath) !== 0) {
				throw (new Error('Refusing to create a directory outside the output path.'));
			}

			return makeDir(dir).then(fsP.realpath);
		});
};

const preventWritingThroughSymlink = (destination, realOutputPath) => {
	return fsP.readlink(destination)
		.catch(_ => {
			// Either no file exists, or it's not a symlink. In either case, this is
			// not an escape we need to worry about in this phase.
			return null;
		})
		.then(symlinkPointsTo => {
			if (symlinkPointsTo) {
				throw new Error('Refusing to write into a symlink');
			}

			// No symlink exists at `destination`, so we can continue
			return realOutputPath;
		});
};

const extractFile = (input, output, opts) => runPlugins(input, opts).then(files => {
	if (opts.strip > 0) {
		files = files
			.map(x => {
				x.path = stripDirs(x.path, opts.strip);
				return x;
			})
			.filter(x => x.path !== '.');
	}

	if (typeof opts.filter === 'function') {
		files = files.filter(opts.filter);
	}

	if (typeof opts.map === 'function') {
		files = files.map(opts.map);
	}

	if (!output) {
		return files;
	}

	return Promise.all(files.map(x => {
		const dest = path.join(output, x.path);
		const mode = x.mode & ~process.umask();
		const now = new Date();

		if (x.type === 'directory') {
			return makeDir(output)
				.then(outputPath => fsP.realpath(outputPath))
				.then(realOutputPath => safeMakeDir(dest, realOutputPath))
				.then(() => fsP.utimes(dest, now, x.mtime))
				.then(() => x);
		}

		return makeDir(output)
			.then(outputPath => fsP.realpath(outputPath))
			.then(realOutputPath => {
				// Attempt to ensure parent directory exists (failing if it's outside the output dir)
				return safeMakeDir(path.dirname(dest), realOutputPath)
					.then(() => realOutputPath);
			})
			.then(realOutputPath => {
				if (x.type === 'file') {
					return preventWritingThroughSymlink(dest, realOutputPath);
				}

				return realOutputPath;
			})
			.then(realOutputPath => {
				return fsP.realpath(path.dirname(dest))
					.then(realDestinationDir => {
						if (realDestinationDir.indexOf(realOutputPath) !== 0) {
							throw (new Error('Refusing to write outside output directory: ' + realDestinationDir));
						}
					});
			})
			.then(() => {
				if (x.type === 'link') {
					return fsP.link(x.linkname, dest);
				}

				if (x.type === 'symlink' && process.platform === 'win32') {
					return fsP.link(x.linkname, dest);
				}

				if (x.type === 'symlink') {
					return fsP.symlink(x.linkname, dest);
				}

				return fsP.writeFile(dest, x.data, {mode});
			})
			.then(() => x.type === 'file' && fsP.utimes(dest, now, x.mtime))
			.then(() => x);
	}));
});

module.exports = (input, output, opts) => {
	if (typeof input !== 'string' && !Buffer.isBuffer(input)) {
		return Promise.reject(new TypeError('Input file required'));
	}

	if (typeof output === 'object') {
		opts = output;
		output = null;
	}

	opts = Object.assign({plugins: [
		decompressTar(),
		decompressTarbz2(),
		decompressTargz(),
		decompressUnzip()
	]}, opts);

	const read = typeof input === 'string' ? fsP.readFile(input) : Promise.resolve(input);

	return read.then(buf => extractFile(buf, output, opts));
};
