'use strict';
const fs = require('fs');
const path = require('path');
const writeFileAtomic = require('@ava/write-file-atomic');
const babel = require('@babel/core');
const convertSourceMap = require('convert-source-map');
const isPlainObject = require('is-plain-object');
const md5Hex = require('md5-hex');
const packageHash = require('package-hash');
const stripBomBuf = require('strip-bom-buf');
const chalk = require('./chalk').get();

function getSourceMap(filePath, code) {
	let sourceMap = convertSourceMap.fromSource(code);

	if (!sourceMap) {
		const dirPath = path.dirname(filePath);
		sourceMap = convertSourceMap.fromMapFileSource(code, dirPath);
	}

	return sourceMap ? sourceMap.toObject() : undefined;
}

function hasValidKeys(conf) {
	return Object.keys(conf).every(key => key === 'extensions' || key === 'testOptions');
}

function isValidExtensions(extensions) {
	return Array.isArray(extensions) && extensions.every(ext => typeof ext === 'string' && ext !== '');
}

function validate(conf) {
	if (conf === false) {
		return null;
	}

	const defaultOptions = {babelrc: true};

	if (conf === undefined) {
		return {testOptions: defaultOptions};
	}

	if (
		!isPlainObject(conf) ||
		!hasValidKeys(conf) ||
		(conf.testOptions !== undefined && !isPlainObject(conf.testOptions)) ||
		(conf.extensions !== undefined && !isValidExtensions(conf.extensions))
	) {
		throw new Error(`Unexpected Babel configuration for AVA. See ${chalk.underline('https://github.com/avajs/ava/blob/master/docs/recipes/babel.md')} for allowed values.`);
	}

	return {
		extensions: conf.extensions,
		testOptions: Object.assign({}, defaultOptions, conf.testOptions)
	};
}

// Compare actual values rather than file paths, which should be
// more reliable.
function makeValueChecker(ref) {
	const expected = require(ref);
	return ({value}) => value === expected;
}

// Resolved paths are used to create the config item, rather than the plugin
// function itself, so Babel can print better error messages.
// See <https://github.com/babel/babel/issues/7921>.
function createConfigItem(ref, type, options = {}) {
	return babel.createConfigItem([require.resolve(ref), options], {type});
}

// Assume the stage-4 preset is wanted if there are `userOptions`, but there is
// no declaration of a stage-` preset that comes with `false` for its options.
//
// Ideally we'd detect the stage-4 preset anywhere in the configuration
// hierarchy, but Babel's loadPartialConfig() does not return disabled presets.
// See <https://github.com/babel/babel/issues/7920>.
function wantsStage4(userOptions, projectDir) {
	if (!userOptions) {
		return false;
	}

	if (!userOptions.testOptions.presets) {
		return true;
	}

	const stage4 = require('../stage-4');
	return userOptions.testOptions.presets.every(arr => {
		if (!Array.isArray(arr)) {
			return true; // There aren't any preset options.
		}

		const [ref, options] = arr;
		// Require the preset given the aliasing `ava/stage-4` does towards
		// `@ava/babel-preset-stage-4`.
		const resolved = require(babel.resolvePreset(ref, projectDir));
		return resolved !== stage4 || options !== false;
	});
}

