/**
 * Both used by zrender and echarts.
 */

const assert = require('assert');
const nodePath = require('path');
const basename = nodePath.basename;
const extname = nodePath.extname;

const babelTypes = require('@babel/types');
const babelTemplate = require('@babel/template');

const helperModuleTransforms = require('@babel/helper-module-transforms');
const isModule = helperModuleTransforms.isModule;
const isSideEffectImport = helperModuleTransforms.isSideEffectImport;
const ensureStatementsHoisted = helperModuleTransforms.ensureStatementsHoisted;


module.exports = function ({types, template}, options) {
    return {
        visitor: {
            Program: {
                exit(path) {
                    // For now this requires unambiguous rather that just sourceType
                    // because Babel currently parses all files as sourceType:module.
                    if (!isModule(path, true /* requireUnambiguous */)) {
                       return;
                    }

                    // Rename the bindings auto-injected into the scope so there is no
                    // risk of conflict between the bindings.
                    path.scope.rename('exports');
                    path.scope.rename('module');
                    path.scope.rename('require');
                    path.scope.rename('__filename');
                    path.scope.rename('__dirname');

                    const meta = rewriteModuleStatementsAndPrepare(path);

                    let headers = [];
                    let tails = [];
                    const checkExport = createExportChecker();

                    for (const [source, metadata] of meta.source) {
                        headers.push(...buildRequireStatements(types, source, metadata));
                        headers.push(...buildNamespaceInitStatements(meta, metadata, checkExport));
                    }

                    tails.push(...buildLocalExportStatements(meta, checkExport));

                    ensureStatementsHoisted(headers);
                    // FIXME ensure tail?

                    path.unshiftContainer('body', headers);
                    path.pushContainer('body', tails);

                    checkAssignOrUpdateExport(path, meta);
                }
            }
        }
    };
};


/**
 * Remove all imports and exports from the file, and return all metadata
 * needed to reconstruct the module's behavior.
 * @return {ModuleMetadata}
 */
function normalizeModuleAndLoadMetadata(programPath) {

    nameAnonymousExports(programPath);

    const {local, source} = getModuleMetadata(programPath);

    removeModuleDeclarations(programPath);

    // Reuse the imported namespace name if there is one.
    for (const [, metadata] of source) {
        if (metadata.importsNamespace.size > 0) {
            // This is kind of gross. If we stop using `loose: true` we should
            // just make this destructuring assignment.
            metadata.name = metadata.importsNamespace.values().next().value;
        }
    }

    return {
        exportName: 'exports',
        exportNameListName: null,
        local,
        source
    };
}

/**
 * Get metadata about the imports and exports present in this module.
 */
