// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import * as path from 'path';
import * as rspack from '@rspack/core';
import { Build } from './build';
import { WPPlugin } from './webpack-plugins';
import { merge } from 'webpack-merge';
import * as fs from 'fs-extra';
import * as glob from 'glob';
import Ajv from 'ajv';

const baseConfig = require('./webpack.config.base');

// Deliberately the V1 (webpack-compatible) plugin rather than
// `rspack.container.ModuleFederationPlugin`, which uses the Module Federation
// 2.0 runtime. MF2 resolves a shared package that is consumed with
// `import: false` and `singleton: false` - which is how core packages absent
// from JupyterLab's `singletonPackages` are consumed, e.g.
// `@jupyterlab/docregistry` - by failing hard when no version in the share
// scope satisfies `requiredVersion`, since there is no bundled fallback to fall
// back to. V1 keeps webpack's behaviour of warning and using whatever version
// the host provides, which is what makes an extension built against one
// JupyterLab minor loadable in the next.
const { ModuleFederationPluginV1: ModuleFederationPlugin } = rspack.container;

type SharedConfig = {
  requiredVersion?: string;
  import?: false | string;
  singleton?: boolean;
  version?: string;
};
type SharedObject = Record<string, SharedConfig>;

export interface IOptions {
  packagePath?: string;
  corePackageFile?: string;
  staticUrl?: string;
  mode?: 'development' | 'production';
  devtool?: string;
}