function build(projectDir, cacheDir, userOptions, compileEnhancements) {
	if (!userOptions && !compileEnhancements) {
		return null;
	}

	// Note that Babel ignores empty string values, even for NODE_ENV. Here
	// default to 'test' unless NODE_ENV is defined, in which case fall back to
	// Babel's default of 'development' if it's empty.
	const envName = process.env.BABEL_ENV || ('NODE_ENV' in process.env ? process.env.NODE_ENV : 'test') || 'development';

	// Prepare inputs for caching seeds. Compute a seed based on the Node.js
	// version and the project directory. Dependency hashes may vary based on the
	// Node.js version, e.g. with the @ava/stage-4 Babel preset. Certain plugins
	// and presets are provided as absolute paths, which wouldn't necessarily
	// be valid if the project directory changes. Also include `envName`, so
	// options can be cached even if users change BABEL_ENV or NODE_ENV between
	// runs.
	const seedInputs = [
		process.versions.node,
		packageHash.sync(require.resolve('../package.json')),
		projectDir,
		envName,
		JSON.stringify(userOptions)
	];

	// TODO: Take resolved plugin and preset files and compute package hashes for
	// inclusion in the cache key.
	const cacheKey = md5Hex(seedInputs);

	const ensureStage4 = wantsStage4(userOptions, projectDir);
	const containsAsyncGenerators = makeValueChecker('@babel/plugin-syntax-async-generators');
	const containsObjectRestSpread = makeValueChecker('@babel/plugin-syntax-object-rest-spread');
	const containsOptionalCatchBinding = makeValueChecker('@babel/plugin-syntax-optional-catch-binding');
	const containsStage4 = makeValueChecker('../stage-4');
	const containsTransformTestFiles = makeValueChecker('@ava/babel-preset-transform-test-files');

	const loadOptions = (filename, inputSourceMap) => {
		const partialTestConfig = babel.loadPartialConfig(Object.assign({
			babelrc: false,
			babelrcRoots: [projectDir],
			configFile: false,
			sourceMaps: true
		}, userOptions && userOptions.testOptions, {
			cwd: projectDir,
			envName,
			inputSourceMap,
			filename
		}));

		// TODO: Check for `partialTestConfig.config` and include a hash of the file
		// content in the cache key. Do the same for `partialTestConfig.babelrc`,
		// though if it's a `package.json` file only include `package.json#babel` in
		// the cache key.

		if (!partialTestConfig) {
			return null;
		}

		const {options: testOptions} = partialTestConfig;
		if (!testOptions.plugins.some(containsAsyncGenerators)) { // TODO: Remove once Babel can parse this syntax unaided.
			testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-async-generators', 'plugin'));
		}
		if (!testOptions.plugins.some(containsObjectRestSpread)) { // TODO: Remove once Babel can parse this syntax unaided.
			testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-object-rest-spread', 'plugin'));
		}
		if (!testOptions.plugins.some(containsOptionalCatchBinding)) { // TODO: Remove once Babel can parse this syntax unaided.
			testOptions.plugins.unshift(createConfigItem('@babel/plugin-syntax-optional-catch-binding', 'plugin'));
		}
		if (ensureStage4 && !testOptions.presets.some(containsStage4)) {
			// Apply last.
			testOptions.presets.unshift(createConfigItem('../stage-4', 'preset'));
		}
		if (compileEnhancements && !testOptions.presets.some(containsTransformTestFiles)) {
			// Apply first.
			testOptions.presets.push(createConfigItem('@ava/babel-preset-transform-test-files', 'preset', {powerAssert: true}));
		}

		return babel.loadOptions(testOptions);
	};

	return filename => {
		const contents = stripBomBuf(fs.readFileSync(filename));
		const ext = path.extname(filename);
		const hash = md5Hex([cacheKey, contents]);
		const cachePath = path.join(cacheDir, `${hash}${ext}`);

		if (fs.existsSync(cachePath)) {
			return cachePath;
		}

		const inputCode = contents.toString('utf8');
		const inputSourceMap = getSourceMap(filename, inputCode);
		const options = loadOptions(filename, inputSourceMap);
		if (!options) {
			return null;
		}

		const {code, map} = babel.transformSync(inputCode, options);

		if (map) {
			// Save source map
			const mapPath = `${cachePath}.map`;
			writeFileAtomic.sync(mapPath, JSON.stringify(map));

			// Append source map comment to transformed code so that other libraries
			// (like nyc) can find the source map.
			const comment = convertSourceMap.generateMapFileComment(mapPath);
			writeFileAtomic.sync(cachePath, `${code}\n${comment}`);
		} else {
			writeFileAtomic.sync(cachePath, code);
		}

		return cachePath;
	};
}

module.exports = {
	validate,
	build
};
