/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

'use strict';

const JsFileWrapping = require('../ModuleGraph/worker/JsFileWrapping');

const assetTransformer = require('../assetTransformer');
const babylon = require('@babel/parser');
const collectDependencies = require('../ModuleGraph/worker/collectDependencies');
const constantFoldingPlugin = require('./worker/constant-folding-plugin');
const generateImportNames = require('../ModuleGraph/worker/generateImportNames');
const generate = require('@babel/generator').default;
const getKeyFromFiles = require('../lib/getKeyFromFiles');
const getMinifier = require('../lib/getMinifier');
const importExportPlugin = require('./worker/import-export-plugin');
const inlinePlugin = require('./worker/inline-plugin');
const inlineRequiresPlugin = require('babel-preset-fbjs/plugins/inline-requires');
const normalizePseudoglobals = require('./worker/normalizePseudoglobals');
const {transformFromAstSync} = require('@babel/core');
const {stableHash} = require('metro-cache');
const types = require('@babel/types');

const {
  fromRawMappings,
  toBabelSegments,
  toSegmentTuple,
} = require('metro-source-map');

import type {TransformResultDependency} from 'metro/src/DeltaBundler';
import type {DynamicRequiresBehavior} from '../ModuleGraph/worker/collectDependencies';
import type {BabelSourceMap} from '@babel/core';
import type {MetroSourceMapSegmentTuple} from 'metro-source-map';

type MinifierConfig = $ReadOnly<{[string]: mixed}>;

export type MinifierOptions = {
  code: string,
  map: ?BabelSourceMap,
  filename: string,
  reserved: $ReadOnlyArray<string>,
  config: MinifierConfig,
};

export type Type = 'script' | 'module' | 'asset';

export type JsTransformerConfig = $ReadOnly<{|
  assetPlugins: $ReadOnlyArray<string>,
  assetRegistryPath: string,
  asyncRequireModulePath: string,
  babelTransformerPath: string,
  dynamicDepsInPackages: DynamicRequiresBehavior,
  enableBabelRCLookup: boolean,
  enableBabelRuntime: boolean,
  minifierConfig: MinifierConfig,
  minifierPath: string,
  optimizationSizeLimit: number,
  publicPath: string,
|}>;

import type {CustomTransformOptions} from 'metro-babel-transformer';
export type {CustomTransformOptions} from 'metro-babel-transformer';

export type JsTransformOptions = $ReadOnly<{|
  customTransformOptions?: CustomTransformOptions,
  dev: boolean,
  experimentalImportSupport?: boolean,
  hot: boolean,
  inlineRequires: boolean,
  minify: boolean,
  platform: ?string,
  type: Type,
|}>;

export type JsOutput = $ReadOnly<{|
  data: $ReadOnly<{|
    code: string,
    map: Array<MetroSourceMapSegmentTuple>,
  |}>,
  type: string,
|}>;

type Result = {|
  output: $ReadOnlyArray<JsOutput>,
  dependencies: $ReadOnlyArray<TransformResultDependency>,
|};

function getDynamicDepsBehavior(
  inPackages: DynamicRequiresBehavior,
  filename: string,
): DynamicRequiresBehavior {
  switch (inPackages) {
    case 'reject':
      return 'reject';
    case 'throwAtRuntime':
      const isPackage = /(?:^|[/\\])node_modules[/\\]/.test(filename);
      return isPackage ? inPackages : 'reject';
    default:
      (inPackages: empty);
      throw new Error(
        `invalid value for dynamic deps behavior: \`${inPackages}\``,
      );
  }
}

class JsTransformer {
  _config: JsTransformerConfig;
  _projectRoot: string;

  constructor(projectRoot: string, config: JsTransformerConfig) {
    this._projectRoot = projectRoot;
    this._config = config;
  }