function getModuleMetadata(programPath) {
    const localData = getLocalExportMetadata(programPath);

    const sourceData = new Map();
    const getData = sourceNode => {
        const source = sourceNode.value;

        let data = sourceData.get(source);
        if (!data) {
            data = {
                name: programPath.scope.generateUidIdentifier(
                    basename(source, extname(source))
                ).name,

                interop: 'none',

                loc: null,

                // Data about the requested sources and names.
                imports: new Map(),
                // importsNamespace: import * as util from './a/b/util';
                importsNamespace: new Set(),

                // Metadata about data that is passed directly from source to export.
                reexports: new Map(),
                reexportNamespace: new Set(),
                reexportAll: null,
            };
            sourceData.set(source, data);
        }
        return data;
    };

    programPath.get('body').forEach(child => {
        if (child.isImportDeclaration()) {
            const data = getData(child.node.source);
            if (!data.loc) {
                data.loc = child.node.loc;
            }

            child.get('specifiers').forEach(spec => {
                if (spec.isImportDefaultSpecifier()) {
                    const localName = spec.get('local').node.name;

                    data.imports.set(localName, 'default');

                    const reexport = localData.get(localName);
                    if (reexport) {
                        localData.delete(localName);

                        reexport.names.forEach(name => {
                            data.reexports.set(name, 'default');
                        });
                    }
                }
                else if (spec.isImportNamespaceSpecifier()) {
                    const localName = spec.get('local').node.name;

                    assert(
                        data.importsNamespace.size === 0,
                        `Duplicate import namespace: ${localName}`
                    );
                    data.importsNamespace.add(localName);

                    const reexport = localData.get(localName);
                    if (reexport) {
                        localData.delete(localName);

                        reexport.names.forEach(name => {
                            data.reexportNamespace.add(name);
                        });
                    }
                }
                else if (spec.isImportSpecifier()) {
                    const importName = spec.get('imported').node.name;
                    const localName = spec.get('local').node.name;

                    data.imports.set(localName, importName);

                    const reexport = localData.get(localName);
                    if (reexport) {
                        localData.delete(localName);

                        reexport.names.forEach(name => {
                            data.reexports.set(name, importName);
                        });
                    }
                }
            });
        }
        else if (child.isExportAllDeclaration()) {
            const data = getData(child.node.source);
            if (!data.loc) {
                data.loc = child.node.loc;
            }

            data.reexportAll = {
                loc: child.node.loc,
            };
        }
        else if (child.isExportNamedDeclaration() && child.node.source) {
            const data = getData(child.node.source);
            if (!data.loc) {
                data.loc = child.node.loc;
            }

            child.get('specifiers').forEach(spec => {
                if (!spec.isExportSpecifier()) {
                    throw spec.buildCodeFrameError('Unexpected export specifier type');
                }
                const importName = spec.get('local').node.name;
                const exportName = spec.get('exported').node.name;

                data.reexports.set(exportName, importName);

                if (exportName === '__esModule') {
                    throw exportName.buildCodeFrameError('Illegal export "__esModule".');
                }
            });
        }
    });

    for (const metadata of sourceData.values()) {
        if (metadata.importsNamespace.size > 0) {
            metadata.interop = 'namespace';
            continue;
        }
        let needsDefault = false;
        let needsNamed = false;
        for (const importName of metadata.imports.values()) {
            if (importName === 'default') {
                needsDefault = true;
            }
            else {
                needsNamed = true;
            }
        }
        for (const importName of metadata.reexports.values()) {
            if (importName === 'default') {
                needsDefault = true;
            }
            else {
                needsNamed = true;
            }
        }

        if (needsDefault && needsNamed) {
            // TODO(logan): Using the namespace interop here is unfortunate. Revisit.
            metadata.interop = 'namespace';
        }
        else if (needsDefault) {
            metadata.interop = 'default';
        }
    }

    return {
        local: localData,
        source: sourceData,
    };
}

/**
 * Get metadata about local variables that are exported.
 * @return {Map<string, LocalExportMetadata>}
 */
