/*
 Copyright 2012-2015, Yahoo Inc.
 Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
 */
const path = require('path');
const vm = require('vm');
const appendTransform = require('append-transform');
const originalCreateScript = vm.createScript;
const originalRunInThisContext = vm.runInThisContext;
const originalRunInContext = vm.runInContext;

function transformFn(matcher, transformer, verbose) {
    return function(code, options) {
        options = options || {};

        // prior to 2.x, hookRequire returned filename
        // rather than object.
        if (typeof options === 'string') {
            options = { filename: options };
        }

        const shouldHook =
            typeof options.filename === 'string' &&
            matcher(path.resolve(options.filename));
        let transformed;
        let changed = false;

        if (shouldHook) {
            if (verbose) {
                console.error(
                    'Module load hook: transform [' + options.filename + ']'
                );
            }
            try {
                transformed = transformer(code, options);
                changed = true;
            } catch (ex) {
                console.error(
                    'Transformation error for',
                    options.filename,
                    '; return original code'
                );
                console.error(ex.message || String(ex));
                if (verbose) {
                    console.error(ex.stack);
                }
                transformed = code;
            }
        } else {
            transformed = code;
        }
        return { code: transformed, changed };
    };
}
/**
 * unloads the required caches, removing all files that would have matched
 * the supplied matcher.
 * @param {Function} matcher - the match function that accepts a file name and
 *  returns if that file should be unloaded from the cache.
 */
function unloadRequireCache(matcher) {
    /* istanbul ignore else: impossible to test */
    if (matcher && typeof require !== 'undefined' && require && require.cache) {
        Object.keys(require.cache).forEach(filename => {
            if (matcher(filename)) {
                delete require.cache[filename];
            }
        });
    }
}
/**
 * hooks `require` to return transformed code to the node module loader.
 * Exceptions in the transform result in the original code being used instead.
 * @method hookRequire
 * @static
 * @param matcher {Function(filePath)} a function that is called with the absolute path to the file being
 *  `require`-d. Should return a truthy value when transformations need to be applied to the code, a falsy value otherwise
 * @param transformer {Function(code, filePath)} a function called with the original code and the associated path of the file
 *  from where the code was loaded. Should return the transformed code.
 * @param options {Object} options Optional.
 * @param {Boolean} [options.verbose] write a line to standard error every time the transformer is called
 * @param {Function} [options.postLoadHook] a function that is called with the name of the file being
 *  required. This is called after the require is processed irrespective of whether it was transformed.
 * @returns {Function} a reset function that can be called to remove the hook
 */
function hookRequire(matcher, transformer, options) {
    options = options || {};
    let disable = false;
    const fn = transformFn(matcher, transformer, options.verbose);
    const postLoadHook =
        options.postLoadHook && typeof options.postLoadHook === 'function'
            ? options.postLoadHook
            : null;

    const extensions = options.extensions || ['.js'];

    extensions.forEach(ext => {
        appendTransform((code, filename) => {
            if (disable) {
                return code;
            }
            const ret = fn(code, filename);
            if (postLoadHook) {
                postLoadHook(filename);
            }
            return ret.code;
        }, ext);
    });

    return function() {
        disable = true;
    };
}
/**
 * hooks `vm.createScript` to return transformed code out of which a `Script` object will be created.
 * Exceptions in the transform result in the original code being used instead.
 * @method hookCreateScript
 * @static
 * @param matcher {Function(filePath)} a function that is called with the filename passed to `vm.createScript`
 *  Should return a truthy value when transformations need to be applied to the code, a falsy value otherwise
 * @param transformer {Function(code, filePath)} a function called with the original code and the filename passed to
 *  `vm.createScript`. Should return the transformed code.
 * @param options {Object} options Optional.
 * @param {Boolean} [options.verbose] write a line to standard error every time the transformer is called
 */
