import * as path from 'path';
import { join } from 'path';
import { Configuration, ProgressPlugin, WebpackPluginInstance } from 'webpack';
import { ExecutorContext } from 'nx/src/config/misc-interfaces';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { readTsConfig } from '@nx/js';
import { LicenseWebpackPlugin } from 'license-webpack-plugin';

import { NormalizedWebpackExecutorOptions } from '../executors/webpack/schema';
import { StatsJsonPlugin } from '../plugins/stats-json-plugin';
import { createCopyPlugin } from './create-copy-plugin';
import { GeneratePackageJsonPlugin } from '../plugins/generate-package-json-plugin';
import { getOutputHashFormat } from './hash-format';
import { NxWebpackPlugin } from './config';
import { existsSync } from 'fs';
import TerserPlugin = require('terser-webpack-plugin');
import nodeExternals = require('webpack-node-externals');
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
import browserslist = require('browserslist');

const VALID_BROWSERSLIST_FILES = ['.browserslistrc', 'browserslist'];

const ES5_BROWSERS = [
  'ie 10',
  'ie 11',
  'safari 11',
  'safari 11.1',
  'safari 12',
  'safari 12.1',
  'safari 13',
  'ios_saf 13.0',
  'ios_saf 13.3',
];

function getTerserEcmaVersion(projectRoot: string) {
  let pathToBrowserslistFile = '';
  for (const browserslistFile of VALID_BROWSERSLIST_FILES) {
    const fullPathToFile = join(projectRoot, browserslistFile);
    if (existsSync(fullPathToFile)) {
      pathToBrowserslistFile = fullPathToFile;
      break;
    }
  }

  if (!pathToBrowserslistFile) {
    return 2020;
  }

  const env = browserslist.loadConfig({ path: pathToBrowserslistFile });
  const browsers = browserslist(env);
  return browsers.some((b) => ES5_BROWSERS.includes(b)) ? 5 : 2020;
}

const IGNORED_WEBPACK_WARNINGS = [
  /The comment file/i,
  /could not find any license/i,
];

const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const mainFields = ['module', 'main'];

const processed = new Set();

export interface WithNxOptions {
  skipTypeChecking?: boolean;
}

/**
 * @param {WithNxOptions} pluginOptions
 * @returns {NxWebpackPlugin}
 */