function getLocalExportMetadata(programPath){
    const bindingKindLookup = new Map();

    programPath.get('body').forEach(child => {
        let kind;
        if (child.isImportDeclaration()) {
            kind = 'import';
        }
        else {
            if (child.isExportDefaultDeclaration()) {
                child = child.get('declaration');
            }
            if (child.isExportNamedDeclaration() && child.node.declaration) {
                child = child.get('declaration');
            }

            if (child.isFunctionDeclaration()) {
                kind = 'hoisted';
            }
            else if (child.isClassDeclaration()) {
                kind = 'block';
            }
            else if (child.isVariableDeclaration({ kind: 'var' })) {
                kind = 'var';
            }
            else if (child.isVariableDeclaration()) {
                kind = 'block';
            }
            else {
                return;
            }
        }

        Object.keys(child.getOuterBindingIdentifiers()).forEach(name => {
            bindingKindLookup.set(name, kind);
        });
    });

    const localMetadata = new Map();
    const getLocalMetadata = idPath => {
        const localName = idPath.node.name;
        let metadata = localMetadata.get(localName);
        if (!metadata) {
            const kind = bindingKindLookup.get(localName);

            if (kind === undefined) {
                throw idPath.buildCodeFrameError(`Exporting local "${localName}", which is not declared.`);
            }

            metadata = {
                names: [],
                kind,
            };
            localMetadata.set(localName, metadata);
        }
        return metadata;
    };

    programPath.get('body').forEach(child => {
        if (child.isExportNamedDeclaration() && !child.node.source) {
            if (child.node.declaration) {
                const declaration = child.get('declaration');
                const ids = declaration.getOuterBindingIdentifierPaths();
                Object.keys(ids).forEach(name => {
                if (name === '__esModule') {
                    throw declaration.buildCodeFrameError('Illegal export "__esModule".');
                }

                getLocalMetadata(ids[name]).names.push(name);
                });
            }
            else {
                child.get('specifiers').forEach(spec => {
                    const local = spec.get('local');
                    const exported = spec.get('exported');

                    if (exported.node.name === '__esModule') {
                        throw exported.buildCodeFrameError('Illegal export "__esModule".');
                    }

                    getLocalMetadata(local).names.push(exported.node.name);
                });
            }
        }
        else if (child.isExportDefaultDeclaration()) {
            const declaration = child.get('declaration');
            if (
                declaration.isFunctionDeclaration() ||
                declaration.isClassDeclaration()
            ) {
                getLocalMetadata(declaration.get('id')).names.push('default');
            }
            else {
                // These should have been removed by the nameAnonymousExports() call.
                throw declaration.buildCodeFrameError('Unexpected default expression export.');
            }
        }
    });

    return localMetadata;
}

/**
 * Ensure that all exported values have local binding names.
 */
function nameAnonymousExports(programPath) {
    // Name anonymous exported locals.
    programPath.get('body').forEach(child => {
        if (!child.isExportDefaultDeclaration()) {
            return;
        }

        // export default foo;
        const declaration = child.get('declaration');
        if (declaration.isFunctionDeclaration()) {
            if (!declaration.node.id) {
                declaration.node.id = declaration.scope.generateUidIdentifier('default');
            }
        }
        else if (declaration.isClassDeclaration()) {
            if (!declaration.node.id) {
                declaration.node.id = declaration.scope.generateUidIdentifier('default');
            }
        }
        else {
            const id = declaration.scope.generateUidIdentifier('default');
            const namedDecl = babelTypes.exportNamedDeclaration(null, [
                babelTypes.exportSpecifier(babelTypes.identifier(id.name), babelTypes.identifier('default')),
            ]);
            namedDecl._blockHoist = child.node._blockHoist;

            const varDecl = babelTypes.variableDeclaration('var', [
                babelTypes.variableDeclarator(id, declaration.node),
            ]);
            varDecl._blockHoist = child.node._blockHoist;

            child.replaceWithMultiple([namedDecl, varDecl]);
        }
    });
}

function removeModuleDeclarations(programPath) {
    programPath.get('body').forEach(child => {
        if (child.isImportDeclaration()) {
            child.remove();
        }
        else if (child.isExportNamedDeclaration()) {
            if (child.node.declaration) {
                child.node.declaration._blockHoist = child.node._blockHoist;
                child.replaceWith(child.node.declaration);
            }
            else {
                child.remove();
            }
        }
        else if (child.isExportDefaultDeclaration()) {
            // export default foo;
            const declaration = child.get('declaration');
            if (
                declaration.isFunctionDeclaration() ||
                declaration.isClassDeclaration()
            ) {
                declaration._blockHoist = child.node._blockHoist;
                child.replaceWith(declaration);
            }
            else {
                // These should have been removed by the nameAnonymousExports() call.
                throw declaration.buildCodeFrameError('Unexpected default expression export.');
            }
        }
        else if (child.isExportAllDeclaration()) {
            child.remove();
        }
    });
}








/**
 * Perform all of the generic ES6 module rewriting needed to handle initial
 * module processing. This function will rewrite the majority of the given
 * program to reference the modules described by the returned metadata,
 * and returns a list of statements for use when initializing the module.
 */
function rewriteModuleStatementsAndPrepare(path) {
    path.node.sourceType = 'script';

    const meta = normalizeModuleAndLoadMetadata(path);

    return meta;
}