function hookCreateScript(matcher, transformer, opts) {
    opts = opts || {};
    const fn = transformFn(matcher, transformer, opts.verbose);
    vm.createScript = function(code, file) {
        const ret = fn(code, file);
        return originalCreateScript(ret.code, file);
    };
}
/**
 * unhooks vm.createScript, restoring it to its original state.
 * @method unhookCreateScript
 * @static
 */
function unhookCreateScript() {
    vm.createScript = originalCreateScript;
}
/**
 * hooks `vm.runInThisContext` to return transformed code.
 * @method hookRunInThisContext
 * @static
 * @param matcher {Function(filePath)} a function that is called with the filename passed to `vm.runInThisContext`
 *  Should return a truthy value when transformations need to be applied to the code, a falsy value otherwise
 * @param transformer {Function(code, options)} a function called with the original code and the filename passed to
 *  `vm.runInThisContext`. Should return the transformed code.
 * @param opts {Object} [opts={}] options
 * @param {Boolean} [opts.verbose] write a line to standard error every time the transformer is called
 */
function hookRunInThisContext(matcher, transformer, opts) {
    opts = opts || {};
    const fn = transformFn(matcher, transformer, opts.verbose);
    vm.runInThisContext = function(code, options) {
        const ret = fn(code, options);
        return originalRunInThisContext(ret.code, options);
    };
}
/**
 * unhooks vm.runInThisContext, restoring it to its original state.
 * @method unhookRunInThisContext
 * @static
 */
function unhookRunInThisContext() {
    vm.runInThisContext = originalRunInThisContext;
}
/**
 * hooks `vm.runInContext` to return transformed code.
 * @method hookRunInContext
 * @static
 * @param matcher {Function(filePath)} a function that is called with the filename passed to `vm.createScript`
 *  Should return a truthy value when transformations need to be applied to the code, a falsy value otherwise
 * @param transformer {Function(code, filePath)} a function called with the original code and the filename passed to
 *  `vm.createScript`. Should return the transformed code.
 * @param opts {Object} [opts={}] options
 * @param {Boolean} [options.verbose] write a line to standard error every time the transformer is called
 */
function hookRunInContext(matcher, transformer, opts) {
    opts = opts || {};
    const fn = transformFn(matcher, transformer, opts.verbose);
    vm.runInContext = function(code, context, file) {
        const ret = fn(code, file);
        const coverageVariable = opts.coverageVariable || '__coverage__';
        // Refer coverage variable in context to global coverage variable.
        // So that coverage data will be written in global coverage variable for unit tests run in vm.runInContext.
        // If all unit tests are run in vm.runInContext, no global coverage variable will be generated.
        // Thus initialize a global coverage variable here.
        if (!global[coverageVariable]) {
            global[coverageVariable] = {};
        }
        context[coverageVariable] = global[coverageVariable];
        return originalRunInContext(ret.code, context, file);
    };
}
/**
 * unhooks vm.runInContext, restoring it to its original state.
 * @method unhookRunInContext
 * @static
 */
function unhookRunInContext() {
    vm.runInContext = originalRunInContext;
}
/**
 * istanbul-lib-hook provides mechanisms to transform code in the scope of `require`,
 * `vm.createScript`, `vm.runInThisContext` etc.
 *
 * This mechanism is general and relies on a user-supplied `matcher` function that
 * determines when transformations should be performed and a user-supplied `transformer`
 * function that performs the actual transform. Instrumenting code for coverage is
 * one specific example of useful hooking.
 *
 * Note that both the `matcher` and `transformer` must execute synchronously.
 *
 * @module Exports
 * @example
 * var hook = require('istanbul-lib-hook'),
 *     myMatcher = function (file) { return file.match(/foo/); },
 *     myTransformer = function (code, file) {
 *         return 'console.log("' + file + '");' + code;
 *     };
 *
 * hook.hookRequire(myMatcher, myTransformer);
 * var foo = require('foo'); //will now print foo's module path to console
 */
module.exports = {
    hookRequire,
    hookCreateScript,
    unhookCreateScript,
    hookRunInThisContext,
    unhookRunInThisContext,
    hookRunInContext,
    unhookRunInContext,
    unloadRequireCache
};