export function withNx(pluginOptions?: WithNxOptions): NxWebpackPlugin {
  return function configure(
    config: Configuration,
    {
      options,
      context,
    }: {
      options: NormalizedWebpackExecutorOptions;
      context: ExecutorContext;
    }
  ): Configuration {
    if (processed.has(config)) return config;

    const plugins: WebpackPluginInstance[] = [];

    if (!pluginOptions?.skipTypeChecking) {
      plugins.push(
        new ForkTsCheckerWebpackPlugin({
          typescript: {
            configFile: options.tsConfig,
            memoryLimit: options.memoryLimit || 2018,
          },
        })
      );
    }
    const entry = {};

    if (options.main) {
      const mainEntry = options.outputFileName
        ? path.parse(options.outputFileName).name
        : 'main';
      entry[mainEntry] = [options.main];
    }

    if (options.additionalEntryPoints) {
      for (const { entryName, entryPath } of options.additionalEntryPoints) {
        entry[entryName] = entryPath;
      }
    }

    if (options.polyfills) {
      entry['polyfills'] = [
        ...(entry['polyfills'] || []),
        path.resolve(options.root, options.polyfills),
      ];
    }

    if (options.progress) {
      plugins.push(new ProgressPlugin({ profile: options.verbose }));
    }

    if (options.extractLicenses) {
      plugins.push(
        new LicenseWebpackPlugin({
          stats: {
            warnings: false,
            errors: false,
          },
          perChunkOutput: false,
          outputFilename: `3rdpartylicenses.txt`,
        }) as unknown as WebpackPluginInstance
      );
    }

    if (Array.isArray(options.assets) && options.assets.length > 0) {
      plugins.push(createCopyPlugin(options.assets));
    }

    if (options.generatePackageJson && context) {
      plugins.push(new GeneratePackageJsonPlugin(options, context));
    }

    if (options.statsJson) {
      plugins.push(new StatsJsonPlugin());
    }

    let externals = [];
    if (options.target === 'node' && options.externalDependencies === 'all') {
      const modulesDir = `${options.root}/node_modules`;
      externals.push(nodeExternals({ modulesDir }));
    } else if (Array.isArray(options.externalDependencies)) {
      externals.push(function (ctx, callback: Function) {
        if (options.externalDependencies.includes(ctx.request)) {
          // not bundled
          return callback(null, `commonjs ${ctx.request}`);
        }
        // bundled
        callback();
      });
    }

    const hashFormat = getOutputHashFormat(options.outputHashing as string);
    const filename = options.outputHashing
      ? `[name]${hashFormat.script}.js`
      : '[name].js';
    const chunkFilename = options.outputHashing
      ? `[name]${hashFormat.chunk}.js`
      : '[name].js';

    const updated = {
      ...config,
      context: context
        ? path.join(context.root, options.projectRoot)
        : undefined,
      target: options.target,
      node: false as const,
      mode:
        // When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value.
        options.target === ('node' as const)
          ? 'none'
          : // Otherwise, make sure it matches `process.env.NODE_ENV`.
          // When mode is development or production, webpack will automatically
          // configure DefinePlugin to replace `process.env.NODE_ENV` with the
          // build-time value. Thus, we need to make sure it's the same value to
          // avoid conflicts.
          //
          // When the NODE_ENV is something else (e.g. test), then set it to none
          // to prevent extra behavior from webpack.
          process.env.NODE_ENV === 'development' ||
            process.env.NODE_ENV === 'production'
          ? (process.env.NODE_ENV as 'development' | 'production')
          : ('none' as const),
      devtool:
        options.sourceMap === 'hidden'
          ? 'hidden-source-map'
          : options.sourceMap
          ? 'source-map'
          : (false as const),
      entry,
      output: {
        ...config.output,
        libraryTarget: options.target === 'node' ? 'commonjs' : undefined,
        path: options.outputPath,
        filename,
        chunkFilename,
        hashFunction: 'xxhash64',
        // Disabled for performance
        pathinfo: false,
        // Use CJS for Node since it has the widest support.
        scriptType: options.target === 'node' ? undefined : ('module' as const),
      },
      watch: options.watch,
      watchOptions: {
        poll: options.poll,
      },
      profile: options.statsJson,
      resolve: {
        ...config.resolve,
        extensions: [...extensions, ...(config?.resolve?.extensions ?? [])],
        alias: options.fileReplacements.reduce(
          (aliases, replacement) => ({
            ...aliases,
            [replacement.replace]: replacement.with,
          }),
          {}
        ),
        plugins: [
          ...(config.resolve?.plugins ?? []),
          new TsconfigPathsPlugin({
            configFile: options.tsConfig,
            extensions: [...extensions, ...(config?.resolve?.extensions ?? [])],
            mainFields,
          }),
        ],
        mainFields,
      },
      externals,
      optimization: {
        ...config.optimization,
        sideEffects: true,
        minimize:
          typeof options.optimization === 'object'
            ? !!options.optimization.scripts
            : !!options.optimization,
        minimizer: [
          options.compiler !== 'swc'
            ? new TerserPlugin({
                parallel: true,
                terserOptions: {
                  keep_classnames: true,
                  ecma: getTerserEcmaVersion(
                    join(options.root, options.projectRoot)
                  ),
                  safari10: true,
                  format: {
                    ascii_only: true,
                    comments: false,
                    webkit: true,
                  },
                },
                extractComments: false,
              })
            : new TerserPlugin({
                minify: TerserPlugin.swcMinify,
                // `terserOptions` options will be passed to `swc`
                terserOptions: {
                  mangle: false,
                },
              }),
        ],
        runtimeChunk: false,
        concatenateModules: true,
      },
      performance: {
        ...config.performance,
        hints: false as const,
      },
      experiments: { ...config.experiments, cacheUnaffected: true },
      ignoreWarnings: [
        (x) =>
          IGNORED_WEBPACK_WARNINGS.some((r) =>
            typeof x === 'string' ? r.test(x) : r.test(x.message)
          ),
      ],
      module: {
        ...config.module,
        // Enabled for performance
        unsafeCache: true,
        rules: [
          ...(config?.module?.rules ?? []),
          options.sourceMap && {
            test: /\.js$/,
            enforce: 'pre' as const,
            loader: require.resolve('source-map-loader'),
          },
          {
            // There's an issue resolving paths without fully specified extensions
            // See: https://github.com/graphql/graphql-js/issues/2721
            // TODO(jack): Add a flag to turn this option on like Next.js does via experimental flag.
            // See: https://github.com/vercel/next.js/pull/29880
            test: /\.m?jsx?$/,
            resolve: {
              fullySpecified: false,
            },
          },
          // There's an issue when using buildable libs and .js files (instead of .ts files),
          // where the wrong type is used (commonjs vs esm) resulting in export-imports throwing errors.
          // See: https://github.com/nrwl/nx/issues/10990
          {
            test: /\.js$/,
            type: 'javascript/auto',
          },
          createLoaderFromCompiler(options),
        ].filter((r) => !!r),
      },
      plugins: (config.plugins ?? []).concat(plugins),
      stats: {
        hash: true,
        timings: false,
        cached: false,
        cachedAssets: false,
        modules: false,
        warnings: true,
        errors: true,
        colors: !options.verbose && !options.statsJson,
        chunks: !options.verbose,
        assets: !!options.verbose,
        chunkOrigins: !!options.verbose,
        chunkModules: !!options.verbose,
        children: !!options.verbose,
        reasons: !!options.verbose,
        version: !!options.verbose,
        errorDetails: !!options.verbose,
        moduleTrace: !!options.verbose,
        usedExports: !!options.verbose,
      },
    };

    processed.add(updated);
    return updated;
  };
}