/**
 * Create the runtime initialization statements for a given requested source.
 * These will initialize all of the runtime import/export logic that
 * can't be handled statically by the statements created by
 * buildExportInitializationStatements().
 */
function buildNamespaceInitStatements(meta, metadata, checkExport) {
    const statements = [];
    const {localImportName, localImportDefaultName} = getLocalImportName(metadata);

    for (const exportName of metadata.reexportNamespace) {
        // Assign export to namespace object.
        checkExport(exportName);
        statements.push(buildExport({exportName, localName: localImportName}));
    }

    // Source code:
    //      import {color2 as color2Alias, color3, color4, color5} from 'xxx';
    //      export {default as b} from 'xxx';
    //      export {color2Alias};
    //      export {color3};
    //      let color5Renamed = color5
    //      export {color5Renamed};
    // Only two entries in metadata.reexports:
    //      'color2Alias' => 'color2'
    //      'color3' => 'color3',
    //      'b' => 'default'
    //
    // And consider:
    //      export {default as defaultAsBB} from './xx/yy';
    //      export {exportSingle} from './xx/yy';
    // No entries in metadata.imports, and 'default' exists in metadata.reexports.
    for (const entry of metadata.reexports.entries()) {
        const exportName = entry[0];
        checkExport(exportName);
        statements.push(
            (localImportDefaultName || entry[1] === 'default')
                ? buildExport({exportName, localName: localImportName})
                : buildExport({exportName, namespace: localImportName, propName: entry[1]})
        );
    }

    if (metadata.reexportAll) {
        const statement = buildNamespaceReexport(
            meta,
            metadata.name,
            checkExport
        );
        statement.loc = metadata.reexportAll.loc;

        // Iterate props creating getter for each prop.
        statements.push(statement);
    }

    return statements;
}

/**
 * Create a re-export initialization loop for a specific imported namespace.
 */
function buildNamespaceReexport(meta, namespace, checkExport) {
    checkExport();
    return babelTemplate.statement(`
        (function() {
          for (var key in NAMESPACE) {
            if (NAMESPACE == null || !NAMESPACE.hasOwnProperty(key) || key === 'default' || key === '__esModule') return;
            VERIFY_NAME_LIST;
            exports[key] = NAMESPACE[key];
          }
        })();
    `)({
        NAMESPACE: namespace,
        VERIFY_NAME_LIST: meta.exportNameListName
            ? babelTemplate.statement(`
                if (Object.prototype.hasOwnProperty.call(EXPORTS_LIST, key)) return;
            `)({EXPORTS_LIST: meta.exportNameListName})
            : null
    });
}

function buildRequireStatements(types, source, metadata) {
    let headers = [];

    const loadExpr = types.callExpression(
        types.identifier('require'),
        // replace `require('./src/xxx')` to `require('./lib/xxx')`
        // for echarts and zrender in old npm or webpack.
        [types.stringLiteral(source.replace('/src/', '/lib/'))]
    );

    // side effect import: import 'xxx';
    if (isSideEffectImport(metadata)) {
        let header = types.expressionStatement(loadExpr);
        header.loc = metadata.loc;
        headers.push(header);
    }
    else {
        const {localImportName, localImportDefaultName} = getLocalImportName(metadata);

        let reqHeader = types.variableDeclaration('var', [
            types.variableDeclarator(
                types.identifier(localImportName),
                loadExpr
            )
        ]);

        reqHeader.loc = metadata.loc;
        headers.push(reqHeader);

        if (!localImportDefaultName) {
            // src:
            //      import {someInZrUtil1 as someInZrUtil1Alias, zz} from 'zrender/core/util';
            // metadata.imports:
            //      Map { 'someInZrUtil1Alias' => 'someInZrUtil1', 'zz' => 'zz' }
            for (const importEntry of metadata.imports) {
                headers.push(
                    babelTemplate.statement(`var IMPORTNAME = NAMESPACE.PROPNAME;`)({
                        NAMESPACE: localImportName,
                        IMPORTNAME: importEntry[0],
                        PROPNAME: importEntry[1]
                    })
                );
            }
        }
    }

    return headers;
}

