/**
 * Copyright (c) Meta Platforms, Inc. and 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
 * @oncall react_native
 */

// This file uses Flow comment syntax so that it may be used from source as a
// transformer without itself requiring transformation, such as in
// facebook/react-native's own tests.

'use strict';

/*::
import type {BabelCoreOptions, Plugins, TransformResult} from '@babel/core';
import type {
  BabelTransformer,
  MetroBabelFileMetadata,
} from 'metro-babel-transformer';
*/

const {parseSync, transformFromAstSync} = require('@babel/core');
const makeHMRConfig = require('@react-native/babel-preset/src/configs/hmr');
const crypto = require('crypto');
const fs = require('fs');
const nullthrows = require('nullthrows');
const path = require('path');

const cacheKeyParts = [fs.readFileSync(__filename)];

// TS detection conditions copied from @react-native/babel-preset
function isTypeScriptSource(fileName /*: string */) {
  return !!fileName && fileName.endsWith('.ts');
}

function isTSXSource(fileName /*: string */) {
  return !!fileName && fileName.endsWith('.tsx');
}

/**
 * Return a memoized function that checks for the existence of a
 * project level .babelrc file, and if it doesn't exist, reads the
 * default RN babelrc file and uses that.
 */
const getBabelRC = (function () {
  let babelRC /*: ?BabelCoreOptions */ = null;

  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  return function _getBabelRC({
    projectRoot,
    extendsBabelConfigPath,
    ...options
  }) {
    if (babelRC != null) {
      return babelRC;
    }

    babelRC = {
      plugins: [],
      extends: extendsBabelConfigPath,
    };

    if (extendsBabelConfigPath) {
      return babelRC;
    }

    // Let's look for a babel config file in the project root.
    let projectBabelRCPath;

    // .babelrc
    if (projectRoot) {
      projectBabelRCPath = path.resolve(projectRoot, '.babelrc');
    }

    if (projectBabelRCPath) {
      // .babelrc.js
      if (!fs.existsSync(projectBabelRCPath)) {
        projectBabelRCPath = path.resolve(projectRoot, '.babelrc.js');
      }

      // babel.config.js
      if (!fs.existsSync(projectBabelRCPath)) {
        projectBabelRCPath = path.resolve(projectRoot, 'babel.config.js');
      }

      // If we found a babel config file, extend our config off of it
      // otherwise the default config will be used
      if (fs.existsSync(projectBabelRCPath)) {
        // $FlowFixMe[incompatible-use] `extends` is missing in null or undefined.
        babelRC.extends = projectBabelRCPath;
      }
    }

    // If a babel config file doesn't exist in the project then
    // the default preset for react-native will be used instead.
    // $FlowFixMe[incompatible-use] `extends` is missing in null or undefined.
    // $FlowFixMe[incompatible-type] `extends` is missing in null or undefined.
    if (!babelRC.extends) {
      const {experimentalImportSupport, ...presetOptions} = options;

      // $FlowFixMe[incompatible-use] `presets` is missing in null or undefined.
      babelRC.presets = [
        [
          require('@react-native/babel-preset'),
          {
            projectRoot,
            ...presetOptions,
            disableImportExportTransform: experimentalImportSupport,
            enableBabelRuntime: options.enableBabelRuntime,
          },
        ],
      ];
    }

    return babelRC;
  };
})();

/**
 * Given a filename and options, build a Babel
 * config object with the appropriate plugins.
 */
function buildBabelConfig(
  filename /*: string */,
  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  options,
  plugins /*:: ?: Plugins*/ = [],
) /*: BabelCoreOptions*/ {
  const babelRC = getBabelRC(options);

  const extraConfig /*: BabelCoreOptions */ = {
    babelrc:
      typeof options.enableBabelRCLookup === 'boolean'
        ? options.enableBabelRCLookup
        : true,
    code: false,
    cwd: options.projectRoot,
    filename,
    highlightCode: true,
  };

  let config /*: BabelCoreOptions */ = {
    ...babelRC,
    ...extraConfig,
  };

  const withExtraPlugins = (config.plugins = [
    ...(config.plugins ?? []),
    ...plugins,
  ]);

  if (options.dev && options.hot) {
    // Note: this intentionally doesn't include the path separator because
    // I'm not sure which one it should use on Windows, and false positives
    // are unlikely anyway. If you later decide to include the separator,
    // don't forget that the string usually *starts* with "node_modules" so
    // the first one often won't be there.
    const mayContainEditableReactComponents =
      filename.indexOf('node_modules') === -1;

    if (mayContainEditableReactComponents) {
      const hmrConfig = makeHMRConfig();
      hmrConfig.plugins = withExtraPlugins.concat(hmrConfig.plugins);
      config = {...config, ...hmrConfig};
    }
  }

  return {
    ...babelRC,
    ...config,
  };
}

const transform /*: BabelTransformer['transform'] */ = ({
  filename,
  options,
  src,
  plugins,
}) => {
  const OLD_BABEL_ENV = process.env.BABEL_ENV;
  process.env.BABEL_ENV = options.dev
    ? 'development'
    : process.env.BABEL_ENV || 'production';

  try {
    const babelConfig = {
      // ES modules require sourceType='module' but OSS may not always want that
      sourceType: 'unambiguous',
      ...buildBabelConfig(filename, options, plugins),
      caller: {name: 'metro', bundler: 'metro', platform: options.platform},
      ast: true,

      // NOTE(EvanBacon): We split the parse/transform steps up to accommodate
      // Hermes parsing, but this defaults to cloning the AST which increases
      // the transformation time by a fair amount.
      // You get this behavior by default when using Babel's `transform` method directly.
      cloneInputAst: false,
    };
    const sourceAst /*: BabelNodeFile */ =
      isTypeScriptSource(filename) ||
      isTSXSource(filename) ||
      !options.hermesParser
        ? parseSync(src, babelConfig)
        : // $FlowFixMe[incompatible-exact]
          require('hermes-parser').parse(src, {
            babel: true,
            sourceType: babelConfig.sourceType,
          });

    const result /*: TransformResult<MetroBabelFileMetadata> */ =
      transformFromAstSync(sourceAst, src, babelConfig);

    // The result from `transformFromAstSync` can be null (if the file is ignored)
    if (!result) {
      /* $FlowFixMe BabelTransformer specifies that the `ast` can never be null but
       * the function returns here. Discovered when typing `BabelNode`. */
      return {ast: null};
    }

    return {ast: nullthrows(result.ast), metadata: result.metadata};
  } finally {
    if (OLD_BABEL_ENV) {
      process.env.BABEL_ENV = OLD_BABEL_ENV;
    }
  }
};

function getCacheKey() {
  var key = crypto.createHash('md5');
  cacheKeyParts.forEach(part => key.update(part));
  return key.digest('hex');
}

const babelTransformer /*: BabelTransformer */ = {
  transform,
  getCacheKey,
};

module.exports = babelTransformer;