function generateConfig({
  packagePath = '',
  corePackageFile = 'package.json',
  staticUrl = '',
  mode = 'production',
  devtool = mode === 'development' ? 'source-map' : undefined
}: IOptions = {}): rspack.Configuration[] {
  const data = require(path.join(packagePath, 'package.json'));

  const ajv = new Ajv({ useDefaults: true, strict: false });
  const validate = ajv.compile(require('./metadata_schema.json'));
  const valid = validate(data.jupyterlab ?? {});
  if (!valid) {
    console.error(validate.errors);
    process.exit(1);
  }

  const outputPath = path.join(packagePath, data.jupyterlab['outputDir']);
  const staticPath = path.join(outputPath, 'static');

  // Handle the extension entry point and the lib entry point, if different
  const index = require.resolve(packagePath);
  const exposes: { [id: string]: string } = {
    './index': index
  };

  const extension = data.jupyterlab.extension;
  if (extension === true) {
    exposes['./extension'] = index;
  } else if (typeof extension === 'string') {
    exposes['./extension'] = path.join(packagePath, extension);
  }

  const mimeExtension = data.jupyterlab.mimeExtension;
  if (mimeExtension === true) {
    exposes['./mimeExtension'] = index;
  } else if (typeof mimeExtension === 'string') {
    exposes['./mimeExtension'] = path.join(packagePath, mimeExtension);
  }

  if (typeof data.styleModule === 'string') {
    exposes['./style'] = path.join(packagePath, data.styleModule);
  } else if (typeof data.style === 'string') {
    exposes['./style'] = path.join(packagePath, data.style);
  }
  const coreData = JSON.parse(
    fs.readFileSync(corePackageFile, {
      encoding: 'utf8'
    })
  );

  let shared: SharedObject = {};

  // Start with core package versions.
  const coreDeps: Record<string, string> = {
    ...coreData.dependencies,
    ...(coreData.resolutions ?? {})
  };

  // Allow extensions to match a wider range than the core dependency
  // To ensure forward compatibility.
  Object.keys(coreDeps).forEach(element => {
    shared[element] = {
      requiredVersion: coreDeps[element].replace('~', '^'),
      import: false
    };
  });

  // Add package dependencies.
  Object.keys(data.dependencies).forEach(element => {
    // TODO: make sure that the core dependency semver range is a subset of our
    // data.dependencies version range for any packages in the core deps.
    if (!shared[element]) {
      shared[element] = {};
    }
  });

  // Set core packages as singletons that are not bundled.
  coreData.jupyterlab.singletonPackages.forEach((element: string) => {
    if (!shared[element]) {
      shared[element] = {};
    }
    shared[element].import = false;
    shared[element].singleton = true;
  });

  // Now we merge in the sharedPackages configuration provided by the extension.

  const sharedPackages = data.jupyterlab.sharedPackages ?? {};

  // Delete any modules that are explicitly not shared
  Object.keys(sharedPackages).forEach(pkg => {
    if (sharedPackages[pkg] === false) {
      delete shared[pkg];
      delete sharedPackages[pkg];
    }
  });

  // Transform the sharedPackages information into valid webpack config
  Object.keys(sharedPackages).forEach(pkg => {
    // Convert `bundled` to `import`
    if (sharedPackages[pkg].bundled === false) {
      sharedPackages[pkg].import = false;
    } else if (
      sharedPackages[pkg].bundled === true &&
      shared[pkg]?.import === false
    ) {
      // We can't delete a key in the merge, so we have to delete it in the source
      delete shared[pkg].import;
    }
    delete sharedPackages[pkg].bundled;
  });

  shared = merge(shared, sharedPackages);

  // add the root module itself to shared
  if (shared[data.name]) {
    console.error(
      `The root package itself '${data.name}' may not specified as a shared dependency.`
    );
  }
  shared[data.name] = {
    version: data.version,
    singleton: true,
    import: index
  };

  // Ensure a clean output directory - remove files but not the directory
  // in case it is a symlink
  fs.emptyDirSync(outputPath);

  const extras = Build.ensureAssets({
    packageNames: [],
    packagePaths: [packagePath],
    output: staticPath,
    schemaOutput: outputPath,
    themeOutput: outputPath
  });

  fs.copyFileSync(
    path.join(packagePath, 'package.json'),
    path.join(outputPath, 'package.json')
  );

  class CleanupPlugin {
    apply(compiler: rspack.Compiler): void {
      compiler.hooks.done.tap('Cleanup', (stats: rspack.Stats) => {
        const newlyCreatedAssets = stats.compilation.assets;

        // Clear out any remoteEntry files that are stale
        // https://stackoverflow.com/a/40370750
        // The directory is passed as `cwd` instead of being joined into the
        // pattern: glob reads a pattern as glob syntax, so on Windows the
        // separators of a joined path are taken as escape characters and match
        // nothing. The same applies on any platform to a directory whose name
        // contains `[`, `{` or an extglob prefix such as `@(`, which make the
        // pattern mean something other than the literal directory name.
        const files = glob.sync('remoteEntry.*.js', {
          cwd: staticPath,
          absolute: true
        });
        let newEntry = '';
        const unlinked: string[] = [];
        files.forEach(file => {
          const fileName = path.basename(file);
          if (!newlyCreatedAssets[fileName]) {
            fs.unlinkSync(path.resolve(file));
            unlinked.push(fileName);
          } else {
            newEntry = fileName;
          }
        });
        if (unlinked.length > 0) {
          console.log('Removed old assets: ', unlinked);
        }

        if (!newEntry) {
          // Without this guard `path.posix.join('static', newEntry)` below
          // would record `"load": "static"`, JupyterLab would request the
          // directory itself, and the extension would fail to load with no
          // signal at build time - the symptom reported in
          // https://github.com/jupyterlab/jupyter-builder/issues/163.
          stats.compilation.errors.push(
            new Error(
              `No remoteEntry.*.js was found in ${staticPath}, so the extension ` +
                'entry point cannot be recorded in jupyterlab._build.load.'
            )
          );
          return;
        }

        // Find the remoteEntry file and add it to the package.json metadata
        const data = fs.readJSONSync(path.join(outputPath, 'package.json'));
        const _build: {
          load: string;
          extension?: string;
          mimeExtension?: string;
          style?: string;
        } = {
          // Joined with the posix separator because JupyterLab interpolates
          // this straight into the URL it fetches the entry point from, and an
          // extension may be built on one platform and installed on another.
          load: path.posix.join('static', newEntry)
        };
        if (exposes['./extension'] !== undefined) {
          _build.extension = './extension';
        }
        if (exposes['./mimeExtension'] !== undefined) {
          _build.mimeExtension = './mimeExtension';
        }
        if (exposes['./style'] !== undefined) {
          _build.style = './style';
        }
        data.jupyterlab._build = _build;
        fs.writeJSONSync(path.join(outputPath, 'package.json'), data, {
          spaces: 2
        });
      });
    }
  }

  // Allow custom webpack config
  let webpackConfigPath = data.jupyterlab['webpackConfig'];
  let webpackConfig = {};

  // Use the custom webpack config only if the path to the config
  // is specified in package.json (opt-in)
  if (webpackConfigPath) {
    webpackConfigPath = path.join(packagePath, webpackConfigPath);
    if (fs.existsSync(webpackConfigPath)) {
      webpackConfig = require(webpackConfigPath);
    }
  }

  const plugins: NonNullable<rspack.Configuration['plugins']> = [
    new ModuleFederationPlugin({
      name: data.name,
      library: {
        type: 'var',
        name: ['_JUPYTERLAB', data.name]
      },
      filename: 'remoteEntry.[contenthash].js',
      exposes,
      shared
    }),
    new CleanupPlugin()
  ];

  if (mode === 'production') {
    plugins.push(
      new WPPlugin.JSONLicenseWebpackPlugin({
        excludedPackageTest: packageName => packageName === data.name
      })
    );
  }

  // Add version argument when in production so the Jupyter server
  // allows caching of files (i.e., does not set the CacheControl header to no-cache to prevent caching static files)
  let filename = '[name].[contenthash].js';
  if (mode === 'production') {
    filename += '?v=[contenthash]';
  }

  const rules: rspack.RuleSetRule[] = [
    { test: /\.html$/, type: 'asset/resource' }
  ];

  if (mode === 'development') {
    rules.push({
      test: /\.js$/,
      enforce: 'pre',
      extractSourceMap: true
    });
  }

  const config = [
    merge(
      baseConfig,
      {
        mode,
        devtool,
        entry: {},
        output: {
          filename,
          path: staticPath,
          publicPath: staticUrl || 'auto'
        },
        plugins
      },
      webpackConfig,
      {
        module: {
          rules
        }
      }
    )
  ].concat(extras);

  if (mode === 'development') {
    const logPath = path.join(outputPath, 'build_log.json');
    function regExpReplacer(key: string, value: unknown): unknown {
      if (value instanceof RegExp) {
        return value.toString();
      } else {
        return value;
      }
    }
    fs.writeFileSync(logPath, JSON.stringify(config, regExpReplacer, '  '));
  }
  return config;
}

export default generateConfig;