function getLocalImportName(metadata) {
    const localImportDefaultName = getDefaultName(metadata.imports);

    assert(
        !localImportDefaultName || metadata.imports.size === 1,
        'Forbiden that both import default and others.'
    );

    return {
        localImportName: localImportDefaultName || metadata.name,
        localImportDefaultName
    };
}

function getDefaultName(map) {
    for (const entry of map) {
        if (entry[1] === 'default') {
            return entry[0];
        }
    }
}

function buildLocalExportStatements(meta, checkExport) {
    let tails = [];

    // All local export, for example:
    // Map {
    // 'localVarMame' => {
    //      names: [ 'exportName1', 'exportName2' ],
    //      kind: 'var'
    // },
    for (const localEntry of meta.local) {
        for (const exportName of localEntry[1].names) {
            checkExport(exportName);
            tails.push(buildExport({exportName, localName: localEntry[0]}));
        }
    }

    return tails;
}

function createExportChecker() {
    let someHasBeenExported;
    return function checkExport(exportName) {
        assert(
            !someHasBeenExported || exportName !== 'default',
            `Forbiden that both export default and others.`
        );
        someHasBeenExported = true;
    };
}

function buildExport({exportName, namespace, propName, localName}) {
    const exportDefault = exportName === 'default';

    const head = exportDefault ? 'module.exports' : `exports.${exportName}`;

    let opt = {};
    // FIXME
    // Does `PRIORITY`, `LOCATION_PARAMS` recognised as babel-template placeholder?
    // We have to do this for workaround temporarily.
    if (/^[A-Z0-9_]+$/.test(localName)) {
        opt[localName] = localName;
    }

    return babelTemplate.statement(
        localName
            ? `${head} = ${localName};`
            : `${head} = ${namespace}.${propName};`
    )(opt);
}

/**
 * Consider this case:
 *      export var a;
 *      function inject(b) {
 *          a = b;
 *      }
 * It will be transpiled to:
 *      var a;
 *      exports.a = 1;
 *      function inject(b) {
 *          a = b;
 *      }
 * That is a wrong transpilation, because the `export.a` will not
 * be assigned as `b` when `inject` called.
 * Of course, it can be transpiled correctly as:
 *      var _locals = {};
 *      var a;
 *      Object.defineProperty(exports, 'a', {
 *          get: function () { return _locals[a]; }
 *      };
 *      exports.a = a;
 *      function inject(b) {
 *          _locals[a] = b;
 *      }
 * But it is not ES3 compatible.
 * So we just forbiden this usage here.
 */
function checkAssignOrUpdateExport(programPath, meta) {

    let visitor = {
        // Include:
        // `a++;` (no `path.get('left')`)
        // `x += 1212`;
        UpdateExpression: {
            exit: function exit(path, scope) {
                // console.log(arguments);
                let left = path.get('left');
                if (left && left.isIdentifier()) {
                    asertNotAssign(path, left.node.name);
                }
            }
        },
        // Include:
        // `x = 5;` (`x` is an identifier.)
        // `c.d = 3;` (but `c.d` is not an identifier.)
        // `y = function () {}`
        // Exclude:
        // `var x = 121;`
        // `export var x = 121;`
        AssignmentExpression: {
            exit: function exit(path) {
                let left = path.get('left');
                if (left.isIdentifier()) {
                    asertNotAssign(path, left.node.name);
                }
            }
        }
    };

    function asertNotAssign(path, localName) {
        // Ignore variables that is not in global scope.
        if (programPath.scope.getBinding(localName) !== path.scope.getBinding(localName)) {
            return;
        }
        for (const localEntry of meta.local) {
            assert(
                localName !== localEntry[0],
                `An exported variable \`${localEntry[0]}\` is forbiden to be assigned.`
            );
        }
    }

    programPath.traverse(visitor);
}