/**
 * 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
 */

import type {BabelFileMetadata} from '@babel/core';
import type {File as BabelNodeFile} from '@babel/types';

import {
  loadPartialConfigSync,
  parseSync,
  transformFromAstSync,
} from '@babel/core';
import {getCacheKey as getFileCacheKey} from 'metro-cache-key';
import nullthrows from 'nullthrows';

type BabelTransformOptions = NonNullable<
  Parameters<typeof transformFromAstSync>[2],
>;

export type CustomTransformOptions = {
  [string]: unknown,
  __proto__: null,
  ...
};

export type TransformProfile = 'default' | 'hermes-stable' | 'hermes-canary';

type BabelTransformerOptions = Readonly<{
  customTransformOptions?: CustomTransformOptions,
  dev: boolean,
  enableBabelRCLookup?: boolean,
  enableBabelRuntime: boolean | string,
  extendsBabelConfigPath?: string,
  experimentalImportSupport?: boolean,
  hermesParser?: boolean,
  minify: boolean,
  platform: ?string,
  projectRoot: string,
  publicPath: string,
  unstable_transformProfile?: TransformProfile,
  globalPrefix: string,
  inlineRequires?: void,
  ...
}>;

export type BabelTransformerArgs = Readonly<{
  filename: string,
  options: BabelTransformerOptions,
  plugins?: BabelTransformOptions['plugins'],
  src: string,
}>;

export type BabelFileFunctionMapMetadata = Readonly<{
  names: ReadonlyArray<string>,
  mappings: string,
}>;

export type BabelFileImportLocsMetadata = ReadonlySet<string>;

export type MetroBabelFileMetadata = {
  ...BabelFileMetadata,
  metro?: ?{
    functionMap?: ?BabelFileFunctionMapMetadata,
    unstable_importDeclarationLocs?: ?BabelFileImportLocsMetadata,
    ...
  },
  ...
};

export type BabelTransformerCacheKeyOptions = Readonly<{
  projectRoot?: string,
  enableBabelRCLookup?: boolean,
}>;

export type BabelTransformer = Readonly<{
  transform: BabelTransformerArgs => Readonly<{
    ast: BabelNodeFile,
    // Deprecated, will be removed in a future breaking release. Function maps
    // will be generated by an input Babel plugin instead and written into
    // `metadata` - transformers don't need to return them explicitly.
    functionMap?: BabelFileFunctionMapMetadata,
    metadata?: MetroBabelFileMetadata,
    ...
  }>,
  getCacheKey?: (options?: BabelTransformerCacheKeyOptions) => string,
}>;

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

  try {
    const babelConfig: BabelTransformOptions = {
      ast: true,
      babelrc: options.enableBabelRCLookup,
      caller: {bundler: 'metro', name: 'metro', platform: options.platform},
      // 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,
      code: false,
      cwd: options.projectRoot,
      filename,
      highlightCode: true,
      plugins,
      sourceType: 'module',
    };
    const sourceAst = options.hermesParser
      ? // eslint-disable-next-line import/no-commonjs
        require('hermes-parser').parse(src, {
          babel: true,
          sourceType: babelConfig.sourceType,
        })
      : parseSync(src, babelConfig);

    const transformResult = transformFromAstSync<MetroBabelFileMetadata>(
      // $FlowFixMe[incompatible-type] BabelFile vs BabelNodeFile
      sourceAst,
      src,
      babelConfig,
    );

    return {
      ast: nullthrows(transformResult.ast),
      metadata: transformResult.metadata,
    };
  } finally {
    // Restore the old process.env.BABEL_ENV
    if (OLD_BABEL_ENV == null) {
      // We have to treat this as a special case because writing undefined to
      // an environment variable coerces it to the string 'undefined'. To
      // unset it, we must delete it.
      // See https://github.com/facebook/metro/pull/446
      delete process.env.BABEL_ENV;
    } else {
      process.env.BABEL_ENV = OLD_BABEL_ENV;
    }
  }
}

/**
 * Generates a cache key component based on the user's Babel configuration files.
 * This uses Babel's loadPartialConfigSync to resolve which config files apply
 * to a given file, and includes their contents in the cache key so that changes
 * to babel.config.js or .babelrc will invalidate the transform cache.
 *
 * This is called once by the main thread (not on worker instances).
 */
function getCacheKey(options?: BabelTransformerCacheKeyOptions): string {
  if (options == null) {
    return '';
  }
  // Load the partial babel config to get the resolved config file paths
  const partialConfig = loadPartialConfigSync({
    cwd: options.projectRoot,
    root: options.projectRoot,
    babelrc: options.enableBabelRCLookup ?? true,
  });

  const files = partialConfig?.files;

  if (files == null || files.size === 0) {
    return '';
  }

  // Hash the contents of all config files
  return getFileCacheKey([...files].sort());
}

// Type check exports
/*::
({transform, getCacheKey}) as BabelTransformer;
*/

export {transform, getCacheKey};