  async transform(
    filename: string,
    data: Buffer,
    options: JsTransformOptions,
  ): Promise<Result> {
    const sourceCode = data.toString('utf8');
    let type = 'js/module';

    if (options.type === 'asset') {
      type = 'js/module/asset';
    }
    if (options.type === 'script') {
      type = 'js/script';
    }

    if (filename.endsWith('.json')) {
      let code = JsFileWrapping.wrapJson(sourceCode);
      let map = [];

      if (options.minify) {
        ({map, code} = await this._minifyCode(filename, code, sourceCode, map));
      }

      return {dependencies: [], output: [{data: {code, map}, type}]};
    }

    // $FlowFixMe TODO t26372934 Plugin system
    const transformer: Transformer<*> = require(this._config
      .babelTransformerPath);

    const transformerArgs = {
      filename,
      options: {
        ...options,
        enableBabelRCLookup: this._config.enableBabelRCLookup,
        enableBabelRuntime: this._config.enableBabelRuntime,
        // Inline requires are now performed at a secondary step. We cannot
        // unfortunately remove it from the internal transformer, since this one
        // is used by other tooling, and this would affect it.
        inlineRequires: false,
        projectRoot: this._projectRoot,
        publicPath: this._config.publicPath,
      },
      plugins: [],
      src: sourceCode,
    };

    const transformResult =
      type === 'js/module/asset'
        ? await assetTransformer.transform(
            transformerArgs,
            this._config.assetRegistryPath,
            this._config.assetPlugins,
          )
        : await transformer.transform(transformerArgs);

    // Transformers can ouptut null ASTs (if they ignore the file). In that case
    // we need to parse the module source code to get their AST.
    let ast =
      transformResult.ast ||
      babylon.parse(sourceCode, {sourceType: 'unambiguous'});

    const {importDefault, importAll} = generateImportNames(ast);

    // Add "use strict" if the file was parsed as a module, and the directive did
    // not exist yet.
    const {directives} = ast.program;

    if (
      ast.program.sourceType === 'module' &&
      directives.findIndex(d => d.value.value === 'use strict') === -1
    ) {
      directives.push(types.directive(types.directiveLiteral('use strict')));
    }

    // Perform the import-export transform (in case it's still needed), then
    // fold requires and perform constant folding (if in dev).
    const plugins = [];
    const opts = {
      ...options,
      inlineableCalls: [importDefault, importAll],
      importDefault,
      importAll,
    };

    if (options.experimentalImportSupport) {
      plugins.push([importExportPlugin, opts]);
    }

    if (options.inlineRequires) {
      plugins.push([inlineRequiresPlugin, opts]);
    }

    if (!options.dev) {
      plugins.push([constantFoldingPlugin, opts]);
      plugins.push([inlinePlugin, opts]);
    }

    ({ast} = transformFromAstSync(ast, '', {
      ast: true,
      babelrc: false,
      code: false,
      configFile: false,
      comments: false,
      compact: false,
      filename,
      plugins,
      sourceMaps: false,
    }));

    let dependencyMapName = '';
    let dependencies;
    let wrappedAst;

    // If the module to transform is a script (meaning that is not part of the
    // dependency graph and it code will just be prepended to the bundle modules),
    // we need to wrap it differently than a commonJS module (also, scripts do
    // not have dependencies).
    if (type === 'js/script') {
      dependencies = [];
      wrappedAst = JsFileWrapping.wrapPolyfill(ast);
    } else {
      try {
        const opts = {
          asyncRequireModulePath: this._config.asyncRequireModulePath,
          dynamicRequires: getDynamicDepsBehavior(
            this._config.dynamicDepsInPackages,
            filename,
          ),
          inlineableCalls: [importDefault, importAll],
          keepRequireNames: options.dev,
        };
        ({dependencies, dependencyMapName} = collectDependencies(ast, opts));
      } catch (error) {
        if (error instanceof collectDependencies.InvalidRequireCallError) {
          throw new InvalidRequireCallError(error, filename);
        }
        throw error;
      }

      ({ast: wrappedAst} = JsFileWrapping.wrapModule(
        ast,
        importDefault,
        importAll,
        dependencyMapName,
      ));
    }

    const reserved =
      options.minify && data.length <= this._config.optimizationSizeLimit
        ? normalizePseudoglobals(wrappedAst)
        : [];

    const result = generate(
      wrappedAst,
      {
        comments: false,
        compact: false,
        filename,
        retainLines: false,
        sourceFileName: filename,
        sourceMaps: true,
      },
      sourceCode,
    );

    let map = result.rawMappings ? result.rawMappings.map(toSegmentTuple) : [];
    let code = result.code;

    if (options.minify) {
      ({map, code} = await this._minifyCode(
        filename,
        result.code,
        sourceCode,
        map,
        reserved,
      ));
    }

    return {dependencies, output: [{data: {code, map}, type}]};
  }

  async _minifyCode(
    filename: string,
    code: string,
    source: string,
    map: Array<MetroSourceMapSegmentTuple>,
    reserved?: $ReadOnlyArray<string> = [],
  ): Promise<{
    code: string,
    map: Array<MetroSourceMapSegmentTuple>,
  }> {
    const sourceMap = fromRawMappings([
      {code, source, map, path: filename},
    ]).toMap(undefined, {});

    const minify = getMinifier(this._config.minifierPath);

    try {
      const minified = minify({
        code,
        map: sourceMap,
        filename,
        reserved,
        config: this._config.minifierConfig,
      });

      return {
        code: minified.code,
        map: minified.map
          ? toBabelSegments(minified.map).map(toSegmentTuple)
          : [],
      };
    } catch (error) {
      if (error.constructor.name === 'JS_Parse_Error') {
        throw new Error(
          `${error.message} in file ${filename} at ${error.line}:${error.col}`,
        );
      }

      throw error;
    }
  }

  getCacheKey(): string {
    const {babelTransformerPath, minifierPath, ...config} = this._config;

    const filesKey = getKeyFromFiles([
      require.resolve(babelTransformerPath),
      require.resolve(minifierPath),
      require.resolve('../ModuleGraph/worker/JsFileWrapping'),
      require.resolve('../assetTransformer'),
      require.resolve('../ModuleGraph/worker/collectDependencies'),
      require.resolve('./worker/constant-folding-plugin'),
      require.resolve('../lib/getMinifier'),
      require.resolve('./worker/inline-plugin'),
      require.resolve('./worker/import-export-plugin'),
      require.resolve('./worker/normalizePseudoglobals'),
      require.resolve('../ModuleGraph/worker/optimizeDependencies'),
      require.resolve('../ModuleGraph/worker/generateImportNames'),
    ]);

    const babelTransformer = require(babelTransformerPath);
    const babelTransformerKey = babelTransformer.getCacheKey
      ? babelTransformer.getCacheKey()
      : '';

    return [
      filesKey,
      stableHash(config).toString('hex'),
      babelTransformerKey,
    ].join('$');
  }
}

class InvalidRequireCallError extends Error {
  innerError: collectDependencies.InvalidRequireCallError;
  filename: string;

  constructor(
    innerError: collectDependencies.InvalidRequireCallError,
    filename: string,
  ) {
    super(`${filename}:${innerError.message}`);
    this.innerError = innerError;
    this.filename = filename;
  }
}

module.exports = JsTransformer;