import webpack from "webpack";

import type { Compiler } from "webpack";

const { SourceMapDevToolPlugin, javascript, sources } = webpack;

/**
 * @see https://regex101.com/r/O0SiGW/1
 */
const IMPORT_PATTERN = /var (\w+_WEBPACK_[A-Z]+_MODULE_\w+) = ([/*#\w]*)(__webpack_require__[^;,]+);/g;

/**
 * Inspired by https://github.com/atlassian-labs/inline-require-webpack-plugin/:
 *
 * > This plugin enables an advanced runtime performance optimisation where evaluation cost of a module dependencies
 * > is shifted from the module initialisation phase to where each dependency is consumed.
 * > This technique has been successfully leveraged by other bundlers (eg FB Metro) and proved to be quite effective on
 * > large applications, especially on 2-4 CPUs clients (with TTI improvements up to 400ms on P90).
 * > It is an alternative to feeding Webpack with CommonJS modules and introducing a Babel plugin like `@babel/plugin-transform-modules-commonjs`.
 * > The main advantage is that Webpack is not aware of this optimisation while processing the source code, so all ESM benefits (eg treeshaking) and other plugins optimisations are not affected.
 */
class InlineRequirePlugin {
    private plugin = "InlineRequirePlugin";

    private options: {
        /**
         * Allows to exclude files from inlining by module / filename.
         */
        exclude?: RegExp;
    };

    public constructor(options: InlineRequirePlugin["options"] = {}) {
        this.options = options;
    }

    public apply(compiler: Compiler) {
        const { plugin } = this;
        const { devtool, mode, plugins } = compiler.options;
        const sourceMap =
            typeof devtool === "string"
                ? devtool.endsWith("source-map")
                : (mode === "production" && devtool !== false) ||
                  !!plugins.find((d) => d instanceof SourceMapDevToolPlugin);

        compiler.hooks.compilation.tap(plugin, (compilation) => {
            javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).renderModulePackage.tap(
                plugin,
                (moduleSource, module) => {
                    // Get the filename from module
                    const filename =
                        (module as any).resource || (module as any).userRequest || (module as any).identifier();

                    if (this.options.exclude && this.options.exclude.test(filename)) {
                        return moduleSource;
                    }

                    const original =
                        sourceMap && moduleSource.sourceAndMap
                            ? moduleSource.sourceAndMap()
                            : {
                                  source: moduleSource.source() as string,
                                  map: sourceMap && typeof moduleSource.map === "function" ? moduleSource.map() : null,
                              };
                    const newSource = this.processSource(original.source.toString());
                    if (newSource === null) {
                        return moduleSource;
                    }
                    return original.map
                        ? new sources.SourceMapSource(
                              newSource,
                              module.id.toString(),
                              original.map,
                              original.source,
                              original.map,
                          )
                        : new sources.RawSource(newSource);
                },
            );
        });
    }

    protected processSource(source: string) {
        if (source.indexOf("_MODULE_") > -1) {
            let newSource = source;
            const requireVariables = this.collectRequires(source);

            if (requireVariables.size === 0) {
                return null;
            }

            for (const [variableName, requireExpression] of requireVariables.entries()) {
                // Remove top-level `require`
                const declarationlessOutput = newSource.replace(
                    new RegExp(`var ${variableName}[^\\w]([^;]+);`),
                    (m, p0) => `// (inlined) ${(p0.match(/"([^"]+)/) || [])[1]}`,
                );

                // Replace inline variable references with `require` expression
                const reflessOutput = declarationlessOutput.replace(
                    new RegExp(`([^\\w])${variableName}([^\\w])`, "g"),
                    `$1(${requireExpression})$2`,
                );

                if (reflessOutput !== declarationlessOutput) {
                    // Import variable is being used somewhere, confirm replacements
                    newSource = reflessOutput;
                }
            }

            // nothing has changed
            if (newSource === source) {
                return null;
            }
            return newSource;
        }
        return null;
    }

    protected collectRequires(source: string) {
        // Collect require variables
        const requireVariables = new Map<string, string>();
        const matches = source.matchAll(IMPORT_PATTERN);
        for (const match of matches) {
            const [, variableName] = match;
            let [, , , requireExpression] = match;

            // if referencing another require var, inline it
            requireExpression = requireExpression.replace(
                /\w+_WEBPACK_[A-Z]+_MODULE_\w+/,
                (s) => requireVariables.get(s) || s,
            );

            requireVariables.set(variableName, requireExpression);
        }
        return requireVariables;
    }
}

export { InlineRequirePlugin };