export function createLoaderFromCompiler(
  options: NormalizedWebpackExecutorOptions
) {
  switch (options.compiler) {
    case 'swc':
      return {
        test: /\.([jt])sx?$/,
        loader: require.resolve('swc-loader'),
        exclude: /node_modules/,
        options: {
          jsc: {
            parser: {
              syntax: 'typescript',
              decorators: true,
              tsx: true,
            },
            transform: {
              react: {
                runtime: 'automatic',
              },
            },
            loose: true,
          },
        },
      };
    case 'tsc':
      const { loadTsTransformers } = require('@nx/js');
      const { compilerPluginHooks, hasPlugin } = loadTsTransformers(
        options.transformers
      );
      return {
        test: /\.([jt])sx?$/,
        loader: require.resolve(`ts-loader`),
        exclude: /node_modules/,
        options: {
          configFile: options.tsConfig,
          transpileOnly: !hasPlugin,
          // https://github.com/TypeStrong/ts-loader/pull/685
          experimentalWatchApi: true,
          getCustomTransformers: (program) => ({
            before: compilerPluginHooks.beforeHooks.map((hook) =>
              hook(program)
            ),
            after: compilerPluginHooks.afterHooks.map((hook) => hook(program)),
            afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
              (hook) => hook(program)
            ),
          }),
        },
      };
    case 'babel':
      const tsConfig = readTsConfig(options.tsConfig);

      const babelConfig = {
        test: /\.([jt])sx?$/,
        loader: path.join(__dirname, './web-babel-loader'),
        exclude: /node_modules/,
        options: {
          cwd: path.join(options.root, options.sourceRoot),
          emitDecoratorMetadata: tsConfig.options.emitDecoratorMetadata,
          isModern: true,
          isTest: process.env.NX_CYPRESS_COMPONENT_TEST === 'true',
          envName: process.env.BABEL_ENV ?? process.env.NODE_ENV,
          cacheDirectory: true,
          cacheCompression: false,
        },
      };

      if (options.babelUpwardRootMode) {
        babelConfig.options['rootMode'] = 'upward';
        babelConfig.options['babelrc'] = true;
      } else {
        babelConfig.options['configFile'] = options.babelConfig
          ? path.join(options.root, options.babelConfig)
          : path.join(options.root, options.projectRoot, '.babelrc');
      }

      return babelConfig;
    default:
      return null;
  }
}
